@sonenta/realtime
Advanced tools
@@ -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"]} |
+4
-4
| { | ||
| "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": { |
+2
-2
| # @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 @@ ]} |
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.
80065
-0.02%