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.
- package/CHANGELOG.md +22 -0
- package/dist/ai-server.d.ts +4 -5
- package/dist/ai-server.d.ts.map +1 -1
- package/dist/ai-server.js +8 -7
- package/dist/ai-server.js.map +1 -1
- package/dist/ai.d.ts +334 -2
- package/dist/ai.d.ts.map +1 -0
- package/dist/ai.js +1 -1
- package/dist/client.d.ts +202 -2
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +1025 -1
- package/dist/client.js.map +1 -0
- package/dist/errors-BQuJpe82.js.map +1 -1
- package/dist/index.d.ts +22 -3
- package/dist/index.d.ts.map +1 -0
- package/dist/{internal-DstsI6Re.js → internal-DRXJ56EI.js} +5 -28
- package/dist/internal-DRXJ56EI.js.map +1 -0
- package/dist/react.d.ts +1 -1
- package/dist/react.js +1 -1
- package/dist/scheduler-qstash.d.ts +3 -3
- package/dist/scheduler-qstash.js +4 -5
- package/dist/scheduler-qstash.js.map +1 -1
- package/dist/scheduler-vercel.d.ts +2 -2
- package/dist/scheduler-vercel.js +4 -4
- package/dist/scheduler-vercel.js.map +1 -1
- package/dist/{server-Duw6MVlB.js → server-286j79Mt.js} +708 -79
- package/dist/server-286j79Mt.js.map +1 -0
- package/dist/{server-DpvjhdoE.d.ts → server-DgXmORIq.d.ts} +67 -49
- package/dist/server-DgXmORIq.d.ts.map +1 -0
- package/dist/server.d.ts +3 -3
- package/dist/server.js +1 -1
- package/dist/store-N8PXxDAS.js.map +1 -1
- package/dist/{store-DysUkTH3.d.ts → store-flRz1OWh.d.ts} +2 -57
- package/dist/store-flRz1OWh.d.ts.map +1 -0
- package/dist/store-memory.d.ts +1 -1
- package/dist/store-memory.d.ts.map +1 -1
- package/dist/store-memory.js +1 -59
- package/dist/store-memory.js.map +1 -1
- package/dist/{store-polling-dSeLxzfb.js → store-polling-6DW7F1DT.js} +2 -2
- package/dist/{store-polling-dSeLxzfb.js.map → store-polling-6DW7F1DT.js.map} +1 -1
- package/dist/store-postgres.d.ts +1 -1
- package/dist/store-postgres.js +1 -81
- package/dist/store-postgres.js.map +1 -1
- package/dist/{store-redis-core-BFLwz0Wj.js → store-redis-core-DEYO8Ryv.js} +48 -134
- package/dist/store-redis-core-DEYO8Ryv.js.map +1 -0
- package/dist/store-redis-http.d.ts +1 -1
- package/dist/store-redis-http.js +2 -3
- package/dist/store-redis-http.js.map +1 -1
- package/dist/store-redis.d.ts +1 -1
- package/dist/store-redis.js +3 -4
- package/dist/store-redis.js.map +1 -1
- package/dist/store-sqlite.d.ts +1 -1
- package/dist/store-sqlite.d.ts.map +1 -1
- package/dist/store-sqlite.js +1 -72
- package/dist/store-sqlite.js.map +1 -1
- package/dist/{wire-BFQmSJ-9.js → wire-B6te_wns.js} +4 -3
- package/dist/wire-B6te_wns.js.map +1 -0
- package/docs/guides/03-react.mdx +20 -28
- package/docs/guides/05-production.mdx +9 -11
- package/docs/guides/06-ai-agents.mdx +8 -14
- package/docs/guides/09-presence.mdx +14 -19
- package/docs/guides/10-transports.mdx +104 -86
- package/docs/reference/01-api.mdx +143 -277
- package/docs/reference/02-errors.mdx +5 -7
- package/package.json +1 -14
- package/src/ai-server.ts +9 -5
- package/src/client.ts +2 -2
- package/src/errors.ts +1 -0
- package/src/internal.ts +3 -62
- package/src/push-envelope.ts +24 -21
- package/src/scheduler-qstash.ts +3 -3
- package/src/scheduler-vercel.ts +2 -2
- package/src/server-fetch.ts +344 -0
- package/src/server.ts +73 -225
- package/src/session-socket.ts +36 -20
- package/src/sse.ts +2 -2
- package/src/store-memory.ts +0 -81
- package/src/store-postgres.ts +0 -100
- package/src/store-redis-core.ts +47 -211
- package/src/store-redis-http.ts +0 -1
- package/src/store-redis.ts +0 -1
- package/src/store-sqlite.ts +0 -119
- package/src/store.ts +0 -60
- package/src/wire.ts +2 -1
- package/dist/ai-D_PGS-JR.d.ts +0 -334
- package/dist/ai-D_PGS-JR.d.ts.map +0 -1
- package/dist/cli-B3VuxoDe.js +0 -599
- package/dist/cli-B3VuxoDe.js.map +0 -1
- package/dist/cli-bin.d.ts +0 -1
- package/dist/cli-bin.js +0 -7
- package/dist/cli-bin.js.map +0 -1
- package/dist/cli.d.ts +0 -20
- package/dist/cli.d.ts.map +0 -1
- package/dist/cli.js +0 -2
- package/dist/client-CdMqi7mC.d.ts +0 -202
- package/dist/client-CdMqi7mC.d.ts.map +0 -1
- package/dist/client-Dj5d3SP_.js +0 -1026
- package/dist/client-Dj5d3SP_.js.map +0 -1
- package/dist/devtools-J_jZ2vQf.d.ts +0 -152
- package/dist/devtools-J_jZ2vQf.d.ts.map +0 -1
- package/dist/devtools-kJJaORn-.js +0 -340
- package/dist/devtools-kJJaORn-.js.map +0 -1
- package/dist/devtools-server.browser.d.ts +0 -1
- package/dist/devtools-server.browser.js +0 -6
- package/dist/devtools-server.browser.js.map +0 -1
- package/dist/devtools-server.d.ts +0 -23
- package/dist/devtools-server.d.ts.map +0 -1
- package/dist/devtools-server.js +0 -1270
- package/dist/devtools-server.js.map +0 -1
- package/dist/devtools.d.ts +0 -2
- package/dist/devtools.js +0 -2
- package/dist/errors-W6nwJ-fm.d.ts +0 -21
- package/dist/errors-W6nwJ-fm.d.ts.map +0 -1
- package/dist/http.d.ts +0 -151
- package/dist/http.d.ts.map +0 -1
- package/dist/http.js +0 -706
- package/dist/http.js.map +0 -1
- package/dist/inspection-DaxB5jM2.js +0 -13
- package/dist/inspection-DaxB5jM2.js.map +0 -1
- package/dist/internal-DstsI6Re.js.map +0 -1
- package/dist/platform-B4TnJtWu.js +0 -34
- package/dist/platform-B4TnJtWu.js.map +0 -1
- package/dist/server-DpvjhdoE.d.ts.map +0 -1
- package/dist/server-Duw6MVlB.js.map +0 -1
- package/dist/store-DysUkTH3.d.ts.map +0 -1
- package/dist/store-redis-core-BFLwz0Wj.js.map +0 -1
- package/dist/testing.browser.d.ts +0 -1
- package/dist/testing.browser.js +0 -6
- package/dist/testing.browser.js.map +0 -1
- package/dist/testing.d.ts +0 -32
- package/dist/testing.d.ts.map +0 -1
- package/dist/testing.js +0 -103
- package/dist/testing.js.map +0 -1
- package/dist/wire-BFQmSJ-9.js.map +0 -1
- package/docs/guides/07-devtools.mdx +0 -229
- package/src/cli-bin.ts +0 -5
- package/src/cli.ts +0 -1046
- package/src/devtools-app.ts +0 -989
- package/src/devtools-server.browser.ts +0 -5
- package/src/devtools-server.ts +0 -604
- package/src/devtools.ts +0 -716
- package/src/http.ts +0 -394
- package/src/inspection.ts +0 -39
- package/src/testing.browser.ts +0 -5
- 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
|
-
}
|