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
@@ -0,0 +1,548 @@
1
+ /**
2
+ * The A2 socket protocol's server half (specs/a2-api.md §13): the
3
+ * per-socket shell — teardown, backpressure, heartbeat, the malformed
4
+ * budget, the in-flight bound — and `sessionsSocket`, the multiplexed
5
+ * frame handler riding it. Internal module: `handle`'s upgrade lane is
6
+ * the sanctioned mount; only `A2Socket` (the structural socket the
7
+ * platform hands over) is re-exported as public surface.
8
+ */
9
+
10
+ import { A2Error } from './errors.ts'
11
+ import { SOCKET_TIMINGS, STREAM_TIMINGS } from './internal.ts'
12
+ import type { Event } from './store.ts'
13
+ import type { EventDefs, PresencePatch, PresenceSnapshot } from './contract.ts'
14
+ import type { PushedEvent, PushedPresence, Session } from './server.ts'
15
+ import { parsePresenceSibling, parsePushEvents } from './push-envelope.ts'
16
+ import {
17
+ SOCKET_PING_FRAME,
18
+ asA2Error,
19
+ socketAckFor,
20
+ socketErrorAckFor,
21
+ socketFrameFor,
22
+ socketSubscribedFor,
23
+ socketUnsubscribedFor,
24
+ } from './wire.ts'
25
+
26
+ // ── the ws wire ───────────────────────────────────────────────────────
27
+
28
+ /**
29
+ * The structural socket `sessionsSocket` speaks against — satisfied
30
+ * by `ws` and by `@vercel/functions`' upgraded socket alike, so the
31
+ * platform upgrade API stays out of the library. `message` data
32
+ * arrives as whatever the platform delivers (string, Buffer,
33
+ * ArrayBuffer, or fragments); the handler normalizes defensively.
34
+ */
35
+ export type A2Socket = {
36
+ send(data: string): void
37
+ on(event: 'message', listener: (data: unknown) => void): void
38
+ on(event: 'close', listener: () => void): void
39
+ on(event: 'error', listener: (error: unknown) => void): void
40
+ close(code?: number, reason?: string): void
41
+ /** Bytes queued but not yet transmitted — when exposed, the down
42
+ * pump uses it for backpressure (`SOCKET_TIMINGS`). */
43
+ bufferedAmount?: number
44
+ }
45
+
46
+ const messageText = (data: unknown): string | null => {
47
+ if (typeof data === 'string') return data
48
+ if (data instanceof ArrayBuffer) return new TextDecoder().decode(data)
49
+ if (ArrayBuffer.isView(data)) return new TextDecoder().decode(data)
50
+ if (Array.isArray(data) && data.every(ArrayBuffer.isView)) {
51
+ // ws delivers fragmented messages as a Buffer array; stream-decode
52
+ // so multi-byte characters can span fragment boundaries.
53
+ const decoder = new TextDecoder()
54
+ let text = ''
55
+ for (const part of data) text += decoder.decode(part, { stream: true })
56
+ return text + decoder.decode()
57
+ }
58
+ return null
59
+ }
60
+
61
+ /**
62
+ * How many unparseable or invalid frames a socket may send before it
63
+ * is closed (1008): the budget absorbs isolated client bugs without
64
+ * letting a broken peer spin the validation path forever. Push frames
65
+ * with a usable `req` never count — their failures are answered with
66
+ * error acks, exactly like the POST path's 4xx bodies.
67
+ */
68
+ const MAX_MALFORMED_FRAMES = 5
69
+
70
+ /**
71
+ * The pump's runtime view of a session. The presence members are
72
+ * conditional on the public surface (`WithPresence`) and therefore
73
+ * invisible on a generic `Session<D>`, so capability is checked at
74
+ * runtime (`setPresence` present) — the same resolution as the docs'
75
+ * route sample. The casts live in `sessionsSocket` and `handle`.
76
+ */
77
+ export type SocketSession = {
78
+ stream(opts: {
79
+ startAfter: number
80
+ presence?: boolean
81
+ }): AsyncIterable<Event | PresencePatch | PresenceSnapshot>
82
+ append(...events: PushedEvent[]): Promise<Event[]>
83
+ setPresence?(patch: PushedPresence): Promise<void>
84
+ }
85
+
86
+ /**
87
+ * The per-socket machinery `sessionsSocket` builds on: the torn flag
88
+ * with a release-once teardown, the backpressure-aware send with its
89
+ * non-reader ceiling, the malformed budget, the in-flight bound, the
90
+ * message normalization, the heartbeat, and the optional deadline.
91
+ */
92
+ type SocketShell = {
93
+ torn(): boolean
94
+ send(data: string): void
95
+ shutdown(code: number, reason?: string): void
96
+ malformedFrame(reason: string): void
97
+ buffered(): number
98
+ overCap(): boolean
99
+ track(work: Promise<void>): void
100
+ }
101
+
102
+ function socketShell(
103
+ socket: A2Socket,
104
+ release: () => void,
105
+ onFrame: (frame: Record<string, unknown>) => void,
106
+ deadlineAt: number | undefined,
107
+ ): SocketShell {
108
+ let torn = false
109
+ let malformed = 0
110
+ let heartbeat: ReturnType<typeof setInterval> | undefined
111
+ let deadline: ReturnType<typeof setTimeout> | undefined
112
+
113
+ const teardown = (): void => {
114
+ if (torn) return
115
+ torn = true
116
+ if (heartbeat !== undefined) clearInterval(heartbeat)
117
+ if (deadline !== undefined) clearTimeout(deadline)
118
+ release()
119
+ }
120
+
121
+ const shutdown = (code: number, reason?: string): void => {
122
+ if (torn) return
123
+ teardown()
124
+ try {
125
+ socket.close(code, reason)
126
+ } catch {
127
+ // Already closed underneath us — teardown has run either way.
128
+ }
129
+ }
130
+
131
+ const send = (data: string): void => {
132
+ if (torn) return
133
+ // The pump parks at the high-water mark, but acks and pings are
134
+ // exempt (small, correctness-relevant) — so a peer that never
135
+ // reads while it keeps uploading grows the buffer through its own
136
+ // acks. The ceiling closes the class: past it, the peer is not a
137
+ // slow reader, it is a non-reader (adversarial F4).
138
+ if (
139
+ typeof socket.bufferedAmount === 'number' &&
140
+ socket.bufferedAmount > SOCKET_TIMINGS.disconnectWaterMarkBytes
141
+ ) {
142
+ shutdown(1008, 'backpressure: peer not reading')
143
+ return
144
+ }
145
+ try {
146
+ socket.send(data)
147
+ } catch {
148
+ // The platform closed the socket without an event; closing it
149
+ // again would throw the same way, so just tear down.
150
+ teardown()
151
+ }
152
+ }
153
+
154
+ const malformedFrame = (reason: string): void => {
155
+ malformed += 1
156
+ if (malformed >= MAX_MALFORMED_FRAMES) {
157
+ shutdown(1008, `too many malformed frames (last: ${reason})`)
158
+ }
159
+ }
160
+
161
+ // The up-lane bound (adversarial F3): one socket must not hold
162
+ // unbounded concurrent appends. Shedding is per-plane semantics —
163
+ // pushes are TOLD (retryable error ack, ids make the retry safe),
164
+ // presence is silently dropped (a lost update is repainted by the
165
+ // next one). Sheds are our load decision, never the peer's
166
+ // malformedness, so they spend no budget.
167
+ let inFlight = 0
168
+ const track = (work: Promise<void>): void => {
169
+ inFlight += 1
170
+ void work.finally(() => {
171
+ inFlight -= 1
172
+ })
173
+ }
174
+
175
+ const handleMessage = (data: unknown): void => {
176
+ if (torn) return
177
+ const text = messageText(data)
178
+ if (text === null) {
179
+ malformedFrame('non-text frame')
180
+ return
181
+ }
182
+ let parsed: unknown
183
+ try {
184
+ parsed = JSON.parse(text)
185
+ } catch {
186
+ malformedFrame('frame is not valid JSON')
187
+ return
188
+ }
189
+ if (
190
+ parsed === null ||
191
+ typeof parsed !== 'object' ||
192
+ Array.isArray(parsed)
193
+ ) {
194
+ malformedFrame('frame is not an object')
195
+ return
196
+ }
197
+ onFrame(parsed as Record<string, unknown>)
198
+ }
199
+
200
+ socket.on('message', handleMessage)
201
+ socket.on('close', teardown)
202
+ socket.on('error', teardown)
203
+
204
+ heartbeat = setInterval(
205
+ () => send(SOCKET_PING_FRAME),
206
+ STREAM_TIMINGS.sseHeartbeatMs,
207
+ )
208
+ ;(heartbeat as { unref?: () => void }).unref?.()
209
+
210
+ if (deadlineAt !== undefined) {
211
+ deadline = setTimeout(
212
+ () => shutdown(1000, 'deadline'),
213
+ Math.max(0, deadlineAt - Date.now()),
214
+ )
215
+ ;(deadline as { unref?: () => void }).unref?.()
216
+ }
217
+
218
+ return {
219
+ torn: () => torn,
220
+ send,
221
+ shutdown,
222
+ malformedFrame,
223
+ buffered: () =>
224
+ typeof socket.bufferedAmount === 'number' ? socket.bufferedAmount : 0,
225
+ overCap: () => inFlight >= SOCKET_TIMINGS.maxInFlightMessages,
226
+ track,
227
+ }
228
+ }
229
+
230
+ /**
231
+ * The per-session authorization seam of `sessionsSocket`: called once
232
+ * per `subscribe` entry (and never for the pushes and presence that
233
+ * ride an accepted subscription). `null` rejects that session — the
234
+ * socket answers `unsubscribed` with a rejection reason and every
235
+ * other session on it is untouched.
236
+ */
237
+ export type SessionsSocketResolve<D extends EventDefs> = (
238
+ sessionId: string,
239
+ /** The entry's resume frontier — `stream({ startAfter })`. */
240
+ startAfter: number,
241
+ ) => Session<D> | null | Promise<Session<D> | null>
242
+
243
+ export type SessionsSocketOptions = {
244
+ /** The `stream()` presence opt-in for every session on the socket —
245
+ * the server decides, same rule as the SSE route. */
246
+ presence?: boolean
247
+ /** Epoch ms: close cleanly (code 1000) at this time, ahead of a known
248
+ * platform deadline, so clients reconnect on our schedule. */
249
+ deadline?: number
250
+ /**
251
+ * Per-frame push gate — policy's seam on the up lane, the socket
252
+ * counterpart of a POST route's checks (`handle`'s `before` rides
253
+ * it). Runs after the frame parses and before `append`: return an
254
+ * `A2Error` to answer the push with that error ack (nothing
255
+ * appended), `null` to allow. Absent, every push on an accepted
256
+ * subscription is allowed — the prior behavior, unchanged.
257
+ */
258
+ gatePush?(
259
+ sessionId: string,
260
+ events: PushedEvent[],
261
+ ): A2Error | null | Promise<A2Error | null>
262
+ }
263
+
264
+ type SocketSubscription = {
265
+ target: SocketSession
266
+ iterator: AsyncIterator<Event | PresencePatch | PresenceSnapshot>
267
+ stopped: boolean
268
+ }
269
+
270
+ const stopSubscription = (subscription: SocketSubscription): void => {
271
+ subscription.stopped = true
272
+ void subscription.iterator.return?.()?.catch(() => {})
273
+ }
274
+
275
+ const parseSubscribeEntries = (
276
+ value: unknown,
277
+ ): Array<{ id: string; index: number }> | null => {
278
+ if (!Array.isArray(value) || value.length === 0) return null
279
+ const entries: Array<{ id: string; index: number }> = []
280
+ for (const entry of value) {
281
+ if (entry === null || typeof entry !== 'object') return null
282
+ const { id, index } = entry as Record<string, unknown>
283
+ if (typeof id !== 'string' || id.length === 0) return null
284
+ if (typeof index !== 'number' || !Number.isInteger(index) || index < 0) {
285
+ return null
286
+ }
287
+ entries.push({ id, index })
288
+ }
289
+ return entries
290
+ }
291
+
292
+ /**
293
+ * Speak the multiplexed A2 socket protocol (specs/a2-api.md §13)
294
+ * against many sessions on one socket. `subscribe` frames open a
295
+ * per-session pump at that session's own resume frontier; push and
296
+ * presence frames route by `sessionId` through the same validation
297
+ * seams as the POST lane; every down frame carries the `sessionId` it
298
+ * belongs to. One heartbeat, one
299
+ * malformed budget, one in-flight bound, one backpressure gauge — per
300
+ * socket, shared by all sessions. A session's stream ending or failing
301
+ * answers `unsubscribed` for that session; only the peer, the
302
+ * deadline, the malformed budget, or backpressure closes the socket.
303
+ */
304
+ export function sessionsSocket<D extends EventDefs>(
305
+ resolve: SessionsSocketResolve<D>,
306
+ socket: A2Socket,
307
+ options?: SessionsSocketOptions,
308
+ ): void {
309
+ const presence = options?.presence === true
310
+ const subscriptions = new Map<string, SocketSubscription>()
311
+
312
+ const retire = (id: string, subscription: SocketSubscription): void => {
313
+ if (subscriptions.get(id) === subscription) subscriptions.delete(id)
314
+ }
315
+
316
+ const pump = async (
317
+ id: string,
318
+ subscription: SocketSubscription,
319
+ ): Promise<void> => {
320
+ for (;;) {
321
+ for (;;) {
322
+ if (
323
+ shell.torn() ||
324
+ subscription.stopped ||
325
+ shell.buffered() <= SOCKET_TIMINGS.highWaterMarkBytes
326
+ )
327
+ break
328
+ // oxlint-disable-next-line no-await-in-loop -- backpressure park
329
+ await new Promise((wake) =>
330
+ setTimeout(wake, SOCKET_TIMINGS.resumePollMs),
331
+ )
332
+ }
333
+ if (shell.torn() || subscription.stopped) return
334
+ // oxlint-disable-next-line no-await-in-loop -- stream pump
335
+ const { value, done } = await subscription.iterator.next()
336
+ if (shell.torn() || subscription.stopped) return
337
+ if (done) return
338
+ shell.send(socketFrameFor(value, id))
339
+ }
340
+ }
341
+
342
+ const runSubscription = async (
343
+ id: string,
344
+ subscription: SocketSubscription,
345
+ ): Promise<void> => {
346
+ try {
347
+ await pump(id, subscription)
348
+ if (shell.torn() || subscription.stopped) return
349
+ retire(id, subscription)
350
+ shell.send(socketUnsubscribedFor(id))
351
+ } catch {
352
+ if (shell.torn() || subscription.stopped) return
353
+ retire(id, subscription)
354
+ shell.send(socketUnsubscribedFor(id, 'stream failed'))
355
+ }
356
+ }
357
+
358
+ const startSubscription = async (
359
+ id: string,
360
+ index: number,
361
+ ): Promise<void> => {
362
+ let session: Session<D> | null
363
+ try {
364
+ session = await resolve(id, index)
365
+ } catch {
366
+ session = null
367
+ }
368
+ if (shell.torn()) return
369
+ if (session === null) {
370
+ shell.send(socketUnsubscribedFor(id, 'subscribe rejected'))
371
+ return
372
+ }
373
+ const target = session as unknown as SocketSession
374
+ const existing = subscriptions.get(id)
375
+ if (existing) stopSubscription(existing)
376
+ const iterator = (
377
+ presence
378
+ ? target.stream({ startAfter: index, presence: true })
379
+ : target.stream({ startAfter: index })
380
+ )[Symbol.asyncIterator]()
381
+ const subscription: SocketSubscription = {
382
+ target,
383
+ iterator,
384
+ stopped: false,
385
+ }
386
+ subscriptions.set(id, subscription)
387
+ shell.send(socketSubscribedFor(id))
388
+ void runSubscription(id, subscription)
389
+ }
390
+
391
+ // Control frames (subscribe/unsubscribe) run in arrival order — an
392
+ // unsubscribe can never overtake the subscribe it targets while the
393
+ // resolve hook is still pending.
394
+ let controlTail: Promise<void> = Promise.resolve()
395
+ const control = (work: () => Promise<void> | void): void => {
396
+ controlTail = controlTail.then(work).catch(() => {})
397
+ }
398
+
399
+ const handlePush = async (
400
+ frame: Record<string, unknown>,
401
+ req: number,
402
+ sessionId: string,
403
+ ): Promise<void> => {
404
+ const subscription = subscriptions.get(sessionId)
405
+ if (subscription === undefined) {
406
+ // Retryable: the client re-subscribes on reconnect and the retry
407
+ // rides the same client-generated event ids.
408
+ shell.send(
409
+ socketErrorAckFor(
410
+ req,
411
+ new A2Error(
412
+ 'STORE_UNAVAILABLE',
413
+ 'session is not subscribed on this socket',
414
+ ),
415
+ sessionId,
416
+ ),
417
+ )
418
+ return
419
+ }
420
+ try {
421
+ const events = parsePushEvents(frame['events'])
422
+ const denial = (await options?.gatePush?.(sessionId, events)) ?? null
423
+ if (denial !== null) {
424
+ shell.send(socketErrorAckFor(req, denial, sessionId))
425
+ return
426
+ }
427
+ const appended = await subscription.target.append(...events)
428
+ shell.send(socketAckFor(req, appended, sessionId))
429
+ } catch (err) {
430
+ shell.send(socketErrorAckFor(req, asA2Error(err), sessionId))
431
+ }
432
+ }
433
+
434
+ const handlePresence = async (
435
+ frame: Record<string, unknown>,
436
+ sessionId: string,
437
+ ): Promise<void> => {
438
+ const subscription = subscriptions.get(sessionId)
439
+ // An unsubscribe race, not malformedness — the plane repaints.
440
+ if (subscription === undefined) return
441
+ if (typeof subscription.target.setPresence !== 'function') {
442
+ shell.malformedFrame('presence frame on a presence-less session')
443
+ return
444
+ }
445
+ try {
446
+ const patch = parsePresenceSibling(frame)
447
+ if (patch) await subscription.target.setPresence(patch)
448
+ } catch {
449
+ shell.malformedFrame('invalid presence frame')
450
+ }
451
+ }
452
+
453
+ const handleFrame = (frame: Record<string, unknown>): void => {
454
+ switch (frame['kind']) {
455
+ case 'subscribe': {
456
+ const entries = parseSubscribeEntries(frame['sessions'])
457
+ if (entries === null) {
458
+ shell.malformedFrame('malformed subscribe frame')
459
+ return
460
+ }
461
+ for (const entry of entries) {
462
+ control(() => startSubscription(entry.id, entry.index))
463
+ }
464
+ return
465
+ }
466
+ case 'unsubscribe': {
467
+ const ids = frame['sessions']
468
+ if (
469
+ !Array.isArray(ids) ||
470
+ ids.length === 0 ||
471
+ !ids.every((id) => typeof id === 'string')
472
+ ) {
473
+ shell.malformedFrame('malformed unsubscribe frame')
474
+ return
475
+ }
476
+ for (const id of ids as string[]) {
477
+ control(() => {
478
+ const subscription = subscriptions.get(id)
479
+ if (subscription === undefined) return
480
+ stopSubscription(subscription)
481
+ subscriptions.delete(id)
482
+ })
483
+ }
484
+ return
485
+ }
486
+ case 'push': {
487
+ const req = frame['req']
488
+ const sessionId = frame['sessionId']
489
+ if (shell.overCap()) {
490
+ if (typeof req === 'number') {
491
+ shell.send(
492
+ socketErrorAckFor(
493
+ req,
494
+ new A2Error(
495
+ 'STORE_UNAVAILABLE',
496
+ 'push shed: too many in flight on this socket',
497
+ ),
498
+ typeof sessionId === 'string' ? sessionId : undefined,
499
+ ),
500
+ )
501
+ } else {
502
+ shell.malformedFrame('push frame without a numeric req')
503
+ }
504
+ return
505
+ }
506
+ if (typeof req !== 'number') {
507
+ shell.malformedFrame('push frame without a numeric req')
508
+ return
509
+ }
510
+ if (typeof sessionId !== 'string' || sessionId.length === 0) {
511
+ shell.send(
512
+ socketErrorAckFor(
513
+ req,
514
+ new A2Error('INVALID_PAYLOAD', 'push frame without a sessionId'),
515
+ ),
516
+ )
517
+ return
518
+ }
519
+ shell.track(handlePush(frame, req, sessionId))
520
+ return
521
+ }
522
+ case 'presence': {
523
+ if (shell.overCap()) return
524
+ const sessionId = frame['sessionId']
525
+ if (typeof sessionId !== 'string' || sessionId.length === 0) {
526
+ shell.malformedFrame('presence frame without a sessionId')
527
+ return
528
+ }
529
+ shell.track(handlePresence(frame, sessionId))
530
+ return
531
+ }
532
+ default:
533
+ return // unknown kinds are skipped — the forward-compatibility rule
534
+ }
535
+ }
536
+
537
+ const shell = socketShell(
538
+ socket,
539
+ () => {
540
+ for (const subscription of subscriptions.values()) {
541
+ stopSubscription(subscription)
542
+ }
543
+ subscriptions.clear()
544
+ },
545
+ (frame) => handleFrame(frame),
546
+ options?.deadline,
547
+ )
548
+ }
package/src/sse.ts ADDED
@@ -0,0 +1,141 @@
1
+ /**
2
+ * The SSE response protocol — framing, heartbeat, deadline, and
3
+ * teardown for the live stream lane. Internal module: `handle`'s
4
+ * stream branch is the one sanctioned caller; the framing rules stay
5
+ * documented on `sseResponse` because the client's parser (and the
6
+ * stall watchdog) depend on them.
7
+ */
8
+
9
+ import { STREAM_TIMINGS } from './internal.ts'
10
+ import type { Event } from './store.ts'
11
+ import type { PresencePatch, PresenceSnapshot } from './contract.ts'
12
+ import { invocationDeadlineMs } from './platform.ts'
13
+ import {
14
+ eventToWire,
15
+ presencePatchToWire,
16
+ presenceSnapshotToWire,
17
+ } from './wire.ts'
18
+
19
+ /**
20
+ * Pipe a live event iterable into an SSE `Response`. Each event is one
21
+ * frame — `id:` carries the event-log index, `data:` the JSON event. A
22
+ * `stream({ presence: true })` iterable also yields presence items,
23
+ * which ride as named frames old clients skip: `event:
24
+ * presence-snapshot` first (the map, ISO dates), then `event: presence`
25
+ * per patch — no `id:`, presence never advances the resume frontier. A
26
+ * disconnecting client cancels the stream, which closes the underlying
27
+ * subscription. Two kinds of comment frames ride along: a `: connected`
28
+ * prelude that flushes headers immediately, and a `: ping` heartbeat
29
+ * every 15 seconds so clients (and proxies) can tell a quiet stream
30
+ * from a dead connection — the session client's stall watchdog counts
31
+ * on it. When the platform exposes an invocation deadline, the response
32
+ * closes cleanly one second before it so the client reconnects without
33
+ * a platform-timeout failure.
34
+ */
35
+ export function sseResponse(
36
+ iterable: AsyncIterable<Event | PresencePatch | PresenceSnapshot>,
37
+ ): Response {
38
+ const iterator = iterable[Symbol.asyncIterator]()
39
+ const encoder = new TextEncoder()
40
+ let heartbeat: ReturnType<typeof setInterval> | undefined
41
+ let deadlineTimer: ReturnType<typeof setTimeout> | undefined
42
+ let closed = false
43
+ let iteratorReturned = false
44
+ const stopTimers = (): void => {
45
+ if (heartbeat !== undefined) clearInterval(heartbeat)
46
+ if (deadlineTimer !== undefined) clearTimeout(deadlineTimer)
47
+ heartbeat = undefined
48
+ deadlineTimer = undefined
49
+ }
50
+ const returnIterator = async (): Promise<void> => {
51
+ if (iteratorReturned) return
52
+ iteratorReturned = true
53
+ await iterator.return?.()
54
+ }
55
+ const stream = new ReadableStream<Uint8Array>({
56
+ start(controller) {
57
+ // Prelude comment frame: flushes headers immediately (a fresh
58
+ // session may otherwise send nothing for a long time — some
59
+ // servers buffer headers until the first byte, and clients can't
60
+ // report "live" until they see the response), and gives proxies
61
+ // early proof this is a stream. Comment frames are invisible to
62
+ // SSE consumers.
63
+ controller.enqueue(encoder.encode(': connected\n\n'))
64
+ // Heartbeat: comment frames are ignored by SSE parsers but are
65
+ // bytes on the wire — liveness proof for the client's watchdog
66
+ // and for idle-timeout-happy intermediaries.
67
+ heartbeat = setInterval(() => {
68
+ try {
69
+ controller.enqueue(encoder.encode(': ping\n\n'))
70
+ } catch {
71
+ // The stream closed underneath the timer.
72
+ stopTimers()
73
+ }
74
+ }, STREAM_TIMINGS.sseHeartbeatMs)
75
+ ;(heartbeat as { unref?: () => void }).unref?.()
76
+
77
+ const invocationDeadline = invocationDeadlineMs()
78
+ if (invocationDeadline !== null) {
79
+ const delay = Math.max(
80
+ 0,
81
+ invocationDeadline - Date.now() - STREAM_TIMINGS.sseDeadlineGraceMs,
82
+ )
83
+ deadlineTimer = setTimeout(() => {
84
+ if (closed) return
85
+ closed = true
86
+ stopTimers()
87
+ void returnIterator().catch(() => {})
88
+ try {
89
+ controller.close()
90
+ } catch {
91
+ // The response already closed underneath the timer.
92
+ }
93
+ }, delay)
94
+ ;(deadlineTimer as { unref?: () => void }).unref?.()
95
+ }
96
+ },
97
+ async pull(controller) {
98
+ try {
99
+ const { value, done } = await iterator.next()
100
+ if (closed) return
101
+ if (done) {
102
+ closed = true
103
+ stopTimers()
104
+ controller.close()
105
+ return
106
+ }
107
+ controller.enqueue(encoder.encode(frameFor(value)))
108
+ } catch (error) {
109
+ if (closed) return
110
+ closed = true
111
+ stopTimers()
112
+ void returnIterator().catch(() => {})
113
+ throw error
114
+ }
115
+ },
116
+ async cancel() {
117
+ if (closed) return
118
+ closed = true
119
+ stopTimers()
120
+ await returnIterator()
121
+ },
122
+ })
123
+ return new Response(stream, {
124
+ status: 200,
125
+ headers: {
126
+ 'content-type': 'text/event-stream',
127
+ 'cache-control': 'no-cache, no-transform',
128
+ connection: 'keep-alive',
129
+ },
130
+ })
131
+ }
132
+
133
+ function frameFor(item: Event | PresencePatch | PresenceSnapshot): string {
134
+ if ('snapshot' in item) {
135
+ return `event: presence-snapshot\ndata: ${JSON.stringify(presenceSnapshotToWire(item))}\n\n`
136
+ }
137
+ if ('participant' in item) {
138
+ return `event: presence\ndata: ${JSON.stringify(presencePatchToWire(item))}\n\n`
139
+ }
140
+ return `id: ${item.index}\ndata: ${JSON.stringify(eventToWire(item))}\n\n`
141
+ }