From d0a8f46d8095dfd25c0b32402deb9fe35179008e Mon Sep 17 00:00:00 2001 From: "Fabian @ Blax Software" Date: Mon, 28 Sep 2026 14:42:03 +0200 Subject: [PATCH] feat(ws): opt-in send timeout, per call or client-wide (0.3.3) send() waited forever when the server never answered. In apps that awaited a read during page setup, a dropped response left the old layout on screen with an empty body until a reload. - sendTimeout (ms, default 0 = off): reject unanswered sends with { message, code: 'timeout' }. The clock starts at the call, so a send stuck behind the connect or channel subscribe times out too. - send(event, data, channel, { timeout, progress }): per-call override; a bare function as 4th argument still means progress. timeout 0 opts out. - A send that timed out before it was written is dropped from the queue instead of going out late after a reconnect. - Protocol events (websocket.*) never time out. Co-Authored-By: Claude Opus 5.5 (1M context) --- AGENTS.md | 11 +++++--- README.md | 3 +- package-lock.json | 4 +-- package.json | 2 +- src/index.ts | 1 + src/types.ts | 18 ++++++++++++ src/ws.ts | 72 ++++++++++++++++++++++++++++++++++++++++++++--- test/ws.test.mjs | 50 ++++++++++++++++++++++++++++++++ 8 files changed, 149 insertions(+), 12 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 6b79995..ed2203a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -77,9 +77,12 @@ ws.send('listening.generate', {}, null, (p) => { ``` ⚠️ **Footguns:** -- **No client-side timeout.** If the backend never emits `:response`/`:error`/`:timeout`, +- **No client-side timeout by default.** If the backend never emits `:response`/`:error`/`:timeout`, the Promise **hangs forever**. Make sure the handler always replies (return a value, or - `$this->success/error`, or throw). + `$this->success/error`, or throw). Set `sendTimeout` (ms, ≥ 0.3.3) to reject unanswered + sends with `{ message, code: 'timeout' }`, or bound one call with + `send(event, data, channel, { timeout, progress })`. A send that times out before it was + written is dropped, not sent late. `timeout: 0` opts one call out of the default. - **Connection lost mid-request.** A send already written to a socket that then closes stays pending by default. Set `rejectOnDisconnect: true` (≥ 0.3.2) to have it reject with `{ message, code: 'connection_lost' }` instead. Sends still queued while disconnected are @@ -224,10 +227,10 @@ const ws = createWsClient({ url, getAuthToken: () => api.bearerToken }) // ✅ - [ ] Use the app's exported `ws` singleton — never call a factory twice. - [ ] `setAppReady()` must run once at startup or every `send()` hangs. -- [ ] `send()` has **no timeout** — a non-replying backend hangs the Promise. +- [ ] `send()` has **no timeout** unless `sendTimeout` or a per-call `{ timeout }` is set — otherwise a non-replying backend hangs the Promise. - [ ] A send in flight when the socket drops stays pending unless `rejectOnDisconnect: true`. - [ ] `send()` resolves the **unwrapped** payload (not `{ data }`); rejects with `msg.data` (not an Error). -- [ ] Streaming = the 4th `progress` arg of `send()`. Don't hand-wire `:progress` listeners. +- [ ] Streaming = the 4th `progress` arg of `send()` (a callback, or `{ progress, timeout }`). Don't hand-wire `:progress` listeners. - [ ] In components prefer `listenWhileMounted` (auto-teardown + channel filter). - [ ] `listen()` channel filter is strict; `null` → `'websocket'`. Match the server's channel. - [ ] Reactive refs need the `/vue` or `/nuxt` factory; always read `.value`. diff --git a/README.md b/README.md index df294aa..fbe55e2 100644 --- a/README.md +++ b/README.md @@ -172,6 +172,7 @@ const api = createApiClient({ | `reconnectDelay` | `number` | `3000` | Delay before reconnect attempt (ms) | | `autoReconnect` | `boolean` | `true` | Auto-reconnect on disconnect | | `rejectOnDisconnect` | `boolean` | `false` | Reject in-flight sends with `{ code: 'connection_lost' }` when their socket closes (≥ 0.3.2) | +| `sendTimeout` | `number` (ms) | `0` (off) | Reject sends the server has not answered in time with `{ code: 'timeout' }`; per call: `send(e, d, ch, { timeout })` (≥ 0.3.3) | | `isServer` | `boolean \| () => boolean` | `false` | Returns safe no-op stub when true | | `isNativePlatform` | `boolean \| () => boolean` | `false` | Suppresses browser-only notifications | @@ -180,7 +181,7 @@ const api = createApiClient({ | Method | Returns | Description | |-------------------------------------------|------------------------------|------------------------------------------| | `connect(force?)` | `Promise` | Open socket connection | -| `send(event, data?, channel?, progress?)` | `Promise` | Send event, await response | +| `send(event, data?, channel?, progress?)` | `Promise` | Send event, await response. `progress` is a callback or `{ progress, timeout }` | | `listen(event, channel, callback)` | `() => void` | Listen for event, returns unsubscribe fn | | `listenOnce(event, channel?)` | `Promise` | Resolve on next occurrence | | `setAppReady()` | `void` | Unblock gated `send()` calls | diff --git a/package-lock.json b/package-lock.json index a838662..900b889 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@blax-software/networking", - "version": "0.3.2", + "version": "0.3.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@blax-software/networking", - "version": "0.3.2", + "version": "0.3.3", "license": "MIT", "devDependencies": { "axios": "^1.7.0", diff --git a/package.json b/package.json index 46bf2d3..e36aab8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@blax-software/networking", - "version": "0.3.2", + "version": "0.3.3", "description": "Plug-and-play API + WebSocket client. Framework-agnostic core with optional Vue, Nuxt, and React bindings.", "type": "module", "main": "./dist/index.cjs", diff --git a/src/index.ts b/src/index.ts index ebfbcec..3afc3ed 100644 --- a/src/index.ts +++ b/src/index.ts @@ -15,6 +15,7 @@ export type { HttpRequestConfig, ApiClientConfig, WsClientConfig, + WsSendOptions, ReactiveRef, CreateRefFn, } from './types' diff --git a/src/types.ts b/src/types.ts index fb0816d..c0bf6fc 100644 --- a/src/types.ts +++ b/src/types.ts @@ -170,6 +170,16 @@ export interface WsClientConfig { */ rejectOnDisconnect?: boolean + /** + * Reject a `send()` the server has not answered within this many ms, with + * `{ message, code: 'timeout' }`. The clock starts at the call, so a send + * stuck waiting for the connection or its channel times out too; one that + * was never written is dropped instead of going out late. Override per call + * with `send(event, data, channel, { timeout })`. Protocol events + * (`websocket.*`) never time out. Default: `0` (off, wait forever). + */ + sendTimeout?: number + /** * Show connection lost/restored notifications. * Set to `false` on native mobile apps where a toast overlay may be unwanted. @@ -190,6 +200,14 @@ export interface WsClientConfig { onConnectionStateChange?: (state: 'connecting' | 'connected' | 'disconnected' | 'reconnecting') => void } +/** Per-call options for `WsClient.send()`, passed in place of the progress callback. */ +export interface WsSendOptions { + /** Called for each `:progress` frame the server streams before the response. */ + progress?: (data: any) => void + /** Overrides `sendTimeout` for this call, in ms. `0` waits forever. */ + timeout?: number +} + // --------------------------------------------------------------------------- // Reactive ref abstraction (works with Vue, React state, or plain objects) // --------------------------------------------------------------------------- diff --git a/src/ws.ts b/src/ws.ts index 4b640b1..12c9857 100644 --- a/src/ws.ts +++ b/src/ws.ts @@ -1,5 +1,6 @@ import type { WsClientConfig, + WsSendOptions, NotifyFn, TranslateFn, ReactiveRef, @@ -15,6 +16,13 @@ interface WsSocket extends WebSocket { socket_id?: string } +/** Links a `sendTimeout` deadline to the send it bounds. */ +interface SendControl { + timedOut: boolean + /** Drops the queued payload and removes the response listeners. */ + cancel: (() => void) | null +} + /** A send written to a socket, tracked for `rejectOnDisconnect`. */ interface InflightSend { socket: WsSocket | null @@ -46,11 +54,12 @@ export interface WsClient { connect(force_reset?: boolean): Promise ensureConnected(): Promise channel(channel_name?: string | null): Promise + /** The 4th argument is a progress callback, or `{ progress, timeout }`. */ send( event: string, data?: object, channel_name?: string | null, - progress?: (data: any) => void, + progress?: ((data: any) => void) | WsSendOptions, ): Promise unsubscribe(channel_name?: string | null): Promise @@ -211,6 +220,8 @@ class WsClientImpl extends EventTarget implements WsClient { // rejectOnDisconnect: sends written to a socket, keyed by that socket. private _inflight = new Set() private _payloadSend = new WeakMap() + // sendTimeout: queued payloads whose send already timed out; never written. + private _timedOutPayloads = new WeakSet() constructor(config: WsClientConfig, createRef: CreateRefFn) { super() @@ -483,7 +494,9 @@ class WsClientImpl extends EventTarget implements WsClient { const queue = this.send_queue this.send_queue = [] for (const payload of queue) { + if (this._timedOutPayloads.has(payload)) continue await this.channel(payload.channel) + if (this._timedOutPayloads.has(payload)) continue this.socket?.send(JSON.stringify(payload)) const tracked = this._payloadSend.get(payload) if (tracked) tracked.socket = this.socket @@ -498,8 +511,40 @@ class WsClientImpl extends EventTarget implements WsClient { event: string, data: object = {}, channel_name: string | null = null, - progress?: (data: any) => void, - _retryOnSubscriptionLost = true, + progress?: ((data: any) => void) | WsSendOptions, + ): Promise { + const opts: WsSendOptions = typeof progress === 'function' ? { progress } : (progress ?? {}) + const timeout = opts.timeout ?? this._config.sendTimeout ?? 0 + if (!(timeout > 0) || _isProtocolEvent(event)) { + return this._send(event, data, channel_name, opts.progress, true, null) + } + + const ctl: SendControl = { timedOut: false, cancel: null } + let timer: ReturnType | undefined + const deadline = new Promise((_, reject) => { + timer = setTimeout(() => { + ctl.timedOut = true + ctl.cancel?.() + reject({ + message: this._t('websocket.requesttimeout', 'The server did not answer in time.'), + code: 'timeout', + }) + }, timeout) + }) + + return Promise.race([ + this._send(event, data, channel_name, opts.progress, true, ctl), + deadline, + ]).finally(() => clearTimeout(timer)) + } + + private async _send( + event: string, + data: object, + channel_name: string | null, + progress: ((data: any) => void) | undefined, + _retryOnSubscriptionLost: boolean, + ctl: SendControl | null, ): Promise { // Gate non-protocol events until app signals readiness if (!this._appReady && !_isProtocolEvent(event)) { @@ -531,12 +576,22 @@ class WsClientImpl extends EventTarget implements WsClient { this._payloadSend.set(payload, tracked) } + // sendTimeout: a payload still sitting in the send queue is never written. + const dropQueued = () => { + this._timedOutPayloads.add(payload) + if (tracked) this._inflight.delete(tracked) + } + if (ctl) ctl.cancel = dropQueued + try { // Ensure the target channel is subscribed if (channel_name && !_isProtocolEvent(event)) { await this.channel(channel_name) } + // The caller gave up while the channel was being established. + if (ctl?.timedOut) throw new Error('Send timed out before it was written') + // Send or queue if (this.is_opened.value && this.socket?.readyState === WebSocket.OPEN) { this.socket.send(JSON.stringify(payload)) @@ -603,6 +658,15 @@ class WsClientImpl extends EventTarget implements WsClient { this.addEventListener(sendingevent + ':error', handler) this.addEventListener(sendingevent + ':response', handler) + // sendTimeout: stop listening once the caller has given up. + if (ctl) { + ctl.cancel = () => { + dropQueued() + cleanup() + } + if (ctl.timedOut) ctl.cancel() + } + if (tracked) { tracked.fail = (err) => { cleanup() @@ -625,7 +689,7 @@ class WsClientImpl extends EventTarget implements WsClient { ch._establishPromise = null } console.log('[ws] Re-establishing channel after subscription loss') - return this.send(event, data, channel_name, progress, false) + return this._send(event, data, channel_name, progress, false, ctl) } throw error }) as Promise diff --git a/test/ws.test.mjs b/test/ws.test.mjs index 19c8cae..2abd2dd 100644 --- a/test/ws.test.mjs +++ b/test/ws.test.mjs @@ -15,6 +15,8 @@ class FakeWS extends EventTarget { static sockets = [] /** event name → false to never answer it. */ static answer = {} + /** event name → progress payload streamed before the response. */ + static progress = {} constructor(url) { super() @@ -44,6 +46,7 @@ class FakeWS extends EventTarget { if (msg.event === 'websocket.subscribe') { this.push({ event: 'websocket.subscribe:response', data: { channel: msg.data.channel }, channel: msg.data.channel }) } else { + if (FakeWS.progress[base]) this.push({ event: msg.event + ':progress', data: FakeWS.progress[base], channel: msg.channel }) this.push({ event: msg.event + ':response', data: { ok: true, via: this.index }, channel: msg.channel }) } }, 2) @@ -74,6 +77,7 @@ beforeEach(() => { FakeWS.plan = [] FakeWS.sockets = [] FakeWS.answer = {} + FakeWS.progress = {} globalThis.WebSocket = FakeWS }) @@ -212,3 +216,49 @@ test('destroy() cancels an auto-reconnect that a drop had already scheduled', as await sleep(80) assert.equal(FakeWS.sockets.length, 1, 'no reconnect after destroy()') }) + +test('sendTimeout: an unanswered send rejects with code timeout; answered sends are unaffected', async () => { + FakeWS.answer = { 'slow.call': false } + const ws = client({ sendTimeout: 50 }) + ws.setAppReady() + assert.deepEqual(await within(ws.send('dash.index')), { ok: true, via: 0 }) + const r = await within(ws.send('slow.call').then(() => 'RESOLVED', (e) => e)) + assert.equal(r?.code, 'timeout') + assert.ok(typeof r?.message === 'string' && r.message.length > 0) + assert.deepEqual(await within(ws.send('dash.index')), { ok: true, via: 0 }) +}) + +test('sendTimeout: a per-call timeout works without a client default, and timeout 0 opts out', async () => { + FakeWS.answer = { 'slow.call': false } + const plain = client() + plain.setAppReady() + const r = await within(plain.send('slow.call', {}, null, { timeout: 30 }).then(() => 'RESOLVED', (e) => e)) + assert.equal(r?.code, 'timeout') + + const bounded = client({ sendTimeout: 30 }) + bounded.setAppReady() + assert.equal(await within(bounded.send('slow.call', {}, null, { timeout: 0 }), 150), 'PENDING') +}) + +test('sendTimeout: a send that timed out before it was written never goes out after the reconnect', async () => { + const ws = client({ sendTimeout: 10 }) + ws.setAppReady() + await within(ws.send('dash.index')) + FakeWS.sockets[0].drop() + const r = await within(ws.send('late.call').then(() => 'RESOLVED', (e) => e)) + assert.equal(r?.code, 'timeout') + await sleep(150) + assert.ok(FakeWS.sockets.length >= 2, 'the client reconnected') + const written = FakeWS.sockets.flatMap((s) => s.sent).some((m) => String(m.event).startsWith('late.call')) + assert.equal(written, false, 'the timed-out send was not written late') +}) + +test('progress: the 4th argument still takes a callback, or { progress }', async () => { + FakeWS.progress = { 'stream.call': { step: 1 } } + const ws = client() + ws.setAppReady() + const seen = [] + await within(ws.send('stream.call', {}, null, (d) => seen.push(['fn', d]))) + await within(ws.send('stream.call', {}, null, { progress: (d) => seen.push(['opt', d]), timeout: 500 })) + assert.deepEqual(seen, [['fn', { step: 1 }], ['opt', { step: 1 }]]) +})