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:
parent
d0a8f46d80
commit
e749526ef8
|
|
@ -235,6 +235,7 @@ const ws = createWsClient({ url, getAuthToken: () => api.bearerToken }) // ✅
|
||||||
- [ ] `listen()` channel filter is strict; `null` → `'websocket'`. Match the server's channel.
|
- [ ] `listen()` channel filter is strict; `null` → `'websocket'`. Match the server's channel.
|
||||||
- [ ] Reactive refs need the `/vue` or `/nuxt` factory; always read `.value`.
|
- [ ] 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`.
|
- [ ] 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.
|
- [ ] `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` |
|
| `createFromNuxtConfig` | `src/nuxt.ts` |
|
||||||
| `ApiClient` verbs, bearer, `parseError/parseThen` | `src/api.ts` |
|
| `ApiClient` verbs, bearer, `parseError/parseThen` | `src/api.ts` |
|
||||||
| `WsClientConfig`, `ApiClientConfig` | `src/types.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`,
|
**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`.
|
`$this->progress`, `ws_broadcast`, channels) is documented in the `laravel-websockets` `AGENTS.md`.
|
||||||
|
|
|
||||||
20
README.md
20
README.md
|
|
@ -166,7 +166,7 @@ const api = createApiClient({
|
||||||
| `url` | `string \| () => string` | **required** | Full WebSocket URL |
|
| `url` | `string \| () => string` | **required** | Full WebSocket URL |
|
||||||
| `getAuthToken` | `() => string \| null` | — | Auth token getter for channel subscription |
|
| `getAuthToken` | `() => string \| null` | — | Auth token getter for channel subscription |
|
||||||
| `notify` | `NotifyFn` | — | Connection state notifications |
|
| `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 |
|
| `defaultChannel` | `string` | `'websocket'` | Default channel name |
|
||||||
| `heartbeatInterval` | `number` | `20000` | Ping interval (ms) |
|
| `heartbeatInterval` | `number` | `20000` | Ping interval (ms) |
|
||||||
| `reconnectDelay` | `number` | `3000` | Delay before reconnect attempt (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_connecting_socket` | `ReactiveRef<boolean>` | Connection in progress |
|
||||||
| `is_after_lost_connection` | `ReactiveRef<boolean>` | Had a connection loss |
|
| `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
|
## Storage Adapters
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,12 @@
|
||||||
{
|
{
|
||||||
"name": "@blax-software/networking",
|
"name": "@blax-software/networking",
|
||||||
"version": "0.3.3",
|
"version": "0.4.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "@blax-software/networking",
|
"name": "@blax-software/networking",
|
||||||
"version": "0.3.3",
|
"version": "0.4.0",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"axios": "^1.7.0",
|
"axios": "^1.7.0",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@blax-software/networking",
|
"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.",
|
"description": "Plug-and-play API + WebSocket client. Framework-agnostic core with optional Vue, Nuxt, and React bindings.",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "./dist/index.cjs",
|
"main": "./dist/index.cjs",
|
||||||
|
|
@ -26,8 +26,14 @@
|
||||||
"types": "./dist/api-axios.d.ts",
|
"types": "./dist/api-axios.d.ts",
|
||||||
"import": "./dist/api-axios.js",
|
"import": "./dist/api-axios.js",
|
||||||
"require": "./dist/api-axios.cjs"
|
"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": [
|
"files": [
|
||||||
"dist",
|
"dist",
|
||||||
"AGENTS.md"
|
"AGENTS.md"
|
||||||
|
|
|
||||||
|
|
@ -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.'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -272,10 +272,13 @@ class WsClientImpl extends EventTarget implements WsClient {
|
||||||
return typeof u === 'function' ? u() : u
|
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 {
|
private _t(key: string, fallback: string): string {
|
||||||
if (this._translate) {
|
if (this._translate) {
|
||||||
const result = this._translate(key)
|
const result = this._translate(key)
|
||||||
if (result) return result
|
if (result && result !== key) return result
|
||||||
}
|
}
|
||||||
return fallback
|
return fallback
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 }))
|
await within(ws.send('stream.call', {}, null, { progress: (d) => seen.push(['opt', d]), timeout: 500 }))
|
||||||
assert.deepEqual(seen, [['fn', { step: 1 }], ['opt', { step: 1 }]])
|
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'))
|
||||||
|
})
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ export default defineConfig({
|
||||||
vue: 'src/vue.ts',
|
vue: 'src/vue.ts',
|
||||||
nuxt: 'src/nuxt.ts',
|
nuxt: 'src/nuxt.ts',
|
||||||
'api-axios': 'src/api-axios.ts',
|
'api-axios': 'src/api-axios.ts',
|
||||||
|
i18n: 'src/i18n.ts',
|
||||||
},
|
},
|
||||||
format: ['esm', 'cjs'],
|
format: ['esm', 'cjs'],
|
||||||
dts: true,
|
dts: true,
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue