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) <noreply@anthropic.com>
This commit is contained in:
Fabian @ Blax Software 2026-09-28 14:42:03 +02:00
parent 737954ba2d
commit d0a8f46d80
8 changed files with 149 additions and 12 deletions

View File

@ -77,9 +77,12 @@ ws.send('listening.generate', {}, null, (p) => {
```
⚠️ **Footguns:**
- **No client-side timeout.** If the backend never emits `<event>:response`/`:error`/`:timeout`,
- **No client-side timeout by default.** If the backend never emits `<event>: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`.

View File

@ -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<WebSocket \| void>` | Open socket connection |
| `send(event, data?, channel?, progress?)` | `Promise<T>` | Send event, await response |
| `send(event, data?, channel?, progress?)` | `Promise<T>` | 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<any>` | Resolve on next occurrence |
| `setAppReady()` | `void` | Unblock gated `send()` calls |

4
package-lock.json generated
View File

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

View File

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

View File

@ -15,6 +15,7 @@ export type {
HttpRequestConfig,
ApiClientConfig,
WsClientConfig,
WsSendOptions,
ReactiveRef,
CreateRefFn,
} from './types'

View File

@ -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)
// ---------------------------------------------------------------------------

View File

@ -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<WsSocket | void>
ensureConnected(): Promise<void>
channel(channel_name?: string | null): Promise<WsChannel | undefined>
/** The 4th argument is a progress callback, or `{ progress, timeout }`. */
send<T = any>(
event: string,
data?: object,
channel_name?: string | null,
progress?: (data: any) => void,
progress?: ((data: any) => void) | WsSendOptions,
): Promise<T>
unsubscribe(channel_name?: string | null): Promise<boolean>
@ -211,6 +220,8 @@ class WsClientImpl extends EventTarget implements WsClient {
// rejectOnDisconnect: sends written to a socket, keyed by that socket.
private _inflight = new Set<InflightSend>()
private _payloadSend = new WeakMap<object, InflightSend>()
// sendTimeout: queued payloads whose send already timed out; never written.
private _timedOutPayloads = new WeakSet<object>()
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<T> {
const opts: WsSendOptions = typeof progress === 'function' ? { progress } : (progress ?? {})
const timeout = opts.timeout ?? this._config.sendTimeout ?? 0
if (!(timeout > 0) || _isProtocolEvent(event)) {
return this._send<T>(event, data, channel_name, opts.progress, true, null)
}
const ctl: SendControl = { timedOut: false, cancel: null }
let timer: ReturnType<typeof setTimeout> | undefined
const deadline = new Promise<never>((_, 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<T>(event, data, channel_name, opts.progress, true, ctl),
deadline,
]).finally(() => clearTimeout(timer))
}
private async _send<T = any>(
event: string,
data: object,
channel_name: string | null,
progress: ((data: any) => void) | undefined,
_retryOnSubscriptionLost: boolean,
ctl: SendControl | null,
): Promise<T> {
// 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<T>(event, data, channel_name, progress, false)
return this._send<T>(event, data, channel_name, progress, false, ctl)
}
throw error
}) as Promise<T>

View File

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