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:**
|
⚠️ **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
|
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
|
- **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
|
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
|
`{ 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.
|
- [ ] Use the app's exported `ws` singleton — never call a factory twice.
|
||||||
- [ ] `setAppReady()` must run once at startup or every `send()` hangs.
|
- [ ] `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`.
|
- [ ] 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).
|
- [ ] `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).
|
- [ ] In components prefer `listenWhileMounted` (auto-teardown + channel filter).
|
||||||
- [ ] `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`.
|
||||||
|
|
|
||||||
|
|
@ -172,6 +172,7 @@ const api = createApiClient({
|
||||||
| `reconnectDelay` | `number` | `3000` | Delay before reconnect attempt (ms) |
|
| `reconnectDelay` | `number` | `3000` | Delay before reconnect attempt (ms) |
|
||||||
| `autoReconnect` | `boolean` | `true` | Auto-reconnect on disconnect |
|
| `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) |
|
| `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 |
|
| `isServer` | `boolean \| () => boolean` | `false` | Returns safe no-op stub when true |
|
||||||
| `isNativePlatform` | `boolean \| () => boolean` | `false` | Suppresses browser-only notifications |
|
| `isNativePlatform` | `boolean \| () => boolean` | `false` | Suppresses browser-only notifications |
|
||||||
|
|
||||||
|
|
@ -180,7 +181,7 @@ const api = createApiClient({
|
||||||
| Method | Returns | Description |
|
| Method | Returns | Description |
|
||||||
|-------------------------------------------|------------------------------|------------------------------------------|
|
|-------------------------------------------|------------------------------|------------------------------------------|
|
||||||
| `connect(force?)` | `Promise<WebSocket \| void>` | Open socket connection |
|
| `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 |
|
| `listen(event, channel, callback)` | `() => void` | Listen for event, returns unsubscribe fn |
|
||||||
| `listenOnce(event, channel?)` | `Promise<any>` | Resolve on next occurrence |
|
| `listenOnce(event, channel?)` | `Promise<any>` | Resolve on next occurrence |
|
||||||
| `setAppReady()` | `void` | Unblock gated `send()` calls |
|
| `setAppReady()` | `void` | Unblock gated `send()` calls |
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,12 @@
|
||||||
{
|
{
|
||||||
"name": "@blax-software/networking",
|
"name": "@blax-software/networking",
|
||||||
"version": "0.3.2",
|
"version": "0.3.3",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "@blax-software/networking",
|
"name": "@blax-software/networking",
|
||||||
"version": "0.3.2",
|
"version": "0.3.3",
|
||||||
"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.2",
|
"version": "0.3.3",
|
||||||
"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",
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ export type {
|
||||||
HttpRequestConfig,
|
HttpRequestConfig,
|
||||||
ApiClientConfig,
|
ApiClientConfig,
|
||||||
WsClientConfig,
|
WsClientConfig,
|
||||||
|
WsSendOptions,
|
||||||
ReactiveRef,
|
ReactiveRef,
|
||||||
CreateRefFn,
|
CreateRefFn,
|
||||||
} from './types'
|
} from './types'
|
||||||
|
|
|
||||||
18
src/types.ts
18
src/types.ts
|
|
@ -170,6 +170,16 @@ export interface WsClientConfig {
|
||||||
*/
|
*/
|
||||||
rejectOnDisconnect?: boolean
|
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.
|
* Show connection lost/restored notifications.
|
||||||
* Set to `false` on native mobile apps where a toast overlay may be unwanted.
|
* 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
|
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)
|
// 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 {
|
import type {
|
||||||
WsClientConfig,
|
WsClientConfig,
|
||||||
|
WsSendOptions,
|
||||||
NotifyFn,
|
NotifyFn,
|
||||||
TranslateFn,
|
TranslateFn,
|
||||||
ReactiveRef,
|
ReactiveRef,
|
||||||
|
|
@ -15,6 +16,13 @@ interface WsSocket extends WebSocket {
|
||||||
socket_id?: string
|
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`. */
|
/** A send written to a socket, tracked for `rejectOnDisconnect`. */
|
||||||
interface InflightSend {
|
interface InflightSend {
|
||||||
socket: WsSocket | null
|
socket: WsSocket | null
|
||||||
|
|
@ -46,11 +54,12 @@ export interface WsClient {
|
||||||
connect(force_reset?: boolean): Promise<WsSocket | void>
|
connect(force_reset?: boolean): Promise<WsSocket | void>
|
||||||
ensureConnected(): Promise<void>
|
ensureConnected(): Promise<void>
|
||||||
channel(channel_name?: string | null): Promise<WsChannel | undefined>
|
channel(channel_name?: string | null): Promise<WsChannel | undefined>
|
||||||
|
/** The 4th argument is a progress callback, or `{ progress, timeout }`. */
|
||||||
send<T = any>(
|
send<T = any>(
|
||||||
event: string,
|
event: string,
|
||||||
data?: object,
|
data?: object,
|
||||||
channel_name?: string | null,
|
channel_name?: string | null,
|
||||||
progress?: (data: any) => void,
|
progress?: ((data: any) => void) | WsSendOptions,
|
||||||
): Promise<T>
|
): Promise<T>
|
||||||
unsubscribe(channel_name?: string | null): Promise<boolean>
|
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.
|
// rejectOnDisconnect: sends written to a socket, keyed by that socket.
|
||||||
private _inflight = new Set<InflightSend>()
|
private _inflight = new Set<InflightSend>()
|
||||||
private _payloadSend = new WeakMap<object, 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) {
|
constructor(config: WsClientConfig, createRef: CreateRefFn) {
|
||||||
super()
|
super()
|
||||||
|
|
@ -483,7 +494,9 @@ class WsClientImpl extends EventTarget implements WsClient {
|
||||||
const queue = this.send_queue
|
const queue = this.send_queue
|
||||||
this.send_queue = []
|
this.send_queue = []
|
||||||
for (const payload of queue) {
|
for (const payload of queue) {
|
||||||
|
if (this._timedOutPayloads.has(payload)) continue
|
||||||
await this.channel(payload.channel)
|
await this.channel(payload.channel)
|
||||||
|
if (this._timedOutPayloads.has(payload)) continue
|
||||||
this.socket?.send(JSON.stringify(payload))
|
this.socket?.send(JSON.stringify(payload))
|
||||||
const tracked = this._payloadSend.get(payload)
|
const tracked = this._payloadSend.get(payload)
|
||||||
if (tracked) tracked.socket = this.socket
|
if (tracked) tracked.socket = this.socket
|
||||||
|
|
@ -498,8 +511,40 @@ class WsClientImpl extends EventTarget implements WsClient {
|
||||||
event: string,
|
event: string,
|
||||||
data: object = {},
|
data: object = {},
|
||||||
channel_name: string | null = null,
|
channel_name: string | null = null,
|
||||||
progress?: (data: any) => void,
|
progress?: ((data: any) => void) | WsSendOptions,
|
||||||
_retryOnSubscriptionLost = true,
|
): 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> {
|
): Promise<T> {
|
||||||
// Gate non-protocol events until app signals readiness
|
// Gate non-protocol events until app signals readiness
|
||||||
if (!this._appReady && !_isProtocolEvent(event)) {
|
if (!this._appReady && !_isProtocolEvent(event)) {
|
||||||
|
|
@ -531,12 +576,22 @@ class WsClientImpl extends EventTarget implements WsClient {
|
||||||
this._payloadSend.set(payload, tracked)
|
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 {
|
try {
|
||||||
// Ensure the target channel is subscribed
|
// Ensure the target channel is subscribed
|
||||||
if (channel_name && !_isProtocolEvent(event)) {
|
if (channel_name && !_isProtocolEvent(event)) {
|
||||||
await this.channel(channel_name)
|
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
|
// Send or queue
|
||||||
if (this.is_opened.value && this.socket?.readyState === WebSocket.OPEN) {
|
if (this.is_opened.value && this.socket?.readyState === WebSocket.OPEN) {
|
||||||
this.socket.send(JSON.stringify(payload))
|
this.socket.send(JSON.stringify(payload))
|
||||||
|
|
@ -603,6 +658,15 @@ class WsClientImpl extends EventTarget implements WsClient {
|
||||||
this.addEventListener(sendingevent + ':error', handler)
|
this.addEventListener(sendingevent + ':error', handler)
|
||||||
this.addEventListener(sendingevent + ':response', 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) {
|
if (tracked) {
|
||||||
tracked.fail = (err) => {
|
tracked.fail = (err) => {
|
||||||
cleanup()
|
cleanup()
|
||||||
|
|
@ -625,7 +689,7 @@ class WsClientImpl extends EventTarget implements WsClient {
|
||||||
ch._establishPromise = null
|
ch._establishPromise = null
|
||||||
}
|
}
|
||||||
console.log('[ws] Re-establishing channel after subscription loss')
|
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
|
throw error
|
||||||
}) as Promise<T>
|
}) as Promise<T>
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,8 @@ class FakeWS extends EventTarget {
|
||||||
static sockets = []
|
static sockets = []
|
||||||
/** event name → false to never answer it. */
|
/** event name → false to never answer it. */
|
||||||
static answer = {}
|
static answer = {}
|
||||||
|
/** event name → progress payload streamed before the response. */
|
||||||
|
static progress = {}
|
||||||
|
|
||||||
constructor(url) {
|
constructor(url) {
|
||||||
super()
|
super()
|
||||||
|
|
@ -44,6 +46,7 @@ class FakeWS extends EventTarget {
|
||||||
if (msg.event === 'websocket.subscribe') {
|
if (msg.event === 'websocket.subscribe') {
|
||||||
this.push({ event: 'websocket.subscribe:response', data: { channel: msg.data.channel }, channel: msg.data.channel })
|
this.push({ event: 'websocket.subscribe:response', data: { channel: msg.data.channel }, channel: msg.data.channel })
|
||||||
} else {
|
} 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 })
|
this.push({ event: msg.event + ':response', data: { ok: true, via: this.index }, channel: msg.channel })
|
||||||
}
|
}
|
||||||
}, 2)
|
}, 2)
|
||||||
|
|
@ -74,6 +77,7 @@ beforeEach(() => {
|
||||||
FakeWS.plan = []
|
FakeWS.plan = []
|
||||||
FakeWS.sockets = []
|
FakeWS.sockets = []
|
||||||
FakeWS.answer = {}
|
FakeWS.answer = {}
|
||||||
|
FakeWS.progress = {}
|
||||||
globalThis.WebSocket = FakeWS
|
globalThis.WebSocket = FakeWS
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -212,3 +216,49 @@ test('destroy() cancels an auto-reconnect that a drop had already scheduled', as
|
||||||
await sleep(80)
|
await sleep(80)
|
||||||
assert.equal(FakeWS.sockets.length, 1, 'no reconnect after destroy()')
|
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