@vocab-bloom-hub/client
Typed client for the public read-only API of a Vocab Bloom Hub instance — an English dictionary with IPA, CEFR levels, sense-level definitions, examples, translations (Russian, Spanish, French, German, Portuguese, Chinese, Arabic) and inflected forms, served under /api/v1.
Documentation, the API reference and a playground: vocab-bloom-hub.com.
- One method per endpoint, typed from the server's OpenAPI document — the types cannot drift from the API.
- Node.js ≥ 20 and browsers;
fetchonly, no dependencies; ESM and CommonJS. - Errors are thrown as typed exceptions;
AbortSignalon every call; optional ETag cache for repeated reads; opt-in retry on429/5xxhonouringRetry-After; a versionedUser-Agent.
Install
npm install @vocab-bloom-hub/client
Stable releases publish under latest. A prerelease publishes under its channel dist-tag
(alpha, beta) and never takes latest, so it has to be named:
npm install @vocab-bloom-hub/client@beta.
Quick start
import { VocabBloomClient, NotFoundError } from '@vocab-bloom-hub/client';
const client = new VocabBloomClient({ baseUrl: 'https://dict.example.com' });
// search: relevance tiers, typo tolerance
const { data, meta } = await client.search({ search: 'definately' });
console.log(meta.fuzzy, data[0].word); // true "definitely"
// a headword with every part of speech, forms, meanings and translations
try {
const run = await client.word('run');
console.log(run.data[0].meanings[0].definition);
} catch (error) {
if (error instanceof NotFoundError) console.log('no such word');
else throw error;
}
// walk the whole dictionary, page after page
for await (const word of client.iterateWords({ word_level: ['A1', 'A2'], with_meanings: true })) {
console.log(word.word, word.meanings.length);
}
Runnable cursor pagination example
examples/cursor-pagination.mjs
walks the verbs of an instance with client.words(), two entries per page. It prints the
dataset's metadata and terms first, then each page's entries with their origins, contributions
and licenses, and labels entries changed on the instance (see License).
With Node.js ≥ 20, save the linked file as cursor-pagination.mjs in an empty directory, then run:
npm install @vocab-bloom-hub/client
node cursor-pagination.mjs https://dict.example.com
Replace the URL with your instance's base URL; /api/v1 is appended by the client.
The example imports the package's public ESM export and needs no other dependency.
From a repository checkout, use the pinned Yarn and build the SDK first (Node.js ≥ 22.13):
yarn install --immutable
yarn workspace @vocab-bloom-hub/client build
node packages/npm-sdk/examples/cursor-pagination.mjs http://localhost:3010
For an isolated local walkthrough, run yarn workspace server fixture:public-api in another
terminal (Node.js ≥ 24.9). It uses an in-memory SQLite database and prints LISTENING <url>.
Use that URL in the command above: its three verbs produce two pages and Done: 3 entries across 2 page(s). Stop the fixture with Ctrl+C when finished.
The first request omits cursor. Every subsequent request keeps limit and part_of_speech
unchanged and passes meta.next_cursor back exactly as received; the cursor is opaque.
An absent cursor ends the walk, even on a full final page. An empty first page prints
No entries match the filter. and completes without an extra request. SDK failures go to
stderr and set a non-zero exit code; the example does not retry automatically.
When you only need entries, replace the manual loop with the existing convenience API:
for await (const word of client.iterateWords({ limit: 2, part_of_speech: ['verb'] })) {
console.log(`${word.word}${word.modified ? ' (changed on this instance)' : ''}`);
console.log(JSON.stringify(word, null, 2));
}
iterateWords() fetches pages lazily and follows their cursors for you. Keep the metadata
display and error handling around it. Running both loops reads the same entries twice.
API
| Method | Endpoint | Answer |
|---|---|---|
search(request) | GET /search | SearchResponse |
searchDetailed(request) | GET /search/detailed | DetailedSearchResponse |
word(headword) | GET /words/{word} | HeadwordResponse |
wordsBatch(words) | POST /words/batch | WordsBatchResponse — up to 50 headwords, one rate-limit unit; misses under meta.not_found |
wordById(id) | GET /words/id/{id} | WordResponse |
meanings(headword) | GET /words/{word}/meanings | MeaningsResponse |
translations(headword, query?) | GET /words/{word}/translations | TranslationsResponse |
forms(headword) | GET /words/{word}/forms | FormsResponse |
synonyms(headword) | GET /words/{word}/synonyms | LinksResponse — the linked headwords per meaning |
antonyms(headword) | GET /words/{word}/antonyms | LinksResponse |
history(headword) | GET /words/{word}/history | HistoryResponse — what was changed on the instance, with the values before and after |
wordDatasets(headword) | GET /words/{word}/datasets | WordDatasetsResponse — the headword in every dataset of the instance, a group per dataset with its terms |
datasetHistory(headword, dataset) | GET /words/{word}/datasets/{dataset}/history | HistoryResponse — the edits of the headword in one dataset |
words(query?) | GET /words | WordsResponse (one page) |
iterateWords(query?) | GET /words, following the cursor | AsyncGenerator<Word> |
iterateSearchDetailed(request) | GET /search/detailed, page after page | AsyncGenerator<Word> — stops at the server's page cap (DETAILED_SEARCH_MAX_PAGE, 20) |
random(filters?) | GET /random | WordResponse |
meta() | GET /meta | MetaResponse |
openapi() | GET /openapi.json | the OpenAPI 3 document |
suggest(request) | POST /suggestions | SuggestionCreatedResponse — files a reader report (or an edit proposal) into the instance's moderation queue |
For a report from a dataset tab, pass its dataset name: client.suggest({ headword: "run", dataset: "reader_words", message: "Please check this definition." }).
Omitting it uses the active dataset. Unknown, closed or deleted targets raise NotFoundError;
the client-wide rate limit is shared across targets.
Every method resolves to the { data, meta } envelope the API answers with and takes an optional last argument { signal, headers, timeoutMs }. The request and response types (Word, Meaning, SearchRequest, ListWordsQuery, …) are exported — the request types of the GET reads are their query strings, so a SearchRequest is { search, type?, limit? } — and so are the raw generated paths / components / operations for anything not aliased. The contract itself — tiers, filters, cursor pagination, caching — is documented in the server's docs/api.md.
Options
new VocabBloomClient({
baseUrl: 'https://dict.example.com', // origin of the instance; /api/v1 is appended
headers: { 'X-App': 'my-app' }, // sent with every request
cache: true, // ETag revalidation (see below); or your own ResponseCache
fetch: myFetch, // a custom fetch (instrumentation, polyfills, tests)
timeoutMs: 10_000, // fail a hung request with NetworkError; null disables (the default is 10 s)
retry: { attempts: 3, backoffMs: 500, maxDelayMs: 60_000 }, // opt-in: retry the GET reads on 429 / 5xx (see below)
});
timeoutMs can also be passed per call and combines with your signal — whichever fires first aborts the request.
Every request carries User-Agent: vocab-bloom-hub-npm/<version> (USER_AGENT, built from SDK_VERSION) so an operator can tell SDK traffic apart in the log; pass your own User-Agent in headers to replace it. Browsers own the header and ignore the value.
Retry
Off by default — the client documents exact request counts against the rate limit, so the loop is opt-in. With retry: {} (or explicit attempts / backoffMs) a GET answered 429 or 5xx is sent again: after Retry-After when the server sent it, otherwise after backoffMs, then twice that, and so on, up to attempts tries in total (the first one included; 3 and 500 ms by default); no single wait exceeds maxDelayMs (60 s by default), whatever Retry-After says. POST requests (the batch lookup, a suggestion), 4xx answers and network errors are never retried; an abort of your signal while waiting ends the wait with NetworkError.
Errors
Failed requests throw:
| Class | When | Fields |
|---|---|---|
NotFoundError | 404 | status, code (word_doesnt_found), body |
RateLimitError | 429 — the public rate limit | retryAfter (seconds, from Retry-After) |
NetworkError | no answer: DNS, connection, TLS, abort, timeout | status: 0, code: 'network_error', cause |
VocabBloomError | everything else | status, code, body |
Without the retry option the client never retries on its own: a RateLimitError carries retryAfter (seconds) and backoff is the caller's decision.
code is the machine-readable error of the API (invalid_cursor, too_many_requests, …), or http_error when the answer was not JSON (a proxy page, for instance).
ETag cache
With cache: true every GET answer is kept in memory per URL together with its ETag; the next read of the same URL sends If-None-Match and, on 304 Not Modified, returns the kept body — the round trip stays, the payload does not. MemoryCache holds 500 entries (least recently used out); pass an object with get(url) / set(url, entry) for a store of your own. Off by default.
Development
yarn workspace @vocab-bloom-hub/client generate # types from apps/server/openapi/public-v1.json
yarn workspace @vocab-bloom-hub/client generate:check # fail when the generated types are stale (CI)
yarn workspace @vocab-bloom-hub/client build # dist/ (ESM, CJS, d.ts)
yarn workspace @vocab-bloom-hub/client test # unit tests + the client against the real server on SQLite
yarn workspace @vocab-bloom-hub/client pack:check # publint + arethetypeswrong on the packed tarball (CI, release)
The package ships ESM and CommonJS with a declaration file for each (dist/index.d.ts, dist/index.d.cts); the exports map hands every consumer the pair its resolution asks for.
src/generated/openapi.ts is produced by openapi-typescript from the committed public spec and committed itself: a contract change on the server shows up as a diff here, and test/contract.spec.ts fails until every native /api/v1 operation of the spec has a client method. Compatibility adapters have generated types but no wrapper methods: they are intended for third-party clients replacing their base URL.
License
MIT for the client. The dictionary data comes under the license of the dataset the instance
serves — CC BY 4.0 for the project's own, the license of the source for another, the one its owner
chose for a dataset of the instance's own: read license, attribution and license_text from
meta() and show them with the data (title names the dataset). An entry the owner of
the instance changed carries modified: true, and client.history(headword) says what was changed: say next
to such an entry that it was changed. wordDatasets(headword) answers from every dataset of the
instance: each group carries the terms of its own dataset, and entries taken from several groups
are bound by each of them.
A word may have several origins, contributions and licenses. Each license's origin_id
refers to its source or contribution snapshot: preserve the name, version, links, attribution
and notices, and respect license_relation (all or any). For example, OpenGloss words
marked as derived from WordNet retain both sets of terms. An unchanged fork keeps the original
terms; actual edits add the fork's captured contribution terms. The primary license from
meta() does not replace these word-specific licenses. These fields are optional when
reading older v1 servers. See the
API source fields.
What the licenses ask for: DATA_LICENSE.md.
Search selected dictionaries
const choices = await client.datasets(); // public names and terms; installation order
const flat = await client.search({ search: 'run', dataset: 'wiktionary' });
const detailed = await client.searchDetailed({ search: 'run', dataset: 'wiktionary', with_meanings: true });
const grouped = await client.searchDatasets({ search: 'run', datasets: ['default', 'wiktionary'], limit: 5 });
for (const group of grouped.data) {
if (group.status === 'ok') console.log(group.dataset, group.data, group.meta.has_more);
else console.log(group.dataset, group.error); // unavailable, not an empty match
}
Omit dataset to keep active-dictionary behavior. Explicit unknown or closed targets fail with
404, without fallback. Grouped search accepts 1–5 names; each costs one unit of the shared public
budget. Pagination and ranking are per group; request only that name to advance one group.
Both selected searches and inventory revalidate on every read. Partial-failure responses are
not cached. Preserve each group's source terms and use its dataset when linking to a word page.