experimental-a2 0.3.0 → 0.5.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 (205) hide show
  1. package/CHANGELOG.md +207 -0
  2. package/dist/ai-CFNeCrRl.d.ts +334 -0
  3. package/dist/ai-CFNeCrRl.d.ts.map +1 -0
  4. package/dist/ai-server.browser.js +2 -0
  5. package/dist/ai-server.browser.js.map +1 -0
  6. package/dist/ai-server.d.ts +49 -9
  7. package/dist/ai-server.d.ts.map +1 -0
  8. package/dist/ai-server.js +123 -49
  9. package/dist/ai-server.js.map +1 -0
  10. package/dist/ai.d.ts +2 -303
  11. package/dist/ai.js +233 -86
  12. package/dist/ai.js.map +1 -0
  13. package/dist/cli-B3VuxoDe.js +599 -0
  14. package/dist/cli-B3VuxoDe.js.map +1 -0
  15. package/dist/cli-bin.d.ts +1 -0
  16. package/dist/cli-bin.js +7 -0
  17. package/dist/cli-bin.js.map +1 -0
  18. package/dist/cli.d.ts +20 -0
  19. package/dist/cli.d.ts.map +1 -0
  20. package/dist/cli.js +2 -0
  21. package/dist/client-BKlyLiOU.js +1008 -0
  22. package/dist/client-BKlyLiOU.js.map +1 -0
  23. package/dist/client-D7mvIXrF.d.ts +191 -0
  24. package/dist/client-D7mvIXrF.d.ts.map +1 -0
  25. package/dist/client.d.ts +2 -90
  26. package/dist/client.js +1 -409
  27. package/dist/{contract-CG_adnu_.js → contract-48bUMgcL.js} +12 -2
  28. package/dist/contract-48bUMgcL.js.map +1 -0
  29. package/dist/{contract-C_3dIIEU.d.ts → contract-jIfaR085.d.ts} +63 -8
  30. package/dist/contract-jIfaR085.d.ts.map +1 -0
  31. package/dist/devtools-J_jZ2vQf.d.ts +152 -0
  32. package/dist/devtools-J_jZ2vQf.d.ts.map +1 -0
  33. package/dist/devtools-kJJaORn-.js +340 -0
  34. package/dist/devtools-kJJaORn-.js.map +1 -0
  35. package/dist/devtools-server.browser.js +3 -1
  36. package/dist/devtools-server.browser.js.map +1 -0
  37. package/dist/devtools-server.d.ts +4 -3
  38. package/dist/devtools-server.d.ts.map +1 -0
  39. package/dist/devtools-server.js +226 -43
  40. package/dist/devtools-server.js.map +1 -0
  41. package/dist/devtools.d.ts +2 -0
  42. package/dist/devtools.js +2 -0
  43. package/dist/{errors-BJRMd-h6.js → errors-BQuJpe82.js} +6 -4
  44. package/dist/errors-BQuJpe82.js.map +1 -0
  45. package/dist/{errors-xL_JTXsY.d.ts → errors-W6nwJ-fm.d.ts} +3 -2
  46. package/dist/errors-W6nwJ-fm.d.ts.map +1 -0
  47. package/dist/http.d.ts +135 -28
  48. package/dist/http.d.ts.map +1 -0
  49. package/dist/http.js +647 -61
  50. package/dist/http.js.map +1 -0
  51. package/dist/{idempotent-replay-BMyHrP0L.js → idempotent-replay-DuqEkYA7.js} +4 -2
  52. package/dist/idempotent-replay-DuqEkYA7.js.map +1 -0
  53. package/dist/index.d.ts +5 -5
  54. package/dist/index.js +2 -2
  55. package/dist/{inspection-E7qbD0Xj.js → inspection-DaxB5jM2.js} +4 -1
  56. package/dist/inspection-DaxB5jM2.js.map +1 -0
  57. package/dist/internal-DstsI6Re.js +211 -0
  58. package/dist/internal-DstsI6Re.js.map +1 -0
  59. package/dist/otel.d.ts +3 -2
  60. package/dist/otel.d.ts.map +1 -0
  61. package/dist/otel.js +2 -0
  62. package/dist/otel.js.map +1 -0
  63. package/dist/platform-B4TnJtWu.js +34 -0
  64. package/dist/platform-B4TnJtWu.js.map +1 -0
  65. package/dist/react.d.ts +47 -15
  66. package/dist/react.d.ts.map +1 -0
  67. package/dist/react.js +30 -15
  68. package/dist/react.js.map +1 -0
  69. package/dist/retryable-lazy-DZWmHpii.js +2 -0
  70. package/dist/retryable-lazy-DZWmHpii.js.map +1 -0
  71. package/dist/scheduler-qstash.d.ts +79 -0
  72. package/dist/scheduler-qstash.d.ts.map +1 -0
  73. package/dist/scheduler-qstash.js +501 -0
  74. package/dist/scheduler-qstash.js.map +1 -0
  75. package/dist/scheduler-task-BpzhPnRS.js +56 -0
  76. package/dist/scheduler-task-BpzhPnRS.js.map +1 -0
  77. package/dist/{recovery-vercel.d.ts → scheduler-vercel.d.ts} +18 -24
  78. package/dist/scheduler-vercel.d.ts.map +1 -0
  79. package/dist/scheduler-vercel.js +228 -0
  80. package/dist/scheduler-vercel.js.map +1 -0
  81. package/dist/server-C72KOw51.js +1457 -0
  82. package/dist/server-C72KOw51.js.map +1 -0
  83. package/dist/server-DUF9pjsx.d.ts +261 -0
  84. package/dist/server-DUF9pjsx.d.ts.map +1 -0
  85. package/dist/server.browser.js +2 -0
  86. package/dist/server.browser.js.map +1 -0
  87. package/dist/server.d.ts +4 -155
  88. package/dist/server.js +2 -2
  89. package/dist/{log-ldf5g8Cx.d.ts → store-DysUkTH3.d.ts} +120 -35
  90. package/dist/store-DysUkTH3.d.ts.map +1 -0
  91. package/dist/{log-yJbXUf72.js → store-N8PXxDAS.js} +3 -1
  92. package/dist/store-N8PXxDAS.js.map +1 -0
  93. package/dist/store-codec-DTG0Ftek.js +10 -0
  94. package/dist/store-codec-DTG0Ftek.js.map +1 -0
  95. package/dist/store-memory.d.ts +12 -0
  96. package/dist/store-memory.d.ts.map +1 -0
  97. package/dist/{log-memory.js → store-memory.js} +145 -34
  98. package/dist/store-memory.js.map +1 -0
  99. package/dist/{log-polling-DZ1MiKLg.js → store-polling-dSeLxzfb.js} +7 -5
  100. package/dist/store-polling-dSeLxzfb.js.map +1 -0
  101. package/dist/{log-postgres.d.ts → store-postgres.d.ts} +7 -6
  102. package/dist/store-postgres.d.ts.map +1 -0
  103. package/dist/{log-postgres.js → store-postgres.js} +214 -24
  104. package/dist/store-postgres.js.map +1 -0
  105. package/dist/{log-redis-core-CyJ5L8yR.js → store-redis-core-BFLwz0Wj.js} +271 -66
  106. package/dist/store-redis-core-BFLwz0Wj.js.map +1 -0
  107. package/dist/{log-redis-http.d.ts → store-redis-http.d.ts} +8 -7
  108. package/dist/store-redis-http.d.ts.map +1 -0
  109. package/dist/{log-redis-http.js → store-redis-http.js} +22 -12
  110. package/dist/store-redis-http.js.map +1 -0
  111. package/dist/{log-redis.d.ts → store-redis.d.ts} +7 -6
  112. package/dist/store-redis.d.ts.map +1 -0
  113. package/dist/{log-redis.js → store-redis.js} +101 -39
  114. package/dist/store-redis.js.map +1 -0
  115. package/dist/{log-sqlite.d.ts → store-sqlite.d.ts} +7 -6
  116. package/dist/store-sqlite.d.ts.map +1 -0
  117. package/dist/{log-sqlite.js → store-sqlite.js} +118 -22
  118. package/dist/store-sqlite.js.map +1 -0
  119. package/dist/{telemetry-Cso0qyHQ.d.ts → telemetry-BjYHTfh2.d.ts} +3 -2
  120. package/dist/telemetry-BjYHTfh2.d.ts.map +1 -0
  121. package/dist/testing.browser.d.ts +1 -0
  122. package/dist/testing.browser.js +6 -0
  123. package/dist/testing.browser.js.map +1 -0
  124. package/dist/testing.d.ts +32 -0
  125. package/dist/testing.d.ts.map +1 -0
  126. package/dist/testing.js +103 -0
  127. package/dist/testing.js.map +1 -0
  128. package/dist/validate-XKT4FSNn.js +2 -0
  129. package/dist/validate-XKT4FSNn.js.map +1 -0
  130. package/dist/wire-BFQmSJ-9.js +284 -0
  131. package/dist/wire-BFQmSJ-9.js.map +1 -0
  132. package/docs/01-quickstart.mdx +3 -3
  133. package/docs/concepts/01-contracts.mdx +18 -13
  134. package/docs/concepts/02-handlers.mdx +7 -7
  135. package/docs/concepts/03-durability.mdx +21 -21
  136. package/docs/concepts/04-state.mdx +18 -21
  137. package/docs/guides/01-timers.mdx +154 -49
  138. package/docs/guides/02-cancellation.mdx +30 -4
  139. package/docs/guides/03-react.mdx +76 -57
  140. package/docs/guides/04-local-first.mdx +1 -1
  141. package/docs/guides/05-production.mdx +310 -61
  142. package/docs/guides/06-ai-agents.mdx +252 -74
  143. package/docs/guides/07-devtools.mdx +137 -12
  144. package/docs/guides/08-application-data.mdx +1 -1
  145. package/docs/guides/09-presence.mdx +263 -0
  146. package/docs/guides/10-transports.mdx +140 -0
  147. package/docs/index.mdx +18 -14
  148. package/docs/reference/01-api.mdx +839 -110
  149. package/docs/reference/02-errors.mdx +13 -6
  150. package/package.json +24 -7
  151. package/src/ai-coordinator.ts +358 -0
  152. package/src/ai-projector.ts +524 -0
  153. package/src/ai-sdk-step.ts +261 -0
  154. package/src/ai-server.browser.ts +5 -0
  155. package/src/ai-server.ts +1719 -0
  156. package/src/ai.ts +2155 -0
  157. package/src/cache-indexeddb.ts +10 -0
  158. package/src/cli-bin.ts +5 -0
  159. package/src/cli.ts +1046 -0
  160. package/src/client.ts +1826 -0
  161. package/src/contract.ts +206 -0
  162. package/src/deterministic-id.ts +72 -0
  163. package/src/devtools-app.ts +989 -0
  164. package/src/devtools-server.browser.ts +5 -0
  165. package/src/devtools-server.ts +604 -0
  166. package/src/devtools.ts +716 -0
  167. package/src/errors.ts +50 -0
  168. package/src/http.ts +394 -0
  169. package/src/idempotent-replay.ts +53 -0
  170. package/src/index.ts +37 -0
  171. package/src/inspection.ts +39 -0
  172. package/src/internal.ts +426 -0
  173. package/src/otel.ts +59 -0
  174. package/src/platform.ts +60 -0
  175. package/src/push-envelope.ts +137 -0
  176. package/src/react.ts +284 -0
  177. package/src/reducer.ts +108 -0
  178. package/src/retryable-lazy.ts +27 -0
  179. package/src/scheduler-qstash.ts +915 -0
  180. package/src/scheduler-task.ts +106 -0
  181. package/src/scheduler-vercel.ts +437 -0
  182. package/src/server.browser.ts +12 -0
  183. package/src/server.ts +2700 -0
  184. package/src/session-socket.ts +548 -0
  185. package/src/sse.ts +141 -0
  186. package/src/standard-schema.ts +77 -0
  187. package/src/store-codec.ts +10 -0
  188. package/src/store-memory.ts +788 -0
  189. package/src/store-polling.ts +102 -0
  190. package/src/store-postgres.ts +1212 -0
  191. package/src/store-redis-core.ts +1494 -0
  192. package/src/store-redis-http.ts +116 -0
  193. package/src/store-redis.ts +458 -0
  194. package/src/store-sqlite.ts +1108 -0
  195. package/src/store.ts +385 -0
  196. package/src/telemetry.ts +47 -0
  197. package/src/testing.browser.ts +5 -0
  198. package/src/testing.ts +185 -0
  199. package/src/validate.ts +39 -0
  200. package/src/wire.ts +454 -0
  201. package/dist/internal-gCd5qMry.js +0 -44
  202. package/dist/log-memory.d.ts +0 -11
  203. package/dist/recovery-vercel.js +0 -119
  204. package/dist/server-BcLa4RFL.js +0 -877
  205. package/dist/wire-BVsgR8o9.js +0 -62
package/src/client.ts ADDED
@@ -0,0 +1,1826 @@
1
+ /**
2
+ * experimental-a2/client — the framework-agnostic session client.
3
+ *
4
+ * Everything the browser needs to read a session live and push
5
+ * optimistically, with no framework attached: the SSE subscription with
6
+ * frontier resume and reconnection, the optimistic push queue with
7
+ * ack/rollback, and the local fold through the same reducer the server
8
+ * uses. `experimental-a2/react`'s `createReact` is a thin binding over it, and the
9
+ * store contract (`subscribe`/`getSnapshot`) is exactly what
10
+ * `useSyncExternalStore` wants.
11
+ *
12
+ * The client is a replica, never an access path — the routes it talks
13
+ * to authorize every read and write.
14
+ */
15
+
16
+ // oxlint-disable no-await-in-loop -- retry/backoff/stream loops are sequential by nature
17
+ import { A2Error } from './errors.ts'
18
+ import {
19
+ PRESENCE_TIMINGS,
20
+ STREAM_TIMINGS,
21
+ nullProtoRecord,
22
+ } from './internal.ts'
23
+ import type {
24
+ AppendInput,
25
+ ContractEvent,
26
+ EventDefs,
27
+ PresenceDefs,
28
+ PresenceMap,
29
+ PresencePatch,
30
+ WithPresence,
31
+ } from './contract.ts'
32
+ import type { Reducer } from './reducer.ts'
33
+ import { validateSync } from './validate.ts'
34
+ import {
35
+ errorFromWire,
36
+ eventFromWire,
37
+ isWireEvent,
38
+ isWirePresencePatch,
39
+ isWirePresenceSnapshot,
40
+ parseSocketFrame,
41
+ presencePatchFromWire,
42
+ presenceSnapshotFromWire,
43
+ type SocketDownFrame,
44
+ type SocketPresenceFrame,
45
+ type SocketPushFrame,
46
+ type SocketSubscribeFrame,
47
+ type SocketUnsubscribeFrame,
48
+ type WireEvent,
49
+ type WirePresencePatch,
50
+ type WirePresenceSnapshot,
51
+ } from './wire.ts'
52
+
53
+ export type ConnectionStatus = 'idle' | 'connecting' | 'live' | 'closed'
54
+
55
+ /**
56
+ * The connection, as a discriminated union — impossible states are
57
+ * unrepresentable: an error only exists while disconnected,
58
+ * `reconnects` only once a connection has been attempted.
59
+ * "Reconnecting…" is `status === 'connecting' && reconnects > 0`.
60
+ */
61
+ export type Connection =
62
+ | { status: 'idle' }
63
+ | {
64
+ status: 'connecting'
65
+ /** Drops of an established stream so far. `0` = first connect. */
66
+ reconnects: number
67
+ /** Why the last connection ended; `null` on the first connect. */
68
+ error: Error | null
69
+ }
70
+ | { status: 'live'; reconnects: number }
71
+ | { status: 'closed' }
72
+
73
+ /**
74
+ * What `push` returns: resolves at the server ack (exactly like a
75
+ * plain promise — `await push(...)` gives the acked events), and
76
+ * carries `confirmed` for the later moment when the live stream has
77
+ * delivered the whole batch back and the optimistic overlay entry
78
+ * retired — the view now shows server truth. `confirmed` is lazy:
79
+ * never accessed, never created. A rejected push rejects both.
80
+ */
81
+ export type PushResult<D extends EventDefs> = Promise<ContractEvent<D>[]> & {
82
+ readonly confirmed: Promise<ContractEvent<D>[]>
83
+ }
84
+
85
+ export type LoadHistoryOptions = {
86
+ /**
87
+ * Exclusive upper bound for the fetch. Defaults to the oldest
88
+ * already-loaded index — successive calls page backward — or one
89
+ * past the hydration frontier while nothing is loaded yet.
90
+ */
91
+ before?: number
92
+ /** Page size; defaults to 50. */
93
+ limit?: number
94
+ }
95
+
96
+ /** Backscroll progress, derived from the raw event feed. */
97
+ export type HistoryState = {
98
+ /** A `loadHistory` fetch is in flight. */
99
+ loading: boolean
100
+ /** The feed reaches index 1 — nothing older remains to fetch. */
101
+ complete: boolean
102
+ /** The oldest loaded index; `null` while nothing is loaded. */
103
+ oldestLoaded: number | null
104
+ }
105
+
106
+ /** One immutable view of the session — stable identity between changes. */
107
+ export type SessionSnapshot<
108
+ D extends EventDefs,
109
+ S,
110
+ P extends PresenceDefs = Record<never, never>,
111
+ > = WithPresence<
112
+ P,
113
+ {
114
+ /** The replicated presence map, this client included (local echo). */
115
+ presence: PresenceMap<P>
116
+ }
117
+ > & {
118
+ /** The live view: server events folded, optimistic pushes applied. */
119
+ state: S
120
+ /** The raw event feed: server truth — observed, seeded, or
121
+ * backscrolled via `loadHistory` — plus pending optimistic events
122
+ * (provisional indexes past the frontier). `state` never folds
123
+ * backscrolled events; they are display data below the frontier. */
124
+ events: ContractEvent<D>[]
125
+ /** The stream frontier: the last server-confirmed index. This is the
126
+ * `lastSeenIndex` cancellation wants. */
127
+ index: number
128
+ /** Backscroll progress — `loadHistory` moves it. */
129
+ history: HistoryState
130
+ connection: Connection
131
+ }
132
+
133
+ /**
134
+ * The presence member of a client session — intersected in via
135
+ * `WithPresence`, so it exists exactly when the contract declares
136
+ * presence fields.
137
+ */
138
+ export type SessionClientPresence<P extends PresenceDefs> = {
139
+ /**
140
+ * Fire-and-forget presence: validates locally against the reducer's
141
+ * presence schemas, merges into the local map immediately (the
142
+ * echo), and coalesces on the wire to a fixed cadence — a leading
143
+ * send immediately, then at most one merged send per interval,
144
+ * later values winning field-wise. `null` clears a field. No ack,
145
+ * no retry, no rollback: a lost patch is repainted by the next one.
146
+ * Requires the session's `participant`.
147
+ */
148
+ setPresence(values: PresencePatch<P>['values']): void
149
+ }
150
+
151
+ export type SessionClient<
152
+ D extends EventDefs,
153
+ S,
154
+ P extends PresenceDefs = Record<never, never>,
155
+ > = WithPresence<P, SessionClientPresence<P>> & {
156
+ readonly sessionId: string
157
+ subscribe(listener: () => void): () => void
158
+ getSnapshot(): SessionSnapshot<D, S, P>
159
+ /**
160
+ * Optimistic append: validates locally against the reducer's event
161
+ * schemas (instant `INVALID_PAYLOAD`, no flicker), applies to the
162
+ * local fold, POSTs, swaps in the ack, rolls back on rejection.
163
+ * Auto-retries only `STORE_UNAVAILABLE`. Resolves with the appended
164
+ * events as the server recorded them. Overlapping calls on this
165
+ * handle enter the transport in invocation order without delaying
166
+ * their optimistic folds.
167
+ */
168
+ push(...events: AppendInput<D>[]): PushResult<D>
169
+ /**
170
+ * Backscroll: fetch a bounded slice of events from below the
171
+ * frontier and merge it into `events` — deduped, ordered, shared by
172
+ * every handle of this session. Folded state and the optimistic
173
+ * overlay are never touched. Calls serialize per session and skip
174
+ * already-loaded ranges, so overlapping backscrolls never fetch the
175
+ * same range twice. Resolves with the events in the requested range.
176
+ * Requires an http api; the `ws` transport has no history lane and
177
+ * this throws a TypeError there.
178
+ */
179
+ loadHistory(options?: LoadHistoryOptions): Promise<ContractEvent<D>[]>
180
+ /** Open the live stream (idempotent while open). Reconnects with
181
+ * backoff and resumes from the frontier until `close()`. */
182
+ connect(): void
183
+ /**
184
+ * Stop the live stream. Not terminal: `connect()` starts it again
185
+ * from the current frontier — which is what makes the React
186
+ * StrictMode mount dance (setup → cleanup → setup) work.
187
+ */
188
+ close(): void
189
+ }
190
+
191
+ export type SessionOptions<D extends EventDefs, S> = {
192
+ initialState?: S
193
+ initialIndex?: number
194
+ /** Server-rendered history through `initialIndex`. Seeds the event feed. */
195
+ initialEvents?: ContractEvent<D>[]
196
+ /**
197
+ * This client's presence identity — required to call `setPresence`.
198
+ * Caller-supplied (a user id, a tab nonce, a guest name): A2 does
199
+ * not invent an identity story. Bound once per session handle.
200
+ */
201
+ participant?: string
202
+ }
203
+
204
+ export type A2Client<
205
+ D extends EventDefs,
206
+ S,
207
+ P extends PresenceDefs = Record<never, never>,
208
+ > = {
209
+ session(
210
+ sessionId: string,
211
+ options?: SessionOptions<D, S>,
212
+ ): SessionClient<D, S, P>
213
+ }
214
+
215
+ /**
216
+ * The wire the client rides. The string form is one route serving GET
217
+ * (SSE stream) + POST (push). `http` splits the verbs across two
218
+ * routes, for when platform duration limits differ per verb. `ws`
219
+ * rides everything — every session of this client — over one
220
+ * multiplexed WebSocket, served by `sessionsSocket`.
221
+ */
222
+ export type ClientApi =
223
+ | string // one route: GET SSE stream + POST push
224
+ | { type: 'http'; push: string; stream: string } // split routes
225
+ | { type: 'ws'; url: string } // one socket, all sessions, both directions
226
+
227
+ /**
228
+ * The socket surface the `ws` transport drives — satisfied structurally
229
+ * by the DOM `WebSocket` and Node's alike, and small enough to fake in
230
+ * tests.
231
+ */
232
+ export type ClientWebSocket = {
233
+ readonly readyState: number
234
+ send(data: string): void
235
+ close(code?: number, reason?: string): void
236
+ addEventListener(type: 'open', listener: () => void): void
237
+ addEventListener(
238
+ type: 'message',
239
+ listener: (event: { data: unknown }) => void,
240
+ ): void
241
+ addEventListener(
242
+ type: 'close',
243
+ listener: (event: { code: number; reason: string }) => void,
244
+ ): void
245
+ addEventListener(type: 'error', listener: () => void): void
246
+ }
247
+
248
+ export type ClientWebSocketConstructor = new (url: string) => ClientWebSocket
249
+
250
+ export type CreateClientOptions<
251
+ D extends EventDefs,
252
+ S,
253
+ P extends PresenceDefs = Record<never, never>,
254
+ > = {
255
+ reducer: Reducer<D, S, P>
256
+ /** The route (or routes) the client speaks — see `ClientApi`. */
257
+ api: ClientApi
258
+ /** Injectable fetch — defaults to the global. */
259
+ fetch?: typeof globalThis.fetch
260
+ /** Injectable WebSocket constructor for the `ws` api — defaults to
261
+ * the global. The http transports never touch it. */
262
+ webSocket?: ClientWebSocketConstructor
263
+ /** How long an idle session keeps its in-memory identity, in
264
+ * milliseconds. Defaults to five minutes; `Infinity` disables GC. */
265
+ gcTime?: number
266
+ }
267
+
268
+ const PUSH_ATTEMPTS = 3
269
+ const DEFAULT_GC_TIME_MS = 5 * 60 * 1_000
270
+ const DEFAULT_HISTORY_LIMIT = 50
271
+
272
+ // ── the transport seam ───────────────────────────────────────────────
273
+ // Everything above it — optimistic queue, retry loop, presence
274
+ // throttle, reconnect/backoff, stall watchdog — is transport-agnostic.
275
+
276
+ /** The parsed frame lanes every transport yields: the SSE frame
277
+ * discrimination today, the ws `kind` discriminant tomorrow. `ping`
278
+ * carries no data — it exists so the watchdog sees liveness. */
279
+ type TransportFrame =
280
+ | { kind: 'event'; event: WireEvent }
281
+ | { kind: 'presence'; patch: WirePresencePatch }
282
+ | { kind: 'presence-snapshot'; snapshot: WirePresenceSnapshot }
283
+ | { kind: 'ping' }
284
+
285
+ type TransportPushBody = {
286
+ sessionId: string
287
+ events: Array<{ id: string; type: string; payload: unknown }>
288
+ }
289
+
290
+ type TransportPresenceEnvelope = {
291
+ sessionId: string
292
+ presence: {
293
+ participant: string
294
+ values: Record<string, unknown>
295
+ seen: number
296
+ at: number
297
+ }
298
+ }
299
+
300
+ type Transport = {
301
+ /**
302
+ * One live read: the first frame means the stream is established
303
+ * (any kind — a bare `ping` when no data is pending), the iterable
304
+ * ending or throwing is the disconnect. The caller owns
305
+ * reconnect/backoff and counts every frame as liveness.
306
+ */
307
+ connect(opts: {
308
+ sessionId: string
309
+ startAfter: number
310
+ signal: AbortSignal
311
+ }): AsyncIterable<TransportFrame>
312
+ /** One attempt (the retry loop lives above the seam): resolves with
313
+ * the acked wire events, throws the wire-deserialized A2Error. */
314
+ push(body: TransportPushBody): Promise<WireEvent[]>
315
+ /** One bounded cold read of the log, oldest first — the lane
316
+ * `loadHistory` rides. Absent on transports without a
317
+ * request/response wire (ws), where `loadHistory` fails fast. */
318
+ history?(opts: {
319
+ sessionId: string
320
+ gte: number
321
+ lte: number
322
+ }): Promise<WireEvent[]>
323
+ /** Fire-and-forget; failures are dropped (presence repaints). */
324
+ sendPresence(envelope: TransportPresenceEnvelope): void
325
+ /**
326
+ * Resolve when the session's wire can carry a push again, or after
327
+ * `maxWaitMs` — whichever comes first; never rejects. Transports
328
+ * whose pushes are connection-bound (ws) implement it so the retry
329
+ * loop waits for the wire instead of a fixed clock; HTTP pushes are
330
+ * stream-independent and omit it.
331
+ */
332
+ awaitReady?(sessionId: string, maxWaitMs: number): Promise<void>
333
+ }
334
+
335
+ const httpTransport = (
336
+ routes: { push: string; stream: string },
337
+ fetchImpl: typeof globalThis.fetch,
338
+ // Contracts that declare no presence skip presence frames unparsed,
339
+ // exactly like unknown named frames.
340
+ presence: boolean,
341
+ ): Transport => ({
342
+ async *connect({ sessionId, startAfter, signal }) {
343
+ const sep = routes.stream.includes('?') ? '&' : '?'
344
+ const res = await fetchImpl(
345
+ `${routes.stream}${sep}sessionId=${encodeURIComponent(sessionId)}&index=${startAfter}`,
346
+ { headers: { accept: 'text/event-stream' }, signal },
347
+ )
348
+ if (!res.ok || !res.body) {
349
+ throw new Error(`stream failed with ${res.status}`)
350
+ }
351
+ yield { kind: 'ping' } // connected — headers in hand, before any bytes
352
+ const decoder = new TextDecoder()
353
+ const reader = res.body.getReader()
354
+ let buffer = ''
355
+ try {
356
+ for (;;) {
357
+ // oxlint-disable-next-line no-await-in-loop -- stream read loop
358
+ const { done, value } = await reader.read()
359
+ if (done) return
360
+ // Any bytes count — heartbeat comments included. Liveness is
361
+ // about the connection, not about events.
362
+ yield { kind: 'ping' }
363
+ buffer += decoder.decode(value, { stream: true })
364
+ for (;;) {
365
+ const boundary = buffer.indexOf('\n\n')
366
+ if (boundary === -1) break
367
+ const frame = buffer.slice(0, boundary)
368
+ buffer = buffer.slice(boundary + 2)
369
+ let eventName: string | undefined
370
+ const dataLines: string[] = []
371
+ for (const line of frame.split('\n')) {
372
+ if (line.startsWith('data:')) {
373
+ dataLines.push(line.slice(5).trimStart())
374
+ } else if (line.startsWith('event:')) {
375
+ eventName = line.slice(6).trim()
376
+ }
377
+ }
378
+ const data = dataLines.join('\n')
379
+ if (!data) continue
380
+ if (eventName === undefined) {
381
+ const parsed: unknown = JSON.parse(data)
382
+ if (isWireEvent(parsed)) yield { kind: 'event', event: parsed }
383
+ } else if (presence && eventName === 'presence-snapshot') {
384
+ const parsed: unknown = JSON.parse(data)
385
+ if (isWirePresenceSnapshot(parsed)) {
386
+ yield { kind: 'presence-snapshot', snapshot: parsed }
387
+ }
388
+ } else if (presence && eventName === 'presence') {
389
+ const parsed: unknown = JSON.parse(data)
390
+ if (isWirePresencePatch(parsed)) {
391
+ yield { kind: 'presence', patch: parsed }
392
+ }
393
+ }
394
+ // Other named frames: skipped — old clients stay compatible
395
+ // with future lanes the same way.
396
+ }
397
+ }
398
+ } finally {
399
+ reader.cancel().catch(() => {})
400
+ }
401
+ },
402
+
403
+ async push(body) {
404
+ const res = await fetchImpl(routes.push, {
405
+ method: 'POST',
406
+ headers: { 'content-type': 'application/json' },
407
+ body: JSON.stringify(body),
408
+ })
409
+ if (res.ok) {
410
+ const rows = (await res.json()) as unknown
411
+ if (!Array.isArray(rows) || !rows.every(isWireEvent)) {
412
+ throw new A2Error(
413
+ 'STORE_UNAVAILABLE',
414
+ 'push ack was not a list of events',
415
+ )
416
+ }
417
+ return rows
418
+ }
419
+ throw (
420
+ errorFromWire(await res.json().catch(() => null)) ??
421
+ new A2Error('STORE_UNAVAILABLE', `push failed with ${res.status}`)
422
+ )
423
+ },
424
+
425
+ async history({ sessionId, gte, lte }) {
426
+ const sep = routes.stream.includes('?') ? '&' : '?'
427
+ const res = await fetchImpl(
428
+ `${routes.stream}${sep}sessionId=${encodeURIComponent(sessionId)}&gte=${gte}&lte=${lte}`,
429
+ { headers: { accept: 'application/json' } },
430
+ )
431
+ if (!res.ok) {
432
+ throw (
433
+ errorFromWire(await res.json().catch(() => null)) ??
434
+ new A2Error('STORE_UNAVAILABLE', `history failed with ${res.status}`)
435
+ )
436
+ }
437
+ const rows = (await res.json()) as unknown
438
+ if (!Array.isArray(rows) || !rows.every(isWireEvent)) {
439
+ throw new A2Error(
440
+ 'STORE_UNAVAILABLE',
441
+ 'history response was not a list of events',
442
+ )
443
+ }
444
+ return rows
445
+ },
446
+
447
+ sendPresence(envelope) {
448
+ void (async () => {
449
+ try {
450
+ const res = await fetchImpl(routes.push, {
451
+ method: 'POST',
452
+ headers: { 'content-type': 'application/json' },
453
+ body: JSON.stringify(envelope),
454
+ })
455
+ await res.arrayBuffer()
456
+ } catch {
457
+ // Dropped by design — nothing was ever true.
458
+ }
459
+ })()
460
+ },
461
+ })
462
+
463
+ /** `WebSocket.OPEN` — fixed by the spec, identical in every runtime. */
464
+ const WS_OPEN = 1
465
+
466
+ /** One session's lane on the shared ws socket — created per `connect`
467
+ * attempt, so `startAfter` is that attempt's resume frontier. */
468
+ type WsChannel = {
469
+ sessionId: string
470
+ startAfter: number
471
+ queue: TransportFrame[]
472
+ /** The server confirmed the subscribe — pushes and presence ride. */
473
+ live: boolean
474
+ /** The server already dropped it — no unsubscribe frame owed. */
475
+ dropped: boolean
476
+ ended: boolean
477
+ failure: Error | null
478
+ wake: (() => void) | null
479
+ }
480
+
481
+ const wakeChannel = (channel: WsChannel): void => {
482
+ channel.wake?.()
483
+ channel.wake = null
484
+ }
485
+
486
+ const subscribeFrame = (
487
+ sessions: Array<{ id: string; index: number }>,
488
+ ): string => {
489
+ const frame: SocketSubscribeFrame = { kind: 'subscribe', sessions }
490
+ return JSON.stringify(frame)
491
+ }
492
+
493
+ const wsTransport = (
494
+ url: string,
495
+ webSocketImpl: ClientWebSocketConstructor | undefined,
496
+ presence: boolean,
497
+ ): Transport => {
498
+ type PendingAck = {
499
+ resolve: (events: WireEvent[]) => void
500
+ reject: (error: A2Error) => void
501
+ }
502
+ // ONE socket serves every session of this client (specs §13's
503
+ // multiplexed superset): channels subscribe on it at their own
504
+ // frontiers, down frames route back by sessionId, acks by req.
505
+ const channels = new Map<string, WsChannel>()
506
+ const pending = new Map<number, PendingAck>()
507
+ /** Push retries parked in `awaitReady`, flushed per session on its
508
+ * subscribe confirmation. */
509
+ const readyWaiters = new Map<string, Set<() => void>>()
510
+ let socket: ClientWebSocket | null = null
511
+ let socketOpen = false
512
+ let watchdog: ReturnType<typeof setTimeout> | undefined
513
+ let nextReq = 1
514
+
515
+ const flushReadyWaiters = (sessionId: string): void => {
516
+ const waiters = readyWaiters.get(sessionId)
517
+ if (!waiters) return
518
+ readyWaiters.delete(sessionId)
519
+ for (const waiter of waiters) waiter()
520
+ }
521
+
522
+ const socketUrl = (): string => {
523
+ let resolved: URL
524
+ if (/^(https?|wss?):\/\//i.test(url)) {
525
+ resolved = new URL(url)
526
+ } else {
527
+ // A relative url resolves against the page, exactly like fetch.
528
+ // Outside a browser (SSR, tests) there is nothing to resolve
529
+ // against — demand an absolute url there.
530
+ const base = (globalThis as { location?: { href: string } }).location
531
+ ?.href
532
+ if (base === undefined) {
533
+ throw new TypeError(
534
+ `cannot resolve the relative ws url '${url}' without a browser location — pass an absolute ws:// or wss:// url`,
535
+ )
536
+ }
537
+ resolved = new URL(url, base)
538
+ }
539
+ if (resolved.protocol === 'http:') resolved.protocol = 'ws:'
540
+ else if (resolved.protocol === 'https:') resolved.protocol = 'wss:'
541
+ return resolved.href
542
+ }
543
+
544
+ const trySend = (data: string): void => {
545
+ if (socket === null || !socketOpen) return
546
+ try {
547
+ socket.send(data)
548
+ } catch {
549
+ // Dropped — the socket is going down and its close event settles
550
+ // everything that depended on this frame.
551
+ }
552
+ }
553
+
554
+ const deliver = (channel: WsChannel, frame: TransportFrame): void => {
555
+ channel.queue.push(frame)
556
+ wakeChannel(channel)
557
+ }
558
+
559
+ const settleChannel = (channel: WsChannel, failure: Error | null): void => {
560
+ if (channel.ended) return
561
+ channel.ended = true
562
+ channel.failure = failure
563
+ wakeChannel(channel)
564
+ }
565
+
566
+ // Unhook the socket from the transport state and reject every
567
+ // outstanding ack — nothing was acked, and the client-generated ids
568
+ // make the retries idempotent (§13).
569
+ const releaseSocket = (): ClientWebSocket | null => {
570
+ const current = socket
571
+ socket = null
572
+ socketOpen = false
573
+ clearTimeout(watchdog)
574
+ watchdog = undefined
575
+ if (pending.size > 0) {
576
+ const rejection = new A2Error(
577
+ 'STORE_UNAVAILABLE',
578
+ 'socket closed with the ack outstanding',
579
+ )
580
+ for (const waiter of pending.values()) waiter.reject(rejection)
581
+ pending.clear()
582
+ }
583
+ return current
584
+ }
585
+
586
+ // Idempotent from every direction (close event, error event, stall):
587
+ // every channel ends — each session's reconnect loop re-subscribes at
588
+ // its own current frontier.
589
+ const failSocket = (failure: Error | null): void => {
590
+ const current = releaseSocket()
591
+ for (const channel of channels.values()) settleChannel(channel, failure)
592
+ try {
593
+ current?.close(1000)
594
+ } catch {
595
+ // Already closed — it is down either way.
596
+ }
597
+ }
598
+
599
+ const detach = (channel: WsChannel): void => {
600
+ if (channels.get(channel.sessionId) !== channel) return
601
+ channels.delete(channel.sessionId)
602
+ if (!channel.dropped) {
603
+ const frame: SocketUnsubscribeFrame = {
604
+ kind: 'unsubscribe',
605
+ sessions: [channel.sessionId],
606
+ }
607
+ trySend(JSON.stringify(frame))
608
+ }
609
+ if (channels.size === 0) {
610
+ // The last session left — the shared socket has no reason to live.
611
+ const current = releaseSocket()
612
+ try {
613
+ current?.close(1000)
614
+ } catch {
615
+ // Already closed — it is down either way.
616
+ }
617
+ }
618
+ }
619
+
620
+ // One heartbeat watchdog per socket: any frame is liveness, and
621
+ // silence past the stall timeout means the connection is dead in a
622
+ // way the socket won't report.
623
+ const armWatchdog = (created: ClientWebSocket): void => {
624
+ clearTimeout(watchdog)
625
+ watchdog = setTimeout(() => {
626
+ if (socket !== created) return
627
+ failSocket(
628
+ new Error(
629
+ `socket stalled: no data for ${STREAM_TIMINGS.stallTimeoutMs}ms`,
630
+ ),
631
+ )
632
+ }, STREAM_TIMINGS.stallTimeoutMs)
633
+ ;(watchdog as { unref?: () => void }).unref?.()
634
+ }
635
+
636
+ const route = (frame: SocketDownFrame): void => {
637
+ switch (frame.kind) {
638
+ case 'ping': {
639
+ // Global liveness — every live channel's stall watchdog feeds
640
+ // on it, the way each SSE stream feeds on its own heartbeat.
641
+ for (const channel of channels.values()) {
642
+ if (channel.live && !channel.ended) {
643
+ deliver(channel, { kind: 'ping' })
644
+ }
645
+ }
646
+ return
647
+ }
648
+ case 'subscribed': {
649
+ const channel = channels.get(frame.sessionId)
650
+ if (channel === undefined || channel.ended || channel.live) return
651
+ channel.live = true
652
+ deliver(channel, { kind: 'ping' }) // the connected signal
653
+ flushReadyWaiters(frame.sessionId)
654
+ return
655
+ }
656
+ case 'unsubscribed': {
657
+ const channel = channels.get(frame.sessionId)
658
+ if (channel === undefined) return
659
+ channel.dropped = true
660
+ settleChannel(
661
+ channel,
662
+ frame.reason === undefined ? null : new Error(frame.reason),
663
+ )
664
+ return
665
+ }
666
+ case 'ack': {
667
+ const waiter = pending.get(frame.req)
668
+ if (waiter === undefined) return
669
+ pending.delete(frame.req)
670
+ if ('events' in frame) waiter.resolve(frame.events)
671
+ else waiter.reject(frame.error)
672
+ return
673
+ }
674
+ case 'event': {
675
+ // Pre-confirmation frames are safe to drop: the subscription
676
+ // being confirmed replays from this channel's own frontier.
677
+ const channel = channels.get(frame.event.sessionId)
678
+ if (channel !== undefined && channel.live && !channel.ended) {
679
+ deliver(channel, { kind: 'event', event: frame.event })
680
+ }
681
+ return
682
+ }
683
+ case 'presence': {
684
+ // Contracts that declare no presence skip presence frames,
685
+ // exactly like the http transport.
686
+ if (!presence || frame.sessionId === undefined) return
687
+ const channel = channels.get(frame.sessionId)
688
+ if (channel !== undefined && channel.live && !channel.ended) {
689
+ deliver(channel, { kind: 'presence', patch: frame.patch })
690
+ }
691
+ return
692
+ }
693
+ case 'presence-snapshot': {
694
+ if (!presence || frame.sessionId === undefined) return
695
+ const channel = channels.get(frame.sessionId)
696
+ if (channel !== undefined && channel.live && !channel.ended) {
697
+ deliver(channel, {
698
+ kind: 'presence-snapshot',
699
+ snapshot: frame.snapshot,
700
+ })
701
+ }
702
+ return
703
+ }
704
+ }
705
+ }
706
+
707
+ const ensureSocket = (): void => {
708
+ if (socket !== null) return
709
+ if (webSocketImpl === undefined) {
710
+ throw new TypeError(
711
+ 'no WebSocket implementation available — pass one via createClient({ webSocket }) where the global is missing',
712
+ )
713
+ }
714
+ const created = new webSocketImpl(socketUrl())
715
+ socket = created
716
+ socketOpen = false
717
+ armWatchdog(created)
718
+ created.addEventListener('open', () => {
719
+ if (socket !== created) return
720
+ socketOpen = true
721
+ armWatchdog(created)
722
+ const sessions = [...channels.values()]
723
+ .filter((channel) => !channel.ended)
724
+ .map((channel) => ({
725
+ id: channel.sessionId,
726
+ index: channel.startAfter,
727
+ }))
728
+ if (sessions.length > 0) trySend(subscribeFrame(sessions))
729
+ })
730
+ created.addEventListener('message', (event) => {
731
+ if (socket !== created) return
732
+ if (typeof event.data !== 'string') return
733
+ armWatchdog(created)
734
+ const frame = parseSocketFrame(event.data)
735
+ if (frame === null) return
736
+ route(frame)
737
+ })
738
+ created.addEventListener('close', (event) => {
739
+ if (socket !== created) return
740
+ failSocket(
741
+ event.code === 1000
742
+ ? null // deliberate server close — reconnect, but not an error
743
+ : new Error(
744
+ `socket closed (${event.code}${event.reason ? `: ${event.reason}` : ''})`,
745
+ ),
746
+ )
747
+ })
748
+ created.addEventListener('error', () => {
749
+ if (socket !== created) return
750
+ failSocket(new Error('socket error'))
751
+ })
752
+ }
753
+
754
+ return {
755
+ async *connect({ sessionId, startAfter, signal }) {
756
+ const channel: WsChannel = {
757
+ sessionId,
758
+ startAfter,
759
+ queue: [],
760
+ live: false,
761
+ dropped: false,
762
+ ended: false,
763
+ failure: null,
764
+ wake: null,
765
+ }
766
+ channels.set(sessionId, channel)
767
+ try {
768
+ ensureSocket()
769
+ if (socketOpen) {
770
+ trySend(subscribeFrame([{ id: sessionId, index: startAfter }]))
771
+ }
772
+ } catch (error) {
773
+ if (channels.get(sessionId) === channel) channels.delete(sessionId)
774
+ throw error
775
+ }
776
+
777
+ const onAbort = (): void => {
778
+ settleChannel(
779
+ channel,
780
+ signal.reason instanceof Error
781
+ ? signal.reason
782
+ : new Error('stream aborted'),
783
+ )
784
+ // Synchronously, not from the pump's finally: the watchdog and
785
+ // close() must not leave a dead subscription behind.
786
+ detach(channel)
787
+ }
788
+ if (signal.aborted) onAbort()
789
+ else signal.addEventListener('abort', onAbort, { once: true })
790
+
791
+ // An unconfirmed subscribe receives no frames at all (global
792
+ // pings only feed live channels), so a server that never answers
793
+ // — a single-session route, say — would hang this session
794
+ // silently. Fail it loudly instead; the reconnect loop retries.
795
+ const pendingSubscribe = setTimeout(() => {
796
+ if (channel.live) return
797
+ settleChannel(
798
+ channel,
799
+ new Error(
800
+ 'subscribe not confirmed: is the ws route serving the multiplexed socket (handle with options.upgrade)?',
801
+ ),
802
+ )
803
+ }, STREAM_TIMINGS.stallTimeoutMs)
804
+ ;(pendingSubscribe as { unref?: () => void }).unref?.()
805
+
806
+ try {
807
+ for (;;) {
808
+ while (channel.queue.length > 0) yield channel.queue.shift()!
809
+ if (channel.ended) {
810
+ if (channel.failure) throw channel.failure
811
+ return
812
+ }
813
+ // oxlint-disable-next-line no-await-in-loop -- frame pump
814
+ await new Promise<void>((resolve) => {
815
+ channel.wake = resolve
816
+ })
817
+ }
818
+ } finally {
819
+ clearTimeout(pendingSubscribe)
820
+ signal.removeEventListener('abort', onAbort)
821
+ settleChannel(channel, null)
822
+ detach(channel)
823
+ }
824
+ },
825
+
826
+ async push(body) {
827
+ const channel = channels.get(body.sessionId)
828
+ const target = socket
829
+ if (
830
+ channel === undefined ||
831
+ !channel.live ||
832
+ channel.ended ||
833
+ target === null ||
834
+ !socketOpen ||
835
+ target.readyState !== WS_OPEN
836
+ ) {
837
+ // Retryable by contract: the reconnect loop restores the
838
+ // socket and the retry rides the same event ids.
839
+ throw new A2Error('STORE_UNAVAILABLE', 'no open socket for the session')
840
+ }
841
+ const req = nextReq
842
+ nextReq += 1
843
+ return await new Promise<WireEvent[]>((resolve, reject) => {
844
+ pending.set(req, { resolve, reject })
845
+ const frame: SocketPushFrame = {
846
+ kind: 'push',
847
+ req,
848
+ sessionId: body.sessionId,
849
+ events: body.events,
850
+ }
851
+ try {
852
+ target.send(JSON.stringify(frame))
853
+ } catch (cause) {
854
+ pending.delete(req)
855
+ reject(
856
+ new A2Error('STORE_UNAVAILABLE', 'socket send failed', { cause }),
857
+ )
858
+ }
859
+ })
860
+ },
861
+
862
+ awaitReady(sessionId, maxWaitMs) {
863
+ const channel = channels.get(sessionId)
864
+ if (
865
+ channel !== undefined &&
866
+ channel.live &&
867
+ !channel.ended &&
868
+ socketOpen
869
+ ) {
870
+ return Promise.resolve()
871
+ }
872
+ return new Promise((resolve) => {
873
+ let waiters = readyWaiters.get(sessionId)
874
+ if (!waiters) {
875
+ waiters = new Set()
876
+ readyWaiters.set(sessionId, waiters)
877
+ }
878
+ let settled = false
879
+ const done = (): void => {
880
+ if (settled) return
881
+ settled = true
882
+ clearTimeout(timer)
883
+ waiters.delete(done)
884
+ if (waiters.size === 0) readyWaiters.delete(sessionId)
885
+ // oxlint-disable-next-line promise/no-multiple-resolved -- `settled` guards the two callers (timer, open flush); the rule cannot see through the flag
886
+ resolve()
887
+ }
888
+ // The cap keeps a session nobody reconnects (write-only usage,
889
+ // an unreachable server) from parking a push forever — the
890
+ // attempt then fails fast and the loop moves on.
891
+ const timer = setTimeout(done, maxWaitMs)
892
+ ;(timer as { unref?: () => void }).unref?.()
893
+ waiters.add(done)
894
+ })
895
+ },
896
+ sendPresence(envelope) {
897
+ const channel = channels.get(envelope.sessionId)
898
+ if (channel === undefined || !channel.live || channel.ended) {
899
+ return // dropped by design — the reconnect re-send covers the gap
900
+ }
901
+ const frame: SocketPresenceFrame = {
902
+ kind: 'presence',
903
+ sessionId: envelope.sessionId,
904
+ ...envelope.presence,
905
+ }
906
+ trySend(JSON.stringify(frame))
907
+ },
908
+ }
909
+ }
910
+
911
+ const transportFor = (
912
+ api: ClientApi,
913
+ fetchImpl: typeof globalThis.fetch,
914
+ webSocketImpl: ClientWebSocketConstructor | undefined,
915
+ presence: boolean,
916
+ ): Transport => {
917
+ if (typeof api === 'string') {
918
+ return httpTransport({ push: api, stream: api }, fetchImpl, presence)
919
+ }
920
+ if (api.type === 'http') {
921
+ return httpTransport(
922
+ { push: api.push, stream: api.stream },
923
+ fetchImpl,
924
+ presence,
925
+ )
926
+ }
927
+ return wsTransport(api.url, webSocketImpl, presence)
928
+ }
929
+
930
+ type Pending<D extends EventDefs> = {
931
+ id: string
932
+ type: string
933
+ payload: unknown
934
+ createdAt: Date
935
+ /** Set once the POST ack returns; the entry leaves the overlay when
936
+ * the stream delivers its index. */
937
+ acked?: ContractEvent<D>
938
+ }
939
+
940
+ type SessionRuntime<
941
+ D extends EventDefs,
942
+ S,
943
+ P extends PresenceDefs,
944
+ > = SessionClient<D, S, P> & {
945
+ hydrate(options?: SessionOptions<D, S>): void
946
+ canEvict(): boolean
947
+ }
948
+
949
+ type CachedSession<D extends EventDefs, S, P extends PresenceDefs> = {
950
+ runtime: SessionRuntime<D, S, P>
951
+ timer?: ReturnType<typeof setTimeout>
952
+ }
953
+
954
+ export function createClient<
955
+ D extends EventDefs,
956
+ S,
957
+ P extends PresenceDefs = Record<never, never>,
958
+ >(options: CreateClientOptions<D, S, P>): A2Client<D, S, P> {
959
+ const { reducer, api } = options
960
+ const presenceDefs: Readonly<PresenceDefs> = reducer.presence
961
+ const declaresPresence = Object.keys(presenceDefs).length > 0
962
+ const fetchImpl = options.fetch ?? globalThis.fetch.bind(globalThis)
963
+ const webSocketImpl =
964
+ options.webSocket ??
965
+ (globalThis as { WebSocket?: ClientWebSocketConstructor }).WebSocket
966
+ const transport = transportFor(
967
+ api,
968
+ fetchImpl,
969
+ webSocketImpl,
970
+ declaresPresence,
971
+ )
972
+ const gcTime = options.gcTime ?? DEFAULT_GC_TIME_MS
973
+ if (Number.isNaN(gcTime) || gcTime < 0) {
974
+ throw new RangeError('gcTime must be a non-negative number or Infinity')
975
+ }
976
+ const sessions = new Map<string, CachedSession<D, S, P>>()
977
+
978
+ const scheduleGc = (sessionId: string, delay = gcTime): void => {
979
+ const entry = sessions.get(sessionId)
980
+ if (!entry) return
981
+ clearTimeout(entry.timer)
982
+ if (gcTime === Infinity) return
983
+ entry.timer = setTimeout(() => {
984
+ const current = sessions.get(sessionId)
985
+ if (current !== entry) return
986
+ if (entry.runtime.canEvict()) {
987
+ sessions.delete(sessionId)
988
+ return
989
+ }
990
+ // `gcTime: 0` is useful for tests and explicit no-cache clients,
991
+ // but must not spin while a session is mounted or a push is in flight.
992
+ scheduleGc(sessionId, Math.max(gcTime, 1_000))
993
+ }, delay)
994
+ ;(entry.timer as { unref?: () => void }).unref?.()
995
+ }
996
+
997
+ const touch = (sessionId: string): void => {
998
+ scheduleGc(sessionId)
999
+ }
1000
+
1001
+ const makeSession = (
1002
+ sessionId: string,
1003
+ sessionOptions?: SessionOptions<D, S>,
1004
+ ): SessionRuntime<D, S, P> => {
1005
+ // ── server truth ─────────────────────────────────────────────
1006
+ let frontier = sessionOptions?.initialIndex ?? 0
1007
+ let foldedState: S =
1008
+ sessionOptions?.initialState !== undefined
1009
+ ? sessionOptions.initialState
1010
+ : reducer.initialState
1011
+ const serverEvents: ContractEvent<D>[] = (
1012
+ sessionOptions?.initialEvents ?? []
1013
+ )
1014
+ .filter((event) => event.index <= frontier)
1015
+ .toSorted((a, b) => a.index - b.index)
1016
+
1017
+ // ── optimistic overlay ───────────────────────────────────────
1018
+ let pending: Pending<D>[] = []
1019
+
1020
+ // ── the presence plane ───────────────────────────────────────
1021
+ // Never cached, never folded. Foreign entries replicate from the
1022
+ // stream; the own entry is the local echo — server copies of self
1023
+ // are ignored (the echo is at least as new, and comparing it to
1024
+ // server stamps would put two clocks in one order).
1025
+ let participant = sessionOptions?.participant
1026
+ // Participant and field keys arrive off the wire, so the map and
1027
+ // every entry are built null-prototype — see `nullProtoRecord`.
1028
+ let presenceState: PresenceMap = nullProtoRecord()
1029
+ let presenceLastSentAt = 0
1030
+ /** The last LWW stamp issued — see `nextPresenceAt`. */
1031
+ let presenceLastAt = 0
1032
+ /** Values awaiting the trailing send — non-null exactly while
1033
+ * `presenceTimer` is armed. */
1034
+ let presenceBuffer: Record<string, unknown> | null = null
1035
+ let presenceTimer: ReturnType<typeof setTimeout> | undefined
1036
+ /** Own fields set while the stream was down — re-sent once on
1037
+ * reconnect (their POSTs may have been lost with the network). */
1038
+ let presenceResend: Record<string, unknown> | null = null
1039
+
1040
+ // ── store plumbing ───────────────────────────────────────────
1041
+ const listeners = new Set<() => void>()
1042
+ let snapshot: SessionSnapshot<D, S> | null = null
1043
+ let status: ConnectionStatus = 'idle'
1044
+ let reconnects = 0
1045
+ let lastError: Error | null = null
1046
+ let inFlightPushes = 0
1047
+ let pushWireTail: Promise<void> = Promise.resolve()
1048
+ let deferredHydration: SessionOptions<D, S> | undefined
1049
+ let hydrationNotification: ReturnType<typeof setTimeout> | undefined
1050
+
1051
+ const emit = (): void => {
1052
+ // copy: a listener may unsubscribe (or subscribe) mid-notify
1053
+ // oxlint-disable-next-line no-useless-spread
1054
+ for (const listener of [...listeners]) listener()
1055
+ }
1056
+
1057
+ const notify = (): void => {
1058
+ snapshot = null
1059
+ emit()
1060
+ }
1061
+
1062
+ const notifyHydrated = (): void => {
1063
+ // `session(id, serverFold)` is also called while a destination
1064
+ // provider renders. Its own snapshot must advance synchronously,
1065
+ // but notifying an outgoing provider during that render would be a
1066
+ // React setState-in-render violation.
1067
+ snapshot = null
1068
+ clearTimeout(hydrationNotification)
1069
+ hydrationNotification = setTimeout(emit, 0)
1070
+ ;(hydrationNotification as { unref?: () => void }).unref?.()
1071
+ }
1072
+
1073
+ const mergeServerEvents = (
1074
+ events: ContractEvent<D>[] | undefined,
1075
+ throughIndex: number,
1076
+ ): boolean => {
1077
+ if (!events || events.length === 0) return false
1078
+ const byIndex = new Map(serverEvents.map((event) => [event.index, event]))
1079
+ let changed = false
1080
+ for (const event of events) {
1081
+ if (event.index > throughIndex) continue
1082
+ const existing = byIndex.get(event.index)
1083
+ if (existing?.id === event.id) continue
1084
+ byIndex.set(event.index, event)
1085
+ changed = true
1086
+ }
1087
+ if (!changed) return false
1088
+ serverEvents.splice(
1089
+ 0,
1090
+ serverEvents.length,
1091
+ ...[...byIndex.values()].toSorted((a, b) => a.index - b.index),
1092
+ )
1093
+ return true
1094
+ }
1095
+
1096
+ const overlayEvents = (): ContractEvent<D>[] => {
1097
+ // Acked entries first (they have a server-assigned order), then
1098
+ // unacked in push order with provisional indexes.
1099
+ const acked = pending
1100
+ .filter((p) => p.acked)
1101
+ .map((p) => p.acked!)
1102
+ .toSorted((a, b) => a.index - b.index)
1103
+ const maxKnown = acked.at(-1)?.index ?? frontier
1104
+ const unacked = pending
1105
+ .filter((p) => !p.acked)
1106
+ .map(
1107
+ (p, i) =>
1108
+ ({
1109
+ id: p.id,
1110
+ type: p.type,
1111
+ payload: p.payload,
1112
+ index: maxKnown + 1 + i, // provisional
1113
+ sessionId,
1114
+ createdAt: p.createdAt,
1115
+ }) as ContractEvent<D>,
1116
+ )
1117
+ return [...acked, ...unacked]
1118
+ }
1119
+
1120
+ const connection = (): Connection => {
1121
+ switch (status) {
1122
+ case 'idle':
1123
+ return { status: 'idle' }
1124
+ case 'closed':
1125
+ return { status: 'closed' }
1126
+ case 'live':
1127
+ return { status: 'live', reconnects }
1128
+ case 'connecting':
1129
+ return { status: 'connecting', reconnects, error: lastError }
1130
+ }
1131
+ }
1132
+
1133
+ const historyState = (): HistoryState => {
1134
+ const oldestLoaded = serverEvents[0]?.index ?? null
1135
+ return {
1136
+ loading: historyLoads > 0,
1137
+ // Index 1 loaded — or a frontier of 0, an empty log as far as
1138
+ // this client knows — means nothing older is left to fetch.
1139
+ complete: oldestLoaded === 1 || frontier === 0,
1140
+ oldestLoaded,
1141
+ }
1142
+ }
1143
+
1144
+ const buildSnapshot = (): SessionSnapshot<D, S, P> => {
1145
+ const overlay = overlayEvents()
1146
+ let state = foldedState
1147
+ for (const event of overlay) state = reducer.fold(state, event)
1148
+ return {
1149
+ state,
1150
+ events: [...serverEvents, ...overlay],
1151
+ index: frontier,
1152
+ history: historyState(),
1153
+ connection: connection(),
1154
+ ...(declaresPresence
1155
+ ? { presence: presenceState as PresenceMap<P> }
1156
+ : {}),
1157
+ } as SessionSnapshot<D, S, P>
1158
+ }
1159
+
1160
+ // ── presence ─────────────────────────────────────────────────
1161
+ /**
1162
+ * The per-session monotonic LWW stamp: wall-seeded, guarded
1163
+ * `max(now, last + 1)`, so overlapping sends can never invert —
1164
+ * the wall clock seeds it because it is the only monotonic state
1165
+ * that survives a reload without A2 owning persistence.
1166
+ */
1167
+ const nextPresenceAt = (): number => {
1168
+ presenceLastAt = Math.max(Date.now(), presenceLastAt + 1)
1169
+ return presenceLastAt
1170
+ }
1171
+
1172
+ // Copy-on-write throughout: snapshots alias `presenceState`, so
1173
+ // every change replaces the map and the touched entry instead of
1174
+ // mutating them.
1175
+ const applyPresencePatch = (patch: PresencePatch): void => {
1176
+ if (patch.participant === participant) return // the echo is self truth
1177
+ const entry: PresenceMap[string] = Object.assign(
1178
+ nullProtoRecord<PresenceMap[string]>(),
1179
+ presenceState[patch.participant],
1180
+ )
1181
+ let changed = false
1182
+ for (const [field, value] of Object.entries(patch.values)) {
1183
+ if (value === undefined) continue
1184
+ const existing = entry[field]
1185
+ // Field-wise LWW: an older stamp loses, a tie goes to the wire.
1186
+ if (existing && existing.at.getTime() > patch.at.getTime()) continue
1187
+ if (value === null) {
1188
+ if (existing) {
1189
+ delete entry[field]
1190
+ changed = true
1191
+ }
1192
+ } else {
1193
+ entry[field] = { value, seen: patch.seen, at: patch.at }
1194
+ changed = true
1195
+ }
1196
+ }
1197
+ if (!changed) return
1198
+ const next = Object.assign(nullProtoRecord<PresenceMap>(), presenceState)
1199
+ if (Object.keys(entry).length === 0) delete next[patch.participant]
1200
+ else next[patch.participant] = entry
1201
+ presenceState = next
1202
+ notify()
1203
+ }
1204
+
1205
+ const applyPresenceSnapshot = (incoming: PresenceMap): void => {
1206
+ const next: PresenceMap = nullProtoRecord()
1207
+ for (const [id, fields] of Object.entries(incoming)) {
1208
+ if (id === participant) continue
1209
+ next[id] = fields
1210
+ }
1211
+ // Foreign entries replaced wholesale; the own echo survives.
1212
+ const own =
1213
+ participant === undefined ? undefined : presenceState[participant]
1214
+ if (participant !== undefined && own !== undefined)
1215
+ next[participant] = own
1216
+ presenceState = next
1217
+ notify()
1218
+ }
1219
+
1220
+ const echoPresence = (
1221
+ id: string,
1222
+ values: Record<string, unknown>,
1223
+ atMs: number,
1224
+ ): void => {
1225
+ const entry: PresenceMap[string] = Object.assign(
1226
+ nullProtoRecord<PresenceMap[string]>(),
1227
+ presenceState[id],
1228
+ )
1229
+ const at = new Date(atMs)
1230
+ for (const [field, value] of Object.entries(values)) {
1231
+ if (value === null) delete entry[field]
1232
+ else entry[field] = { value, seen: frontier, at }
1233
+ }
1234
+ const next = Object.assign(nullProtoRecord<PresenceMap>(), presenceState)
1235
+ if (Object.keys(entry).length === 0) delete next[id]
1236
+ else next[id] = entry
1237
+ presenceState = next
1238
+ notify()
1239
+ }
1240
+
1241
+ // One send, fire-and-forget: no retry, no rollback, failures
1242
+ // dropped silently — the next send repaints. Sends are NOT
1243
+ // serialized on the ack and may overlap in flight: safe, because
1244
+ // merges are LWW by `at` per (participant, field) and one
1245
+ // sender's stamps are monotonic. `seen` is the frontier at send
1246
+ // time, not at set time; `at` is the sender's monotonic stamp.
1247
+ const sendPresence = (
1248
+ values: Record<string, unknown>,
1249
+ at: number,
1250
+ ): void => {
1251
+ presenceLastSentAt = Date.now()
1252
+ transport.sendPresence({
1253
+ sessionId,
1254
+ // Every path here starts at setPresence, which requires the
1255
+ // participant.
1256
+ presence: { participant: participant!, values, seen: frontier, at },
1257
+ })
1258
+ touch(sessionId)
1259
+ }
1260
+
1261
+ // The fixed-cadence throttle: a set outside the interval sends on
1262
+ // the leading edge; within it, values merge field-wise into one
1263
+ // trailing send at the boundary. The cadence never couples to RTT
1264
+ // — waiting on the ack served no purpose on this plane and
1265
+ // stretched remote motion on slow links.
1266
+ const queuePresenceSend = (
1267
+ values: Record<string, unknown>,
1268
+ at?: number,
1269
+ ): void => {
1270
+ if (presenceTimer !== undefined) {
1271
+ presenceBuffer = { ...presenceBuffer, ...values }
1272
+ return
1273
+ }
1274
+ const wait =
1275
+ presenceLastSentAt + PRESENCE_TIMINGS.sendIntervalMs - Date.now()
1276
+ if (wait <= 0) {
1277
+ sendPresence(values, at ?? nextPresenceAt())
1278
+ return
1279
+ }
1280
+ presenceBuffer = { ...presenceBuffer, ...values }
1281
+ presenceTimer = setTimeout(() => {
1282
+ presenceTimer = undefined
1283
+ const buffered = presenceBuffer
1284
+ presenceBuffer = null
1285
+ // The trailing send stamps at fire time — always past every
1286
+ // stamp its merged values echoed with.
1287
+ if (buffered) sendPresence(buffered, nextPresenceAt())
1288
+ }, wait)
1289
+ ;(presenceTimer as { unref?: () => void }).unref?.()
1290
+ }
1291
+
1292
+ const setPresence = (values: PresencePatch<P>['values']): void => {
1293
+ if (participant === undefined) {
1294
+ throw new TypeError(
1295
+ 'setPresence requires a participant — pass one in the session options (the SessionProvider participant prop)',
1296
+ )
1297
+ }
1298
+ const validated: Record<string, unknown> = nullProtoRecord()
1299
+ for (const [field, value] of Object.entries(values)) {
1300
+ if (value === undefined) continue
1301
+ const schema = Object.hasOwn(presenceDefs, field)
1302
+ ? presenceDefs[field]
1303
+ : undefined
1304
+ if (!schema) {
1305
+ throw new A2Error(
1306
+ 'UNKNOWN_PRESENCE_FIELD',
1307
+ `no presence field '${field}' in the reducer's vocabulary`,
1308
+ )
1309
+ }
1310
+ if (value === null) {
1311
+ validated[field] = null
1312
+ continue
1313
+ }
1314
+ const result = validateSync(schema, value, `presence field '${field}'`)
1315
+ if (result.issues) {
1316
+ throw new A2Error(
1317
+ 'INVALID_PAYLOAD',
1318
+ `invalid value for presence field '${field}'`,
1319
+ { details: result.issues },
1320
+ )
1321
+ }
1322
+ validated[field] = result.value
1323
+ }
1324
+ if (Object.keys(validated).length === 0) return
1325
+ // One stamp per set: the echo and a leading send share it, so
1326
+ // the local map and the wire agree on this write's LWW position.
1327
+ const at = nextPresenceAt()
1328
+ echoPresence(participant, validated, at)
1329
+ if (status !== 'live') {
1330
+ presenceResend = { ...presenceResend, ...validated }
1331
+ }
1332
+ queuePresenceSend(validated, at)
1333
+ touch(sessionId)
1334
+ }
1335
+
1336
+ /** Pushes awaiting stream confirmation — resolved by `ingest` the
1337
+ * moment the frontier passes their batch. */
1338
+ let confirmWatchers: Array<{ index: number; resolve: () => void }> = []
1339
+
1340
+ /** The single ingest point: every server-confirmed event, in log
1341
+ * order, from the stream. */
1342
+ const ingest = (event: ContractEvent<D>): void => {
1343
+ if (event.index <= frontier) return
1344
+ frontier = event.index
1345
+ serverEvents.push(event)
1346
+ foldedState = reducer.fold(foldedState, event)
1347
+ pending = pending.filter((p) => p.id !== event.id)
1348
+ if (confirmWatchers.some((w) => w.index <= frontier)) {
1349
+ const due = confirmWatchers.filter((w) => w.index <= frontier)
1350
+ confirmWatchers = confirmWatchers.filter((w) => w.index > frontier)
1351
+ for (const watcher of due) watcher.resolve()
1352
+ }
1353
+ notify()
1354
+ touch(sessionId)
1355
+ }
1356
+
1357
+ // ── push ─────────────────────────────────────────────────────
1358
+ const validated = (
1359
+ events: AppendInput<D>[],
1360
+ ): Array<{ id: string; type: string; payload: unknown }> =>
1361
+ events.map((event) => {
1362
+ const schema = Object.hasOwn(reducer.events, event.type)
1363
+ ? reducer.events[event.type]
1364
+ : undefined
1365
+ if (!schema) {
1366
+ throw new A2Error(
1367
+ 'UNKNOWN_EVENT_TYPE',
1368
+ `no event type '${String(event.type)}' in the reducer's vocabulary`,
1369
+ )
1370
+ }
1371
+ const result = validateSync(
1372
+ schema,
1373
+ event.payload,
1374
+ `event '${String(event.type)}'`,
1375
+ )
1376
+ if (result.issues) {
1377
+ throw new A2Error(
1378
+ 'INVALID_PAYLOAD',
1379
+ `invalid payload for event '${String(event.type)}'`,
1380
+ { details: result.issues },
1381
+ )
1382
+ }
1383
+ return {
1384
+ id: event.id ?? crypto.randomUUID(),
1385
+ type: event.type,
1386
+ payload: result.value,
1387
+ }
1388
+ })
1389
+
1390
+ const post = async (
1391
+ body: TransportPushBody,
1392
+ ): Promise<ContractEvent<D>[]> => {
1393
+ let lastPushError: A2Error = new A2Error(
1394
+ 'STORE_UNAVAILABLE',
1395
+ 'push failed',
1396
+ )
1397
+ for (let attempt = 1; attempt <= PUSH_ATTEMPTS; attempt += 1) {
1398
+ try {
1399
+ const rows = await transport.push(body)
1400
+ return rows.map((row) => eventFromWire(row) as ContractEvent<D>)
1401
+ } catch (err) {
1402
+ lastPushError =
1403
+ err instanceof A2Error
1404
+ ? err
1405
+ : new A2Error('STORE_UNAVAILABLE', 'push request failed', {
1406
+ cause: err,
1407
+ })
1408
+ }
1409
+ // Only the documented retryable code retries; identical ids
1410
+ // make the retry idempotent on the server.
1411
+ if (lastPushError.code !== 'STORE_UNAVAILABLE') throw lastPushError
1412
+ if (attempt < PUSH_ATTEMPTS) {
1413
+ const sleepMs = 250 * attempt
1414
+ // Wait for the clock AND, when the transport can tell us,
1415
+ // the wire. The pacing sleep is a floor: a failure acked
1416
+ // over a healthy socket (a shedding store) must not re-fire
1417
+ // at wire speed. A push caught in a routine reconnect blip
1418
+ // additionally parks until the subscription is confirmed;
1419
+ // that cap scales with the reconnect backoff so an
1420
+ // unreachable server still exhausts attempts promptly.
1421
+ await Promise.all([
1422
+ new Promise((resolve) => setTimeout(resolve, sleepMs)),
1423
+ transport.awaitReady?.(
1424
+ sessionId,
1425
+ sleepMs + STREAM_TIMINGS.reconnectBaseMs * attempt,
1426
+ ),
1427
+ ])
1428
+ }
1429
+ }
1430
+ throw lastPushError
1431
+ }
1432
+
1433
+ /**
1434
+ * Decorate the ack promise into a PushResult. `confirmed` is a
1435
+ * lazy getter — materialized on first access, so callers that
1436
+ * ignore it can't leak an unhandled rejection.
1437
+ */
1438
+ const withConfirmed = (ack: Promise<ContractEvent<D>[]>): PushResult<D> => {
1439
+ let confirmed: Promise<ContractEvent<D>[]> | undefined
1440
+ return Object.defineProperty(ack as PushResult<D>, 'confirmed', {
1441
+ get(): Promise<ContractEvent<D>[]> {
1442
+ confirmed ??= ack.then(
1443
+ (acked) =>
1444
+ new Promise<ContractEvent<D>[]>((resolve) => {
1445
+ const last = acked.at(-1)?.index ?? 0
1446
+ if (frontier >= last) {
1447
+ resolve(acked)
1448
+ return
1449
+ }
1450
+ confirmWatchers.push({
1451
+ index: last,
1452
+ resolve: () => resolve(acked),
1453
+ })
1454
+ }),
1455
+ )
1456
+ return confirmed
1457
+ },
1458
+ })
1459
+ }
1460
+
1461
+ const push = (...events: AppendInput<D>[]): PushResult<D> => {
1462
+ touch(sessionId)
1463
+ return withConfirmed(
1464
+ (async () => {
1465
+ if (events.length === 0) {
1466
+ throw new TypeError('push requires at least one event')
1467
+ }
1468
+ const entries: Pending<D>[] = validated(events).map((e) => ({
1469
+ id: e.id,
1470
+ type: e.type,
1471
+ payload: e.payload,
1472
+ createdAt: new Date(),
1473
+ }))
1474
+ // Reserve before notifying: a subscriber may reenter push(),
1475
+ // and that work must follow the call whose state it observed.
1476
+ const waitForPriorPush = pushWireTail
1477
+ let releasePush!: () => void
1478
+ pushWireTail = new Promise<void>((resolve) => {
1479
+ releasePush = resolve
1480
+ })
1481
+ pending.push(...entries)
1482
+ inFlightPushes += 1
1483
+
1484
+ try {
1485
+ notify() // optimistic — the view updates before the network moves
1486
+ await waitForPriorPush
1487
+ const acked = await post({
1488
+ sessionId,
1489
+ events: entries.map(({ id, type, payload }) => ({
1490
+ id,
1491
+ type,
1492
+ payload,
1493
+ })),
1494
+ })
1495
+ for (const event of acked) {
1496
+ const entry = pending.find((p) => p.id === event.id)
1497
+ if (entry) entry.acked = event // stream retires it at its index
1498
+ }
1499
+ notify()
1500
+ return acked
1501
+ } catch (err) {
1502
+ // Rollback: refold without the rejected events.
1503
+ const ids = new Set(entries.map((e) => e.id))
1504
+ pending = pending.filter((p) => !ids.has(p.id))
1505
+ notify()
1506
+ throw err
1507
+ } finally {
1508
+ releasePush()
1509
+ inFlightPushes -= 1
1510
+ if (inFlightPushes === 0 && deferredHydration) {
1511
+ const next = deferredHydration
1512
+ deferredHydration = undefined
1513
+ runtime.hydrate(next)
1514
+ }
1515
+ touch(sessionId)
1516
+ }
1517
+ })(),
1518
+ )
1519
+ }
1520
+
1521
+ // ── backscroll ───────────────────────────────────────────────
1522
+ // Cold reads of the log below the frontier. Fetched events merge
1523
+ // into `serverEvents` only — never the fold, never the overlay.
1524
+ let historyLoads = 0
1525
+ /** Calls chain per session: each resolves its bounds after the
1526
+ * prior call's merge, so overlapping backscrolls page backward
1527
+ * instead of re-fetching the same range. */
1528
+ let historyTail: Promise<unknown> = Promise.resolve()
1529
+
1530
+ const eventsBetween = (gte: number, lte: number): ContractEvent<D>[] =>
1531
+ serverEvents.filter((event) => event.index >= gte && event.index <= lte)
1532
+
1533
+ /** The narrowest sub-range of [gte, lte] missing from the feed, or
1534
+ * null when the range is fully loaded (nothing to fetch). */
1535
+ const missingBetween = (
1536
+ gte: number,
1537
+ lte: number,
1538
+ ): { gte: number; lte: number } | null => {
1539
+ const present = new Set(eventsBetween(gte, lte).map((e) => e.index))
1540
+ let lo = gte
1541
+ while (lo <= lte && present.has(lo)) lo += 1
1542
+ if (lo > lte) return null
1543
+ let hi = lte
1544
+ while (hi > lo && present.has(hi)) hi -= 1
1545
+ return { gte: lo, lte: hi }
1546
+ }
1547
+
1548
+ const loadHistory = (
1549
+ loadOptions?: LoadHistoryOptions,
1550
+ ): Promise<ContractEvent<D>[]> => {
1551
+ const readHistory = transport.history
1552
+ if (readHistory === undefined) {
1553
+ throw new TypeError(
1554
+ 'loadHistory requires an http api — the ws transport has no history lane; serve the session over handle() and pass its route url as the string or { type: "http" } api',
1555
+ )
1556
+ }
1557
+ const before = loadOptions?.before
1558
+ const limit = loadOptions?.limit
1559
+ if (
1560
+ before !== undefined &&
1561
+ (!Number.isSafeInteger(before) || before < 1)
1562
+ ) {
1563
+ throw new TypeError(
1564
+ 'loadHistory before must be a positive safe integer',
1565
+ )
1566
+ }
1567
+ if (limit !== undefined && (!Number.isSafeInteger(limit) || limit < 1)) {
1568
+ throw new TypeError('loadHistory limit must be a positive safe integer')
1569
+ }
1570
+ touch(sessionId)
1571
+ const run = historyTail.then(async () => {
1572
+ const upper = before ?? serverEvents[0]?.index ?? frontier + 1
1573
+ // Strictly at or below the frontier: the live stream owns
1574
+ // everything above it, and the optimistic overlay must never
1575
+ // collide with a backscrolled row.
1576
+ const lte = Math.min(upper - 1, frontier)
1577
+ const gte = Math.max(1, upper - (limit ?? DEFAULT_HISTORY_LIMIT))
1578
+ if (gte > lte) return []
1579
+ const missing = missingBetween(gte, lte)
1580
+ if (missing === null) return eventsBetween(gte, lte)
1581
+ historyLoads += 1
1582
+ notify()
1583
+ try {
1584
+ const rows = await readHistory({ sessionId, ...missing })
1585
+ mergeServerEvents(
1586
+ rows.map((row) => eventFromWire(row) as ContractEvent<D>),
1587
+ frontier,
1588
+ )
1589
+ return eventsBetween(gte, lte)
1590
+ } finally {
1591
+ historyLoads -= 1
1592
+ notify()
1593
+ touch(sessionId)
1594
+ }
1595
+ })
1596
+ // Failures reject the caller, never the chain.
1597
+ historyTail = run.catch(() => {})
1598
+ return run
1599
+ }
1600
+
1601
+ // ── the live stream ──────────────────────────────────────────
1602
+ // A run-generation model instead of a terminal `closed` flag:
1603
+ // close() bumps the generation (the running loop notices and
1604
+ // exits), connect() starts a fresh one. Reentrant by design.
1605
+ let generation = 0
1606
+ let active = false
1607
+ let abort: AbortController | null = null
1608
+
1609
+ const runStream = async (run: number): Promise<void> => {
1610
+ let backoff = STREAM_TIMINGS.reconnectBaseMs
1611
+ // oxlint-disable no-await-in-loop -- sequential reconnect loop
1612
+ // oxlint-disable-next-line no-unmodified-loop-condition -- close() bumps `generation`
1613
+ while (generation === run) {
1614
+ const controller = new AbortController()
1615
+ abort = controller
1616
+ // The stall watchdog: the server heartbeats the stream, so a
1617
+ // healthy connection is never silent for long. Silence past the
1618
+ // timeout means the connection is dead in a way the socket
1619
+ // won't report (an offline browser's established SSE socket
1620
+ // neither errors nor receives) — abort it and let the normal
1621
+ // reconnect path run. This is what keeps `status: 'live'`
1622
+ // honest.
1623
+ let stall: ReturnType<typeof setTimeout> | undefined
1624
+ let stalled = false
1625
+ const armStall = (): void => {
1626
+ clearTimeout(stall)
1627
+ stall = setTimeout(() => {
1628
+ stalled = true
1629
+ controller.abort()
1630
+ }, STREAM_TIMINGS.stallTimeoutMs)
1631
+ ;(stall as { unref?: () => void }).unref?.()
1632
+ }
1633
+ let wasLive = false
1634
+ try {
1635
+ const frames = transport.connect({
1636
+ sessionId,
1637
+ startAfter: frontier,
1638
+ signal: controller.signal,
1639
+ })
1640
+ for await (const frame of frames) {
1641
+ if (generation !== run) break
1642
+ // Every frame counts — heartbeats included. Liveness is
1643
+ // about the connection, not about events.
1644
+ armStall()
1645
+ if (!wasLive) {
1646
+ // The first frame is the connection signal: the transport
1647
+ // yields it the moment the stream is established.
1648
+ backoff = STREAM_TIMINGS.reconnectBaseMs
1649
+ wasLive = true
1650
+ status = 'live'
1651
+ lastError = null
1652
+ notify()
1653
+ if (presenceResend) {
1654
+ const values = presenceResend
1655
+ presenceResend = null
1656
+ queuePresenceSend(values)
1657
+ }
1658
+ }
1659
+ if (frame.kind === 'event') {
1660
+ ingest(eventFromWire(frame.event) as ContractEvent<D>)
1661
+ } else if (frame.kind === 'presence') {
1662
+ applyPresencePatch(presencePatchFromWire(frame.patch))
1663
+ } else if (frame.kind === 'presence-snapshot') {
1664
+ applyPresenceSnapshot(
1665
+ presenceSnapshotFromWire(frame.snapshot).snapshot,
1666
+ )
1667
+ }
1668
+ }
1669
+ // The transport ended the stream cleanly — reconnect, but it
1670
+ // isn't an error.
1671
+ if (generation === run) {
1672
+ lastError = null
1673
+ }
1674
+ } catch (err) {
1675
+ if (generation === run) {
1676
+ lastError = stalled
1677
+ ? new Error(
1678
+ `stream stalled: no data for ${STREAM_TIMINGS.stallTimeoutMs}ms`,
1679
+ )
1680
+ : err instanceof Error
1681
+ ? err
1682
+ : new Error(String(err))
1683
+ }
1684
+ } finally {
1685
+ clearTimeout(stall)
1686
+ }
1687
+ if (generation !== run) break
1688
+ if (wasLive) reconnects += 1
1689
+ status = 'connecting'
1690
+ notify()
1691
+ await new Promise<void>((resolve) => {
1692
+ let settled = false
1693
+ const finish = (): void => {
1694
+ if (settled) return
1695
+ settled = true
1696
+ clearTimeout(timer)
1697
+ // oxlint-disable-next-line no-multiple-resolved -- guarded above
1698
+ resolve()
1699
+ }
1700
+ const timer = setTimeout(finish, backoff)
1701
+ ;(timer as { unref?: () => void }).unref?.()
1702
+ abort?.signal.addEventListener('abort', finish)
1703
+ })
1704
+ backoff = Math.min(backoff * 2, STREAM_TIMINGS.reconnectMaxMs)
1705
+ }
1706
+ // oxlint-enable no-await-in-loop
1707
+ }
1708
+
1709
+ const runtime: SessionRuntime<D, S, P> = {
1710
+ sessionId,
1711
+ // Only on presence-declaring contracts — the type surface
1712
+ // promises no runtime-inert members (a2-api.md §12).
1713
+ ...(declaresPresence ? { setPresence } : {}),
1714
+ subscribe(listener) {
1715
+ touch(sessionId)
1716
+ listeners.add(listener)
1717
+ return () => {
1718
+ listeners.delete(listener)
1719
+ touch(sessionId)
1720
+ }
1721
+ },
1722
+ getSnapshot() {
1723
+ snapshot ??= buildSnapshot()
1724
+ return snapshot
1725
+ },
1726
+ push,
1727
+ loadHistory,
1728
+ connect() {
1729
+ touch(sessionId)
1730
+ if (active) return
1731
+ active = true
1732
+ status = 'connecting'
1733
+ notify()
1734
+ void runStream(generation)
1735
+ },
1736
+ close() {
1737
+ touch(sessionId)
1738
+ if (!active) return
1739
+ active = false
1740
+ generation += 1 // the running loop notices and exits
1741
+ abort?.abort()
1742
+ abort = null
1743
+ // A pending trailing send dies with the stream — losing one is
1744
+ // fine by definition; sets made while closed accumulate in
1745
+ // `presenceResend` and repaint after the next connect.
1746
+ clearTimeout(presenceTimer)
1747
+ presenceTimer = undefined
1748
+ presenceBuffer = null
1749
+ status = 'closed'
1750
+ notify()
1751
+ },
1752
+ hydrate(next) {
1753
+ touch(sessionId)
1754
+ if (!next) return
1755
+ participant ??= next.participant
1756
+ const nextIndex = next.initialIndex ?? 0
1757
+ if (nextIndex > frontier && inFlightPushes > 0) {
1758
+ if (
1759
+ !deferredHydration ||
1760
+ nextIndex > (deferredHydration.initialIndex ?? 0)
1761
+ ) {
1762
+ deferredHydration = next
1763
+ }
1764
+ return
1765
+ }
1766
+
1767
+ const historyChanged = mergeServerEvents(
1768
+ next.initialEvents,
1769
+ Math.min(nextIndex, frontier),
1770
+ )
1771
+ if (next.initialState === undefined || nextIndex <= frontier) {
1772
+ if (historyChanged) notifyHydrated()
1773
+ return
1774
+ }
1775
+
1776
+ // The server fold subsumes everything observed through the old
1777
+ // frontier. Keep later optimistic work overlaid, retiring acked
1778
+ // entries the new frontier proves are already durable.
1779
+ frontier = nextIndex
1780
+ foldedState = next.initialState
1781
+ mergeServerEvents(next.initialEvents, frontier)
1782
+ pending = pending.filter(
1783
+ (entry) => !entry.acked || entry.acked.index > frontier,
1784
+ )
1785
+ if (confirmWatchers.some((watcher) => watcher.index <= frontier)) {
1786
+ const due = confirmWatchers.filter(
1787
+ (watcher) => watcher.index <= frontier,
1788
+ )
1789
+ confirmWatchers = confirmWatchers.filter(
1790
+ (watcher) => watcher.index > frontier,
1791
+ )
1792
+ for (const watcher of due) watcher.resolve()
1793
+ }
1794
+ notifyHydrated()
1795
+ },
1796
+ canEvict() {
1797
+ return (
1798
+ listeners.size === 0 &&
1799
+ !active &&
1800
+ inFlightPushes === 0 &&
1801
+ historyLoads === 0 &&
1802
+ confirmWatchers.length === 0 &&
1803
+ presenceTimer === undefined
1804
+ )
1805
+ },
1806
+ } as SessionRuntime<D, S, P>
1807
+
1808
+ return runtime
1809
+ }
1810
+
1811
+ return {
1812
+ session(sessionId, sessionOptions) {
1813
+ const existing = sessions.get(sessionId)
1814
+ if (existing) {
1815
+ existing.runtime.hydrate(sessionOptions)
1816
+ touch(sessionId)
1817
+ return existing.runtime
1818
+ }
1819
+
1820
+ const runtime = makeSession(sessionId, sessionOptions)
1821
+ sessions.set(sessionId, { runtime })
1822
+ touch(sessionId)
1823
+ return runtime
1824
+ },
1825
+ }
1826
+ }