Sign In

@sonenta/realtime

Package Overview
Dependencies
Maintainers
1
Versions
6
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

@sonenta/realtime - npm Package Compare versions

Comparing version
1.0.0
to
1.1.0
+1
-1
dist/react/index.cjs

@@ -153,3 +153,3 @@ "use strict";

// src/react/plugin.tsx
var DEFAULT_API_BASE = "https://api.verbumia.dev";
var DEFAULT_API_BASE = "https://api.sonenta.com";
function warn(msg, err) {

@@ -156,0 +156,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/* Deprecated `verbumia*` aliases (Verbumia → Sonenta rebrand, 1.0.0).\n * Kept for back-compat; removed in a future major. Prefer `sonentaRealtime`. */\n/** @deprecated Renamed to {@link sonentaRealtime} in 1.0.0. */\nexport { sonentaRealtime as verbumiaRealtime } from \"./plugin\";\nexport type {\n /** @deprecated Renamed to `SonentaRealtimeOptions` in 1.0.0. */\n SonentaRealtimeOptions as VerbumiaRealtimeOptions,\n} from \"./plugin\";\n","/**\n * Tiny Centrifugo WebSocket client tailored to the Sonenta\n * `translations:` channel. Hand-rolled (no `centrifuge-js` dep) so the SDK\n * stays under 15 KB gzipped — we only need: connect, subscribe, listen.\n *\n * Wire format reference:\n * https://centrifugal.dev/docs/transports/websocket\n *\n * Lifecycle:\n * - call connect() with a valid token\n * - server replies with {push|reply}; we wait for the connect ack\n * - subscribe(channel) — send subscribe command, wait for ack\n * - subsequent {push, channel, pub.data} are routed to onMessage\n * - reconnect with exponential backoff (capped at 30s) on close\n */\n\nexport interface LiveClientConfig {\n url: string;\n token: string;\n channel: string;\n onMessage: (data: unknown) => void;\n onStatus?: (status: \"connecting\" | \"connected\" | \"disconnected\") => void;\n /**\n * Hook called just before each connect attempt — return a fresh token\n * (used to refresh a token whose `exp` is close). Defaults to the\n * static one passed in `token`.\n */\n refreshToken?: () => Promise<string>;\n}\n\nexport class LiveClient {\n private _ws: WebSocket | null = null;\n private _id = 0;\n private _backoffMs = 1_000;\n private _disposed = false;\n private _connectAcked = false;\n\n constructor(private readonly cfg: LiveClientConfig) {}\n\n /** Open the socket and try to subscribe. Idempotent — calling twice is a no-op. */\n async connect(): Promise<void> {\n if (this._ws) return;\n if (this._disposed) return;\n this.cfg.onStatus?.(\"connecting\");\n const token = this.cfg.refreshToken ? await this.cfg.refreshToken() : this.cfg.token;\n // Centrifugo's WebSocket endpoint lives at `/connection/websocket`.\n // Be forgiving on the input — accept either a bare host\n // (`wss://centrifugo.example`) or the full path.\n let url = this.cfg.url;\n if (!url.includes(\"/connection/websocket\")) {\n url = url.replace(/\\/+$/, \"\") + \"/connection/websocket\";\n }\n const ws = new WebSocket(url);\n this._ws = ws;\n this._connectAcked = false;\n\n ws.onopen = () => {\n // Centrifugo command: connect with token\n this._send({ id: ++this._id, connect: { token } });\n };\n ws.onmessage = (evt) => this._onFrame(evt.data);\n ws.onclose = () => this._onClose();\n ws.onerror = () => {\n // Let onclose handle the reconnect.\n };\n }\n\n dispose(): void {\n this._disposed = true;\n if (this._ws) {\n try {\n this._ws.close();\n } catch {\n // ignore\n }\n this._ws = null;\n }\n this.cfg.onStatus?.(\"disconnected\");\n }\n\n // ---- internals ----\n\n private _send(msg: unknown): void {\n if (!this._ws || this._ws.readyState !== WebSocket.OPEN) return;\n try {\n this._ws.send(JSON.stringify(msg));\n } catch {\n // ignore — onclose will fire shortly\n }\n }\n\n private _onFrame(raw: unknown): void {\n let parsed: {\n id?: number;\n connect?: { client?: string };\n subscribe?: unknown;\n push?: { channel?: string; pub?: { data?: unknown }; sub?: unknown };\n } | undefined;\n try {\n parsed = JSON.parse(raw as string);\n } catch {\n return;\n }\n if (!parsed || typeof parsed !== \"object\") return;\n // Centrifugo v2 protocol pings: server periodically sends an empty\n // object `{}`. Client MUST reply with an empty `{}` to keep the\n // connection alive — without this the server force-closes after\n // ~25-30s and we lose pushes silently.\n if (Object.keys(parsed).length === 0) {\n this._send({});\n return;\n }\n if (parsed.connect && !this._connectAcked) {\n this._connectAcked = true;\n this.cfg.onStatus?.(\"connected\");\n this._backoffMs = 1_000;\n // Connection token's `channels` claim auto-subscribes us server-side,\n // so an explicit subscribe command is unnecessary. We still send it\n // for back-compat with anonymous-channel deployments where no\n // server-side subscription was created.\n this._send({\n id: ++this._id,\n subscribe: { channel: this.cfg.channel },\n });\n return;\n }\n const push = parsed.push;\n if (push && push.channel === this.cfg.channel && push.pub) {\n this.cfg.onMessage(push.pub.data);\n }\n }\n\n private _onClose(): void {\n this._ws = null;\n this._connectAcked = false;\n this.cfg.onStatus?.(\"disconnected\");\n if (this._disposed) return;\n const delay = this._backoffMs;\n this._backoffMs = Math.min(this._backoffMs * 2, 30_000);\n setTimeout(() => {\n if (!this._disposed) void this.connect();\n }, delay);\n }\n}\n\n/**\n * Fetch a fresh Centrifugo connection token from the backend\n * (`POST /v1/auth/centrifugo-token`). For version-scoped translations\n * realtime, pass `kind: \"translations\"` + the `versionUuid`; the backend\n * grants the version channel only while that version is dev (contract #738).\n * Response: `{token, channel, expires_at}`.\n */\nexport async function fetchCentrifugoToken(\n endpoint: string,\n projectUuid: string,\n authToken: string,\n opts: {\n kind?: \"translations\" | \"missing\" | \"feedback\";\n versionUuid?: string;\n } = {},\n fetchImpl: typeof fetch = fetch,\n): Promise<{ token: string; channel: string; expires_at: number }> {\n const body: Record<string, unknown> = { project_uuid: projectUuid };\n if (opts.kind) body.kind = opts.kind;\n if (opts.versionUuid) body.version_uuid = opts.versionUuid;\n const r = await fetchImpl(endpoint, {\n method: \"POST\",\n headers: {\n \"Content-Type\": \"application/json\",\n Authorization: `ApiKey ${authToken}`,\n },\n body: JSON.stringify(body),\n });\n if (!r.ok) {\n throw new Error(`centrifugo-token endpoint ${r.status}: ${await r.text()}`);\n }\n return (await r.json()) as { token: string; channel: string; expires_at: number };\n}\n","/**\n * Version metadata used to gate dev-only realtime (backend contract #738).\n * The SDK-facing surface is intentionally just `is_dev` + `realtime_channel`\n * (plus `uuid`, needed for the version-scoped token); the rest is informative.\n */\nexport interface VersionMeta {\n uuid: string;\n project_uuid: string;\n name?: string;\n slug?: string;\n is_production?: boolean;\n /** true while the version has no published CDN release (= realtime-eligible). */\n is_dev: boolean;\n /** Centrifugo channel for dev versions; `null` once the version is published. */\n realtime_channel: string | null;\n}\n\n/**\n * `GET /v1/projects/{projectUuid}/versions/{idOrSlug}` — resolve a version by\n * uuid OR slug and read its dev/realtime state. Auth: project viewer (API key).\n */\nexport async function fetchVersionMeta(\n apiBase: string,\n projectUuid: string,\n idOrSlug: string,\n authToken: string,\n fetchImpl: typeof fetch = fetch,\n): Promise<VersionMeta> {\n const url = `${apiBase.replace(/\\/+$/, \"\")}/v1/projects/${projectUuid}/versions/${encodeURIComponent(\n idOrSlug,\n )}`;\n const r = await fetchImpl(url, {\n method: \"GET\",\n headers: { Authorization: `ApiKey ${authToken}` },\n credentials: \"omit\",\n });\n if (!r.ok) {\n throw new Error(`version metadata endpoint ${r.status}: ${await r.text()}`);\n }\n return (await r.json()) as VersionMeta;\n}\n","/**\n * `@sonenta/realtime` as a PLUGIN of the `@sonenta/react-i18next` provider.\n * Adds zero-deploy translation updates for DEV versions: it reads the version's\n * dev/realtime state from the backend, and only for a dev version subscribes to\n * its Centrifugo channel and calls `ctx.i18n.reload(...)` on each\n * `translations_published` push.\n *\n * Dev-gating is PER-VERSION (backend contract #738), not based on the SDK's\n * `env` flag: a version is realtime-eligible until it has a published CDN\n * release. A published version returns `realtime_channel === null` → no\n * subscription, the bundle is served statically from the CDN.\n */\nimport { LiveClient, fetchCentrifugoToken } from \"../core/live\";\nimport { fetchVersionMeta } from \"../core/version\";\nimport type {\n SonentaPlugin,\n SonentaPluginContext,\n} from \"@sonenta/react-i18next\";\n\nexport interface SonentaRealtimeOptions {\n /**\n * WebSocket URL of the Centrifugo node, e.g.\n * `wss://rt.verbumia.ca/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.verbumia.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 void (async () => {\n // 1. Read the version's dev/realtime state.\n let meta;\n try {\n meta = await fetchVersionMeta(\n apiBase,\n projectUuid,\n version,\n apiToken,\n );\n } catch (err) {\n warn(`version metadata fetch failed for '${version}'`, err);\n return;\n }\n if (cancelled) return;\n\n // 3. Published version → no realtime; serve static CDN bundles.\n if (!meta.is_dev || !meta.realtime_channel) {\n warn(\n `realtime is only available on dev versions; '${version}' is published — no subscription, serving static CDN bundles.`,\n );\n return;\n }\n const channel = meta.realtime_channel;\n const versionUuid = meta.uuid;\n\n // 2. Dev version → mint a version-scoped translations token + subscribe.\n const refreshToken = async (): Promise<string> => {\n const { token } = await fetchCentrifugoToken(\n tokenEndpoint,\n projectUuid,\n apiToken,\n { kind: \"translations\", versionUuid },\n );\n return token;\n };\n\n let token: string;\n try {\n const minted = await fetchCentrifugoToken(\n tokenEndpoint,\n projectUuid,\n apiToken,\n { kind: \"translations\", versionUuid },\n );\n token = minted.token;\n } catch (err) {\n warn(\"live token mint failed\", err);\n return;\n }\n if (cancelled) return; // teardown ran during the awaits — do not connect.\n\n live = new LiveClient({\n url: wsUrl,\n token,\n channel,\n refreshToken,\n onMessage: (data) => {\n if (!data || typeof data !== \"object\") return;\n const d = data as {\n event?: string;\n language_code?: string;\n namespace_slug?: string;\n };\n if (d.event !== \"translations_published\") return;\n const lang = d.language_code;\n const ns = d.namespace_slug;\n if (!lang || !ns) return;\n void ctx.i18n.reload({ locale: lang, namespace: ns });\n },\n });\n live.connect();\n })();\n\n // Teardown: cancel the in-flight setup and dispose any open client.\n return () => {\n cancelled = true;\n if (live) {\n live.dispose();\n live = null;\n }\n };\n },\n };\n}\n"],"mappings":"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","names":["token"]}
{"version":3,"sources":["../../src/react/index.ts","../../src/core/live.ts","../../src/core/version.ts","../../src/react/plugin.tsx"],"sourcesContent":["export { sonentaRealtime } from \"./plugin\";\nexport type { SonentaRealtimeOptions } from \"./plugin\";\n\n/* Deprecated `verbumia*` aliases (Verbumia → Sonenta rebrand, 1.0.0).\n * Kept for back-compat; removed in a future major. Prefer `sonentaRealtime`. */\n/** @deprecated Renamed to {@link sonentaRealtime} in 1.0.0. */\nexport { sonentaRealtime as verbumiaRealtime } from \"./plugin\";\nexport type {\n /** @deprecated Renamed to `SonentaRealtimeOptions` in 1.0.0. */\n SonentaRealtimeOptions as VerbumiaRealtimeOptions,\n} from \"./plugin\";\n","/**\n * Tiny Centrifugo WebSocket client tailored to the Sonenta\n * `translations:` channel. Hand-rolled (no `centrifuge-js` dep) so the SDK\n * stays under 15 KB gzipped — we only need: connect, subscribe, listen.\n *\n * Wire format reference:\n * https://centrifugal.dev/docs/transports/websocket\n *\n * Lifecycle:\n * - call connect() with a valid token\n * - server replies with {push|reply}; we wait for the connect ack\n * - subscribe(channel) — send subscribe command, wait for ack\n * - subsequent {push, channel, pub.data} are routed to onMessage\n * - reconnect with exponential backoff (capped at 30s) on close\n */\n\nexport interface LiveClientConfig {\n url: string;\n token: string;\n channel: string;\n onMessage: (data: unknown) => void;\n onStatus?: (status: \"connecting\" | \"connected\" | \"disconnected\") => void;\n /**\n * Hook called just before each connect attempt — return a fresh token\n * (used to refresh a token whose `exp` is close). Defaults to the\n * static one passed in `token`.\n */\n refreshToken?: () => Promise<string>;\n}\n\nexport class LiveClient {\n private _ws: WebSocket | null = null;\n private _id = 0;\n private _backoffMs = 1_000;\n private _disposed = false;\n private _connectAcked = false;\n\n constructor(private readonly cfg: LiveClientConfig) {}\n\n /** Open the socket and try to subscribe. Idempotent — calling twice is a no-op. */\n async connect(): Promise<void> {\n if (this._ws) return;\n if (this._disposed) return;\n this.cfg.onStatus?.(\"connecting\");\n const token = this.cfg.refreshToken ? await this.cfg.refreshToken() : this.cfg.token;\n // Centrifugo's WebSocket endpoint lives at `/connection/websocket`.\n // Be forgiving on the input — accept either a bare host\n // (`wss://centrifugo.example`) or the full path.\n let url = this.cfg.url;\n if (!url.includes(\"/connection/websocket\")) {\n url = url.replace(/\\/+$/, \"\") + \"/connection/websocket\";\n }\n const ws = new WebSocket(url);\n this._ws = ws;\n this._connectAcked = false;\n\n ws.onopen = () => {\n // Centrifugo command: connect with token\n this._send({ id: ++this._id, connect: { token } });\n };\n ws.onmessage = (evt) => this._onFrame(evt.data);\n ws.onclose = () => this._onClose();\n ws.onerror = () => {\n // Let onclose handle the reconnect.\n };\n }\n\n dispose(): void {\n this._disposed = true;\n if (this._ws) {\n try {\n this._ws.close();\n } catch {\n // ignore\n }\n this._ws = null;\n }\n this.cfg.onStatus?.(\"disconnected\");\n }\n\n // ---- internals ----\n\n private _send(msg: unknown): void {\n if (!this._ws || this._ws.readyState !== WebSocket.OPEN) return;\n try {\n this._ws.send(JSON.stringify(msg));\n } catch {\n // ignore — onclose will fire shortly\n }\n }\n\n private _onFrame(raw: unknown): void {\n let parsed: {\n id?: number;\n connect?: { client?: string };\n subscribe?: unknown;\n push?: { channel?: string; pub?: { data?: unknown }; sub?: unknown };\n } | undefined;\n try {\n parsed = JSON.parse(raw as string);\n } catch {\n return;\n }\n if (!parsed || typeof parsed !== \"object\") return;\n // Centrifugo v2 protocol pings: server periodically sends an empty\n // object `{}`. Client MUST reply with an empty `{}` to keep the\n // connection alive — without this the server force-closes after\n // ~25-30s and we lose pushes silently.\n if (Object.keys(parsed).length === 0) {\n this._send({});\n return;\n }\n if (parsed.connect && !this._connectAcked) {\n this._connectAcked = true;\n this.cfg.onStatus?.(\"connected\");\n this._backoffMs = 1_000;\n // Connection token's `channels` claim auto-subscribes us server-side,\n // so an explicit subscribe command is unnecessary. We still send it\n // for back-compat with anonymous-channel deployments where no\n // server-side subscription was created.\n this._send({\n id: ++this._id,\n subscribe: { channel: this.cfg.channel },\n });\n return;\n }\n const push = parsed.push;\n if (push && push.channel === this.cfg.channel && push.pub) {\n this.cfg.onMessage(push.pub.data);\n }\n }\n\n private _onClose(): void {\n this._ws = null;\n this._connectAcked = false;\n this.cfg.onStatus?.(\"disconnected\");\n if (this._disposed) return;\n const delay = this._backoffMs;\n this._backoffMs = Math.min(this._backoffMs * 2, 30_000);\n setTimeout(() => {\n if (!this._disposed) void this.connect();\n }, delay);\n }\n}\n\n/**\n * Fetch a fresh Centrifugo connection token from the backend\n * (`POST /v1/auth/centrifugo-token`). For version-scoped translations\n * realtime, pass `kind: \"translations\"` + the `versionUuid`; the backend\n * grants the version channel only while that version is dev (contract #738).\n * Response: `{token, channel, expires_at}`.\n */\nexport async function fetchCentrifugoToken(\n endpoint: string,\n projectUuid: string,\n authToken: string,\n opts: {\n kind?: \"translations\" | \"missing\" | \"feedback\";\n versionUuid?: string;\n } = {},\n fetchImpl: typeof fetch = fetch,\n): Promise<{ token: string; channel: string; expires_at: number }> {\n const body: Record<string, unknown> = { project_uuid: projectUuid };\n if (opts.kind) body.kind = opts.kind;\n if (opts.versionUuid) body.version_uuid = opts.versionUuid;\n const r = await fetchImpl(endpoint, {\n method: \"POST\",\n headers: {\n \"Content-Type\": \"application/json\",\n Authorization: `ApiKey ${authToken}`,\n },\n body: JSON.stringify(body),\n });\n if (!r.ok) {\n throw new Error(`centrifugo-token endpoint ${r.status}: ${await r.text()}`);\n }\n return (await r.json()) as { token: string; channel: string; expires_at: number };\n}\n","/**\n * Version metadata used to gate dev-only realtime (backend contract #738).\n * The SDK-facing surface is intentionally just `is_dev` + `realtime_channel`\n * (plus `uuid`, needed for the version-scoped token); the rest is informative.\n */\nexport interface VersionMeta {\n uuid: string;\n project_uuid: string;\n name?: string;\n slug?: string;\n is_production?: boolean;\n /** true while the version has no published CDN release (= realtime-eligible). */\n is_dev: boolean;\n /** Centrifugo channel for dev versions; `null` once the version is published. */\n realtime_channel: string | null;\n}\n\n/**\n * `GET /v1/projects/{projectUuid}/versions/{idOrSlug}` — resolve a version by\n * uuid OR slug and read its dev/realtime state. Auth: project viewer (API key).\n */\nexport async function fetchVersionMeta(\n apiBase: string,\n projectUuid: string,\n idOrSlug: string,\n authToken: string,\n fetchImpl: typeof fetch = fetch,\n): Promise<VersionMeta> {\n const url = `${apiBase.replace(/\\/+$/, \"\")}/v1/projects/${projectUuid}/versions/${encodeURIComponent(\n idOrSlug,\n )}`;\n const r = await fetchImpl(url, {\n method: \"GET\",\n headers: { Authorization: `ApiKey ${authToken}` },\n credentials: \"omit\",\n });\n if (!r.ok) {\n throw new Error(`version metadata endpoint ${r.status}: ${await r.text()}`);\n }\n return (await r.json()) as VersionMeta;\n}\n","/**\n * `@sonenta/realtime` as a PLUGIN of the `@sonenta/react-i18next` provider.\n * Adds zero-deploy translation updates for DEV versions: it reads the version's\n * dev/realtime state from the backend, and only for a dev version subscribes to\n * its Centrifugo channel and calls `ctx.i18n.reload(...)` on each\n * `translations_published` push.\n *\n * Dev-gating is PER-VERSION (backend contract #738), not based on the SDK's\n * `env` flag: a version is realtime-eligible until it has a published CDN\n * release. A published version returns `realtime_channel === null` → no\n * subscription, the bundle is served statically from the CDN.\n */\nimport { LiveClient, fetchCentrifugoToken } from \"../core/live\";\nimport { fetchVersionMeta } from \"../core/version\";\nimport type {\n SonentaPlugin,\n SonentaPluginContext,\n} from \"@sonenta/react-i18next\";\n\nexport interface SonentaRealtimeOptions {\n /**\n * WebSocket URL of the Centrifugo node, e.g.\n * `wss://rt.sonenta.dev/connection/websocket`. Required — the\n * plugin can't infer it from the API base.\n */\n wsUrl: string;\n /**\n * Endpoint that mints a short-lived Centrifugo connection token. Defaults to\n * `${apiBase}/v1/auth/centrifugo-token`. Override for self-hosted deployments.\n */\n tokenEndpoint?: string;\n}\n\nconst DEFAULT_API_BASE = \"https://api.sonenta.com\";\n\nfunction warn(msg: string, err?: unknown): void {\n if (typeof console !== \"undefined\") {\n if (err !== undefined) console.warn(`@sonenta/realtime: ${msg}`, err);\n else console.warn(`@sonenta/realtime: ${msg}`);\n }\n}\n\n/**\n * Build the realtime plugin. Add it to `<SonentaProvider plugins={[...]}>`:\n *\n * ```tsx\n * plugins={[sonentaRealtime({ wsUrl: \"wss://centrifugo.example/connection/websocket\" })]}\n * ```\n */\nexport function sonentaRealtime(\n options: SonentaRealtimeOptions,\n): SonentaPlugin {\n return {\n name: \"@sonenta/realtime\",\n setup(ctx: SonentaPluginContext) {\n const wsUrl = options.wsUrl;\n if (!wsUrl) {\n warn(\"wsUrl is empty; skipping subscription.\");\n return;\n }\n\n const apiBase = (ctx.config.apiBase ?? DEFAULT_API_BASE).replace(\n /\\/+$/,\n \"\",\n );\n const projectUuid = ctx.config.projectUuid;\n const apiToken = ctx.config.token;\n // Mirrors the core's version resolution (#739): `version` is the public\n // name, `versionSlug` a deprecated alias, default \"main\".\n const version = ctx.config.version ?? ctx.config.versionSlug ?? \"main\";\n const tokenEndpoint =\n options.tokenEndpoint ?? `${apiBase}/v1/auth/centrifugo-token`;\n\n // Set by teardown; checked before opening the socket so an unmount during\n // the async setup never leaves a LiveClient connected post-unmount.\n let cancelled = false;\n let live: LiveClient | null = null;\n\n 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"]}

@@ -6,3 +6,3 @@ import { SonentaPlugin } from '@sonenta/react-i18next';

* WebSocket URL of the Centrifugo node, e.g.
* `wss://rt.verbumia.ca/connection/websocket`. Required — the
* `wss://rt.sonenta.dev/connection/websocket`. Required — the
* plugin can't infer it from the API base.

@@ -9,0 +9,0 @@ */

@@ -6,3 +6,3 @@ import { SonentaPlugin } from '@sonenta/react-i18next';

* WebSocket URL of the Centrifugo node, e.g.
* `wss://rt.verbumia.ca/connection/websocket`. Required — the
* `wss://rt.sonenta.dev/connection/websocket`. Required — the
* plugin can't infer it from the API base.

@@ -9,0 +9,0 @@ */

@@ -8,3 +8,3 @@ import {

// src/react/plugin.tsx
var DEFAULT_API_BASE = "https://api.verbumia.dev";
var DEFAULT_API_BASE = "https://api.sonenta.com";
function warn(msg, err) {

@@ -11,0 +11,0 @@ if (typeof console !== "undefined") {

@@ -1,1 +0,1 @@

{"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.verbumia.ca/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.verbumia.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 void (async () => {\n // 1. Read the version's dev/realtime state.\n let meta;\n try {\n meta = await fetchVersionMeta(\n apiBase,\n projectUuid,\n version,\n apiToken,\n );\n } catch (err) {\n warn(`version metadata fetch failed for '${version}'`, err);\n return;\n }\n if (cancelled) return;\n\n // 3. Published version → no realtime; serve static CDN bundles.\n if (!meta.is_dev || !meta.realtime_channel) {\n warn(\n `realtime is only available on dev versions; '${version}' is published — no subscription, serving static CDN bundles.`,\n );\n return;\n }\n const channel = meta.realtime_channel;\n const versionUuid = meta.uuid;\n\n // 2. Dev version → mint a version-scoped translations token + subscribe.\n const refreshToken = async (): Promise<string> => {\n const { token } = await fetchCentrifugoToken(\n tokenEndpoint,\n projectUuid,\n apiToken,\n { kind: \"translations\", versionUuid },\n );\n return token;\n };\n\n let token: string;\n try {\n const minted = await fetchCentrifugoToken(\n tokenEndpoint,\n projectUuid,\n apiToken,\n { kind: \"translations\", versionUuid },\n );\n token = minted.token;\n } catch (err) {\n warn(\"live token mint failed\", err);\n return;\n }\n if (cancelled) return; // teardown ran during the awaits — do not connect.\n\n live = new LiveClient({\n url: wsUrl,\n token,\n channel,\n refreshToken,\n onMessage: (data) => {\n if (!data || typeof data !== \"object\") return;\n const d = data as {\n event?: string;\n language_code?: string;\n namespace_slug?: string;\n };\n if (d.event !== \"translations_published\") return;\n const lang = d.language_code;\n const ns = d.namespace_slug;\n if (!lang || !ns) return;\n void ctx.i18n.reload({ locale: lang, namespace: ns });\n },\n });\n live.connect();\n })();\n\n // Teardown: cancel the in-flight setup and dispose any open client.\n return () => {\n cancelled = true;\n if (live) {\n live.dispose();\n live = null;\n }\n };\n },\n };\n}\n"],"mappings":";;;;;;;AAiCA,IAAM,mBAAmB;AAEzB,SAAS,KAAK,KAAa,KAAqB;AAC9C,MAAI,OAAO,YAAY,aAAa;AAClC,QAAI,QAAQ,OAAW,SAAQ,KAAK,sBAAsB,GAAG,IAAI,GAAG;AAAA,QAC/D,SAAQ,KAAK,sBAAsB,GAAG,EAAE;AAAA,EAC/C;AACF;AASO,SAAS,gBACd,SACe;AACf,SAAO;AAAA,IACL,MAAM;AAAA,IACN,MAAM,KAA2B;AAC/B,YAAM,QAAQ,QAAQ;AACtB,UAAI,CAAC,OAAO;AACV,aAAK,wCAAwC;AAC7C;AAAA,MACF;AAEA,YAAM,WAAW,IAAI,OAAO,WAAW,kBAAkB;AAAA,QACvD;AAAA,QACA;AAAA,MACF;AACA,YAAM,cAAc,IAAI,OAAO;AAC/B,YAAM,WAAW,IAAI,OAAO;AAG5B,YAAM,UAAU,IAAI,OAAO,WAAW,IAAI,OAAO,eAAe;AAChE,YAAM,gBACJ,QAAQ,iBAAiB,GAAG,OAAO;AAIrC,UAAI,YAAY;AAChB,UAAI,OAA0B;AAE9B,YAAM,YAAY;AAEhB,YAAI;AACJ,YAAI;AACF,iBAAO,MAAM;AAAA,YACX;AAAA,YACA;AAAA,YACA;AAAA,YACA;AAAA,UACF;AAAA,QACF,SAAS,KAAK;AACZ,eAAK,sCAAsC,OAAO,KAAK,GAAG;AAC1D;AAAA,QACF;AACA,YAAI,UAAW;AAGf,YAAI,CAAC,KAAK,UAAU,CAAC,KAAK,kBAAkB;AAC1C;AAAA,YACE,gDAAgD,OAAO;AAAA,UACzD;AACA;AAAA,QACF;AACA,cAAM,UAAU,KAAK;AACrB,cAAM,cAAc,KAAK;AAGzB,cAAM,eAAe,YAA6B;AAChD,gBAAM,EAAE,OAAAA,OAAM,IAAI,MAAM;AAAA,YACtB;AAAA,YACA;AAAA,YACA;AAAA,YACA,EAAE,MAAM,gBAAgB,YAAY;AAAA,UACtC;AACA,iBAAOA;AAAA,QACT;AAEA,YAAI;AACJ,YAAI;AACF,gBAAM,SAAS,MAAM;AAAA,YACnB;AAAA,YACA;AAAA,YACA;AAAA,YACA,EAAE,MAAM,gBAAgB,YAAY;AAAA,UACtC;AACA,kBAAQ,OAAO;AAAA,QACjB,SAAS,KAAK;AACZ,eAAK,0BAA0B,GAAG;AAClC;AAAA,QACF;AACA,YAAI,UAAW;AAEf,eAAO,IAAI,WAAW;AAAA,UACpB,KAAK;AAAA,UACL;AAAA,UACA;AAAA,UACA;AAAA,UACA,WAAW,CAAC,SAAS;AACnB,gBAAI,CAAC,QAAQ,OAAO,SAAS,SAAU;AACvC,kBAAM,IAAI;AAKV,gBAAI,EAAE,UAAU,yBAA0B;AAC1C,kBAAM,OAAO,EAAE;AACf,kBAAM,KAAK,EAAE;AACb,gBAAI,CAAC,QAAQ,CAAC,GAAI;AAClB,iBAAK,IAAI,KAAK,OAAO,EAAE,QAAQ,MAAM,WAAW,GAAG,CAAC;AAAA,UACtD;AAAA,QACF,CAAC;AACD,aAAK,QAAQ;AAAA,MACf,GAAG;AAGH,aAAO,MAAM;AACX,oBAAY;AACZ,YAAI,MAAM;AACR,eAAK,QAAQ;AACb,iBAAO;AAAA,QACT;AAAA,MACF;AAAA,IACF;AAAA,EACF;AACF;","names":["token"]}
{"version":3,"sources":["../../src/react/plugin.tsx"],"sourcesContent":["/**\n * `@sonenta/realtime` as a PLUGIN of the `@sonenta/react-i18next` provider.\n * Adds zero-deploy translation updates for DEV versions: it reads the version's\n * dev/realtime state from the backend, and only for a dev version subscribes to\n * its Centrifugo channel and calls `ctx.i18n.reload(...)` on each\n * `translations_published` push.\n *\n * Dev-gating is PER-VERSION (backend contract #738), not based on the SDK's\n * `env` flag: a version is realtime-eligible until it has a published CDN\n * release. A published version returns `realtime_channel === null` → no\n * subscription, the bundle is served statically from the CDN.\n */\nimport { LiveClient, fetchCentrifugoToken } from \"../core/live\";\nimport { fetchVersionMeta } from \"../core/version\";\nimport type {\n SonentaPlugin,\n SonentaPluginContext,\n} from \"@sonenta/react-i18next\";\n\nexport interface SonentaRealtimeOptions {\n /**\n * WebSocket URL of the Centrifugo node, e.g.\n * `wss://rt.sonenta.dev/connection/websocket`. Required — the\n * plugin can't infer it from the API base.\n */\n wsUrl: string;\n /**\n * Endpoint that mints a short-lived Centrifugo connection token. Defaults to\n * `${apiBase}/v1/auth/centrifugo-token`. Override for self-hosted deployments.\n */\n tokenEndpoint?: string;\n}\n\nconst DEFAULT_API_BASE = \"https://api.sonenta.com\";\n\nfunction warn(msg: string, err?: unknown): void {\n if (typeof console !== \"undefined\") {\n if (err !== undefined) console.warn(`@sonenta/realtime: ${msg}`, err);\n else console.warn(`@sonenta/realtime: ${msg}`);\n }\n}\n\n/**\n * Build the realtime plugin. Add it to `<SonentaProvider plugins={[...]}>`:\n *\n * ```tsx\n * plugins={[sonentaRealtime({ wsUrl: \"wss://centrifugo.example/connection/websocket\" })]}\n * ```\n */\nexport function sonentaRealtime(\n options: SonentaRealtimeOptions,\n): SonentaPlugin {\n return {\n name: \"@sonenta/realtime\",\n setup(ctx: SonentaPluginContext) {\n const wsUrl = options.wsUrl;\n if (!wsUrl) {\n warn(\"wsUrl is empty; skipping subscription.\");\n return;\n }\n\n const apiBase = (ctx.config.apiBase ?? DEFAULT_API_BASE).replace(\n /\\/+$/,\n \"\",\n );\n const projectUuid = ctx.config.projectUuid;\n const apiToken = ctx.config.token;\n // Mirrors the core's version resolution (#739): `version` is the public\n // name, `versionSlug` a deprecated alias, default \"main\".\n const version = ctx.config.version ?? ctx.config.versionSlug ?? \"main\";\n const tokenEndpoint =\n options.tokenEndpoint ?? `${apiBase}/v1/auth/centrifugo-token`;\n\n // Set by teardown; checked before opening the socket so an unmount during\n // the async setup never leaves a LiveClient connected post-unmount.\n let cancelled = false;\n let live: LiveClient | null = null;\n\n void (async () => {\n // 1. Read the version's dev/realtime state.\n let meta;\n try {\n meta = await fetchVersionMeta(\n apiBase,\n projectUuid,\n version,\n apiToken,\n );\n } catch (err) {\n warn(`version metadata fetch failed for '${version}'`, err);\n return;\n }\n if (cancelled) return;\n\n // 3. Published version → no realtime; serve static CDN bundles.\n if (!meta.is_dev || !meta.realtime_channel) {\n warn(\n `realtime is only available on dev versions; '${version}' is published — no subscription, serving static CDN bundles.`,\n );\n return;\n }\n const channel = meta.realtime_channel;\n const versionUuid = meta.uuid;\n\n // 2. Dev version → mint a version-scoped translations token + subscribe.\n const refreshToken = async (): Promise<string> => {\n const { token } = await fetchCentrifugoToken(\n tokenEndpoint,\n projectUuid,\n apiToken,\n { kind: \"translations\", versionUuid },\n );\n return token;\n };\n\n let token: string;\n try {\n const minted = await fetchCentrifugoToken(\n tokenEndpoint,\n projectUuid,\n apiToken,\n { kind: \"translations\", versionUuid },\n );\n token = minted.token;\n } catch (err) {\n warn(\"live token mint failed\", err);\n return;\n }\n if (cancelled) return; // teardown ran during the awaits — do not connect.\n\n live = new LiveClient({\n url: wsUrl,\n token,\n channel,\n refreshToken,\n onMessage: (data) => {\n if (!data || typeof data !== \"object\") return;\n const d = data as {\n event?: string;\n language_code?: string;\n namespace_slug?: string;\n };\n if (d.event !== \"translations_published\") return;\n const lang = d.language_code;\n const ns = d.namespace_slug;\n if (!lang || !ns) return;\n void ctx.i18n.reload({ locale: lang, namespace: ns });\n },\n });\n live.connect();\n })();\n\n // Teardown: cancel the in-flight setup and dispose any open client.\n return () => {\n cancelled = true;\n if (live) {\n live.dispose();\n live = null;\n }\n };\n },\n };\n}\n"],"mappings":";;;;;;;AAiCA,IAAM,mBAAmB;AAEzB,SAAS,KAAK,KAAa,KAAqB;AAC9C,MAAI,OAAO,YAAY,aAAa;AAClC,QAAI,QAAQ,OAAW,SAAQ,KAAK,sBAAsB,GAAG,IAAI,GAAG;AAAA,QAC/D,SAAQ,KAAK,sBAAsB,GAAG,EAAE;AAAA,EAC/C;AACF;AASO,SAAS,gBACd,SACe;AACf,SAAO;AAAA,IACL,MAAM;AAAA,IACN,MAAM,KAA2B;AAC/B,YAAM,QAAQ,QAAQ;AACtB,UAAI,CAAC,OAAO;AACV,aAAK,wCAAwC;AAC7C;AAAA,MACF;AAEA,YAAM,WAAW,IAAI,OAAO,WAAW,kBAAkB;AAAA,QACvD;AAAA,QACA;AAAA,MACF;AACA,YAAM,cAAc,IAAI,OAAO;AAC/B,YAAM,WAAW,IAAI,OAAO;AAG5B,YAAM,UAAU,IAAI,OAAO,WAAW,IAAI,OAAO,eAAe;AAChE,YAAM,gBACJ,QAAQ,iBAAiB,GAAG,OAAO;AAIrC,UAAI,YAAY;AAChB,UAAI,OAA0B;AAE9B,YAAM,YAAY;AAEhB,YAAI;AACJ,YAAI;AACF,iBAAO,MAAM;AAAA,YACX;AAAA,YACA;AAAA,YACA;AAAA,YACA;AAAA,UACF;AAAA,QACF,SAAS,KAAK;AACZ,eAAK,sCAAsC,OAAO,KAAK,GAAG;AAC1D;AAAA,QACF;AACA,YAAI,UAAW;AAGf,YAAI,CAAC,KAAK,UAAU,CAAC,KAAK,kBAAkB;AAC1C;AAAA,YACE,gDAAgD,OAAO;AAAA,UACzD;AACA;AAAA,QACF;AACA,cAAM,UAAU,KAAK;AACrB,cAAM,cAAc,KAAK;AAGzB,cAAM,eAAe,YAA6B;AAChD,gBAAM,EAAE,OAAAA,OAAM,IAAI,MAAM;AAAA,YACtB;AAAA,YACA;AAAA,YACA;AAAA,YACA,EAAE,MAAM,gBAAgB,YAAY;AAAA,UACtC;AACA,iBAAOA;AAAA,QACT;AAEA,YAAI;AACJ,YAAI;AACF,gBAAM,SAAS,MAAM;AAAA,YACnB;AAAA,YACA;AAAA,YACA;AAAA,YACA,EAAE,MAAM,gBAAgB,YAAY;AAAA,UACtC;AACA,kBAAQ,OAAO;AAAA,QACjB,SAAS,KAAK;AACZ,eAAK,0BAA0B,GAAG;AAClC;AAAA,QACF;AACA,YAAI,UAAW;AAEf,eAAO,IAAI,WAAW;AAAA,UACpB,KAAK;AAAA,UACL;AAAA,UACA;AAAA,UACA;AAAA,UACA,WAAW,CAAC,SAAS;AACnB,gBAAI,CAAC,QAAQ,OAAO,SAAS,SAAU;AACvC,kBAAM,IAAI;AAKV,gBAAI,EAAE,UAAU,yBAA0B;AAC1C,kBAAM,OAAO,EAAE;AACf,kBAAM,KAAK,EAAE;AACb,gBAAI,CAAC,QAAQ,CAAC,GAAI;AAClB,iBAAK,IAAI,KAAK,OAAO,EAAE,QAAQ,MAAM,WAAW,GAAG,CAAC;AAAA,UACtD;AAAA,QACF,CAAC;AACD,aAAK,QAAQ;AAAA,MACf,GAAG;AAGH,aAAO,MAAM;AACX,oBAAY;AACZ,YAAI,MAAM;AACR,eAAK,QAAQ;AACb,iBAAO;AAAA,QACT;AAAA,MACF;AAAA,IACF;AAAA,EACF;AACF;","names":["token"]}
{
"name": "@sonenta/realtime",
"version": "1.0.0",
"version": "1.1.0",
"description": "Realtime translation updates plugin for @sonenta/react-i18next (Centrifugo).",
"license": "MIT",
"homepage": "https://verbumia.ca",
"homepage": "https://sonenta.com",
"repository": {
"type": "git",
"url": "https://github.com/verbumia/verbumia-sdk.git",
"url": "https://github.com/sonenta/sdk.git",
"directory": "packages/realtime"

@@ -62,3 +62,3 @@ },

"vitest": "^2.1.0",
"@sonenta/react-i18next": "2.0.0"
"@sonenta/react-i18next": "2.1.0"
},

@@ -65,0 +65,0 @@ "publishConfig": {

# @sonenta/realtime
Realtime translation updates for [Sonenta](https://verbumia.ca). A plugin
Realtime translation updates for [Sonenta](https://sonenta.com). A plugin
of your existing `@sonenta/react-i18next` provider: for **dev versions** it

@@ -44,3 +44,3 @@ subscribes to the version's Centrifugo channel and, when a new release is

sonentaRealtime({
wsUrl: "wss://rt.verbumia.ca/connection/websocket",
wsUrl: "wss://rt.sonenta.dev/connection/websocket",
}),

@@ -47,0 +47,0 @@ ]}