JavaScript runtime
@transglot/runtime: the framework-agnostic client that loads published bundles from the CDN, caches them, and translates keys.
@transglot/runtime is the client every other runtime package builds on. It fetches a locale's published bundle, keeps it in memory, revalidates it with the bundle's ETag, interpolates parameters and picks plural forms with Intl.PluralRules. It has no dependencies and runs in Node.js 20 or later, in browsers, and in React Native.
Install
npm install @transglot/runtimeThe package is ESM only.
Before you start
You need three things from the project's Settings → Developer page, in the Runtime delivery (CDN) card:
- A CDN key. Under CDN keys, enter a Name and select New CDN key. The key starts with
taicdn_and is shown once. - The project id: the number in the Latest URL shown for each locale (
https://api.transglot.ai/v1/cdn/<project id>/<locale>). - At least one published locale. A locale shows not published until you publish it. See Publish a locale.
Runtime delivery is available on the Starter plan and above.
Minimal setup
import { createClient } from '@transglot/runtime';
export const i18n = createClient({
baseUrl: 'https://api.transglot.ai',
project: 42,
cdnKey: 'taicdn_your_key',
locale: 'en',
authIn: 'query',
});
await i18n.loadLocale('en');
i18n.t('home.title'); // "Welcome"
i18n.t('home.greeting', { name: 'Ada' }); // "Hello, Ada"
i18n.t('cart.items', { count: 3 }); // "3 items"Call loadLocale (or setLocale) before you translate. Until a locale is loaded, t returns the key you passed.
In a browser
The CDN endpoint sends Access-Control-Allow-Origin: * on its bundle responses, but it does not answer CORS preflight requests. A cross-origin browser request that carries a custom header needs a preflight, so in a browser:
- Set
authIn: 'query'. The default,'header', sendsX-Transglot-Cdn-Key, which the browser will not send cross-origin. - Revalidating a locale the client already holds sends
If-None-Match, which also needs a preflight. In a browser,refresh(),refreshIntervalMs,onForegroundandsetLocaleback to a locale that is already loaded fail withnetwork-error(the background triggers log it as a dev warning). To revalidate from a browser, serve/v1/cdn/*from your own origin through a reverse proxy tohttps://api.transglot.aiand setbaseUrlto your origin, so the requests are same-origin. - Error responses carry no CORS header, so the browser hides them and every failure reaches you as
network-error.
None of this applies on a server, in React Native, or in the server-side helpers of the Next.js, SvelteKit and Astro packages.
How delivery works
The client reads two kinds of address on the CDN:
| Address | What it serves | Caching |
|---|---|---|
GET /v1/cdn/{project}/{locale} | The latest published version of the locale | public, short max-age, must-revalidate, with an ETag |
GET /v1/cdn/{project}/{locale}/v{n} | One published version, which never changes | public, one year, immutable |
The key travels in the X-Transglot-Cdn-Key header or the key query parameter. Every successful response carries:
ETag: a hash of the bundle body. When the client already holds a locale, it sends that value back inIf-None-Match, and the CDN answers304 Not Modifiedwith no body if nothing changed.X-Cdn-Version: the published version number.X-Cdn-Format: the delivery format the bundle was published in. The client uses it to parse the body.
For the full picture of versions, freshness and what a CDN key can reach, see The CDN.
Options
createClient(options: ClientOptions): RuntimeClient
Prop
Type
Client methods
Prop
Type
Interpolation
Placeholders use single braces: {name}. A name can contain letters, digits, _, . and -, and whitespace inside the braces is ignored, so { user.name } works.
- A placeholder with no matching parameter, or one whose value is
nullorundefined, stays in the output as written, so a missing value is visible. - Substitution is a single pass. A value that itself contains
{x}is inserted as plain text.
i18n.t('greeting', { name: 'Ada' }); // "Hello, Ada"
i18n.t('greeting'); // "Hello, {name}"Plurals
When an entry has plural forms, t reads params.count. If it is a number, the client asks Intl.PluralRules for the active locale which category applies (one, few, many, other and so on) and returns that form. If there is no form for the category, it falls back to other, then to the first form. If count is missing or not a number, it uses other.
i18n.t('cart.items', { count: 1 }); // "1 item"
i18n.t('cart.items', { count: 5 }); // "5 items"Plural forms only reach the client in the flutter_arb delivery format, which carries them as ICU plural messages ({count, plural, one {...} other {...}}). The flat and nested JSON formats cannot represent plurals, so plural keys are left out of bundles published in those formats. See Delivery formats.
The client's plural support is narrower than full ICU:
- The count always comes from the parameter named
count, whatever variable the message names. - Only the CLDR categories (
zero,one,two,few,many,other) are selected. Exact-value selectors such as=0are never chosen. #inside a plural form is not replaced with the number. Write{count}instead.- In the JSON formats, a value that happens to be an ICU plural string is treated as plain text:
{count}is interpolated and the rest stays as written.
The client selects plural forms and interpolates placeholders. It does not implement the rest of ICU MessageFormat: a string with a select, number or date argument needs a formatter such as @transglot/react-intl.
Missing keys and locales
t never throws. For a key that is not in the active locale's bundle, it returns the key. With dev on, it logs one warning per key, prefixed [transglot].
A published bundle holds only the keys that have a translation in that locale. The client does not fall back to another locale. If you want the source text for an untranslated key, load the source locale too and choose between the two in your own code.
Delivery formats
A bundle is served in the project's delivery format, set in Delivery format on the Runtime delivery (CDN) card. The client parses the JSON-family formats:
| Format | Shape | Plurals |
|---|---|---|
json_flat | { "home.title": "Welcome" } | Left out of the bundle |
json_nested | { "home": { "title": "Welcome" } } | Left out of the bundle |
laravel_json | A flat object, like json_flat | Left out of the bundle |
flutter_arb | ARB, with @-prefixed metadata members | Yes, as ICU plural messages |
Every format resolves to the same dotted key space, so t('home.title') reads the same key whether the bundle is flat or nested. Any other delivery format throws a RuntimeError with the slug unsupported-format.
Project default follows the format of the files you last pushed, and falls back to json_flat. If your project pushes a non-JSON format (Android XML, for example), choose a JSON-family format in Delivery format and select Save settings. The setting applies to versions you publish after the change.
Persistent cache
By default the cache lives in memory, so a fresh process starts empty. Pass a storage adapter and the client saves each locale's last good bundle, keyed per project and locale. On the next start, hydrate() serves it without a network call, and the next loadLocale revalidates it with the saved ETag.
import { createClient } from '@transglot/runtime';
export const i18n = createClient({
baseUrl: 'https://api.transglot.ai',
project: 42,
cdnKey: 'taicdn_your_key',
locale: 'en',
storage: {
get: (key) => localStorage.getItem(key),
set: (key, value) => localStorage.setItem(key, value),
},
});
if (await i18n.hydrate()) {
render(); // strings from the last session, available offline
}
await i18n.loadLocale('en');StorageAdapter has two methods, and either may return a promise:
interface StorageAdapter {
get(key: string): string | null | Promise<string | null>;
set(key: string, value: string): void | Promise<void>;
}Persistence never breaks translation: a read or write that throws, or a stored value that does not parse, is ignored.
Background refresh
refreshIntervalMs revalidates the active locale on a timer, and onForeground lets you revalidate when the app comes back into view. Both run in the background and never reject; a failure is logged as a dev warning. When a new publish lands, onChange listeners are called, the same as after setLocale.
import { createClient } from '@transglot/runtime';
export const i18n = createClient({
baseUrl: 'https://api.transglot.ai',
project: 42,
cdnKey: 'taicdn_your_key',
locale: 'en',
refreshIntervalMs: 5 * 60_000,
onForeground: (revalidate) => {
const onVisible = () => {
if (!document.hidden) revalidate();
};
document.addEventListener('visibilitychange', onVisible);
return () => document.removeEventListener('visibilitychange', onVisible);
},
});
i18n.onChange(() => render());
// On teardown:
i18n.stop();In a browser, read In a browser before relying on background refresh.
React Native
React Native has no localStorage and no CORS, so the header default works. AsyncStorage already matches the adapter shape, and AppState gives you the foreground signal:
import AsyncStorage from '@react-native-async-storage/async-storage';
import { createClient } from '@transglot/runtime';
import { AppState } from 'react-native';
export const i18n = createClient({
baseUrl: 'https://api.transglot.ai',
project: 42,
cdnKey: 'taicdn_your_key',
locale: 'en',
storage: { get: AsyncStorage.getItem, set: AsyncStorage.setItem },
refreshIntervalMs: 15 * 60_000,
onForeground: (revalidate) => {
const subscription = AppState.addEventListener('change', (state) => {
if (state === 'active') revalidate();
});
return () => subscription.remove();
},
});
await i18n.hydrate();
i18n.loadLocale('en').catch(() => {
// Offline: the hydrated bundle keeps serving.
});Errors
Network and HTTP failures reject with a RuntimeError:
class RuntimeError extends Error {
readonly slug: string; // stable identifier to branch on
readonly hint?: string; // what to do about it
readonly status?: number; // HTTP status, when the failure came from a response
}For an error response from the CDN, slug is the problem type from the response body and message is its detail.
slug | Status | Meaning |
|---|---|---|
cdn-key-required | 401 | No key was sent. |
cdn-key-invalid | 401 | The key is unknown, inactive or deleted. |
not-found | 404 | The project id does not match the key's project, or the locale is not configured on the project. |
not-published | 404 | The locale has never been published, or the pinned version no longer exists. |
plan-feature-required | 403 | The account's plan does not include runtime delivery. |
workspace-closed | 403 | The workspace is closed, so its bundles are not served. |
rate-limited | 429 | Still rate limited after the client's retries. |
network-error | The request did not complete: DNS, connection, or a browser CORS block. | |
server-error | any | The response was an error without a problem body. Check baseUrl. |
unsupported-format | The bundle is not one of the JSON-family delivery formats, or is not valid JSON. | |
invalid-plural | A plural message in a flutter_arb bundle has unbalanced braces. |
On a 429, the client waits for the Retry-After interval (at most 60 seconds) and retries, up to two times, before it rejects.
import { RuntimeError } from '@transglot/runtime';
try {
await i18n.setLocale('fr');
} catch (error) {
if (error instanceof RuntimeError && error.slug === 'not-published') {
// keep the current locale and tell the user
} else {
throw error;
}
}Lower-level exports
The package also exports the pieces the client is made of, for adapters and tests:
| Export | Signature | Purpose |
|---|---|---|
parseBundle | (body: string, format?: string) => ParsedBundle | Parse a bundle body into a flat map of entries. Without format, the shape is detected. Throws RuntimeError (unsupported-format). |
interpolate | (template: string, params?: Record<string, unknown>) => string | The placeholder substitution t uses. |
scanIcuPlural | (message: string) => IcuPlural | null | Read an ICU plural message into its variable and forms. Returns null for a plain string. |
decodeIcu | (raw: string) => string | Decode ICU apostrophe quoting in a message body. |
Types: RuntimeClient, ClientOptions, ChangeListener, StorageAdapter, Scheduler, Entry, ParsedBundle, IcuPlural.