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) <noreply@anthropic.com>
This commit is contained in:
Fabian @ Blax Software 2026-09-30 12:07:34 +02:00
parent d0a8f46d80
commit e749526ef8
8 changed files with 157 additions and 5 deletions

View File

@ -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`.

View File

@ -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<boolean>` | Connection in progress |
| `is_after_lost_connection` | `ReactiveRef<boolean>` | 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

4
package-lock.json generated
View File

@ -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",

View File

@ -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"

88
src/i18n.ts Normal file
View File

@ -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.'
}
}
}

View File

@ -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
}

View File

@ -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'))
})

View File

@ -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,