Skip to content

Latest commit

 

History

History
824 lines (655 loc) · 52.8 KB

File metadata and controls

824 lines (655 loc) · 52.8 KB

i18n-keyless protocol and client behaviour, v3

Reference: i18n-keyless-core, i18n-keyless-react and i18n-keyless-node 3.3.0 (repository commit b819592). Every client statement below is derived from that code, not from the README. Every server statement (what the API accepts, answers and counts) was verified against the official API source, i18n-keyless-saas/api-express at commit bacc3df; section 16 lists each verified item with its source location. A self-hosted backend (API_URL) MUST reproduce the server statements to serve an unmodified SDK.

A port (PHP, Dart, Python, Vue, Angular, plain browser, ...) conforms when it satisfies the MUST statements of this document and replays the vectors in conformance/vectors/ green (see conformance/README.md). The words MUST, SHOULD and MAY follow RFC 2119.

Contents

  1. Vocabulary
  2. Configuration
  3. HTTP transport: base URL, headers, timeout, retry, error results
  4. Endpoints
  5. Text resolution on the client
  6. The translate-on-miss queue
  7. Bulk fetch, delta cursor, ETag replay, merge
  8. Language switch and boot sequence
  9. Usage analytics
  10. Identity: device id, server, the sdk header
  11. Storage contract
  12. Server rendering (SSR) rules
  13. The node (server) SDK differences
  14. Languages: the 48 codes, v2 to v3, resolveLang, App Store slots
  15. Known limitations of the reference implementation
  16. Verified against the API

1. Vocabulary

Term Meaning
key / source text The text as written by the developer, in the primary language. It is the translation key.
primary language languages.primary: the language the source texts are written in. Any of the 48 codes.
supported languages languages.supported: the codes the project translates into. Sent on every translate request.
current language The language the client renders in.
context Optional per-call string that disambiguates a key. Part of the storage key (key__context).
namespace Optional per-call string that partitions the project's translations into independently fetched and stored slices. The reserved default is the literal string default.
storage key storageKeyFor(key, context): the key of an entry in a dictionary and in the usage map.
dictionary Record<storageKey, translatedText> for one language (and, on the client, one namespace).
delta cursor (lastRefresh) A string the API returns with a dictionary and the client echoes as last_refresh. The client treats it as opaque. The API writes it as a Unix time in milliseconds and uses it to skip the payload (an empty map) when the client is fresher than the namespace; it never sends a partial dictionary (section 4.2).
origin language (UGC) originLanguage: the language a user-written key is in, when it is not the primary language.
device id (unique_id) A 16-character id generated by a device client, persisted, and sent on every request. It is what the API counts as one user for a device runtime.
runtime (sdk) react-client, react-server or node: how the API counts the request (a device by its id, a server by its connection).

2. Configuration

2.1 Fields and defaults

Field Required Default / rule (client SDK, packages/react/store.ts: init)
API_KEY yes, always Non-empty string. init throws i18n-keyless: API_KEY is required when empty, even with API_URL or custom handlers. Sent as Authorization: Bearer <API_KEY>.
API_URL no Base URL of a self-hosted backend, no trailing slash. Default https://api.i18n-keyless.com.
languages.primary yes One of the 48 codes. init throws i18n-keyless: primary is required when absent.
languages.supported yes Array of codes. initWithDefault is appended when missing from it.
languages.fallback no Defaults to primary. Used by validateLanguage when a requested language is not in supported.
languages.initWithDefault no Defaults to primary. The language used when storage holds none, or when skipCurrentLanguageHydration is true.
languages.skipCurrentLanguageHydration no false. When true the stored language is ignored at boot (the URL drives the language).
defaultNamespace no Applied when a call passes no namespace. Absent means default.
storage browser: yes, server: no An adapter (section 11). In a browser (typeof window !== "undefined") a missing storage throws. On a server it defaults to an in-memory Map adapter.
ssr no false. When true the SDK behaves as a server: read-only usage, runtime react-server, no device id.
debug no false. Console logging only.
addMissingTranslations no Forced to true. The value is never read by the reference implementation.
handleTranslate, getAllTranslations, sendTranslationsUsage no Custom handlers, section 2.2.
onInit(lang), onSetLanguage(lang) no Callbacks. onInit fires after hydration with the resolved current language; onSetLanguage fires before every language switch.

The node SDK (packages/node/service.ts: init) has the same API_KEY, API_URL, languages, defaultNamespace, debug, onInit and handlers (handleTranslate, getAllTranslationsForAllLanguages, sendTranslationsUsage), no storage and no ssr.

2.2 The three modes, in priority order

For each network operation the SDK picks the first available row:

Priority Operation Custom handler (mode 1) Self-hosted (mode 2) Official (mode 3)
1 → 3 translate a missing key handleTranslate(key) POST <API_URL>/translate POST https://api.i18n-keyless.com/translate
1 → 3 fetch a language dictionary (client) getAllTranslations() GET <API_URL>/translate/:lang GET https://api.i18n-keyless.com/translate/:lang
1 → 3 fetch all languages (node) getAllTranslationsForAllLanguages() GET <API_URL>/translate/ GET https://api.i18n-keyless.com/translate/
1 → 3 report usage sendTranslationsUsage(defaultBucket) POST <API_URL>/translate/last-used-translations POST https://api.i18n-keyless.com/translate/last-used-translations

Modes 2 and 3 differ only by the base URL. API_KEY is sent in both.

Exact handler contracts, as the reference implementation calls them:

Handler Called with Must return
handleTranslate (key) only. The context, namespace, languages and origin language are not passed. Promise<{ ok, message, data: { translation: Record<Lang, string> } }>. The client SDK ignores data; the node SDK caches data.translation.
getAllTranslations () with no argument. The handler must know the language from its own state. Promise<I18nKeylessResponse> (section 4.2 envelope).
getAllTranslationsForAllLanguages () Promise<I18nKeylessAllTranslationsResponse> (section 4.3 envelope).
sendTranslationsUsage (usageByNamespace["default"] ?? {}): the default-namespace bucket only. Promise<{ ok, message }>.

A port MAY pass more arguments to its handlers, but MUST accept handlers that ignore them.

3. HTTP transport

3.1 Base URL

config.API_URL || "https://api.i18n-keyless.com". The SDK concatenates paths without normalising slashes: API_URL MUST NOT end with /.

3.2 Headers sent on every request

Header Value Sent by
Content-Type application/json all requests, GET included
Authorization Bearer <API_KEY> (one space) all
Version the SDK package version, semantic version string, e.g. 3.3.0 all
sdk react-client, react-server or node (section 10) all
unique_id the device id (section 10) only when sdk is react-client
If-None-Match the ETag remembered for this dictionary dictionary GETs, only when an ETag is known

Header names are written exactly as above (HTTP treats them case-insensitively). No other header is sent. Nothing is sent as a cookie or a query parameter except what section 4 lists.

On the server side (api-express/src/middlewares/withBearerToken.ts:36) the API key is the text after the first space of Authorization; the scheme word is not checked. A header without a space, an unknown key, a key not yet activated and a deactivated key all answer 401. The API reads no other request header than the five above (plus Origin, for the public demo key only).

3.3 The Version header contract

The API reads Version only to choose the language-code dialect of its answers (api-express/src/middlewares/version-check.ts:14-17, wireDialect):

Version header Dialect
parses with parseInt(value, 10) to a major >= 3 (3.0.0, 3.3.0, 4.1.0-beta) v3: zh-Hans, cs
major < 3 (2.6.2, 1.0.0) v2: cn, cz
absent, empty, or not starting with digits (v3.0.0, latest) v2

The dialect applies to every language code the API emits: the keys of the all-languages dictionary (section 4.3), the POST /translate response row, error messages, and it is part of the ETag (section 4.2). The usage body format is not gated on Version: both translationsUsageByNamespace and the legacy flat translationsUsage are accepted from any client (section 4.4). No minimum version is enforced: the version gate in versionCheck is commented out and the middleware only calls next().

A port MUST send a semantic version whose major is >= 3 (the placeholder $SDK_VERSION in the vectors) so the API answers in the v3 dialect described here. Sending a Version that starts with a letter would silently switch the port to cn/cz.

3.4 Timeout, retry, backoff (packages/core/api.ts)

One shared fetchWithRetry serves all four operations.

Parameter Value
Per-attempt timeout 10000 ms (AbortController). A timed-out attempt reports the error string timeout.
Attempts 3 in total.
Backoff before attempt 2 / attempt 3 500 ms / 1500 ms. No wait after the last attempt. No jitter, no exponential growth.
Retried a network error (fetch rejected), a timeout, HTTP 429, HTTP 5xx (status >= 500).
Not retried every other status: 4xx other than 429, every 2xx other than 200, every 3xx other than 304. The call ends at once with { ok: false, error }.
Result on 200 the parsed JSON body. When the response carries an ETag header its value is copied into the body as etag.
Result on 304 { ok: true, notModified: true }. The body is not read.
Result on failure { ok: false, error } where error is the status text when non-empty, else HTTP <code>, else the network error message, else timeout.
Never throws. Never clears a stored dictionary. A body that fails to parse on a 200 counts as a failed attempt and is retried.

Vectors: backoff.json, retry-decision.json.

3.5 Envelope

Every 200 body is an envelope:

{ "ok": true, "data": { ... }, "error": "", "message": "" }

ok: false with an error string means the operation failed even though HTTP said 200; the client treats it like a transport failure (keeps its stored copy, logs). A non-empty message is informational: the SDK logs console.warn("i18n-keyless: ", message) and continues. A port MUST surface message the same way (a warning, not an error).

Server facts (api-express/src/controllers/translate.ts): the official API never sends ok: false with a 200, and today always sends message: ""; the usage endpoint answers without a data field (section 4.4). The client rules above still apply, so a self-hosted backend MAY use them.

3.6 Status codes the API uses

Case Status Body Source
Missing, unknown, not activated or deactivated API key 401 JSON { "ok": false, "code": "SERVER_ERROR", "error": "<generic French sentence>" }: the reason is not in the body withBearerToken.ts:37-61, errors.ts:75-79
Invalid POST /translate body (zod), empty key, key longer than 2000 characters, context longer than 200, namespace longer than 200, primary language different from the project's 400 JSON envelope { "ok": false, "error": "<reason>", "data": { "translation": null }, "message": "" } translate.ts:585-608, translate-key.ts:102-147
Usage POST whose primaryLanguage is not the project's (or the project has none yet) 400 same envelope translate.ts:473-485
Project whose stored primary language is not a known code (legacy data) 400 on the dictionary GETs envelope with empty translations translate.ts:306-318, 391-403
AI translation of a UGC key into the primary language failed 500 envelope translate-key.ts:176-185
Unhandled server error 500 the same generic JSON as the 401 errors.ts:75-79
Rate limit 429 a text body (Too many requests, please try again later. or a longer variant), plus X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset and Retry-After headers translate.ts:44-50, 435-451
If-None-Match matches 304 no body, ETag and Cache-Control headers translate.ts:258-273

Rate limits are per API key (the raw Authorization header value is the bucket; the IP only for unauthenticated probes): dictionary GETs 20000 per 10 s; POST /translate and the usage POST 10000 per 1 s and 10000 per 10 s. They exist to stop a runaway loop; a fleet cold start fits.

There is no quota status: the API has no plan or credit check on the SDK routes (the only gates are activated_at / deactivated_at on the key, which answer 401). Billing is metered from the usage table after the fact. A client therefore never sees 402 or 403 from the official API.

Because the SDK does not read non-200 bodies (section 3.4), a port MAY ignore them too; the shapes above are given so a port that logs them knows what to expect.

4. Endpoints

4.1 POST /translate: translate one missing key

Request body (I18nKeylessRequestBody); a field whose value is absent is not serialised:

Field Type Rule
key string the source text, not trimmed by this layer
context string? as given
namespace string? omitted when it resolves to default
forceTemporary Record<Lang, string>? as given, when given
languages Lang[] languages.supported, in config order
primaryLanguage Lang languages.primary
originLanguage Lang? the per-call originLanguage, omitted when absent or equal to the primary
{ "key": "8 heures", "context": "time", "namespace": "checkout", "languages": ["fr", "en", "es"], "primaryLanguage": "fr" }

Server-side rules (api-express/src/controllers/translate.ts:552-564, api-express/src/service/translate-key.ts):

  • key, languages and primaryLanguage are required; every language code MUST be one of the 48 codes or cn / cz (zod wireLang), else 400 Invalid request body. The keys of forceTemporary follow the same rule.
  • An empty key is 400. Limits: key 2000 characters, context 200, namespace 200 (translate-key.ts:102-126). A longer value is 400 with a descriptive error. Only the context and namespace caps defend the unique index entry (Translations_identity_key holds source_hash, not the text). The 2000 on key is a quality rule, and it is not a rule against long-form content: a client that translates a blog post sends one request per Markdown block — around 1000 characters each — with the Markdown kept inside the block and the same context (a one-sentence summary of the document) on every block of it. See https://docs.i18n-keyless.com/docs/guides/long-form-content
  • An absent namespace and an explicit "default" are the same bucket (translate-key.ts:98).
  • An originLanguage equal to primaryLanguage is ignored (translate-key.ts:95-96).
  • The first POST /translate of a project sets its primary language permanently (translate-key.ts:130-138). Every later call, and every usage POST, MUST send the same primaryLanguage, else 400 Invalid primary language: <sent>, expected <stored>.
  • languages overwrites the project's stored supported-language list on every call that translates or applies forceTemporary (translate-key.ts:290, 342). This list, plus the primary, is what GET /translate/ (section 4.3) emits. Languages already filled on the row are not re-translated; only empty cells are sent to the AI (translate-key.ts:294).
  • forceTemporary[lang] (for a lang present in languages) overwrites the stored cell for that language (translate-key.ts:276-284). The value is permanent: the AI never rewrites a non-empty cell, and only a later forceTemporary, a dashboard edit or the MCP tool changes it again. The write bumps the row's updated_at, so the next dictionary fetch (new ETag, new namespace version) returns the override at once. The name is historical; "temporary" is not a server behaviour.

Response body (api-express/src/utils/translation-row.ts:70-108, toWireRow):

{ "ok": true, "message": "", "error": "",
  "data": { "translation": {
    "fr": "8 heures", "en": "8 AM", "es": "8 de la mañana", "de": null, "...": "(one key per language code except id)",
    "languages": { "fr": "8 heures", "en": "8 AM", "es": "8 de la mañana", "de": null, "id": null, "...": "(all 48 codes)" },
    "id": 4213, "api_key": "<API_KEY>", "namespace": "checkout", "context": "time",
    "source_lang": "fr", "source_text": "8 heures", "origin_language": "",
    "llm_tokens": 12, "group": "", "created_at": "2026-08-26T12:00:00.000Z", "updated_at": "2026-08-26T12:00:00.000Z", "last_used_at": "2026-08-26" } } }

data.translation is the stored row, not a map of the languages translated by this call: for a v3 client it holds every one of the 48 codes (in languages), the primary included (its cell holds the source text); a language never translated for this row is null. Languages outside languages may already be filled when another namespace held the same key (translate-key.ts:204-220). A v2 client receives the 19 v2 codes only.

The flat top-level keys are the v2 shape and cannot carry Indonesian: the key id is the numeric row id (translation-row.ts:30). A port MUST read translations from data.translation.languages[lang], never from the flat keys (see section 15, item 9).

The client SDK uses nothing of the body except message; the store is filled by the bulk fetch that follows (section 7). The node SDK caches the flat keys for every language it knows (section 13).

Vectors: translate-request.json.

4.2 GET /translate/:lang: one language dictionary (client SDKs)

URL: <base>/translate/<lang><query>. <lang> is the code verbatim (zh-Hans is not encoded). Query rules (buildDictionaryUrl):

ETag known for (API key, lang, namespace)? namespace is default namespace is ns
no ?last_refresh=<cursor> ?last_refresh=<cursor>&namespace=<encodeURIComponent(ns)>
yes (no query) ?namespace=<encodeURIComponent(ns)>

<cursor> is written by string interpolation: a null cursor is the literal null (?last_refresh=null), an empty cursor is empty (?last_refresh=). The client treats the cursor as opaque.

Cursor format and semantics (api-express/src/controllers/translate.ts:247-250, 375, 404-418): the API emits lastRefresh as the current Unix time in milliseconds, as a decimal string ("1756209600000"), or null for a project that has no primary language yet (no key was ever translated). On a request it reads Number(last_refresh); null, the empty string, an absent parameter and any non-numeric value all become NaN or 0 and mean "not fresh": the full dictionary is sent. The cursor is not a delta: when last_refresh >= newest updated_at of the namespace + 600000 (a 10-minute safety buffer) the API answers 200 with translations: {} and a new lastRefresh; otherwise it sends the whole namespace dictionary. A response is therefore always all or nothing, which is why the client merge (section 7.3) never removes keys. The ETag check runs before the cursor check, so a client that sends both gets a 304 first.

<lang>: either dialect is accepted and canonicalised (cn and zh-Hans read the same column, translate.ts:373). A code that is none of the 50 accepted spellings is not rejected: the API answers 200 with every key of the namespace mapped to "" (translate.ts:167-181). A port MUST NOT request a language outside section 14.1.

namespace: an empty namespace= is the default namespace (translate.ts:374). A namespace that was never written is not an error: 200, translations: {}, ETag W/"0-0-<dialect>-<lang>", and a normal lastRefresh.

Response (I18nKeylessResponse):

{ "ok": true, "data": { "translations": { "Bonjour": "Hello", "8 heures__time": "8 AM" }, "uniqueId": "AbCdEfGhIjKlMnOp", "lastRefresh": "1756209600000" }, "error": "", "message": "" }

data.translations is keyed by storage key; a UGC row is emitted twice, under its primary text and under its raw origin text (translate.ts:174-178). A cell never translated is the empty string "" (the client treats it as a miss, section 5). data.uniqueId echoes the counting key (section 10.3). data.lastRefresh is the next cursor.

Response headers on every 200: ETag: W/"<rowCount>-<newestUpdatedAtMs>-<dialect>-<lang>" (a weak validator, translate.ts:265) and Cache-Control: private, max-age=60. The API compares If-None-Match to its current ETag with a plain string equality (no list, no *, no weak-comparison stripping): a port MUST echo the ETag byte for byte. On a match: 304, no body, the same ETag and Cache-Control headers. The ETag is the content version of (API key, namespace) plus the dialect and the language: it does not name the namespace itself, so two namespaces with the same row count and newest timestamp produce the same string. That is harmless because the client keys its ETag map by namespace (section 7.2) and never replays an ETag across namespaces. The Version header changes the ETag (section 3.3). No ETag is sent for a project without a primary language.

Vectors: dictionary-request.json, dictionary-response.json.

4.3 GET /translate/: every language at once (node SDK)

URL: <base>/translate/<query> with the trailing slash, same query rules as 4.2. The node SDK never stores a cursor, so without an ETag the query is ?last_refresh=.

Response (I18nKeylessAllTranslationsResponse): same envelope, with data.translations keyed by language then storage key:

{ "ok": true, "data": { "translations": { "fr": { "Bonjour": "Bonjour" }, "en": { "Bonjour": "Hello" }, "es": { "Bonjour": "Hola" } }, "uniqueId": "srv_x1Y2z3A4b5C6d7E8f9G0hI", "lastRefresh": "1756209600000" }, "error": "", "message": "" }

Server rules (api-express/src/controllers/translate.ts:192-198, 275-355):

  • This route honours namespace, last_refresh and If-None-Match exactly like 4.2; the ETag's language segment is the literal all (W/"<count>-<ms>-<dialect>-all"). The cursor short-circuit returns one empty map per emitted language.
  • The languages emitted are the project's stored supported list (the languages of the last translating POST /translate, section 4.1) plus the primary language when it is not in that list, in that order, spelled in the client's dialect. The primary bucket holds the source text of every row (and the AI translation for a UGC row). Every emitted language holds every key of the namespace; a cell never translated is "".
  • A project without a primary language answers one empty map per stored supported language, lastRefresh: null, no ETag.

Languages the SDK does not know are dropped on merge.

4.4 POST /translate/last-used-translations: usage analytics

Request body (I18nKeylessTranslationsUsageRequestBody):

{ "primaryLanguage": "fr", "translationsUsageByNamespace": { "default": { "Bonjour": "2026-08-04", "8 heures__time": "2026-08-04" }, "checkout": { "Payer": "2026-08-05" } } }

Dates are UTC calendar dates YYYY-MM-DD (new Date().toISOString().slice(0, 10)). The default namespace is present under the literal key default. Never sent with an empty map by the reference client.

Response: { "ok": true, "error": "", "message": "" } (no data field, api-express/src/controllers/translate.ts:537-541).

Server rules (translate.ts:453-545):

  • primaryLanguage MUST equal the project's stored primary language (either dialect). Otherwise, and when the project has no primary language yet (no key ever translated), the answer is 400 with the envelope of section 3.6. A port that reports usage before its first POST /translate gets that 400; the reference client only reports after hydration, when the map is non-empty, so it rarely hits it.
  • An empty map (translationsUsageByNamespace: {}, or a namespace with no keys) is accepted: 200, nothing written. The legacy flat body translationsUsage (SDK < 2.4.1) is accepted too and read as the default namespace; an empty namespace name is default.
  • Each key is split at the first __: the first segment is the source text, the second the context, further segments are dropped (translate.ts:501-503). A source text containing __ is therefore never stamped (see section 5.1). A key whose first segment is empty is skipped.
  • The stamp is an updateMany on the exact (API key, primary language, namespace, source text, context) rows, in chunks of 200 keys per (namespace, date). An unknown key, an unknown namespace and a UGC key sent as its raw origin text are silent no-ops. The date string is stored verbatim; the API does not validate its format.

Vectors: usage-request.json.

5. Text resolution on the client (getTranslationCore)

Inputs: key, the store (currentLanguage, config, flat translations map) and the options (context, namespace, unpersistedNamespace, debug, forceTemporary, replace, originLanguage). The function is synchronous and MUST return a string.

  1. If config.API_KEY is empty: throw i18n-keyless: config is not initialized.
  2. sourceLanguage = originLanguage when given and different from the primary, else the primary language.
  3. If currentLanguage === sourceLanguage: text = key. No lookup, no request, even when forceTemporary[currentLanguage] is set.
  4. Else:
    1. if forceTemporary[currentLanguage] is set: queue a translate request (section 6),
    2. text = translations[storageKeyFor(key, context)],
    3. if text is missing or an empty string: queue a translate request, and text = key. A context miss never falls back to the entry without context.
  5. Apply replace to text (section 5.2) and return it.

forceTemporary never changes what is rendered: its value only travels to the API, which stores it; the override arrives with the next dictionary fetch.

Vectors: translation-lookup.json, storage-key.json.

5.1 Storage key

storageKeyFor(key, context) = context ? key + "__" + context : key. An empty context is no context. Nothing is escaped: a key containing __ is ambiguous by design.

5.2 replace

applyReplace(text, replace):

  • every map key is a literal placeholder: the characters . * + ? ^ $ { } ( ) | [ ] \ are escaped, then all placeholders are joined with | into one global regular expression, in the map's key order;
  • the text is scanned once, left to right, non-overlapping; at a given position the first placeholder in map order that matches wins;
  • each match is replaced by replace[matched] || matched: an empty replacement leaves the placeholder in place;
  • replacement values are inserted verbatim (no $1, $& expansion) and never re-scanned;
  • an absent option or an empty map returns the text unchanged.

Vectors: replace.json.

5.3 Trimming

The component path (<T>, useTranslation) trims the source text (text.trim()) before everything else and warns in development when it had surrounding whitespace. The function path (getTranslation) and the node SDK do not trim. A port MUST trim in its component / template path and MUST NOT trim in its imperative function.

6. The translate-on-miss queue (translateKey, MyPQueue)

Rule Value
Scope one process-wide queue shared by every namespace and language
Concurrency 30 requests in flight at most
Priority every translate task is added with priority 1; higher priority runs first; ties keep insertion order
Task id queueIdFor(namespace, key) = namespace + ":" + key. The context and the origin language are not part of the id (section 15)
Dedupe (waiting) adding an id that is still waiting returns the waiting task's promise; nothing is added
Dedupe (in flight) a per-id translating flag: a task whose id is in flight returns at once without a request; the flag is cleared when the request settles, on success and on failure
Skips an empty key; a key whose storage key is present and non-empty in the flat map, unless forceTemporary[currentLanguage] is set
Side effect before queueing the resolved namespace is recorded in a namespacesToFetchAfterTranslationFinished map, with unpersistedNamespace as its value (last write wins)
Boot gate before the request leaves, the task awaits the device-id gate (section 10.4)
Errors caught and logged; the task never rejects
empty event fired when nothing is waiting and nothing is in flight; the wrapper reacts by bulk-fetching (section 7.1)

The response of POST /translate is not merged into the client store. Only the bulk fetch fills it.

Vectors: queue.json.

7. Bulk fetch, delta cursor, ETag replay, merge

7.1 Trigger

On the queue's empty event the wrapper reads and clears the recorded namespaces and, for each { namespace, unpersisted }, calls GET /translate/<currentLanguage> for that namespace with the namespace's own cursor (lastRefreshByNamespace[namespace] ?? null), then merges the answer (setTranslations). Nothing is fetched for a namespace that had no miss.

7.2 ETag replay (packages/core/service.ts)

  • The SDK remembers the ETag of every 200 dictionary answer in an in-memory map keyed by etagCacheKey(apiKey, lang, namespace) = apiKey + "|" + lang + "|" + (namespace || "default"). Nothing is persisted: after a restart the first fetch is a plain 200.
  • With a known ETag the next request for the same key sends If-None-Match: <etag> and drops last_refresh from the URL (section 4.2), so the URL is stable for shared caches.
  • A 304 returns nothing to the wrapper: the stored dictionary is kept, the ETag stays.
  • The node SDK keys its map by namespace only (namespace || "default").
  • The official API's ETag is weak (W/"...", section 4.2) and is compared as an exact string. The client never parses it: it stores and echoes the header value verbatim.

7.3 Merge (packages/react/store.ts: setTranslations)

Given an ok response for (namespace, unpersisted):

  1. flat map: translations = { ...translations, ...data.translations } (new values win; keys never removed);
  2. per-namespace slice: translationsByNamespace[namespace] = { ...previous, ...data.translations };
  3. namespaces index gains the namespace when new; unpersistedNamespaces gains it when unpersisted;
  4. data.uniqueId is adopted only when the device has none (section 10.3);
  5. if unpersisted: remember data.lastRefresh in memory and stop (nothing persisted);
  6. persist the slice under translationsKeyFor(namespace) as JSON; when the namespace is new persist the index (persisted namespaces only) under i18n-keyless-namespaces;
  7. if data.lastRefresh is truthy: set lastRefresh and lastRefreshByNamespace[namespace] and persist it under lastRefreshKeyFor(namespace).

A response with ok: false or no response changes nothing.

Because the flat map is shared across languages, after a language switch a key that has no entry in the new language's dictionary keeps showing the previous language's text until the translation arrives.

8. Language switch and boot sequence

8.1 setLanguage(lang) (packages/react/store.ts)

  1. validated = supported.includes(lang) ? lang : fallback. No resolveLang is applied here: cn, cz or fr-CH fall back like any unknown code.
  2. currentLanguage = validated; persist it under i18n-keyless-current-language.
  3. Reset every cursor: lastRefresh = null, lastRefreshByNamespace = {}, and persist the empty string under lastRefreshKeyFor(ns) for every known persisted namespace.
  4. Known namespaces = namespaces when non-empty, else ["default"].
  5. If validated !== primary: fetch every known namespace in parallel with a null cursor and merge each (section 7.3), persisting unless the namespace is unpersisted. Else, if originNamespaces is non-empty: fetch only those (their primary-language text is an AI translation, not the key).

8.2 init(config) sequence

  1. Validate and default the config (section 2.1).
  2. Hold the device-id gate (section 10.4); set the config in the store.
  3. hydrate() from storage (section 11.3). Release the gate in finally.
  4. onInit(currentLanguage).
  5. setLanguage(currentLanguage) (not awaited).
  6. On a device runtime only: sendTranslationsUsage() (section 9).

Consequence: every boot in a non-primary language performs a full fetch of each known namespace (cursor null, no ETag), because step 5 resets the cursors and the ETag map is in-memory. The stored cursor is only used by the in-session empty refetch.

9. Usage analytics

Aspect Client SDK (react-client) Node SDK
Recorded when every getTranslation / <T> render, on a microtask after render every awaitForTranslationOrThrow / awaitForTranslationOrFallbackToOriginal call, including primary-language calls
Skipped for unpersistedNamespace calls; any server runtime unpersistedNamespace calls
Key / value storageKeyFor(key, context) → UTC date YYYY-MM-DD, under resolveNamespace(...) same
Persisted yes, JSON under i18n-keyless-translations-usage no
Sent when once per init, right after hydration, when the map is non-empty on a 10000 ms debounce after a key's date changes; the timer must not keep the process alive
After a successful send the map is cleared and the storage value set to "" the map is never cleared (cumulative)
Suppressed when typeof window === "undefined" or config.ssr === true: neither recorded nor sent never

Rule as a function (isUsageReportingEnabled(runtime)): usage is active unless the runtime is react-server. Vectors: usage-reporting.json, usage-request.json.

10. Identity: device id, server, the sdk header (packages/core/unique-id.ts)

10.1 Runtime (resolveSdkRuntime)

Package Condition sdk header unique_id header
react, vue, angular typeof window !== "undefined" and ssr not true react-client, vue-client, angular-client yes
react, vue, angular no window, or ssr: true react-server, vue-server, angular-server no
browser always browser yes
node always node no
laravel port always laravel no (reports usage like node)
rails port always rails no (reports usage like node)
flutter port always flutter yes

Rule (isServerRuntime in core, isServerRuntime in the API): node, laravel, rails and every label ending in -server are servers. Everything else, an absent header included, is a device. A new port picks <name>-client / <name>-server when it can run on both sides, or a bare name otherwise.

How the API reads the header (api-express/src/middlewares/user-count.ts:24-29, sdkRuntime):

sdk header value Runtime the API assumes Counting key (user-count.ts:147-150)
node, laravel, rails, any *-server server srv_<22 chars>: a SHA-256 of the server secret, the API key and the source address (IPv6 bucketed to its /64), user-count.ts:42-47. Any unique_id sent is ignored.
react-client, vue-client, angular-client, browser, flutter device the unique_id header; when absent or empty, anon_<API_KEY>: one shared row per project, user-count.ts:67-69
absent device same as react-client (SDKs before 3.2.0 sent no header)
any other string (a typo) device same as react-client

The value is never validated or rejected: the only test in the API is isServerRuntime (node, laravel, rails, or a -server suffix); everything else is a device. The value itself is not stored: the usage table holds only the counting key it selects, and Sentry tags carry version, appbuild, appdevice, currentroute and unique_id but not sdk (api-express/src/index.ts:92-100; the full header set reaches Sentry only inside the report of an unhandled 5xx, errors.ts:51-70). The counting happens in memory per (counting key) and is flushed to the Usage table every 30 s, one row per counting key, with a translate_req counter for the POST routes and a translations_req counter for the dictionary GETs (user-count.ts:131-163). The counting key is also echoed as data.uniqueId on the dictionary GETs (section 10.3).

Consequences for a port:

  • a device port MUST send sdk: react-client (or a value the API does not know, which is counted the same way) and a persisted unique_id, else its whole install base is counted as one anonymous user;
  • a server port MUST send sdk: node or sdk: react-server and no unique_id; any other value would make the API count the server as a device, and without an id every request of that server collapses into anon_<API_KEY>;
  • a runtime label for a new port (vue, angular, browser, flutter) is therefore accepted today, but counted as a device (laravel and rails are known servers). Before such a label is sent from a server, sdkRuntime in user-count.ts MUST learn it, otherwise the server is miscounted. The docs in i18n-keyless-saas/docs do not describe the header yet.

An empty unique_id header is never sent by the reference client; the API would then count the device under the shared anon_<API_KEY> row (it no longer mints one user per request, user-count.ts:49-66).

10.2 Generation and validity

  • Alphabet: 0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ_abcdefghijklmnopqrstuvwxyz (63 characters).
  • Length: 16. Random bytes from a CSPRNG when available, else any PRNG; a byte >= 252 is discarded (rejection sampling), otherwise alphabet[byte % 63].
  • Valid id (isUniqueId): a string, 1 to 64 characters, every character in 0x21..0x7E. A persisted value that is not valid is replaced by a fresh id.

10.3 Resolution and persistence

  • resolveUniqueIdForRequest(storeId): the process-level id wins; else a valid storeId is adopted and becomes the process-level id; else a fresh id is generated and kept.
  • At boot (device runtime), hydrate() reads i18n-keyless-user-id first, generates and persists an id when missing or invalid, and only then releases the gate.
  • A dictionary answer's data.uniqueId is adopted (and persisted) only when the device has no id yet, only on a device runtime, and only when it passes isUniqueId (packages/react/store.ts:68-79). An existing id is never replaced by a response.
  • What the API echoes in data.uniqueId is its counting key (section 10.1): the device's own unique_id header; anon_<API_KEY> for a device that sent none; srv_<22 chars> for a node / react-server request (api-express/src/controllers/translate.ts:297, 425). A port MUST NOT persist an anon_ or srv_ value as its device id: the reference client cannot, because a device runtime always has an id before its first request (the boot gate, 10.4) and a server runtime never adopts one.
  • clearI18nKeylessStorage keeps i18n-keyless-user-id.

10.4 The boot gate

holdRequestsUntilUniqueIdIsKnown() returns a release function; init calls it before hydration and releases in finally. translateKey, the dictionary fetch and the usage POST await whenUniqueIdIsKnown() (null when nothing holds) before sending. A second hold reuses the same gate.

Vectors: unique-id.json, usage-reporting.json.

11. Storage contract (client SDKs)

11.1 Adapter

An object with, for each operation, the first present method: read getItem / get / getString; write setItem / set; delete delete / del / removeItem / remove. Methods may be synchronous or return promises. Values are always strings. A failing read is logged and yields null; a failing write is logged and rethrown; a failing delete is logged. Writes are not awaited by the store.

11.2 Keys and serialisation

Key Value Cleared by clearI18nKeylessStorage
i18n-keyless-user-id device id, raw no
i18n-keyless-current-language language code, raw yes
i18n-keyless-translations JSON object: the default namespace slice yes
i18n-keyless-translations__<ns> JSON object: the <ns> slice yes
i18n-keyless-last-refresh cursor of default, raw; "" after a language switch yes
i18n-keyless-last-refresh__<ns> cursor of <ns>, raw yes
i18n-keyless-translations-usage JSON object { ns: { storageKey: "YYYY-MM-DD" } }; "" after a successful usage POST yes
i18n-keyless-namespaces JSON array of persisted namespaces yes
i18n-keyless-origin-namespaces JSON array of namespaces holding UGC keys yes

The namespace is not encoded in the key (i18n-keyless-translations__check out). Unpersisted namespaces never touch storage and never enter the two indexes.

11.3 Hydration order (hydrate())

  1. Runtime and device id (section 10.3). Requests are held until this step is done.
  2. Unless a server snapshot was applied synchronously (hydrateFromServer): read i18n-keyless-namespaces (else ["default"]); for each namespace read its translations slice then its cursor; set the flat map (merged in index order), the slices, the namespaces and the cursors when at least one slice was found.
  3. Read i18n-keyless-origin-namespaces.
  4. Read i18n-keyless-translations-usage; keep it only when its values are objects (a legacy flat map with string values is discarded).
  5. Current language: keep the server-seeded one if any; else initWithDefault when skipCurrentLanguageHydration; else the stored value; else initWithDefault. The stored value is not validated here (the language switch that follows validates it).
  6. Read i18n-keyless-last-refresh into lastRefresh.

Vectors: storage-keys.json (replayed by the wrapper packages).

12. Server rendering (SSR) rules

  • On a server (typeof window === "undefined") or with ssr: true: runtime react-server, no device id, no usage recording, no usage POST. Translate-on-miss still works.
  • storage defaults to an in-memory adapter on a server.
  • getServerTranslations(lang): returns {} for the primary language; else one fetch per language per process (GET /translate/<lang>?last_refresh=null, default namespace only, no namespace parameter), cached in a process-wide map; {} on failure.
  • Per-request language travels in a render-scoped context (React context or AsyncLocalStorage), never in the process-wide store. See docs/SSR.md.
  • A server snapshot applied on the client before the first render (hydrateFromServer) is authoritative: hydration keeps its language and merges storage on top of it.

13. The node (server) SDK differences (packages/node/service.ts)

Aspect Node SDK
Store in memory: translations[lang][storageKey], one flat map per language, no namespace dimension
Boot init awaits GET /translate/ for config.defaultNamespace, merges every known language; never stores the cursor
Resolution awaitForTranslationOrThrow(key, lang, options): async. Empty key returns "". Primary (or origin) language returns applyReplace(key) unless forceTemporary[lang] is set. Hit returns applyReplace(translation). Miss: handleTranslate(key) when configured, else POST /translate, then returns `applyReplace(data.translation[lang]
Dedupe in-flight map keyed by namespace + ":" + storageKey + ":" + (originLanguage ?? ""), never for forceTemporary calls; plus the shared queue's empty event refetches GET /translate/ per recorded namespace
Cache after POST the flat keys of data.translation are merged into the store for every known language (unknown keys and empty values dropped). The flat key id is the numeric row id, not Indonesian (section 4.1): see section 15, item 9
Errors a failed POST rejects with i18n-keyless: FATAL: awaitForTranslationOrThrow failed for key "<key>". ... and the original error as cause; an unhandled rejection terminates the process by design. awaitForTranslationOrFallbackToOriginal swallows the same error after logging it and returns the key
Usage recorded on every call, flushed on a 10000 ms debounce, never cleared
Identity sdk: node, no unique_id; the echoed uniqueId is ignored
unpersistedNamespace no effect except that usage is not recorded

14. Languages

14.1 The 48 codes (AVAILABLE_LANGS, reference order)

ar bn ca zh-Hans zh-Hant hr cs da nl en en-GB fi fr fr-CA de el gu he hi hu id it ja kn ko ms ml mr no or pl pt pt-BR pa ro ru sk sl es es-MX sv ta te th tr uk ur vi

Exactly six codes carry a region or script: en-GB es-MX fr-CA pt-BR zh-Hans zh-Hant. There is no bare zh. Any code can be the primary language.

14.2 v2 to v3

v2 v3
cn zh-Hans
cz cs

The 17 other v2 codes (fr en nl it de es pl pt ro hu sv tr ja ru ko ar el) are unchanged. A v3 client never sends cn or cz; resolveLang("cn") and resolveLang("cz") return nothing, and a persisted cn falls back to languages.fallback at the next language switch. A self-hosted backend must rename the two codes in its data.

14.3 resolveLang(tag, { supported, fallback })

  1. Normalise: _-, trim, lowercase. Empty → no candidates.
  2. Candidates, most specific first, without duplicates:
    1. the whole tag matched case-insensitively against the 48 codes;
    2. if the language subtag is zh: the script from the last subtag (cn, sg, hanszh-Hans; tw, hk, mo, hantzh-Hant; anything else → zh-Hans), then stop (Chinese never falls back to a bare language);
    3. if the tag is es-419: es-MX;
    4. the bare language subtag matched against the 48 codes.
  3. Return the first candidate contained in supported (when given), else fallback, else nothing.

Vectors: resolve-lang.json, languages.json.

14.4 toAppStoreLocale(lang)

A fixed map onto App Store Connect slots (frfr-FR, enen-US, ptpt-PT, dede-DE, nlnl-NL, arar-SA, eses-ES, variants and the other bare codes unchanged). 48 distinct slots; en-AU and en-CA have no code. Vectors: app-store-locales.json.

15. Known limitations of the reference implementation

These are facts of 3.3.0 that a port MUST reproduce to behave identically, listed so that a future protocol revision can fix them everywhere at once.

  1. The queue id ignores the context and the origin language: two contexts of the same key in the same batch produce one POST /translate (the second context is translated on a later render). queue.json encodes this.
  2. handleTranslate receives only the key; getAllTranslations receives nothing.
  3. An empty replacement in replace leaves the placeholder in place.
  4. Every boot in a non-primary language is a full fetch (section 8.2).
  5. The flat map is shared across languages (section 7.3).
  6. In the client path, forceTemporary in the primary language sends nothing; in the node SDK it does.
  7. The Content-Type: application/json header is sent on GET requests.
  8. API_KEY is required in every mode, including custom handlers.
  9. The node SDK reads the flat keys of the POST /translate response row (packages/node/service.ts: knownLanguagesOnly). The flat key id is the numeric row id (section 4.1), so a project that supports Indonesian caches a number under translations.id[key] until the next bulk fetch overwrites it. A port MUST NOT reproduce this: read data.translation.languages[lang].

16. Verified against the API

Each item below was an open question of the first revision of this document. All eleven are settled by the API source (i18n-keyless-saas/api-express, commit bacc3df); the verified behaviour is written in the section named, and the source location is given here. No item remains open.

# Question Verified answer Source Section
1 Version thresholds and dialect switch parseInt of the header; major >= 3 is v3, everything else (including absent or unparsable) is v2. Only the language-code dialect and the ETag depend on it; the usage body format does not; no minimum version is enforced. middlewares/version-check.ts:14-17, 19-49 3.3
2 Accepted sdk values, unknown values Only node and react-server are recognised (as servers). Absent and every other value is a device: counted by unique_id, or by the shared anon_<API_KEY> row when no id is sent. Never rejected, never stored. middlewares/user-count.ts:24-29, 42-47, 67-69, 147-150 10.1
3 lastRefresh format, null and empty Unix milliseconds as a decimal string, or null before the first translation. On input Number(value): null, "", absent and non-numeric all mean "send everything". Not a delta: full dictionary, or an empty map when the client is fresher than the newest change plus 10 minutes. controllers/translate.ts:247-250, 290, 375, 406-418 4.2, 1
4 Strong or weak ETag; which routes honour If-None-Match Weak: W/"<count>-<newestMs>-<dialect>-<lang>" (all on the all-languages route). Both GET routes honour it, namespaced requests included; exact string comparison; 304 with no body; Cache-Control: private, max-age=60. controllers/translate.ts:258-273, 322, 405; __tests__/etag-cache.test.ts 4.2, 4.3, 7.2
5 Status codes and non-200 envelopes 401 for every key problem (generic JSON, reason not included); 400 with the JSON envelope for validation errors; 429 with a text body and X-RateLimit-* / Retry-After headers; 500 for AI failure and unhandled errors. No quota status exists. middlewares/withBearerToken.ts:37-61, middlewares/errors.ts:75-79, controllers/translate.ts:44-50, 435-451, 585-608, service/translate-key.ts:102-147, 176-185 3.6
6 POST /translate response contents The whole stored row: languages holds all 48 codes (v3 client), the primary included, null where never translated; flat keys hold the same minus id, which is the numeric row id. utils/translation-row.ts:30, 70-108 4.1
7 forceTemporary semantics Overwrites the stored cell permanently (the AI never rewrites a non-empty cell); bumps updated_at, so the next dictionary fetch returns it. service/translate-key.ts:272-297 4.1
8 data.uniqueId for server runtimes The counting key: srv_<22 chars> for node / react-server, anon_<API_KEY> for a device without id, else the device's own id. The client adopts it only on a device runtime that has no id. controllers/translate.ts:297, 425; middlewares/user-count.ts:147-151 10.3
9 Key length limit 2000 characters for the key, 200 for the context, 200 for the namespace; empty key rejected; all 400. service/translate-key.ts:102-126; __tests__/translate-post.test.ts:66-102 4.1
10 Usage POST: empty map, unknown keys Empty map accepted (200, no write). Unknown keys, unknown namespaces and UGC keys are silent no-ops. primaryLanguage must match the project's, else 400. Keys split at the first __. controllers/translate.ts:461-545 4.4
11 Never-created namespace Not an error: 200, translations: {}, ETag W/"0-0-<dialect>-<lang>". controllers/translate.ts:72-79, 404-430; __tests__/translate-get-lang.test.ts:174 4.2

Further server facts found during the verification and written into this document: the primary language is set by the first POST /translate and is immutable (4.1); the project's supported-language list is overwritten by each translating POST and drives the all-languages payload (4.1, 4.3); an unknown <lang> on the dictionary GET answers every key as "" (4.2); UGC rows are emitted under both their primary and their origin text (4.2); the usage response carries error and no data (4.4); the Authorization scheme word is not checked (3.2).