Runtime SDKs

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/runtime

The 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

i18n.ts
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', sends X-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, onForeground and setLocale back to a locale that is already loaded fail with network-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 to https://api.transglot.ai and set baseUrl to 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:

AddressWhat it servesCaching
GET /v1/cdn/{project}/{locale}The latest published version of the localepublic, short max-age, must-revalidate, with an ETag
GET /v1/cdn/{project}/{locale}/v{n}One published version, which never changespublic, 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 in If-None-Match, and the CDN answers 304 Not Modified with 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 null or undefined, 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 =0 are 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:

FormatShapePlurals
json_flat{ "home.title": "Welcome" }Left out of the bundle
json_nested{ "home": { "title": "Welcome" } }Left out of the bundle
laravel_jsonA flat object, like json_flatLeft out of the bundle
flutter_arbARB, with @-prefixed metadata membersYes, 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.

i18n.ts
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.

i18n.ts
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:

i18n.ts
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.

slugStatusMeaning
cdn-key-required401No key was sent.
cdn-key-invalid401The key is unknown, inactive or deleted.
not-found404The project id does not match the key's project, or the locale is not configured on the project.
not-published404The locale has never been published, or the pinned version no longer exists.
plan-feature-required403The account's plan does not include runtime delivery.
workspace-closed403The workspace is closed, so its bundles are not served.
rate-limited429Still rate limited after the client's retries.
network-errorThe request did not complete: DNS, connection, or a browser CORS block.
server-erroranyThe response was an error without a problem body. Check baseUrl.
unsupported-formatThe bundle is not one of the JSON-family delivery formats, or is not valid JSON.
invalid-pluralA 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:

ExportSignaturePurpose
parseBundle(body: string, format?: string) => ParsedBundleParse 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>) => stringThe placeholder substitution t uses.
scanIcuPlural(message: string) => IcuPlural | nullRead an ICU plural message into its variable and forms. Returns null for a plain string.
decodeIcu(raw: string) => stringDecode ICU apostrophe quoting in a message body.

Types: RuntimeClient, ClientOptions, ChangeListener, StorageAdapter, Scheduler, Entry, ParsedBundle, IcuPlural.

On this page