experimental-a2 0.9.0 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +53 -0
- package/dist/actor-D_54lz_1.d.ts +310 -0
- package/dist/actor-D_54lz_1.d.ts.map +1 -0
- package/dist/actor-client.d.ts +13 -4
- package/dist/actor-client.d.ts.map +1 -1
- package/dist/actor-client.js +63 -7
- package/dist/actor-client.js.map +1 -1
- package/dist/actor-react.d.ts +5 -4
- package/dist/actor-react.d.ts.map +1 -1
- package/dist/actor-react.js +16 -2
- package/dist/actor-react.js.map +1 -1
- package/dist/actor.d.ts +2 -176
- package/dist/actor.js +13 -2
- package/dist/actor.js.map +1 -1
- package/dist/ai-server.d.ts +1 -1
- package/dist/ai-server.js +2 -2
- package/dist/ai.d.ts +1 -1
- package/dist/ai.js +1 -1
- package/dist/client-Bf6uSEAk.js +1342 -0
- package/dist/client-Bf6uSEAk.js.map +1 -0
- package/dist/client-P_NNNRM-.d.ts +243 -0
- package/dist/client-P_NNNRM-.d.ts.map +1 -0
- package/dist/client.d.ts +2 -202
- package/dist/client.js +2 -1026
- package/dist/errors-DCk6ch5n.js.map +1 -1
- package/dist/errors-DvhSXnxk.d.ts +28 -0
- package/dist/errors-DvhSXnxk.d.ts.map +1 -0
- package/dist/index.d.ts +3 -35
- package/dist/{internal-DRXJ56EI.js → internal-Dq2qYxou.js} +2 -2
- package/dist/{internal-DRXJ56EI.js.map → internal-Dq2qYxou.js.map} +1 -1
- package/dist/platform-B4TnJtWu.js +34 -0
- package/dist/platform-B4TnJtWu.js.map +1 -0
- package/dist/react.d.ts +3 -1
- package/dist/react.d.ts.map +1 -1
- package/dist/react.js +2 -1
- package/dist/react.js.map +1 -1
- package/dist/scheduler-qstash.d.ts +2 -2
- package/dist/scheduler-qstash.js +3 -2
- package/dist/scheduler-qstash.js.map +1 -1
- package/dist/scheduler-vercel.d.ts +2 -2
- package/dist/scheduler-vercel.js +2 -2
- package/dist/{server-CBET-jSz.js → server-Dkz2a84E.js} +295 -133
- package/dist/server-Dkz2a84E.js.map +1 -0
- package/dist/{server-CKY3_lbw.d.ts → server-DwPrMqHB.d.ts} +4 -2
- package/dist/server-DwPrMqHB.d.ts.map +1 -0
- package/dist/server.d.ts +2 -2
- package/dist/server.js +1 -1
- package/dist/{store-DGHeBtIQ.d.ts → store-DtDOWLSn.d.ts} +4 -5
- package/dist/{store-DGHeBtIQ.d.ts.map → store-DtDOWLSn.d.ts.map} +1 -1
- package/dist/store-N8PXxDAS.js.map +1 -1
- package/dist/store-memory.d.ts +1 -1
- package/dist/store-memory.js +1 -1
- package/dist/{store-polling-6DW7F1DT.js → store-polling-CmxUbV93.js} +56 -7
- package/dist/store-polling-CmxUbV93.js.map +1 -0
- package/dist/store-postgres.d.ts +6 -4
- package/dist/store-postgres.d.ts.map +1 -1
- package/dist/store-postgres.js +701 -53
- package/dist/store-postgres.js.map +1 -1
- package/dist/store-presence-polling-C7-XZyW9.js +94 -0
- package/dist/store-presence-polling-C7-XZyW9.js.map +1 -0
- package/dist/store-redis-http.d.ts +2 -2
- package/dist/store-redis-http.d.ts.map +1 -1
- package/dist/store-redis-http.js +184 -38
- package/dist/store-redis-http.js.map +1 -1
- package/dist/{store-redis-core-z-ykbyMg.js → store-redis-notify-BUCyXOn0.js} +491 -27
- package/dist/store-redis-notify-BUCyXOn0.js.map +1 -0
- package/dist/store-redis.d.ts +5 -13
- package/dist/store-redis.d.ts.map +1 -1
- package/dist/store-redis.js +27 -271
- package/dist/store-redis.js.map +1 -1
- package/dist/store-sqlite.d.ts +1 -1
- package/dist/store-sqlite.js +1 -1
- package/dist/{wire--yji6mO3.js → wire-BO5wWCb1.js} +18 -16
- package/dist/wire-BO5wWCb1.js.map +1 -0
- package/docs/actors/04-routes.mdx +40 -8
- package/docs/guides/03-react.mdx +52 -10
- package/docs/guides/05-production.mdx +54 -5
- package/docs/guides/09-presence.mdx +12 -7
- package/docs/guides/10-transports.mdx +85 -22
- package/docs/reference/01-api.mdx +90 -28
- package/docs/reference/02-errors.mdx +45 -11
- package/examples/playground/package.json +1 -1
- package/package.json +1 -1
- package/src/actor-client.ts +114 -14
- package/src/actor-react.ts +18 -3
- package/src/actor.ts +16 -1
- package/src/client-errors.ts +51 -0
- package/src/client.ts +577 -175
- package/src/errors.ts +4 -9
- package/src/index.ts +1 -1
- package/src/internal.ts +1 -1
- package/src/postgres-notification-scope.ts +134 -0
- package/src/postgres-notifications.ts +244 -0
- package/src/postgres-pool.ts +65 -0
- package/src/postgres-resources.ts +185 -0
- package/src/presence-recovery.ts +120 -0
- package/src/react.ts +3 -0
- package/src/redis-http-subscriptions.ts +199 -0
- package/src/server-fetch.ts +51 -23
- package/src/server.ts +146 -19
- package/src/session-socket.ts +241 -88
- package/src/sse.ts +17 -0
- package/src/store-polling.ts +32 -12
- package/src/store-postgres.ts +292 -75
- package/src/store-presence-polling.ts +125 -0
- package/src/store-redis-core.ts +37 -30
- package/src/store-redis-http.ts +51 -45
- package/src/store-redis-notify.ts +464 -0
- package/src/store-redis.ts +9 -364
- package/src/store.ts +3 -4
- package/src/stream-activity.ts +32 -0
- package/src/wire.ts +39 -15
- package/dist/actor-shared-BACubf4x.d.ts +0 -136
- package/dist/actor-shared-BACubf4x.d.ts.map +0 -1
- package/dist/actor.d.ts.map +0 -1
- package/dist/client.d.ts.map +0 -1
- package/dist/client.js.map +0 -1
- package/dist/index.d.ts.map +0 -1
- package/dist/server-CBET-jSz.js.map +0 -1
- package/dist/server-CKY3_lbw.d.ts.map +0 -1
- package/dist/store-polling-6DW7F1DT.js.map +0 -1
- package/dist/store-redis-core-z-ykbyMg.js.map +0 -1
- package/dist/wire--yji6mO3.js.map +0 -1
package/src/client.ts
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
16
|
// oxlint-disable no-await-in-loop -- retry/backoff/stream loops are sequential by nature
|
|
17
|
-
import {
|
|
17
|
+
import { A2ClientError, asClientError } from './client-errors.ts'
|
|
18
18
|
import {
|
|
19
19
|
PRESENCE_TIMINGS,
|
|
20
20
|
RESERVED_PARTICIPANT_IDS,
|
|
@@ -51,7 +51,17 @@ import {
|
|
|
51
51
|
type WirePresenceSnapshot,
|
|
52
52
|
} from './wire.ts'
|
|
53
53
|
|
|
54
|
-
export
|
|
54
|
+
export { A2ClientError } from './client-errors.ts'
|
|
55
|
+
export type { A2ClientErrorOptions } from './client-errors.ts'
|
|
56
|
+
|
|
57
|
+
export type ReconnectPolicy = (failure: {
|
|
58
|
+
sessionId: string
|
|
59
|
+
error: A2ClientError | null
|
|
60
|
+
attempt: number
|
|
61
|
+
}) => number | false
|
|
62
|
+
|
|
63
|
+
export type ConnectionStatus =
|
|
64
|
+
'idle' | 'connecting' | 'paused' | 'live' | 'closed'
|
|
55
65
|
|
|
56
66
|
/**
|
|
57
67
|
* The connection, as a discriminated union — impossible states are
|
|
@@ -63,11 +73,12 @@ export type Connection =
|
|
|
63
73
|
| { status: 'idle' }
|
|
64
74
|
| {
|
|
65
75
|
status: 'connecting'
|
|
66
|
-
/**
|
|
76
|
+
/** Stream restarts so far. `0` = first connect. */
|
|
67
77
|
reconnects: number
|
|
68
78
|
/** Why the last connection ended; `null` on the first connect. */
|
|
69
|
-
error:
|
|
79
|
+
error: A2ClientError | null
|
|
70
80
|
}
|
|
81
|
+
| { status: 'paused'; reconnects: number; error: A2ClientError | null }
|
|
71
82
|
| { status: 'live'; reconnects: number }
|
|
72
83
|
| { status: 'closed' }
|
|
73
84
|
|
|
@@ -123,7 +134,7 @@ export type SessionSnapshot<
|
|
|
123
134
|
* (provisional indexes past the frontier). `state` never folds
|
|
124
135
|
* backscrolled events; they are display data below the frontier. */
|
|
125
136
|
events: ContractEvent<D>[]
|
|
126
|
-
/** The
|
|
137
|
+
/** The latest server-confirmed state index. This is the
|
|
127
138
|
* `lastSeenIndex` cancellation wants. */
|
|
128
139
|
index: number
|
|
129
140
|
/** Backscroll progress — `loadHistory` moves it. */
|
|
@@ -161,7 +172,7 @@ export type SessionClient<
|
|
|
161
172
|
* Optimistic append: validates locally against the reducer's event
|
|
162
173
|
* schemas (instant `INVALID_PAYLOAD`, no flicker), applies to the
|
|
163
174
|
* local fold, POSTs, swaps in the ack, rolls back on rejection.
|
|
164
|
-
* Auto-retries
|
|
175
|
+
* Auto-retries `STORE_UNAVAILABLE` and transport failures. Resolves with the appended
|
|
165
176
|
* events as the server recorded them. Overlapping calls on this
|
|
166
177
|
* handle enter the transport in invocation order without delaying
|
|
167
178
|
* their optimistic folds.
|
|
@@ -184,13 +195,17 @@ export type SessionClient<
|
|
|
184
195
|
* closes — so independent consumers of one identity-mapped handle
|
|
185
196
|
* (two hooks, a hook plus vanilla code) never fight over the
|
|
186
197
|
* stream. Releasing twice is a no-op. While any lease is held the
|
|
187
|
-
* stream
|
|
198
|
+
* stream follows the client's reconnection policy and resumes after
|
|
199
|
+
* its last streamed event.
|
|
188
200
|
*/
|
|
189
201
|
connect(): () => void
|
|
202
|
+
/** Restart a leased subscription immediately after its last streamed event.
|
|
203
|
+
* Preserves state, pending writes, and leases. No-op without a lease. */
|
|
204
|
+
reconnect(): void
|
|
190
205
|
/**
|
|
191
206
|
* The hard stop: drops every outstanding lease and closes the
|
|
192
207
|
* stream now. Not terminal — a later `connect()` starts fresh from
|
|
193
|
-
* the
|
|
208
|
+
* the last streamed event.
|
|
194
209
|
*/
|
|
195
210
|
close(): void
|
|
196
211
|
}
|
|
@@ -217,16 +232,21 @@ export type A2Client<
|
|
|
217
232
|
}
|
|
218
233
|
|
|
219
234
|
/**
|
|
220
|
-
* The wire the client rides. The
|
|
235
|
+
* The wire the client rides. The first form is one route serving GET
|
|
221
236
|
* (SSE stream) + POST (push). `http` splits the verbs across two
|
|
222
237
|
* routes, for when platform duration limits differ per verb. `ws`
|
|
223
|
-
* rides everything
|
|
224
|
-
*
|
|
238
|
+
* rides everything over WebSocket, one socket per session by default.
|
|
239
|
+
* Multiplexing every session of the client is an explicit opt-in.
|
|
225
240
|
*/
|
|
241
|
+
export type SessionUrl = string | ((sessionId: string) => string)
|
|
242
|
+
|
|
226
243
|
export type ClientApi =
|
|
227
|
-
|
|
|
228
|
-
| { type: 'http'; push:
|
|
229
|
-
|
|
244
|
+
| SessionUrl // one route: GET SSE stream + POST push
|
|
245
|
+
| { type: 'http'; push: SessionUrl; stream: SessionUrl } // split routes
|
|
246
|
+
/** One socket per session. */
|
|
247
|
+
| { type: 'ws'; url: SessionUrl; multiplex?: false }
|
|
248
|
+
/** One socket for every session of this client. */
|
|
249
|
+
| { type: 'ws'; url: string; multiplex: true }
|
|
230
250
|
|
|
231
251
|
/**
|
|
232
252
|
* The socket surface the `ws` transport drives — satisfied structurally
|
|
@@ -259,6 +279,8 @@ export type CreateClientOptions<
|
|
|
259
279
|
reducer: Reducer<D, S, P>
|
|
260
280
|
/** The route (or routes) the client speaks — see `ClientApi`. */
|
|
261
281
|
api: ClientApi
|
|
282
|
+
/** Delay the next connection attempt in milliseconds, or pause with false. */
|
|
283
|
+
reconnect?: ReconnectPolicy
|
|
262
284
|
/** Injectable fetch — defaults to the global. */
|
|
263
285
|
fetch?: typeof globalThis.fetch
|
|
264
286
|
/** Injectable WebSocket constructor for the `ws` api — defaults to
|
|
@@ -278,6 +300,13 @@ export type CreateClientOptions<
|
|
|
278
300
|
}
|
|
279
301
|
|
|
280
302
|
const PUSH_ATTEMPTS = 3
|
|
303
|
+
const RETRYABLE_PUSH_CODES = new Set([
|
|
304
|
+
'STORE_UNAVAILABLE',
|
|
305
|
+
'TIMEOUT',
|
|
306
|
+
'HTTP_ERROR',
|
|
307
|
+
'CONNECTION_FAILED',
|
|
308
|
+
'UNKNOWN',
|
|
309
|
+
])
|
|
281
310
|
const DEFAULT_GC_TIME_MS = 5 * 60 * 1_000
|
|
282
311
|
const DEFAULT_HISTORY_LIMIT = 50
|
|
283
312
|
|
|
@@ -292,6 +321,7 @@ type TransportFrame =
|
|
|
292
321
|
| { kind: 'event'; event: WireEvent }
|
|
293
322
|
| { kind: 'presence'; patch: WirePresencePatch }
|
|
294
323
|
| { kind: 'presence-snapshot'; snapshot: WirePresenceSnapshot }
|
|
324
|
+
| { kind: 'activity'; idle: boolean }
|
|
295
325
|
| { kind: 'ping' }
|
|
296
326
|
|
|
297
327
|
type TransportPushBody = {
|
|
@@ -322,7 +352,7 @@ type Transport = {
|
|
|
322
352
|
signal: AbortSignal
|
|
323
353
|
}): AsyncIterable<TransportFrame>
|
|
324
354
|
/** One attempt (the retry loop lives above the seam): resolves with
|
|
325
|
-
* the acked wire events, throws
|
|
355
|
+
* the acked wire events, throws A2ClientError. */
|
|
326
356
|
push(body: TransportPushBody): Promise<WireEvent[]>
|
|
327
357
|
/** One bounded cold read of the log, oldest first — the lane
|
|
328
358
|
* `loadHistory` rides. Absent on transports without a
|
|
@@ -344,21 +374,50 @@ type Transport = {
|
|
|
344
374
|
awaitReady?(sessionId: string, maxWaitMs: number): Promise<void>
|
|
345
375
|
}
|
|
346
376
|
|
|
377
|
+
const sessionUrl = (url: SessionUrl, sessionId: string): string => {
|
|
378
|
+
const resolved = typeof url === 'function' ? url(sessionId) : url
|
|
379
|
+
if (typeof resolved !== 'string') {
|
|
380
|
+
throw new TypeError('a session URL function must return a string')
|
|
381
|
+
}
|
|
382
|
+
return resolved
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
const httpError = async (response: Response): Promise<A2ClientError> => {
|
|
386
|
+
const error = errorFromWire(await response.json().catch(() => null))
|
|
387
|
+
return error === null
|
|
388
|
+
? new A2ClientError(
|
|
389
|
+
'HTTP_ERROR',
|
|
390
|
+
`request failed with ${response.status}`,
|
|
391
|
+
{
|
|
392
|
+
status: response.status,
|
|
393
|
+
},
|
|
394
|
+
)
|
|
395
|
+
: new A2ClientError(error.code, error.message, {
|
|
396
|
+
details: error.details,
|
|
397
|
+
status: response.status,
|
|
398
|
+
cause: error,
|
|
399
|
+
})
|
|
400
|
+
}
|
|
401
|
+
|
|
347
402
|
const httpTransport = (
|
|
348
|
-
routes: { push:
|
|
403
|
+
routes: { push: SessionUrl; stream: SessionUrl },
|
|
349
404
|
fetchImpl: typeof globalThis.fetch,
|
|
350
405
|
// Contracts that declare no presence skip presence frames unparsed,
|
|
351
406
|
// exactly like unknown named frames.
|
|
352
407
|
presence: boolean,
|
|
353
408
|
): Transport => ({
|
|
354
409
|
async *connect({ sessionId, startAfter, signal }) {
|
|
355
|
-
const
|
|
410
|
+
const streamUrl = sessionUrl(routes.stream, sessionId)
|
|
411
|
+
const sep = streamUrl.includes('?') ? '&' : '?'
|
|
356
412
|
const res = await fetchImpl(
|
|
357
|
-
`${
|
|
413
|
+
`${streamUrl}${sep}sessionId=${encodeURIComponent(sessionId)}&index=${startAfter}`,
|
|
358
414
|
{ headers: { accept: 'text/event-stream' }, signal },
|
|
359
415
|
)
|
|
360
|
-
if (!res.ok
|
|
361
|
-
|
|
416
|
+
if (!res.ok) throw await httpError(res)
|
|
417
|
+
if (!res.body) {
|
|
418
|
+
throw new A2ClientError('UNKNOWN', 'stream response has no body', {
|
|
419
|
+
status: res.status,
|
|
420
|
+
})
|
|
362
421
|
}
|
|
363
422
|
yield { kind: 'ping' } // connected — headers in hand, before any bytes
|
|
364
423
|
const decoder = new TextDecoder()
|
|
@@ -402,6 +461,11 @@ const httpTransport = (
|
|
|
402
461
|
if (isWirePresencePatch(parsed)) {
|
|
403
462
|
yield { kind: 'presence', patch: parsed }
|
|
404
463
|
}
|
|
464
|
+
} else if (
|
|
465
|
+
eventName === 'a2-activity' &&
|
|
466
|
+
(data === 'idle' || data === 'active')
|
|
467
|
+
) {
|
|
468
|
+
yield { kind: 'activity', idle: data === 'idle' }
|
|
405
469
|
}
|
|
406
470
|
// Other named frames: skipped — old clients stay compatible
|
|
407
471
|
// with future lanes the same way.
|
|
@@ -413,7 +477,7 @@ const httpTransport = (
|
|
|
413
477
|
},
|
|
414
478
|
|
|
415
479
|
async push(body) {
|
|
416
|
-
const res = await fetchImpl(routes.push, {
|
|
480
|
+
const res = await fetchImpl(sessionUrl(routes.push, body.sessionId), {
|
|
417
481
|
method: 'POST',
|
|
418
482
|
headers: { 'content-type': 'application/json' },
|
|
419
483
|
body: JSON.stringify(body),
|
|
@@ -421,34 +485,29 @@ const httpTransport = (
|
|
|
421
485
|
if (res.ok) {
|
|
422
486
|
const rows = (await res.json()) as unknown
|
|
423
487
|
if (!Array.isArray(rows) || !rows.every(isWireEvent)) {
|
|
424
|
-
throw new
|
|
488
|
+
throw new A2ClientError(
|
|
425
489
|
'STORE_UNAVAILABLE',
|
|
426
490
|
'push ack was not a list of events',
|
|
427
491
|
)
|
|
428
492
|
}
|
|
429
493
|
return rows
|
|
430
494
|
}
|
|
431
|
-
throw (
|
|
432
|
-
errorFromWire(await res.json().catch(() => null)) ??
|
|
433
|
-
new A2Error('STORE_UNAVAILABLE', `push failed with ${res.status}`)
|
|
434
|
-
)
|
|
495
|
+
throw await httpError(res)
|
|
435
496
|
},
|
|
436
497
|
|
|
437
498
|
async history({ sessionId, gte, lte }) {
|
|
438
|
-
const
|
|
499
|
+
const streamUrl = sessionUrl(routes.stream, sessionId)
|
|
500
|
+
const sep = streamUrl.includes('?') ? '&' : '?'
|
|
439
501
|
const res = await fetchImpl(
|
|
440
|
-
`${
|
|
502
|
+
`${streamUrl}${sep}sessionId=${encodeURIComponent(sessionId)}>e=${gte}<e=${lte}`,
|
|
441
503
|
{ headers: { accept: 'application/json' } },
|
|
442
504
|
)
|
|
443
505
|
if (!res.ok) {
|
|
444
|
-
throw (
|
|
445
|
-
errorFromWire(await res.json().catch(() => null)) ??
|
|
446
|
-
new A2Error('STORE_UNAVAILABLE', `history failed with ${res.status}`)
|
|
447
|
-
)
|
|
506
|
+
throw await httpError(res)
|
|
448
507
|
}
|
|
449
508
|
const rows = (await res.json()) as unknown
|
|
450
509
|
if (!Array.isArray(rows) || !rows.every(isWireEvent)) {
|
|
451
|
-
throw new
|
|
510
|
+
throw new A2ClientError(
|
|
452
511
|
'STORE_UNAVAILABLE',
|
|
453
512
|
'history response was not a list of events',
|
|
454
513
|
)
|
|
@@ -459,11 +518,14 @@ const httpTransport = (
|
|
|
459
518
|
sendPresence(envelope) {
|
|
460
519
|
void (async () => {
|
|
461
520
|
try {
|
|
462
|
-
const res = await fetchImpl(
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
521
|
+
const res = await fetchImpl(
|
|
522
|
+
sessionUrl(routes.push, envelope.sessionId),
|
|
523
|
+
{
|
|
524
|
+
method: 'POST',
|
|
525
|
+
headers: { 'content-type': 'application/json' },
|
|
526
|
+
body: JSON.stringify(envelope),
|
|
527
|
+
},
|
|
528
|
+
)
|
|
467
529
|
await res.arrayBuffer()
|
|
468
530
|
} catch {
|
|
469
531
|
// Dropped by design — nothing was ever true.
|
|
@@ -475,6 +537,296 @@ const httpTransport = (
|
|
|
475
537
|
/** `WebSocket.OPEN` — fixed by the spec, identical in every runtime. */
|
|
476
538
|
const WS_OPEN = 1
|
|
477
539
|
|
|
540
|
+
type PendingAck = {
|
|
541
|
+
resolve: (events: WireEvent[]) => void
|
|
542
|
+
reject: (error: A2ClientError) => void
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
const webSocketUrl = (value: string): URL => {
|
|
546
|
+
let resolved: URL
|
|
547
|
+
if (/^(https?|wss?):\/\//i.test(value)) {
|
|
548
|
+
resolved = new URL(value)
|
|
549
|
+
} else {
|
|
550
|
+
const base = (globalThis as { location?: { href: string } }).location?.href
|
|
551
|
+
if (base === undefined) {
|
|
552
|
+
throw new TypeError(
|
|
553
|
+
`cannot resolve the relative ws url '${value}' without a browser location — pass an absolute ws:// or wss:// url`,
|
|
554
|
+
)
|
|
555
|
+
}
|
|
556
|
+
resolved = new URL(value, base)
|
|
557
|
+
}
|
|
558
|
+
if (resolved.protocol === 'http:') resolved.protocol = 'ws:'
|
|
559
|
+
else if (resolved.protocol === 'https:') resolved.protocol = 'wss:'
|
|
560
|
+
return resolved
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
const socketReadyWaiters = () => {
|
|
564
|
+
const sessions = new Map<string, Set<() => void>>()
|
|
565
|
+
|
|
566
|
+
const notify = (sessionId: string): void => {
|
|
567
|
+
const waiters = sessions.get(sessionId)
|
|
568
|
+
if (!waiters) return
|
|
569
|
+
sessions.delete(sessionId)
|
|
570
|
+
for (const waiter of waiters) waiter()
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
const wait = (sessionId: string, maxWaitMs: number): Promise<void> =>
|
|
574
|
+
new Promise((resolve) => {
|
|
575
|
+
let waiters = sessions.get(sessionId)
|
|
576
|
+
if (!waiters) {
|
|
577
|
+
waiters = new Set()
|
|
578
|
+
sessions.set(sessionId, waiters)
|
|
579
|
+
}
|
|
580
|
+
let settled = false
|
|
581
|
+
const done = (): void => {
|
|
582
|
+
if (settled) return
|
|
583
|
+
settled = true
|
|
584
|
+
clearTimeout(timer)
|
|
585
|
+
waiters.delete(done)
|
|
586
|
+
if (waiters.size === 0) sessions.delete(sessionId)
|
|
587
|
+
// oxlint-disable-next-line promise/no-multiple-resolved -- `settled` guards the timer and notification callers
|
|
588
|
+
resolve()
|
|
589
|
+
}
|
|
590
|
+
const timer = setTimeout(done, maxWaitMs)
|
|
591
|
+
;(timer as { unref?: () => void }).unref?.()
|
|
592
|
+
waiters.add(done)
|
|
593
|
+
})
|
|
594
|
+
|
|
595
|
+
return { notify, wait }
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
const rejectPendingAcks = (pending: Map<number, PendingAck>): void => {
|
|
599
|
+
if (pending.size === 0) return
|
|
600
|
+
const error = new A2ClientError(
|
|
601
|
+
'STORE_UNAVAILABLE',
|
|
602
|
+
'socket closed with the ack outstanding',
|
|
603
|
+
)
|
|
604
|
+
for (const waiter of pending.values()) waiter.reject(error)
|
|
605
|
+
pending.clear()
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
const sessionWsTransport = (
|
|
609
|
+
url: SessionUrl,
|
|
610
|
+
webSocketImpl: ClientWebSocketConstructor | undefined,
|
|
611
|
+
presence: boolean,
|
|
612
|
+
): Transport => {
|
|
613
|
+
type SocketConnection = {
|
|
614
|
+
socket: ClientWebSocket
|
|
615
|
+
pending: Map<number, PendingAck>
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
const connections = new Map<string, SocketConnection>()
|
|
619
|
+
const ready = socketReadyWaiters()
|
|
620
|
+
let nextReq = 1
|
|
621
|
+
|
|
622
|
+
const socketUrl = (sessionId: string, startAfter: number): string => {
|
|
623
|
+
const resolved = webSocketUrl(sessionUrl(url, sessionId))
|
|
624
|
+
resolved.searchParams.set('sessionId', sessionId)
|
|
625
|
+
resolved.searchParams.set('index', String(startAfter))
|
|
626
|
+
return resolved.href
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
return {
|
|
630
|
+
async *connect({ sessionId, startAfter, signal }) {
|
|
631
|
+
if (webSocketImpl === undefined) {
|
|
632
|
+
throw new TypeError(
|
|
633
|
+
'no WebSocket implementation available — pass one via createClient({ webSocket }) where the global is missing',
|
|
634
|
+
)
|
|
635
|
+
}
|
|
636
|
+
const socket = new webSocketImpl(socketUrl(sessionId, startAfter))
|
|
637
|
+
const connection: SocketConnection = { socket, pending: new Map() }
|
|
638
|
+
connections.set(sessionId, connection)
|
|
639
|
+
|
|
640
|
+
const queue: TransportFrame[] = []
|
|
641
|
+
let ended = false
|
|
642
|
+
let failure: Error | null = null
|
|
643
|
+
let wake: (() => void) | null = null
|
|
644
|
+
const notify = (): void => {
|
|
645
|
+
wake?.()
|
|
646
|
+
wake = null
|
|
647
|
+
}
|
|
648
|
+
const finish = (error: Error | null): void => {
|
|
649
|
+
if (ended) return
|
|
650
|
+
ended = true
|
|
651
|
+
failure = error
|
|
652
|
+
if (connections.get(sessionId) === connection) {
|
|
653
|
+
connections.delete(sessionId)
|
|
654
|
+
}
|
|
655
|
+
rejectPendingAcks(connection.pending)
|
|
656
|
+
notify()
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
socket.addEventListener('open', () => {
|
|
660
|
+
if (connections.get(sessionId) !== connection || ended) return
|
|
661
|
+
queue.push({ kind: 'ping' })
|
|
662
|
+
notify()
|
|
663
|
+
ready.notify(sessionId)
|
|
664
|
+
})
|
|
665
|
+
socket.addEventListener('message', (event) => {
|
|
666
|
+
if (connections.get(sessionId) !== connection || ended) return
|
|
667
|
+
if (typeof event.data !== 'string') return
|
|
668
|
+
const frame = parseSocketFrame(event.data)
|
|
669
|
+
if (frame === null) return
|
|
670
|
+
switch (frame.kind) {
|
|
671
|
+
case 'ack': {
|
|
672
|
+
const waiter = connection.pending.get(frame.req)
|
|
673
|
+
if (waiter === undefined) return
|
|
674
|
+
connection.pending.delete(frame.req)
|
|
675
|
+
if (
|
|
676
|
+
frame.sessionId !== undefined ||
|
|
677
|
+
('events' in frame &&
|
|
678
|
+
frame.events.some((row) => row.sessionId !== sessionId))
|
|
679
|
+
) {
|
|
680
|
+
waiter.reject(
|
|
681
|
+
new A2ClientError(
|
|
682
|
+
'STORE_UNAVAILABLE',
|
|
683
|
+
'ack does not belong to the socket session',
|
|
684
|
+
),
|
|
685
|
+
)
|
|
686
|
+
return
|
|
687
|
+
}
|
|
688
|
+
if ('events' in frame) waiter.resolve(frame.events)
|
|
689
|
+
else waiter.reject(asClientError(frame.error))
|
|
690
|
+
return
|
|
691
|
+
}
|
|
692
|
+
case 'event':
|
|
693
|
+
if (frame.event.sessionId !== sessionId) return
|
|
694
|
+
queue.push({ kind: 'event', event: frame.event })
|
|
695
|
+
break
|
|
696
|
+
case 'presence':
|
|
697
|
+
if (presence && frame.sessionId === undefined) {
|
|
698
|
+
queue.push({ kind: 'presence', patch: frame.patch })
|
|
699
|
+
}
|
|
700
|
+
break
|
|
701
|
+
case 'presence-snapshot':
|
|
702
|
+
if (presence && frame.sessionId === undefined) {
|
|
703
|
+
queue.push({
|
|
704
|
+
kind: 'presence-snapshot',
|
|
705
|
+
snapshot: frame.snapshot,
|
|
706
|
+
})
|
|
707
|
+
}
|
|
708
|
+
break
|
|
709
|
+
case 'ping':
|
|
710
|
+
queue.push(frame)
|
|
711
|
+
break
|
|
712
|
+
case 'subscribed':
|
|
713
|
+
case 'unsubscribed':
|
|
714
|
+
return
|
|
715
|
+
}
|
|
716
|
+
notify()
|
|
717
|
+
})
|
|
718
|
+
socket.addEventListener('close', (event) => {
|
|
719
|
+
finish(
|
|
720
|
+
event.code === 1000
|
|
721
|
+
? null
|
|
722
|
+
: new A2ClientError(
|
|
723
|
+
'CONNECTION_FAILED',
|
|
724
|
+
`socket closed (${event.code}${event.reason ? `: ${event.reason}` : ''})`,
|
|
725
|
+
{ closeCode: event.code, closeReason: event.reason },
|
|
726
|
+
),
|
|
727
|
+
)
|
|
728
|
+
})
|
|
729
|
+
socket.addEventListener('error', () => {
|
|
730
|
+
finish(new A2ClientError('CONNECTION_FAILED', 'socket error'))
|
|
731
|
+
})
|
|
732
|
+
|
|
733
|
+
const onAbort = (): void => {
|
|
734
|
+
finish(
|
|
735
|
+
signal.reason instanceof Error
|
|
736
|
+
? signal.reason
|
|
737
|
+
: new Error('stream aborted'),
|
|
738
|
+
)
|
|
739
|
+
try {
|
|
740
|
+
socket.close(1000)
|
|
741
|
+
} catch {
|
|
742
|
+
// Already closed.
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
if (signal.aborted) onAbort()
|
|
746
|
+
else signal.addEventListener('abort', onAbort, { once: true })
|
|
747
|
+
|
|
748
|
+
try {
|
|
749
|
+
for (;;) {
|
|
750
|
+
while (queue.length > 0) yield queue.shift()!
|
|
751
|
+
if (ended) {
|
|
752
|
+
if (failure) throw failure
|
|
753
|
+
return
|
|
754
|
+
}
|
|
755
|
+
await new Promise<void>((resolve) => {
|
|
756
|
+
wake = resolve
|
|
757
|
+
})
|
|
758
|
+
}
|
|
759
|
+
} finally {
|
|
760
|
+
signal.removeEventListener('abort', onAbort)
|
|
761
|
+
finish(null)
|
|
762
|
+
try {
|
|
763
|
+
socket.close(1000)
|
|
764
|
+
} catch {
|
|
765
|
+
// Already closed.
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
},
|
|
769
|
+
|
|
770
|
+
async push(body) {
|
|
771
|
+
const connection = connections.get(body.sessionId)
|
|
772
|
+
if (
|
|
773
|
+
connection === undefined ||
|
|
774
|
+
connection.socket.readyState !== WS_OPEN
|
|
775
|
+
) {
|
|
776
|
+
throw new A2ClientError(
|
|
777
|
+
'STORE_UNAVAILABLE',
|
|
778
|
+
'no open socket for the session',
|
|
779
|
+
)
|
|
780
|
+
}
|
|
781
|
+
const req = nextReq
|
|
782
|
+
nextReq += 1
|
|
783
|
+
return await new Promise<WireEvent[]>((resolve, reject) => {
|
|
784
|
+
connection.pending.set(req, { resolve, reject })
|
|
785
|
+
const frame: SocketPushFrame = {
|
|
786
|
+
kind: 'push',
|
|
787
|
+
req,
|
|
788
|
+
events: body.events,
|
|
789
|
+
}
|
|
790
|
+
try {
|
|
791
|
+
connection.socket.send(JSON.stringify(frame))
|
|
792
|
+
} catch (cause) {
|
|
793
|
+
connection.pending.delete(req)
|
|
794
|
+
reject(
|
|
795
|
+
new A2ClientError('STORE_UNAVAILABLE', 'socket send failed', {
|
|
796
|
+
cause,
|
|
797
|
+
}),
|
|
798
|
+
)
|
|
799
|
+
}
|
|
800
|
+
})
|
|
801
|
+
},
|
|
802
|
+
|
|
803
|
+
awaitReady(sessionId, maxWaitMs) {
|
|
804
|
+
const connection = connections.get(sessionId)
|
|
805
|
+
if (connection?.socket.readyState === WS_OPEN) return Promise.resolve()
|
|
806
|
+
return ready.wait(sessionId, maxWaitMs)
|
|
807
|
+
},
|
|
808
|
+
|
|
809
|
+
sendPresence(envelope) {
|
|
810
|
+
const connection = connections.get(envelope.sessionId)
|
|
811
|
+
if (
|
|
812
|
+
connection === undefined ||
|
|
813
|
+
connection.socket.readyState !== WS_OPEN
|
|
814
|
+
) {
|
|
815
|
+
return
|
|
816
|
+
}
|
|
817
|
+
const frame: SocketPresenceFrame = {
|
|
818
|
+
kind: 'presence',
|
|
819
|
+
...envelope.presence,
|
|
820
|
+
}
|
|
821
|
+
try {
|
|
822
|
+
connection.socket.send(JSON.stringify(frame))
|
|
823
|
+
} catch {
|
|
824
|
+
return
|
|
825
|
+
}
|
|
826
|
+
},
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
|
|
478
830
|
/** One session's lane on the shared ws socket — created per `connect`
|
|
479
831
|
* attempt, so `startAfter` is that attempt's resume frontier. */
|
|
480
832
|
type WsChannel = {
|
|
@@ -502,15 +854,11 @@ const subscribeFrame = (
|
|
|
502
854
|
return JSON.stringify(frame)
|
|
503
855
|
}
|
|
504
856
|
|
|
505
|
-
const
|
|
857
|
+
const multiplexedWsTransport = (
|
|
506
858
|
url: string,
|
|
507
859
|
webSocketImpl: ClientWebSocketConstructor | undefined,
|
|
508
860
|
presence: boolean,
|
|
509
861
|
): Transport => {
|
|
510
|
-
type PendingAck = {
|
|
511
|
-
resolve: (events: WireEvent[]) => void
|
|
512
|
-
reject: (error: A2Error) => void
|
|
513
|
-
}
|
|
514
862
|
// ONE socket serves every session of this client (specs §13's
|
|
515
863
|
// multiplexed superset): channels subscribe on it at their own
|
|
516
864
|
// frontiers, down frames route back by sessionId, acks by req.
|
|
@@ -518,41 +866,12 @@ const wsTransport = (
|
|
|
518
866
|
const pending = new Map<number, PendingAck>()
|
|
519
867
|
/** Push retries parked in `awaitReady`, flushed per session on its
|
|
520
868
|
* subscribe confirmation. */
|
|
521
|
-
const
|
|
869
|
+
const ready = socketReadyWaiters()
|
|
522
870
|
let socket: ClientWebSocket | null = null
|
|
523
871
|
let socketOpen = false
|
|
524
872
|
let watchdog: ReturnType<typeof setTimeout> | undefined
|
|
525
873
|
let nextReq = 1
|
|
526
874
|
|
|
527
|
-
const flushReadyWaiters = (sessionId: string): void => {
|
|
528
|
-
const waiters = readyWaiters.get(sessionId)
|
|
529
|
-
if (!waiters) return
|
|
530
|
-
readyWaiters.delete(sessionId)
|
|
531
|
-
for (const waiter of waiters) waiter()
|
|
532
|
-
}
|
|
533
|
-
|
|
534
|
-
const socketUrl = (): string => {
|
|
535
|
-
let resolved: URL
|
|
536
|
-
if (/^(https?|wss?):\/\//i.test(url)) {
|
|
537
|
-
resolved = new URL(url)
|
|
538
|
-
} else {
|
|
539
|
-
// A relative url resolves against the page, exactly like fetch.
|
|
540
|
-
// Outside a browser (SSR, tests) there is nothing to resolve
|
|
541
|
-
// against — demand an absolute url there.
|
|
542
|
-
const base = (globalThis as { location?: { href: string } }).location
|
|
543
|
-
?.href
|
|
544
|
-
if (base === undefined) {
|
|
545
|
-
throw new TypeError(
|
|
546
|
-
`cannot resolve the relative ws url '${url}' without a browser location — pass an absolute ws:// or wss:// url`,
|
|
547
|
-
)
|
|
548
|
-
}
|
|
549
|
-
resolved = new URL(url, base)
|
|
550
|
-
}
|
|
551
|
-
if (resolved.protocol === 'http:') resolved.protocol = 'ws:'
|
|
552
|
-
else if (resolved.protocol === 'https:') resolved.protocol = 'wss:'
|
|
553
|
-
return resolved.href
|
|
554
|
-
}
|
|
555
|
-
|
|
556
875
|
const trySend = (data: string): void => {
|
|
557
876
|
if (socket === null || !socketOpen) return
|
|
558
877
|
try {
|
|
@@ -584,14 +903,7 @@ const wsTransport = (
|
|
|
584
903
|
socketOpen = false
|
|
585
904
|
clearTimeout(watchdog)
|
|
586
905
|
watchdog = undefined
|
|
587
|
-
|
|
588
|
-
const rejection = new A2Error(
|
|
589
|
-
'STORE_UNAVAILABLE',
|
|
590
|
-
'socket closed with the ack outstanding',
|
|
591
|
-
)
|
|
592
|
-
for (const waiter of pending.values()) waiter.reject(rejection)
|
|
593
|
-
pending.clear()
|
|
594
|
-
}
|
|
906
|
+
rejectPendingAcks(pending)
|
|
595
907
|
return current
|
|
596
908
|
}
|
|
597
909
|
|
|
@@ -637,8 +949,10 @@ const wsTransport = (
|
|
|
637
949
|
watchdog = setTimeout(() => {
|
|
638
950
|
if (socket !== created) return
|
|
639
951
|
failSocket(
|
|
640
|
-
new
|
|
952
|
+
new A2ClientError(
|
|
953
|
+
'TIMEOUT',
|
|
641
954
|
`socket stalled: no data for ${STREAM_TIMINGS.stallTimeoutMs}ms`,
|
|
955
|
+
{ phase: 'stream', timeoutMs: STREAM_TIMINGS.stallTimeoutMs },
|
|
642
956
|
),
|
|
643
957
|
)
|
|
644
958
|
}, STREAM_TIMINGS.stallTimeoutMs)
|
|
@@ -662,7 +976,7 @@ const wsTransport = (
|
|
|
662
976
|
if (channel === undefined || channel.ended || channel.live) return
|
|
663
977
|
channel.live = true
|
|
664
978
|
deliver(channel, { kind: 'ping' }) // the connected signal
|
|
665
|
-
|
|
979
|
+
ready.notify(frame.sessionId)
|
|
666
980
|
return
|
|
667
981
|
}
|
|
668
982
|
case 'unsubscribed': {
|
|
@@ -671,7 +985,11 @@ const wsTransport = (
|
|
|
671
985
|
channel.dropped = true
|
|
672
986
|
settleChannel(
|
|
673
987
|
channel,
|
|
674
|
-
frame.
|
|
988
|
+
frame.error
|
|
989
|
+
? asClientError(frame.error)
|
|
990
|
+
: frame.reason === undefined
|
|
991
|
+
? null
|
|
992
|
+
: new A2ClientError('CONNECTION_FAILED', frame.reason),
|
|
675
993
|
)
|
|
676
994
|
return
|
|
677
995
|
}
|
|
@@ -680,7 +998,7 @@ const wsTransport = (
|
|
|
680
998
|
if (waiter === undefined) return
|
|
681
999
|
pending.delete(frame.req)
|
|
682
1000
|
if ('events' in frame) waiter.resolve(frame.events)
|
|
683
|
-
else waiter.reject(frame.error)
|
|
1001
|
+
else waiter.reject(asClientError(frame.error))
|
|
684
1002
|
return
|
|
685
1003
|
}
|
|
686
1004
|
case 'event': {
|
|
@@ -723,7 +1041,7 @@ const wsTransport = (
|
|
|
723
1041
|
'no WebSocket implementation available — pass one via createClient({ webSocket }) where the global is missing',
|
|
724
1042
|
)
|
|
725
1043
|
}
|
|
726
|
-
const created = new webSocketImpl(
|
|
1044
|
+
const created = new webSocketImpl(webSocketUrl(url).href)
|
|
727
1045
|
socket = created
|
|
728
1046
|
socketOpen = false
|
|
729
1047
|
armWatchdog(created)
|
|
@@ -752,14 +1070,16 @@ const wsTransport = (
|
|
|
752
1070
|
failSocket(
|
|
753
1071
|
event.code === 1000
|
|
754
1072
|
? null // deliberate server close — reconnect, but not an error
|
|
755
|
-
: new
|
|
1073
|
+
: new A2ClientError(
|
|
1074
|
+
'CONNECTION_FAILED',
|
|
756
1075
|
`socket closed (${event.code}${event.reason ? `: ${event.reason}` : ''})`,
|
|
1076
|
+
{ closeCode: event.code, closeReason: event.reason },
|
|
757
1077
|
),
|
|
758
1078
|
)
|
|
759
1079
|
})
|
|
760
1080
|
created.addEventListener('error', () => {
|
|
761
1081
|
if (socket !== created) return
|
|
762
|
-
failSocket(new
|
|
1082
|
+
failSocket(new A2ClientError('CONNECTION_FAILED', 'socket error'))
|
|
763
1083
|
})
|
|
764
1084
|
}
|
|
765
1085
|
|
|
@@ -808,8 +1128,10 @@ const wsTransport = (
|
|
|
808
1128
|
if (channel.live) return
|
|
809
1129
|
settleChannel(
|
|
810
1130
|
channel,
|
|
811
|
-
new
|
|
1131
|
+
new A2ClientError(
|
|
1132
|
+
'TIMEOUT',
|
|
812
1133
|
'subscribe not confirmed: is the ws route calling server.fetch with upgradeWebSocket?',
|
|
1134
|
+
{ phase: 'subscribe', timeoutMs: STREAM_TIMINGS.stallTimeoutMs },
|
|
813
1135
|
),
|
|
814
1136
|
)
|
|
815
1137
|
}, STREAM_TIMINGS.stallTimeoutMs)
|
|
@@ -848,7 +1170,10 @@ const wsTransport = (
|
|
|
848
1170
|
) {
|
|
849
1171
|
// Retryable by contract: the reconnect loop restores the
|
|
850
1172
|
// socket and the retry rides the same event ids.
|
|
851
|
-
throw new
|
|
1173
|
+
throw new A2ClientError(
|
|
1174
|
+
'STORE_UNAVAILABLE',
|
|
1175
|
+
'no open socket for the session',
|
|
1176
|
+
)
|
|
852
1177
|
}
|
|
853
1178
|
const req = nextReq
|
|
854
1179
|
nextReq += 1
|
|
@@ -865,7 +1190,9 @@ const wsTransport = (
|
|
|
865
1190
|
} catch (cause) {
|
|
866
1191
|
pending.delete(req)
|
|
867
1192
|
reject(
|
|
868
|
-
new
|
|
1193
|
+
new A2ClientError('STORE_UNAVAILABLE', 'socket send failed', {
|
|
1194
|
+
cause,
|
|
1195
|
+
}),
|
|
869
1196
|
)
|
|
870
1197
|
}
|
|
871
1198
|
})
|
|
@@ -881,29 +1208,7 @@ const wsTransport = (
|
|
|
881
1208
|
) {
|
|
882
1209
|
return Promise.resolve()
|
|
883
1210
|
}
|
|
884
|
-
return
|
|
885
|
-
let waiters = readyWaiters.get(sessionId)
|
|
886
|
-
if (!waiters) {
|
|
887
|
-
waiters = new Set()
|
|
888
|
-
readyWaiters.set(sessionId, waiters)
|
|
889
|
-
}
|
|
890
|
-
let settled = false
|
|
891
|
-
const done = (): void => {
|
|
892
|
-
if (settled) return
|
|
893
|
-
settled = true
|
|
894
|
-
clearTimeout(timer)
|
|
895
|
-
waiters.delete(done)
|
|
896
|
-
if (waiters.size === 0) readyWaiters.delete(sessionId)
|
|
897
|
-
// oxlint-disable-next-line promise/no-multiple-resolved -- `settled` guards the two callers (timer, open flush); the rule cannot see through the flag
|
|
898
|
-
resolve()
|
|
899
|
-
}
|
|
900
|
-
// The cap keeps a session nobody reconnects (write-only usage,
|
|
901
|
-
// an unreachable server) from parking a push forever — the
|
|
902
|
-
// attempt then fails fast and the loop moves on.
|
|
903
|
-
const timer = setTimeout(done, maxWaitMs)
|
|
904
|
-
;(timer as { unref?: () => void }).unref?.()
|
|
905
|
-
waiters.add(done)
|
|
906
|
-
})
|
|
1211
|
+
return ready.wait(sessionId, maxWaitMs)
|
|
907
1212
|
},
|
|
908
1213
|
sendPresence(envelope) {
|
|
909
1214
|
const channel = channels.get(envelope.sessionId)
|
|
@@ -926,7 +1231,7 @@ const transportFor = (
|
|
|
926
1231
|
webSocketImpl: ClientWebSocketConstructor | undefined,
|
|
927
1232
|
presence: boolean,
|
|
928
1233
|
): Transport => {
|
|
929
|
-
if (typeof api === 'string') {
|
|
1234
|
+
if (typeof api === 'string' || typeof api === 'function') {
|
|
930
1235
|
return httpTransport({ push: api, stream: api }, fetchImpl, presence)
|
|
931
1236
|
}
|
|
932
1237
|
if (api.type === 'http') {
|
|
@@ -936,7 +1241,13 @@ const transportFor = (
|
|
|
936
1241
|
presence,
|
|
937
1242
|
)
|
|
938
1243
|
}
|
|
939
|
-
|
|
1244
|
+
if (api.multiplex === true) {
|
|
1245
|
+
if (typeof api.url !== 'string') {
|
|
1246
|
+
throw new TypeError('a multiplexed WebSocket URL must be a string')
|
|
1247
|
+
}
|
|
1248
|
+
return multiplexedWsTransport(api.url, webSocketImpl, presence)
|
|
1249
|
+
}
|
|
1250
|
+
return sessionWsTransport(api.url, webSocketImpl, presence)
|
|
940
1251
|
}
|
|
941
1252
|
|
|
942
1253
|
type Pending<D extends EventDefs> = {
|
|
@@ -972,12 +1283,19 @@ export function createClient<
|
|
|
972
1283
|
const presenceDefs: Readonly<PresenceDefs> = reducer.presence
|
|
973
1284
|
const declaresPresence = Object.keys(presenceDefs).length > 0
|
|
974
1285
|
const fetchImpl = options.fetch ?? globalThis.fetch.bind(globalThis)
|
|
1286
|
+
const clientFetch: typeof globalThis.fetch = async (input, init) => {
|
|
1287
|
+
try {
|
|
1288
|
+
return await fetchImpl(input, init)
|
|
1289
|
+
} catch (error) {
|
|
1290
|
+
throw asClientError(error, { code: 'CONNECTION_FAILED' })
|
|
1291
|
+
}
|
|
1292
|
+
}
|
|
975
1293
|
const webSocketImpl =
|
|
976
1294
|
options.webSocket ??
|
|
977
1295
|
(globalThis as { WebSocket?: ClientWebSocketConstructor }).WebSocket
|
|
978
1296
|
const transport = transportFor(
|
|
979
1297
|
api,
|
|
980
|
-
|
|
1298
|
+
clientFetch,
|
|
981
1299
|
webSocketImpl,
|
|
982
1300
|
declaresPresence,
|
|
983
1301
|
)
|
|
@@ -1015,7 +1333,11 @@ export function createClient<
|
|
|
1015
1333
|
sessionOptions?: SessionOptions<D, S>,
|
|
1016
1334
|
): SessionRuntime<D, S, P> => {
|
|
1017
1335
|
// ── server truth ─────────────────────────────────────────────
|
|
1018
|
-
let
|
|
1336
|
+
let stateIndex = sessionOptions?.initialIndex ?? 0
|
|
1337
|
+
let streamIndex =
|
|
1338
|
+
sessionOptions?.initialState === undefined
|
|
1339
|
+
? sessionOptions?.initialIndex
|
|
1340
|
+
: stateIndex
|
|
1019
1341
|
let foldedState: S =
|
|
1020
1342
|
sessionOptions?.initialState !== undefined
|
|
1021
1343
|
? sessionOptions.initialState
|
|
@@ -1023,7 +1345,7 @@ export function createClient<
|
|
|
1023
1345
|
const serverEvents: ContractEvent<D>[] = (
|
|
1024
1346
|
sessionOptions?.initialEvents ?? []
|
|
1025
1347
|
)
|
|
1026
|
-
.filter((event) => event.index <=
|
|
1348
|
+
.filter((event) => event.index <= stateIndex)
|
|
1027
1349
|
.toSorted((a, b) => a.index - b.index)
|
|
1028
1350
|
|
|
1029
1351
|
// ── optimistic overlay ───────────────────────────────────────
|
|
@@ -1054,7 +1376,7 @@ export function createClient<
|
|
|
1054
1376
|
let snapshot: SessionSnapshot<D, S> | null = null
|
|
1055
1377
|
let status: ConnectionStatus = 'idle'
|
|
1056
1378
|
let reconnects = 0
|
|
1057
|
-
let lastError:
|
|
1379
|
+
let lastError: A2ClientError | null = null
|
|
1058
1380
|
let inFlightPushes = 0
|
|
1059
1381
|
let pushWireTail: Promise<void> = Promise.resolve()
|
|
1060
1382
|
let deferredHydration: SessionOptions<D, S> | undefined
|
|
@@ -1112,7 +1434,7 @@ export function createClient<
|
|
|
1112
1434
|
.filter((p) => p.acked)
|
|
1113
1435
|
.map((p) => p.acked!)
|
|
1114
1436
|
.toSorted((a, b) => a.index - b.index)
|
|
1115
|
-
const maxKnown = acked.at(-1)?.index ??
|
|
1437
|
+
const maxKnown = acked.at(-1)?.index ?? stateIndex
|
|
1116
1438
|
const unacked = pending
|
|
1117
1439
|
.filter((p) => !p.acked)
|
|
1118
1440
|
.map(
|
|
@@ -1138,7 +1460,8 @@ export function createClient<
|
|
|
1138
1460
|
case 'live':
|
|
1139
1461
|
return { status: 'live', reconnects }
|
|
1140
1462
|
case 'connecting':
|
|
1141
|
-
|
|
1463
|
+
case 'paused':
|
|
1464
|
+
return { status, reconnects, error: lastError }
|
|
1142
1465
|
}
|
|
1143
1466
|
}
|
|
1144
1467
|
|
|
@@ -1146,9 +1469,9 @@ export function createClient<
|
|
|
1146
1469
|
const oldestLoaded = serverEvents[0]?.index ?? null
|
|
1147
1470
|
return {
|
|
1148
1471
|
loading: historyLoads > 0,
|
|
1149
|
-
// Index 1 loaded — or a
|
|
1472
|
+
// Index 1 loaded — or a state index of 0, an empty log as far as
|
|
1150
1473
|
// this client knows — means nothing older is left to fetch.
|
|
1151
|
-
complete: oldestLoaded === 1 ||
|
|
1474
|
+
complete: oldestLoaded === 1 || stateIndex === 0,
|
|
1152
1475
|
oldestLoaded,
|
|
1153
1476
|
}
|
|
1154
1477
|
}
|
|
@@ -1160,7 +1483,7 @@ export function createClient<
|
|
|
1160
1483
|
return {
|
|
1161
1484
|
state,
|
|
1162
1485
|
events: [...serverEvents, ...overlay],
|
|
1163
|
-
index:
|
|
1486
|
+
index: stateIndex,
|
|
1164
1487
|
history: historyState(),
|
|
1165
1488
|
connection: connection(),
|
|
1166
1489
|
...(declaresPresence
|
|
@@ -1241,7 +1564,7 @@ export function createClient<
|
|
|
1241
1564
|
const at = new Date(atMs)
|
|
1242
1565
|
for (const [field, value] of Object.entries(values)) {
|
|
1243
1566
|
if (value === null) delete entry[field]
|
|
1244
|
-
else entry[field] = { value, seen:
|
|
1567
|
+
else entry[field] = { value, seen: stateIndex, at }
|
|
1245
1568
|
}
|
|
1246
1569
|
const next = Object.assign(nullProtoRecord<PresenceMap>(), presenceState)
|
|
1247
1570
|
if (Object.keys(entry).length === 0) delete next[id]
|
|
@@ -1254,7 +1577,7 @@ export function createClient<
|
|
|
1254
1577
|
// dropped silently — the next send repaints. Sends are NOT
|
|
1255
1578
|
// serialized on the ack and may overlap in flight: safe, because
|
|
1256
1579
|
// merges are LWW by `at` per (participant, field) and one
|
|
1257
|
-
// sender's stamps are monotonic. `seen` is the
|
|
1580
|
+
// sender's stamps are monotonic. `seen` is the state index at send
|
|
1258
1581
|
// time, not at set time; `at` is the sender's monotonic stamp.
|
|
1259
1582
|
const sendPresence = (
|
|
1260
1583
|
values: Record<string, unknown>,
|
|
@@ -1265,7 +1588,7 @@ export function createClient<
|
|
|
1265
1588
|
sessionId,
|
|
1266
1589
|
// Every path here starts at setPresence, which requires the
|
|
1267
1590
|
// participant.
|
|
1268
|
-
presence: { participant: participant!, values, seen:
|
|
1591
|
+
presence: { participant: participant!, values, seen: stateIndex, at },
|
|
1269
1592
|
})
|
|
1270
1593
|
touch(sessionId)
|
|
1271
1594
|
}
|
|
@@ -1321,7 +1644,7 @@ export function createClient<
|
|
|
1321
1644
|
? undefined
|
|
1322
1645
|
: presenceDefs['*']
|
|
1323
1646
|
if (!schema) {
|
|
1324
|
-
throw new
|
|
1647
|
+
throw new A2ClientError(
|
|
1325
1648
|
'UNKNOWN_PRESENCE_FIELD',
|
|
1326
1649
|
`no presence field '${field}' in the reducer's vocabulary`,
|
|
1327
1650
|
)
|
|
@@ -1332,7 +1655,7 @@ export function createClient<
|
|
|
1332
1655
|
}
|
|
1333
1656
|
const result = validateSync(schema, value, `presence field '${field}'`)
|
|
1334
1657
|
if (result.issues) {
|
|
1335
|
-
throw new
|
|
1658
|
+
throw new A2ClientError(
|
|
1336
1659
|
'INVALID_PAYLOAD',
|
|
1337
1660
|
`invalid value for presence field '${field}'`,
|
|
1338
1661
|
{ details: result.issues },
|
|
@@ -1353,20 +1676,25 @@ export function createClient<
|
|
|
1353
1676
|
}
|
|
1354
1677
|
|
|
1355
1678
|
/** Pushes awaiting stream confirmation — resolved by `ingest` the
|
|
1356
|
-
* moment the
|
|
1679
|
+
* moment the state index passes their batch. */
|
|
1357
1680
|
let confirmWatchers: Array<{ index: number; resolve: () => void }> = []
|
|
1358
1681
|
|
|
1359
1682
|
/** The single ingest point: every server-confirmed event, in log
|
|
1360
1683
|
* order, from the stream. */
|
|
1361
1684
|
const ingest = (event: ContractEvent<D>): void => {
|
|
1362
|
-
if (event.index <=
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1685
|
+
if (event.index <= (streamIndex ?? 0)) return
|
|
1686
|
+
streamIndex = event.index
|
|
1687
|
+
if (event.index > stateIndex) {
|
|
1688
|
+
stateIndex = event.index
|
|
1689
|
+
serverEvents.push(event)
|
|
1690
|
+
foldedState = reducer.fold(foldedState, event)
|
|
1691
|
+
} else {
|
|
1692
|
+
mergeServerEvents([event], stateIndex)
|
|
1693
|
+
}
|
|
1366
1694
|
pending = pending.filter((p) => p.id !== event.id)
|
|
1367
|
-
if (confirmWatchers.some((w) => w.index <=
|
|
1368
|
-
const due = confirmWatchers.filter((w) => w.index <=
|
|
1369
|
-
confirmWatchers = confirmWatchers.filter((w) => w.index >
|
|
1695
|
+
if (confirmWatchers.some((w) => w.index <= stateIndex)) {
|
|
1696
|
+
const due = confirmWatchers.filter((w) => w.index <= stateIndex)
|
|
1697
|
+
confirmWatchers = confirmWatchers.filter((w) => w.index > stateIndex)
|
|
1370
1698
|
for (const watcher of due) watcher.resolve()
|
|
1371
1699
|
}
|
|
1372
1700
|
notify()
|
|
@@ -1382,7 +1710,7 @@ export function createClient<
|
|
|
1382
1710
|
? reducer.events[event.type]
|
|
1383
1711
|
: undefined
|
|
1384
1712
|
if (!schema) {
|
|
1385
|
-
throw new
|
|
1713
|
+
throw new A2ClientError(
|
|
1386
1714
|
'UNKNOWN_EVENT_TYPE',
|
|
1387
1715
|
`no event type '${String(event.type)}' in the reducer's vocabulary`,
|
|
1388
1716
|
)
|
|
@@ -1393,7 +1721,7 @@ export function createClient<
|
|
|
1393
1721
|
`event '${String(event.type)}'`,
|
|
1394
1722
|
)
|
|
1395
1723
|
if (result.issues) {
|
|
1396
|
-
throw new
|
|
1724
|
+
throw new A2ClientError(
|
|
1397
1725
|
'INVALID_PAYLOAD',
|
|
1398
1726
|
`invalid payload for event '${String(event.type)}'`,
|
|
1399
1727
|
{ details: result.issues },
|
|
@@ -1409,7 +1737,7 @@ export function createClient<
|
|
|
1409
1737
|
const post = async (
|
|
1410
1738
|
body: TransportPushBody,
|
|
1411
1739
|
): Promise<ContractEvent<D>[]> => {
|
|
1412
|
-
let lastPushError:
|
|
1740
|
+
let lastPushError: A2ClientError = new A2ClientError(
|
|
1413
1741
|
'STORE_UNAVAILABLE',
|
|
1414
1742
|
'push failed',
|
|
1415
1743
|
)
|
|
@@ -1418,16 +1746,9 @@ export function createClient<
|
|
|
1418
1746
|
const rows = await transport.push(body)
|
|
1419
1747
|
return rows.map((row) => eventFromWire(row) as ContractEvent<D>)
|
|
1420
1748
|
} catch (err) {
|
|
1421
|
-
lastPushError =
|
|
1422
|
-
err instanceof A2Error
|
|
1423
|
-
? err
|
|
1424
|
-
: new A2Error('STORE_UNAVAILABLE', 'push request failed', {
|
|
1425
|
-
cause: err,
|
|
1426
|
-
})
|
|
1749
|
+
lastPushError = asClientError(err)
|
|
1427
1750
|
}
|
|
1428
|
-
|
|
1429
|
-
// make the retry idempotent on the server.
|
|
1430
|
-
if (lastPushError.code !== 'STORE_UNAVAILABLE') throw lastPushError
|
|
1751
|
+
if (!RETRYABLE_PUSH_CODES.has(lastPushError.code)) throw lastPushError
|
|
1431
1752
|
if (attempt < PUSH_ATTEMPTS) {
|
|
1432
1753
|
const sleepMs = 250 * attempt
|
|
1433
1754
|
// Wait for the clock AND, when the transport can tell us,
|
|
@@ -1462,7 +1783,7 @@ export function createClient<
|
|
|
1462
1783
|
(acked) =>
|
|
1463
1784
|
new Promise<ContractEvent<D>[]>((resolve) => {
|
|
1464
1785
|
const last = acked.at(-1)?.index ?? 0
|
|
1465
|
-
if (
|
|
1786
|
+
if (stateIndex >= last) {
|
|
1466
1787
|
resolve(acked)
|
|
1467
1788
|
return
|
|
1468
1789
|
}
|
|
@@ -1501,6 +1822,7 @@ export function createClient<
|
|
|
1501
1822
|
inFlightPushes += 1
|
|
1502
1823
|
|
|
1503
1824
|
try {
|
|
1825
|
+
refreshIdleStream()
|
|
1504
1826
|
notify() // optimistic — the view updates before the network moves
|
|
1505
1827
|
await waitForPriorPush
|
|
1506
1828
|
const acked = await post({
|
|
@@ -1515,6 +1837,8 @@ export function createClient<
|
|
|
1515
1837
|
const entry = pending.find((p) => p.id === event.id)
|
|
1516
1838
|
if (entry) entry.acked = event // stream retires it at its index
|
|
1517
1839
|
}
|
|
1840
|
+
if ((acked.at(-1)?.index ?? 0) > (streamIndex ?? stateIndex))
|
|
1841
|
+
refreshIdleStream()
|
|
1518
1842
|
notify()
|
|
1519
1843
|
return acked
|
|
1520
1844
|
} catch (err) {
|
|
@@ -1538,7 +1862,7 @@ export function createClient<
|
|
|
1538
1862
|
}
|
|
1539
1863
|
|
|
1540
1864
|
// ── backscroll ───────────────────────────────────────────────
|
|
1541
|
-
// Cold reads of the log below the
|
|
1865
|
+
// Cold reads of the log below the state index. Fetched events merge
|
|
1542
1866
|
// into `serverEvents` only — never the fold, never the overlay.
|
|
1543
1867
|
let historyLoads = 0
|
|
1544
1868
|
/** Calls chain per session: each resolves its bounds after the
|
|
@@ -1588,11 +1912,11 @@ export function createClient<
|
|
|
1588
1912
|
}
|
|
1589
1913
|
touch(sessionId)
|
|
1590
1914
|
const run = historyTail.then(async () => {
|
|
1591
|
-
const upper = before ?? serverEvents[0]?.index ??
|
|
1592
|
-
// Strictly at or below the
|
|
1915
|
+
const upper = before ?? serverEvents[0]?.index ?? stateIndex + 1
|
|
1916
|
+
// Strictly at or below the state index: the live stream owns
|
|
1593
1917
|
// everything above it, and the optimistic overlay must never
|
|
1594
1918
|
// collide with a backscrolled row.
|
|
1595
|
-
const lte = Math.min(upper - 1,
|
|
1919
|
+
const lte = Math.min(upper - 1, stateIndex)
|
|
1596
1920
|
const gte = Math.max(1, upper - (limit ?? DEFAULT_HISTORY_LIMIT))
|
|
1597
1921
|
if (gte > lte) return []
|
|
1598
1922
|
const missing = missingBetween(gte, lte)
|
|
@@ -1603,9 +1927,11 @@ export function createClient<
|
|
|
1603
1927
|
const rows = await readHistory({ sessionId, ...missing })
|
|
1604
1928
|
mergeServerEvents(
|
|
1605
1929
|
rows.map((row) => eventFromWire(row) as ContractEvent<D>),
|
|
1606
|
-
|
|
1930
|
+
stateIndex,
|
|
1607
1931
|
)
|
|
1608
1932
|
return eventsBetween(gte, lte)
|
|
1933
|
+
} catch (error) {
|
|
1934
|
+
throw asClientError(error)
|
|
1609
1935
|
} finally {
|
|
1610
1936
|
historyLoads -= 1
|
|
1611
1937
|
notify()
|
|
@@ -1627,6 +1953,7 @@ export function createClient<
|
|
|
1627
1953
|
// `leaseEpoch` makes the outstanding releases inert).
|
|
1628
1954
|
let generation = 0
|
|
1629
1955
|
let active = false
|
|
1956
|
+
let streamIdle = false
|
|
1630
1957
|
let abort: AbortController | null = null
|
|
1631
1958
|
let leases = 0
|
|
1632
1959
|
let leaseEpoch = 0
|
|
@@ -1635,6 +1962,7 @@ export function createClient<
|
|
|
1635
1962
|
touch(sessionId)
|
|
1636
1963
|
if (!active) return
|
|
1637
1964
|
active = false
|
|
1965
|
+
streamIdle = false
|
|
1638
1966
|
generation += 1 // the running loop notices and exits
|
|
1639
1967
|
abort?.abort()
|
|
1640
1968
|
abort = null
|
|
@@ -1650,9 +1978,11 @@ export function createClient<
|
|
|
1650
1978
|
|
|
1651
1979
|
const runStream = async (run: number): Promise<void> => {
|
|
1652
1980
|
let backoff = STREAM_TIMINGS.reconnectBaseMs
|
|
1981
|
+
let attempt = 0
|
|
1653
1982
|
// oxlint-disable no-await-in-loop -- sequential reconnect loop
|
|
1654
1983
|
// oxlint-disable-next-line no-unmodified-loop-condition -- close() bumps `generation`
|
|
1655
1984
|
while (generation === run) {
|
|
1985
|
+
streamIdle = false
|
|
1656
1986
|
const controller = new AbortController()
|
|
1657
1987
|
abort = controller
|
|
1658
1988
|
// The stall watchdog: the server heartbeats the stream, so a
|
|
@@ -1676,7 +2006,7 @@ export function createClient<
|
|
|
1676
2006
|
try {
|
|
1677
2007
|
const frames = transport.connect({
|
|
1678
2008
|
sessionId,
|
|
1679
|
-
startAfter:
|
|
2009
|
+
startAfter: (streamIndex ??= stateIndex),
|
|
1680
2010
|
signal: controller.signal,
|
|
1681
2011
|
})
|
|
1682
2012
|
for await (const frame of frames) {
|
|
@@ -1688,10 +2018,12 @@ export function createClient<
|
|
|
1688
2018
|
// The first frame is the connection signal: the transport
|
|
1689
2019
|
// yields it the moment the stream is established.
|
|
1690
2020
|
backoff = STREAM_TIMINGS.reconnectBaseMs
|
|
2021
|
+
attempt = 0
|
|
1691
2022
|
wasLive = true
|
|
1692
2023
|
status = 'live'
|
|
1693
2024
|
lastError = null
|
|
1694
2025
|
notify()
|
|
2026
|
+
if (generation !== run) break
|
|
1695
2027
|
if (presenceResend) {
|
|
1696
2028
|
const values = presenceResend
|
|
1697
2029
|
presenceResend = null
|
|
@@ -1699,7 +2031,20 @@ export function createClient<
|
|
|
1699
2031
|
}
|
|
1700
2032
|
}
|
|
1701
2033
|
if (frame.kind === 'event') {
|
|
2034
|
+
streamIdle = false
|
|
1702
2035
|
ingest(eventFromWire(frame.event) as ContractEvent<D>)
|
|
2036
|
+
} else if (frame.kind === 'activity') {
|
|
2037
|
+
streamIdle = frame.idle
|
|
2038
|
+
if (
|
|
2039
|
+
streamIdle &&
|
|
2040
|
+
(inFlightPushes > 0 ||
|
|
2041
|
+
pending.some(
|
|
2042
|
+
(entry) =>
|
|
2043
|
+
(entry.acked?.index ?? 0) > (streamIndex ?? stateIndex),
|
|
2044
|
+
))
|
|
2045
|
+
) {
|
|
2046
|
+
refreshIdleStream()
|
|
2047
|
+
}
|
|
1703
2048
|
} else if (frame.kind === 'presence') {
|
|
1704
2049
|
applyPresencePatch(presencePatchFromWire(frame.patch))
|
|
1705
2050
|
} else if (frame.kind === 'presence-snapshot') {
|
|
@@ -1716,38 +2061,91 @@ export function createClient<
|
|
|
1716
2061
|
} catch (err) {
|
|
1717
2062
|
if (generation === run) {
|
|
1718
2063
|
lastError = stalled
|
|
1719
|
-
? new
|
|
2064
|
+
? new A2ClientError(
|
|
2065
|
+
'TIMEOUT',
|
|
1720
2066
|
`stream stalled: no data for ${STREAM_TIMINGS.stallTimeoutMs}ms`,
|
|
2067
|
+
{ phase: 'stream', timeoutMs: STREAM_TIMINGS.stallTimeoutMs },
|
|
1721
2068
|
)
|
|
1722
|
-
: err
|
|
1723
|
-
? err
|
|
1724
|
-
: new Error(String(err))
|
|
2069
|
+
: asClientError(err)
|
|
1725
2070
|
}
|
|
1726
2071
|
} finally {
|
|
1727
2072
|
clearTimeout(stall)
|
|
1728
2073
|
}
|
|
1729
2074
|
if (generation !== run) break
|
|
2075
|
+
streamIdle = false
|
|
1730
2076
|
if (wasLive) reconnects += 1
|
|
2077
|
+
attempt += 1
|
|
2078
|
+
let delay: number | false
|
|
2079
|
+
try {
|
|
2080
|
+
delay = options.reconnect
|
|
2081
|
+
? options.reconnect({ sessionId, error: lastError, attempt })
|
|
2082
|
+
: backoff
|
|
2083
|
+
if (
|
|
2084
|
+
delay !== false &&
|
|
2085
|
+
(!Number.isFinite(delay) || delay < 0 || delay > 2_147_483_647)
|
|
2086
|
+
) {
|
|
2087
|
+
throw new RangeError(
|
|
2088
|
+
'reconnect must return false or a delay from 0 to 2147483647 ms',
|
|
2089
|
+
)
|
|
2090
|
+
}
|
|
2091
|
+
} catch (error) {
|
|
2092
|
+
if (generation !== run) break
|
|
2093
|
+
lastError = asClientError(error)
|
|
2094
|
+
delay = false
|
|
2095
|
+
}
|
|
2096
|
+
if (generation !== run) break
|
|
2097
|
+
if (delay === false) {
|
|
2098
|
+
abort = null
|
|
2099
|
+
status = 'paused'
|
|
2100
|
+
notify()
|
|
2101
|
+
return
|
|
2102
|
+
}
|
|
2103
|
+
const waiting = new AbortController()
|
|
2104
|
+
abort = waiting
|
|
1731
2105
|
status = 'connecting'
|
|
1732
2106
|
notify()
|
|
2107
|
+
if (generation !== run) break
|
|
1733
2108
|
await new Promise<void>((resolve) => {
|
|
1734
2109
|
let settled = false
|
|
1735
2110
|
const finish = (): void => {
|
|
1736
2111
|
if (settled) return
|
|
1737
2112
|
settled = true
|
|
1738
2113
|
clearTimeout(timer)
|
|
2114
|
+
waiting.signal.removeEventListener('abort', finish)
|
|
1739
2115
|
// oxlint-disable-next-line no-multiple-resolved -- guarded above
|
|
1740
2116
|
resolve()
|
|
1741
2117
|
}
|
|
1742
|
-
const timer = setTimeout(finish,
|
|
2118
|
+
const timer = setTimeout(finish, delay)
|
|
1743
2119
|
;(timer as { unref?: () => void }).unref?.()
|
|
1744
|
-
|
|
2120
|
+
if (waiting.signal.aborted) finish()
|
|
2121
|
+
else waiting.signal.addEventListener('abort', finish, { once: true })
|
|
1745
2122
|
})
|
|
1746
2123
|
backoff = Math.min(backoff * 2, STREAM_TIMINGS.reconnectMaxMs)
|
|
1747
2124
|
}
|
|
1748
2125
|
// oxlint-enable no-await-in-loop
|
|
1749
2126
|
}
|
|
1750
2127
|
|
|
2128
|
+
const refreshIdleStream = (): void => {
|
|
2129
|
+
if (streamIdle && status !== 'paused') reconnectStream()
|
|
2130
|
+
}
|
|
2131
|
+
|
|
2132
|
+
const reconnectStream = (): void => {
|
|
2133
|
+
touch(sessionId)
|
|
2134
|
+
if (!active) return
|
|
2135
|
+
streamIdle = false
|
|
2136
|
+
generation += 1
|
|
2137
|
+
const run = generation
|
|
2138
|
+
const previous = abort
|
|
2139
|
+
abort = null
|
|
2140
|
+
reconnects += 1
|
|
2141
|
+
status = 'connecting'
|
|
2142
|
+
lastError = null
|
|
2143
|
+
notify()
|
|
2144
|
+
// Replace the lane before aborting so a multiplexed session keeps its socket.
|
|
2145
|
+
if (active && generation === run) void runStream(run)
|
|
2146
|
+
previous?.abort()
|
|
2147
|
+
}
|
|
2148
|
+
|
|
1751
2149
|
const runtime: SessionRuntime<D, S, P> = {
|
|
1752
2150
|
sessionId,
|
|
1753
2151
|
// Only on presence-declaring contracts — the type surface
|
|
@@ -1767,6 +2165,7 @@ export function createClient<
|
|
|
1767
2165
|
},
|
|
1768
2166
|
push,
|
|
1769
2167
|
loadHistory,
|
|
2168
|
+
reconnect: reconnectStream,
|
|
1770
2169
|
connect() {
|
|
1771
2170
|
touch(sessionId)
|
|
1772
2171
|
leases += 1
|
|
@@ -1774,8 +2173,10 @@ export function createClient<
|
|
|
1774
2173
|
if (!active) {
|
|
1775
2174
|
active = true
|
|
1776
2175
|
status = 'connecting'
|
|
2176
|
+
lastError = null
|
|
2177
|
+
const run = generation
|
|
1777
2178
|
notify()
|
|
1778
|
-
void runStream(
|
|
2179
|
+
if (active && generation === run) void runStream(run)
|
|
1779
2180
|
}
|
|
1780
2181
|
let released = false
|
|
1781
2182
|
return () => {
|
|
@@ -1798,7 +2199,7 @@ export function createClient<
|
|
|
1798
2199
|
if (!next) return
|
|
1799
2200
|
participant ??= next.participant
|
|
1800
2201
|
const nextIndex = next.initialIndex ?? 0
|
|
1801
|
-
if (nextIndex >
|
|
2202
|
+
if (nextIndex > stateIndex && inFlightPushes > 0) {
|
|
1802
2203
|
if (
|
|
1803
2204
|
!deferredHydration ||
|
|
1804
2205
|
nextIndex > (deferredHydration.initialIndex ?? 0)
|
|
@@ -1810,28 +2211,29 @@ export function createClient<
|
|
|
1810
2211
|
|
|
1811
2212
|
const historyChanged = mergeServerEvents(
|
|
1812
2213
|
next.initialEvents,
|
|
1813
|
-
Math.min(nextIndex,
|
|
2214
|
+
Math.min(nextIndex, stateIndex),
|
|
1814
2215
|
)
|
|
1815
|
-
if (next.initialState
|
|
2216
|
+
if (next.initialState !== undefined) streamIndex ??= nextIndex
|
|
2217
|
+
if (next.initialState === undefined || nextIndex <= stateIndex) {
|
|
1816
2218
|
if (historyChanged) notifyHydrated()
|
|
1817
2219
|
return
|
|
1818
2220
|
}
|
|
1819
2221
|
|
|
1820
2222
|
// The server fold subsumes everything observed through the old
|
|
1821
|
-
//
|
|
1822
|
-
// entries the new
|
|
1823
|
-
|
|
2223
|
+
// state index. Keep later optimistic work overlaid, retiring acked
|
|
2224
|
+
// entries the new state index proves are already durable.
|
|
2225
|
+
stateIndex = nextIndex
|
|
1824
2226
|
foldedState = next.initialState
|
|
1825
|
-
mergeServerEvents(next.initialEvents,
|
|
2227
|
+
mergeServerEvents(next.initialEvents, stateIndex)
|
|
1826
2228
|
pending = pending.filter(
|
|
1827
|
-
(entry) => !entry.acked || entry.acked.index >
|
|
2229
|
+
(entry) => !entry.acked || entry.acked.index > stateIndex,
|
|
1828
2230
|
)
|
|
1829
|
-
if (confirmWatchers.some((watcher) => watcher.index <=
|
|
2231
|
+
if (confirmWatchers.some((watcher) => watcher.index <= stateIndex)) {
|
|
1830
2232
|
const due = confirmWatchers.filter(
|
|
1831
|
-
(watcher) => watcher.index <=
|
|
2233
|
+
(watcher) => watcher.index <= stateIndex,
|
|
1832
2234
|
)
|
|
1833
2235
|
confirmWatchers = confirmWatchers.filter(
|
|
1834
|
-
(watcher) => watcher.index >
|
|
2236
|
+
(watcher) => watcher.index > stateIndex,
|
|
1835
2237
|
)
|
|
1836
2238
|
for (const watcher of due) watcher.resolve()
|
|
1837
2239
|
}
|