@sonenta/realtime
Advanced tools
+64
| # Changelog — @sonenta/realtime | ||
| ## 1.3.0 | ||
| **Actionable:** only hosts running **without an API token** (the no-embedded-secret pattern: a custom | ||
| `transport`, key on your server). `token` became optional in the engine — and **realtime cannot work | ||
| without one**, because every realtime call is authenticated. | ||
| ### Fixed — realtime now says it is off, instead of 401-ing in silence | ||
| It would have sent `undefined` as the key, taken a 401, swallowed it, and looked **on** while doing | ||
| nothing. It now **warns once and stands down**. A disabled feature that announces itself beats a dead | ||
| one that does not — which is the same lesson as the missing-key telemetry fix shipping alongside it. | ||
| ## 1.1.0 | ||
| - **Default host → `sonenta.*`.** `DEFAULT_API_BASE` → `https://api.sonenta.com`; | ||
| realtime `wsUrl` example → `wss://rt.sonenta.dev/connection/websocket` (the | ||
| realtime host is `.dev`). Non-breaking (verbumia.* dual-served indefinitely). | ||
| > Renamed from `@verbumia/realtime` in 1.0.0 (Verbumia → Sonenta rebrand). | ||
| > Entries below 1.0.0 were published under the old scope. | ||
| ## 1.0.0 | ||
| - **Rebrand — `@verbumia/realtime` → `@sonenta/realtime`.** Republished under | ||
| the new `@sonenta/*` scope. Same code, same behavior. | ||
| - **Exports renamed to `sonenta*`:** `sonentaRealtime`, | ||
| `SonentaRealtimeOptions`. The previous `verbumiaRealtime` / | ||
| `VerbumiaRealtimeOptions` stay exported as **deprecated aliases**. | ||
| - peerDep retargeted to `@sonenta/react-i18next` (`>=2.0.0`). | ||
| - **No behavior change.** Default `apiBase` stays `api.verbumia.dev` and | ||
| the `wss://rt.verbumia.ca/...` example is unchanged until the backend | ||
| serves `sonenta.*` hosts (a later minor). | ||
| - The old `@verbumia/realtime` stays published and is deprecated on npm | ||
| with a pointer here — never unpublished. | ||
| ## 0.1.1 | ||
| - **Fix (packaging):** `/core` and `/react` are now resolvable by resolvers | ||
| that ignore the `exports` field (Metro / React Native — Expo SDK 52 has | ||
| `unstable_enablePackageExports` off by default). Added root re-export shims | ||
| (`core.js`, `react.js` + matching `.d.ts`) so the subpaths resolve from the | ||
| package root with no consumer-side Metro config. Also removed `baseUrl` from | ||
| this package's `tsconfig.json`: with `baseUrl` set, the new root `react.js` | ||
| shim shadowed the real `react` module during `tsc` (bare specifiers resolve | ||
| against the package root first); `paths` still maps the peer's source types | ||
| in TS 5+ without it. `exports`/`dist` unchanged; no public API change. | ||
| ## 0.1.0 | ||
| - Initial release. Realtime translation updates extracted from | ||
| `@verbumia/react-i18next` core (0.9.0 clean break). Entries: | ||
| `/react` (the `VerbumiaProvider` plugin `verbumiaRealtime({ wsUrl })`) | ||
| and `/core` (the framework-agnostic `LiveClient`, `fetchCentrifugoToken` | ||
| and `fetchVersionMeta` Centrifugo/version helpers). | ||
| - **Per-version dev-gating** (backend contract #738): the plugin reads the | ||
| version's `is_dev` + `realtime_channel` via | ||
| `GET /v1/projects/{id}/versions/{version}` and, only for a *dev* version, | ||
| mints a version-scoped translations token | ||
| (`POST /v1/auth/centrifugo-token { kind: "translations", version_uuid }`) | ||
| and subscribes to the version channel | ||
| (`translations:org_<>:project_<>:version_<>`). A published version | ||
| (`realtime_channel === null`) is a no-op with a clear warning — bundles | ||
| are served statically from the CDN. On each `translations_published` | ||
| push it calls `i18n.reload({ locale, namespace })`. Teardown cancels any | ||
| in-flight setup and disposes the client (no post-unmount connection). |
| import { | ||
| LiveClient, | ||
| fetchCentrifugoToken, | ||
| fetchVersionMeta | ||
| } from "./chunk-2EOI7ZJZ.js"; | ||
| // src/react/plugin.tsx | ||
| var DEFAULT_API_BASE = "https://api.sonenta.com"; | ||
| function warn(msg, err) { | ||
| if (typeof console !== "undefined") { | ||
| if (err !== void 0) console.warn(`@sonenta/realtime: ${msg}`, err); | ||
| else console.warn(`@sonenta/realtime: ${msg}`); | ||
| } | ||
| } | ||
| function sonentaRealtime(options) { | ||
| return { | ||
| name: "@sonenta/realtime", | ||
| setup(ctx) { | ||
| const wsUrl = options.wsUrl; | ||
| if (!wsUrl) { | ||
| warn("wsUrl is empty; skipping subscription."); | ||
| return; | ||
| } | ||
| const apiBase = (ctx.config.apiBase ?? DEFAULT_API_BASE).replace( | ||
| /\/+$/, | ||
| "" | ||
| ); | ||
| const projectUuid = ctx.config.projectUuid; | ||
| const apiToken = ctx.config.token; | ||
| const version = ctx.config.version ?? ctx.config.versionSlug ?? "main"; | ||
| const tokenEndpoint = options.tokenEndpoint ?? `${apiBase}/v1/auth/centrifugo-token`; | ||
| let cancelled = false; | ||
| let live = null; | ||
| if (!apiToken) { | ||
| warn( | ||
| "realtime is DISABLED: it needs an API token and this provider has none. (Likely the no-embedded-secret pattern: a custom `transport` with the key on your server. Translations and missing-key reporting work normally; live updates cannot, because every realtime call is authenticated.)" | ||
| ); | ||
| return; | ||
| } | ||
| void (async () => { | ||
| let meta; | ||
| try { | ||
| meta = await fetchVersionMeta( | ||
| apiBase, | ||
| projectUuid, | ||
| version, | ||
| apiToken | ||
| ); | ||
| } catch (err) { | ||
| warn(`version metadata fetch failed for '${version}'`, err); | ||
| return; | ||
| } | ||
| if (cancelled) return; | ||
| if (!meta.is_dev || !meta.realtime_channel) { | ||
| warn( | ||
| `realtime is only available on dev versions; '${version}' is published \u2014 no subscription, serving static CDN bundles.` | ||
| ); | ||
| return; | ||
| } | ||
| const channel = meta.realtime_channel; | ||
| const versionUuid = meta.uuid; | ||
| const refreshToken = async () => { | ||
| const { token: token2 } = await fetchCentrifugoToken( | ||
| tokenEndpoint, | ||
| projectUuid, | ||
| apiToken, | ||
| { kind: "translations", versionUuid } | ||
| ); | ||
| return token2; | ||
| }; | ||
| let token; | ||
| try { | ||
| const minted = await fetchCentrifugoToken( | ||
| tokenEndpoint, | ||
| projectUuid, | ||
| apiToken, | ||
| { kind: "translations", versionUuid } | ||
| ); | ||
| token = minted.token; | ||
| } catch (err) { | ||
| warn("live token mint failed", err); | ||
| return; | ||
| } | ||
| if (cancelled) return; | ||
| live = new LiveClient({ | ||
| url: wsUrl, | ||
| token, | ||
| channel, | ||
| refreshToken, | ||
| onMessage: (data) => { | ||
| if (!data || typeof data !== "object") return; | ||
| const d = data; | ||
| if (d.event !== "translations_published") return; | ||
| const lang = d.language_code; | ||
| const ns = d.namespace_slug; | ||
| if (!lang || !ns) return; | ||
| void ctx.i18n.reload({ locale: lang, namespace: ns }); | ||
| } | ||
| }); | ||
| live.connect(); | ||
| })(); | ||
| return () => { | ||
| cancelled = true; | ||
| if (live) { | ||
| live.dispose(); | ||
| live = null; | ||
| } | ||
| }; | ||
| } | ||
| }; | ||
| } | ||
| export { | ||
| sonentaRealtime | ||
| }; | ||
| //# sourceMappingURL=chunk-SWHAKZ5H.js.map |
| {"version":3,"sources":["../src/react/plugin.tsx"],"sourcesContent":["/**\n * `@sonenta/realtime` as a PLUGIN of the `@sonenta/react-i18next` provider.\n * Adds zero-deploy translation updates for DEV versions: it reads the version's\n * dev/realtime state from the backend, and only for a dev version subscribes to\n * its Centrifugo channel and calls `ctx.i18n.reload(...)` on each\n * `translations_published` push.\n *\n * Dev-gating is PER-VERSION (backend contract #738), not based on the SDK's\n * `env` flag: a version is realtime-eligible until it has a published CDN\n * release. A published version returns `realtime_channel === null` → no\n * subscription, the bundle is served statically from the CDN.\n */\nimport { LiveClient, fetchCentrifugoToken } from \"../core/live\";\nimport { fetchVersionMeta } from \"../core/version\";\nimport type {\n SonentaPlugin,\n SonentaPluginContext,\n} from \"@sonenta/react-i18next\";\n\nexport interface SonentaRealtimeOptions {\n /**\n * WebSocket URL of the Centrifugo node, e.g.\n * `wss://rt.sonenta.dev/connection/websocket`. Required — the\n * plugin can't infer it from the API base.\n */\n wsUrl: string;\n /**\n * Endpoint that mints a short-lived Centrifugo connection token. Defaults to\n * `${apiBase}/v1/auth/centrifugo-token`. Override for self-hosted deployments.\n */\n tokenEndpoint?: string;\n}\n\nconst DEFAULT_API_BASE = \"https://api.sonenta.com\";\n\nfunction warn(msg: string, err?: unknown): void {\n if (typeof console !== \"undefined\") {\n if (err !== undefined) console.warn(`@sonenta/realtime: ${msg}`, err);\n else console.warn(`@sonenta/realtime: ${msg}`);\n }\n}\n\n/**\n * Build the realtime plugin. Add it to `<SonentaProvider plugins={[...]}>`:\n *\n * ```tsx\n * plugins={[sonentaRealtime({ wsUrl: \"wss://centrifugo.example/connection/websocket\" })]}\n * ```\n */\nexport function sonentaRealtime(\n options: SonentaRealtimeOptions,\n): SonentaPlugin {\n return {\n name: \"@sonenta/realtime\",\n setup(ctx: SonentaPluginContext) {\n const wsUrl = options.wsUrl;\n if (!wsUrl) {\n warn(\"wsUrl is empty; skipping subscription.\");\n return;\n }\n\n const apiBase = (ctx.config.apiBase ?? DEFAULT_API_BASE).replace(\n /\\/+$/,\n \"\",\n );\n const projectUuid = ctx.config.projectUuid;\n const apiToken = ctx.config.token;\n // Mirrors the core's version resolution (#739): `version` is the public\n // name, `versionSlug` a deprecated alias, default \"main\".\n const version = ctx.config.version ?? ctx.config.versionSlug ?? \"main\";\n const tokenEndpoint =\n options.tokenEndpoint ?? `${apiBase}/v1/auth/centrifugo-token`;\n\n // Set by teardown; checked before opening the socket so an unmount during\n // the async setup never leaves a LiveClient connected post-unmount.\n let cancelled = false;\n let live: LiveClient | null = null;\n\n // REALTIME IS AN AUTHED FEATURE, AND `token` IS NOW OPTIONAL.\n //\n // A host can legitimately run the SDK with NO client-side token: they pass\n // their own `transport` and keep the key on their server (no secret in the\n // app bundle). That host CANNOT use realtime — every call here\n // authenticates. So say so, once, and stand down.\n //\n // The alternative is the very bug we are fixing today: send `undefined` into\n // an authed call, take the 401, swallow it, and let the feature look ON\n // while it silently does nothing. A disabled feature that ANNOUNCES itself\n // beats a dead one that does not.\n if (!apiToken) {\n warn(\n \"realtime is DISABLED: it needs an API token and this provider has none. \" +\n \"(Likely the no-embedded-secret pattern: a custom `transport` with the key \" +\n \"on your server. Translations and missing-key reporting work normally; live \" +\n \"updates cannot, because every realtime call is authenticated.)\",\n );\n return;\n }\n\n void (async () => {\n // 1. Read the version's dev/realtime state.\n let meta;\n try {\n meta = await fetchVersionMeta(\n apiBase,\n projectUuid,\n version,\n apiToken,\n );\n } catch (err) {\n warn(`version metadata fetch failed for '${version}'`, err);\n return;\n }\n if (cancelled) return;\n\n // 3. Published version → no realtime; serve static CDN bundles.\n if (!meta.is_dev || !meta.realtime_channel) {\n warn(\n `realtime is only available on dev versions; '${version}' is published — no subscription, serving static CDN bundles.`,\n );\n return;\n }\n const channel = meta.realtime_channel;\n const versionUuid = meta.uuid;\n\n // 2. Dev version → mint a version-scoped translations token + subscribe.\n const refreshToken = async (): Promise<string> => {\n const { token } = await fetchCentrifugoToken(\n tokenEndpoint,\n projectUuid,\n apiToken,\n { kind: \"translations\", versionUuid },\n );\n return token;\n };\n\n let token: string;\n try {\n const minted = await fetchCentrifugoToken(\n tokenEndpoint,\n projectUuid,\n apiToken,\n { kind: \"translations\", versionUuid },\n );\n token = minted.token;\n } catch (err) {\n warn(\"live token mint failed\", err);\n return;\n }\n if (cancelled) return; // teardown ran during the awaits — do not connect.\n\n live = new LiveClient({\n url: wsUrl,\n token,\n channel,\n refreshToken,\n onMessage: (data) => {\n if (!data || typeof data !== \"object\") return;\n const d = data as {\n event?: string;\n language_code?: string;\n namespace_slug?: string;\n };\n if (d.event !== \"translations_published\") return;\n const lang = d.language_code;\n const ns = d.namespace_slug;\n if (!lang || !ns) return;\n void ctx.i18n.reload({ locale: lang, namespace: ns });\n },\n });\n live.connect();\n })();\n\n // Teardown: cancel the in-flight setup and dispose any open client.\n return () => {\n cancelled = true;\n if (live) {\n live.dispose();\n live = null;\n }\n };\n },\n };\n}\n"],"mappings":";;;;;;;AAiCA,IAAM,mBAAmB;AAEzB,SAAS,KAAK,KAAa,KAAqB;AAC9C,MAAI,OAAO,YAAY,aAAa;AAClC,QAAI,QAAQ,OAAW,SAAQ,KAAK,sBAAsB,GAAG,IAAI,GAAG;AAAA,QAC/D,SAAQ,KAAK,sBAAsB,GAAG,EAAE;AAAA,EAC/C;AACF;AASO,SAAS,gBACd,SACe;AACf,SAAO;AAAA,IACL,MAAM;AAAA,IACN,MAAM,KAA2B;AAC/B,YAAM,QAAQ,QAAQ;AACtB,UAAI,CAAC,OAAO;AACV,aAAK,wCAAwC;AAC7C;AAAA,MACF;AAEA,YAAM,WAAW,IAAI,OAAO,WAAW,kBAAkB;AAAA,QACvD;AAAA,QACA;AAAA,MACF;AACA,YAAM,cAAc,IAAI,OAAO;AAC/B,YAAM,WAAW,IAAI,OAAO;AAG5B,YAAM,UAAU,IAAI,OAAO,WAAW,IAAI,OAAO,eAAe;AAChE,YAAM,gBACJ,QAAQ,iBAAiB,GAAG,OAAO;AAIrC,UAAI,YAAY;AAChB,UAAI,OAA0B;AAa9B,UAAI,CAAC,UAAU;AACb;AAAA,UACE;AAAA,QAIF;AACA;AAAA,MACF;AAEA,YAAM,YAAY;AAEhB,YAAI;AACJ,YAAI;AACF,iBAAO,MAAM;AAAA,YACX;AAAA,YACA;AAAA,YACA;AAAA,YACA;AAAA,UACF;AAAA,QACF,SAAS,KAAK;AACZ,eAAK,sCAAsC,OAAO,KAAK,GAAG;AAC1D;AAAA,QACF;AACA,YAAI,UAAW;AAGf,YAAI,CAAC,KAAK,UAAU,CAAC,KAAK,kBAAkB;AAC1C;AAAA,YACE,gDAAgD,OAAO;AAAA,UACzD;AACA;AAAA,QACF;AACA,cAAM,UAAU,KAAK;AACrB,cAAM,cAAc,KAAK;AAGzB,cAAM,eAAe,YAA6B;AAChD,gBAAM,EAAE,OAAAA,OAAM,IAAI,MAAM;AAAA,YACtB;AAAA,YACA;AAAA,YACA;AAAA,YACA,EAAE,MAAM,gBAAgB,YAAY;AAAA,UACtC;AACA,iBAAOA;AAAA,QACT;AAEA,YAAI;AACJ,YAAI;AACF,gBAAM,SAAS,MAAM;AAAA,YACnB;AAAA,YACA;AAAA,YACA;AAAA,YACA,EAAE,MAAM,gBAAgB,YAAY;AAAA,UACtC;AACA,kBAAQ,OAAO;AAAA,QACjB,SAAS,KAAK;AACZ,eAAK,0BAA0B,GAAG;AAClC;AAAA,QACF;AACA,YAAI,UAAW;AAEf,eAAO,IAAI,WAAW;AAAA,UACpB,KAAK;AAAA,UACL;AAAA,UACA;AAAA,UACA;AAAA,UACA,WAAW,CAAC,SAAS;AACnB,gBAAI,CAAC,QAAQ,OAAO,SAAS,SAAU;AACvC,kBAAM,IAAI;AAKV,gBAAI,EAAE,UAAU,yBAA0B;AAC1C,kBAAM,OAAO,EAAE;AACf,kBAAM,KAAK,EAAE;AACb,gBAAI,CAAC,QAAQ,CAAC,GAAI;AAClB,iBAAK,IAAI,KAAK,OAAO,EAAE,QAAQ,MAAM,WAAW,GAAG,CAAC;AAAA,UACtD;AAAA,QACF,CAAC;AACD,aAAK,QAAQ;AAAA,MACf,GAAG;AAGH,aAAO,MAAM;AACX,oBAAY;AACZ,YAAI,MAAM;AACR,eAAK,QAAQ;AACb,iBAAO;AAAA,QACT;AAAA,MACF;AAAA,IACF;AAAA,EACF;AACF;","names":["token"]} |
@@ -179,2 +179,8 @@ "use strict"; | ||
| let live = null; | ||
| if (!apiToken) { | ||
| warn( | ||
| "realtime is DISABLED: it needs an API token and this provider has none. (Likely the no-embedded-secret pattern: a custom `transport` with the key on your server. Translations and missing-key reporting work normally; live updates cannot, because every realtime call is authenticated.)" | ||
| ); | ||
| return; | ||
| } | ||
| void (async () => { | ||
@@ -181,0 +187,0 @@ let meta; |
@@ -1,1 +0,1 @@ | ||
| {"version":3,"sources":["../../src/react/index.ts","../../src/core/live.ts","../../src/core/version.ts","../../src/react/plugin.tsx"],"sourcesContent":["export { sonentaRealtime } from \"./plugin\";\nexport type { SonentaRealtimeOptions } from \"./plugin\";\n\n/* Deprecated `verbumia*` aliases (Verbumia → Sonenta rebrand, 1.0.0).\n * Kept for back-compat; removed in a future major. Prefer `sonentaRealtime`. */\n/** @deprecated Renamed to {@link sonentaRealtime} in 1.0.0. */\nexport { sonentaRealtime as verbumiaRealtime } from \"./plugin\";\nexport type {\n /** @deprecated Renamed to `SonentaRealtimeOptions` in 1.0.0. */\n SonentaRealtimeOptions as VerbumiaRealtimeOptions,\n} from \"./plugin\";\n","/**\n * Tiny Centrifugo WebSocket client tailored to the Sonenta\n * `translations:` channel. Hand-rolled (no `centrifuge-js` dep) so the SDK\n * stays under 15 KB gzipped — we only need: connect, subscribe, listen.\n *\n * Wire format reference:\n * https://centrifugal.dev/docs/transports/websocket\n *\n * Lifecycle:\n * - call connect() with a valid token\n * - server replies with {push|reply}; we wait for the connect ack\n * - subscribe(channel) — send subscribe command, wait for ack\n * - subsequent {push, channel, pub.data} are routed to onMessage\n * - reconnect with exponential backoff (capped at 30s) on close\n */\n\nexport interface LiveClientConfig {\n url: string;\n token: string;\n channel: string;\n onMessage: (data: unknown) => void;\n onStatus?: (status: \"connecting\" | \"connected\" | \"disconnected\") => void;\n /**\n * Hook called just before each connect attempt — return a fresh token\n * (used to refresh a token whose `exp` is close). Defaults to the\n * static one passed in `token`.\n */\n refreshToken?: () => Promise<string>;\n}\n\nexport class LiveClient {\n private _ws: WebSocket | null = null;\n private _id = 0;\n private _backoffMs = 1_000;\n private _disposed = false;\n private _connectAcked = false;\n\n constructor(private readonly cfg: LiveClientConfig) {}\n\n /** Open the socket and try to subscribe. Idempotent — calling twice is a no-op. */\n async connect(): Promise<void> {\n if (this._ws) return;\n if (this._disposed) return;\n this.cfg.onStatus?.(\"connecting\");\n const token = this.cfg.refreshToken ? await this.cfg.refreshToken() : this.cfg.token;\n // Centrifugo's WebSocket endpoint lives at `/connection/websocket`.\n // Be forgiving on the input — accept either a bare host\n // (`wss://centrifugo.example`) or the full path.\n let url = this.cfg.url;\n if (!url.includes(\"/connection/websocket\")) {\n url = url.replace(/\\/+$/, \"\") + \"/connection/websocket\";\n }\n const ws = new WebSocket(url);\n this._ws = ws;\n this._connectAcked = false;\n\n ws.onopen = () => {\n // Centrifugo command: connect with token\n this._send({ id: ++this._id, connect: { token } });\n };\n ws.onmessage = (evt) => this._onFrame(evt.data);\n ws.onclose = () => this._onClose();\n ws.onerror = () => {\n // Let onclose handle the reconnect.\n };\n }\n\n dispose(): void {\n this._disposed = true;\n if (this._ws) {\n try {\n this._ws.close();\n } catch {\n // ignore\n }\n this._ws = null;\n }\n this.cfg.onStatus?.(\"disconnected\");\n }\n\n // ---- internals ----\n\n private _send(msg: unknown): void {\n if (!this._ws || this._ws.readyState !== WebSocket.OPEN) return;\n try {\n this._ws.send(JSON.stringify(msg));\n } catch {\n // ignore — onclose will fire shortly\n }\n }\n\n private _onFrame(raw: unknown): void {\n let parsed: {\n id?: number;\n connect?: { client?: string };\n subscribe?: unknown;\n push?: { channel?: string; pub?: { data?: unknown }; sub?: unknown };\n } | undefined;\n try {\n parsed = JSON.parse(raw as string);\n } catch {\n return;\n }\n if (!parsed || typeof parsed !== \"object\") return;\n // Centrifugo v2 protocol pings: server periodically sends an empty\n // object `{}`. Client MUST reply with an empty `{}` to keep the\n // connection alive — without this the server force-closes after\n // ~25-30s and we lose pushes silently.\n if (Object.keys(parsed).length === 0) {\n this._send({});\n return;\n }\n if (parsed.connect && !this._connectAcked) {\n this._connectAcked = true;\n this.cfg.onStatus?.(\"connected\");\n this._backoffMs = 1_000;\n // Connection token's `channels` claim auto-subscribes us server-side,\n // so an explicit subscribe command is unnecessary. We still send it\n // for back-compat with anonymous-channel deployments where no\n // server-side subscription was created.\n this._send({\n id: ++this._id,\n subscribe: { channel: this.cfg.channel },\n });\n return;\n }\n const push = parsed.push;\n if (push && push.channel === this.cfg.channel && push.pub) {\n this.cfg.onMessage(push.pub.data);\n }\n }\n\n private _onClose(): void {\n this._ws = null;\n this._connectAcked = false;\n this.cfg.onStatus?.(\"disconnected\");\n if (this._disposed) return;\n const delay = this._backoffMs;\n this._backoffMs = Math.min(this._backoffMs * 2, 30_000);\n setTimeout(() => {\n if (!this._disposed) void this.connect();\n }, delay);\n }\n}\n\n/**\n * Fetch a fresh Centrifugo connection token from the backend\n * (`POST /v1/auth/centrifugo-token`). For version-scoped translations\n * realtime, pass `kind: \"translations\"` + the `versionUuid`; the backend\n * grants the version channel only while that version is dev (contract #738).\n * Response: `{token, channel, expires_at}`.\n */\nexport async function fetchCentrifugoToken(\n endpoint: string,\n projectUuid: string,\n authToken: string,\n opts: {\n kind?: \"translations\" | \"missing\" | \"feedback\";\n versionUuid?: string;\n } = {},\n fetchImpl: typeof fetch = fetch,\n): Promise<{ token: string; channel: string; expires_at: number }> {\n const body: Record<string, unknown> = { project_uuid: projectUuid };\n if (opts.kind) body.kind = opts.kind;\n if (opts.versionUuid) body.version_uuid = opts.versionUuid;\n const r = await fetchImpl(endpoint, {\n method: \"POST\",\n headers: {\n \"Content-Type\": \"application/json\",\n Authorization: `ApiKey ${authToken}`,\n },\n body: JSON.stringify(body),\n });\n if (!r.ok) {\n throw new Error(`centrifugo-token endpoint ${r.status}: ${await r.text()}`);\n }\n return (await r.json()) as { token: string; channel: string; expires_at: number };\n}\n","/**\n * Version metadata used to gate dev-only realtime (backend contract #738).\n * The SDK-facing surface is intentionally just `is_dev` + `realtime_channel`\n * (plus `uuid`, needed for the version-scoped token); the rest is informative.\n */\nexport interface VersionMeta {\n uuid: string;\n project_uuid: string;\n name?: string;\n slug?: string;\n is_production?: boolean;\n /** true while the version has no published CDN release (= realtime-eligible). */\n is_dev: boolean;\n /** Centrifugo channel for dev versions; `null` once the version is published. */\n realtime_channel: string | null;\n}\n\n/**\n * `GET /v1/projects/{projectUuid}/versions/{idOrSlug}` — resolve a version by\n * uuid OR slug and read its dev/realtime state. Auth: project viewer (API key).\n */\nexport async function fetchVersionMeta(\n apiBase: string,\n projectUuid: string,\n idOrSlug: string,\n authToken: string,\n fetchImpl: typeof fetch = fetch,\n): Promise<VersionMeta> {\n const url = `${apiBase.replace(/\\/+$/, \"\")}/v1/projects/${projectUuid}/versions/${encodeURIComponent(\n idOrSlug,\n )}`;\n const r = await fetchImpl(url, {\n method: \"GET\",\n headers: { Authorization: `ApiKey ${authToken}` },\n credentials: \"omit\",\n });\n if (!r.ok) {\n throw new Error(`version metadata endpoint ${r.status}: ${await r.text()}`);\n }\n return (await r.json()) as VersionMeta;\n}\n","/**\n * `@sonenta/realtime` as a PLUGIN of the `@sonenta/react-i18next` provider.\n * Adds zero-deploy translation updates for DEV versions: it reads the version's\n * dev/realtime state from the backend, and only for a dev version subscribes to\n * its Centrifugo channel and calls `ctx.i18n.reload(...)` on each\n * `translations_published` push.\n *\n * Dev-gating is PER-VERSION (backend contract #738), not based on the SDK's\n * `env` flag: a version is realtime-eligible until it has a published CDN\n * release. A published version returns `realtime_channel === null` → no\n * subscription, the bundle is served statically from the CDN.\n */\nimport { LiveClient, fetchCentrifugoToken } from \"../core/live\";\nimport { fetchVersionMeta } from \"../core/version\";\nimport type {\n SonentaPlugin,\n SonentaPluginContext,\n} from \"@sonenta/react-i18next\";\n\nexport interface SonentaRealtimeOptions {\n /**\n * WebSocket URL of the Centrifugo node, e.g.\n * `wss://rt.sonenta.dev/connection/websocket`. Required — the\n * plugin can't infer it from the API base.\n */\n wsUrl: string;\n /**\n * Endpoint that mints a short-lived Centrifugo connection token. Defaults to\n * `${apiBase}/v1/auth/centrifugo-token`. Override for self-hosted deployments.\n */\n tokenEndpoint?: string;\n}\n\nconst DEFAULT_API_BASE = \"https://api.sonenta.com\";\n\nfunction warn(msg: string, err?: unknown): void {\n if (typeof console !== \"undefined\") {\n if (err !== undefined) console.warn(`@sonenta/realtime: ${msg}`, err);\n else console.warn(`@sonenta/realtime: ${msg}`);\n }\n}\n\n/**\n * Build the realtime plugin. Add it to `<SonentaProvider plugins={[...]}>`:\n *\n * ```tsx\n * plugins={[sonentaRealtime({ wsUrl: \"wss://centrifugo.example/connection/websocket\" })]}\n * ```\n */\nexport function sonentaRealtime(\n options: SonentaRealtimeOptions,\n): SonentaPlugin {\n return {\n name: \"@sonenta/realtime\",\n setup(ctx: SonentaPluginContext) {\n const wsUrl = options.wsUrl;\n if (!wsUrl) {\n warn(\"wsUrl is empty; skipping subscription.\");\n return;\n }\n\n const apiBase = (ctx.config.apiBase ?? DEFAULT_API_BASE).replace(\n /\\/+$/,\n \"\",\n );\n const projectUuid = ctx.config.projectUuid;\n const apiToken = ctx.config.token;\n // Mirrors the core's version resolution (#739): `version` is the public\n // name, `versionSlug` a deprecated alias, default \"main\".\n const version = ctx.config.version ?? ctx.config.versionSlug ?? \"main\";\n const tokenEndpoint =\n options.tokenEndpoint ?? `${apiBase}/v1/auth/centrifugo-token`;\n\n // Set by teardown; checked before opening the socket so an unmount during\n // the async setup never leaves a LiveClient connected post-unmount.\n let cancelled = false;\n let live: LiveClient | null = null;\n\n void (async () => {\n // 1. Read the version's dev/realtime state.\n let meta;\n try {\n meta = await fetchVersionMeta(\n apiBase,\n projectUuid,\n version,\n apiToken,\n );\n } catch (err) {\n warn(`version metadata fetch failed for '${version}'`, err);\n return;\n }\n if (cancelled) return;\n\n // 3. Published version → no realtime; serve static CDN bundles.\n if (!meta.is_dev || !meta.realtime_channel) {\n warn(\n `realtime is only available on dev versions; '${version}' is published — no subscription, serving static CDN bundles.`,\n );\n return;\n }\n const channel = meta.realtime_channel;\n const versionUuid = meta.uuid;\n\n // 2. Dev version → mint a version-scoped translations token + subscribe.\n const refreshToken = async (): Promise<string> => {\n const { token } = await fetchCentrifugoToken(\n tokenEndpoint,\n projectUuid,\n apiToken,\n { kind: \"translations\", versionUuid },\n );\n return token;\n };\n\n let token: string;\n try {\n const minted = await fetchCentrifugoToken(\n tokenEndpoint,\n projectUuid,\n apiToken,\n { kind: \"translations\", versionUuid },\n );\n token = minted.token;\n } catch (err) {\n warn(\"live token mint failed\", err);\n return;\n }\n if (cancelled) return; // teardown ran during the awaits — do not connect.\n\n live = new LiveClient({\n url: wsUrl,\n token,\n channel,\n refreshToken,\n onMessage: (data) => {\n if (!data || typeof data !== \"object\") return;\n const d = data as {\n event?: string;\n language_code?: string;\n namespace_slug?: string;\n };\n if (d.event !== \"translations_published\") return;\n const lang = d.language_code;\n const ns = d.namespace_slug;\n if (!lang || !ns) return;\n void ctx.i18n.reload({ locale: lang, namespace: ns });\n },\n });\n live.connect();\n })();\n\n // Teardown: cancel the in-flight setup and dispose any open client.\n return () => {\n cancelled = true;\n if (live) {\n live.dispose();\n live = null;\n }\n };\n },\n };\n}\n"],"mappings":"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","names":["token"]} | ||
| {"version":3,"sources":["../../src/react/index.ts","../../src/core/live.ts","../../src/core/version.ts","../../src/react/plugin.tsx"],"sourcesContent":["export { sonentaRealtime } from \"./plugin\";\nexport type { SonentaRealtimeOptions } from \"./plugin\";\n\n/* Deprecated `verbumia*` aliases (Verbumia → Sonenta rebrand, 1.0.0).\n * Kept for back-compat; removed in a future major. Prefer `sonentaRealtime`. */\n/** @deprecated Renamed to {@link sonentaRealtime} in 1.0.0. */\nexport { sonentaRealtime as verbumiaRealtime } from \"./plugin\";\nexport type {\n /** @deprecated Renamed to `SonentaRealtimeOptions` in 1.0.0. */\n SonentaRealtimeOptions as VerbumiaRealtimeOptions,\n} from \"./plugin\";\n","/**\n * Tiny Centrifugo WebSocket client tailored to the Sonenta\n * `translations:` channel. Hand-rolled (no `centrifuge-js` dep) so the SDK\n * stays under 15 KB gzipped — we only need: connect, subscribe, listen.\n *\n * Wire format reference:\n * https://centrifugal.dev/docs/transports/websocket\n *\n * Lifecycle:\n * - call connect() with a valid token\n * - server replies with {push|reply}; we wait for the connect ack\n * - subscribe(channel) — send subscribe command, wait for ack\n * - subsequent {push, channel, pub.data} are routed to onMessage\n * - reconnect with exponential backoff (capped at 30s) on close\n */\n\nexport interface LiveClientConfig {\n url: string;\n token: string;\n channel: string;\n onMessage: (data: unknown) => void;\n onStatus?: (status: \"connecting\" | \"connected\" | \"disconnected\") => void;\n /**\n * Hook called just before each connect attempt — return a fresh token\n * (used to refresh a token whose `exp` is close). Defaults to the\n * static one passed in `token`.\n */\n refreshToken?: () => Promise<string>;\n}\n\nexport class LiveClient {\n private _ws: WebSocket | null = null;\n private _id = 0;\n private _backoffMs = 1_000;\n private _disposed = false;\n private _connectAcked = false;\n\n constructor(private readonly cfg: LiveClientConfig) {}\n\n /** Open the socket and try to subscribe. Idempotent — calling twice is a no-op. */\n async connect(): Promise<void> {\n if (this._ws) return;\n if (this._disposed) return;\n this.cfg.onStatus?.(\"connecting\");\n const token = this.cfg.refreshToken ? await this.cfg.refreshToken() : this.cfg.token;\n // Centrifugo's WebSocket endpoint lives at `/connection/websocket`.\n // Be forgiving on the input — accept either a bare host\n // (`wss://centrifugo.example`) or the full path.\n let url = this.cfg.url;\n if (!url.includes(\"/connection/websocket\")) {\n url = url.replace(/\\/+$/, \"\") + \"/connection/websocket\";\n }\n const ws = new WebSocket(url);\n this._ws = ws;\n this._connectAcked = false;\n\n ws.onopen = () => {\n // Centrifugo command: connect with token\n this._send({ id: ++this._id, connect: { token } });\n };\n ws.onmessage = (evt) => this._onFrame(evt.data);\n ws.onclose = () => this._onClose();\n ws.onerror = () => {\n // Let onclose handle the reconnect.\n };\n }\n\n dispose(): void {\n this._disposed = true;\n if (this._ws) {\n try {\n this._ws.close();\n } catch {\n // ignore\n }\n this._ws = null;\n }\n this.cfg.onStatus?.(\"disconnected\");\n }\n\n // ---- internals ----\n\n private _send(msg: unknown): void {\n if (!this._ws || this._ws.readyState !== WebSocket.OPEN) return;\n try {\n this._ws.send(JSON.stringify(msg));\n } catch {\n // ignore — onclose will fire shortly\n }\n }\n\n private _onFrame(raw: unknown): void {\n let parsed: {\n id?: number;\n connect?: { client?: string };\n subscribe?: unknown;\n push?: { channel?: string; pub?: { data?: unknown }; sub?: unknown };\n } | undefined;\n try {\n parsed = JSON.parse(raw as string);\n } catch {\n return;\n }\n if (!parsed || typeof parsed !== \"object\") return;\n // Centrifugo v2 protocol pings: server periodically sends an empty\n // object `{}`. Client MUST reply with an empty `{}` to keep the\n // connection alive — without this the server force-closes after\n // ~25-30s and we lose pushes silently.\n if (Object.keys(parsed).length === 0) {\n this._send({});\n return;\n }\n if (parsed.connect && !this._connectAcked) {\n this._connectAcked = true;\n this.cfg.onStatus?.(\"connected\");\n this._backoffMs = 1_000;\n // Connection token's `channels` claim auto-subscribes us server-side,\n // so an explicit subscribe command is unnecessary. We still send it\n // for back-compat with anonymous-channel deployments where no\n // server-side subscription was created.\n this._send({\n id: ++this._id,\n subscribe: { channel: this.cfg.channel },\n });\n return;\n }\n const push = parsed.push;\n if (push && push.channel === this.cfg.channel && push.pub) {\n this.cfg.onMessage(push.pub.data);\n }\n }\n\n private _onClose(): void {\n this._ws = null;\n this._connectAcked = false;\n this.cfg.onStatus?.(\"disconnected\");\n if (this._disposed) return;\n const delay = this._backoffMs;\n this._backoffMs = Math.min(this._backoffMs * 2, 30_000);\n setTimeout(() => {\n if (!this._disposed) void this.connect();\n }, delay);\n }\n}\n\n/**\n * Fetch a fresh Centrifugo connection token from the backend\n * (`POST /v1/auth/centrifugo-token`). For version-scoped translations\n * realtime, pass `kind: \"translations\"` + the `versionUuid`; the backend\n * grants the version channel only while that version is dev (contract #738).\n * Response: `{token, channel, expires_at}`.\n */\nexport async function fetchCentrifugoToken(\n endpoint: string,\n projectUuid: string,\n authToken: string,\n opts: {\n kind?: \"translations\" | \"missing\" | \"feedback\";\n versionUuid?: string;\n } = {},\n fetchImpl: typeof fetch = fetch,\n): Promise<{ token: string; channel: string; expires_at: number }> {\n const body: Record<string, unknown> = { project_uuid: projectUuid };\n if (opts.kind) body.kind = opts.kind;\n if (opts.versionUuid) body.version_uuid = opts.versionUuid;\n const r = await fetchImpl(endpoint, {\n method: \"POST\",\n headers: {\n \"Content-Type\": \"application/json\",\n Authorization: `ApiKey ${authToken}`,\n },\n body: JSON.stringify(body),\n });\n if (!r.ok) {\n throw new Error(`centrifugo-token endpoint ${r.status}: ${await r.text()}`);\n }\n return (await r.json()) as { token: string; channel: string; expires_at: number };\n}\n","/**\n * Version metadata used to gate dev-only realtime (backend contract #738).\n * The SDK-facing surface is intentionally just `is_dev` + `realtime_channel`\n * (plus `uuid`, needed for the version-scoped token); the rest is informative.\n */\nexport interface VersionMeta {\n uuid: string;\n project_uuid: string;\n name?: string;\n slug?: string;\n is_production?: boolean;\n /** true while the version has no published CDN release (= realtime-eligible). */\n is_dev: boolean;\n /** Centrifugo channel for dev versions; `null` once the version is published. */\n realtime_channel: string | null;\n}\n\n/**\n * `GET /v1/projects/{projectUuid}/versions/{idOrSlug}` — resolve a version by\n * uuid OR slug and read its dev/realtime state. Auth: project viewer (API key).\n */\nexport async function fetchVersionMeta(\n apiBase: string,\n projectUuid: string,\n idOrSlug: string,\n authToken: string,\n fetchImpl: typeof fetch = fetch,\n): Promise<VersionMeta> {\n const url = `${apiBase.replace(/\\/+$/, \"\")}/v1/projects/${projectUuid}/versions/${encodeURIComponent(\n idOrSlug,\n )}`;\n const r = await fetchImpl(url, {\n method: \"GET\",\n headers: { Authorization: `ApiKey ${authToken}` },\n credentials: \"omit\",\n });\n if (!r.ok) {\n throw new Error(`version metadata endpoint ${r.status}: ${await r.text()}`);\n }\n return (await r.json()) as VersionMeta;\n}\n","/**\n * `@sonenta/realtime` as a PLUGIN of the `@sonenta/react-i18next` provider.\n * Adds zero-deploy translation updates for DEV versions: it reads the version's\n * dev/realtime state from the backend, and only for a dev version subscribes to\n * its Centrifugo channel and calls `ctx.i18n.reload(...)` on each\n * `translations_published` push.\n *\n * Dev-gating is PER-VERSION (backend contract #738), not based on the SDK's\n * `env` flag: a version is realtime-eligible until it has a published CDN\n * release. A published version returns `realtime_channel === null` → no\n * subscription, the bundle is served statically from the CDN.\n */\nimport { LiveClient, fetchCentrifugoToken } from \"../core/live\";\nimport { fetchVersionMeta } from \"../core/version\";\nimport type {\n SonentaPlugin,\n SonentaPluginContext,\n} from \"@sonenta/react-i18next\";\n\nexport interface SonentaRealtimeOptions {\n /**\n * WebSocket URL of the Centrifugo node, e.g.\n * `wss://rt.sonenta.dev/connection/websocket`. Required — the\n * plugin can't infer it from the API base.\n */\n wsUrl: string;\n /**\n * Endpoint that mints a short-lived Centrifugo connection token. Defaults to\n * `${apiBase}/v1/auth/centrifugo-token`. Override for self-hosted deployments.\n */\n tokenEndpoint?: string;\n}\n\nconst DEFAULT_API_BASE = \"https://api.sonenta.com\";\n\nfunction warn(msg: string, err?: unknown): void {\n if (typeof console !== \"undefined\") {\n if (err !== undefined) console.warn(`@sonenta/realtime: ${msg}`, err);\n else console.warn(`@sonenta/realtime: ${msg}`);\n }\n}\n\n/**\n * Build the realtime plugin. Add it to `<SonentaProvider plugins={[...]}>`:\n *\n * ```tsx\n * plugins={[sonentaRealtime({ wsUrl: \"wss://centrifugo.example/connection/websocket\" })]}\n * ```\n */\nexport function sonentaRealtime(\n options: SonentaRealtimeOptions,\n): SonentaPlugin {\n return {\n name: \"@sonenta/realtime\",\n setup(ctx: SonentaPluginContext) {\n const wsUrl = options.wsUrl;\n if (!wsUrl) {\n warn(\"wsUrl is empty; skipping subscription.\");\n return;\n }\n\n const apiBase = (ctx.config.apiBase ?? DEFAULT_API_BASE).replace(\n /\\/+$/,\n \"\",\n );\n const projectUuid = ctx.config.projectUuid;\n const apiToken = ctx.config.token;\n // Mirrors the core's version resolution (#739): `version` is the public\n // name, `versionSlug` a deprecated alias, default \"main\".\n const version = ctx.config.version ?? ctx.config.versionSlug ?? \"main\";\n const tokenEndpoint =\n options.tokenEndpoint ?? `${apiBase}/v1/auth/centrifugo-token`;\n\n // Set by teardown; checked before opening the socket so an unmount during\n // the async setup never leaves a LiveClient connected post-unmount.\n let cancelled = false;\n let live: LiveClient | null = null;\n\n // REALTIME IS AN AUTHED FEATURE, AND `token` IS NOW OPTIONAL.\n //\n // A host can legitimately run the SDK with NO client-side token: they pass\n // their own `transport` and keep the key on their server (no secret in the\n // app bundle). That host CANNOT use realtime — every call here\n // authenticates. So say so, once, and stand down.\n //\n // The alternative is the very bug we are fixing today: send `undefined` into\n // an authed call, take the 401, swallow it, and let the feature look ON\n // while it silently does nothing. A disabled feature that ANNOUNCES itself\n // beats a dead one that does not.\n if (!apiToken) {\n warn(\n \"realtime is DISABLED: it needs an API token and this provider has none. \" +\n \"(Likely the no-embedded-secret pattern: a custom `transport` with the key \" +\n \"on your server. Translations and missing-key reporting work normally; live \" +\n \"updates cannot, because every realtime call is authenticated.)\",\n );\n return;\n }\n\n void (async () => {\n // 1. Read the version's dev/realtime state.\n let meta;\n try {\n meta = await fetchVersionMeta(\n apiBase,\n projectUuid,\n version,\n apiToken,\n );\n } catch (err) {\n warn(`version metadata fetch failed for '${version}'`, err);\n return;\n }\n if (cancelled) return;\n\n // 3. Published version → no realtime; serve static CDN bundles.\n if (!meta.is_dev || !meta.realtime_channel) {\n warn(\n `realtime is only available on dev versions; '${version}' is published — no subscription, serving static CDN bundles.`,\n );\n return;\n }\n const channel = meta.realtime_channel;\n const versionUuid = meta.uuid;\n\n // 2. Dev version → mint a version-scoped translations token + subscribe.\n const refreshToken = async (): Promise<string> => {\n const { token } = await fetchCentrifugoToken(\n tokenEndpoint,\n projectUuid,\n apiToken,\n { kind: \"translations\", versionUuid },\n );\n return token;\n };\n\n let token: string;\n try {\n const minted = await fetchCentrifugoToken(\n tokenEndpoint,\n projectUuid,\n apiToken,\n { kind: \"translations\", versionUuid },\n );\n token = minted.token;\n } catch (err) {\n warn(\"live token mint failed\", err);\n return;\n }\n if (cancelled) return; // teardown ran during the awaits — do not connect.\n\n live = new LiveClient({\n url: wsUrl,\n token,\n channel,\n refreshToken,\n onMessage: (data) => {\n if (!data || typeof data !== \"object\") return;\n const d = data as {\n event?: string;\n language_code?: string;\n namespace_slug?: string;\n };\n if (d.event !== \"translations_published\") return;\n const lang = d.language_code;\n const ns = d.namespace_slug;\n if (!lang || !ns) return;\n void ctx.i18n.reload({ locale: lang, namespace: ns });\n },\n });\n live.connect();\n })();\n\n // Teardown: cancel the in-flight setup and dispose any open client.\n return () => {\n cancelled = true;\n if (live) {\n live.dispose();\n live = null;\n }\n };\n },\n };\n}\n"],"mappings":"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","names":["token"]} |
| import { | ||
| sonentaRealtime | ||
| } from "../chunk-4XBXVJX2.js"; | ||
| } from "../chunk-SWHAKZ5H.js"; | ||
| import "../chunk-2EOI7ZJZ.js"; | ||
@@ -5,0 +5,0 @@ export { |
@@ -178,2 +178,8 @@ "use strict"; | ||
| let live = null; | ||
| if (!apiToken) { | ||
| warn( | ||
| "realtime is DISABLED: it needs an API token and this provider has none. (Likely the no-embedded-secret pattern: a custom `transport` with the key on your server. Translations and missing-key reporting work normally; live updates cannot, because every realtime call is authenticated.)" | ||
| ); | ||
| return; | ||
| } | ||
| void (async () => { | ||
@@ -180,0 +186,0 @@ let meta; |
@@ -1,1 +0,1 @@ | ||
| {"version":3,"sources":["../../src/svelte/index.ts","../../src/core/live.ts","../../src/core/version.ts","../../src/react/plugin.tsx"],"sourcesContent":["/**\n * `@sonenta/realtime/svelte` — the realtime missing-key/live-update plugin for\n * the Sonenta svelte binding. The plugin is framework-agnostic (it hooks the\n * engine via the provider-plugin `setup()` lifecycle), so this is the same\n * `sonentaRealtime` plugin as `/react`; pass it to the binding's `plugins`:\n *\n * import { createSonentaI18n } from \"@sonenta/svelte-i18n\";\n * import { sonentaRealtime } from \"@sonenta/realtime/svelte\";\n * createSonentaI18n({ ...config, plugins: [sonentaRealtime({ wsUrl })] });\n */\nexport { sonentaRealtime } from \"../react/plugin\";\nexport type { SonentaRealtimeOptions } from \"../react/plugin\";\n","/**\n * Tiny Centrifugo WebSocket client tailored to the Sonenta\n * `translations:` channel. Hand-rolled (no `centrifuge-js` dep) so the SDK\n * stays under 15 KB gzipped — we only need: connect, subscribe, listen.\n *\n * Wire format reference:\n * https://centrifugal.dev/docs/transports/websocket\n *\n * Lifecycle:\n * - call connect() with a valid token\n * - server replies with {push|reply}; we wait for the connect ack\n * - subscribe(channel) — send subscribe command, wait for ack\n * - subsequent {push, channel, pub.data} are routed to onMessage\n * - reconnect with exponential backoff (capped at 30s) on close\n */\n\nexport interface LiveClientConfig {\n url: string;\n token: string;\n channel: string;\n onMessage: (data: unknown) => void;\n onStatus?: (status: \"connecting\" | \"connected\" | \"disconnected\") => void;\n /**\n * Hook called just before each connect attempt — return a fresh token\n * (used to refresh a token whose `exp` is close). Defaults to the\n * static one passed in `token`.\n */\n refreshToken?: () => Promise<string>;\n}\n\nexport class LiveClient {\n private _ws: WebSocket | null = null;\n private _id = 0;\n private _backoffMs = 1_000;\n private _disposed = false;\n private _connectAcked = false;\n\n constructor(private readonly cfg: LiveClientConfig) {}\n\n /** Open the socket and try to subscribe. Idempotent — calling twice is a no-op. */\n async connect(): Promise<void> {\n if (this._ws) return;\n if (this._disposed) return;\n this.cfg.onStatus?.(\"connecting\");\n const token = this.cfg.refreshToken ? await this.cfg.refreshToken() : this.cfg.token;\n // Centrifugo's WebSocket endpoint lives at `/connection/websocket`.\n // Be forgiving on the input — accept either a bare host\n // (`wss://centrifugo.example`) or the full path.\n let url = this.cfg.url;\n if (!url.includes(\"/connection/websocket\")) {\n url = url.replace(/\\/+$/, \"\") + \"/connection/websocket\";\n }\n const ws = new WebSocket(url);\n this._ws = ws;\n this._connectAcked = false;\n\n ws.onopen = () => {\n // Centrifugo command: connect with token\n this._send({ id: ++this._id, connect: { token } });\n };\n ws.onmessage = (evt) => this._onFrame(evt.data);\n ws.onclose = () => this._onClose();\n ws.onerror = () => {\n // Let onclose handle the reconnect.\n };\n }\n\n dispose(): void {\n this._disposed = true;\n if (this._ws) {\n try {\n this._ws.close();\n } catch {\n // ignore\n }\n this._ws = null;\n }\n this.cfg.onStatus?.(\"disconnected\");\n }\n\n // ---- internals ----\n\n private _send(msg: unknown): void {\n if (!this._ws || this._ws.readyState !== WebSocket.OPEN) return;\n try {\n this._ws.send(JSON.stringify(msg));\n } catch {\n // ignore — onclose will fire shortly\n }\n }\n\n private _onFrame(raw: unknown): void {\n let parsed: {\n id?: number;\n connect?: { client?: string };\n subscribe?: unknown;\n push?: { channel?: string; pub?: { data?: unknown }; sub?: unknown };\n } | undefined;\n try {\n parsed = JSON.parse(raw as string);\n } catch {\n return;\n }\n if (!parsed || typeof parsed !== \"object\") return;\n // Centrifugo v2 protocol pings: server periodically sends an empty\n // object `{}`. Client MUST reply with an empty `{}` to keep the\n // connection alive — without this the server force-closes after\n // ~25-30s and we lose pushes silently.\n if (Object.keys(parsed).length === 0) {\n this._send({});\n return;\n }\n if (parsed.connect && !this._connectAcked) {\n this._connectAcked = true;\n this.cfg.onStatus?.(\"connected\");\n this._backoffMs = 1_000;\n // Connection token's `channels` claim auto-subscribes us server-side,\n // so an explicit subscribe command is unnecessary. We still send it\n // for back-compat with anonymous-channel deployments where no\n // server-side subscription was created.\n this._send({\n id: ++this._id,\n subscribe: { channel: this.cfg.channel },\n });\n return;\n }\n const push = parsed.push;\n if (push && push.channel === this.cfg.channel && push.pub) {\n this.cfg.onMessage(push.pub.data);\n }\n }\n\n private _onClose(): void {\n this._ws = null;\n this._connectAcked = false;\n this.cfg.onStatus?.(\"disconnected\");\n if (this._disposed) return;\n const delay = this._backoffMs;\n this._backoffMs = Math.min(this._backoffMs * 2, 30_000);\n setTimeout(() => {\n if (!this._disposed) void this.connect();\n }, delay);\n }\n}\n\n/**\n * Fetch a fresh Centrifugo connection token from the backend\n * (`POST /v1/auth/centrifugo-token`). For version-scoped translations\n * realtime, pass `kind: \"translations\"` + the `versionUuid`; the backend\n * grants the version channel only while that version is dev (contract #738).\n * Response: `{token, channel, expires_at}`.\n */\nexport async function fetchCentrifugoToken(\n endpoint: string,\n projectUuid: string,\n authToken: string,\n opts: {\n kind?: \"translations\" | \"missing\" | \"feedback\";\n versionUuid?: string;\n } = {},\n fetchImpl: typeof fetch = fetch,\n): Promise<{ token: string; channel: string; expires_at: number }> {\n const body: Record<string, unknown> = { project_uuid: projectUuid };\n if (opts.kind) body.kind = opts.kind;\n if (opts.versionUuid) body.version_uuid = opts.versionUuid;\n const r = await fetchImpl(endpoint, {\n method: \"POST\",\n headers: {\n \"Content-Type\": \"application/json\",\n Authorization: `ApiKey ${authToken}`,\n },\n body: JSON.stringify(body),\n });\n if (!r.ok) {\n throw new Error(`centrifugo-token endpoint ${r.status}: ${await r.text()}`);\n }\n return (await r.json()) as { token: string; channel: string; expires_at: number };\n}\n","/**\n * Version metadata used to gate dev-only realtime (backend contract #738).\n * The SDK-facing surface is intentionally just `is_dev` + `realtime_channel`\n * (plus `uuid`, needed for the version-scoped token); the rest is informative.\n */\nexport interface VersionMeta {\n uuid: string;\n project_uuid: string;\n name?: string;\n slug?: string;\n is_production?: boolean;\n /** true while the version has no published CDN release (= realtime-eligible). */\n is_dev: boolean;\n /** Centrifugo channel for dev versions; `null` once the version is published. */\n realtime_channel: string | null;\n}\n\n/**\n * `GET /v1/projects/{projectUuid}/versions/{idOrSlug}` — resolve a version by\n * uuid OR slug and read its dev/realtime state. Auth: project viewer (API key).\n */\nexport async function fetchVersionMeta(\n apiBase: string,\n projectUuid: string,\n idOrSlug: string,\n authToken: string,\n fetchImpl: typeof fetch = fetch,\n): Promise<VersionMeta> {\n const url = `${apiBase.replace(/\\/+$/, \"\")}/v1/projects/${projectUuid}/versions/${encodeURIComponent(\n idOrSlug,\n )}`;\n const r = await fetchImpl(url, {\n method: \"GET\",\n headers: { Authorization: `ApiKey ${authToken}` },\n credentials: \"omit\",\n });\n if (!r.ok) {\n throw new Error(`version metadata endpoint ${r.status}: ${await r.text()}`);\n }\n return (await r.json()) as VersionMeta;\n}\n","/**\n * `@sonenta/realtime` as a PLUGIN of the `@sonenta/react-i18next` provider.\n * Adds zero-deploy translation updates for DEV versions: it reads the version's\n * dev/realtime state from the backend, and only for a dev version subscribes to\n * its Centrifugo channel and calls `ctx.i18n.reload(...)` on each\n * `translations_published` push.\n *\n * Dev-gating is PER-VERSION (backend contract #738), not based on the SDK's\n * `env` flag: a version is realtime-eligible until it has a published CDN\n * release. A published version returns `realtime_channel === null` → no\n * subscription, the bundle is served statically from the CDN.\n */\nimport { LiveClient, fetchCentrifugoToken } from \"../core/live\";\nimport { fetchVersionMeta } from \"../core/version\";\nimport type {\n SonentaPlugin,\n SonentaPluginContext,\n} from \"@sonenta/react-i18next\";\n\nexport interface SonentaRealtimeOptions {\n /**\n * WebSocket URL of the Centrifugo node, e.g.\n * `wss://rt.sonenta.dev/connection/websocket`. Required — the\n * plugin can't infer it from the API base.\n */\n wsUrl: string;\n /**\n * Endpoint that mints a short-lived Centrifugo connection token. Defaults to\n * `${apiBase}/v1/auth/centrifugo-token`. Override for self-hosted deployments.\n */\n tokenEndpoint?: string;\n}\n\nconst DEFAULT_API_BASE = \"https://api.sonenta.com\";\n\nfunction warn(msg: string, err?: unknown): void {\n if (typeof console !== \"undefined\") {\n if (err !== undefined) console.warn(`@sonenta/realtime: ${msg}`, err);\n else console.warn(`@sonenta/realtime: ${msg}`);\n }\n}\n\n/**\n * Build the realtime plugin. Add it to `<SonentaProvider plugins={[...]}>`:\n *\n * ```tsx\n * plugins={[sonentaRealtime({ wsUrl: \"wss://centrifugo.example/connection/websocket\" })]}\n * ```\n */\nexport function sonentaRealtime(\n options: SonentaRealtimeOptions,\n): SonentaPlugin {\n return {\n name: \"@sonenta/realtime\",\n setup(ctx: SonentaPluginContext) {\n const wsUrl = options.wsUrl;\n if (!wsUrl) {\n warn(\"wsUrl is empty; skipping subscription.\");\n return;\n }\n\n const apiBase = (ctx.config.apiBase ?? DEFAULT_API_BASE).replace(\n /\\/+$/,\n \"\",\n );\n const projectUuid = ctx.config.projectUuid;\n const apiToken = ctx.config.token;\n // Mirrors the core's version resolution (#739): `version` is the public\n // name, `versionSlug` a deprecated alias, default \"main\".\n const version = ctx.config.version ?? ctx.config.versionSlug ?? \"main\";\n const tokenEndpoint =\n options.tokenEndpoint ?? `${apiBase}/v1/auth/centrifugo-token`;\n\n // Set by teardown; checked before opening the socket so an unmount during\n // the async setup never leaves a LiveClient connected post-unmount.\n let cancelled = false;\n let live: LiveClient | null = null;\n\n void (async () => {\n // 1. Read the version's dev/realtime state.\n let meta;\n try {\n meta = await fetchVersionMeta(\n apiBase,\n projectUuid,\n version,\n apiToken,\n );\n } catch (err) {\n warn(`version metadata fetch failed for '${version}'`, err);\n return;\n }\n if (cancelled) return;\n\n // 3. Published version → no realtime; serve static CDN bundles.\n if (!meta.is_dev || !meta.realtime_channel) {\n warn(\n `realtime is only available on dev versions; '${version}' is published — no subscription, serving static CDN bundles.`,\n );\n return;\n }\n const channel = meta.realtime_channel;\n const versionUuid = meta.uuid;\n\n // 2. Dev version → mint a version-scoped translations token + subscribe.\n const refreshToken = async (): Promise<string> => {\n const { token } = await fetchCentrifugoToken(\n tokenEndpoint,\n projectUuid,\n apiToken,\n { kind: \"translations\", versionUuid },\n );\n return token;\n };\n\n let token: string;\n try {\n const minted = await fetchCentrifugoToken(\n tokenEndpoint,\n projectUuid,\n apiToken,\n { kind: \"translations\", versionUuid },\n );\n token = minted.token;\n } catch (err) {\n warn(\"live token mint failed\", err);\n return;\n }\n if (cancelled) return; // teardown ran during the awaits — do not connect.\n\n live = new LiveClient({\n url: wsUrl,\n token,\n channel,\n refreshToken,\n onMessage: (data) => {\n if (!data || typeof data !== \"object\") return;\n const d = data as {\n event?: string;\n language_code?: string;\n namespace_slug?: string;\n };\n if (d.event !== \"translations_published\") return;\n const lang = d.language_code;\n const ns = d.namespace_slug;\n if (!lang || !ns) return;\n void ctx.i18n.reload({ locale: lang, namespace: ns });\n },\n });\n live.connect();\n })();\n\n // Teardown: cancel the in-flight setup and dispose any open client.\n return () => {\n cancelled = true;\n if (live) {\n live.dispose();\n live = null;\n }\n };\n },\n };\n}\n"],"mappings":"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","names":["token"]} | ||
| {"version":3,"sources":["../../src/svelte/index.ts","../../src/core/live.ts","../../src/core/version.ts","../../src/react/plugin.tsx"],"sourcesContent":["/**\n * `@sonenta/realtime/svelte` — the realtime missing-key/live-update plugin for\n * the Sonenta svelte binding. The plugin is framework-agnostic (it hooks the\n * engine via the provider-plugin `setup()` lifecycle), so this is the same\n * `sonentaRealtime` plugin as `/react`; pass it to the binding's `plugins`:\n *\n * import { createSonentaI18n } from \"@sonenta/svelte-i18n\";\n * import { sonentaRealtime } from \"@sonenta/realtime/svelte\";\n * createSonentaI18n({ ...config, plugins: [sonentaRealtime({ wsUrl })] });\n */\nexport { sonentaRealtime } from \"../react/plugin\";\nexport type { SonentaRealtimeOptions } from \"../react/plugin\";\n","/**\n * Tiny Centrifugo WebSocket client tailored to the Sonenta\n * `translations:` channel. Hand-rolled (no `centrifuge-js` dep) so the SDK\n * stays under 15 KB gzipped — we only need: connect, subscribe, listen.\n *\n * Wire format reference:\n * https://centrifugal.dev/docs/transports/websocket\n *\n * Lifecycle:\n * - call connect() with a valid token\n * - server replies with {push|reply}; we wait for the connect ack\n * - subscribe(channel) — send subscribe command, wait for ack\n * - subsequent {push, channel, pub.data} are routed to onMessage\n * - reconnect with exponential backoff (capped at 30s) on close\n */\n\nexport interface LiveClientConfig {\n url: string;\n token: string;\n channel: string;\n onMessage: (data: unknown) => void;\n onStatus?: (status: \"connecting\" | \"connected\" | \"disconnected\") => void;\n /**\n * Hook called just before each connect attempt — return a fresh token\n * (used to refresh a token whose `exp` is close). Defaults to the\n * static one passed in `token`.\n */\n refreshToken?: () => Promise<string>;\n}\n\nexport class LiveClient {\n private _ws: WebSocket | null = null;\n private _id = 0;\n private _backoffMs = 1_000;\n private _disposed = false;\n private _connectAcked = false;\n\n constructor(private readonly cfg: LiveClientConfig) {}\n\n /** Open the socket and try to subscribe. Idempotent — calling twice is a no-op. */\n async connect(): Promise<void> {\n if (this._ws) return;\n if (this._disposed) return;\n this.cfg.onStatus?.(\"connecting\");\n const token = this.cfg.refreshToken ? await this.cfg.refreshToken() : this.cfg.token;\n // Centrifugo's WebSocket endpoint lives at `/connection/websocket`.\n // Be forgiving on the input — accept either a bare host\n // (`wss://centrifugo.example`) or the full path.\n let url = this.cfg.url;\n if (!url.includes(\"/connection/websocket\")) {\n url = url.replace(/\\/+$/, \"\") + \"/connection/websocket\";\n }\n const ws = new WebSocket(url);\n this._ws = ws;\n this._connectAcked = false;\n\n ws.onopen = () => {\n // Centrifugo command: connect with token\n this._send({ id: ++this._id, connect: { token } });\n };\n ws.onmessage = (evt) => this._onFrame(evt.data);\n ws.onclose = () => this._onClose();\n ws.onerror = () => {\n // Let onclose handle the reconnect.\n };\n }\n\n dispose(): void {\n this._disposed = true;\n if (this._ws) {\n try {\n this._ws.close();\n } catch {\n // ignore\n }\n this._ws = null;\n }\n this.cfg.onStatus?.(\"disconnected\");\n }\n\n // ---- internals ----\n\n private _send(msg: unknown): void {\n if (!this._ws || this._ws.readyState !== WebSocket.OPEN) return;\n try {\n this._ws.send(JSON.stringify(msg));\n } catch {\n // ignore — onclose will fire shortly\n }\n }\n\n private _onFrame(raw: unknown): void {\n let parsed: {\n id?: number;\n connect?: { client?: string };\n subscribe?: unknown;\n push?: { channel?: string; pub?: { data?: unknown }; sub?: unknown };\n } | undefined;\n try {\n parsed = JSON.parse(raw as string);\n } catch {\n return;\n }\n if (!parsed || typeof parsed !== \"object\") return;\n // Centrifugo v2 protocol pings: server periodically sends an empty\n // object `{}`. Client MUST reply with an empty `{}` to keep the\n // connection alive — without this the server force-closes after\n // ~25-30s and we lose pushes silently.\n if (Object.keys(parsed).length === 0) {\n this._send({});\n return;\n }\n if (parsed.connect && !this._connectAcked) {\n this._connectAcked = true;\n this.cfg.onStatus?.(\"connected\");\n this._backoffMs = 1_000;\n // Connection token's `channels` claim auto-subscribes us server-side,\n // so an explicit subscribe command is unnecessary. We still send it\n // for back-compat with anonymous-channel deployments where no\n // server-side subscription was created.\n this._send({\n id: ++this._id,\n subscribe: { channel: this.cfg.channel },\n });\n return;\n }\n const push = parsed.push;\n if (push && push.channel === this.cfg.channel && push.pub) {\n this.cfg.onMessage(push.pub.data);\n }\n }\n\n private _onClose(): void {\n this._ws = null;\n this._connectAcked = false;\n this.cfg.onStatus?.(\"disconnected\");\n if (this._disposed) return;\n const delay = this._backoffMs;\n this._backoffMs = Math.min(this._backoffMs * 2, 30_000);\n setTimeout(() => {\n if (!this._disposed) void this.connect();\n }, delay);\n }\n}\n\n/**\n * Fetch a fresh Centrifugo connection token from the backend\n * (`POST /v1/auth/centrifugo-token`). For version-scoped translations\n * realtime, pass `kind: \"translations\"` + the `versionUuid`; the backend\n * grants the version channel only while that version is dev (contract #738).\n * Response: `{token, channel, expires_at}`.\n */\nexport async function fetchCentrifugoToken(\n endpoint: string,\n projectUuid: string,\n authToken: string,\n opts: {\n kind?: \"translations\" | \"missing\" | \"feedback\";\n versionUuid?: string;\n } = {},\n fetchImpl: typeof fetch = fetch,\n): Promise<{ token: string; channel: string; expires_at: number }> {\n const body: Record<string, unknown> = { project_uuid: projectUuid };\n if (opts.kind) body.kind = opts.kind;\n if (opts.versionUuid) body.version_uuid = opts.versionUuid;\n const r = await fetchImpl(endpoint, {\n method: \"POST\",\n headers: {\n \"Content-Type\": \"application/json\",\n Authorization: `ApiKey ${authToken}`,\n },\n body: JSON.stringify(body),\n });\n if (!r.ok) {\n throw new Error(`centrifugo-token endpoint ${r.status}: ${await r.text()}`);\n }\n return (await r.json()) as { token: string; channel: string; expires_at: number };\n}\n","/**\n * Version metadata used to gate dev-only realtime (backend contract #738).\n * The SDK-facing surface is intentionally just `is_dev` + `realtime_channel`\n * (plus `uuid`, needed for the version-scoped token); the rest is informative.\n */\nexport interface VersionMeta {\n uuid: string;\n project_uuid: string;\n name?: string;\n slug?: string;\n is_production?: boolean;\n /** true while the version has no published CDN release (= realtime-eligible). */\n is_dev: boolean;\n /** Centrifugo channel for dev versions; `null` once the version is published. */\n realtime_channel: string | null;\n}\n\n/**\n * `GET /v1/projects/{projectUuid}/versions/{idOrSlug}` — resolve a version by\n * uuid OR slug and read its dev/realtime state. Auth: project viewer (API key).\n */\nexport async function fetchVersionMeta(\n apiBase: string,\n projectUuid: string,\n idOrSlug: string,\n authToken: string,\n fetchImpl: typeof fetch = fetch,\n): Promise<VersionMeta> {\n const url = `${apiBase.replace(/\\/+$/, \"\")}/v1/projects/${projectUuid}/versions/${encodeURIComponent(\n idOrSlug,\n )}`;\n const r = await fetchImpl(url, {\n method: \"GET\",\n headers: { Authorization: `ApiKey ${authToken}` },\n credentials: \"omit\",\n });\n if (!r.ok) {\n throw new Error(`version metadata endpoint ${r.status}: ${await r.text()}`);\n }\n return (await r.json()) as VersionMeta;\n}\n","/**\n * `@sonenta/realtime` as a PLUGIN of the `@sonenta/react-i18next` provider.\n * Adds zero-deploy translation updates for DEV versions: it reads the version's\n * dev/realtime state from the backend, and only for a dev version subscribes to\n * its Centrifugo channel and calls `ctx.i18n.reload(...)` on each\n * `translations_published` push.\n *\n * Dev-gating is PER-VERSION (backend contract #738), not based on the SDK's\n * `env` flag: a version is realtime-eligible until it has a published CDN\n * release. A published version returns `realtime_channel === null` → no\n * subscription, the bundle is served statically from the CDN.\n */\nimport { LiveClient, fetchCentrifugoToken } from \"../core/live\";\nimport { fetchVersionMeta } from \"../core/version\";\nimport type {\n SonentaPlugin,\n SonentaPluginContext,\n} from \"@sonenta/react-i18next\";\n\nexport interface SonentaRealtimeOptions {\n /**\n * WebSocket URL of the Centrifugo node, e.g.\n * `wss://rt.sonenta.dev/connection/websocket`. Required — the\n * plugin can't infer it from the API base.\n */\n wsUrl: string;\n /**\n * Endpoint that mints a short-lived Centrifugo connection token. Defaults to\n * `${apiBase}/v1/auth/centrifugo-token`. Override for self-hosted deployments.\n */\n tokenEndpoint?: string;\n}\n\nconst DEFAULT_API_BASE = \"https://api.sonenta.com\";\n\nfunction warn(msg: string, err?: unknown): void {\n if (typeof console !== \"undefined\") {\n if (err !== undefined) console.warn(`@sonenta/realtime: ${msg}`, err);\n else console.warn(`@sonenta/realtime: ${msg}`);\n }\n}\n\n/**\n * Build the realtime plugin. Add it to `<SonentaProvider plugins={[...]}>`:\n *\n * ```tsx\n * plugins={[sonentaRealtime({ wsUrl: \"wss://centrifugo.example/connection/websocket\" })]}\n * ```\n */\nexport function sonentaRealtime(\n options: SonentaRealtimeOptions,\n): SonentaPlugin {\n return {\n name: \"@sonenta/realtime\",\n setup(ctx: SonentaPluginContext) {\n const wsUrl = options.wsUrl;\n if (!wsUrl) {\n warn(\"wsUrl is empty; skipping subscription.\");\n return;\n }\n\n const apiBase = (ctx.config.apiBase ?? DEFAULT_API_BASE).replace(\n /\\/+$/,\n \"\",\n );\n const projectUuid = ctx.config.projectUuid;\n const apiToken = ctx.config.token;\n // Mirrors the core's version resolution (#739): `version` is the public\n // name, `versionSlug` a deprecated alias, default \"main\".\n const version = ctx.config.version ?? ctx.config.versionSlug ?? \"main\";\n const tokenEndpoint =\n options.tokenEndpoint ?? `${apiBase}/v1/auth/centrifugo-token`;\n\n // Set by teardown; checked before opening the socket so an unmount during\n // the async setup never leaves a LiveClient connected post-unmount.\n let cancelled = false;\n let live: LiveClient | null = null;\n\n // REALTIME IS AN AUTHED FEATURE, AND `token` IS NOW OPTIONAL.\n //\n // A host can legitimately run the SDK with NO client-side token: they pass\n // their own `transport` and keep the key on their server (no secret in the\n // app bundle). That host CANNOT use realtime — every call here\n // authenticates. So say so, once, and stand down.\n //\n // The alternative is the very bug we are fixing today: send `undefined` into\n // an authed call, take the 401, swallow it, and let the feature look ON\n // while it silently does nothing. A disabled feature that ANNOUNCES itself\n // beats a dead one that does not.\n if (!apiToken) {\n warn(\n \"realtime is DISABLED: it needs an API token and this provider has none. \" +\n \"(Likely the no-embedded-secret pattern: a custom `transport` with the key \" +\n \"on your server. Translations and missing-key reporting work normally; live \" +\n \"updates cannot, because every realtime call is authenticated.)\",\n );\n return;\n }\n\n void (async () => {\n // 1. Read the version's dev/realtime state.\n let meta;\n try {\n meta = await fetchVersionMeta(\n apiBase,\n projectUuid,\n version,\n apiToken,\n );\n } catch (err) {\n warn(`version metadata fetch failed for '${version}'`, err);\n return;\n }\n if (cancelled) return;\n\n // 3. Published version → no realtime; serve static CDN bundles.\n if (!meta.is_dev || !meta.realtime_channel) {\n warn(\n `realtime is only available on dev versions; '${version}' is published — no subscription, serving static CDN bundles.`,\n );\n return;\n }\n const channel = meta.realtime_channel;\n const versionUuid = meta.uuid;\n\n // 2. Dev version → mint a version-scoped translations token + subscribe.\n const refreshToken = async (): Promise<string> => {\n const { token } = await fetchCentrifugoToken(\n tokenEndpoint,\n projectUuid,\n apiToken,\n { kind: \"translations\", versionUuid },\n );\n return token;\n };\n\n let token: string;\n try {\n const minted = await fetchCentrifugoToken(\n tokenEndpoint,\n projectUuid,\n apiToken,\n { kind: \"translations\", versionUuid },\n );\n token = minted.token;\n } catch (err) {\n warn(\"live token mint failed\", err);\n return;\n }\n if (cancelled) return; // teardown ran during the awaits — do not connect.\n\n live = new LiveClient({\n url: wsUrl,\n token,\n channel,\n refreshToken,\n onMessage: (data) => {\n if (!data || typeof data !== \"object\") return;\n const d = data as {\n event?: string;\n language_code?: string;\n namespace_slug?: string;\n };\n if (d.event !== \"translations_published\") return;\n const lang = d.language_code;\n const ns = d.namespace_slug;\n if (!lang || !ns) return;\n void ctx.i18n.reload({ locale: lang, namespace: ns });\n },\n });\n live.connect();\n })();\n\n // Teardown: cancel the in-flight setup and dispose any open client.\n return () => {\n cancelled = true;\n if (live) {\n live.dispose();\n live = null;\n }\n };\n },\n };\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;;;AC8BO,IAAM,aAAN,MAAiB;AAAA,EAOtB,YAA6B,KAAuB;AAAvB;AAAA,EAAwB;AAAA,EAAxB;AAAA,EANrB,MAAwB;AAAA,EACxB,MAAM;AAAA,EACN,aAAa;AAAA,EACb,YAAY;AAAA,EACZ,gBAAgB;AAAA;AAAA,EAKxB,MAAM,UAAyB;AAC7B,QAAI,KAAK,IAAK;AACd,QAAI,KAAK,UAAW;AACpB,SAAK,IAAI,WAAW,YAAY;AAChC,UAAM,QAAQ,KAAK,IAAI,eAAe,MAAM,KAAK,IAAI,aAAa,IAAI,KAAK,IAAI;AAI/E,QAAI,MAAM,KAAK,IAAI;AACnB,QAAI,CAAC,IAAI,SAAS,uBAAuB,GAAG;AAC1C,YAAM,IAAI,QAAQ,QAAQ,EAAE,IAAI;AAAA,IAClC;AACA,UAAM,KAAK,IAAI,UAAU,GAAG;AAC5B,SAAK,MAAM;AACX,SAAK,gBAAgB;AAErB,OAAG,SAAS,MAAM;AAEhB,WAAK,MAAM,EAAE,IAAI,EAAE,KAAK,KAAK,SAAS,EAAE,MAAM,EAAE,CAAC;AAAA,IACnD;AACA,OAAG,YAAY,CAAC,QAAQ,KAAK,SAAS,IAAI,IAAI;AAC9C,OAAG,UAAU,MAAM,KAAK,SAAS;AACjC,OAAG,UAAU,MAAM;AAAA,IAEnB;AAAA,EACF;AAAA,EAEA,UAAgB;AACd,SAAK,YAAY;AACjB,QAAI,KAAK,KAAK;AACZ,UAAI;AACF,aAAK,IAAI,MAAM;AAAA,MACjB,QAAQ;AAAA,MAER;AACA,WAAK,MAAM;AAAA,IACb;AACA,SAAK,IAAI,WAAW,cAAc;AAAA,EACpC;AAAA;AAAA,EAIQ,MAAM,KAAoB;AAChC,QAAI,CAAC,KAAK,OAAO,KAAK,IAAI,eAAe,UAAU,KAAM;AACzD,QAAI;AACF,WAAK,IAAI,KAAK,KAAK,UAAU,GAAG,CAAC;AAAA,IACnC,QAAQ;AAAA,IAER;AAAA,EACF;AAAA,EAEQ,SAAS,KAAoB;AACnC,QAAI;AAMJ,QAAI;AACF,eAAS,KAAK,MAAM,GAAa;AAAA,IACnC,QAAQ;AACN;AAAA,IACF;AACA,QAAI,CAAC,UAAU,OAAO,WAAW,SAAU;AAK3C,QAAI,OAAO,KAAK,MAAM,EAAE,WAAW,GAAG;AACpC,WAAK,MAAM,CAAC,CAAC;AACb;AAAA,IACF;AACA,QAAI,OAAO,WAAW,CAAC,KAAK,eAAe;AACzC,WAAK,gBAAgB;AACrB,WAAK,IAAI,WAAW,WAAW;AAC/B,WAAK,aAAa;AAKlB,WAAK,MAAM;AAAA,QACT,IAAI,EAAE,KAAK;AAAA,QACX,WAAW,EAAE,SAAS,KAAK,IAAI,QAAQ;AAAA,MACzC,CAAC;AACD;AAAA,IACF;AACA,UAAM,OAAO,OAAO;AACpB,QAAI,QAAQ,KAAK,YAAY,KAAK,IAAI,WAAW,KAAK,KAAK;AACzD,WAAK,IAAI,UAAU,KAAK,IAAI,IAAI;AAAA,IAClC;AAAA,EACF;AAAA,EAEQ,WAAiB;AACvB,SAAK,MAAM;AACX,SAAK,gBAAgB;AACrB,SAAK,IAAI,WAAW,cAAc;AAClC,QAAI,KAAK,UAAW;AACpB,UAAM,QAAQ,KAAK;AACnB,SAAK,aAAa,KAAK,IAAI,KAAK,aAAa,GAAG,GAAM;AACtD,eAAW,MAAM;AACf,UAAI,CAAC,KAAK,UAAW,MAAK,KAAK,QAAQ;AAAA,IACzC,GAAG,KAAK;AAAA,EACV;AACF;AASA,eAAsB,qBACpB,UACA,aACA,WACA,OAGI,CAAC,GACL,YAA0B,OACuC;AACjE,QAAM,OAAgC,EAAE,cAAc,YAAY;AAClE,MAAI,KAAK,KAAM,MAAK,OAAO,KAAK;AAChC,MAAI,KAAK,YAAa,MAAK,eAAe,KAAK;AAC/C,QAAM,IAAI,MAAM,UAAU,UAAU;AAAA,IAClC,QAAQ;AAAA,IACR,SAAS;AAAA,MACP,gBAAgB;AAAA,MAChB,eAAe,UAAU,SAAS;AAAA,IACpC;AAAA,IACA,MAAM,KAAK,UAAU,IAAI;AAAA,EAC3B,CAAC;AACD,MAAI,CAAC,EAAE,IAAI;AACT,UAAM,IAAI,MAAM,6BAA6B,EAAE,MAAM,KAAK,MAAM,EAAE,KAAK,CAAC,EAAE;AAAA,EAC5E;AACA,SAAQ,MAAM,EAAE,KAAK;AACvB;;;AC5JA,eAAsB,iBACpB,SACA,aACA,UACA,WACA,YAA0B,OACJ;AACtB,QAAM,MAAM,GAAG,QAAQ,QAAQ,QAAQ,EAAE,CAAC,gBAAgB,WAAW,aAAa;AAAA,IAChF;AAAA,EACF,CAAC;AACD,QAAM,IAAI,MAAM,UAAU,KAAK;AAAA,IAC7B,QAAQ;AAAA,IACR,SAAS,EAAE,eAAe,UAAU,SAAS,GAAG;AAAA,IAChD,aAAa;AAAA,EACf,CAAC;AACD,MAAI,CAAC,EAAE,IAAI;AACT,UAAM,IAAI,MAAM,6BAA6B,EAAE,MAAM,KAAK,MAAM,EAAE,KAAK,CAAC,EAAE;AAAA,EAC5E;AACA,SAAQ,MAAM,EAAE,KAAK;AACvB;;;ACPA,IAAM,mBAAmB;AAEzB,SAAS,KAAK,KAAa,KAAqB;AAC9C,MAAI,OAAO,YAAY,aAAa;AAClC,QAAI,QAAQ,OAAW,SAAQ,KAAK,sBAAsB,GAAG,IAAI,GAAG;AAAA,QAC/D,SAAQ,KAAK,sBAAsB,GAAG,EAAE;AAAA,EAC/C;AACF;AASO,SAAS,gBACd,SACe;AACf,SAAO;AAAA,IACL,MAAM;AAAA,IACN,MAAM,KAA2B;AAC/B,YAAM,QAAQ,QAAQ;AACtB,UAAI,CAAC,OAAO;AACV,aAAK,wCAAwC;AAC7C;AAAA,MACF;AAEA,YAAM,WAAW,IAAI,OAAO,WAAW,kBAAkB;AAAA,QACvD;AAAA,QACA;AAAA,MACF;AACA,YAAM,cAAc,IAAI,OAAO;AAC/B,YAAM,WAAW,IAAI,OAAO;AAG5B,YAAM,UAAU,IAAI,OAAO,WAAW,IAAI,OAAO,eAAe;AAChE,YAAM,gBACJ,QAAQ,iBAAiB,GAAG,OAAO;AAIrC,UAAI,YAAY;AAChB,UAAI,OAA0B;AAa9B,UAAI,CAAC,UAAU;AACb;AAAA,UACE;AAAA,QAIF;AACA;AAAA,MACF;AAEA,YAAM,YAAY;AAEhB,YAAI;AACJ,YAAI;AACF,iBAAO,MAAM;AAAA,YACX;AAAA,YACA;AAAA,YACA;AAAA,YACA;AAAA,UACF;AAAA,QACF,SAAS,KAAK;AACZ,eAAK,sCAAsC,OAAO,KAAK,GAAG;AAC1D;AAAA,QACF;AACA,YAAI,UAAW;AAGf,YAAI,CAAC,KAAK,UAAU,CAAC,KAAK,kBAAkB;AAC1C;AAAA,YACE,gDAAgD,OAAO;AAAA,UACzD;AACA;AAAA,QACF;AACA,cAAM,UAAU,KAAK;AACrB,cAAM,cAAc,KAAK;AAGzB,cAAM,eAAe,YAA6B;AAChD,gBAAM,EAAE,OAAAA,OAAM,IAAI,MAAM;AAAA,YACtB;AAAA,YACA;AAAA,YACA;AAAA,YACA,EAAE,MAAM,gBAAgB,YAAY;AAAA,UACtC;AACA,iBAAOA;AAAA,QACT;AAEA,YAAI;AACJ,YAAI;AACF,gBAAM,SAAS,MAAM;AAAA,YACnB;AAAA,YACA;AAAA,YACA;AAAA,YACA,EAAE,MAAM,gBAAgB,YAAY;AAAA,UACtC;AACA,kBAAQ,OAAO;AAAA,QACjB,SAAS,KAAK;AACZ,eAAK,0BAA0B,GAAG;AAClC;AAAA,QACF;AACA,YAAI,UAAW;AAEf,eAAO,IAAI,WAAW;AAAA,UACpB,KAAK;AAAA,UACL;AAAA,UACA;AAAA,UACA;AAAA,UACA,WAAW,CAAC,SAAS;AACnB,gBAAI,CAAC,QAAQ,OAAO,SAAS,SAAU;AACvC,kBAAM,IAAI;AAKV,gBAAI,EAAE,UAAU,yBAA0B;AAC1C,kBAAM,OAAO,EAAE;AACf,kBAAM,KAAK,EAAE;AACb,gBAAI,CAAC,QAAQ,CAAC,GAAI;AAClB,iBAAK,IAAI,KAAK,OAAO,EAAE,QAAQ,MAAM,WAAW,GAAG,CAAC;AAAA,UACtD;AAAA,QACF,CAAC;AACD,aAAK,QAAQ;AAAA,MACf,GAAG;AAGH,aAAO,MAAM;AACX,oBAAY;AACZ,YAAI,MAAM;AACR,eAAK,QAAQ;AACb,iBAAO;AAAA,QACT;AAAA,MACF;AAAA,IACF;AAAA,EACF;AACF;","names":["token"]} |
| import { | ||
| sonentaRealtime | ||
| } from "../chunk-4XBXVJX2.js"; | ||
| } from "../chunk-SWHAKZ5H.js"; | ||
| import "../chunk-2EOI7ZJZ.js"; | ||
@@ -5,0 +5,0 @@ export { |
@@ -178,2 +178,8 @@ "use strict"; | ||
| let live = null; | ||
| if (!apiToken) { | ||
| warn( | ||
| "realtime is DISABLED: it needs an API token and this provider has none. (Likely the no-embedded-secret pattern: a custom `transport` with the key on your server. Translations and missing-key reporting work normally; live updates cannot, because every realtime call is authenticated.)" | ||
| ); | ||
| return; | ||
| } | ||
| void (async () => { | ||
@@ -180,0 +186,0 @@ let meta; |
@@ -1,1 +0,1 @@ | ||
| {"version":3,"sources":["../../src/vue/index.ts","../../src/core/live.ts","../../src/core/version.ts","../../src/react/plugin.tsx"],"sourcesContent":["/**\n * `@sonenta/realtime/vue` — the realtime missing-key/live-update plugin for\n * the Sonenta vue binding. The plugin is framework-agnostic (it hooks the\n * engine via the provider-plugin `setup()` lifecycle), so this is the same\n * `sonentaRealtime` plugin as `/react`; pass it to the binding's `plugins`:\n *\n * import { createSonentaI18n } from \"@sonenta/vue-i18n\";\n * import { sonentaRealtime } from \"@sonenta/realtime/vue\";\n * createSonentaI18n({ ...config, plugins: [sonentaRealtime({ wsUrl })] });\n */\nexport { sonentaRealtime } from \"../react/plugin\";\nexport type { SonentaRealtimeOptions } from \"../react/plugin\";\n","/**\n * Tiny Centrifugo WebSocket client tailored to the Sonenta\n * `translations:` channel. Hand-rolled (no `centrifuge-js` dep) so the SDK\n * stays under 15 KB gzipped — we only need: connect, subscribe, listen.\n *\n * Wire format reference:\n * https://centrifugal.dev/docs/transports/websocket\n *\n * Lifecycle:\n * - call connect() with a valid token\n * - server replies with {push|reply}; we wait for the connect ack\n * - subscribe(channel) — send subscribe command, wait for ack\n * - subsequent {push, channel, pub.data} are routed to onMessage\n * - reconnect with exponential backoff (capped at 30s) on close\n */\n\nexport interface LiveClientConfig {\n url: string;\n token: string;\n channel: string;\n onMessage: (data: unknown) => void;\n onStatus?: (status: \"connecting\" | \"connected\" | \"disconnected\") => void;\n /**\n * Hook called just before each connect attempt — return a fresh token\n * (used to refresh a token whose `exp` is close). Defaults to the\n * static one passed in `token`.\n */\n refreshToken?: () => Promise<string>;\n}\n\nexport class LiveClient {\n private _ws: WebSocket | null = null;\n private _id = 0;\n private _backoffMs = 1_000;\n private _disposed = false;\n private _connectAcked = false;\n\n constructor(private readonly cfg: LiveClientConfig) {}\n\n /** Open the socket and try to subscribe. Idempotent — calling twice is a no-op. */\n async connect(): Promise<void> {\n if (this._ws) return;\n if (this._disposed) return;\n this.cfg.onStatus?.(\"connecting\");\n const token = this.cfg.refreshToken ? await this.cfg.refreshToken() : this.cfg.token;\n // Centrifugo's WebSocket endpoint lives at `/connection/websocket`.\n // Be forgiving on the input — accept either a bare host\n // (`wss://centrifugo.example`) or the full path.\n let url = this.cfg.url;\n if (!url.includes(\"/connection/websocket\")) {\n url = url.replace(/\\/+$/, \"\") + \"/connection/websocket\";\n }\n const ws = new WebSocket(url);\n this._ws = ws;\n this._connectAcked = false;\n\n ws.onopen = () => {\n // Centrifugo command: connect with token\n this._send({ id: ++this._id, connect: { token } });\n };\n ws.onmessage = (evt) => this._onFrame(evt.data);\n ws.onclose = () => this._onClose();\n ws.onerror = () => {\n // Let onclose handle the reconnect.\n };\n }\n\n dispose(): void {\n this._disposed = true;\n if (this._ws) {\n try {\n this._ws.close();\n } catch {\n // ignore\n }\n this._ws = null;\n }\n this.cfg.onStatus?.(\"disconnected\");\n }\n\n // ---- internals ----\n\n private _send(msg: unknown): void {\n if (!this._ws || this._ws.readyState !== WebSocket.OPEN) return;\n try {\n this._ws.send(JSON.stringify(msg));\n } catch {\n // ignore — onclose will fire shortly\n }\n }\n\n private _onFrame(raw: unknown): void {\n let parsed: {\n id?: number;\n connect?: { client?: string };\n subscribe?: unknown;\n push?: { channel?: string; pub?: { data?: unknown }; sub?: unknown };\n } | undefined;\n try {\n parsed = JSON.parse(raw as string);\n } catch {\n return;\n }\n if (!parsed || typeof parsed !== \"object\") return;\n // Centrifugo v2 protocol pings: server periodically sends an empty\n // object `{}`. Client MUST reply with an empty `{}` to keep the\n // connection alive — without this the server force-closes after\n // ~25-30s and we lose pushes silently.\n if (Object.keys(parsed).length === 0) {\n this._send({});\n return;\n }\n if (parsed.connect && !this._connectAcked) {\n this._connectAcked = true;\n this.cfg.onStatus?.(\"connected\");\n this._backoffMs = 1_000;\n // Connection token's `channels` claim auto-subscribes us server-side,\n // so an explicit subscribe command is unnecessary. We still send it\n // for back-compat with anonymous-channel deployments where no\n // server-side subscription was created.\n this._send({\n id: ++this._id,\n subscribe: { channel: this.cfg.channel },\n });\n return;\n }\n const push = parsed.push;\n if (push && push.channel === this.cfg.channel && push.pub) {\n this.cfg.onMessage(push.pub.data);\n }\n }\n\n private _onClose(): void {\n this._ws = null;\n this._connectAcked = false;\n this.cfg.onStatus?.(\"disconnected\");\n if (this._disposed) return;\n const delay = this._backoffMs;\n this._backoffMs = Math.min(this._backoffMs * 2, 30_000);\n setTimeout(() => {\n if (!this._disposed) void this.connect();\n }, delay);\n }\n}\n\n/**\n * Fetch a fresh Centrifugo connection token from the backend\n * (`POST /v1/auth/centrifugo-token`). For version-scoped translations\n * realtime, pass `kind: \"translations\"` + the `versionUuid`; the backend\n * grants the version channel only while that version is dev (contract #738).\n * Response: `{token, channel, expires_at}`.\n */\nexport async function fetchCentrifugoToken(\n endpoint: string,\n projectUuid: string,\n authToken: string,\n opts: {\n kind?: \"translations\" | \"missing\" | \"feedback\";\n versionUuid?: string;\n } = {},\n fetchImpl: typeof fetch = fetch,\n): Promise<{ token: string; channel: string; expires_at: number }> {\n const body: Record<string, unknown> = { project_uuid: projectUuid };\n if (opts.kind) body.kind = opts.kind;\n if (opts.versionUuid) body.version_uuid = opts.versionUuid;\n const r = await fetchImpl(endpoint, {\n method: \"POST\",\n headers: {\n \"Content-Type\": \"application/json\",\n Authorization: `ApiKey ${authToken}`,\n },\n body: JSON.stringify(body),\n });\n if (!r.ok) {\n throw new Error(`centrifugo-token endpoint ${r.status}: ${await r.text()}`);\n }\n return (await r.json()) as { token: string; channel: string; expires_at: number };\n}\n","/**\n * Version metadata used to gate dev-only realtime (backend contract #738).\n * The SDK-facing surface is intentionally just `is_dev` + `realtime_channel`\n * (plus `uuid`, needed for the version-scoped token); the rest is informative.\n */\nexport interface VersionMeta {\n uuid: string;\n project_uuid: string;\n name?: string;\n slug?: string;\n is_production?: boolean;\n /** true while the version has no published CDN release (= realtime-eligible). */\n is_dev: boolean;\n /** Centrifugo channel for dev versions; `null` once the version is published. */\n realtime_channel: string | null;\n}\n\n/**\n * `GET /v1/projects/{projectUuid}/versions/{idOrSlug}` — resolve a version by\n * uuid OR slug and read its dev/realtime state. Auth: project viewer (API key).\n */\nexport async function fetchVersionMeta(\n apiBase: string,\n projectUuid: string,\n idOrSlug: string,\n authToken: string,\n fetchImpl: typeof fetch = fetch,\n): Promise<VersionMeta> {\n const url = `${apiBase.replace(/\\/+$/, \"\")}/v1/projects/${projectUuid}/versions/${encodeURIComponent(\n idOrSlug,\n )}`;\n const r = await fetchImpl(url, {\n method: \"GET\",\n headers: { Authorization: `ApiKey ${authToken}` },\n credentials: \"omit\",\n });\n if (!r.ok) {\n throw new Error(`version metadata endpoint ${r.status}: ${await r.text()}`);\n }\n return (await r.json()) as VersionMeta;\n}\n","/**\n * `@sonenta/realtime` as a PLUGIN of the `@sonenta/react-i18next` provider.\n * Adds zero-deploy translation updates for DEV versions: it reads the version's\n * dev/realtime state from the backend, and only for a dev version subscribes to\n * its Centrifugo channel and calls `ctx.i18n.reload(...)` on each\n * `translations_published` push.\n *\n * Dev-gating is PER-VERSION (backend contract #738), not based on the SDK's\n * `env` flag: a version is realtime-eligible until it has a published CDN\n * release. A published version returns `realtime_channel === null` → no\n * subscription, the bundle is served statically from the CDN.\n */\nimport { LiveClient, fetchCentrifugoToken } from \"../core/live\";\nimport { fetchVersionMeta } from \"../core/version\";\nimport type {\n SonentaPlugin,\n SonentaPluginContext,\n} from \"@sonenta/react-i18next\";\n\nexport interface SonentaRealtimeOptions {\n /**\n * WebSocket URL of the Centrifugo node, e.g.\n * `wss://rt.sonenta.dev/connection/websocket`. Required — the\n * plugin can't infer it from the API base.\n */\n wsUrl: string;\n /**\n * Endpoint that mints a short-lived Centrifugo connection token. Defaults to\n * `${apiBase}/v1/auth/centrifugo-token`. Override for self-hosted deployments.\n */\n tokenEndpoint?: string;\n}\n\nconst DEFAULT_API_BASE = \"https://api.sonenta.com\";\n\nfunction warn(msg: string, err?: unknown): void {\n if (typeof console !== \"undefined\") {\n if (err !== undefined) console.warn(`@sonenta/realtime: ${msg}`, err);\n else console.warn(`@sonenta/realtime: ${msg}`);\n }\n}\n\n/**\n * Build the realtime plugin. Add it to `<SonentaProvider plugins={[...]}>`:\n *\n * ```tsx\n * plugins={[sonentaRealtime({ wsUrl: \"wss://centrifugo.example/connection/websocket\" })]}\n * ```\n */\nexport function sonentaRealtime(\n options: SonentaRealtimeOptions,\n): SonentaPlugin {\n return {\n name: \"@sonenta/realtime\",\n setup(ctx: SonentaPluginContext) {\n const wsUrl = options.wsUrl;\n if (!wsUrl) {\n warn(\"wsUrl is empty; skipping subscription.\");\n return;\n }\n\n const apiBase = (ctx.config.apiBase ?? DEFAULT_API_BASE).replace(\n /\\/+$/,\n \"\",\n );\n const projectUuid = ctx.config.projectUuid;\n const apiToken = ctx.config.token;\n // Mirrors the core's version resolution (#739): `version` is the public\n // name, `versionSlug` a deprecated alias, default \"main\".\n const version = ctx.config.version ?? ctx.config.versionSlug ?? \"main\";\n const tokenEndpoint =\n options.tokenEndpoint ?? `${apiBase}/v1/auth/centrifugo-token`;\n\n // Set by teardown; checked before opening the socket so an unmount during\n // the async setup never leaves a LiveClient connected post-unmount.\n let cancelled = false;\n let live: LiveClient | null = null;\n\n void (async () => {\n // 1. Read the version's dev/realtime state.\n let meta;\n try {\n meta = await fetchVersionMeta(\n apiBase,\n projectUuid,\n version,\n apiToken,\n );\n } catch (err) {\n warn(`version metadata fetch failed for '${version}'`, err);\n return;\n }\n if (cancelled) return;\n\n // 3. Published version → no realtime; serve static CDN bundles.\n if (!meta.is_dev || !meta.realtime_channel) {\n warn(\n `realtime is only available on dev versions; '${version}' is published — no subscription, serving static CDN bundles.`,\n );\n return;\n }\n const channel = meta.realtime_channel;\n const versionUuid = meta.uuid;\n\n // 2. Dev version → mint a version-scoped translations token + subscribe.\n const refreshToken = async (): Promise<string> => {\n const { token } = await fetchCentrifugoToken(\n tokenEndpoint,\n projectUuid,\n apiToken,\n { kind: \"translations\", versionUuid },\n );\n return token;\n };\n\n let token: string;\n try {\n const minted = await fetchCentrifugoToken(\n tokenEndpoint,\n projectUuid,\n apiToken,\n { kind: \"translations\", versionUuid },\n );\n token = minted.token;\n } catch (err) {\n warn(\"live token mint failed\", err);\n return;\n }\n if (cancelled) return; // teardown ran during the awaits — do not connect.\n\n live = new LiveClient({\n url: wsUrl,\n token,\n channel,\n refreshToken,\n onMessage: (data) => {\n if (!data || typeof data !== \"object\") return;\n const d = data as {\n event?: string;\n language_code?: string;\n namespace_slug?: string;\n };\n if (d.event !== \"translations_published\") return;\n const lang = d.language_code;\n const ns = d.namespace_slug;\n if (!lang || !ns) return;\n void ctx.i18n.reload({ locale: lang, namespace: ns });\n },\n });\n live.connect();\n })();\n\n // Teardown: cancel the in-flight setup and dispose any open client.\n return () => {\n cancelled = true;\n if (live) {\n live.dispose();\n live = null;\n }\n };\n },\n };\n}\n"],"mappings":"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","names":["token"]} | ||
| {"version":3,"sources":["../../src/vue/index.ts","../../src/core/live.ts","../../src/core/version.ts","../../src/react/plugin.tsx"],"sourcesContent":["/**\n * `@sonenta/realtime/vue` — the realtime missing-key/live-update plugin for\n * the Sonenta vue binding. The plugin is framework-agnostic (it hooks the\n * engine via the provider-plugin `setup()` lifecycle), so this is the same\n * `sonentaRealtime` plugin as `/react`; pass it to the binding's `plugins`:\n *\n * import { createSonentaI18n } from \"@sonenta/vue-i18n\";\n * import { sonentaRealtime } from \"@sonenta/realtime/vue\";\n * createSonentaI18n({ ...config, plugins: [sonentaRealtime({ wsUrl })] });\n */\nexport { sonentaRealtime } from \"../react/plugin\";\nexport type { SonentaRealtimeOptions } from \"../react/plugin\";\n","/**\n * Tiny Centrifugo WebSocket client tailored to the Sonenta\n * `translations:` channel. Hand-rolled (no `centrifuge-js` dep) so the SDK\n * stays under 15 KB gzipped — we only need: connect, subscribe, listen.\n *\n * Wire format reference:\n * https://centrifugal.dev/docs/transports/websocket\n *\n * Lifecycle:\n * - call connect() with a valid token\n * - server replies with {push|reply}; we wait for the connect ack\n * - subscribe(channel) — send subscribe command, wait for ack\n * - subsequent {push, channel, pub.data} are routed to onMessage\n * - reconnect with exponential backoff (capped at 30s) on close\n */\n\nexport interface LiveClientConfig {\n url: string;\n token: string;\n channel: string;\n onMessage: (data: unknown) => void;\n onStatus?: (status: \"connecting\" | \"connected\" | \"disconnected\") => void;\n /**\n * Hook called just before each connect attempt — return a fresh token\n * (used to refresh a token whose `exp` is close). Defaults to the\n * static one passed in `token`.\n */\n refreshToken?: () => Promise<string>;\n}\n\nexport class LiveClient {\n private _ws: WebSocket | null = null;\n private _id = 0;\n private _backoffMs = 1_000;\n private _disposed = false;\n private _connectAcked = false;\n\n constructor(private readonly cfg: LiveClientConfig) {}\n\n /** Open the socket and try to subscribe. Idempotent — calling twice is a no-op. */\n async connect(): Promise<void> {\n if (this._ws) return;\n if (this._disposed) return;\n this.cfg.onStatus?.(\"connecting\");\n const token = this.cfg.refreshToken ? await this.cfg.refreshToken() : this.cfg.token;\n // Centrifugo's WebSocket endpoint lives at `/connection/websocket`.\n // Be forgiving on the input — accept either a bare host\n // (`wss://centrifugo.example`) or the full path.\n let url = this.cfg.url;\n if (!url.includes(\"/connection/websocket\")) {\n url = url.replace(/\\/+$/, \"\") + \"/connection/websocket\";\n }\n const ws = new WebSocket(url);\n this._ws = ws;\n this._connectAcked = false;\n\n ws.onopen = () => {\n // Centrifugo command: connect with token\n this._send({ id: ++this._id, connect: { token } });\n };\n ws.onmessage = (evt) => this._onFrame(evt.data);\n ws.onclose = () => this._onClose();\n ws.onerror = () => {\n // Let onclose handle the reconnect.\n };\n }\n\n dispose(): void {\n this._disposed = true;\n if (this._ws) {\n try {\n this._ws.close();\n } catch {\n // ignore\n }\n this._ws = null;\n }\n this.cfg.onStatus?.(\"disconnected\");\n }\n\n // ---- internals ----\n\n private _send(msg: unknown): void {\n if (!this._ws || this._ws.readyState !== WebSocket.OPEN) return;\n try {\n this._ws.send(JSON.stringify(msg));\n } catch {\n // ignore — onclose will fire shortly\n }\n }\n\n private _onFrame(raw: unknown): void {\n let parsed: {\n id?: number;\n connect?: { client?: string };\n subscribe?: unknown;\n push?: { channel?: string; pub?: { data?: unknown }; sub?: unknown };\n } | undefined;\n try {\n parsed = JSON.parse(raw as string);\n } catch {\n return;\n }\n if (!parsed || typeof parsed !== \"object\") return;\n // Centrifugo v2 protocol pings: server periodically sends an empty\n // object `{}`. Client MUST reply with an empty `{}` to keep the\n // connection alive — without this the server force-closes after\n // ~25-30s and we lose pushes silently.\n if (Object.keys(parsed).length === 0) {\n this._send({});\n return;\n }\n if (parsed.connect && !this._connectAcked) {\n this._connectAcked = true;\n this.cfg.onStatus?.(\"connected\");\n this._backoffMs = 1_000;\n // Connection token's `channels` claim auto-subscribes us server-side,\n // so an explicit subscribe command is unnecessary. We still send it\n // for back-compat with anonymous-channel deployments where no\n // server-side subscription was created.\n this._send({\n id: ++this._id,\n subscribe: { channel: this.cfg.channel },\n });\n return;\n }\n const push = parsed.push;\n if (push && push.channel === this.cfg.channel && push.pub) {\n this.cfg.onMessage(push.pub.data);\n }\n }\n\n private _onClose(): void {\n this._ws = null;\n this._connectAcked = false;\n this.cfg.onStatus?.(\"disconnected\");\n if (this._disposed) return;\n const delay = this._backoffMs;\n this._backoffMs = Math.min(this._backoffMs * 2, 30_000);\n setTimeout(() => {\n if (!this._disposed) void this.connect();\n }, delay);\n }\n}\n\n/**\n * Fetch a fresh Centrifugo connection token from the backend\n * (`POST /v1/auth/centrifugo-token`). For version-scoped translations\n * realtime, pass `kind: \"translations\"` + the `versionUuid`; the backend\n * grants the version channel only while that version is dev (contract #738).\n * Response: `{token, channel, expires_at}`.\n */\nexport async function fetchCentrifugoToken(\n endpoint: string,\n projectUuid: string,\n authToken: string,\n opts: {\n kind?: \"translations\" | \"missing\" | \"feedback\";\n versionUuid?: string;\n } = {},\n fetchImpl: typeof fetch = fetch,\n): Promise<{ token: string; channel: string; expires_at: number }> {\n const body: Record<string, unknown> = { project_uuid: projectUuid };\n if (opts.kind) body.kind = opts.kind;\n if (opts.versionUuid) body.version_uuid = opts.versionUuid;\n const r = await fetchImpl(endpoint, {\n method: \"POST\",\n headers: {\n \"Content-Type\": \"application/json\",\n Authorization: `ApiKey ${authToken}`,\n },\n body: JSON.stringify(body),\n });\n if (!r.ok) {\n throw new Error(`centrifugo-token endpoint ${r.status}: ${await r.text()}`);\n }\n return (await r.json()) as { token: string; channel: string; expires_at: number };\n}\n","/**\n * Version metadata used to gate dev-only realtime (backend contract #738).\n * The SDK-facing surface is intentionally just `is_dev` + `realtime_channel`\n * (plus `uuid`, needed for the version-scoped token); the rest is informative.\n */\nexport interface VersionMeta {\n uuid: string;\n project_uuid: string;\n name?: string;\n slug?: string;\n is_production?: boolean;\n /** true while the version has no published CDN release (= realtime-eligible). */\n is_dev: boolean;\n /** Centrifugo channel for dev versions; `null` once the version is published. */\n realtime_channel: string | null;\n}\n\n/**\n * `GET /v1/projects/{projectUuid}/versions/{idOrSlug}` — resolve a version by\n * uuid OR slug and read its dev/realtime state. Auth: project viewer (API key).\n */\nexport async function fetchVersionMeta(\n apiBase: string,\n projectUuid: string,\n idOrSlug: string,\n authToken: string,\n fetchImpl: typeof fetch = fetch,\n): Promise<VersionMeta> {\n const url = `${apiBase.replace(/\\/+$/, \"\")}/v1/projects/${projectUuid}/versions/${encodeURIComponent(\n idOrSlug,\n )}`;\n const r = await fetchImpl(url, {\n method: \"GET\",\n headers: { Authorization: `ApiKey ${authToken}` },\n credentials: \"omit\",\n });\n if (!r.ok) {\n throw new Error(`version metadata endpoint ${r.status}: ${await r.text()}`);\n }\n return (await r.json()) as VersionMeta;\n}\n","/**\n * `@sonenta/realtime` as a PLUGIN of the `@sonenta/react-i18next` provider.\n * Adds zero-deploy translation updates for DEV versions: it reads the version's\n * dev/realtime state from the backend, and only for a dev version subscribes to\n * its Centrifugo channel and calls `ctx.i18n.reload(...)` on each\n * `translations_published` push.\n *\n * Dev-gating is PER-VERSION (backend contract #738), not based on the SDK's\n * `env` flag: a version is realtime-eligible until it has a published CDN\n * release. A published version returns `realtime_channel === null` → no\n * subscription, the bundle is served statically from the CDN.\n */\nimport { LiveClient, fetchCentrifugoToken } from \"../core/live\";\nimport { fetchVersionMeta } from \"../core/version\";\nimport type {\n SonentaPlugin,\n SonentaPluginContext,\n} from \"@sonenta/react-i18next\";\n\nexport interface SonentaRealtimeOptions {\n /**\n * WebSocket URL of the Centrifugo node, e.g.\n * `wss://rt.sonenta.dev/connection/websocket`. Required — the\n * plugin can't infer it from the API base.\n */\n wsUrl: string;\n /**\n * Endpoint that mints a short-lived Centrifugo connection token. Defaults to\n * `${apiBase}/v1/auth/centrifugo-token`. Override for self-hosted deployments.\n */\n tokenEndpoint?: string;\n}\n\nconst DEFAULT_API_BASE = \"https://api.sonenta.com\";\n\nfunction warn(msg: string, err?: unknown): void {\n if (typeof console !== \"undefined\") {\n if (err !== undefined) console.warn(`@sonenta/realtime: ${msg}`, err);\n else console.warn(`@sonenta/realtime: ${msg}`);\n }\n}\n\n/**\n * Build the realtime plugin. Add it to `<SonentaProvider plugins={[...]}>`:\n *\n * ```tsx\n * plugins={[sonentaRealtime({ wsUrl: \"wss://centrifugo.example/connection/websocket\" })]}\n * ```\n */\nexport function sonentaRealtime(\n options: SonentaRealtimeOptions,\n): SonentaPlugin {\n return {\n name: \"@sonenta/realtime\",\n setup(ctx: SonentaPluginContext) {\n const wsUrl = options.wsUrl;\n if (!wsUrl) {\n warn(\"wsUrl is empty; skipping subscription.\");\n return;\n }\n\n const apiBase = (ctx.config.apiBase ?? DEFAULT_API_BASE).replace(\n /\\/+$/,\n \"\",\n );\n const projectUuid = ctx.config.projectUuid;\n const apiToken = ctx.config.token;\n // Mirrors the core's version resolution (#739): `version` is the public\n // name, `versionSlug` a deprecated alias, default \"main\".\n const version = ctx.config.version ?? ctx.config.versionSlug ?? \"main\";\n const tokenEndpoint =\n options.tokenEndpoint ?? `${apiBase}/v1/auth/centrifugo-token`;\n\n // Set by teardown; checked before opening the socket so an unmount during\n // the async setup never leaves a LiveClient connected post-unmount.\n let cancelled = false;\n let live: LiveClient | null = null;\n\n // REALTIME IS AN AUTHED FEATURE, AND `token` IS NOW OPTIONAL.\n //\n // A host can legitimately run the SDK with NO client-side token: they pass\n // their own `transport` and keep the key on their server (no secret in the\n // app bundle). That host CANNOT use realtime — every call here\n // authenticates. So say so, once, and stand down.\n //\n // The alternative is the very bug we are fixing today: send `undefined` into\n // an authed call, take the 401, swallow it, and let the feature look ON\n // while it silently does nothing. A disabled feature that ANNOUNCES itself\n // beats a dead one that does not.\n if (!apiToken) {\n warn(\n \"realtime is DISABLED: it needs an API token and this provider has none. \" +\n \"(Likely the no-embedded-secret pattern: a custom `transport` with the key \" +\n \"on your server. Translations and missing-key reporting work normally; live \" +\n \"updates cannot, because every realtime call is authenticated.)\",\n );\n return;\n }\n\n void (async () => {\n // 1. Read the version's dev/realtime state.\n let meta;\n try {\n meta = await fetchVersionMeta(\n apiBase,\n projectUuid,\n version,\n apiToken,\n );\n } catch (err) {\n warn(`version metadata fetch failed for '${version}'`, err);\n return;\n }\n if (cancelled) return;\n\n // 3. Published version → no realtime; serve static CDN bundles.\n if (!meta.is_dev || !meta.realtime_channel) {\n warn(\n `realtime is only available on dev versions; '${version}' is published — no subscription, serving static CDN bundles.`,\n );\n return;\n }\n const channel = meta.realtime_channel;\n const versionUuid = meta.uuid;\n\n // 2. Dev version → mint a version-scoped translations token + subscribe.\n const refreshToken = async (): Promise<string> => {\n const { token } = await fetchCentrifugoToken(\n tokenEndpoint,\n projectUuid,\n apiToken,\n { kind: \"translations\", versionUuid },\n );\n return token;\n };\n\n let token: string;\n try {\n const minted = await fetchCentrifugoToken(\n tokenEndpoint,\n projectUuid,\n apiToken,\n { kind: \"translations\", versionUuid },\n );\n token = minted.token;\n } catch (err) {\n warn(\"live token mint failed\", err);\n return;\n }\n if (cancelled) return; // teardown ran during the awaits — do not connect.\n\n live = new LiveClient({\n url: wsUrl,\n token,\n channel,\n refreshToken,\n onMessage: (data) => {\n if (!data || typeof data !== \"object\") return;\n const d = data as {\n event?: string;\n language_code?: string;\n namespace_slug?: string;\n };\n if (d.event !== \"translations_published\") return;\n const lang = d.language_code;\n const ns = d.namespace_slug;\n if (!lang || !ns) return;\n void ctx.i18n.reload({ locale: lang, namespace: ns });\n },\n });\n live.connect();\n })();\n\n // Teardown: cancel the in-flight setup and dispose any open client.\n return () => {\n cancelled = true;\n if (live) {\n live.dispose();\n live = null;\n }\n };\n },\n };\n}\n"],"mappings":"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","names":["token"]} |
| import { | ||
| sonentaRealtime | ||
| } from "../chunk-4XBXVJX2.js"; | ||
| } from "../chunk-SWHAKZ5H.js"; | ||
| import "../chunk-2EOI7ZJZ.js"; | ||
@@ -5,0 +5,0 @@ export { |
+5
-3
| { | ||
| "name": "@sonenta/realtime", | ||
| "version": "1.2.0", | ||
| "version": "1.3.0", | ||
| "description": "Realtime translation updates plugin for @sonenta/react-i18next (Centrifugo).", | ||
@@ -42,3 +42,4 @@ "license": "MIT", | ||
| "require": "./dist/svelte/index.cjs" | ||
| } | ||
| }, | ||
| "./package.json": "./package.json" | ||
| }, | ||
@@ -52,2 +53,3 @@ "files": [ | ||
| "README.md", | ||
| "CHANGELOG.md", | ||
| "LICENSE", | ||
@@ -81,3 +83,3 @@ "vue.js", | ||
| "vitest": "^2.1.0", | ||
| "@sonenta/react-i18next": "2.4.0" | ||
| "@sonenta/react-i18next": "2.8.0" | ||
| }, | ||
@@ -84,0 +86,0 @@ "publishConfig": { |
+34
-1
@@ -13,6 +13,39 @@ # @sonenta/realtime | ||
| - `@sonenta/realtime/react` — the `SonentaProvider` plugin (web/React). | ||
| - `@sonenta/realtime/react` — the provider plugin (web/React). | ||
| - `@sonenta/realtime/vue` — the same plugin, for `@sonenta/vue-i18n`. | ||
| - `@sonenta/realtime/svelte` — the same plugin, for `@sonenta/svelte-i18n`. | ||
| - `@sonenta/realtime/core` — the framework-agnostic Centrifugo client | ||
| (`LiveClient`, `fetchCentrifugoToken`) for advanced/other-framework use. | ||
| ### React Native: TESTED — it resolves and runs. It is dev-only, not web-only. | ||
| Unlike `@sonenta/feedback` and `@sonenta/in-context`, this package ships no | ||
| `native` entry point — it ships no UI at all, so it has no native *view* layer | ||
| to provide. That absence says what this package **ships**, not what it | ||
| **runs on**: nothing here is DOM-coupled, and `LiveClient` talks to Centrifugo | ||
| over a plain `new WebSocket(url)`, which is a React Native global. | ||
| **What has actually been tested** (demo-app-expo, Expo 52 / RN 0.76.5 / Hermes, | ||
| 2026-07-13): | ||
| - `@sonenta/realtime/react` **resolves under Metro** with zero app-side config, | ||
| via the package root shim (Expo ships `unstable_enablePackageExports` off, so | ||
| the `exports` map is bypassed — which is exactly what the 0.1.1 shims were for). | ||
| - It **runs under Hermes** — no polyfill, no patch; the plugin's `setup()` | ||
| executes on-device. | ||
| - The **WebSocket was not attempted — and that is correct, not a failure.** With a | ||
| live key the client fetched version metadata (200), evaluated the gate, and | ||
| logged: *"realtime is only available on dev versions; main is published — no | ||
| subscription, serving static CDN bundles."* The project's only version was | ||
| **published**, so the SDK **declined to subscribe by design**. That gate is | ||
| **platform-agnostic**: a published *web* project behaves identically. | ||
| So on React Native the SDK **resolves, runs, and executes its full startup path, | ||
| including the conditional it is supposed to evaluate**. The socket is unexercised | ||
| because the **product** rule closed it, not because the **platform** blocked it. | ||
| **Do not call this package "web only."** That would assert a platform limitation | ||
| that has now been positively disproven on a device. It is **dev-only** — which is | ||
| a product decision, not a platform one, and true on every framework. | ||
| MIT. | ||
@@ -19,0 +52,0 @@ |
| import { | ||
| LiveClient, | ||
| fetchCentrifugoToken, | ||
| fetchVersionMeta | ||
| } from "./chunk-2EOI7ZJZ.js"; | ||
| // src/react/plugin.tsx | ||
| var DEFAULT_API_BASE = "https://api.sonenta.com"; | ||
| function warn(msg, err) { | ||
| if (typeof console !== "undefined") { | ||
| if (err !== void 0) console.warn(`@sonenta/realtime: ${msg}`, err); | ||
| else console.warn(`@sonenta/realtime: ${msg}`); | ||
| } | ||
| } | ||
| function sonentaRealtime(options) { | ||
| return { | ||
| name: "@sonenta/realtime", | ||
| setup(ctx) { | ||
| const wsUrl = options.wsUrl; | ||
| if (!wsUrl) { | ||
| warn("wsUrl is empty; skipping subscription."); | ||
| return; | ||
| } | ||
| const apiBase = (ctx.config.apiBase ?? DEFAULT_API_BASE).replace( | ||
| /\/+$/, | ||
| "" | ||
| ); | ||
| const projectUuid = ctx.config.projectUuid; | ||
| const apiToken = ctx.config.token; | ||
| const version = ctx.config.version ?? ctx.config.versionSlug ?? "main"; | ||
| const tokenEndpoint = options.tokenEndpoint ?? `${apiBase}/v1/auth/centrifugo-token`; | ||
| let cancelled = false; | ||
| let live = null; | ||
| void (async () => { | ||
| let meta; | ||
| try { | ||
| meta = await fetchVersionMeta( | ||
| apiBase, | ||
| projectUuid, | ||
| version, | ||
| apiToken | ||
| ); | ||
| } catch (err) { | ||
| warn(`version metadata fetch failed for '${version}'`, err); | ||
| return; | ||
| } | ||
| if (cancelled) return; | ||
| if (!meta.is_dev || !meta.realtime_channel) { | ||
| warn( | ||
| `realtime is only available on dev versions; '${version}' is published \u2014 no subscription, serving static CDN bundles.` | ||
| ); | ||
| return; | ||
| } | ||
| const channel = meta.realtime_channel; | ||
| const versionUuid = meta.uuid; | ||
| const refreshToken = async () => { | ||
| const { token: token2 } = await fetchCentrifugoToken( | ||
| tokenEndpoint, | ||
| projectUuid, | ||
| apiToken, | ||
| { kind: "translations", versionUuid } | ||
| ); | ||
| return token2; | ||
| }; | ||
| let token; | ||
| try { | ||
| const minted = await fetchCentrifugoToken( | ||
| tokenEndpoint, | ||
| projectUuid, | ||
| apiToken, | ||
| { kind: "translations", versionUuid } | ||
| ); | ||
| token = minted.token; | ||
| } catch (err) { | ||
| warn("live token mint failed", err); | ||
| return; | ||
| } | ||
| if (cancelled) return; | ||
| live = new LiveClient({ | ||
| url: wsUrl, | ||
| token, | ||
| channel, | ||
| refreshToken, | ||
| onMessage: (data) => { | ||
| if (!data || typeof data !== "object") return; | ||
| const d = data; | ||
| if (d.event !== "translations_published") return; | ||
| const lang = d.language_code; | ||
| const ns = d.namespace_slug; | ||
| if (!lang || !ns) return; | ||
| void ctx.i18n.reload({ locale: lang, namespace: ns }); | ||
| } | ||
| }); | ||
| live.connect(); | ||
| })(); | ||
| return () => { | ||
| cancelled = true; | ||
| if (live) { | ||
| live.dispose(); | ||
| live = null; | ||
| } | ||
| }; | ||
| } | ||
| }; | ||
| } | ||
| export { | ||
| sonentaRealtime | ||
| }; | ||
| //# sourceMappingURL=chunk-4XBXVJX2.js.map |
| {"version":3,"sources":["../src/react/plugin.tsx"],"sourcesContent":["/**\n * `@sonenta/realtime` as a PLUGIN of the `@sonenta/react-i18next` provider.\n * Adds zero-deploy translation updates for DEV versions: it reads the version's\n * dev/realtime state from the backend, and only for a dev version subscribes to\n * its Centrifugo channel and calls `ctx.i18n.reload(...)` on each\n * `translations_published` push.\n *\n * Dev-gating is PER-VERSION (backend contract #738), not based on the SDK's\n * `env` flag: a version is realtime-eligible until it has a published CDN\n * release. A published version returns `realtime_channel === null` → no\n * subscription, the bundle is served statically from the CDN.\n */\nimport { LiveClient, fetchCentrifugoToken } from \"../core/live\";\nimport { fetchVersionMeta } from \"../core/version\";\nimport type {\n SonentaPlugin,\n SonentaPluginContext,\n} from \"@sonenta/react-i18next\";\n\nexport interface SonentaRealtimeOptions {\n /**\n * WebSocket URL of the Centrifugo node, e.g.\n * `wss://rt.sonenta.dev/connection/websocket`. Required — the\n * plugin can't infer it from the API base.\n */\n wsUrl: string;\n /**\n * Endpoint that mints a short-lived Centrifugo connection token. Defaults to\n * `${apiBase}/v1/auth/centrifugo-token`. Override for self-hosted deployments.\n */\n tokenEndpoint?: string;\n}\n\nconst DEFAULT_API_BASE = \"https://api.sonenta.com\";\n\nfunction warn(msg: string, err?: unknown): void {\n if (typeof console !== \"undefined\") {\n if (err !== undefined) console.warn(`@sonenta/realtime: ${msg}`, err);\n else console.warn(`@sonenta/realtime: ${msg}`);\n }\n}\n\n/**\n * Build the realtime plugin. Add it to `<SonentaProvider plugins={[...]}>`:\n *\n * ```tsx\n * plugins={[sonentaRealtime({ wsUrl: \"wss://centrifugo.example/connection/websocket\" })]}\n * ```\n */\nexport function sonentaRealtime(\n options: SonentaRealtimeOptions,\n): SonentaPlugin {\n return {\n name: \"@sonenta/realtime\",\n setup(ctx: SonentaPluginContext) {\n const wsUrl = options.wsUrl;\n if (!wsUrl) {\n warn(\"wsUrl is empty; skipping subscription.\");\n return;\n }\n\n const apiBase = (ctx.config.apiBase ?? DEFAULT_API_BASE).replace(\n /\\/+$/,\n \"\",\n );\n const projectUuid = ctx.config.projectUuid;\n const apiToken = ctx.config.token;\n // Mirrors the core's version resolution (#739): `version` is the public\n // name, `versionSlug` a deprecated alias, default \"main\".\n const version = ctx.config.version ?? ctx.config.versionSlug ?? \"main\";\n const tokenEndpoint =\n options.tokenEndpoint ?? `${apiBase}/v1/auth/centrifugo-token`;\n\n // Set by teardown; checked before opening the socket so an unmount during\n // the async setup never leaves a LiveClient connected post-unmount.\n let cancelled = false;\n let live: LiveClient | null = null;\n\n void (async () => {\n // 1. Read the version's dev/realtime state.\n let meta;\n try {\n meta = await fetchVersionMeta(\n apiBase,\n projectUuid,\n version,\n apiToken,\n );\n } catch (err) {\n warn(`version metadata fetch failed for '${version}'`, err);\n return;\n }\n if (cancelled) return;\n\n // 3. Published version → no realtime; serve static CDN bundles.\n if (!meta.is_dev || !meta.realtime_channel) {\n warn(\n `realtime is only available on dev versions; '${version}' is published — no subscription, serving static CDN bundles.`,\n );\n return;\n }\n const channel = meta.realtime_channel;\n const versionUuid = meta.uuid;\n\n // 2. Dev version → mint a version-scoped translations token + subscribe.\n const refreshToken = async (): Promise<string> => {\n const { token } = await fetchCentrifugoToken(\n tokenEndpoint,\n projectUuid,\n apiToken,\n { kind: \"translations\", versionUuid },\n );\n return token;\n };\n\n let token: string;\n try {\n const minted = await fetchCentrifugoToken(\n tokenEndpoint,\n projectUuid,\n apiToken,\n { kind: \"translations\", versionUuid },\n );\n token = minted.token;\n } catch (err) {\n warn(\"live token mint failed\", err);\n return;\n }\n if (cancelled) return; // teardown ran during the awaits — do not connect.\n\n live = new LiveClient({\n url: wsUrl,\n token,\n channel,\n refreshToken,\n onMessage: (data) => {\n if (!data || typeof data !== \"object\") return;\n const d = data as {\n event?: string;\n language_code?: string;\n namespace_slug?: string;\n };\n if (d.event !== \"translations_published\") return;\n const lang = d.language_code;\n const ns = d.namespace_slug;\n if (!lang || !ns) return;\n void ctx.i18n.reload({ locale: lang, namespace: ns });\n },\n });\n live.connect();\n })();\n\n // Teardown: cancel the in-flight setup and dispose any open client.\n return () => {\n cancelled = true;\n if (live) {\n live.dispose();\n live = null;\n }\n };\n },\n };\n}\n"],"mappings":";;;;;;;AAiCA,IAAM,mBAAmB;AAEzB,SAAS,KAAK,KAAa,KAAqB;AAC9C,MAAI,OAAO,YAAY,aAAa;AAClC,QAAI,QAAQ,OAAW,SAAQ,KAAK,sBAAsB,GAAG,IAAI,GAAG;AAAA,QAC/D,SAAQ,KAAK,sBAAsB,GAAG,EAAE;AAAA,EAC/C;AACF;AASO,SAAS,gBACd,SACe;AACf,SAAO;AAAA,IACL,MAAM;AAAA,IACN,MAAM,KAA2B;AAC/B,YAAM,QAAQ,QAAQ;AACtB,UAAI,CAAC,OAAO;AACV,aAAK,wCAAwC;AAC7C;AAAA,MACF;AAEA,YAAM,WAAW,IAAI,OAAO,WAAW,kBAAkB;AAAA,QACvD;AAAA,QACA;AAAA,MACF;AACA,YAAM,cAAc,IAAI,OAAO;AAC/B,YAAM,WAAW,IAAI,OAAO;AAG5B,YAAM,UAAU,IAAI,OAAO,WAAW,IAAI,OAAO,eAAe;AAChE,YAAM,gBACJ,QAAQ,iBAAiB,GAAG,OAAO;AAIrC,UAAI,YAAY;AAChB,UAAI,OAA0B;AAE9B,YAAM,YAAY;AAEhB,YAAI;AACJ,YAAI;AACF,iBAAO,MAAM;AAAA,YACX;AAAA,YACA;AAAA,YACA;AAAA,YACA;AAAA,UACF;AAAA,QACF,SAAS,KAAK;AACZ,eAAK,sCAAsC,OAAO,KAAK,GAAG;AAC1D;AAAA,QACF;AACA,YAAI,UAAW;AAGf,YAAI,CAAC,KAAK,UAAU,CAAC,KAAK,kBAAkB;AAC1C;AAAA,YACE,gDAAgD,OAAO;AAAA,UACzD;AACA;AAAA,QACF;AACA,cAAM,UAAU,KAAK;AACrB,cAAM,cAAc,KAAK;AAGzB,cAAM,eAAe,YAA6B;AAChD,gBAAM,EAAE,OAAAA,OAAM,IAAI,MAAM;AAAA,YACtB;AAAA,YACA;AAAA,YACA;AAAA,YACA,EAAE,MAAM,gBAAgB,YAAY;AAAA,UACtC;AACA,iBAAOA;AAAA,QACT;AAEA,YAAI;AACJ,YAAI;AACF,gBAAM,SAAS,MAAM;AAAA,YACnB;AAAA,YACA;AAAA,YACA;AAAA,YACA,EAAE,MAAM,gBAAgB,YAAY;AAAA,UACtC;AACA,kBAAQ,OAAO;AAAA,QACjB,SAAS,KAAK;AACZ,eAAK,0BAA0B,GAAG;AAClC;AAAA,QACF;AACA,YAAI,UAAW;AAEf,eAAO,IAAI,WAAW;AAAA,UACpB,KAAK;AAAA,UACL;AAAA,UACA;AAAA,UACA;AAAA,UACA,WAAW,CAAC,SAAS;AACnB,gBAAI,CAAC,QAAQ,OAAO,SAAS,SAAU;AACvC,kBAAM,IAAI;AAKV,gBAAI,EAAE,UAAU,yBAA0B;AAC1C,kBAAM,OAAO,EAAE;AACf,kBAAM,KAAK,EAAE;AACb,gBAAI,CAAC,QAAQ,CAAC,GAAI;AAClB,iBAAK,IAAI,KAAK,OAAO,EAAE,QAAQ,MAAM,WAAW,GAAG,CAAC;AAAA,UACtD;AAAA,QACF,CAAC;AACD,aAAK,QAAQ;AAAA,MACf,GAAG;AAGH,aAAO,MAAM;AACX,oBAAY;AACZ,YAAI,MAAM;AACR,eAAK,QAAQ;AACb,iBAAO;AAAA,QACT;AAAA,MACF;AAAA,IACF;AAAA,EACF;AACF;","names":["token"]} |
145015
8.85%40
2.56%1320
1.85%111
42.31%