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:
parent
737954ba2d
commit
d0a8f46d80
11
AGENTS.md
11
AGENTS.md
|
|
@ -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`.
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ export type {
|
|||
HttpRequestConfig,
|
||||
ApiClientConfig,
|
||||
WsClientConfig,
|
||||
WsSendOptions,
|
||||
ReactiveRef,
|
||||
CreateRefFn,
|
||||
} from './types'
|
||||
|
|
|
|||
18
src/types.ts
18
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)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
72
src/ws.ts
72
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<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>
|
||||
|
|
|
|||
|
|
@ -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 }]])
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in New Issue