experimental-a2 0.6.0 → 0.7.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 (145) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/dist/ai-server.d.ts +4 -5
  3. package/dist/ai-server.d.ts.map +1 -1
  4. package/dist/ai-server.js +8 -7
  5. package/dist/ai-server.js.map +1 -1
  6. package/dist/ai.d.ts +334 -2
  7. package/dist/ai.d.ts.map +1 -0
  8. package/dist/ai.js +1 -1
  9. package/dist/client.d.ts +202 -2
  10. package/dist/client.d.ts.map +1 -0
  11. package/dist/client.js +1025 -1
  12. package/dist/client.js.map +1 -0
  13. package/dist/errors-BQuJpe82.js.map +1 -1
  14. package/dist/index.d.ts +22 -3
  15. package/dist/index.d.ts.map +1 -0
  16. package/dist/{internal-DstsI6Re.js → internal-DRXJ56EI.js} +5 -28
  17. package/dist/internal-DRXJ56EI.js.map +1 -0
  18. package/dist/react.d.ts +1 -1
  19. package/dist/react.js +1 -1
  20. package/dist/scheduler-qstash.d.ts +3 -3
  21. package/dist/scheduler-qstash.js +4 -5
  22. package/dist/scheduler-qstash.js.map +1 -1
  23. package/dist/scheduler-vercel.d.ts +2 -2
  24. package/dist/scheduler-vercel.js +4 -4
  25. package/dist/scheduler-vercel.js.map +1 -1
  26. package/dist/{server-Duw6MVlB.js → server-286j79Mt.js} +708 -79
  27. package/dist/server-286j79Mt.js.map +1 -0
  28. package/dist/{server-DpvjhdoE.d.ts → server-DgXmORIq.d.ts} +67 -49
  29. package/dist/server-DgXmORIq.d.ts.map +1 -0
  30. package/dist/server.d.ts +3 -3
  31. package/dist/server.js +1 -1
  32. package/dist/store-N8PXxDAS.js.map +1 -1
  33. package/dist/{store-DysUkTH3.d.ts → store-flRz1OWh.d.ts} +2 -57
  34. package/dist/store-flRz1OWh.d.ts.map +1 -0
  35. package/dist/store-memory.d.ts +1 -1
  36. package/dist/store-memory.d.ts.map +1 -1
  37. package/dist/store-memory.js +1 -59
  38. package/dist/store-memory.js.map +1 -1
  39. package/dist/{store-polling-dSeLxzfb.js → store-polling-6DW7F1DT.js} +2 -2
  40. package/dist/{store-polling-dSeLxzfb.js.map → store-polling-6DW7F1DT.js.map} +1 -1
  41. package/dist/store-postgres.d.ts +1 -1
  42. package/dist/store-postgres.js +1 -81
  43. package/dist/store-postgres.js.map +1 -1
  44. package/dist/{store-redis-core-BFLwz0Wj.js → store-redis-core-DEYO8Ryv.js} +48 -134
  45. package/dist/store-redis-core-DEYO8Ryv.js.map +1 -0
  46. package/dist/store-redis-http.d.ts +1 -1
  47. package/dist/store-redis-http.js +2 -3
  48. package/dist/store-redis-http.js.map +1 -1
  49. package/dist/store-redis.d.ts +1 -1
  50. package/dist/store-redis.js +3 -4
  51. package/dist/store-redis.js.map +1 -1
  52. package/dist/store-sqlite.d.ts +1 -1
  53. package/dist/store-sqlite.d.ts.map +1 -1
  54. package/dist/store-sqlite.js +1 -72
  55. package/dist/store-sqlite.js.map +1 -1
  56. package/dist/{wire-BFQmSJ-9.js → wire-B6te_wns.js} +4 -3
  57. package/dist/wire-B6te_wns.js.map +1 -0
  58. package/docs/guides/03-react.mdx +20 -28
  59. package/docs/guides/05-production.mdx +9 -11
  60. package/docs/guides/06-ai-agents.mdx +8 -14
  61. package/docs/guides/09-presence.mdx +14 -19
  62. package/docs/guides/10-transports.mdx +104 -86
  63. package/docs/reference/01-api.mdx +143 -277
  64. package/docs/reference/02-errors.mdx +5 -7
  65. package/package.json +1 -14
  66. package/src/ai-server.ts +9 -5
  67. package/src/client.ts +2 -2
  68. package/src/errors.ts +1 -0
  69. package/src/internal.ts +3 -62
  70. package/src/push-envelope.ts +24 -21
  71. package/src/scheduler-qstash.ts +3 -3
  72. package/src/scheduler-vercel.ts +2 -2
  73. package/src/server-fetch.ts +344 -0
  74. package/src/server.ts +73 -225
  75. package/src/session-socket.ts +36 -20
  76. package/src/sse.ts +2 -2
  77. package/src/store-memory.ts +0 -81
  78. package/src/store-postgres.ts +0 -100
  79. package/src/store-redis-core.ts +47 -211
  80. package/src/store-redis-http.ts +0 -1
  81. package/src/store-redis.ts +0 -1
  82. package/src/store-sqlite.ts +0 -119
  83. package/src/store.ts +0 -60
  84. package/src/wire.ts +2 -1
  85. package/dist/ai-D_PGS-JR.d.ts +0 -334
  86. package/dist/ai-D_PGS-JR.d.ts.map +0 -1
  87. package/dist/cli-B3VuxoDe.js +0 -599
  88. package/dist/cli-B3VuxoDe.js.map +0 -1
  89. package/dist/cli-bin.d.ts +0 -1
  90. package/dist/cli-bin.js +0 -7
  91. package/dist/cli-bin.js.map +0 -1
  92. package/dist/cli.d.ts +0 -20
  93. package/dist/cli.d.ts.map +0 -1
  94. package/dist/cli.js +0 -2
  95. package/dist/client-CdMqi7mC.d.ts +0 -202
  96. package/dist/client-CdMqi7mC.d.ts.map +0 -1
  97. package/dist/client-Dj5d3SP_.js +0 -1026
  98. package/dist/client-Dj5d3SP_.js.map +0 -1
  99. package/dist/devtools-J_jZ2vQf.d.ts +0 -152
  100. package/dist/devtools-J_jZ2vQf.d.ts.map +0 -1
  101. package/dist/devtools-kJJaORn-.js +0 -340
  102. package/dist/devtools-kJJaORn-.js.map +0 -1
  103. package/dist/devtools-server.browser.d.ts +0 -1
  104. package/dist/devtools-server.browser.js +0 -6
  105. package/dist/devtools-server.browser.js.map +0 -1
  106. package/dist/devtools-server.d.ts +0 -23
  107. package/dist/devtools-server.d.ts.map +0 -1
  108. package/dist/devtools-server.js +0 -1270
  109. package/dist/devtools-server.js.map +0 -1
  110. package/dist/devtools.d.ts +0 -2
  111. package/dist/devtools.js +0 -2
  112. package/dist/errors-W6nwJ-fm.d.ts +0 -21
  113. package/dist/errors-W6nwJ-fm.d.ts.map +0 -1
  114. package/dist/http.d.ts +0 -151
  115. package/dist/http.d.ts.map +0 -1
  116. package/dist/http.js +0 -706
  117. package/dist/http.js.map +0 -1
  118. package/dist/inspection-DaxB5jM2.js +0 -13
  119. package/dist/inspection-DaxB5jM2.js.map +0 -1
  120. package/dist/internal-DstsI6Re.js.map +0 -1
  121. package/dist/platform-B4TnJtWu.js +0 -34
  122. package/dist/platform-B4TnJtWu.js.map +0 -1
  123. package/dist/server-DpvjhdoE.d.ts.map +0 -1
  124. package/dist/server-Duw6MVlB.js.map +0 -1
  125. package/dist/store-DysUkTH3.d.ts.map +0 -1
  126. package/dist/store-redis-core-BFLwz0Wj.js.map +0 -1
  127. package/dist/testing.browser.d.ts +0 -1
  128. package/dist/testing.browser.js +0 -6
  129. package/dist/testing.browser.js.map +0 -1
  130. package/dist/testing.d.ts +0 -32
  131. package/dist/testing.d.ts.map +0 -1
  132. package/dist/testing.js +0 -103
  133. package/dist/testing.js.map +0 -1
  134. package/dist/wire-BFQmSJ-9.js.map +0 -1
  135. package/docs/guides/07-devtools.mdx +0 -229
  136. package/src/cli-bin.ts +0 -5
  137. package/src/cli.ts +0 -1046
  138. package/src/devtools-app.ts +0 -989
  139. package/src/devtools-server.browser.ts +0 -5
  140. package/src/devtools-server.ts +0 -604
  141. package/src/devtools.ts +0 -716
  142. package/src/http.ts +0 -394
  143. package/src/inspection.ts +0 -39
  144. package/src/testing.browser.ts +0 -5
  145. package/src/testing.ts +0 -185
package/src/http.ts DELETED
@@ -1,394 +0,0 @@
1
- /**
2
- * experimental-a2/http — the wire's server half (specs/a2-api.md §9–10, §13).
3
- *
4
- * A thin public assembly. The surface: `handle` — GET, POST, and the
5
- * socket's frames parsed into intents (stream, history, push,
6
- * ws-upgrade) with `before`/`after` hooks around them for policy —
7
- * plus `A2Intent`/`A2Outcome`/`UpgradeFn`/`A2Socket` (its argument
8
- * types), `errorResponse`/`deserializeError` (the A2Error wire pair
9
- * both sides branch on), and `schedulerHandler` (the scheduler
10
- * callback mount). The machinery lives in internal modules — the SSE
11
- * framing in sse.ts, the push-envelope trust boundary in
12
- * push-envelope.ts, the socket shell and multiplexed handler in
13
- * session-socket.ts — because nothing outside `handle` is sanctioned
14
- * to compose them. Policy — auth, caching, quotas — never enters this
15
- * module: the hooks own it.
16
- */
17
-
18
- import { A2Error } from './errors.ts'
19
- import {
20
- assertServerSchedulerBindings,
21
- assertSchedulerTargets,
22
- schedulerForServer,
23
- } from './internal.ts'
24
- import type { ContractEvent, EventDefs, PresenceDefs } from './contract.ts'
25
- import type {
26
- A2Server,
27
- DrainableServer,
28
- PushedEvent,
29
- PushedPresence,
30
- } from './server.ts'
31
- import { sseResponse } from './sse.ts'
32
- import { invalidPushBody, parsePushBody } from './push-envelope.ts'
33
- import {
34
- sessionsSocket,
35
- type A2Socket,
36
- type SocketSession,
37
- } from './session-socket.ts'
38
- import {
39
- asA2Error,
40
- errorFromWire,
41
- errorStatus,
42
- errorToWire,
43
- eventToWire,
44
- } from './wire.ts'
45
-
46
- export type { A2Socket } from './session-socket.ts'
47
-
48
- /**
49
- * Create one scheduler callback route for servers sharing the same configured
50
- * scheduler. Configuration mistakes throw immediately while the route module
51
- * is evaluated, before a delivery can be acknowledged by the wrong adapter.
52
- */
53
- export function schedulerHandler(
54
- firstServer: DrainableServer,
55
- ...servers: DrainableServer[]
56
- ): (req: Request) => Promise<Response> {
57
- if (firstServer === undefined) {
58
- throw new TypeError('schedulerHandler() requires at least one A2 server')
59
- }
60
- const scheduler = schedulerForServer(firstServer)
61
- const targets = [firstServer, ...servers]
62
- assertSchedulerTargets(targets, { allowStructural: false })
63
- assertServerSchedulerBindings(scheduler, targets)
64
- return scheduler.handler(...targets)
65
- }
66
-
67
- /**
68
- * Serialize an error into the documented wire shape,
69
- * `{ error: { code, message, details } }`, with the mapped status
70
- * (400 for caller bugs, 503 for STORE_UNAVAILABLE). Non-A2Errors become a
71
- * 503 STORE_UNAVAILABLE — from the client's perspective an unknown server
72
- * failure is retryable-once, not a protocol contract. The natural
73
- * return value of a `before` hook that wants the wire's own error
74
- * shapes.
75
- */
76
- export function errorResponse(error: unknown): Response {
77
- const a2error = asA2Error(error)
78
- return Response.json(errorToWire(a2error), {
79
- status: errorStatus(a2error.code),
80
- })
81
- }
82
-
83
- /**
84
- * The other half of the wire pair: rebuild an `A2Error` from a response
85
- * body, or null if the body isn't one. Client `push` uses it so both
86
- * sides branch on identical codes.
87
- */
88
- export function deserializeError(body: unknown): A2Error | null {
89
- return errorFromWire(body)
90
- }
91
-
92
- // ── handle: the request protocol, parsed ────────────────────────
93
-
94
- /**
95
- * What a request (or socket frame) asks of a session, parsed — the
96
- * argument policy hooks branch on. `stream` covers the SSE GET and the
97
- * socket's subscribe frames; `push` covers the HTTP POST and the
98
- * socket's push frames; `history` is the bounded cold read the
99
- * client's `loadHistory` rides; `ws-upgrade` is the upgrade GET before
100
- * any frame flows.
101
- */
102
- export type A2Intent<D extends EventDefs = EventDefs> =
103
- | { type: 'ws-upgrade' }
104
- | {
105
- type: 'stream'
106
- sessionId: string
107
- startAfter: number
108
- transport: 'sse' | 'ws'
109
- }
110
- | { type: 'history'; sessionId: string; gte: number; lte: number }
111
- | {
112
- type: 'push'
113
- sessionId: string
114
- /** Parsed and branded, payloads not yet validated — `type`
115
- * autocompletes the contract's names but is any string off the
116
- * wire; `append` rejects unknown types after the hooks run. */
117
- events: Array<PushedEvent & { type: SuggestedEventType<D> }>
118
- presence?: PushedPresence
119
- transport: 'http' | 'ws'
120
- }
121
-
122
- /** The contract's event names for editor completion, without excluding
123
- * the wire's reality (any string can arrive; validation is later). */
124
- type SuggestedEventType<D extends EventDefs> =
125
- (keyof D & string) | (string & Record<never, never>)
126
-
127
- /** What the library did with an allowed intent — `after`'s view.
128
- * `covered` = the closed range is fully present (an immutable slice of
129
- * the append-only log), the one caching-relevant fact the library
130
- * owns; policy over it belongs to the app's `after`. */
131
- export type A2Outcome<D extends EventDefs = EventDefs> =
132
- | { type: 'stream' }
133
- | { type: 'history'; covered: boolean; events: ContractEvent<D>[] }
134
- | { type: 'push'; appended: ContractEvent<D>[] }
135
-
136
- /**
137
- * The platform's socket-upgrade seam: given the attach callback, return
138
- * the 101 response. On Vercel:
139
- * `upgrade: (attach) => experimental_upgradeWebSocket(attach)`.
140
- */
141
- export type UpgradeFn = (
142
- attach: (socket: A2Socket) => void,
143
- ) => Response | Promise<Response>
144
-
145
- const parseHistoryBounds = (
146
- gte: string | null,
147
- lte: string | null,
148
- ): { gte: number; lte: number } => {
149
- if (gte === null || lte === null) {
150
- throw new A2Error(
151
- 'INVALID_PAYLOAD',
152
- 'a history read takes both gte and lte',
153
- )
154
- }
155
- const bounds = { gte: Number(gte), lte: Number(lte) }
156
- for (const [name, value] of Object.entries(bounds)) {
157
- if (!Number.isSafeInteger(value) || value < 0) {
158
- throw new A2Error(
159
- 'INVALID_PAYLOAD',
160
- `history ${name} must be a non-negative safe integer`,
161
- )
162
- }
163
- }
164
- if (bounds.gte > bounds.lte) {
165
- throw new A2Error(
166
- 'INVALID_PAYLOAD',
167
- 'history gte must be less than or equal to lte',
168
- )
169
- }
170
- return bounds
171
- }
172
-
173
- const parseResumeIndex = (raw: string | null): number => {
174
- if (raw === null) return 0
175
- const index = Number(raw)
176
- if (!Number.isSafeInteger(index) || index < 0) {
177
- throw new A2Error(
178
- 'INVALID_PAYLOAD',
179
- 'stream index must be a non-negative safe integer',
180
- )
181
- }
182
- return index
183
- }
184
-
185
- /** One bag: the policy hooks and the capability options — the names
186
- * distinguish them, so `handle(server, { presence: true })` and
187
- * `handle(server, { before, upgrade })` both read naturally. */
188
- export type HandleOptions<D extends EventDefs = EventDefs> = {
189
- /** Return a Response to deny; thrown errors are treated as
190
- * infrastructure failures and answer with the retryable wire shape. */
191
- before?(args: {
192
- request: Request
193
- intent: A2Intent<D>
194
- }): Response | undefined | void | Promise<Response | undefined | void>
195
- after?(args: {
196
- request: Request
197
- intent: A2Intent<D>
198
- outcome: A2Outcome<D>
199
- response: Response
200
- }): Response | undefined | void | Promise<Response | undefined | void>
201
- upgrade?: UpgradeFn
202
- presence?: boolean
203
- deadline?: number
204
- }
205
-
206
- /**
207
- * The session route pair as one call: `export const { GET, POST } =
208
- * handle(server, options?)` serves the live stream (SSE GET, resumed
209
- * at `index`), the history slice (GET with `gte`/`lte`), the push
210
- * envelope (POST), and — with `options.upgrade` — the multiplexed
211
- * WebSocket, all through one pair of policy hooks.
212
- *
213
- * Parsing is protocol, hooks are policy: a request that fails to parse
214
- * (missing sessionId, malformed bounds, bad push envelope) answers
215
- * INVALID_PAYLOAD on the wire before any hook runs. `before` sees
216
- * every parsed intent — HTTP requests and socket frames alike, with
217
- * `request` always the original (for socket frames: upgrade) Request —
218
- * and short-circuits by returning a Response: over HTTP it is the
219
- * response verbatim; over the socket it is translated to the wire's
220
- * own vocabulary (subscribe → `unsubscribed { reason: 'subscribe
221
- * rejected' }`, push → an INVALID_PAYLOAD error ack), because a
222
- * Response body cannot cross a socket. `after` runs only where the
223
- * library produced an HTTP response — never after a short-circuit,
224
- * never for socket frames — and may mutate `response.headers` in place
225
- * or return a replacement (history caching policy lives here, on
226
- * `outcome.covered`). `options.deadline` (epoch ms) closes sockets
227
- * cleanly ahead of a known platform deadline, so clients reconnect on
228
- * our schedule.
229
- */
230
- export function handle<
231
- D extends EventDefs,
232
- P extends PresenceDefs = Record<never, never>,
233
- >(
234
- server: A2Server<D, P>,
235
- options?: HandleOptions<D>,
236
- ): {
237
- GET(req: Request): Promise<Response>
238
- POST(req: Request): Promise<Response>
239
- } {
240
- const presence = options?.presence === true
241
-
242
- const before = async (
243
- request: Request,
244
- intent: A2Intent<D>,
245
- ): Promise<Response | null> => {
246
- const result = await options?.before?.({ request, intent })
247
- return result instanceof Response ? result : null
248
- }
249
-
250
- const decorate = async (
251
- request: Request,
252
- intent: A2Intent<D>,
253
- outcome: A2Outcome<D>,
254
- response: Response,
255
- ): Promise<Response> => {
256
- const replaced = await options?.after?.({
257
- request,
258
- intent,
259
- outcome,
260
- response,
261
- })
262
- return replaced instanceof Response ? replaced : response
263
- }
264
-
265
- // Socket frames re-enter `before` with the upgrade Request — one
266
- // policy point for both wires, and the fix for pushes that were
267
- // gated only at subscribe time.
268
- const attach =
269
- (request: Request) =>
270
- (socket: A2Socket): void => {
271
- sessionsSocket<D>(
272
- async (sessionId, startAfter) => {
273
- const short = await before(request, {
274
- type: 'stream',
275
- sessionId,
276
- startAfter,
277
- transport: 'ws',
278
- })
279
- return short === null ? server.session(sessionId) : null
280
- },
281
- socket,
282
- {
283
- presence,
284
- ...(options?.deadline === undefined
285
- ? {}
286
- : { deadline: options.deadline }),
287
- gatePush: async (sessionId, events) => {
288
- const short = await before(request, {
289
- type: 'push',
290
- sessionId,
291
- events,
292
- transport: 'ws',
293
- })
294
- // The denial Response cannot cross the socket; INVALID_PAYLOAD
295
- // is the one caller-fault code the client never retries — a
296
- // retryable code would spin retries against a standing denial.
297
- return short === null
298
- ? null
299
- : new A2Error('INVALID_PAYLOAD', 'push rejected')
300
- },
301
- },
302
- )
303
- }
304
-
305
- return {
306
- async GET(req: Request): Promise<Response> {
307
- try {
308
- if (req.headers.get('upgrade')?.toLowerCase() === 'websocket') {
309
- const short = await before(req, { type: 'ws-upgrade' })
310
- if (short !== null) return short
311
- if (options?.upgrade === undefined) {
312
- return new Response(
313
- "WebSocket upgrade requested, but handle() has no upgrade implementation — pass options.upgrade, e.g. { upgrade: (attach) => experimental_upgradeWebSocket(attach) } from '@vercel/functions'",
314
- { status: 426 },
315
- )
316
- }
317
- return await options.upgrade(attach(req))
318
- }
319
- const { searchParams } = new URL(req.url)
320
- const sessionId = searchParams.get('sessionId')
321
- if (sessionId === null || sessionId.length === 0) {
322
- throw new A2Error('INVALID_PAYLOAD', 'missing sessionId')
323
- }
324
- const gteRaw = searchParams.get('gte')
325
- const lteRaw = searchParams.get('lte')
326
- if (gteRaw !== null || lteRaw !== null) {
327
- const bounds = parseHistoryBounds(gteRaw, lteRaw)
328
- const intent: A2Intent<D> = { type: 'history', sessionId, ...bounds }
329
- const short = await before(req, intent)
330
- if (short !== null) return short
331
- const events = await server.session(sessionId).history(bounds)
332
- const covered = events.length === bounds.lte - bounds.gte + 1
333
- return await decorate(
334
- req,
335
- intent,
336
- { type: 'history', covered, events },
337
- Response.json(events.map(eventToWire)),
338
- )
339
- }
340
- const startAfter = parseResumeIndex(searchParams.get('index'))
341
- const intent: A2Intent<D> = {
342
- type: 'stream',
343
- sessionId,
344
- startAfter,
345
- transport: 'sse',
346
- }
347
- const short = await before(req, intent)
348
- if (short !== null) return short
349
- const target = server.session(sessionId) as unknown as SocketSession
350
- const response = sseResponse(
351
- presence
352
- ? target.stream({ startAfter, presence: true })
353
- : target.stream({ startAfter }),
354
- )
355
- return await decorate(req, intent, { type: 'stream' }, response)
356
- } catch (err) {
357
- return errorResponse(err)
358
- }
359
- },
360
-
361
- async POST(req: Request): Promise<Response> {
362
- try {
363
- const body = await parsePushBody(req)
364
- const intent: A2Intent<D> = {
365
- type: 'push',
366
- sessionId: body.sessionId,
367
- events: body.events,
368
- ...(body.presence === undefined ? {} : { presence: body.presence }),
369
- transport: 'http',
370
- }
371
- const short = await before(req, intent)
372
- if (short !== null) return short
373
- const session = server.session(body.sessionId)
374
- if (body.presence !== undefined) {
375
- const target = session as unknown as SocketSession
376
- if (typeof target.setPresence !== 'function') {
377
- throw invalidPushBody('presence is not declared by this contract')
378
- }
379
- await target.setPresence(body.presence)
380
- }
381
- const appended =
382
- body.events.length === 0 ? [] : await session.append(...body.events)
383
- return await decorate(
384
- req,
385
- intent,
386
- { type: 'push', appended },
387
- Response.json(appended),
388
- )
389
- } catch (err) {
390
- return errorResponse(err)
391
- }
392
- },
393
- }
394
- }
package/src/inspection.ts DELETED
@@ -1,39 +0,0 @@
1
- /**
2
- * Private bridge between `createServer` and `experimental-a2/devtools/server`.
3
- * Servers keep their store closure private; the WeakMap grants A2's own
4
- * read-only devtools entry point access without adding public methods to
5
- * application server handles.
6
- */
7
-
8
- import type { StoredEvent, StoredSessionPage, StoredSnapshot } from './store.ts'
9
-
10
- export class InspectionUnsupportedError extends Error {}
11
-
12
- export type ServerInspection = {
13
- listSessions(options: {
14
- cursor?: string
15
- limit: number
16
- }): Promise<StoredSessionPage>
17
- readSessionPage(
18
- sessionId: string,
19
- options: { afterIndex: number; throughIndex?: number; limit: number },
20
- ): Promise<{
21
- events: StoredEvent[]
22
- snapshots: StoredSnapshot[]
23
- throughIndex: number
24
- nextIndex: number | null
25
- }>
26
- }
27
-
28
- export const serverInspection: WeakMap<object, ServerInspection> = new WeakMap()
29
-
30
- /** Owned live-inspection cadence; mutable only for white-box tests. */
31
- export const DEVTOOLS_TIMINGS: {
32
- activeMs: number
33
- idleMs: number
34
- heartbeatMs: number
35
- } = {
36
- activeMs: 250,
37
- idleMs: 1_000,
38
- heartbeatMs: 15_000,
39
- }
@@ -1,5 +0,0 @@
1
- /** experimental-a2/testing loads local capture files and is Node-only. */
2
- throw new Error(
3
- 'experimental-a2/testing is Node-only. Load captures in a local test or script.',
4
- )
5
- export {}
package/src/testing.ts DELETED
@@ -1,185 +0,0 @@
1
- /**
2
- * Offline helpers for loading a captured A2 session and replaying its events
3
- * through the application's current reducers. This module never constructs a
4
- * server or dispatches handlers.
5
- */
6
-
7
- import { readFile } from 'node:fs/promises'
8
- import { fileURLToPath } from 'node:url'
9
- import type { Contract, ContractEvent, EventDefs } from './contract.ts'
10
- import {
11
- parseDevtoolsCapture,
12
- verifyDevtoolsCapture,
13
- type DevtoolsCapture,
14
- type DevtoolsCaptureEvent,
15
- } from './devtools.ts'
16
- import type { Reducer } from './reducer.ts'
17
- import { validateSync } from './validate.ts'
18
-
19
- export type CaptureSource = string | URL
20
-
21
- export type LoadedCapture<D extends EventDefs> = {
22
- /** The verified capture, including its footer and operational records. */
23
- readonly capture: DevtoolsCapture
24
- /** The contract used to validate and type the captured event history. */
25
- readonly contract: Contract<D>
26
- /** Contract-typed public events with `createdAt` revived to `Date`. */
27
- readonly events: readonly ContractEvent<D>[]
28
- /** Unmodified wire events, including dispatch and failure metadata. */
29
- readonly rawEvents: readonly DevtoolsCaptureEvent[]
30
- }
31
-
32
- export type ReplayCaptureOptions = {
33
- /** Fold events through this log index, inclusive. Zero means no events. */
34
- throughIndex?: number
35
- }
36
-
37
- export type CaptureReplay<S> = {
38
- readonly state: S
39
- /** The last event index actually folded, or zero for the initial state. */
40
- readonly index: number
41
- }
42
-
43
- const captureError = (message: string): TypeError =>
44
- new TypeError(`invalid A2 capture: ${message}`)
45
-
46
- const typedEvents = <D extends EventDefs>(
47
- capture: DevtoolsCapture,
48
- contract: Contract<D>,
49
- ): ContractEvent<D>[] => {
50
- const events: ContractEvent<D>[] = []
51
- for (const event of capture.events) {
52
- const schema = Object.hasOwn(contract.events, event.type)
53
- ? contract.events[event.type]
54
- : undefined
55
- if (schema === undefined) {
56
- throw captureError(
57
- `contract '${contract.name}' has no event type '${event.type}' at index ${event.index}`,
58
- )
59
- }
60
- const result = validateSync(
61
- schema,
62
- structuredClone(event.payload),
63
- `captured event '${event.type}'`,
64
- )
65
- if (result.issues !== undefined) {
66
- throw captureError(
67
- `event #${event.index} '${event.type}' has an invalid payload: ${result.issues
68
- .map((issue) => issue.message)
69
- .join('; ')}`,
70
- )
71
- }
72
- events.push({
73
- id: event.id,
74
- type: event.type,
75
- payload: result.value,
76
- index: event.index,
77
- sessionId: event.sessionId,
78
- createdAt: new Date(event.createdAt),
79
- } as ContractEvent<D>)
80
- }
81
- return events
82
- }
83
-
84
- /** Validate an already-parsed capture against an application's contract. */
85
- export async function prepareCapture<D extends EventDefs>(
86
- capture: DevtoolsCapture,
87
- contract: Contract<D>,
88
- ): Promise<LoadedCapture<D>> {
89
- await verifyDevtoolsCapture(capture)
90
- if (capture.contract !== contract.name) {
91
- throw captureError(
92
- `expected contract '${contract.name}', received '${capture.contract}'`,
93
- )
94
- }
95
- return {
96
- capture,
97
- contract,
98
- events: typedEvents(capture, contract),
99
- rawEvents: capture.events,
100
- }
101
- }
102
-
103
- const displayUrl = (url: URL): string => {
104
- if (url.protocol === 'data:') return 'data: URL'
105
- const safe = new URL(url)
106
- safe.username = ''
107
- safe.password = ''
108
- safe.search = ''
109
- safe.hash = ''
110
- return safe.href
111
- }
112
-
113
- const fetchCapture = async (url: URL): Promise<Uint8Array> => {
114
- const location = displayUrl(url)
115
- let response: Response
116
- try {
117
- response = await fetch(url)
118
- } catch {
119
- throw new Error(`failed to load A2 capture from '${location}'`)
120
- }
121
- if (!response.ok) {
122
- throw new Error(
123
- `failed to load A2 capture from '${location}': ${response.status} ${response.statusText}`,
124
- )
125
- }
126
- return new Uint8Array(await response.arrayBuffer())
127
- }
128
-
129
- const readCapture = async (source: CaptureSource): Promise<Uint8Array> => {
130
- if (source instanceof URL) {
131
- return source.protocol === 'file:'
132
- ? readFile(fileURLToPath(source))
133
- : fetchCapture(source)
134
- }
135
- if (/^[a-z]:[\\/]/i.test(source)) return readFile(source)
136
- let url: URL | undefined
137
- try {
138
- url = new URL(source)
139
- } catch {
140
- return readFile(source)
141
- }
142
- return readCapture(url)
143
- }
144
-
145
- /** Load, integrity-check, and contract-type a `.a2log` file or URL. */
146
- export async function loadCapture<D extends EventDefs>(
147
- source: CaptureSource,
148
- contract: Contract<D>,
149
- ): Promise<LoadedCapture<D>> {
150
- const encoded = await readCapture(source)
151
- const capture = parseDevtoolsCapture(encoded)
152
- return prepareCapture(capture, contract)
153
- }
154
-
155
- const cloneInitial = <S>(initialState: S): S => {
156
- try {
157
- return structuredClone(initialState)
158
- } catch {
159
- return initialState
160
- }
161
- }
162
-
163
- /** Purely fold captured events through a reducer, optionally to a cutpoint. */
164
- export function replayCapture<D extends EventDefs, S>(
165
- loaded: LoadedCapture<D>,
166
- reducer: Reducer<D, S>,
167
- options: ReplayCaptureOptions = {},
168
- ): CaptureReplay<S> {
169
- const { throughIndex } = options
170
- if (
171
- throughIndex !== undefined &&
172
- (!Number.isInteger(throughIndex) || throughIndex < 0)
173
- ) {
174
- throw new TypeError('throughIndex must be a non-negative integer')
175
- }
176
-
177
- let state = cloneInitial(reducer.initialState)
178
- let index = 0
179
- for (const event of loaded.events) {
180
- if (throughIndex !== undefined && event.index > throughIndex) break
181
- state = reducer.fold(state, event)
182
- index = event.index
183
- }
184
- return { state, index }
185
- }