Vocab Bloom Hub
Эта страница доступна только на английском.

@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; fetch only, no dependencies; ESM and CommonJS.
  • Errors are thrown as typed exceptions; AbortSignal on every call; optional ETag cache for repeated reads; opt-in retry on 429 / 5xx honouring Retry-After; a versioned User-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

MethodEndpointAnswer
search(request)GET /searchSearchResponse
searchDetailed(request)GET /search/detailedDetailedSearchResponse
word(headword)GET /words/{word}HeadwordResponse
wordsBatch(words)POST /words/batchWordsBatchResponse — 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}/meaningsMeaningsResponse
translations(headword, query?)GET /words/{word}/translationsTranslationsResponse
forms(headword)GET /words/{word}/formsFormsResponse
synonyms(headword)GET /words/{word}/synonymsLinksResponse — the linked headwords per meaning
antonyms(headword)GET /words/{word}/antonymsLinksResponse
history(headword)GET /words/{word}/historyHistoryResponse — what was changed on the instance, with the values before and after
wordDatasets(headword)GET /words/{word}/datasetsWordDatasetsResponse — the headword in every dataset of the instance, a group per dataset with its terms
datasetHistory(headword, dataset)GET /words/{word}/datasets/{dataset}/historyHistoryResponse — the edits of the headword in one dataset
words(query?)GET /wordsWordsResponse (one page)
iterateWords(query?)GET /words, following the cursorAsyncGenerator<Word>
iterateSearchDetailed(request)GET /search/detailed, page after pageAsyncGenerator<Word> — stops at the server's page cap (DETAILED_SEARCH_MAX_PAGE, 20)
random(filters?)GET /randomWordResponse
meta()GET /metaMetaResponse
openapi()GET /openapi.jsonthe OpenAPI 3 document
suggest(request)POST /suggestionsSuggestionCreatedResponse — 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:

ClassWhenFields
NotFoundError404status, code (word_doesnt_found), body
RateLimitError429 — the public rate limitretryAfter (seconds, from Retry-After)
NetworkErrorno answer: DNS, connection, TLS, abort, timeoutstatus: 0, code: 'network_error', cause
VocabBloomErroreverything elsestatus, 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.