From e749526ef8c007528babd0b4bffef3ecad178f10 Mon Sep 17 00:00:00 2001 From: "Fabian @ Blax Software" Date: Wed, 30 Sep 2026 12:07:34 +0200 Subject: [PATCH] feat(i18n): ship websocket.* translations, ignore echoed keys (0.4.0) - src/i18n.ts holds the client's own texts (connection lost/restored, request timeout/lost) in en de es fr it ja nl pl tr uk, exported as `@blax-software/networking/i18n` and registered through the "blax-i18n" package.json field, so `blax-i18n build --packages auto` merges them. - `_t()` treats a translation equal to its key as missing. vue-i18n echoes missing keys, so users saw "websocket.connectionrestored" instead of the English fallback. - Tests: echoed key falls back, real translation is used, the shipped module covers every `_t()` key in every locale. Co-Authored-By: Claude Opus 5.5 (1M context) --- AGENTS.md | 2 ++ README.md | 20 ++++++++++- package-lock.json | 4 +-- package.json | 8 ++++- src/i18n.ts | 88 +++++++++++++++++++++++++++++++++++++++++++++++ src/ws.ts | 5 ++- test/ws.test.mjs | 34 ++++++++++++++++++ tsup.config.ts | 1 + 8 files changed, 157 insertions(+), 5 deletions(-) create mode 100644 src/i18n.ts diff --git a/AGENTS.md b/AGENTS.md index ed2203a..7d25e32 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -235,6 +235,7 @@ const ws = createWsClient({ url, getAuthToken: () => api.bearerToken }) // ✅ - [ ] `listen()` channel filter is strict; `null` → `'websocket'`. Match the server's channel. - [ ] Reactive refs need the `/vue` or `/nuxt` factory; always read `.value`. - [ ] SSR: guard with `!import.meta.server && ws.is_setup.value`, fall back to `api.get`. +- [ ] Toast texts come from `translate("websocket.*")`; ship-with-the-package strings live in `src/i18n.ts` (registered via the `"blax-i18n"` package.json field). A new `_t()` key needs an entry there in every locale — `npm test` fails otherwise. - [ ] `ApiClient`: `bearerToken` getter / `getBackendUrl()` / `setBearer(null)` — not the README's old names. --- @@ -248,6 +249,7 @@ const ws = createWsClient({ url, getAuthToken: () => api.bearerToken }) // ✅ | `createFromNuxtConfig` | `src/nuxt.ts` | | `ApiClient` verbs, bearer, `parseError/parseThen` | `src/api.ts` | | `WsClientConfig`, `ApiClientConfig` | `src/types.ts` | +| Shipped `websocket.*` translations (`./i18n` export, `"blax-i18n"` field) | `src/i18n.ts` | **Backend counterpart:** the server side of this protocol (who emits `:progress`/`:response`/`:error`, `$this->progress`, `ws_broadcast`, channels) is documented in the `laravel-websockets` `AGENTS.md`. diff --git a/README.md b/README.md index fbe55e2..ba0c874 100644 --- a/README.md +++ b/README.md @@ -166,7 +166,7 @@ const api = createApiClient({ | `url` | `string \| () => string` | **required** | Full WebSocket URL | | `getAuthToken` | `() => string \| null` | — | Auth token getter for channel subscription | | `notify` | `NotifyFn` | — | Connection state notifications | -| `translate` | `TranslateFn` | — | Translation function for notification text | +| `translate` | `TranslateFn` | — | Translation function for notification text. A result equal to the key counts as missing, and the English fallback shows (≥ 0.4.0) | | `defaultChannel` | `string` | `'websocket'` | Default channel name | | `heartbeatInterval` | `number` | `20000` | Ping interval (ms) | | `reconnectDelay` | `number` | `3000` | Delay before reconnect attempt (ms) | @@ -197,6 +197,24 @@ const api = createApiClient({ | `is_connecting_socket` | `ReactiveRef` | Connection in progress | | `is_after_lost_connection` | `ReactiveRef` | Had a connection loss | +## Translations + +The client's own texts (connection lost/restored, request timeout/lost) use the keys +`websocket.connectionlost`, `websocket.connectionrestored`, `websocket.requesttimeout` and +`websocket.requestlost`, looked up through `translate`. The package ships them in en, de, es, fr, +it, ja, nl, pl, tr and uk as `@blax-software/networking/i18n`, and registers that module through +the `"blax-i18n"` field of its package.json, so an app built with +[`@blax-software/i18n`](https://www.npmjs.com/package/@blax-software/i18n) picks them up with +`blax-i18n build --packages auto`. Apps that merge messages at runtime pass it in themselves: + +```ts +import networking from '@blax-software/networking/i18n' +mergeMessages(locales, pages, { packages: [networking] }) +``` + +Then wire `translate` to your i18n instance, e.g. `translate: (key) => i18n.global.t(key)`. +An app string with the same key overrides the shipped one. + ## Storage Adapters ```typescript diff --git a/package-lock.json b/package-lock.json index 900b889..b32416a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@blax-software/networking", - "version": "0.3.3", + "version": "0.4.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@blax-software/networking", - "version": "0.3.3", + "version": "0.4.0", "license": "MIT", "devDependencies": { "axios": "^1.7.0", diff --git a/package.json b/package.json index e36aab8..9688733 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@blax-software/networking", - "version": "0.3.3", + "version": "0.4.0", "description": "Plug-and-play API + WebSocket client. Framework-agnostic core with optional Vue, Nuxt, and React bindings.", "type": "module", "main": "./dist/index.cjs", @@ -26,8 +26,14 @@ "types": "./dist/api-axios.d.ts", "import": "./dist/api-axios.js", "require": "./dist/api-axios.cjs" + }, + "./i18n": { + "types": "./dist/i18n.d.ts", + "import": "./dist/i18n.js", + "require": "./dist/i18n.cjs" } }, + "blax-i18n": "./dist/i18n.js", "files": [ "dist", "AGENTS.md" diff --git a/src/i18n.ts b/src/i18n.ts new file mode 100644 index 0000000..077a814 --- /dev/null +++ b/src/i18n.ts @@ -0,0 +1,88 @@ +// Strings the WS client shows itself (connection toasts, request errors), in the +// shape @blax-software/i18n merges: `{ [locale]: messages }`. package.json points +// its "blax-i18n" field here, so an app built with `blax-i18n --packages auto` +// gets these keys without defining them; any app string with the same key wins. +// The client reads them through `translate` (see `_t()` in ws.ts) and falls back +// to the English literal when a key is missing. +export default { + en: { + websocket: { + connectionlost: 'Connection lost. Reconnecting…', + connectionrestored: 'Connection restored', + requesttimeout: 'The server did not answer in time.', + requestlost: 'The connection was lost before the server answered.' + } + }, + tr: { + websocket: { + connectionlost: 'Bağlantı kesildi. Yeniden bağlanılıyor…', + connectionrestored: 'Bağlantı yeniden kuruldu', + requesttimeout: 'Sunucu zamanında yanıt vermedi.', + requestlost: 'Sunucu yanıt vermeden bağlantı kesildi.' + } + }, + de: { + websocket: { + connectionlost: 'Verbindung unterbrochen. Verbinde neu…', + connectionrestored: 'Verbindung wiederhergestellt', + requesttimeout: 'Der Server hat nicht rechtzeitig geantwortet.', + requestlost: 'Die Verbindung brach ab, bevor der Server geantwortet hat.' + } + }, + es: { + websocket: { + connectionlost: 'Conexión perdida. Reconectando…', + connectionrestored: 'Conexión restablecida', + requesttimeout: 'El servidor no respondió a tiempo.', + requestlost: 'Se perdió la conexión antes de que el servidor respondiera.' + } + }, + uk: { + websocket: { + connectionlost: 'Зʼєднання втрачено. Перепідключення…', + connectionrestored: 'Зʼєднання відновлено', + requesttimeout: 'Сервер не відповів вчасно.', + requestlost: 'Зʼєднання обірвалося до того, як сервер відповів.' + } + }, + ja: { + websocket: { + connectionlost: '接続が切れました。再接続しています…', + connectionrestored: '接続が復旧しました', + requesttimeout: 'サーバーが時間内に応答しませんでした。', + requestlost: 'サーバーの応答前に接続が切れました。' + } + }, + pl: { + websocket: { + connectionlost: 'Utracono połączenie. Ponowne łączenie…', + connectionrestored: 'Połączenie przywrócone', + requesttimeout: 'Serwer nie odpowiedział na czas.', + requestlost: 'Połączenie zostało przerwane, zanim serwer odpowiedział.' + } + }, + nl: { + websocket: { + connectionlost: 'Verbinding verbroken. Opnieuw verbinden…', + connectionrestored: 'Verbinding hersteld', + requesttimeout: 'De server antwoordde niet op tijd.', + requestlost: 'De verbinding viel weg voordat de server antwoordde.' + } + }, + fr: { + websocket: { + connectionlost: 'Connexion perdue. Reconnexion…', + connectionrestored: 'Connexion rétablie', + requesttimeout: 'Le serveur n’a pas répondu à temps.', + requestlost: 'La connexion a été perdue avant la réponse du serveur.' + } + }, + it: { + websocket: { + connectionlost: 'Connessione persa. Riconnessione…', + connectionrestored: 'Connessione ripristinata', + requesttimeout: 'Il server non ha risposto in tempo.', + requestlost: 'La connessione si è interrotta prima della risposta del server.' + } + } +} diff --git a/src/ws.ts b/src/ws.ts index 12c9857..f75058c 100644 --- a/src/ws.ts +++ b/src/ws.ts @@ -272,10 +272,13 @@ class WsClientImpl extends EventTarget implements WsClient { return typeof u === 'function' ? u() : u } + // vue-i18n (and most i18n libs) return the key itself for a missing + // translation. Treat that as missing, so the user reads the English fallback + // instead of "websocket.connectionrestored". private _t(key: string, fallback: string): string { if (this._translate) { const result = this._translate(key) - if (result) return result + if (result && result !== key) return result } return fallback } diff --git a/test/ws.test.mjs b/test/ws.test.mjs index 2abd2dd..4e51c26 100644 --- a/test/ws.test.mjs +++ b/test/ws.test.mjs @@ -262,3 +262,37 @@ test('progress: the 4th argument still takes a callback, or { progress }', async await within(ws.send('stream.call', {}, null, { progress: (d) => seen.push(['opt', d]), timeout: 500 })) assert.deepEqual(seen, [['fn', { step: 1 }], ['opt', { step: 1 }]]) }) + +// ── Translations ──────────────────────────────────────────────────────────── + +const toastsWith = async (translate) => { + const texts = [] + const ws = client({ showConnectionNotifications: true, notify: (n) => texts.push(n.text), translate }) + ws.setAppReady() + await within(ws.send('dash.index')) + ws.socket.drop() + await sleep(120) // lost → reconnect after reconnectDelay → restored + return texts +} + +test('translate: a translator that echoes the key counts as missing, so the English fallback shows', async () => { + assert.deepEqual(await toastsWith((key) => key), ['Connection lost. Reconnecting…', 'Connection restored']) +}) + +test('translate: a real translation is used; null falls back', async () => { + assert.deepEqual(await toastsWith((key) => `T(${key})`), ['T(websocket.connectionlost)', 'T(websocket.connectionrestored)']) + assert.deepEqual(await toastsWith(() => null), ['Connection lost. Reconnecting…', 'Connection restored']) +}) + +test('i18n: the registered module ships every key the client asks for, in every locale', async () => { + const { readFileSync } = await import('node:fs') + const manifest = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')) + const { default: messages } = await import(new URL('../' + manifest['blax-i18n'], import.meta.url).href) + const src = readFileSync(new URL('../src/ws.ts', import.meta.url), 'utf8') + const asked = [...src.matchAll(/_t\('websocket\.(\w+)'/g)].map((m) => m[1]).sort() + assert.ok(asked.length >= 4) + for (const [locale, tree] of Object.entries(messages)) { + assert.deepEqual(Object.keys(tree.websocket).sort(), asked, `${locale} covers every key`) + } + assert.ok(Object.keys(messages).includes('en')) +}) diff --git a/tsup.config.ts b/tsup.config.ts index c7399ef..50cb687 100644 --- a/tsup.config.ts +++ b/tsup.config.ts @@ -6,6 +6,7 @@ export default defineConfig({ vue: 'src/vue.ts', nuxt: 'src/nuxt.ts', 'api-axios': 'src/api-axios.ts', + i18n: 'src/i18n.ts', }, format: ['esm', 'cjs'], dts: true,