experimental-a2 0.10.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 +39 -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 -208
- package/dist/client.js +2 -1206
- 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-DlLyvaSH.js → server-Dkz2a84E.js} +165 -62
- package/dist/server-Dkz2a84E.js.map +1 -0
- package/dist/{server-DgCrSuhB.d.ts → server-DwPrMqHB.d.ts} +2 -2
- package/dist/{server-DgCrSuhB.d.ts.map → server-DwPrMqHB.d.ts.map} +1 -1
- 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 +15 -2
- package/docs/reference/01-api.mdx +60 -16
- 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 +253 -96
- 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.ts +146 -19
- package/src/session-socket.ts +25 -7
- 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-USo5MyuF.d.ts +0 -136
- package/dist/actor-shared-USo5MyuF.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-DlLyvaSH.js.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
|
}
|
|
@@ -264,6 +279,8 @@ export type CreateClientOptions<
|
|
|
264
279
|
reducer: Reducer<D, S, P>
|
|
265
280
|
/** The route (or routes) the client speaks — see `ClientApi`. */
|
|
266
281
|
api: ClientApi
|
|
282
|
+
/** Delay the next connection attempt in milliseconds, or pause with false. */
|
|
283
|
+
reconnect?: ReconnectPolicy
|
|
267
284
|
/** Injectable fetch — defaults to the global. */
|
|
268
285
|
fetch?: typeof globalThis.fetch
|
|
269
286
|
/** Injectable WebSocket constructor for the `ws` api — defaults to
|
|
@@ -283,6 +300,13 @@ export type CreateClientOptions<
|
|
|
283
300
|
}
|
|
284
301
|
|
|
285
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
|
+
])
|
|
286
310
|
const DEFAULT_GC_TIME_MS = 5 * 60 * 1_000
|
|
287
311
|
const DEFAULT_HISTORY_LIMIT = 50
|
|
288
312
|
|
|
@@ -297,6 +321,7 @@ type TransportFrame =
|
|
|
297
321
|
| { kind: 'event'; event: WireEvent }
|
|
298
322
|
| { kind: 'presence'; patch: WirePresencePatch }
|
|
299
323
|
| { kind: 'presence-snapshot'; snapshot: WirePresenceSnapshot }
|
|
324
|
+
| { kind: 'activity'; idle: boolean }
|
|
300
325
|
| { kind: 'ping' }
|
|
301
326
|
|
|
302
327
|
type TransportPushBody = {
|
|
@@ -327,7 +352,7 @@ type Transport = {
|
|
|
327
352
|
signal: AbortSignal
|
|
328
353
|
}): AsyncIterable<TransportFrame>
|
|
329
354
|
/** One attempt (the retry loop lives above the seam): resolves with
|
|
330
|
-
* the acked wire events, throws
|
|
355
|
+
* the acked wire events, throws A2ClientError. */
|
|
331
356
|
push(body: TransportPushBody): Promise<WireEvent[]>
|
|
332
357
|
/** One bounded cold read of the log, oldest first — the lane
|
|
333
358
|
* `loadHistory` rides. Absent on transports without a
|
|
@@ -357,6 +382,23 @@ const sessionUrl = (url: SessionUrl, sessionId: string): string => {
|
|
|
357
382
|
return resolved
|
|
358
383
|
}
|
|
359
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
|
+
|
|
360
402
|
const httpTransport = (
|
|
361
403
|
routes: { push: SessionUrl; stream: SessionUrl },
|
|
362
404
|
fetchImpl: typeof globalThis.fetch,
|
|
@@ -371,8 +413,11 @@ const httpTransport = (
|
|
|
371
413
|
`${streamUrl}${sep}sessionId=${encodeURIComponent(sessionId)}&index=${startAfter}`,
|
|
372
414
|
{ headers: { accept: 'text/event-stream' }, signal },
|
|
373
415
|
)
|
|
374
|
-
if (!res.ok
|
|
375
|
-
|
|
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
|
+
})
|
|
376
421
|
}
|
|
377
422
|
yield { kind: 'ping' } // connected — headers in hand, before any bytes
|
|
378
423
|
const decoder = new TextDecoder()
|
|
@@ -416,6 +461,11 @@ const httpTransport = (
|
|
|
416
461
|
if (isWirePresencePatch(parsed)) {
|
|
417
462
|
yield { kind: 'presence', patch: parsed }
|
|
418
463
|
}
|
|
464
|
+
} else if (
|
|
465
|
+
eventName === 'a2-activity' &&
|
|
466
|
+
(data === 'idle' || data === 'active')
|
|
467
|
+
) {
|
|
468
|
+
yield { kind: 'activity', idle: data === 'idle' }
|
|
419
469
|
}
|
|
420
470
|
// Other named frames: skipped — old clients stay compatible
|
|
421
471
|
// with future lanes the same way.
|
|
@@ -435,17 +485,14 @@ const httpTransport = (
|
|
|
435
485
|
if (res.ok) {
|
|
436
486
|
const rows = (await res.json()) as unknown
|
|
437
487
|
if (!Array.isArray(rows) || !rows.every(isWireEvent)) {
|
|
438
|
-
throw new
|
|
488
|
+
throw new A2ClientError(
|
|
439
489
|
'STORE_UNAVAILABLE',
|
|
440
490
|
'push ack was not a list of events',
|
|
441
491
|
)
|
|
442
492
|
}
|
|
443
493
|
return rows
|
|
444
494
|
}
|
|
445
|
-
throw (
|
|
446
|
-
errorFromWire(await res.json().catch(() => null)) ??
|
|
447
|
-
new A2Error('STORE_UNAVAILABLE', `push failed with ${res.status}`)
|
|
448
|
-
)
|
|
495
|
+
throw await httpError(res)
|
|
449
496
|
},
|
|
450
497
|
|
|
451
498
|
async history({ sessionId, gte, lte }) {
|
|
@@ -456,14 +503,11 @@ const httpTransport = (
|
|
|
456
503
|
{ headers: { accept: 'application/json' } },
|
|
457
504
|
)
|
|
458
505
|
if (!res.ok) {
|
|
459
|
-
throw (
|
|
460
|
-
errorFromWire(await res.json().catch(() => null)) ??
|
|
461
|
-
new A2Error('STORE_UNAVAILABLE', `history failed with ${res.status}`)
|
|
462
|
-
)
|
|
506
|
+
throw await httpError(res)
|
|
463
507
|
}
|
|
464
508
|
const rows = (await res.json()) as unknown
|
|
465
509
|
if (!Array.isArray(rows) || !rows.every(isWireEvent)) {
|
|
466
|
-
throw new
|
|
510
|
+
throw new A2ClientError(
|
|
467
511
|
'STORE_UNAVAILABLE',
|
|
468
512
|
'history response was not a list of events',
|
|
469
513
|
)
|
|
@@ -495,7 +539,7 @@ const WS_OPEN = 1
|
|
|
495
539
|
|
|
496
540
|
type PendingAck = {
|
|
497
541
|
resolve: (events: WireEvent[]) => void
|
|
498
|
-
reject: (error:
|
|
542
|
+
reject: (error: A2ClientError) => void
|
|
499
543
|
}
|
|
500
544
|
|
|
501
545
|
const webSocketUrl = (value: string): URL => {
|
|
@@ -553,7 +597,7 @@ const socketReadyWaiters = () => {
|
|
|
553
597
|
|
|
554
598
|
const rejectPendingAcks = (pending: Map<number, PendingAck>): void => {
|
|
555
599
|
if (pending.size === 0) return
|
|
556
|
-
const error = new
|
|
600
|
+
const error = new A2ClientError(
|
|
557
601
|
'STORE_UNAVAILABLE',
|
|
558
602
|
'socket closed with the ack outstanding',
|
|
559
603
|
)
|
|
@@ -634,7 +678,7 @@ const sessionWsTransport = (
|
|
|
634
678
|
frame.events.some((row) => row.sessionId !== sessionId))
|
|
635
679
|
) {
|
|
636
680
|
waiter.reject(
|
|
637
|
-
new
|
|
681
|
+
new A2ClientError(
|
|
638
682
|
'STORE_UNAVAILABLE',
|
|
639
683
|
'ack does not belong to the socket session',
|
|
640
684
|
),
|
|
@@ -642,7 +686,7 @@ const sessionWsTransport = (
|
|
|
642
686
|
return
|
|
643
687
|
}
|
|
644
688
|
if ('events' in frame) waiter.resolve(frame.events)
|
|
645
|
-
else waiter.reject(frame.error)
|
|
689
|
+
else waiter.reject(asClientError(frame.error))
|
|
646
690
|
return
|
|
647
691
|
}
|
|
648
692
|
case 'event':
|
|
@@ -675,13 +719,15 @@ const sessionWsTransport = (
|
|
|
675
719
|
finish(
|
|
676
720
|
event.code === 1000
|
|
677
721
|
? null
|
|
678
|
-
: new
|
|
722
|
+
: new A2ClientError(
|
|
723
|
+
'CONNECTION_FAILED',
|
|
679
724
|
`socket closed (${event.code}${event.reason ? `: ${event.reason}` : ''})`,
|
|
725
|
+
{ closeCode: event.code, closeReason: event.reason },
|
|
680
726
|
),
|
|
681
727
|
)
|
|
682
728
|
})
|
|
683
729
|
socket.addEventListener('error', () => {
|
|
684
|
-
finish(new
|
|
730
|
+
finish(new A2ClientError('CONNECTION_FAILED', 'socket error'))
|
|
685
731
|
})
|
|
686
732
|
|
|
687
733
|
const onAbort = (): void => {
|
|
@@ -727,7 +773,10 @@ const sessionWsTransport = (
|
|
|
727
773
|
connection === undefined ||
|
|
728
774
|
connection.socket.readyState !== WS_OPEN
|
|
729
775
|
) {
|
|
730
|
-
throw new
|
|
776
|
+
throw new A2ClientError(
|
|
777
|
+
'STORE_UNAVAILABLE',
|
|
778
|
+
'no open socket for the session',
|
|
779
|
+
)
|
|
731
780
|
}
|
|
732
781
|
const req = nextReq
|
|
733
782
|
nextReq += 1
|
|
@@ -743,7 +792,9 @@ const sessionWsTransport = (
|
|
|
743
792
|
} catch (cause) {
|
|
744
793
|
connection.pending.delete(req)
|
|
745
794
|
reject(
|
|
746
|
-
new
|
|
795
|
+
new A2ClientError('STORE_UNAVAILABLE', 'socket send failed', {
|
|
796
|
+
cause,
|
|
797
|
+
}),
|
|
747
798
|
)
|
|
748
799
|
}
|
|
749
800
|
})
|
|
@@ -898,8 +949,10 @@ const multiplexedWsTransport = (
|
|
|
898
949
|
watchdog = setTimeout(() => {
|
|
899
950
|
if (socket !== created) return
|
|
900
951
|
failSocket(
|
|
901
|
-
new
|
|
952
|
+
new A2ClientError(
|
|
953
|
+
'TIMEOUT',
|
|
902
954
|
`socket stalled: no data for ${STREAM_TIMINGS.stallTimeoutMs}ms`,
|
|
955
|
+
{ phase: 'stream', timeoutMs: STREAM_TIMINGS.stallTimeoutMs },
|
|
903
956
|
),
|
|
904
957
|
)
|
|
905
958
|
}, STREAM_TIMINGS.stallTimeoutMs)
|
|
@@ -932,7 +985,11 @@ const multiplexedWsTransport = (
|
|
|
932
985
|
channel.dropped = true
|
|
933
986
|
settleChannel(
|
|
934
987
|
channel,
|
|
935
|
-
frame.
|
|
988
|
+
frame.error
|
|
989
|
+
? asClientError(frame.error)
|
|
990
|
+
: frame.reason === undefined
|
|
991
|
+
? null
|
|
992
|
+
: new A2ClientError('CONNECTION_FAILED', frame.reason),
|
|
936
993
|
)
|
|
937
994
|
return
|
|
938
995
|
}
|
|
@@ -941,7 +998,7 @@ const multiplexedWsTransport = (
|
|
|
941
998
|
if (waiter === undefined) return
|
|
942
999
|
pending.delete(frame.req)
|
|
943
1000
|
if ('events' in frame) waiter.resolve(frame.events)
|
|
944
|
-
else waiter.reject(frame.error)
|
|
1001
|
+
else waiter.reject(asClientError(frame.error))
|
|
945
1002
|
return
|
|
946
1003
|
}
|
|
947
1004
|
case 'event': {
|
|
@@ -1013,14 +1070,16 @@ const multiplexedWsTransport = (
|
|
|
1013
1070
|
failSocket(
|
|
1014
1071
|
event.code === 1000
|
|
1015
1072
|
? null // deliberate server close — reconnect, but not an error
|
|
1016
|
-
: new
|
|
1073
|
+
: new A2ClientError(
|
|
1074
|
+
'CONNECTION_FAILED',
|
|
1017
1075
|
`socket closed (${event.code}${event.reason ? `: ${event.reason}` : ''})`,
|
|
1076
|
+
{ closeCode: event.code, closeReason: event.reason },
|
|
1018
1077
|
),
|
|
1019
1078
|
)
|
|
1020
1079
|
})
|
|
1021
1080
|
created.addEventListener('error', () => {
|
|
1022
1081
|
if (socket !== created) return
|
|
1023
|
-
failSocket(new
|
|
1082
|
+
failSocket(new A2ClientError('CONNECTION_FAILED', 'socket error'))
|
|
1024
1083
|
})
|
|
1025
1084
|
}
|
|
1026
1085
|
|
|
@@ -1069,8 +1128,10 @@ const multiplexedWsTransport = (
|
|
|
1069
1128
|
if (channel.live) return
|
|
1070
1129
|
settleChannel(
|
|
1071
1130
|
channel,
|
|
1072
|
-
new
|
|
1131
|
+
new A2ClientError(
|
|
1132
|
+
'TIMEOUT',
|
|
1073
1133
|
'subscribe not confirmed: is the ws route calling server.fetch with upgradeWebSocket?',
|
|
1134
|
+
{ phase: 'subscribe', timeoutMs: STREAM_TIMINGS.stallTimeoutMs },
|
|
1074
1135
|
),
|
|
1075
1136
|
)
|
|
1076
1137
|
}, STREAM_TIMINGS.stallTimeoutMs)
|
|
@@ -1109,7 +1170,10 @@ const multiplexedWsTransport = (
|
|
|
1109
1170
|
) {
|
|
1110
1171
|
// Retryable by contract: the reconnect loop restores the
|
|
1111
1172
|
// socket and the retry rides the same event ids.
|
|
1112
|
-
throw new
|
|
1173
|
+
throw new A2ClientError(
|
|
1174
|
+
'STORE_UNAVAILABLE',
|
|
1175
|
+
'no open socket for the session',
|
|
1176
|
+
)
|
|
1113
1177
|
}
|
|
1114
1178
|
const req = nextReq
|
|
1115
1179
|
nextReq += 1
|
|
@@ -1126,7 +1190,9 @@ const multiplexedWsTransport = (
|
|
|
1126
1190
|
} catch (cause) {
|
|
1127
1191
|
pending.delete(req)
|
|
1128
1192
|
reject(
|
|
1129
|
-
new
|
|
1193
|
+
new A2ClientError('STORE_UNAVAILABLE', 'socket send failed', {
|
|
1194
|
+
cause,
|
|
1195
|
+
}),
|
|
1130
1196
|
)
|
|
1131
1197
|
}
|
|
1132
1198
|
})
|
|
@@ -1217,12 +1283,19 @@ export function createClient<
|
|
|
1217
1283
|
const presenceDefs: Readonly<PresenceDefs> = reducer.presence
|
|
1218
1284
|
const declaresPresence = Object.keys(presenceDefs).length > 0
|
|
1219
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
|
+
}
|
|
1220
1293
|
const webSocketImpl =
|
|
1221
1294
|
options.webSocket ??
|
|
1222
1295
|
(globalThis as { WebSocket?: ClientWebSocketConstructor }).WebSocket
|
|
1223
1296
|
const transport = transportFor(
|
|
1224
1297
|
api,
|
|
1225
|
-
|
|
1298
|
+
clientFetch,
|
|
1226
1299
|
webSocketImpl,
|
|
1227
1300
|
declaresPresence,
|
|
1228
1301
|
)
|
|
@@ -1260,7 +1333,11 @@ export function createClient<
|
|
|
1260
1333
|
sessionOptions?: SessionOptions<D, S>,
|
|
1261
1334
|
): SessionRuntime<D, S, P> => {
|
|
1262
1335
|
// ── server truth ─────────────────────────────────────────────
|
|
1263
|
-
let
|
|
1336
|
+
let stateIndex = sessionOptions?.initialIndex ?? 0
|
|
1337
|
+
let streamIndex =
|
|
1338
|
+
sessionOptions?.initialState === undefined
|
|
1339
|
+
? sessionOptions?.initialIndex
|
|
1340
|
+
: stateIndex
|
|
1264
1341
|
let foldedState: S =
|
|
1265
1342
|
sessionOptions?.initialState !== undefined
|
|
1266
1343
|
? sessionOptions.initialState
|
|
@@ -1268,7 +1345,7 @@ export function createClient<
|
|
|
1268
1345
|
const serverEvents: ContractEvent<D>[] = (
|
|
1269
1346
|
sessionOptions?.initialEvents ?? []
|
|
1270
1347
|
)
|
|
1271
|
-
.filter((event) => event.index <=
|
|
1348
|
+
.filter((event) => event.index <= stateIndex)
|
|
1272
1349
|
.toSorted((a, b) => a.index - b.index)
|
|
1273
1350
|
|
|
1274
1351
|
// ── optimistic overlay ───────────────────────────────────────
|
|
@@ -1299,7 +1376,7 @@ export function createClient<
|
|
|
1299
1376
|
let snapshot: SessionSnapshot<D, S> | null = null
|
|
1300
1377
|
let status: ConnectionStatus = 'idle'
|
|
1301
1378
|
let reconnects = 0
|
|
1302
|
-
let lastError:
|
|
1379
|
+
let lastError: A2ClientError | null = null
|
|
1303
1380
|
let inFlightPushes = 0
|
|
1304
1381
|
let pushWireTail: Promise<void> = Promise.resolve()
|
|
1305
1382
|
let deferredHydration: SessionOptions<D, S> | undefined
|
|
@@ -1357,7 +1434,7 @@ export function createClient<
|
|
|
1357
1434
|
.filter((p) => p.acked)
|
|
1358
1435
|
.map((p) => p.acked!)
|
|
1359
1436
|
.toSorted((a, b) => a.index - b.index)
|
|
1360
|
-
const maxKnown = acked.at(-1)?.index ??
|
|
1437
|
+
const maxKnown = acked.at(-1)?.index ?? stateIndex
|
|
1361
1438
|
const unacked = pending
|
|
1362
1439
|
.filter((p) => !p.acked)
|
|
1363
1440
|
.map(
|
|
@@ -1383,7 +1460,8 @@ export function createClient<
|
|
|
1383
1460
|
case 'live':
|
|
1384
1461
|
return { status: 'live', reconnects }
|
|
1385
1462
|
case 'connecting':
|
|
1386
|
-
|
|
1463
|
+
case 'paused':
|
|
1464
|
+
return { status, reconnects, error: lastError }
|
|
1387
1465
|
}
|
|
1388
1466
|
}
|
|
1389
1467
|
|
|
@@ -1391,9 +1469,9 @@ export function createClient<
|
|
|
1391
1469
|
const oldestLoaded = serverEvents[0]?.index ?? null
|
|
1392
1470
|
return {
|
|
1393
1471
|
loading: historyLoads > 0,
|
|
1394
|
-
// Index 1 loaded — or a
|
|
1472
|
+
// Index 1 loaded — or a state index of 0, an empty log as far as
|
|
1395
1473
|
// this client knows — means nothing older is left to fetch.
|
|
1396
|
-
complete: oldestLoaded === 1 ||
|
|
1474
|
+
complete: oldestLoaded === 1 || stateIndex === 0,
|
|
1397
1475
|
oldestLoaded,
|
|
1398
1476
|
}
|
|
1399
1477
|
}
|
|
@@ -1405,7 +1483,7 @@ export function createClient<
|
|
|
1405
1483
|
return {
|
|
1406
1484
|
state,
|
|
1407
1485
|
events: [...serverEvents, ...overlay],
|
|
1408
|
-
index:
|
|
1486
|
+
index: stateIndex,
|
|
1409
1487
|
history: historyState(),
|
|
1410
1488
|
connection: connection(),
|
|
1411
1489
|
...(declaresPresence
|
|
@@ -1486,7 +1564,7 @@ export function createClient<
|
|
|
1486
1564
|
const at = new Date(atMs)
|
|
1487
1565
|
for (const [field, value] of Object.entries(values)) {
|
|
1488
1566
|
if (value === null) delete entry[field]
|
|
1489
|
-
else entry[field] = { value, seen:
|
|
1567
|
+
else entry[field] = { value, seen: stateIndex, at }
|
|
1490
1568
|
}
|
|
1491
1569
|
const next = Object.assign(nullProtoRecord<PresenceMap>(), presenceState)
|
|
1492
1570
|
if (Object.keys(entry).length === 0) delete next[id]
|
|
@@ -1499,7 +1577,7 @@ export function createClient<
|
|
|
1499
1577
|
// dropped silently — the next send repaints. Sends are NOT
|
|
1500
1578
|
// serialized on the ack and may overlap in flight: safe, because
|
|
1501
1579
|
// merges are LWW by `at` per (participant, field) and one
|
|
1502
|
-
// sender's stamps are monotonic. `seen` is the
|
|
1580
|
+
// sender's stamps are monotonic. `seen` is the state index at send
|
|
1503
1581
|
// time, not at set time; `at` is the sender's monotonic stamp.
|
|
1504
1582
|
const sendPresence = (
|
|
1505
1583
|
values: Record<string, unknown>,
|
|
@@ -1510,7 +1588,7 @@ export function createClient<
|
|
|
1510
1588
|
sessionId,
|
|
1511
1589
|
// Every path here starts at setPresence, which requires the
|
|
1512
1590
|
// participant.
|
|
1513
|
-
presence: { participant: participant!, values, seen:
|
|
1591
|
+
presence: { participant: participant!, values, seen: stateIndex, at },
|
|
1514
1592
|
})
|
|
1515
1593
|
touch(sessionId)
|
|
1516
1594
|
}
|
|
@@ -1566,7 +1644,7 @@ export function createClient<
|
|
|
1566
1644
|
? undefined
|
|
1567
1645
|
: presenceDefs['*']
|
|
1568
1646
|
if (!schema) {
|
|
1569
|
-
throw new
|
|
1647
|
+
throw new A2ClientError(
|
|
1570
1648
|
'UNKNOWN_PRESENCE_FIELD',
|
|
1571
1649
|
`no presence field '${field}' in the reducer's vocabulary`,
|
|
1572
1650
|
)
|
|
@@ -1577,7 +1655,7 @@ export function createClient<
|
|
|
1577
1655
|
}
|
|
1578
1656
|
const result = validateSync(schema, value, `presence field '${field}'`)
|
|
1579
1657
|
if (result.issues) {
|
|
1580
|
-
throw new
|
|
1658
|
+
throw new A2ClientError(
|
|
1581
1659
|
'INVALID_PAYLOAD',
|
|
1582
1660
|
`invalid value for presence field '${field}'`,
|
|
1583
1661
|
{ details: result.issues },
|
|
@@ -1598,20 +1676,25 @@ export function createClient<
|
|
|
1598
1676
|
}
|
|
1599
1677
|
|
|
1600
1678
|
/** Pushes awaiting stream confirmation — resolved by `ingest` the
|
|
1601
|
-
* moment the
|
|
1679
|
+
* moment the state index passes their batch. */
|
|
1602
1680
|
let confirmWatchers: Array<{ index: number; resolve: () => void }> = []
|
|
1603
1681
|
|
|
1604
1682
|
/** The single ingest point: every server-confirmed event, in log
|
|
1605
1683
|
* order, from the stream. */
|
|
1606
1684
|
const ingest = (event: ContractEvent<D>): void => {
|
|
1607
|
-
if (event.index <=
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
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
|
+
}
|
|
1611
1694
|
pending = pending.filter((p) => p.id !== event.id)
|
|
1612
|
-
if (confirmWatchers.some((w) => w.index <=
|
|
1613
|
-
const due = confirmWatchers.filter((w) => w.index <=
|
|
1614
|
-
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)
|
|
1615
1698
|
for (const watcher of due) watcher.resolve()
|
|
1616
1699
|
}
|
|
1617
1700
|
notify()
|
|
@@ -1627,7 +1710,7 @@ export function createClient<
|
|
|
1627
1710
|
? reducer.events[event.type]
|
|
1628
1711
|
: undefined
|
|
1629
1712
|
if (!schema) {
|
|
1630
|
-
throw new
|
|
1713
|
+
throw new A2ClientError(
|
|
1631
1714
|
'UNKNOWN_EVENT_TYPE',
|
|
1632
1715
|
`no event type '${String(event.type)}' in the reducer's vocabulary`,
|
|
1633
1716
|
)
|
|
@@ -1638,7 +1721,7 @@ export function createClient<
|
|
|
1638
1721
|
`event '${String(event.type)}'`,
|
|
1639
1722
|
)
|
|
1640
1723
|
if (result.issues) {
|
|
1641
|
-
throw new
|
|
1724
|
+
throw new A2ClientError(
|
|
1642
1725
|
'INVALID_PAYLOAD',
|
|
1643
1726
|
`invalid payload for event '${String(event.type)}'`,
|
|
1644
1727
|
{ details: result.issues },
|
|
@@ -1654,7 +1737,7 @@ export function createClient<
|
|
|
1654
1737
|
const post = async (
|
|
1655
1738
|
body: TransportPushBody,
|
|
1656
1739
|
): Promise<ContractEvent<D>[]> => {
|
|
1657
|
-
let lastPushError:
|
|
1740
|
+
let lastPushError: A2ClientError = new A2ClientError(
|
|
1658
1741
|
'STORE_UNAVAILABLE',
|
|
1659
1742
|
'push failed',
|
|
1660
1743
|
)
|
|
@@ -1663,16 +1746,9 @@ export function createClient<
|
|
|
1663
1746
|
const rows = await transport.push(body)
|
|
1664
1747
|
return rows.map((row) => eventFromWire(row) as ContractEvent<D>)
|
|
1665
1748
|
} catch (err) {
|
|
1666
|
-
lastPushError =
|
|
1667
|
-
err instanceof A2Error
|
|
1668
|
-
? err
|
|
1669
|
-
: new A2Error('STORE_UNAVAILABLE', 'push request failed', {
|
|
1670
|
-
cause: err,
|
|
1671
|
-
})
|
|
1749
|
+
lastPushError = asClientError(err)
|
|
1672
1750
|
}
|
|
1673
|
-
|
|
1674
|
-
// make the retry idempotent on the server.
|
|
1675
|
-
if (lastPushError.code !== 'STORE_UNAVAILABLE') throw lastPushError
|
|
1751
|
+
if (!RETRYABLE_PUSH_CODES.has(lastPushError.code)) throw lastPushError
|
|
1676
1752
|
if (attempt < PUSH_ATTEMPTS) {
|
|
1677
1753
|
const sleepMs = 250 * attempt
|
|
1678
1754
|
// Wait for the clock AND, when the transport can tell us,
|
|
@@ -1707,7 +1783,7 @@ export function createClient<
|
|
|
1707
1783
|
(acked) =>
|
|
1708
1784
|
new Promise<ContractEvent<D>[]>((resolve) => {
|
|
1709
1785
|
const last = acked.at(-1)?.index ?? 0
|
|
1710
|
-
if (
|
|
1786
|
+
if (stateIndex >= last) {
|
|
1711
1787
|
resolve(acked)
|
|
1712
1788
|
return
|
|
1713
1789
|
}
|
|
@@ -1746,6 +1822,7 @@ export function createClient<
|
|
|
1746
1822
|
inFlightPushes += 1
|
|
1747
1823
|
|
|
1748
1824
|
try {
|
|
1825
|
+
refreshIdleStream()
|
|
1749
1826
|
notify() // optimistic — the view updates before the network moves
|
|
1750
1827
|
await waitForPriorPush
|
|
1751
1828
|
const acked = await post({
|
|
@@ -1760,6 +1837,8 @@ export function createClient<
|
|
|
1760
1837
|
const entry = pending.find((p) => p.id === event.id)
|
|
1761
1838
|
if (entry) entry.acked = event // stream retires it at its index
|
|
1762
1839
|
}
|
|
1840
|
+
if ((acked.at(-1)?.index ?? 0) > (streamIndex ?? stateIndex))
|
|
1841
|
+
refreshIdleStream()
|
|
1763
1842
|
notify()
|
|
1764
1843
|
return acked
|
|
1765
1844
|
} catch (err) {
|
|
@@ -1783,7 +1862,7 @@ export function createClient<
|
|
|
1783
1862
|
}
|
|
1784
1863
|
|
|
1785
1864
|
// ── backscroll ───────────────────────────────────────────────
|
|
1786
|
-
// Cold reads of the log below the
|
|
1865
|
+
// Cold reads of the log below the state index. Fetched events merge
|
|
1787
1866
|
// into `serverEvents` only — never the fold, never the overlay.
|
|
1788
1867
|
let historyLoads = 0
|
|
1789
1868
|
/** Calls chain per session: each resolves its bounds after the
|
|
@@ -1833,11 +1912,11 @@ export function createClient<
|
|
|
1833
1912
|
}
|
|
1834
1913
|
touch(sessionId)
|
|
1835
1914
|
const run = historyTail.then(async () => {
|
|
1836
|
-
const upper = before ?? serverEvents[0]?.index ??
|
|
1837
|
-
// 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
|
|
1838
1917
|
// everything above it, and the optimistic overlay must never
|
|
1839
1918
|
// collide with a backscrolled row.
|
|
1840
|
-
const lte = Math.min(upper - 1,
|
|
1919
|
+
const lte = Math.min(upper - 1, stateIndex)
|
|
1841
1920
|
const gte = Math.max(1, upper - (limit ?? DEFAULT_HISTORY_LIMIT))
|
|
1842
1921
|
if (gte > lte) return []
|
|
1843
1922
|
const missing = missingBetween(gte, lte)
|
|
@@ -1848,9 +1927,11 @@ export function createClient<
|
|
|
1848
1927
|
const rows = await readHistory({ sessionId, ...missing })
|
|
1849
1928
|
mergeServerEvents(
|
|
1850
1929
|
rows.map((row) => eventFromWire(row) as ContractEvent<D>),
|
|
1851
|
-
|
|
1930
|
+
stateIndex,
|
|
1852
1931
|
)
|
|
1853
1932
|
return eventsBetween(gte, lte)
|
|
1933
|
+
} catch (error) {
|
|
1934
|
+
throw asClientError(error)
|
|
1854
1935
|
} finally {
|
|
1855
1936
|
historyLoads -= 1
|
|
1856
1937
|
notify()
|
|
@@ -1872,6 +1953,7 @@ export function createClient<
|
|
|
1872
1953
|
// `leaseEpoch` makes the outstanding releases inert).
|
|
1873
1954
|
let generation = 0
|
|
1874
1955
|
let active = false
|
|
1956
|
+
let streamIdle = false
|
|
1875
1957
|
let abort: AbortController | null = null
|
|
1876
1958
|
let leases = 0
|
|
1877
1959
|
let leaseEpoch = 0
|
|
@@ -1880,6 +1962,7 @@ export function createClient<
|
|
|
1880
1962
|
touch(sessionId)
|
|
1881
1963
|
if (!active) return
|
|
1882
1964
|
active = false
|
|
1965
|
+
streamIdle = false
|
|
1883
1966
|
generation += 1 // the running loop notices and exits
|
|
1884
1967
|
abort?.abort()
|
|
1885
1968
|
abort = null
|
|
@@ -1895,9 +1978,11 @@ export function createClient<
|
|
|
1895
1978
|
|
|
1896
1979
|
const runStream = async (run: number): Promise<void> => {
|
|
1897
1980
|
let backoff = STREAM_TIMINGS.reconnectBaseMs
|
|
1981
|
+
let attempt = 0
|
|
1898
1982
|
// oxlint-disable no-await-in-loop -- sequential reconnect loop
|
|
1899
1983
|
// oxlint-disable-next-line no-unmodified-loop-condition -- close() bumps `generation`
|
|
1900
1984
|
while (generation === run) {
|
|
1985
|
+
streamIdle = false
|
|
1901
1986
|
const controller = new AbortController()
|
|
1902
1987
|
abort = controller
|
|
1903
1988
|
// The stall watchdog: the server heartbeats the stream, so a
|
|
@@ -1921,7 +2006,7 @@ export function createClient<
|
|
|
1921
2006
|
try {
|
|
1922
2007
|
const frames = transport.connect({
|
|
1923
2008
|
sessionId,
|
|
1924
|
-
startAfter:
|
|
2009
|
+
startAfter: (streamIndex ??= stateIndex),
|
|
1925
2010
|
signal: controller.signal,
|
|
1926
2011
|
})
|
|
1927
2012
|
for await (const frame of frames) {
|
|
@@ -1933,10 +2018,12 @@ export function createClient<
|
|
|
1933
2018
|
// The first frame is the connection signal: the transport
|
|
1934
2019
|
// yields it the moment the stream is established.
|
|
1935
2020
|
backoff = STREAM_TIMINGS.reconnectBaseMs
|
|
2021
|
+
attempt = 0
|
|
1936
2022
|
wasLive = true
|
|
1937
2023
|
status = 'live'
|
|
1938
2024
|
lastError = null
|
|
1939
2025
|
notify()
|
|
2026
|
+
if (generation !== run) break
|
|
1940
2027
|
if (presenceResend) {
|
|
1941
2028
|
const values = presenceResend
|
|
1942
2029
|
presenceResend = null
|
|
@@ -1944,7 +2031,20 @@ export function createClient<
|
|
|
1944
2031
|
}
|
|
1945
2032
|
}
|
|
1946
2033
|
if (frame.kind === 'event') {
|
|
2034
|
+
streamIdle = false
|
|
1947
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
|
+
}
|
|
1948
2048
|
} else if (frame.kind === 'presence') {
|
|
1949
2049
|
applyPresencePatch(presencePatchFromWire(frame.patch))
|
|
1950
2050
|
} else if (frame.kind === 'presence-snapshot') {
|
|
@@ -1961,38 +2061,91 @@ export function createClient<
|
|
|
1961
2061
|
} catch (err) {
|
|
1962
2062
|
if (generation === run) {
|
|
1963
2063
|
lastError = stalled
|
|
1964
|
-
? new
|
|
2064
|
+
? new A2ClientError(
|
|
2065
|
+
'TIMEOUT',
|
|
1965
2066
|
`stream stalled: no data for ${STREAM_TIMINGS.stallTimeoutMs}ms`,
|
|
2067
|
+
{ phase: 'stream', timeoutMs: STREAM_TIMINGS.stallTimeoutMs },
|
|
1966
2068
|
)
|
|
1967
|
-
: err
|
|
1968
|
-
? err
|
|
1969
|
-
: new Error(String(err))
|
|
2069
|
+
: asClientError(err)
|
|
1970
2070
|
}
|
|
1971
2071
|
} finally {
|
|
1972
2072
|
clearTimeout(stall)
|
|
1973
2073
|
}
|
|
1974
2074
|
if (generation !== run) break
|
|
2075
|
+
streamIdle = false
|
|
1975
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
|
|
1976
2105
|
status = 'connecting'
|
|
1977
2106
|
notify()
|
|
2107
|
+
if (generation !== run) break
|
|
1978
2108
|
await new Promise<void>((resolve) => {
|
|
1979
2109
|
let settled = false
|
|
1980
2110
|
const finish = (): void => {
|
|
1981
2111
|
if (settled) return
|
|
1982
2112
|
settled = true
|
|
1983
2113
|
clearTimeout(timer)
|
|
2114
|
+
waiting.signal.removeEventListener('abort', finish)
|
|
1984
2115
|
// oxlint-disable-next-line no-multiple-resolved -- guarded above
|
|
1985
2116
|
resolve()
|
|
1986
2117
|
}
|
|
1987
|
-
const timer = setTimeout(finish,
|
|
2118
|
+
const timer = setTimeout(finish, delay)
|
|
1988
2119
|
;(timer as { unref?: () => void }).unref?.()
|
|
1989
|
-
|
|
2120
|
+
if (waiting.signal.aborted) finish()
|
|
2121
|
+
else waiting.signal.addEventListener('abort', finish, { once: true })
|
|
1990
2122
|
})
|
|
1991
2123
|
backoff = Math.min(backoff * 2, STREAM_TIMINGS.reconnectMaxMs)
|
|
1992
2124
|
}
|
|
1993
2125
|
// oxlint-enable no-await-in-loop
|
|
1994
2126
|
}
|
|
1995
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
|
+
|
|
1996
2149
|
const runtime: SessionRuntime<D, S, P> = {
|
|
1997
2150
|
sessionId,
|
|
1998
2151
|
// Only on presence-declaring contracts — the type surface
|
|
@@ -2012,6 +2165,7 @@ export function createClient<
|
|
|
2012
2165
|
},
|
|
2013
2166
|
push,
|
|
2014
2167
|
loadHistory,
|
|
2168
|
+
reconnect: reconnectStream,
|
|
2015
2169
|
connect() {
|
|
2016
2170
|
touch(sessionId)
|
|
2017
2171
|
leases += 1
|
|
@@ -2019,8 +2173,10 @@ export function createClient<
|
|
|
2019
2173
|
if (!active) {
|
|
2020
2174
|
active = true
|
|
2021
2175
|
status = 'connecting'
|
|
2176
|
+
lastError = null
|
|
2177
|
+
const run = generation
|
|
2022
2178
|
notify()
|
|
2023
|
-
void runStream(
|
|
2179
|
+
if (active && generation === run) void runStream(run)
|
|
2024
2180
|
}
|
|
2025
2181
|
let released = false
|
|
2026
2182
|
return () => {
|
|
@@ -2043,7 +2199,7 @@ export function createClient<
|
|
|
2043
2199
|
if (!next) return
|
|
2044
2200
|
participant ??= next.participant
|
|
2045
2201
|
const nextIndex = next.initialIndex ?? 0
|
|
2046
|
-
if (nextIndex >
|
|
2202
|
+
if (nextIndex > stateIndex && inFlightPushes > 0) {
|
|
2047
2203
|
if (
|
|
2048
2204
|
!deferredHydration ||
|
|
2049
2205
|
nextIndex > (deferredHydration.initialIndex ?? 0)
|
|
@@ -2055,28 +2211,29 @@ export function createClient<
|
|
|
2055
2211
|
|
|
2056
2212
|
const historyChanged = mergeServerEvents(
|
|
2057
2213
|
next.initialEvents,
|
|
2058
|
-
Math.min(nextIndex,
|
|
2214
|
+
Math.min(nextIndex, stateIndex),
|
|
2059
2215
|
)
|
|
2060
|
-
if (next.initialState
|
|
2216
|
+
if (next.initialState !== undefined) streamIndex ??= nextIndex
|
|
2217
|
+
if (next.initialState === undefined || nextIndex <= stateIndex) {
|
|
2061
2218
|
if (historyChanged) notifyHydrated()
|
|
2062
2219
|
return
|
|
2063
2220
|
}
|
|
2064
2221
|
|
|
2065
2222
|
// The server fold subsumes everything observed through the old
|
|
2066
|
-
//
|
|
2067
|
-
// entries the new
|
|
2068
|
-
|
|
2223
|
+
// state index. Keep later optimistic work overlaid, retiring acked
|
|
2224
|
+
// entries the new state index proves are already durable.
|
|
2225
|
+
stateIndex = nextIndex
|
|
2069
2226
|
foldedState = next.initialState
|
|
2070
|
-
mergeServerEvents(next.initialEvents,
|
|
2227
|
+
mergeServerEvents(next.initialEvents, stateIndex)
|
|
2071
2228
|
pending = pending.filter(
|
|
2072
|
-
(entry) => !entry.acked || entry.acked.index >
|
|
2229
|
+
(entry) => !entry.acked || entry.acked.index > stateIndex,
|
|
2073
2230
|
)
|
|
2074
|
-
if (confirmWatchers.some((watcher) => watcher.index <=
|
|
2231
|
+
if (confirmWatchers.some((watcher) => watcher.index <= stateIndex)) {
|
|
2075
2232
|
const due = confirmWatchers.filter(
|
|
2076
|
-
(watcher) => watcher.index <=
|
|
2233
|
+
(watcher) => watcher.index <= stateIndex,
|
|
2077
2234
|
)
|
|
2078
2235
|
confirmWatchers = confirmWatchers.filter(
|
|
2079
|
-
(watcher) => watcher.index >
|
|
2236
|
+
(watcher) => watcher.index > stateIndex,
|
|
2080
2237
|
)
|
|
2081
2238
|
for (const watcher of due) watcher.resolve()
|
|
2082
2239
|
}
|