experimental-a2 0.4.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 (182) hide show
  1. package/CHANGELOG.md +51 -0
  2. package/dist/{ai-B4YhEnfw.d.ts → ai-CFNeCrRl.d.ts} +3 -2
  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 +4 -3
  7. package/dist/ai-server.d.ts.map +1 -0
  8. package/dist/ai-server.js +4 -2
  9. package/dist/ai-server.js.map +1 -0
  10. package/dist/ai.d.ts +1 -1
  11. package/dist/ai.js +3 -1
  12. package/dist/ai.js.map +1 -0
  13. package/dist/cli-B3VuxoDe.js +2 -0
  14. package/dist/cli-B3VuxoDe.js.map +1 -0
  15. package/dist/cli-bin.js +2 -0
  16. package/dist/cli-bin.js.map +1 -0
  17. package/dist/cli.d.ts +2 -1
  18. package/dist/cli.d.ts.map +1 -0
  19. package/dist/{client-Bt4tAKi9.js → client-BKlyLiOU.js} +295 -85
  20. package/dist/client-BKlyLiOU.js.map +1 -0
  21. package/dist/{client-BrfDXQ8A.d.ts → client-D7mvIXrF.d.ts} +40 -4
  22. package/dist/client-D7mvIXrF.d.ts.map +1 -0
  23. package/dist/client.d.ts +2 -2
  24. package/dist/client.js +1 -1
  25. package/dist/contract-48bUMgcL.js +2 -0
  26. package/dist/contract-48bUMgcL.js.map +1 -0
  27. package/dist/contract-jIfaR085.d.ts +2 -1
  28. package/dist/contract-jIfaR085.d.ts.map +1 -0
  29. package/dist/devtools-J_jZ2vQf.d.ts +2 -1
  30. package/dist/devtools-J_jZ2vQf.d.ts.map +1 -0
  31. package/dist/devtools-kJJaORn-.js +2 -0
  32. package/dist/devtools-kJJaORn-.js.map +1 -0
  33. package/dist/devtools-server.browser.js +2 -0
  34. package/dist/devtools-server.browser.js.map +1 -0
  35. package/dist/devtools-server.d.ts +2 -1
  36. package/dist/devtools-server.d.ts.map +1 -0
  37. package/dist/devtools-server.js +2 -0
  38. package/dist/devtools-server.js.map +1 -0
  39. package/dist/errors-BQuJpe82.js +2 -0
  40. package/dist/errors-BQuJpe82.js.map +1 -0
  41. package/dist/errors-W6nwJ-fm.d.ts +2 -1
  42. package/dist/errors-W6nwJ-fm.d.ts.map +1 -0
  43. package/dist/http.d.ts +121 -72
  44. package/dist/http.d.ts.map +1 -0
  45. package/dist/http.js +503 -178
  46. package/dist/http.js.map +1 -0
  47. package/dist/idempotent-replay-DuqEkYA7.js +2 -0
  48. package/dist/idempotent-replay-DuqEkYA7.js.map +1 -0
  49. package/dist/index.d.ts +1 -1
  50. package/dist/inspection-DaxB5jM2.js +2 -0
  51. package/dist/inspection-DaxB5jM2.js.map +1 -0
  52. package/dist/{internal-aEotMzu_.js → internal-DstsI6Re.js} +3 -1
  53. package/dist/internal-DstsI6Re.js.map +1 -0
  54. package/dist/otel.d.ts +2 -1
  55. package/dist/otel.d.ts.map +1 -0
  56. package/dist/otel.js +2 -0
  57. package/dist/otel.js.map +1 -0
  58. package/dist/platform-B4TnJtWu.js +2 -0
  59. package/dist/platform-B4TnJtWu.js.map +1 -0
  60. package/dist/react.d.ts +12 -3
  61. package/dist/react.d.ts.map +1 -0
  62. package/dist/react.js +5 -1
  63. package/dist/react.js.map +1 -0
  64. package/dist/retryable-lazy-DZWmHpii.js +2 -0
  65. package/dist/retryable-lazy-DZWmHpii.js.map +1 -0
  66. package/dist/scheduler-qstash.d.ts +4 -3
  67. package/dist/scheduler-qstash.d.ts.map +1 -0
  68. package/dist/scheduler-qstash.js +4 -2
  69. package/dist/scheduler-qstash.js.map +1 -0
  70. package/dist/scheduler-task-BpzhPnRS.js +2 -0
  71. package/dist/scheduler-task-BpzhPnRS.js.map +1 -0
  72. package/dist/scheduler-vercel.d.ts +4 -3
  73. package/dist/scheduler-vercel.d.ts.map +1 -0
  74. package/dist/scheduler-vercel.js +4 -2
  75. package/dist/scheduler-vercel.js.map +1 -0
  76. package/dist/{server-CcNnFnoW.js → server-C72KOw51.js} +100 -48
  77. package/dist/server-C72KOw51.js.map +1 -0
  78. package/dist/{server-YtPq7hjw.d.ts → server-DUF9pjsx.d.ts} +5 -4
  79. package/dist/server-DUF9pjsx.d.ts.map +1 -0
  80. package/dist/server.browser.js +2 -0
  81. package/dist/server.browser.js.map +1 -0
  82. package/dist/server.d.ts +2 -2
  83. package/dist/server.js +1 -1
  84. package/dist/{store-C3sNAaBT.d.ts → store-DysUkTH3.d.ts} +10 -1
  85. package/dist/store-DysUkTH3.d.ts.map +1 -0
  86. package/dist/store-N8PXxDAS.js +2 -0
  87. package/dist/store-N8PXxDAS.js.map +1 -0
  88. package/dist/store-codec-DTG0Ftek.js +2 -0
  89. package/dist/store-codec-DTG0Ftek.js.map +1 -0
  90. package/dist/store-memory.d.ts +3 -2
  91. package/dist/store-memory.d.ts.map +1 -0
  92. package/dist/store-memory.js +19 -11
  93. package/dist/store-memory.js.map +1 -0
  94. package/dist/{store-polling-DgrrAE3d.js → store-polling-dSeLxzfb.js} +3 -1
  95. package/dist/store-polling-dSeLxzfb.js.map +1 -0
  96. package/dist/store-postgres.d.ts +3 -2
  97. package/dist/store-postgres.d.ts.map +1 -0
  98. package/dist/store-postgres.js +57 -1
  99. package/dist/store-postgres.js.map +1 -0
  100. package/dist/{store-redis-core-DWqx3F47.js → store-redis-core-BFLwz0Wj.js} +3 -1
  101. package/dist/store-redis-core-BFLwz0Wj.js.map +1 -0
  102. package/dist/store-redis-http.d.ts +3 -2
  103. package/dist/store-redis-http.d.ts.map +1 -0
  104. package/dist/store-redis-http.js +4 -2
  105. package/dist/store-redis-http.js.map +1 -0
  106. package/dist/store-redis.d.ts +3 -2
  107. package/dist/store-redis.d.ts.map +1 -0
  108. package/dist/store-redis.js +5 -3
  109. package/dist/store-redis.js.map +1 -0
  110. package/dist/store-sqlite.d.ts +3 -2
  111. package/dist/store-sqlite.d.ts.map +1 -0
  112. package/dist/store-sqlite.js +3 -1
  113. package/dist/store-sqlite.js.map +1 -0
  114. package/dist/telemetry-BjYHTfh2.d.ts +2 -1
  115. package/dist/telemetry-BjYHTfh2.d.ts.map +1 -0
  116. package/dist/testing.browser.js +2 -0
  117. package/dist/testing.browser.js.map +1 -0
  118. package/dist/testing.d.ts +2 -1
  119. package/dist/testing.d.ts.map +1 -0
  120. package/dist/testing.js +2 -0
  121. package/dist/testing.js.map +1 -0
  122. package/dist/validate-XKT4FSNn.js +2 -0
  123. package/dist/validate-XKT4FSNn.js.map +1 -0
  124. package/dist/{wire-DCUZBUlT.js → wire-BFQmSJ-9.js} +77 -15
  125. package/dist/wire-BFQmSJ-9.js.map +1 -0
  126. package/docs/guides/03-react.mdx +59 -39
  127. package/docs/guides/06-ai-agents.mdx +5 -27
  128. package/docs/guides/09-presence.mdx +19 -40
  129. package/docs/guides/10-transports.mdx +49 -40
  130. package/docs/reference/01-api.mdx +107 -26
  131. package/docs/reference/02-errors.mdx +4 -2
  132. package/package.json +2 -1
  133. package/src/ai-coordinator.ts +358 -0
  134. package/src/ai-projector.ts +524 -0
  135. package/src/ai-sdk-step.ts +261 -0
  136. package/src/ai-server.browser.ts +5 -0
  137. package/src/ai-server.ts +1719 -0
  138. package/src/ai.ts +2155 -0
  139. package/src/cache-indexeddb.ts +10 -0
  140. package/src/cli-bin.ts +5 -0
  141. package/src/cli.ts +1046 -0
  142. package/src/client.ts +1826 -0
  143. package/src/contract.ts +206 -0
  144. package/src/deterministic-id.ts +72 -0
  145. package/src/devtools-app.ts +989 -0
  146. package/src/devtools-server.browser.ts +5 -0
  147. package/src/devtools-server.ts +604 -0
  148. package/src/devtools.ts +716 -0
  149. package/src/errors.ts +50 -0
  150. package/src/http.ts +394 -0
  151. package/src/idempotent-replay.ts +53 -0
  152. package/src/index.ts +37 -0
  153. package/src/inspection.ts +39 -0
  154. package/src/internal.ts +426 -0
  155. package/src/otel.ts +59 -0
  156. package/src/platform.ts +60 -0
  157. package/src/push-envelope.ts +137 -0
  158. package/src/react.ts +284 -0
  159. package/src/reducer.ts +108 -0
  160. package/src/retryable-lazy.ts +27 -0
  161. package/src/scheduler-qstash.ts +915 -0
  162. package/src/scheduler-task.ts +106 -0
  163. package/src/scheduler-vercel.ts +437 -0
  164. package/src/server.browser.ts +12 -0
  165. package/src/server.ts +2700 -0
  166. package/src/session-socket.ts +548 -0
  167. package/src/sse.ts +141 -0
  168. package/src/standard-schema.ts +77 -0
  169. package/src/store-codec.ts +10 -0
  170. package/src/store-memory.ts +788 -0
  171. package/src/store-polling.ts +102 -0
  172. package/src/store-postgres.ts +1212 -0
  173. package/src/store-redis-core.ts +1494 -0
  174. package/src/store-redis-http.ts +116 -0
  175. package/src/store-redis.ts +458 -0
  176. package/src/store-sqlite.ts +1108 -0
  177. package/src/store.ts +385 -0
  178. package/src/telemetry.ts +47 -0
  179. package/src/testing.browser.ts +5 -0
  180. package/src/testing.ts +185 -0
  181. package/src/validate.ts +39 -0
  182. package/src/wire.ts +454 -0
package/src/wire.ts ADDED
@@ -0,0 +1,454 @@
1
+ /**
2
+ * The wire format shared by experimental-a2/http (server side) and experimental-a2/client
3
+ * (browser side): events as JSON with ISO timestamps, and the A2Error
4
+ * envelope. Internal module — both entry points re-export what's
5
+ * public.
6
+ */
7
+
8
+ import { A2Error, type A2ErrorCode } from './errors.ts'
9
+ import { nullProtoRecord } from './internal.ts'
10
+ import type { Event } from './store.ts'
11
+ import type {
12
+ PresenceMap,
13
+ PresencePatch,
14
+ PresenceSnapshot,
15
+ } from './contract.ts'
16
+
17
+ export type WireEvent = {
18
+ id: string
19
+ type: string
20
+ payload: unknown
21
+ index: number
22
+ sessionId: string
23
+ /** ISO 8601 — revived to a Date on the client so reducers see the
24
+ * same shape on both sides of the wire. */
25
+ createdAt: string
26
+ }
27
+
28
+ export function eventToWire(event: Event): WireEvent {
29
+ return {
30
+ id: event.id,
31
+ type: event.type,
32
+ payload: event.payload,
33
+ index: event.index,
34
+ sessionId: event.sessionId,
35
+ createdAt: event.createdAt.toISOString(),
36
+ }
37
+ }
38
+
39
+ export function eventFromWire(wire: WireEvent): Event {
40
+ return {
41
+ id: wire.id,
42
+ type: wire.type,
43
+ payload: wire.payload,
44
+ index: wire.index,
45
+ sessionId: wire.sessionId,
46
+ createdAt: new Date(wire.createdAt),
47
+ }
48
+ }
49
+
50
+ export function isWireEvent(value: unknown): value is WireEvent {
51
+ if (value === null || typeof value !== 'object') return false
52
+ const v = value as Record<string, unknown>
53
+ return (
54
+ typeof v['id'] === 'string' &&
55
+ typeof v['type'] === 'string' &&
56
+ typeof v['index'] === 'number' &&
57
+ typeof v['sessionId'] === 'string' &&
58
+ typeof v['createdAt'] === 'string'
59
+ )
60
+ }
61
+
62
+ // ── the presence wire pair ───────────────────────────────────────────
63
+
64
+ /** The `event: presence` SSE frame body: one patch as JSON. */
65
+ export type WirePresencePatch = {
66
+ participant: string
67
+ values: Record<string, unknown>
68
+ seen: number
69
+ /** ISO 8601 — revived to a Date on the client. */
70
+ at: string
71
+ }
72
+
73
+ /**
74
+ * The `event: presence-snapshot` SSE frame body: the in-memory
75
+ * `PresenceSnapshot` shape verbatim, with each per-field `at`
76
+ * serialized to ISO 8601.
77
+ */
78
+ export type WirePresenceSnapshot = {
79
+ snapshot: {
80
+ [participant: string]: {
81
+ [field: string]: { value: unknown; seen: number; at: string }
82
+ }
83
+ }
84
+ }
85
+
86
+ export function presencePatchToWire(patch: PresencePatch): WirePresencePatch {
87
+ return {
88
+ participant: patch.participant,
89
+ values: patch.values,
90
+ seen: patch.seen,
91
+ at: patch.at.toISOString(),
92
+ }
93
+ }
94
+
95
+ export function presencePatchFromWire(wire: WirePresencePatch): PresencePatch {
96
+ return {
97
+ participant: wire.participant,
98
+ values: wire.values,
99
+ seen: wire.seen,
100
+ at: new Date(wire.at),
101
+ }
102
+ }
103
+
104
+ // Participant and field keys come off the wire, so every object keyed
105
+ // by them is built null-prototype — see `nullProtoRecord`.
106
+ export function presenceSnapshotToWire(
107
+ snapshot: PresenceSnapshot,
108
+ ): WirePresenceSnapshot {
109
+ const wire: WirePresenceSnapshot['snapshot'] = nullProtoRecord()
110
+ for (const [participant, fields] of Object.entries(snapshot.snapshot)) {
111
+ const wireFields: WirePresenceSnapshot['snapshot'][string] =
112
+ nullProtoRecord()
113
+ for (const [field, entry] of Object.entries(fields)) {
114
+ if (entry === undefined) continue
115
+ wireFields[field] = {
116
+ value: entry.value,
117
+ seen: entry.seen,
118
+ at: entry.at.toISOString(),
119
+ }
120
+ }
121
+ wire[participant] = wireFields
122
+ }
123
+ return { snapshot: wire }
124
+ }
125
+
126
+ export function presenceSnapshotFromWire(
127
+ wire: WirePresenceSnapshot,
128
+ ): PresenceSnapshot {
129
+ const map: PresenceMap = nullProtoRecord()
130
+ for (const [participant, fields] of Object.entries(wire.snapshot)) {
131
+ const revived: PresenceMap[string] = nullProtoRecord()
132
+ for (const [field, entry] of Object.entries(fields)) {
133
+ revived[field] = {
134
+ value: entry.value,
135
+ seen: entry.seen,
136
+ at: new Date(entry.at),
137
+ }
138
+ }
139
+ map[participant] = revived
140
+ }
141
+ return { snapshot: map }
142
+ }
143
+
144
+ export function isWirePresencePatch(
145
+ value: unknown,
146
+ ): value is WirePresencePatch {
147
+ if (value === null || typeof value !== 'object') return false
148
+ const v = value as Record<string, unknown>
149
+ return (
150
+ typeof v['participant'] === 'string' &&
151
+ v['values'] !== null &&
152
+ typeof v['values'] === 'object' &&
153
+ !Array.isArray(v['values']) &&
154
+ typeof v['seen'] === 'number' &&
155
+ typeof v['at'] === 'string'
156
+ )
157
+ }
158
+
159
+ export function isWirePresenceSnapshot(
160
+ value: unknown,
161
+ ): value is WirePresenceSnapshot {
162
+ if (value === null || typeof value !== 'object') return false
163
+ const v = value as Record<string, unknown>
164
+ return (
165
+ v['snapshot'] !== null &&
166
+ typeof v['snapshot'] === 'object' &&
167
+ !Array.isArray(v['snapshot'])
168
+ )
169
+ }
170
+
171
+ // ── the A2Error envelope ─────────────────────────────────────────────
172
+
173
+ export type WireError = {
174
+ error: { code: A2ErrorCode; message: string; details?: unknown }
175
+ }
176
+
177
+ const ERROR_STATUS: Record<A2ErrorCode, number> = {
178
+ INVALID_PAYLOAD: 400,
179
+ UNKNOWN_EVENT_TYPE: 400,
180
+ PARTIAL_DUPLICATE_BATCH: 400,
181
+ SUPERSEDED_ATTEMPT: 409,
182
+ CLAIM_EXPIRED: 409,
183
+ STORE_UNAVAILABLE: 503,
184
+ STORE_NOT_CONFIGURED: 500,
185
+ UNKNOWN_PRESENCE_FIELD: 400,
186
+ PRESENCE_NOT_SUPPORTED: 500,
187
+ }
188
+
189
+ export function errorStatus(code: A2ErrorCode): number {
190
+ return ERROR_STATUS[code]
191
+ }
192
+
193
+ export function errorToWire(error: A2Error): WireError {
194
+ const body: WireError = {
195
+ error: { code: error.code, message: error.message },
196
+ }
197
+ if (error.details !== undefined) body.error.details = error.details
198
+ return body
199
+ }
200
+
201
+ /** Wrap an arbitrary thrown value for the wire: A2Errors pass through,
202
+ * anything else becomes STORE_UNAVAILABLE — from the client's
203
+ * perspective an unknown server failure is retryable-once, not a
204
+ * protocol contract. */
205
+ export function asA2Error(error: unknown): A2Error {
206
+ return error instanceof A2Error
207
+ ? error
208
+ : new A2Error('STORE_UNAVAILABLE', 'internal error', { cause: error })
209
+ }
210
+
211
+ /** Rebuild an A2Error from a wire body; null if the body isn't one. */
212
+ export function errorFromWire(body: unknown): A2Error | null {
213
+ if (body === null || typeof body !== 'object') return null
214
+ const err = (body as { error?: unknown }).error
215
+ if (err === null || typeof err !== 'object') return null
216
+ const { code, message, details } = err as Record<string, unknown>
217
+ if (typeof code !== 'string' || !Object.hasOwn(ERROR_STATUS, code))
218
+ return null
219
+ return new A2Error(code as A2ErrorCode, String(message ?? code), {
220
+ details,
221
+ })
222
+ }
223
+
224
+ // ── the ws frame layer ───────────────────────────────────────────────
225
+ // The SSE lanes reframed for a socket (specs/a2-api.md §13): every
226
+ // message is one JSON text frame, and a frame is its wire payload plus
227
+ // a `kind` discriminant (plus `req` where a reply must correlate).
228
+ // Unknown kinds are skipped by both sides — the same
229
+ // forward-compatibility rule as named SSE frames.
230
+ //
231
+ // The multiplexed superset rides the same frames: `subscribe` /
232
+ // `unsubscribe` up-frames open and close per-session lanes on one
233
+ // socket, `sessionId` tags route everything else. Single-session
234
+ // frames (no tags) keep parsing unchanged — a route opts into
235
+ // multiplexing by choosing the multi-session server handler, never by
236
+ // breaking the old protocol.
237
+
238
+ /**
239
+ * Server → client, parsed: a live-stream item, a push ack, a
240
+ * subscription lifecycle notice, or the heartbeat. `parseSocketFrame`
241
+ * yields these; frames of unknown kind (or ones failing their shape
242
+ * guard) come back `null`. `sessionId` is present on frames from a
243
+ * multiplexed socket and absent on a single-session one.
244
+ */
245
+ export type SocketDownFrame =
246
+ | { kind: 'event'; event: WireEvent }
247
+ | { kind: 'presence'; patch: WirePresencePatch; sessionId?: string }
248
+ | {
249
+ kind: 'presence-snapshot'
250
+ snapshot: WirePresenceSnapshot
251
+ sessionId?: string
252
+ }
253
+ | { kind: 'ack'; req: number; events: WireEvent[]; sessionId?: string }
254
+ | { kind: 'ack'; req: number; error: A2Error; sessionId?: string }
255
+ | { kind: 'subscribed'; sessionId: string }
256
+ | { kind: 'unsubscribed'; sessionId: string; reason?: string }
257
+ | { kind: 'ping' }
258
+
259
+ /**
260
+ * Client → server: the push envelope's two planes. `sessionId` is
261
+ * implied by the socket on a single-session connection and required by
262
+ * the multiplexed handler. Plain JSON on the wire; the server parses
263
+ * and validates them through the same seams as `parsePushBody`.
264
+ */
265
+ export type SocketPushFrame = {
266
+ kind: 'push'
267
+ /** Client-local ack correlator — opaque to the server. */
268
+ req: number
269
+ events: Array<{ type: string; payload: unknown; id?: string }>
270
+ sessionId?: string
271
+ }
272
+
273
+ export type SocketPresenceFrame = {
274
+ kind: 'presence'
275
+ participant: string
276
+ values: Record<string, unknown>
277
+ seen?: number
278
+ at?: number
279
+ sessionId?: string
280
+ }
281
+
282
+ /** Open per-session lanes on a multiplexed socket. `index` is each
283
+ * session's exclusive resume frontier — `stream({ startAfter })`. */
284
+ export type SocketSubscribeFrame = {
285
+ kind: 'subscribe'
286
+ sessions: Array<{ id: string; index: number }>
287
+ }
288
+
289
+ export type SocketUnsubscribeFrame = {
290
+ kind: 'unsubscribe'
291
+ sessions: string[]
292
+ }
293
+
294
+ export type SocketUpFrame =
295
+ | SocketPushFrame
296
+ | SocketPresenceFrame
297
+ | SocketSubscribeFrame
298
+ | SocketUnsubscribeFrame
299
+
300
+ export const SOCKET_PING_FRAME: string = JSON.stringify({ kind: 'ping' })
301
+
302
+ /** One stream item as a socket frame — `sseResponse`'s framing over
303
+ * the same codecs, with `kind` instead of an SSE event name. On a
304
+ * multiplexed socket presence frames carry the `sessionId` tag; events
305
+ * already carry theirs in the wire event. */
306
+ export function socketFrameFor(
307
+ item: Event | PresencePatch | PresenceSnapshot,
308
+ sessionId?: string,
309
+ ): string {
310
+ const tag = sessionId === undefined ? {} : { sessionId }
311
+ if ('snapshot' in item) {
312
+ return JSON.stringify({
313
+ kind: 'presence-snapshot',
314
+ ...presenceSnapshotToWire(item),
315
+ ...tag,
316
+ })
317
+ }
318
+ if ('participant' in item) {
319
+ return JSON.stringify({
320
+ kind: 'presence',
321
+ ...presencePatchToWire(item),
322
+ ...tag,
323
+ })
324
+ }
325
+ return JSON.stringify({ kind: 'event', ...eventToWire(item) })
326
+ }
327
+
328
+ export function socketAckFor(
329
+ req: number,
330
+ events: Event[],
331
+ sessionId?: string,
332
+ ): string {
333
+ return JSON.stringify({
334
+ kind: 'ack',
335
+ req,
336
+ events: events.map(eventToWire),
337
+ ...(sessionId === undefined ? {} : { sessionId }),
338
+ })
339
+ }
340
+
341
+ export function socketErrorAckFor(
342
+ req: number,
343
+ error: A2Error,
344
+ sessionId?: string,
345
+ ): string {
346
+ return JSON.stringify({
347
+ kind: 'ack',
348
+ req,
349
+ ...errorToWire(error),
350
+ ...(sessionId === undefined ? {} : { sessionId }),
351
+ })
352
+ }
353
+
354
+ export function socketSubscribedFor(sessionId: string): string {
355
+ return JSON.stringify({ kind: 'subscribed', sessionId })
356
+ }
357
+
358
+ /** The subscription-over notice: with a `reason` the server rejected
359
+ * or lost the session's stream, without one it ended cleanly. */
360
+ export function socketUnsubscribedFor(
361
+ sessionId: string,
362
+ reason?: string,
363
+ ): string {
364
+ return JSON.stringify({
365
+ kind: 'unsubscribed',
366
+ sessionId,
367
+ ...(reason === undefined ? {} : { reason }),
368
+ })
369
+ }
370
+
371
+ export function parseSocketFrame(data: string): SocketDownFrame | null {
372
+ let parsed: unknown
373
+ try {
374
+ parsed = JSON.parse(data)
375
+ } catch {
376
+ return null
377
+ }
378
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
379
+ return null
380
+ }
381
+ const frame = parsed as Record<string, unknown>
382
+ switch (frame['kind']) {
383
+ case 'ping':
384
+ return { kind: 'ping' }
385
+ case 'event': {
386
+ if (!isWireEvent(parsed)) return null
387
+ const { id, type, payload, index, sessionId, createdAt } = parsed
388
+ return {
389
+ kind: 'event',
390
+ event: { id, type, payload, index, sessionId, createdAt },
391
+ }
392
+ }
393
+ case 'presence': {
394
+ if (!isWirePresencePatch(parsed)) return null
395
+ const { participant, values, seen, at } = parsed
396
+ return {
397
+ kind: 'presence',
398
+ patch: { participant, values, seen, at },
399
+ ...sessionTag(frame),
400
+ }
401
+ }
402
+ case 'presence-snapshot': {
403
+ if (!isWirePresenceSnapshot(parsed)) return null
404
+ return {
405
+ kind: 'presence-snapshot',
406
+ snapshot: { snapshot: parsed.snapshot },
407
+ ...sessionTag(frame),
408
+ }
409
+ }
410
+ case 'subscribed': {
411
+ const sessionId = frame['sessionId']
412
+ if (typeof sessionId !== 'string') return null
413
+ return { kind: 'subscribed', sessionId }
414
+ }
415
+ case 'unsubscribed': {
416
+ const sessionId = frame['sessionId']
417
+ if (typeof sessionId !== 'string') return null
418
+ const reason = frame['reason']
419
+ return typeof reason === 'string'
420
+ ? { kind: 'unsubscribed', sessionId, reason }
421
+ : { kind: 'unsubscribed', sessionId }
422
+ }
423
+ case 'ack': {
424
+ const req = frame['req']
425
+ if (typeof req !== 'number') return null
426
+ const tag = sessionTag(frame)
427
+ const events = frame['events']
428
+ if (Array.isArray(events) && events.every(isWireEvent)) {
429
+ return { kind: 'ack', req, events, ...tag }
430
+ }
431
+ const error = errorFromWire(parsed)
432
+ if (error) return { kind: 'ack', req, error, ...tag }
433
+ // A correlatable ack must never be dropped: its waiter would hang
434
+ // forever behind a healthy socket (pings keep the watchdog fed).
435
+ // An outcome this client cannot interpret — an error code from a
436
+ // newer server, an event shape that fails a guard — degrades to
437
+ // a lost ack: retryable, and the client-generated ids make the
438
+ // retry idempotent even if the append actually committed.
439
+ return {
440
+ kind: 'ack',
441
+ req,
442
+ error: new A2Error('STORE_UNAVAILABLE', 'unintelligible ack'),
443
+ ...tag,
444
+ }
445
+ }
446
+ default:
447
+ return null
448
+ }
449
+ }
450
+
451
+ const sessionTag = (frame: Record<string, unknown>): { sessionId?: string } => {
452
+ const sessionId = frame['sessionId']
453
+ return typeof sessionId === 'string' ? { sessionId } : {}
454
+ }