@sonenta/realtime
Advanced tools
| import { | ||
| LiveClient, | ||
| fetchCentrifugoToken, | ||
| fetchVersionMeta | ||
| } from "./chunk-2EOI7ZJZ.js"; | ||
| // src/react/plugin.tsx | ||
| var DEFAULT_API_BASE = "https://api.sonenta.dev"; | ||
| 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-6H3ZB4AE.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.dev\";\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"]} |
+19
-0
| # Changelog — @sonenta/realtime | ||
| ## 2.1.0 | ||
| **Actionable:** only if you never set `apiBase` AND you filter outbound domains. The compiled | ||
| default moves from `https://api.sonenta.com` to `https://api.sonenta.dev`. Both hosts serve the same | ||
| API today, so nothing breaks on the wire — but if you allowlist domains in a CSP, firewall or | ||
| corporate proxy, add `api.sonenta.dev`, or pin `apiBase: "https://api.sonenta.com"`. This is the | ||
| only way a minor can break you, which is why it is the first line here. | ||
| ### The default `apiBase` is now `https://api.sonenta.dev` | ||
| `api.sonenta.dev` is the canonical API host. Only the COMPILED DEFAULT changed: if you pass | ||
| `apiBase` explicitly, nothing about your app changes. | ||
| Verified rather than assumed: a new test instantiates the SDK with **no** `apiBase` and asserts that | ||
| every API request it makes uses the default host — checking *all* of them, not the first, so a | ||
| partial migration fails the build instead of a customer's app. It is mutation-verified: it fails on | ||
| the previous default. Before it existed nothing in the monorepo asserted the compiled default at | ||
| all — every test passed `apiBase` explicitly, so the suite stayed green whichever host was baked in. | ||
| ## 2.0.0 | ||
@@ -4,0 +23,0 @@ |
@@ -152,3 +152,3 @@ "use strict"; | ||
| // src/react/plugin.tsx | ||
| var DEFAULT_API_BASE = "https://api.sonenta.com"; | ||
| var DEFAULT_API_BASE = "https://api.sonenta.dev"; | ||
| function warn(msg, err) { | ||
@@ -155,0 +155,0 @@ if (typeof console !== "undefined") { |
@@ -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 * 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"]} | ||
| {"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 * 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.dev\";\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"]} |
@@ -1,2 +0,25 @@ | ||
| export { SonentaRealtimeOptions, sonentaRealtime } from '../svelte/index.cjs'; | ||
| import '@sonenta/react-i18next'; | ||
| import { SonentaPlugin } from '@sonenta/react-i18next'; | ||
| interface SonentaRealtimeOptions { | ||
| /** | ||
| * WebSocket URL of the Centrifugo node, e.g. | ||
| * `wss://rt.sonenta.dev/connection/websocket`. Required — the | ||
| * plugin can't infer it from the API base. | ||
| */ | ||
| wsUrl: string; | ||
| /** | ||
| * Endpoint that mints a short-lived Centrifugo connection token. Defaults to | ||
| * `${apiBase}/v1/auth/centrifugo-token`. Override for self-hosted deployments. | ||
| */ | ||
| tokenEndpoint?: string; | ||
| } | ||
| /** | ||
| * Build the realtime plugin. Add it to `<SonentaProvider plugins={[...]}>`: | ||
| * | ||
| * ```tsx | ||
| * plugins={[sonentaRealtime({ wsUrl: "wss://centrifugo.example/connection/websocket" })]} | ||
| * ``` | ||
| */ | ||
| declare function sonentaRealtime(options: SonentaRealtimeOptions): SonentaPlugin; | ||
| export { type SonentaRealtimeOptions, sonentaRealtime }; |
@@ -1,2 +0,25 @@ | ||
| export { SonentaRealtimeOptions, sonentaRealtime } from '../svelte/index.js'; | ||
| import '@sonenta/react-i18next'; | ||
| import { SonentaPlugin } from '@sonenta/react-i18next'; | ||
| interface SonentaRealtimeOptions { | ||
| /** | ||
| * WebSocket URL of the Centrifugo node, e.g. | ||
| * `wss://rt.sonenta.dev/connection/websocket`. Required — the | ||
| * plugin can't infer it from the API base. | ||
| */ | ||
| wsUrl: string; | ||
| /** | ||
| * Endpoint that mints a short-lived Centrifugo connection token. Defaults to | ||
| * `${apiBase}/v1/auth/centrifugo-token`. Override for self-hosted deployments. | ||
| */ | ||
| tokenEndpoint?: string; | ||
| } | ||
| /** | ||
| * Build the realtime plugin. Add it to `<SonentaProvider plugins={[...]}>`: | ||
| * | ||
| * ```tsx | ||
| * plugins={[sonentaRealtime({ wsUrl: "wss://centrifugo.example/connection/websocket" })]} | ||
| * ``` | ||
| */ | ||
| declare function sonentaRealtime(options: SonentaRealtimeOptions): SonentaPlugin; | ||
| export { type SonentaRealtimeOptions, sonentaRealtime }; |
| import { | ||
| sonentaRealtime | ||
| } from "../chunk-SWHAKZ5H.js"; | ||
| } from "../chunk-6H3ZB4AE.js"; | ||
| import "../chunk-2EOI7ZJZ.js"; | ||
@@ -5,0 +5,0 @@ export { |
@@ -152,3 +152,3 @@ "use strict"; | ||
| // src/react/plugin.tsx | ||
| var DEFAULT_API_BASE = "https://api.sonenta.com"; | ||
| var DEFAULT_API_BASE = "https://api.sonenta.dev"; | ||
| function warn(msg, err) { | ||
@@ -155,0 +155,0 @@ if (typeof console !== "undefined") { |
@@ -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 // 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"]} | ||
| {"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.dev\";\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"]} |
@@ -1,25 +0,2 @@ | ||
| import { SonentaPlugin } from '@sonenta/react-i18next'; | ||
| interface SonentaRealtimeOptions { | ||
| /** | ||
| * WebSocket URL of the Centrifugo node, e.g. | ||
| * `wss://rt.sonenta.dev/connection/websocket`. Required — the | ||
| * plugin can't infer it from the API base. | ||
| */ | ||
| wsUrl: string; | ||
| /** | ||
| * Endpoint that mints a short-lived Centrifugo connection token. Defaults to | ||
| * `${apiBase}/v1/auth/centrifugo-token`. Override for self-hosted deployments. | ||
| */ | ||
| tokenEndpoint?: string; | ||
| } | ||
| /** | ||
| * Build the realtime plugin. Add it to `<SonentaProvider plugins={[...]}>`: | ||
| * | ||
| * ```tsx | ||
| * plugins={[sonentaRealtime({ wsUrl: "wss://centrifugo.example/connection/websocket" })]} | ||
| * ``` | ||
| */ | ||
| declare function sonentaRealtime(options: SonentaRealtimeOptions): SonentaPlugin; | ||
| export { type SonentaRealtimeOptions, sonentaRealtime }; | ||
| export { SonentaRealtimeOptions, sonentaRealtime } from '../react/index.cjs'; | ||
| import '@sonenta/react-i18next'; |
@@ -1,25 +0,2 @@ | ||
| import { SonentaPlugin } from '@sonenta/react-i18next'; | ||
| interface SonentaRealtimeOptions { | ||
| /** | ||
| * WebSocket URL of the Centrifugo node, e.g. | ||
| * `wss://rt.sonenta.dev/connection/websocket`. Required — the | ||
| * plugin can't infer it from the API base. | ||
| */ | ||
| wsUrl: string; | ||
| /** | ||
| * Endpoint that mints a short-lived Centrifugo connection token. Defaults to | ||
| * `${apiBase}/v1/auth/centrifugo-token`. Override for self-hosted deployments. | ||
| */ | ||
| tokenEndpoint?: string; | ||
| } | ||
| /** | ||
| * Build the realtime plugin. Add it to `<SonentaProvider plugins={[...]}>`: | ||
| * | ||
| * ```tsx | ||
| * plugins={[sonentaRealtime({ wsUrl: "wss://centrifugo.example/connection/websocket" })]} | ||
| * ``` | ||
| */ | ||
| declare function sonentaRealtime(options: SonentaRealtimeOptions): SonentaPlugin; | ||
| export { type SonentaRealtimeOptions, sonentaRealtime }; | ||
| export { SonentaRealtimeOptions, sonentaRealtime } from '../react/index.js'; | ||
| import '@sonenta/react-i18next'; |
| import { | ||
| sonentaRealtime | ||
| } from "../chunk-SWHAKZ5H.js"; | ||
| } from "../chunk-6H3ZB4AE.js"; | ||
| import "../chunk-2EOI7ZJZ.js"; | ||
@@ -5,0 +5,0 @@ export { |
@@ -152,3 +152,3 @@ "use strict"; | ||
| // src/react/plugin.tsx | ||
| var DEFAULT_API_BASE = "https://api.sonenta.com"; | ||
| var DEFAULT_API_BASE = "https://api.sonenta.dev"; | ||
| function warn(msg, err) { | ||
@@ -155,0 +155,0 @@ if (typeof console !== "undefined") { |
@@ -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 // 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"]} | ||
| {"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.dev\";\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"]} |
@@ -1,2 +0,2 @@ | ||
| export { SonentaRealtimeOptions, sonentaRealtime } from '../svelte/index.cjs'; | ||
| export { SonentaRealtimeOptions, sonentaRealtime } from '../react/index.cjs'; | ||
| import '@sonenta/react-i18next'; |
@@ -1,2 +0,2 @@ | ||
| export { SonentaRealtimeOptions, sonentaRealtime } from '../svelte/index.js'; | ||
| export { SonentaRealtimeOptions, sonentaRealtime } from '../react/index.js'; | ||
| import '@sonenta/react-i18next'; |
| import { | ||
| sonentaRealtime | ||
| } from "../chunk-SWHAKZ5H.js"; | ||
| } from "../chunk-6H3ZB4AE.js"; | ||
| import "../chunk-2EOI7ZJZ.js"; | ||
@@ -5,0 +5,0 @@ export { |
+2
-2
| { | ||
| "name": "@sonenta/realtime", | ||
| "version": "2.0.0", | ||
| "version": "2.1.0", | ||
| "description": "Realtime translation updates plugin for @sonenta/react-i18next (Centrifugo).", | ||
@@ -81,3 +81,3 @@ "license": "MIT", | ||
| "vitest": "^2.1.0", | ||
| "@sonenta/react-i18next": "3.0.0" | ||
| "@sonenta/react-i18next": "3.1.0" | ||
| }, | ||
@@ -84,0 +84,0 @@ "publishConfig": { |
+1
-1
@@ -71,3 +71,3 @@ # @sonenta/realtime | ||
| <SonentaProvider | ||
| token="vrb_live_…" | ||
| token="snt_live_…" | ||
| projectUuid="<project-uuid>" | ||
@@ -74,0 +74,0 @@ defaultLocale="fr" |
| 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"]} |
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
146292
0.81%