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/devtools.ts DELETED
@@ -1,716 +0,0 @@
1
- /**
2
- * Shared wire protocol for A2's read-only devtools surfaces.
3
- *
4
- * The HTTP dashboard and non-interactive consumers use the same response
5
- * shapes. Captures are newline-delimited JSON so they can be streamed,
6
- * inspected with ordinary command-line tools, and verified without access to
7
- * an A2 server.
8
- */
9
-
10
- export const DEVTOOLS_PROTOCOL_VERSION = 1 as const
11
- export const DEVTOOLS_CAPTURE_VERSION = 1 as const
12
- export const DEVTOOLS_CAPTURE_MEDIA_TYPE = 'application/x-ndjson' as const
13
- export const DEVTOOLS_SESSION_PAGE_LIMIT = 250 as const
14
- export const DEVTOOLS_SESSION_PAGE_MAX_LIMIT = 1_000 as const
15
-
16
- export type DevtoolsWireSessionSummary = {
17
- sessionId: string
18
- eventCount: number
19
- pendingCount: number
20
- failedCount: number
21
- attemptCount: number
22
- failureCount: number
23
- firstEventAt: string
24
- updatedAt: string
25
- }
26
-
27
- export type DevtoolsWireEvent = {
28
- id: string
29
- type: string
30
- payload: unknown
31
- index: number
32
- sessionId: string
33
- createdAt: string
34
- cause: { index: number; attempt: number; batchSize?: number } | null
35
- lane: string | null
36
- processedAt: string | null
37
- processedByAttempt: number | null
38
- returnedEventIds: string[] | null
39
- firstClaimedAt: string | null
40
- lastClaimedAt: string | null
41
- attemptCount: number
42
- claimHolder: string | null
43
- claimExpiresAt: string | null
44
- failureCount: number
45
- lastFailedAt: string | null
46
- lastFailedAttempt: number | null
47
- lastError: string | null
48
- failedAt: string | null
49
- }
50
-
51
- export type DevtoolsWireSnapshot = {
52
- reducerName: string
53
- index: number
54
- updatedAt: string | null
55
- }
56
-
57
- export type DevtoolsContractsResponse = {
58
- protocolVersion: typeof DEVTOOLS_PROTOCOL_VERSION
59
- contracts: Array<{ name: string }>
60
- }
61
-
62
- export type DevtoolsSessionsResponse = {
63
- protocolVersion: typeof DEVTOOLS_PROTOCOL_VERSION
64
- cursor: string | null
65
- sessions: DevtoolsWireSessionSummary[]
66
- }
67
-
68
- export type DevtoolsSessionDetail = {
69
- protocolVersion: typeof DEVTOOLS_PROTOCOL_VERSION
70
- contract: string
71
- sessionId: string
72
- /** An invalidation token, not a content-integrity digest. */
73
- revision: string
74
- events: DevtoolsWireEvent[]
75
- snapshots: DevtoolsWireSnapshot[]
76
- }
77
-
78
- export type DevtoolsSessionPage = {
79
- protocolVersion: typeof DEVTOOLS_PROTOCOL_VERSION
80
- contract: string
81
- sessionId: string
82
- /** Inclusive log frontier frozen by the first page. */
83
- throughIndex: number
84
- /** Opaque continuation token; null means this frozen read is complete. */
85
- cursor: string | null
86
- events: DevtoolsWireEvent[]
87
- /** Snapshot metadata is present only on the first page. */
88
- snapshots: DevtoolsWireSnapshot[]
89
- }
90
-
91
- export type DevtoolsCapabilities = {
92
- protocolVersion: typeof DEVTOOLS_PROTOCOL_VERSION
93
- resources: Array<'contracts' | 'sessions' | 'session' | 'watch' | 'export'>
94
- sessionPagination: {
95
- defaultLimit: typeof DEVTOOLS_SESSION_PAGE_LIMIT
96
- maxLimit: typeof DEVTOOLS_SESSION_PAGE_MAX_LIMIT
97
- }
98
- capture: {
99
- version: typeof DEVTOOLS_CAPTURE_VERSION
100
- mediaType: typeof DEVTOOLS_CAPTURE_MEDIA_TYPE
101
- }
102
- }
103
-
104
- export const DEVTOOLS_CAPABILITIES: DevtoolsCapabilities = {
105
- protocolVersion: DEVTOOLS_PROTOCOL_VERSION,
106
- resources: ['contracts', 'sessions', 'session', 'watch', 'export'],
107
- sessionPagination: {
108
- defaultLimit: DEVTOOLS_SESSION_PAGE_LIMIT,
109
- maxLimit: DEVTOOLS_SESSION_PAGE_MAX_LIMIT,
110
- },
111
- capture: {
112
- version: DEVTOOLS_CAPTURE_VERSION,
113
- mediaType: DEVTOOLS_CAPTURE_MEDIA_TYPE,
114
- },
115
- }
116
-
117
- export type DevtoolsCaptureManifest = {
118
- kind: 'a2.capture'
119
- captureVersion: typeof DEVTOOLS_CAPTURE_VERSION
120
- protocolVersion: typeof DEVTOOLS_PROTOCOL_VERSION
121
- contract: string
122
- sessionId: string
123
- revision: string
124
- capturedAt: string
125
- fidelity: 'exact'
126
- }
127
-
128
- /** One exact durable event, with the record discriminator added. */
129
- export type DevtoolsCaptureEvent = DevtoolsWireEvent & { kind: 'event' }
130
-
131
- /** Snapshot metadata only. Cached reducer state is never exported. */
132
- export type DevtoolsCaptureSnapshot = DevtoolsWireSnapshot & {
133
- kind: 'snapshot'
134
- }
135
-
136
- export type DevtoolsCaptureFooter = {
137
- kind: 'end'
138
- captureVersion: typeof DEVTOOLS_CAPTURE_VERSION
139
- eventCount: number
140
- snapshotCount: number
141
- maxIndex: number | null
142
- sha256: string
143
- }
144
-
145
- export type DevtoolsCaptureRecord =
146
- | DevtoolsCaptureManifest
147
- | DevtoolsCaptureEvent
148
- | DevtoolsCaptureSnapshot
149
- | DevtoolsCaptureFooter
150
-
151
- export type DevtoolsCapture = {
152
- contract: string
153
- sessionId: string
154
- revision: string
155
- capturedAt: string
156
- manifest: DevtoolsCaptureManifest
157
- events: DevtoolsCaptureEvent[]
158
- snapshots: DevtoolsCaptureSnapshot[]
159
- footer: DevtoolsCaptureFooter
160
- records: DevtoolsCaptureRecord[]
161
- }
162
-
163
- const encoder = new TextEncoder()
164
- const decoder = new TextDecoder('utf-8', { fatal: true })
165
-
166
- const objectValue = (value: unknown): Record<string, unknown> | null =>
167
- typeof value === 'object' && value !== null && !Array.isArray(value)
168
- ? (value as Record<string, unknown>)
169
- : null
170
-
171
- class DevtoolsCaptureError extends TypeError {}
172
-
173
- const captureError = (message: string): DevtoolsCaptureError =>
174
- new DevtoolsCaptureError(`invalid A2 devtools capture: ${message}`)
175
-
176
- const sha256 = async (value: string): Promise<string> => {
177
- const digest = await crypto.subtle.digest('SHA-256', encoder.encode(value))
178
- return [...new Uint8Array(digest)]
179
- .map((byte) => byte.toString(16).padStart(2, '0'))
180
- .join('')
181
- }
182
-
183
- const timestampMs = (value: string | null): number | null =>
184
- value === null ? null : new Date(value).getTime()
185
-
186
- /** Compute the server-compatible lifecycle invalidation token. */
187
- export const devtoolsSessionRevision = (detail: {
188
- events: readonly DevtoolsWireEvent[]
189
- snapshots: readonly DevtoolsWireSnapshot[]
190
- }): Promise<string> =>
191
- sha256(
192
- JSON.stringify({
193
- events: detail.events.map((event) => [
194
- event.id,
195
- event.index,
196
- event.cause?.index ?? null,
197
- event.cause?.attempt ?? null,
198
- timestampMs(event.processedAt),
199
- event.processedByAttempt,
200
- timestampMs(event.firstClaimedAt),
201
- timestampMs(event.lastClaimedAt),
202
- event.attemptCount,
203
- event.failureCount,
204
- timestampMs(event.lastFailedAt),
205
- event.lastFailedAttempt,
206
- event.lastError,
207
- timestampMs(event.failedAt),
208
- ]),
209
- snapshots: detail.snapshots.map((snapshot) => [
210
- snapshot.reducerName,
211
- snapshot.index,
212
- snapshot.updatedAt === null
213
- ? 0
214
- : new Date(snapshot.updatedAt).getTime(),
215
- ]),
216
- }),
217
- )
218
-
219
- const encodeRecords = (records: readonly unknown[]): string =>
220
- records.map((record) => JSON.stringify(record)).join('\n') + '\n'
221
-
222
- const asCapturedAt = (value: string | Date | undefined): string => {
223
- if (value instanceof Date) {
224
- if (Number.isNaN(value.getTime())) {
225
- throw new TypeError('capturedAt must be a canonical ISO date')
226
- }
227
- return value.toISOString()
228
- }
229
- if (value === undefined) return new Date().toISOString()
230
- const parsed = new Date(value)
231
- if (Number.isNaN(parsed.getTime()) || parsed.toISOString() !== value) {
232
- throw new TypeError(
233
- 'capturedAt must be a canonical ISO date string or Date',
234
- )
235
- }
236
- return value
237
- }
238
-
239
- export async function encodeDevtoolsCapture(
240
- detail: DevtoolsSessionDetail,
241
- options: { capturedAt?: string | Date } = {},
242
- ): Promise<string> {
243
- const manifest: DevtoolsCaptureManifest = {
244
- kind: 'a2.capture',
245
- captureVersion: DEVTOOLS_CAPTURE_VERSION,
246
- protocolVersion: DEVTOOLS_PROTOCOL_VERSION,
247
- contract: detail.contract,
248
- sessionId: detail.sessionId,
249
- revision: detail.revision,
250
- capturedAt: asCapturedAt(options.capturedAt),
251
- fidelity: 'exact',
252
- }
253
- const events = detail.events.map((event): DevtoolsCaptureEvent => ({
254
- ...event,
255
- kind: 'event',
256
- }))
257
- const snapshots = detail.snapshots.map(
258
- (snapshot): DevtoolsCaptureSnapshot => ({
259
- ...snapshot,
260
- kind: 'snapshot',
261
- }),
262
- )
263
- const signedRecords = [manifest, ...events, ...snapshots]
264
- validateCanonicalJsonValue(signedRecords, 'capture')
265
- const signed = encodeRecords(signedRecords)
266
- const footer: DevtoolsCaptureFooter = {
267
- kind: 'end',
268
- captureVersion: DEVTOOLS_CAPTURE_VERSION,
269
- eventCount: events.length,
270
- snapshotCount: snapshots.length,
271
- maxIndex: events.at(-1)?.index ?? null,
272
- sha256: `sha256:${await sha256(signed)}`,
273
- }
274
- return `${signed}${JSON.stringify(footer)}\n`
275
- }
276
-
277
- const recordsFromText = (text: string): unknown[] => {
278
- if (text.length === 0) throw captureError('capture is empty')
279
- const lines = text.split('\n')
280
- if (lines.pop() !== '') {
281
- throw captureError('capture must end with a newline')
282
- }
283
- const records = lines.map((line, index) => {
284
- if (line.length === 0) {
285
- throw captureError(`record ${index + 1} is empty`)
286
- }
287
- try {
288
- return JSON.parse(line) as unknown
289
- } catch {
290
- throw captureError(`record ${index + 1} is not valid JSON`)
291
- }
292
- })
293
- if (encodeRecords(records) !== text) {
294
- throw captureError('capture must use canonical UTF-8 NDJSON encoding')
295
- }
296
- return records
297
- }
298
-
299
- const recordsFromValues = (records: readonly unknown[]): unknown[] => {
300
- try {
301
- validateCanonicalJsonValue(records, 'capture.records')
302
- return recordsFromText(encodeRecords(records))
303
- } catch (error) {
304
- if (error instanceof DevtoolsCaptureError) throw error
305
- throw captureError('capture records are not JSON-serializable')
306
- }
307
- }
308
-
309
- const captureRecordsFrom = (value: unknown): unknown[] => {
310
- if (typeof value === 'string') return recordsFromText(value)
311
- if (value instanceof Uint8Array) {
312
- try {
313
- return recordsFromText(decoder.decode(value))
314
- } catch (error) {
315
- if (error instanceof DevtoolsCaptureError) throw error
316
- throw captureError('capture is not valid UTF-8')
317
- }
318
- }
319
- if (Array.isArray(value)) return recordsFromValues(value)
320
- const capture = objectValue(value)
321
- if (capture) {
322
- if (Array.isArray(capture['records'])) {
323
- return recordsFromValues(capture['records'])
324
- }
325
- const manifest = capture['manifest']
326
- const events = capture['events']
327
- const snapshots = capture['snapshots']
328
- const footer = capture['footer']
329
- if (Array.isArray(events) && Array.isArray(snapshots)) {
330
- return recordsFromValues([manifest, ...events, ...snapshots, footer])
331
- }
332
- }
333
- throw captureError('expected NDJSON text, bytes, records, or a capture')
334
- }
335
-
336
- const stringField = (
337
- value: Record<string, unknown>,
338
- field: string,
339
- record: string,
340
- ): string => {
341
- const candidate = value[field]
342
- if (typeof candidate !== 'string' || candidate.length === 0) {
343
- throw captureError(`${record}.${field} must be a non-empty string`)
344
- }
345
- return candidate
346
- }
347
-
348
- const integerField = (
349
- value: Record<string, unknown>,
350
- field: string,
351
- record: string,
352
- ): number => {
353
- const candidate = value[field]
354
- if (!Number.isSafeInteger(candidate) || (candidate as number) < 0) {
355
- throw captureError(`${record}.${field} must be a non-negative integer`)
356
- }
357
- return candidate as number
358
- }
359
-
360
- const positiveInteger = (value: unknown): value is number =>
361
- Number.isSafeInteger(value) && (value as number) > 0
362
-
363
- const nonNegativeInteger = (value: unknown): value is number =>
364
- Number.isSafeInteger(value) && (value as number) >= 0
365
-
366
- const canonicalDate = (value: unknown): value is string => {
367
- if (typeof value !== 'string') return false
368
- const parsed = new Date(value)
369
- return !Number.isNaN(parsed.getTime()) && parsed.toISOString() === value
370
- }
371
-
372
- const optionalDate = (value: unknown): value is string | null =>
373
- value === null || canonicalDate(value)
374
-
375
- const nullableString = (value: unknown): value is string | null =>
376
- value === null || typeof value === 'string'
377
-
378
- const nullablePositiveInteger = (value: unknown): value is number | null =>
379
- value === null || positiveInteger(value)
380
-
381
- const validateDate = (
382
- value: unknown,
383
- field: string,
384
- record: string,
385
- nullable: boolean,
386
- ): void => {
387
- if (nullable ? optionalDate(value) : canonicalDate(value)) return
388
- throw captureError(
389
- `${record}.${field} has an invalid timestamp; expected a canonical ISO date${nullable ? ' or null' : ''}`,
390
- )
391
- }
392
-
393
- const validateCause = (value: unknown, record: string): void => {
394
- if (value === null) return
395
- const cause = objectValue(value)
396
- if (
397
- cause === null ||
398
- !positiveInteger(cause['index']) ||
399
- !positiveInteger(cause['attempt']) ||
400
- (cause['batchSize'] !== undefined && !positiveInteger(cause['batchSize']))
401
- ) {
402
- throw captureError(`${record}.cause is invalid`)
403
- }
404
- }
405
-
406
- const validateEventRecord = (
407
- record: Record<string, unknown>,
408
- label: string,
409
- ): void => {
410
- stringField(record, 'id', label)
411
- stringField(record, 'type', label)
412
- stringField(record, 'sessionId', label)
413
- if (!Object.hasOwn(record, 'payload')) {
414
- throw captureError(`${label}.payload is required`)
415
- }
416
- if (!positiveInteger(record['index'])) {
417
- throw captureError(`${label}.index must be a positive integer`)
418
- }
419
- validateDate(record['createdAt'], 'createdAt', label, false)
420
- validateCause(record['cause'], label)
421
- if (!nullableString(record['lane'])) {
422
- throw captureError(`${label}.lane must be a string or null`)
423
- }
424
- validateDate(record['processedAt'], 'processedAt', label, true)
425
- if (!nullablePositiveInteger(record['processedByAttempt'])) {
426
- throw captureError(
427
- `${label}.processedByAttempt must be a positive integer or null`,
428
- )
429
- }
430
- validateDate(record['firstClaimedAt'], 'firstClaimedAt', label, true)
431
- validateDate(record['lastClaimedAt'], 'lastClaimedAt', label, true)
432
- if (!nonNegativeInteger(record['attemptCount'])) {
433
- throw captureError(`${label}.attemptCount must be a non-negative integer`)
434
- }
435
- if (!nullableString(record['claimHolder'])) {
436
- throw captureError(`${label}.claimHolder must be a string or null`)
437
- }
438
- validateDate(record['claimExpiresAt'], 'claimExpiresAt', label, true)
439
- if (!nonNegativeInteger(record['failureCount'])) {
440
- throw captureError(`${label}.failureCount must be a non-negative integer`)
441
- }
442
- validateDate(record['lastFailedAt'], 'lastFailedAt', label, true)
443
- if (!nullablePositiveInteger(record['lastFailedAttempt'])) {
444
- throw captureError(
445
- `${label}.lastFailedAttempt must be a positive integer or null`,
446
- )
447
- }
448
- if (!nullableString(record['lastError'])) {
449
- throw captureError(`${label}.lastError must be a string or null`)
450
- }
451
- validateDate(record['failedAt'], 'failedAt', label, true)
452
- const returned = record['returnedEventIds']
453
- if (
454
- returned !== null &&
455
- (!Array.isArray(returned) ||
456
- returned.some((id) => typeof id !== 'string' || id.length === 0))
457
- ) {
458
- throw captureError(`${label}.returnedEventIds is invalid`)
459
- }
460
- }
461
-
462
- const validateSnapshotRecord = (
463
- record: Record<string, unknown>,
464
- label: string,
465
- ): void => {
466
- stringField(record, 'reducerName', label)
467
- if (!nonNegativeInteger(record['index'])) {
468
- throw captureError(`${label}.index must be a non-negative integer`)
469
- }
470
- validateDate(record['updatedAt'], 'updatedAt', label, true)
471
- }
472
-
473
- function validateCanonicalJsonValue(
474
- value: unknown,
475
- path: string,
476
- ancestors = new WeakSet<object>(),
477
- ): void {
478
- if (
479
- value === null ||
480
- typeof value === 'string' ||
481
- typeof value === 'boolean'
482
- ) {
483
- return
484
- }
485
- if (typeof value === 'number') {
486
- if (!Number.isFinite(value) || Object.is(value, -0)) {
487
- throw captureError(`${path} is not a canonical JSON number`)
488
- }
489
- return
490
- }
491
- if (Array.isArray(value)) {
492
- if (ancestors.has(value)) {
493
- throw captureError(`${path} is not a canonical JSON tree`)
494
- }
495
- ancestors.add(value)
496
- try {
497
- const keys = Object.keys(value)
498
- const ownKeys = Reflect.ownKeys(value)
499
- if (
500
- keys.length !== value.length ||
501
- ownKeys.length !== keys.length + 1 ||
502
- !ownKeys.includes('length') ||
503
- keys.some((key, index) => key !== String(index))
504
- ) {
505
- throw captureError(`${path} is not a canonical JSON array`)
506
- }
507
- value.forEach((entry, index) =>
508
- validateCanonicalJsonValue(entry, `${path}[${index}]`, ancestors),
509
- )
510
- } finally {
511
- ancestors.delete(value)
512
- }
513
- return
514
- }
515
- const object = objectValue(value)
516
- if (
517
- object === null ||
518
- (Object.getPrototypeOf(object) !== Object.prototype &&
519
- Object.getPrototypeOf(object) !== null)
520
- ) {
521
- throw captureError(`${path} is not a canonical JSON value`)
522
- }
523
- if (ancestors.has(object)) {
524
- throw captureError(`${path} is not a canonical JSON tree`)
525
- }
526
- ancestors.add(object)
527
- try {
528
- const keys = Object.keys(object)
529
- if (
530
- Reflect.ownKeys(object).length !== keys.length ||
531
- keys.some(
532
- (key) => !Object.prototype.propertyIsEnumerable.call(object, key),
533
- )
534
- ) {
535
- throw captureError(`${path} is not a canonical JSON object`)
536
- }
537
- for (const key of keys) {
538
- validateCanonicalJsonValue(object[key], `${path}.${key}`, ancestors)
539
- }
540
- } finally {
541
- ancestors.delete(object)
542
- }
543
- }
544
-
545
- export function parseDevtoolsCapture(value: unknown): DevtoolsCapture {
546
- const records = captureRecordsFrom(value)
547
- if (records.length < 2) {
548
- throw captureError('capture needs a manifest and footer')
549
- }
550
- const manifestValue = objectValue(records[0])
551
- if (manifestValue?.['kind'] !== 'a2.capture') {
552
- throw captureError("first record must have kind 'a2.capture'")
553
- }
554
- if (manifestValue['captureVersion'] !== DEVTOOLS_CAPTURE_VERSION) {
555
- throw captureError(
556
- `unsupported capture version '${String(manifestValue['captureVersion'])}'`,
557
- )
558
- }
559
- if (manifestValue['protocolVersion'] !== DEVTOOLS_PROTOCOL_VERSION) {
560
- throw captureError(
561
- `unsupported protocol version '${String(manifestValue['protocolVersion'])}'`,
562
- )
563
- }
564
- if (manifestValue['fidelity'] !== 'exact') {
565
- throw captureError("manifest.fidelity must be 'exact'")
566
- }
567
- const manifest = manifestValue as DevtoolsCaptureManifest
568
- const contract = stringField(manifestValue, 'contract', 'manifest')
569
- const sessionId = stringField(manifestValue, 'sessionId', 'manifest')
570
- const revision = stringField(manifestValue, 'revision', 'manifest')
571
- const capturedAt = stringField(manifestValue, 'capturedAt', 'manifest')
572
- if (!canonicalDate(capturedAt)) {
573
- throw captureError('manifest.capturedAt must be a canonical ISO date')
574
- }
575
-
576
- const footerValue = objectValue(records.at(-1))
577
- if (footerValue?.['kind'] !== 'end') {
578
- throw captureError("last record must have kind 'end'")
579
- }
580
- if (footerValue['captureVersion'] !== DEVTOOLS_CAPTURE_VERSION) {
581
- throw captureError('footer capture version does not match')
582
- }
583
- const footer = footerValue as DevtoolsCaptureFooter
584
- integerField(footerValue, 'eventCount', 'footer')
585
- integerField(footerValue, 'snapshotCount', 'footer')
586
- stringField(footerValue, 'sha256', 'footer')
587
- if (
588
- footerValue['maxIndex'] !== null &&
589
- (!Number.isSafeInteger(footerValue['maxIndex']) ||
590
- (footerValue['maxIndex'] as number) < 1)
591
- ) {
592
- throw captureError('footer.maxIndex must be null or a positive integer')
593
- }
594
-
595
- const events: DevtoolsCaptureEvent[] = []
596
- const snapshots: DevtoolsCaptureSnapshot[] = []
597
- let sawSnapshot = false
598
- for (let index = 1; index < records.length - 1; index += 1) {
599
- const record = objectValue(records[index])
600
- if (record?.['kind'] === 'event') {
601
- if (sawSnapshot) {
602
- throw captureError('event records must precede snapshot records')
603
- }
604
- validateEventRecord(record, `record ${index + 1}`)
605
- events.push(record as DevtoolsCaptureEvent)
606
- } else if (record?.['kind'] === 'snapshot') {
607
- sawSnapshot = true
608
- validateSnapshotRecord(record, `record ${index + 1}`)
609
- snapshots.push(record as DevtoolsCaptureSnapshot)
610
- } else {
611
- throw captureError(`record ${index + 1} has an unknown kind`)
612
- }
613
- }
614
-
615
- return {
616
- contract,
617
- sessionId,
618
- revision,
619
- capturedAt,
620
- manifest,
621
- events,
622
- snapshots,
623
- footer,
624
- records: records as DevtoolsCaptureRecord[],
625
- }
626
- }
627
-
628
- export async function verifyDevtoolsCapture(
629
- capture: DevtoolsCapture,
630
- ): Promise<void> {
631
- validateCanonicalJsonValue(capture.records, 'capture.records')
632
- parseDevtoolsCapture(capture.records)
633
- const expectedRecords: DevtoolsCaptureRecord[] = [
634
- capture.manifest,
635
- ...capture.events,
636
- ...capture.snapshots,
637
- capture.footer,
638
- ]
639
- if (
640
- capture.records.length !== expectedRecords.length ||
641
- capture.records.some((record, index) => record !== expectedRecords[index])
642
- ) {
643
- throw captureError('record sequence does not match the capture')
644
- }
645
- if (
646
- capture.contract !== capture.manifest.contract ||
647
- capture.sessionId !== capture.manifest.sessionId ||
648
- capture.revision !== capture.manifest.revision ||
649
- capture.capturedAt !== capture.manifest.capturedAt
650
- ) {
651
- throw captureError('capture identity does not match its manifest')
652
- }
653
- if (!canonicalDate(capture.capturedAt)) {
654
- throw captureError('manifest.capturedAt must be a canonical ISO date')
655
- }
656
- if (capture.footer.eventCount !== capture.events.length) {
657
- throw captureError('footer event count does not match')
658
- }
659
- if (capture.footer.snapshotCount !== capture.snapshots.length) {
660
- throw captureError('footer snapshot count does not match')
661
- }
662
-
663
- let previousIndex = 0
664
- const ids = new Set<string>()
665
- for (const event of capture.events) {
666
- if (event.kind !== 'event') throw captureError('invalid event record kind')
667
- validateEventRecord(
668
- event as unknown as Record<string, unknown>,
669
- `event #${event.index}`,
670
- )
671
- if (
672
- !Number.isSafeInteger(event.index) ||
673
- event.index !== previousIndex + 1
674
- ) {
675
- throw captureError('event indexes must be contiguous from 1')
676
- }
677
- previousIndex = event.index
678
- if (event.sessionId !== capture.sessionId) {
679
- throw captureError(`event #${event.index} belongs to another session`)
680
- }
681
- if (ids.has(event.id)) {
682
- throw captureError(`event #${event.index} repeats id '${event.id}'`)
683
- }
684
- ids.add(event.id)
685
- }
686
-
687
- for (const snapshot of capture.snapshots) {
688
- if (snapshot.kind !== 'snapshot') {
689
- throw captureError('invalid snapshot record kind')
690
- }
691
- validateSnapshotRecord(
692
- snapshot as unknown as Record<string, unknown>,
693
- `snapshot '${snapshot.reducerName}'`,
694
- )
695
- if (
696
- !Number.isSafeInteger(snapshot.index) ||
697
- snapshot.index > previousIndex
698
- ) {
699
- throw captureError(`snapshot '${snapshot.reducerName}' is invalid`)
700
- }
701
- }
702
-
703
- const maxIndex = capture.events.at(-1)?.index ?? null
704
- if (capture.footer.maxIndex !== maxIndex) {
705
- throw captureError('footer max index does not match')
706
- }
707
- if (!/^sha256:[\da-f]{64}$/.test(capture.footer.sha256)) {
708
- throw captureError('footer.sha256 is malformed')
709
- }
710
- const expected = `sha256:${await sha256(
711
- encodeRecords(capture.records.slice(0, -1)),
712
- )}`
713
- if (capture.footer.sha256 !== expected) {
714
- throw captureError('capture digest does not match')
715
- }
716
- }