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.
- package/CHANGELOG.md +51 -0
- package/dist/{ai-B4YhEnfw.d.ts → ai-CFNeCrRl.d.ts} +3 -2
- package/dist/ai-CFNeCrRl.d.ts.map +1 -0
- package/dist/ai-server.browser.js +2 -0
- package/dist/ai-server.browser.js.map +1 -0
- package/dist/ai-server.d.ts +4 -3
- package/dist/ai-server.d.ts.map +1 -0
- package/dist/ai-server.js +4 -2
- package/dist/ai-server.js.map +1 -0
- package/dist/ai.d.ts +1 -1
- package/dist/ai.js +3 -1
- package/dist/ai.js.map +1 -0
- package/dist/cli-B3VuxoDe.js +2 -0
- package/dist/cli-B3VuxoDe.js.map +1 -0
- package/dist/cli-bin.js +2 -0
- package/dist/cli-bin.js.map +1 -0
- package/dist/cli.d.ts +2 -1
- package/dist/cli.d.ts.map +1 -0
- package/dist/{client-Bt4tAKi9.js → client-BKlyLiOU.js} +295 -85
- package/dist/client-BKlyLiOU.js.map +1 -0
- package/dist/{client-BrfDXQ8A.d.ts → client-D7mvIXrF.d.ts} +40 -4
- package/dist/client-D7mvIXrF.d.ts.map +1 -0
- package/dist/client.d.ts +2 -2
- package/dist/client.js +1 -1
- package/dist/contract-48bUMgcL.js +2 -0
- package/dist/contract-48bUMgcL.js.map +1 -0
- package/dist/contract-jIfaR085.d.ts +2 -1
- package/dist/contract-jIfaR085.d.ts.map +1 -0
- package/dist/devtools-J_jZ2vQf.d.ts +2 -1
- package/dist/devtools-J_jZ2vQf.d.ts.map +1 -0
- package/dist/devtools-kJJaORn-.js +2 -0
- package/dist/devtools-kJJaORn-.js.map +1 -0
- package/dist/devtools-server.browser.js +2 -0
- package/dist/devtools-server.browser.js.map +1 -0
- package/dist/devtools-server.d.ts +2 -1
- package/dist/devtools-server.d.ts.map +1 -0
- package/dist/devtools-server.js +2 -0
- package/dist/devtools-server.js.map +1 -0
- package/dist/errors-BQuJpe82.js +2 -0
- package/dist/errors-BQuJpe82.js.map +1 -0
- package/dist/errors-W6nwJ-fm.d.ts +2 -1
- package/dist/errors-W6nwJ-fm.d.ts.map +1 -0
- package/dist/http.d.ts +121 -72
- package/dist/http.d.ts.map +1 -0
- package/dist/http.js +503 -178
- package/dist/http.js.map +1 -0
- package/dist/idempotent-replay-DuqEkYA7.js +2 -0
- package/dist/idempotent-replay-DuqEkYA7.js.map +1 -0
- package/dist/index.d.ts +1 -1
- package/dist/inspection-DaxB5jM2.js +2 -0
- package/dist/inspection-DaxB5jM2.js.map +1 -0
- package/dist/{internal-aEotMzu_.js → internal-DstsI6Re.js} +3 -1
- package/dist/internal-DstsI6Re.js.map +1 -0
- package/dist/otel.d.ts +2 -1
- package/dist/otel.d.ts.map +1 -0
- package/dist/otel.js +2 -0
- package/dist/otel.js.map +1 -0
- package/dist/platform-B4TnJtWu.js +2 -0
- package/dist/platform-B4TnJtWu.js.map +1 -0
- package/dist/react.d.ts +12 -3
- package/dist/react.d.ts.map +1 -0
- package/dist/react.js +5 -1
- package/dist/react.js.map +1 -0
- package/dist/retryable-lazy-DZWmHpii.js +2 -0
- package/dist/retryable-lazy-DZWmHpii.js.map +1 -0
- package/dist/scheduler-qstash.d.ts +4 -3
- package/dist/scheduler-qstash.d.ts.map +1 -0
- package/dist/scheduler-qstash.js +4 -2
- package/dist/scheduler-qstash.js.map +1 -0
- package/dist/scheduler-task-BpzhPnRS.js +2 -0
- package/dist/scheduler-task-BpzhPnRS.js.map +1 -0
- package/dist/scheduler-vercel.d.ts +4 -3
- package/dist/scheduler-vercel.d.ts.map +1 -0
- package/dist/scheduler-vercel.js +4 -2
- package/dist/scheduler-vercel.js.map +1 -0
- package/dist/{server-CcNnFnoW.js → server-C72KOw51.js} +100 -48
- package/dist/server-C72KOw51.js.map +1 -0
- package/dist/{server-YtPq7hjw.d.ts → server-DUF9pjsx.d.ts} +5 -4
- package/dist/server-DUF9pjsx.d.ts.map +1 -0
- package/dist/server.browser.js +2 -0
- package/dist/server.browser.js.map +1 -0
- package/dist/server.d.ts +2 -2
- package/dist/server.js +1 -1
- package/dist/{store-C3sNAaBT.d.ts → store-DysUkTH3.d.ts} +10 -1
- package/dist/store-DysUkTH3.d.ts.map +1 -0
- package/dist/store-N8PXxDAS.js +2 -0
- package/dist/store-N8PXxDAS.js.map +1 -0
- package/dist/store-codec-DTG0Ftek.js +2 -0
- package/dist/store-codec-DTG0Ftek.js.map +1 -0
- package/dist/store-memory.d.ts +3 -2
- package/dist/store-memory.d.ts.map +1 -0
- package/dist/store-memory.js +19 -11
- package/dist/store-memory.js.map +1 -0
- package/dist/{store-polling-DgrrAE3d.js → store-polling-dSeLxzfb.js} +3 -1
- package/dist/store-polling-dSeLxzfb.js.map +1 -0
- package/dist/store-postgres.d.ts +3 -2
- package/dist/store-postgres.d.ts.map +1 -0
- package/dist/store-postgres.js +57 -1
- package/dist/store-postgres.js.map +1 -0
- package/dist/{store-redis-core-DWqx3F47.js → store-redis-core-BFLwz0Wj.js} +3 -1
- package/dist/store-redis-core-BFLwz0Wj.js.map +1 -0
- package/dist/store-redis-http.d.ts +3 -2
- package/dist/store-redis-http.d.ts.map +1 -0
- package/dist/store-redis-http.js +4 -2
- package/dist/store-redis-http.js.map +1 -0
- package/dist/store-redis.d.ts +3 -2
- package/dist/store-redis.d.ts.map +1 -0
- package/dist/store-redis.js +5 -3
- package/dist/store-redis.js.map +1 -0
- package/dist/store-sqlite.d.ts +3 -2
- package/dist/store-sqlite.d.ts.map +1 -0
- package/dist/store-sqlite.js +3 -1
- package/dist/store-sqlite.js.map +1 -0
- package/dist/telemetry-BjYHTfh2.d.ts +2 -1
- package/dist/telemetry-BjYHTfh2.d.ts.map +1 -0
- package/dist/testing.browser.js +2 -0
- package/dist/testing.browser.js.map +1 -0
- package/dist/testing.d.ts +2 -1
- package/dist/testing.d.ts.map +1 -0
- package/dist/testing.js +2 -0
- package/dist/testing.js.map +1 -0
- package/dist/validate-XKT4FSNn.js +2 -0
- package/dist/validate-XKT4FSNn.js.map +1 -0
- package/dist/{wire-DCUZBUlT.js → wire-BFQmSJ-9.js} +77 -15
- package/dist/wire-BFQmSJ-9.js.map +1 -0
- package/docs/guides/03-react.mdx +59 -39
- package/docs/guides/06-ai-agents.mdx +5 -27
- package/docs/guides/09-presence.mdx +19 -40
- package/docs/guides/10-transports.mdx +49 -40
- package/docs/reference/01-api.mdx +107 -26
- package/docs/reference/02-errors.mdx +4 -2
- package/package.json +2 -1
- package/src/ai-coordinator.ts +358 -0
- package/src/ai-projector.ts +524 -0
- package/src/ai-sdk-step.ts +261 -0
- package/src/ai-server.browser.ts +5 -0
- package/src/ai-server.ts +1719 -0
- package/src/ai.ts +2155 -0
- package/src/cache-indexeddb.ts +10 -0
- package/src/cli-bin.ts +5 -0
- package/src/cli.ts +1046 -0
- package/src/client.ts +1826 -0
- package/src/contract.ts +206 -0
- package/src/deterministic-id.ts +72 -0
- package/src/devtools-app.ts +989 -0
- package/src/devtools-server.browser.ts +5 -0
- package/src/devtools-server.ts +604 -0
- package/src/devtools.ts +716 -0
- package/src/errors.ts +50 -0
- package/src/http.ts +394 -0
- package/src/idempotent-replay.ts +53 -0
- package/src/index.ts +37 -0
- package/src/inspection.ts +39 -0
- package/src/internal.ts +426 -0
- package/src/otel.ts +59 -0
- package/src/platform.ts +60 -0
- package/src/push-envelope.ts +137 -0
- package/src/react.ts +284 -0
- package/src/reducer.ts +108 -0
- package/src/retryable-lazy.ts +27 -0
- package/src/scheduler-qstash.ts +915 -0
- package/src/scheduler-task.ts +106 -0
- package/src/scheduler-vercel.ts +437 -0
- package/src/server.browser.ts +12 -0
- package/src/server.ts +2700 -0
- package/src/session-socket.ts +548 -0
- package/src/sse.ts +141 -0
- package/src/standard-schema.ts +77 -0
- package/src/store-codec.ts +10 -0
- package/src/store-memory.ts +788 -0
- package/src/store-polling.ts +102 -0
- package/src/store-postgres.ts +1212 -0
- package/src/store-redis-core.ts +1494 -0
- package/src/store-redis-http.ts +116 -0
- package/src/store-redis.ts +458 -0
- package/src/store-sqlite.ts +1108 -0
- package/src/store.ts +385 -0
- package/src/telemetry.ts +47 -0
- package/src/testing.browser.ts +5 -0
- package/src/testing.ts +185 -0
- package/src/validate.ts +39 -0
- 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
|
+
}
|