@syall/lsm-recovery-version-api-js
v0.3.0
Published
TypeScript client for the LSM Text Only Holy Bible Recovery Version API.
Maintainers
Readme
@syall/lsm-recovery-version-api-js
TypeScript client for the LSM Text Only Holy Bible Recovery Version API.
Documented vs. observed API behavior: several things in this README (and in this package's own doc comments) reflect live, verified behavior of
api.lsm.orgthat differs from — or simply isn't mentioned in — LSM's own published docs (https://api.lsm.org/recver/txo-docs.htm). SeeDIFFERENCES.mdfor the full catalogue with examples. This package follows the live behavior wherever the two disagree.
Looking for the verse-reference string builder? It's a separate, independent package:
@syall/verse-reference-builder(source). This package has no dependency on it — see Building reference strings below.
Install
npm install @syall/lsm-recovery-version-api-jsShips both ESM (import) and CommonJS (require) builds, plus type
declarations for both — works with Node, Bun, Deno, and any bundler
(webpack, esbuild, Vite, Rollup, etc.) targeting either module system.
Node and Bun/Deno compatibility are both verified in CI (see
Runtime compatibility below), not just claimed.
Requires Node 22+ — Node 20 reached end-of-life on 2026-04-30 and is no
longer covered by CI. Every supported line has a global fetch; for
older runtimes or non-Node environments without one, pass a fetch
polyfill via LsmClientConfig.fetch (e.g. via undici or node-fetch).
// ESM / TypeScript
import { LsmRecoveryVersionClient } from "@syall/lsm-recovery-version-api-js";// CommonJS
const { LsmRecoveryVersionClient } = require("@syall/lsm-recovery-version-api-js");Browser <script> tag (no bundler)
A dependency-free, minified UMD-style build is also published under
dist/umd, exposing a LsmRecoveryVersionApi global — usable directly
from a CDN with no build step:
<script src="https://unpkg.com/@syall/lsm-recovery-version-api-js/dist/umd/lsm-recovery-version-api-js.min.js"></script>
<script>
const { LsmRecoveryVersionClient } = LsmRecoveryVersionApi;
// No appId/token needed to try it out — see "Authentication" below.
const client = new LsmRecoveryVersionClient();
client.getVerses({ string: "John 1:14" }).then((result) => console.log(result));
</script>(package.json's unpkg/jsdelivr fields point at this same file, so
https://cdn.jsdelivr.net/npm/@syall/lsm-recovery-version-api-js also
resolves to it.)
Authentication
LSM's own docs describe exactly one auth scheme: HTTP Basic Auth with
an appId/token pair you register at api.lsm.org. Live testing found
a second, undocumented way the API actually accepts requests — a public
file= query-parameter site key, which is what the production
text.recoveryversion.bible widget itself sends (see
DIFFERENCES.md). This package supports both,
chosen automatically from what you configure:
import { LsmRecoveryVersionClient } from "@syall/lsm-recovery-version-api-js";
// 1. No credentials (the default) — uses the built-in public file=
// token. Works immediately, no registration needed. This key isn't
// issued to this package specifically, so LSM could revoke or
// rate-limit it without notice — fine for trying things out or
// casual/low-volume use, not guaranteed for production traffic.
const quickStart = new LsmRecoveryVersionClient();
// 2. Your own registered credentials — sent as HTTP Basic Auth, LSM's
// documented scheme. Both fields are required together.
const production = new LsmRecoveryVersionClient({
appId: process.env.LSM_APP_ID,
token: process.env.LSM_TOKEN,
});
// 3. A different file= token, if LSM issues you one directly, or the
// built-in default is ever revoked.
const customFileToken = new LsmRecoveryVersionClient({ fileToken: "..." });Supplying only one of appId/token is always a configuration mistake
and throws IncompleteCredentialsError synchronously — there's no
partial-credentials mode to fall into.
Usage
import {
LsmRecoveryVersionClient,
IncompleteCredentialsError,
InvalidInputError,
UnauthorizedError,
NetworkError,
} from "@syall/lsm-recovery-version-api-js";
const client = new LsmRecoveryVersionClient({
appId: process.env.LSM_APP_ID,
token: process.env.LSM_TOKEN,
});
try {
const result = await client.getVerses({
string: "Prov. 29:18; Acts 26:19; Eph. 4:4-6; Rev. 21:2, 9-10",
lang: "eng",
});
for (const verse of result.verses) {
console.log(`${verse.ref} — ${verse.text}`);
}
// Required by LSM's Terms of Use: display this alongside the verses.
console.log(result.copyright);
} catch (err) {
if (err instanceof InvalidInputError) {
console.error("Bad reference string:", err.body);
} else if (err instanceof UnauthorizedError) {
console.error("Check your app id / token.");
} else if (err instanceof NetworkError) {
console.error("Couldn't reach the LSM API:", err.message);
} else {
throw err;
}
}Constructing the client itself can throw IncompleteCredentialsError —
that's a configuration mistake (exactly one of appId/token set, see
Authentication above), not an API-call failure, so
it's usually left uncaught rather than handled alongside the errors
above:
new LsmRecoveryVersionClient({ appId: "only-this-one" });
// throws IncompleteCredentialsErrorWord search mode
GetVersesParams.string doesn't have to be a citation. If the API
doesn't recognize it as a reference, it falls back to a full-text
search over the whole Bible, and result.searchType tells you which
happened — this is confirmed live but completely undocumented by
LSM (see DIFFERENCES.md):
const result = await client.getVerses({ string: "eternal life" });
result.searchType; // "words" — no citation was recognized
result.verses; // every verse containing "eternal" and "life"
const ref = await client.getVerses({ string: "John 3:16" });
ref.searchType; // "references"A few things confirmed about word-search mode:
- A multi-word query matches verses containing all the words, not
necessarily as an exact adjacent phrase — e.g.
"eternal life"also matches Daniel 12:2's "life eternal" (reversed) and Matthew 18:8's "eternal fire" (same words, unrelated phrase), not just the exact phrase "eternal life". - Exact-phrase matches are returned first; looser word-only matches are appended at the end — so result order is relevance-ranked, not Bible order, unlike reference-lookup mode.
- Very common connecting words (e.g. "the") appear to be effectively ignored rather than narrowing the match set.
- It also fires — silently, with an empty result rather than an error —
for input the docs claim should be rejected outright (disallowed
characters). See
DIFFERENCES.mdfor the exact example.
Edge cases in string
Two more confirmed-live behaviors worth knowing about before you build
string dynamically (e.g. from user input) — neither is validated for
you by this package, since it never inspects string before sending it:
Empty string (
string: "") doesn't return averses-shaped response at all — LSM's API returns its own HTML documentation landing page instead, mislabeledcontent-type: application/json. This package can't parse that as JSON, so it surfaces as a plainLsmApiError("The API response could not be parsed as JSON") with the HTML dumped intoerr.body, rather than any more specific error. If you buildstringfrom user input, guard against an empty (or whitespace-only) value yourself before callinggetVerses()."No such reference" phantom verses: a syntactically well-formed but non-existent reference (an unrecognized book, or a real book with an out-of-range chapter/verse) doesn't error either — it's a normal
200with one fakeVersestanding in for the real result:{ "ref": " 99:99", "urlpfx": "", "text": "No such reference" }.getVerses()returns it as an ordinary entry inresult.verses, since the request succeeded from the API's own point of view. Detect it by checkingverse.text === NO_SUCH_REFERENCE_TEXT(exported by this package) or an emptyverse.urlpfx:import { NO_SUCH_REFERENCE_TEXT } from "@syall/lsm-recovery-version-api-js"; const result = await client.getVerses({ string: "Zzz 99:99" }); const real = result.verses.filter((v) => v.text !== NO_SUCH_REFERENCE_TEXT);See
DIFFERENCES.mdfor the full write-up of both cases.
Language support
LSM's docs only document lang: "eng" (the default) and lang: "spa".
Live testing found the API also accepts three more values — confirmed
by getting back correctly translated reference labels and verse text,
not just a 200:
await client.getVerses({ string: "John 3:16", lang: "por" }); // Portuguese
await client.getVerses({ string: "John 3:16", lang: "zho" }); // Chinese
await client.getVerses({ string: "John 3:16", lang: "tag" }); // TagalogThese aren't guessed ISO codes — they're the exact internal language
keys LSM's own reader site uses (see DIFFERENCES.md). Other guesses
for the same three languages ("chi", "zh", "pt", "tl") — and any
other value — get a 500 from the API, same as an invalid Lang
always has. This package doesn't special-case that 500 — it's not
confirmed to mean only "unrecognized lang," so it surfaces as a plain
LsmApiError like any other non-200 status, rather than a dedicated
error class that could misattribute some other server-side 500.
lang doesn't just format the response — it also affects how string
itself gets parsed, and this is where it gets risky. Localized book
abbreviations generally work as string input, but only paired with
the matching lang:
await client.getVerses({ string: "Jn. 3:16", lang: "spa" }); // John 3:16, in Spanish
await client.getVerses({ string: "太 1:1", lang: "zho" }); // Matthew 1:1, in ChineseThe same abbreviation under a mismatched (or default "eng") lang
doesn't reliably fail — it can silently resolve to a completely
different, wrong book, with no error and no indication anything went
wrong:
// "Jo" is John's Portuguese abbreviation, but nothing here mentions
// Portuguese — this silently returns a real verse from Joshua 3:16.
await client.getVerses({ string: "Jo 3:16", lang: "eng" });One specific Chinese abbreviation is broken even with the matching
lang: John's single-character abbreviation "約" doesn't resolve to
John under lang: "zho" at all — it prefix-matches Joshua's full
Chinese name instead once a chapter number is appended. The book's
actual Chinese name ("約翰" / "約翰福音") resolves correctly. This
was only spot-checked across a sample of abbreviations, not verified
exhaustively for all 66 books × 5 languages — see DIFFERENCES.md for
the full findings table. If you accept a book abbreviation from user
input, always pass the lang it actually belongs to, and treat any
localized abbreviation as input with some caution.
Building reference strings
GetVersesParams.string is a plain string — this package doesn't know
or care how you produce it, and has no dependency on any builder.
Hand-write it per LSM's documented grammar
(api.lsm.org/recver/txo-docs.htm),
or use the separate, independent @syall/verse-reference-builder
package for a type-safe (and, for runtime-known data, dynamic/runtime-checked)
builder, and call .build() yourself:
import { LsmRecoveryVersionClient } from "@syall/lsm-recovery-version-api-js";
import { VerseReferenceBuilder } from "@syall/verse-reference-builder";
const client = new LsmRecoveryVersionClient({ appId: "...", token: "..." });
const result = await client.getVerses({
string: new VerseReferenceBuilder()
.verse("John", 1, 14)
.verse("Ephesians", 4, { from: 4, to: 6 })
.verse("Jude", 9) // single-chapter book — no chapter argument
.build(),
// "John 1:14; Ephesians 4:4-6; Jude 9"
});See that package's own README for the full builder API. Note this
package does not support LSM's documented OSIS input mode (In=osis)
— only the default reference-string grammar.
Notes
- Attribution: LSM's Terms of Use require displaying the
copyrightstring from every response alongside any verses shown to end users. - No offline storage: don't persist verse text returned by this API.
- 50-verse limit: requests beyond 50 verses return a partial result
with a truncation notice in
message. - Errors can arrive as HTTP 200: LSM's docs don't specify HTTP status
codes for any error condition, and the live API has been observed
reporting an unauthorized request as HTTP 200 with an empty
versesarray and amessagestarting with "Error: ..." rather than a 401 — confirmed identical whether credentials are omitted entirely or a bogusappId/tokenpair is sent.getVerses()checks for this in addition to the HTTP status, soUnauthorizedErroris still thrown correctly — you don't need to inspectmessageyourself. SeeDIFFERENCES.mdfor what this package could not confirm (a genuine non-authorization input error).
Development
npm install
npm run typecheck
npm test
npm run test:browser # loads the built UMD bundle in a real browser (see below)
npm run test:runtime:bun # optional — requires Bun installed (see "Runtime compatibility")
npm run test:runtime:deno # optional — requires Deno installed
npm run build # emits dist/esm, dist/cjs, and dist/umdRun these from this package directory (packages/api-client), or from
the repo root via npm run <script> --workspace=packages/api-client.
See the repo root README for the monorepo layout and shared tooling.
npm run build compiles src/ twice — once as ESM into dist/esm,
once as CommonJS into dist/cjs — and stamps a minimal package.json
({"type": "module"} / {"type": "commonjs"}) into each so Node
resolves every file's module format correctly regardless of the root
package's own "type" field. It also bundles a browser <script>-tag
build into dist/umd via esbuild (see ../../scripts/build-umd.mjs,
shared across every package in this monorepo).
npm run build:esm / build:cjs / build:umd run any one part
individually.
prepublishOnly re-runs the full test suite and a clean build before
npm publish, and prepare builds automatically when this package is
installed directly from its git repository (so a git-URL install works
without a separate manual build step).
Browser smoke test
npm test runs src/'s unit tests under Node, so it never actually
executes the dist/umd bundle the way a <script src="..."> consumer
would. npm run test:browser closes that gap: it builds the UMD
bundle, loads it into a real headless browser via
playwright-core,
stubs window.fetch (no network access needed), and confirms
window.LsmRecoveryVersionApi is exposed and getVerses() works end
to end — for both the default (no-credentials, file-token fallback)
and Basic Auth paths (see test/browser-smoke.mjs and the shared
../../scripts/test-browser.mjs harness).
By default it drives whatever Chrome or Edge is already installed on
your machine — playwright-core doesn't download its own browser — so
it works locally with no extra setup as long as one of those is
installed. If neither is found, either install one, or run
npx playwright install chromium once to fetch a managed Chromium and
re-run the command. To point at a specific browser binary instead, set
PLAYWRIGHT_CHROMIUM_EXECUTABLE=/path/to/browser.
Set PLAYWRIGHT_ENGINE=firefox or PLAYWRIGHT_ENGINE=webkit to run
the same smoke test against Firefox or WebKit instead of the default
Chromium — CI does this for all three (see below) — after first
fetching a managed copy with, e.g., npx playwright install firefox.
CI
.github/workflows/ci.yml (at the repo root) defines four independent
jobs, each running against both packages in this monorepo:
test— runstest(which typechecks first via thepretestscript) andbuildfor both packages, across a matrix of Node 22/24/26 on Ubuntu, Windows, and macOS (9 combinations).browser— runstest:browserfor both packages, matrixed by both OS and browser engine (chromium/firefox/webkit× 3 OSes = 9 combinations), with no Node-version axis at all (one Node version is enough to run the driving script).chromiumdrives the system's preinstalled Chrome/Edge (every GitHub-hosted runner has one);firefox/webkitaren't preinstalled, so the job fetches a managed copy first vianpx playwright@<version> install, where<version>is read directly from the rootplaywright-coredevDependency (not hardcoded) so it can never drift out of sync.runtime-bun/runtime-deno— build the ESM output for both packages, then run each package'stest/runtime-smoke.mjs(via the shared../../scripts/smoke-test-esm.mjsharness) under Bun and Deno respectively, on a single OS each.
CI does not publish anything; publishing runs through the separate,
manually-triggered .github/workflows/publish.yml workflow instead
(see Releasing below).
Runtime compatibility
Beyond Node (tested across three versions × three OSes in the test
job above), this package claims to work with Bun and Deno too — both
support the plain ESM output directly. test/runtime-smoke.mjs (run
via the shared ../../scripts/smoke-test-esm.mjs harness) verifies
that claim: it imports the built dist/esm output (as a real
consumer would), exercises getVerses() against a stubbed fetch for
both the Basic Auth and file-token-fallback paths, and checks the
IncompleteCredentialsError constructor validation — written with only
standard Web/JS APIs so the identical file runs unmodified under Node,
Bun, or Deno. Run it locally with (each script builds dist/esm itself
first, so no separate build step is needed):
npm run test:runtime:bun # requires Bun installed
npm run test:runtime:deno # requires Deno installedReleasing (Changesets)
Version bumps and CHANGELOG.md are managed with
Changesets, independently
per package in this monorepo. For any PR that changes published
behavior:
npx changeset # records the change + bump type (patch/minor/major)Commit the generated .changeset/*.md file with your PR. See the repo
root's .changeset/README.md for more. npm run version-packages
(changeset version, run from the repo root) is still a manual step,
but publishing itself is automated: dispatching the "Publish" GitHub
Actions workflow (.github/workflows/publish.yml) builds, tests, and
publishes via npm Trusted Publishing, pushes the release tag, and opens
a GitHub Release — gated behind a required-reviewer approval on the
npm-publish environment.
License
This package's code is MIT licensed — see LICENSE. The verse text and
other content returned by the LSM API it talks to is not covered by
that license and remains subject to LSM's own Terms of Use — see
NOTICE.md and the Attribution note above.
