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.
Files changed (123) hide show
  1. package/CHANGELOG.md +53 -0
  2. package/dist/actor-D_54lz_1.d.ts +310 -0
  3. package/dist/actor-D_54lz_1.d.ts.map +1 -0
  4. package/dist/actor-client.d.ts +13 -4
  5. package/dist/actor-client.d.ts.map +1 -1
  6. package/dist/actor-client.js +63 -7
  7. package/dist/actor-client.js.map +1 -1
  8. package/dist/actor-react.d.ts +5 -4
  9. package/dist/actor-react.d.ts.map +1 -1
  10. package/dist/actor-react.js +16 -2
  11. package/dist/actor-react.js.map +1 -1
  12. package/dist/actor.d.ts +2 -176
  13. package/dist/actor.js +13 -2
  14. package/dist/actor.js.map +1 -1
  15. package/dist/ai-server.d.ts +1 -1
  16. package/dist/ai-server.js +2 -2
  17. package/dist/ai.d.ts +1 -1
  18. package/dist/ai.js +1 -1
  19. package/dist/client-Bf6uSEAk.js +1342 -0
  20. package/dist/client-Bf6uSEAk.js.map +1 -0
  21. package/dist/client-P_NNNRM-.d.ts +243 -0
  22. package/dist/client-P_NNNRM-.d.ts.map +1 -0
  23. package/dist/client.d.ts +2 -202
  24. package/dist/client.js +2 -1026
  25. package/dist/errors-DCk6ch5n.js.map +1 -1
  26. package/dist/errors-DvhSXnxk.d.ts +28 -0
  27. package/dist/errors-DvhSXnxk.d.ts.map +1 -0
  28. package/dist/index.d.ts +3 -35
  29. package/dist/{internal-DRXJ56EI.js → internal-Dq2qYxou.js} +2 -2
  30. package/dist/{internal-DRXJ56EI.js.map → internal-Dq2qYxou.js.map} +1 -1
  31. package/dist/platform-B4TnJtWu.js +34 -0
  32. package/dist/platform-B4TnJtWu.js.map +1 -0
  33. package/dist/react.d.ts +3 -1
  34. package/dist/react.d.ts.map +1 -1
  35. package/dist/react.js +2 -1
  36. package/dist/react.js.map +1 -1
  37. package/dist/scheduler-qstash.d.ts +2 -2
  38. package/dist/scheduler-qstash.js +3 -2
  39. package/dist/scheduler-qstash.js.map +1 -1
  40. package/dist/scheduler-vercel.d.ts +2 -2
  41. package/dist/scheduler-vercel.js +2 -2
  42. package/dist/{server-CBET-jSz.js → server-Dkz2a84E.js} +295 -133
  43. package/dist/server-Dkz2a84E.js.map +1 -0
  44. package/dist/{server-CKY3_lbw.d.ts → server-DwPrMqHB.d.ts} +4 -2
  45. package/dist/server-DwPrMqHB.d.ts.map +1 -0
  46. package/dist/server.d.ts +2 -2
  47. package/dist/server.js +1 -1
  48. package/dist/{store-DGHeBtIQ.d.ts → store-DtDOWLSn.d.ts} +4 -5
  49. package/dist/{store-DGHeBtIQ.d.ts.map → store-DtDOWLSn.d.ts.map} +1 -1
  50. package/dist/store-N8PXxDAS.js.map +1 -1
  51. package/dist/store-memory.d.ts +1 -1
  52. package/dist/store-memory.js +1 -1
  53. package/dist/{store-polling-6DW7F1DT.js → store-polling-CmxUbV93.js} +56 -7
  54. package/dist/store-polling-CmxUbV93.js.map +1 -0
  55. package/dist/store-postgres.d.ts +6 -4
  56. package/dist/store-postgres.d.ts.map +1 -1
  57. package/dist/store-postgres.js +701 -53
  58. package/dist/store-postgres.js.map +1 -1
  59. package/dist/store-presence-polling-C7-XZyW9.js +94 -0
  60. package/dist/store-presence-polling-C7-XZyW9.js.map +1 -0
  61. package/dist/store-redis-http.d.ts +2 -2
  62. package/dist/store-redis-http.d.ts.map +1 -1
  63. package/dist/store-redis-http.js +184 -38
  64. package/dist/store-redis-http.js.map +1 -1
  65. package/dist/{store-redis-core-z-ykbyMg.js → store-redis-notify-BUCyXOn0.js} +491 -27
  66. package/dist/store-redis-notify-BUCyXOn0.js.map +1 -0
  67. package/dist/store-redis.d.ts +5 -13
  68. package/dist/store-redis.d.ts.map +1 -1
  69. package/dist/store-redis.js +27 -271
  70. package/dist/store-redis.js.map +1 -1
  71. package/dist/store-sqlite.d.ts +1 -1
  72. package/dist/store-sqlite.js +1 -1
  73. package/dist/{wire--yji6mO3.js → wire-BO5wWCb1.js} +18 -16
  74. package/dist/wire-BO5wWCb1.js.map +1 -0
  75. package/docs/actors/04-routes.mdx +40 -8
  76. package/docs/guides/03-react.mdx +52 -10
  77. package/docs/guides/05-production.mdx +54 -5
  78. package/docs/guides/09-presence.mdx +12 -7
  79. package/docs/guides/10-transports.mdx +85 -22
  80. package/docs/reference/01-api.mdx +90 -28
  81. package/docs/reference/02-errors.mdx +45 -11
  82. package/examples/playground/package.json +1 -1
  83. package/package.json +1 -1
  84. package/src/actor-client.ts +114 -14
  85. package/src/actor-react.ts +18 -3
  86. package/src/actor.ts +16 -1
  87. package/src/client-errors.ts +51 -0
  88. package/src/client.ts +577 -175
  89. package/src/errors.ts +4 -9
  90. package/src/index.ts +1 -1
  91. package/src/internal.ts +1 -1
  92. package/src/postgres-notification-scope.ts +134 -0
  93. package/src/postgres-notifications.ts +244 -0
  94. package/src/postgres-pool.ts +65 -0
  95. package/src/postgres-resources.ts +185 -0
  96. package/src/presence-recovery.ts +120 -0
  97. package/src/react.ts +3 -0
  98. package/src/redis-http-subscriptions.ts +199 -0
  99. package/src/server-fetch.ts +51 -23
  100. package/src/server.ts +146 -19
  101. package/src/session-socket.ts +241 -88
  102. package/src/sse.ts +17 -0
  103. package/src/store-polling.ts +32 -12
  104. package/src/store-postgres.ts +292 -75
  105. package/src/store-presence-polling.ts +125 -0
  106. package/src/store-redis-core.ts +37 -30
  107. package/src/store-redis-http.ts +51 -45
  108. package/src/store-redis-notify.ts +464 -0
  109. package/src/store-redis.ts +9 -364
  110. package/src/store.ts +3 -4
  111. package/src/stream-activity.ts +32 -0
  112. package/src/wire.ts +39 -15
  113. package/dist/actor-shared-BACubf4x.d.ts +0 -136
  114. package/dist/actor-shared-BACubf4x.d.ts.map +0 -1
  115. package/dist/actor.d.ts.map +0 -1
  116. package/dist/client.d.ts.map +0 -1
  117. package/dist/client.js.map +0 -1
  118. package/dist/index.d.ts.map +0 -1
  119. package/dist/server-CBET-jSz.js.map +0 -1
  120. package/dist/server-CKY3_lbw.d.ts.map +0 -1
  121. package/dist/store-polling-6DW7F1DT.js.map +0 -1
  122. package/dist/store-redis-core-z-ykbyMg.js.map +0 -1
  123. 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 { A2Error } from './errors.ts'
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 type ConnectionStatus = 'idle' | 'connecting' | 'live' | 'closed'
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
- /** Drops of an established stream so far. `0` = first connect. */
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: Error | null
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 stream frontier: the last server-confirmed index. This is 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 only `STORE_UNAVAILABLE`. Resolves with the appended
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 reconnects with backoff and resumes from the frontier.
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 current frontier.
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 string form is one route serving GET
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 — every session of this client — over one
224
- * multiplexed WebSocket, served by `sessionsSocket`.
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
- | string // one route: GET SSE stream + POST push
228
- | { type: 'http'; push: string; stream: string } // split routes
229
- | { type: 'ws'; url: string } // one socket, all sessions, both directions
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 the wire-deserialized A2Error. */
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: string; stream: string },
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 sep = routes.stream.includes('?') ? '&' : '?'
410
+ const streamUrl = sessionUrl(routes.stream, sessionId)
411
+ const sep = streamUrl.includes('?') ? '&' : '?'
356
412
  const res = await fetchImpl(
357
- `${routes.stream}${sep}sessionId=${encodeURIComponent(sessionId)}&index=${startAfter}`,
413
+ `${streamUrl}${sep}sessionId=${encodeURIComponent(sessionId)}&index=${startAfter}`,
358
414
  { headers: { accept: 'text/event-stream' }, signal },
359
415
  )
360
- if (!res.ok || !res.body) {
361
- throw new Error(`stream failed with ${res.status}`)
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 A2Error(
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 sep = routes.stream.includes('?') ? '&' : '?'
499
+ const streamUrl = sessionUrl(routes.stream, sessionId)
500
+ const sep = streamUrl.includes('?') ? '&' : '?'
439
501
  const res = await fetchImpl(
440
- `${routes.stream}${sep}sessionId=${encodeURIComponent(sessionId)}&gte=${gte}&lte=${lte}`,
502
+ `${streamUrl}${sep}sessionId=${encodeURIComponent(sessionId)}&gte=${gte}&lte=${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 A2Error(
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(routes.push, {
463
- method: 'POST',
464
- headers: { 'content-type': 'application/json' },
465
- body: JSON.stringify(envelope),
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 wsTransport = (
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 readyWaiters = new Map<string, Set<() => void>>()
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
- if (pending.size > 0) {
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 Error(
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
- flushReadyWaiters(frame.sessionId)
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.reason === undefined ? null : new Error(frame.reason),
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(socketUrl())
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 Error(
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 Error('socket error'))
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 Error(
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 A2Error('STORE_UNAVAILABLE', 'no open socket for the session')
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 A2Error('STORE_UNAVAILABLE', 'socket send failed', { cause }),
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 new Promise((resolve) => {
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
- return wsTransport(api.url, webSocketImpl, presence)
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
- fetchImpl,
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 frontier = sessionOptions?.initialIndex ?? 0
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 <= frontier)
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: Error | null = null
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 ?? frontier
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
- return { status: 'connecting', reconnects, error: lastError }
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 frontier of 0, an empty log as far as
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 || frontier === 0,
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: frontier,
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: frontier, at }
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 frontier at send
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: frontier, at },
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 A2Error(
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 A2Error(
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 frontier passes their batch. */
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 <= frontier) return
1363
- frontier = event.index
1364
- serverEvents.push(event)
1365
- foldedState = reducer.fold(foldedState, event)
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 <= frontier)) {
1368
- const due = confirmWatchers.filter((w) => w.index <= frontier)
1369
- confirmWatchers = confirmWatchers.filter((w) => w.index > frontier)
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 A2Error(
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 A2Error(
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: A2Error = new A2Error(
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
- // Only the documented retryable code retries; identical ids
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 (frontier >= last) {
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 frontier. Fetched events merge
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 ?? frontier + 1
1592
- // Strictly at or below the frontier: the live stream owns
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, frontier)
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
- frontier,
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: frontier,
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 Error(
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 instanceof Error
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, backoff)
2118
+ const timer = setTimeout(finish, delay)
1743
2119
  ;(timer as { unref?: () => void }).unref?.()
1744
- abort?.signal.addEventListener('abort', finish)
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(generation)
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 > frontier && inFlightPushes > 0) {
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, frontier),
2214
+ Math.min(nextIndex, stateIndex),
1814
2215
  )
1815
- if (next.initialState === undefined || nextIndex <= frontier) {
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
- // frontier. Keep later optimistic work overlaid, retiring acked
1822
- // entries the new frontier proves are already durable.
1823
- frontier = nextIndex
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, frontier)
2227
+ mergeServerEvents(next.initialEvents, stateIndex)
1826
2228
  pending = pending.filter(
1827
- (entry) => !entry.acked || entry.acked.index > frontier,
2229
+ (entry) => !entry.acked || entry.acked.index > stateIndex,
1828
2230
  )
1829
- if (confirmWatchers.some((watcher) => watcher.index <= frontier)) {
2231
+ if (confirmWatchers.some((watcher) => watcher.index <= stateIndex)) {
1830
2232
  const due = confirmWatchers.filter(
1831
- (watcher) => watcher.index <= frontier,
2233
+ (watcher) => watcher.index <= stateIndex,
1832
2234
  )
1833
2235
  confirmWatchers = confirmWatchers.filter(
1834
- (watcher) => watcher.index > frontier,
2236
+ (watcher) => watcher.index > stateIndex,
1835
2237
  )
1836
2238
  for (const watcher of due) watcher.resolve()
1837
2239
  }