experimental-a2 0.3.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +207 -0
- package/dist/ai-CFNeCrRl.d.ts +334 -0
- package/dist/ai-CFNeCrRl.d.ts.map +1 -0
- package/dist/ai-server.browser.js +2 -0
- package/dist/ai-server.browser.js.map +1 -0
- package/dist/ai-server.d.ts +49 -9
- package/dist/ai-server.d.ts.map +1 -0
- package/dist/ai-server.js +123 -49
- package/dist/ai-server.js.map +1 -0
- package/dist/ai.d.ts +2 -303
- package/dist/ai.js +233 -86
- package/dist/ai.js.map +1 -0
- package/dist/cli-B3VuxoDe.js +599 -0
- package/dist/cli-B3VuxoDe.js.map +1 -0
- package/dist/cli-bin.d.ts +1 -0
- package/dist/cli-bin.js +7 -0
- package/dist/cli-bin.js.map +1 -0
- package/dist/cli.d.ts +20 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +2 -0
- package/dist/client-BKlyLiOU.js +1008 -0
- package/dist/client-BKlyLiOU.js.map +1 -0
- package/dist/client-D7mvIXrF.d.ts +191 -0
- package/dist/client-D7mvIXrF.d.ts.map +1 -0
- package/dist/client.d.ts +2 -90
- package/dist/client.js +1 -409
- package/dist/{contract-CG_adnu_.js → contract-48bUMgcL.js} +12 -2
- package/dist/contract-48bUMgcL.js.map +1 -0
- package/dist/{contract-C_3dIIEU.d.ts → contract-jIfaR085.d.ts} +63 -8
- package/dist/contract-jIfaR085.d.ts.map +1 -0
- package/dist/devtools-J_jZ2vQf.d.ts +152 -0
- package/dist/devtools-J_jZ2vQf.d.ts.map +1 -0
- package/dist/devtools-kJJaORn-.js +340 -0
- package/dist/devtools-kJJaORn-.js.map +1 -0
- package/dist/devtools-server.browser.js +3 -1
- package/dist/devtools-server.browser.js.map +1 -0
- package/dist/devtools-server.d.ts +4 -3
- package/dist/devtools-server.d.ts.map +1 -0
- package/dist/devtools-server.js +226 -43
- package/dist/devtools-server.js.map +1 -0
- package/dist/devtools.d.ts +2 -0
- package/dist/devtools.js +2 -0
- package/dist/{errors-BJRMd-h6.js → errors-BQuJpe82.js} +6 -4
- package/dist/errors-BQuJpe82.js.map +1 -0
- package/dist/{errors-xL_JTXsY.d.ts → errors-W6nwJ-fm.d.ts} +3 -2
- package/dist/errors-W6nwJ-fm.d.ts.map +1 -0
- package/dist/http.d.ts +135 -28
- package/dist/http.d.ts.map +1 -0
- package/dist/http.js +647 -61
- package/dist/http.js.map +1 -0
- package/dist/{idempotent-replay-BMyHrP0L.js → idempotent-replay-DuqEkYA7.js} +4 -2
- package/dist/idempotent-replay-DuqEkYA7.js.map +1 -0
- package/dist/index.d.ts +5 -5
- package/dist/index.js +2 -2
- package/dist/{inspection-E7qbD0Xj.js → inspection-DaxB5jM2.js} +4 -1
- package/dist/inspection-DaxB5jM2.js.map +1 -0
- package/dist/internal-DstsI6Re.js +211 -0
- package/dist/internal-DstsI6Re.js.map +1 -0
- package/dist/otel.d.ts +3 -2
- package/dist/otel.d.ts.map +1 -0
- package/dist/otel.js +2 -0
- package/dist/otel.js.map +1 -0
- package/dist/platform-B4TnJtWu.js +34 -0
- package/dist/platform-B4TnJtWu.js.map +1 -0
- package/dist/react.d.ts +47 -15
- package/dist/react.d.ts.map +1 -0
- package/dist/react.js +30 -15
- package/dist/react.js.map +1 -0
- package/dist/retryable-lazy-DZWmHpii.js +2 -0
- package/dist/retryable-lazy-DZWmHpii.js.map +1 -0
- package/dist/scheduler-qstash.d.ts +79 -0
- package/dist/scheduler-qstash.d.ts.map +1 -0
- package/dist/scheduler-qstash.js +501 -0
- package/dist/scheduler-qstash.js.map +1 -0
- package/dist/scheduler-task-BpzhPnRS.js +56 -0
- package/dist/scheduler-task-BpzhPnRS.js.map +1 -0
- package/dist/{recovery-vercel.d.ts → scheduler-vercel.d.ts} +18 -24
- package/dist/scheduler-vercel.d.ts.map +1 -0
- package/dist/scheduler-vercel.js +228 -0
- package/dist/scheduler-vercel.js.map +1 -0
- package/dist/server-C72KOw51.js +1457 -0
- package/dist/server-C72KOw51.js.map +1 -0
- package/dist/server-DUF9pjsx.d.ts +261 -0
- package/dist/server-DUF9pjsx.d.ts.map +1 -0
- package/dist/server.browser.js +2 -0
- package/dist/server.browser.js.map +1 -0
- package/dist/server.d.ts +4 -155
- package/dist/server.js +2 -2
- package/dist/{log-ldf5g8Cx.d.ts → store-DysUkTH3.d.ts} +120 -35
- package/dist/store-DysUkTH3.d.ts.map +1 -0
- package/dist/{log-yJbXUf72.js → store-N8PXxDAS.js} +3 -1
- package/dist/store-N8PXxDAS.js.map +1 -0
- package/dist/store-codec-DTG0Ftek.js +10 -0
- package/dist/store-codec-DTG0Ftek.js.map +1 -0
- package/dist/store-memory.d.ts +12 -0
- package/dist/store-memory.d.ts.map +1 -0
- package/dist/{log-memory.js → store-memory.js} +145 -34
- package/dist/store-memory.js.map +1 -0
- package/dist/{log-polling-DZ1MiKLg.js → store-polling-dSeLxzfb.js} +7 -5
- package/dist/store-polling-dSeLxzfb.js.map +1 -0
- package/dist/{log-postgres.d.ts → store-postgres.d.ts} +7 -6
- package/dist/store-postgres.d.ts.map +1 -0
- package/dist/{log-postgres.js → store-postgres.js} +214 -24
- package/dist/store-postgres.js.map +1 -0
- package/dist/{log-redis-core-CyJ5L8yR.js → store-redis-core-BFLwz0Wj.js} +271 -66
- package/dist/store-redis-core-BFLwz0Wj.js.map +1 -0
- package/dist/{log-redis-http.d.ts → store-redis-http.d.ts} +8 -7
- package/dist/store-redis-http.d.ts.map +1 -0
- package/dist/{log-redis-http.js → store-redis-http.js} +22 -12
- package/dist/store-redis-http.js.map +1 -0
- package/dist/{log-redis.d.ts → store-redis.d.ts} +7 -6
- package/dist/store-redis.d.ts.map +1 -0
- package/dist/{log-redis.js → store-redis.js} +101 -39
- package/dist/store-redis.js.map +1 -0
- package/dist/{log-sqlite.d.ts → store-sqlite.d.ts} +7 -6
- package/dist/store-sqlite.d.ts.map +1 -0
- package/dist/{log-sqlite.js → store-sqlite.js} +118 -22
- package/dist/store-sqlite.js.map +1 -0
- package/dist/{telemetry-Cso0qyHQ.d.ts → telemetry-BjYHTfh2.d.ts} +3 -2
- package/dist/telemetry-BjYHTfh2.d.ts.map +1 -0
- package/dist/testing.browser.d.ts +1 -0
- package/dist/testing.browser.js +6 -0
- package/dist/testing.browser.js.map +1 -0
- package/dist/testing.d.ts +32 -0
- package/dist/testing.d.ts.map +1 -0
- package/dist/testing.js +103 -0
- package/dist/testing.js.map +1 -0
- package/dist/validate-XKT4FSNn.js +2 -0
- package/dist/validate-XKT4FSNn.js.map +1 -0
- package/dist/wire-BFQmSJ-9.js +284 -0
- package/dist/wire-BFQmSJ-9.js.map +1 -0
- package/docs/01-quickstart.mdx +3 -3
- package/docs/concepts/01-contracts.mdx +18 -13
- package/docs/concepts/02-handlers.mdx +7 -7
- package/docs/concepts/03-durability.mdx +21 -21
- package/docs/concepts/04-state.mdx +18 -21
- package/docs/guides/01-timers.mdx +154 -49
- package/docs/guides/02-cancellation.mdx +30 -4
- package/docs/guides/03-react.mdx +76 -57
- package/docs/guides/04-local-first.mdx +1 -1
- package/docs/guides/05-production.mdx +310 -61
- package/docs/guides/06-ai-agents.mdx +252 -74
- package/docs/guides/07-devtools.mdx +137 -12
- package/docs/guides/08-application-data.mdx +1 -1
- package/docs/guides/09-presence.mdx +263 -0
- package/docs/guides/10-transports.mdx +140 -0
- package/docs/index.mdx +18 -14
- package/docs/reference/01-api.mdx +839 -110
- package/docs/reference/02-errors.mdx +13 -6
- package/package.json +24 -7
- package/src/ai-coordinator.ts +358 -0
- package/src/ai-projector.ts +524 -0
- package/src/ai-sdk-step.ts +261 -0
- package/src/ai-server.browser.ts +5 -0
- package/src/ai-server.ts +1719 -0
- package/src/ai.ts +2155 -0
- package/src/cache-indexeddb.ts +10 -0
- package/src/cli-bin.ts +5 -0
- package/src/cli.ts +1046 -0
- package/src/client.ts +1826 -0
- package/src/contract.ts +206 -0
- package/src/deterministic-id.ts +72 -0
- package/src/devtools-app.ts +989 -0
- package/src/devtools-server.browser.ts +5 -0
- package/src/devtools-server.ts +604 -0
- package/src/devtools.ts +716 -0
- package/src/errors.ts +50 -0
- package/src/http.ts +394 -0
- package/src/idempotent-replay.ts +53 -0
- package/src/index.ts +37 -0
- package/src/inspection.ts +39 -0
- package/src/internal.ts +426 -0
- package/src/otel.ts +59 -0
- package/src/platform.ts +60 -0
- package/src/push-envelope.ts +137 -0
- package/src/react.ts +284 -0
- package/src/reducer.ts +108 -0
- package/src/retryable-lazy.ts +27 -0
- package/src/scheduler-qstash.ts +915 -0
- package/src/scheduler-task.ts +106 -0
- package/src/scheduler-vercel.ts +437 -0
- package/src/server.browser.ts +12 -0
- package/src/server.ts +2700 -0
- package/src/session-socket.ts +548 -0
- package/src/sse.ts +141 -0
- package/src/standard-schema.ts +77 -0
- package/src/store-codec.ts +10 -0
- package/src/store-memory.ts +788 -0
- package/src/store-polling.ts +102 -0
- package/src/store-postgres.ts +1212 -0
- package/src/store-redis-core.ts +1494 -0
- package/src/store-redis-http.ts +116 -0
- package/src/store-redis.ts +458 -0
- package/src/store-sqlite.ts +1108 -0
- package/src/store.ts +385 -0
- package/src/telemetry.ts +47 -0
- package/src/testing.browser.ts +5 -0
- package/src/testing.ts +185 -0
- package/src/validate.ts +39 -0
- package/src/wire.ts +454 -0
- package/dist/internal-gCd5qMry.js +0 -44
- package/dist/log-memory.d.ts +0 -11
- package/dist/recovery-vercel.js +0 -119
- package/dist/server-BcLa4RFL.js +0 -877
- package/dist/wire-BVsgR8o9.js +0 -62
|
@@ -0,0 +1,604 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* experimental-a2/devtools/server — a read-only dashboard over A2's durable data.
|
|
3
|
+
* One Request handler serves the application, its private browser bundle,
|
|
4
|
+
* JSON inspection endpoints, and live SSE invalidations.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
DEVTOOLS_APP_CSS,
|
|
9
|
+
DEVTOOLS_APP_JS,
|
|
10
|
+
devtoolsHtml,
|
|
11
|
+
type DevtoolsBootstrap,
|
|
12
|
+
} from './devtools-app.ts'
|
|
13
|
+
import {
|
|
14
|
+
DEVTOOLS_CAPABILITIES,
|
|
15
|
+
DEVTOOLS_CAPTURE_MEDIA_TYPE,
|
|
16
|
+
DEVTOOLS_PROTOCOL_VERSION,
|
|
17
|
+
DEVTOOLS_SESSION_PAGE_LIMIT,
|
|
18
|
+
DEVTOOLS_SESSION_PAGE_MAX_LIMIT,
|
|
19
|
+
devtoolsSessionRevision,
|
|
20
|
+
encodeDevtoolsCapture,
|
|
21
|
+
type DevtoolsSessionPage,
|
|
22
|
+
type DevtoolsSessionDetail,
|
|
23
|
+
type DevtoolsWireEvent,
|
|
24
|
+
type DevtoolsWireSessionSummary,
|
|
25
|
+
type DevtoolsWireSnapshot,
|
|
26
|
+
} from './devtools.ts'
|
|
27
|
+
import {
|
|
28
|
+
DEVTOOLS_TIMINGS,
|
|
29
|
+
InspectionUnsupportedError,
|
|
30
|
+
serverInspection,
|
|
31
|
+
type ServerInspection,
|
|
32
|
+
} from './inspection.ts'
|
|
33
|
+
import type {
|
|
34
|
+
StoredEvent,
|
|
35
|
+
StoredSessionSummary,
|
|
36
|
+
StoredSnapshot,
|
|
37
|
+
} from './store.ts'
|
|
38
|
+
|
|
39
|
+
export type DevtoolsServer = {
|
|
40
|
+
readonly contract: { readonly name: string }
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export type DevtoolsAuthorization = (
|
|
44
|
+
request: Request,
|
|
45
|
+
) => boolean | Response | Promise<boolean | Response>
|
|
46
|
+
|
|
47
|
+
export type DevtoolsOptions = {
|
|
48
|
+
servers: readonly DevtoolsServer[]
|
|
49
|
+
/**
|
|
50
|
+
* Application-owned access control. Returning a Response short-circuits
|
|
51
|
+
* the request. Without this option, devtools is available only when
|
|
52
|
+
* NODE_ENV is exactly "development" and otherwise returns 404.
|
|
53
|
+
*/
|
|
54
|
+
authorize?: DevtoolsAuthorization
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export type Devtools = {
|
|
58
|
+
handler(): (request: Request) => Promise<Response>
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const MARKER = '/_a2/'
|
|
62
|
+
const encoder = new TextEncoder()
|
|
63
|
+
|
|
64
|
+
type InspectionDetail = {
|
|
65
|
+
events: StoredEvent[]
|
|
66
|
+
snapshots: StoredSnapshot[]
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const isDevelopment = (): boolean =>
|
|
70
|
+
typeof process !== 'undefined' && process.env?.['NODE_ENV'] === 'development'
|
|
71
|
+
|
|
72
|
+
const headers = (contentType: string): HeadersInit => ({
|
|
73
|
+
'content-type': contentType,
|
|
74
|
+
'cache-control': 'no-store',
|
|
75
|
+
'x-content-type-options': 'nosniff',
|
|
76
|
+
'referrer-policy': 'no-referrer',
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
const json = (value: unknown, init?: ResponseInit): Response =>
|
|
80
|
+
Response.json(value, {
|
|
81
|
+
...init,
|
|
82
|
+
headers: {
|
|
83
|
+
...headers('application/json; charset=utf-8'),
|
|
84
|
+
...init?.headers,
|
|
85
|
+
},
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
const notFound = (): Response =>
|
|
89
|
+
new Response('Not found', { status: 404, headers: headers('text/plain') })
|
|
90
|
+
|
|
91
|
+
const badRequest = (message: string): Response =>
|
|
92
|
+
json({ error: message }, { status: 400 })
|
|
93
|
+
|
|
94
|
+
const unavailable = (message: string): Response =>
|
|
95
|
+
json({ error: message }, { status: 501 })
|
|
96
|
+
|
|
97
|
+
const wireSummary = (
|
|
98
|
+
session: StoredSessionSummary,
|
|
99
|
+
): DevtoolsWireSessionSummary => ({
|
|
100
|
+
sessionId: session.sessionId,
|
|
101
|
+
eventCount: session.eventCount,
|
|
102
|
+
pendingCount: session.pendingCount,
|
|
103
|
+
failedCount: session.failedCount,
|
|
104
|
+
attemptCount: session.attemptCount,
|
|
105
|
+
failureCount: session.failureCount,
|
|
106
|
+
firstEventAt: session.firstEventAt.toISOString(),
|
|
107
|
+
updatedAt: session.updatedAt.toISOString(),
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
const wireEvent = (event: StoredEvent): DevtoolsWireEvent => ({
|
|
111
|
+
id: event.id,
|
|
112
|
+
type: event.type,
|
|
113
|
+
payload: event.payload,
|
|
114
|
+
index: event.index,
|
|
115
|
+
sessionId: event.sessionId,
|
|
116
|
+
createdAt: event.createdAt.toISOString(),
|
|
117
|
+
cause:
|
|
118
|
+
event.cause === null
|
|
119
|
+
? null
|
|
120
|
+
: {
|
|
121
|
+
index: event.cause.index,
|
|
122
|
+
attempt: event.cause.attempt,
|
|
123
|
+
...(event.cause.batchSize === undefined
|
|
124
|
+
? {}
|
|
125
|
+
: { batchSize: event.cause.batchSize }),
|
|
126
|
+
},
|
|
127
|
+
lane: event.lane,
|
|
128
|
+
processedAt: event.processedAt?.toISOString() ?? null,
|
|
129
|
+
processedByAttempt: event.processedByAttempt,
|
|
130
|
+
returnedEventIds: event.returnedEventIds,
|
|
131
|
+
firstClaimedAt: event.firstClaimedAt?.toISOString() ?? null,
|
|
132
|
+
lastClaimedAt: event.lastClaimedAt?.toISOString() ?? null,
|
|
133
|
+
attemptCount: event.attemptCount,
|
|
134
|
+
claimHolder: event.claimHolder,
|
|
135
|
+
claimExpiresAt: event.claimExpiresAt?.toISOString() ?? null,
|
|
136
|
+
failureCount: event.failureCount,
|
|
137
|
+
lastFailedAt: event.lastFailedAt?.toISOString() ?? null,
|
|
138
|
+
lastFailedAttempt: event.lastFailedAttempt,
|
|
139
|
+
lastError: event.lastError,
|
|
140
|
+
failedAt: event.failedAt?.toISOString() ?? null,
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
const wireSnapshot = (snapshot: StoredSnapshot): DevtoolsWireSnapshot => ({
|
|
144
|
+
reducerName: snapshot.reducerName,
|
|
145
|
+
index: snapshot.index,
|
|
146
|
+
// Redis snapshots written before inspection metadata have no durable
|
|
147
|
+
// timestamp. Epoch zero is the adapter's explicit "unknown" marker.
|
|
148
|
+
updatedAt:
|
|
149
|
+
snapshot.updatedAt.getTime() === 0
|
|
150
|
+
? null
|
|
151
|
+
: snapshot.updatedAt.toISOString(),
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
const sessionRevision = async (detail: InspectionDetail): Promise<string> => {
|
|
155
|
+
return devtoolsSessionRevision({
|
|
156
|
+
events: detail.events.map(wireEvent),
|
|
157
|
+
snapshots: detail.snapshots.map(wireSnapshot),
|
|
158
|
+
})
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const wireDetail = async (
|
|
162
|
+
contract: string,
|
|
163
|
+
sessionId: string,
|
|
164
|
+
detail: InspectionDetail,
|
|
165
|
+
): Promise<DevtoolsSessionDetail> => ({
|
|
166
|
+
protocolVersion: DEVTOOLS_PROTOCOL_VERSION,
|
|
167
|
+
contract,
|
|
168
|
+
sessionId,
|
|
169
|
+
revision: await sessionRevision(detail),
|
|
170
|
+
events: detail.events.map(wireEvent),
|
|
171
|
+
snapshots: detail.snapshots.map(wireSnapshot),
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
const readCompleteSession = async (
|
|
175
|
+
inspection: ServerInspection,
|
|
176
|
+
sessionId: string,
|
|
177
|
+
): Promise<InspectionDetail> => {
|
|
178
|
+
const events: StoredEvent[] = []
|
|
179
|
+
let snapshots: StoredSnapshot[] = []
|
|
180
|
+
let afterIndex = 0
|
|
181
|
+
let throughIndex: number | undefined
|
|
182
|
+
for (;;) {
|
|
183
|
+
// oxlint-disable-next-line no-await-in-loop -- each request advances one fixed-frontier page
|
|
184
|
+
const page = await inspection.readSessionPage(sessionId, {
|
|
185
|
+
afterIndex,
|
|
186
|
+
limit: DEVTOOLS_SESSION_PAGE_LIMIT,
|
|
187
|
+
...(throughIndex === undefined ? {} : { throughIndex }),
|
|
188
|
+
})
|
|
189
|
+
if (throughIndex === undefined) {
|
|
190
|
+
throughIndex = page.throughIndex
|
|
191
|
+
snapshots = page.snapshots
|
|
192
|
+
} else if (page.throughIndex !== throughIndex) {
|
|
193
|
+
throw new Error('inspection session frontier changed between pages')
|
|
194
|
+
}
|
|
195
|
+
if (events.length + page.events.length > page.throughIndex) {
|
|
196
|
+
throw new Error('inspection session page crossed its frozen frontier')
|
|
197
|
+
}
|
|
198
|
+
events.push(...page.events)
|
|
199
|
+
if (page.nextIndex === null) break
|
|
200
|
+
if (events.length === page.throughIndex) {
|
|
201
|
+
throw new Error('inspection session cursor continued past its frontier')
|
|
202
|
+
}
|
|
203
|
+
if (page.nextIndex <= afterIndex) {
|
|
204
|
+
throw new Error('inspection session cursor did not advance')
|
|
205
|
+
}
|
|
206
|
+
afterIndex = page.nextIndex
|
|
207
|
+
}
|
|
208
|
+
if (throughIndex !== events.length) {
|
|
209
|
+
throw new Error('inspection session ended before its frozen frontier')
|
|
210
|
+
}
|
|
211
|
+
return { events, snapshots }
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const readRevision = async (
|
|
215
|
+
inspection: ServerInspection,
|
|
216
|
+
sessionId: string,
|
|
217
|
+
): Promise<{
|
|
218
|
+
detail: InspectionDetail
|
|
219
|
+
revision: string
|
|
220
|
+
}> => {
|
|
221
|
+
const detail = await readCompleteSession(inspection, sessionId)
|
|
222
|
+
return { detail, revision: await sessionRevision(detail) }
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const bootstrapState = async (
|
|
226
|
+
url: URL,
|
|
227
|
+
servers: ReadonlyMap<string, DevtoolsServer>,
|
|
228
|
+
): Promise<DevtoolsBootstrap> => {
|
|
229
|
+
const contracts = [...servers.keys()].map((name) => ({ name }))
|
|
230
|
+
const requestedContract = url.searchParams.get('contract')
|
|
231
|
+
const contract =
|
|
232
|
+
requestedContract && servers.has(requestedContract)
|
|
233
|
+
? requestedContract
|
|
234
|
+
: (contracts[0]?.name ?? null)
|
|
235
|
+
const bootstrap: DevtoolsBootstrap = {
|
|
236
|
+
contracts,
|
|
237
|
+
contract,
|
|
238
|
+
sessions: [],
|
|
239
|
+
cursor: null,
|
|
240
|
+
session: null,
|
|
241
|
+
detail: null,
|
|
242
|
+
error: null,
|
|
243
|
+
}
|
|
244
|
+
if (!contract) return bootstrap
|
|
245
|
+
|
|
246
|
+
const server = servers.get(contract)
|
|
247
|
+
const inspection = server ? serverInspection.get(server) : undefined
|
|
248
|
+
if (!inspection) {
|
|
249
|
+
bootstrap.error = `contract '${contract}' is not an inspectable A2 server`
|
|
250
|
+
return bootstrap
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
try {
|
|
254
|
+
const page = await inspection.listSessions({ limit: 50 })
|
|
255
|
+
bootstrap.sessions = page.sessions.map(wireSummary)
|
|
256
|
+
bootstrap.cursor = page.cursor
|
|
257
|
+
const requestedSession =
|
|
258
|
+
requestedContract === contract ? url.searchParams.get('session') : null
|
|
259
|
+
bootstrap.session = requestedSession ?? page.sessions[0]?.sessionId ?? null
|
|
260
|
+
if (!bootstrap.session) return bootstrap
|
|
261
|
+
|
|
262
|
+
const detail = await readCompleteSession(inspection, bootstrap.session)
|
|
263
|
+
if (detail.events.length === 0) {
|
|
264
|
+
bootstrap.error = `session '${bootstrap.session}' was not found`
|
|
265
|
+
return bootstrap
|
|
266
|
+
}
|
|
267
|
+
bootstrap.detail = await wireDetail(contract, bootstrap.session, detail)
|
|
268
|
+
} catch (error) {
|
|
269
|
+
bootstrap.error = error instanceof Error ? error.message : String(error)
|
|
270
|
+
}
|
|
271
|
+
return bootstrap
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const resourceOf = (pathname: string): string | null => {
|
|
275
|
+
const marker = pathname.lastIndexOf(MARKER)
|
|
276
|
+
return marker === -1 ? null : pathname.slice(marker + MARKER.length)
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
const inspectionOf = (
|
|
280
|
+
servers: ReadonlyMap<string, DevtoolsServer>,
|
|
281
|
+
contractName: string | null,
|
|
282
|
+
): ServerInspection | Response => {
|
|
283
|
+
if (!contractName) return badRequest('contract is required')
|
|
284
|
+
const server = servers.get(contractName)
|
|
285
|
+
if (!server) return notFound()
|
|
286
|
+
return (
|
|
287
|
+
serverInspection.get(server) ??
|
|
288
|
+
unavailable(`contract '${contractName}' is not an inspectable A2 server`)
|
|
289
|
+
)
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
type SessionCursor = { afterIndex: number; throughIndex: number }
|
|
293
|
+
|
|
294
|
+
const encodeSessionCursor = (
|
|
295
|
+
contract: string,
|
|
296
|
+
sessionId: string,
|
|
297
|
+
cursor: SessionCursor,
|
|
298
|
+
): string =>
|
|
299
|
+
JSON.stringify([
|
|
300
|
+
1,
|
|
301
|
+
contract,
|
|
302
|
+
sessionId,
|
|
303
|
+
cursor.throughIndex,
|
|
304
|
+
cursor.afterIndex,
|
|
305
|
+
])
|
|
306
|
+
|
|
307
|
+
const parseSessionCursor = (
|
|
308
|
+
value: string,
|
|
309
|
+
contract: string,
|
|
310
|
+
sessionId: string,
|
|
311
|
+
): SessionCursor | Response => {
|
|
312
|
+
let decoded: unknown
|
|
313
|
+
try {
|
|
314
|
+
decoded = JSON.parse(value) as unknown
|
|
315
|
+
} catch {
|
|
316
|
+
return badRequest('cursor is invalid')
|
|
317
|
+
}
|
|
318
|
+
if (
|
|
319
|
+
!Array.isArray(decoded) ||
|
|
320
|
+
decoded.length !== 5 ||
|
|
321
|
+
decoded[0] !== 1 ||
|
|
322
|
+
!Number.isSafeInteger(decoded[3]) ||
|
|
323
|
+
!Number.isSafeInteger(decoded[4]) ||
|
|
324
|
+
(decoded[3] as number) < 1 ||
|
|
325
|
+
(decoded[4] as number) < 1 ||
|
|
326
|
+
(decoded[4] as number) >= (decoded[3] as number)
|
|
327
|
+
) {
|
|
328
|
+
return badRequest('cursor is invalid')
|
|
329
|
+
}
|
|
330
|
+
if (decoded[1] !== contract || decoded[2] !== sessionId) {
|
|
331
|
+
return badRequest('cursor does not belong to this contract and session')
|
|
332
|
+
}
|
|
333
|
+
return {
|
|
334
|
+
throughIndex: decoded[3] as number,
|
|
335
|
+
afterIndex: decoded[4] as number,
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
const captureFilename = (contract: string, sessionId: string): string => {
|
|
340
|
+
const safe = `${contract}-${sessionId}`
|
|
341
|
+
.replaceAll(/[^a-zA-Z0-9._-]/g, '_')
|
|
342
|
+
.slice(0, 160)
|
|
343
|
+
return `${safe || 'a2-session'}.a2log`
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
const watchResponse = (
|
|
347
|
+
request: Request,
|
|
348
|
+
inspection: ServerInspection,
|
|
349
|
+
sessionId: string,
|
|
350
|
+
initial: InspectionDetail,
|
|
351
|
+
): Response => {
|
|
352
|
+
let stopped = false
|
|
353
|
+
let timeout: ReturnType<typeof setTimeout> | null = null
|
|
354
|
+
let wake: (() => void) | null = null
|
|
355
|
+
const stop = (): void => {
|
|
356
|
+
stopped = true
|
|
357
|
+
if (timeout !== null) clearTimeout(timeout)
|
|
358
|
+
timeout = null
|
|
359
|
+
wake?.()
|
|
360
|
+
wake = null
|
|
361
|
+
}
|
|
362
|
+
const wait = (milliseconds: number): Promise<void> =>
|
|
363
|
+
new Promise((resolve) => {
|
|
364
|
+
wake = resolve
|
|
365
|
+
timeout = setTimeout(() => {
|
|
366
|
+
timeout = null
|
|
367
|
+
wake = null
|
|
368
|
+
resolve()
|
|
369
|
+
}, milliseconds)
|
|
370
|
+
})
|
|
371
|
+
|
|
372
|
+
const stream = new ReadableStream<Uint8Array>({
|
|
373
|
+
start(controller) {
|
|
374
|
+
request.signal.addEventListener('abort', stop, { once: true })
|
|
375
|
+
controller.enqueue(encoder.encode('retry: 1000\n: connected\n\n'))
|
|
376
|
+
void (async () => {
|
|
377
|
+
let detail = initial
|
|
378
|
+
let previous = new URL(request.url).searchParams.get('revision') ?? ''
|
|
379
|
+
let lastHeartbeat = Date.now()
|
|
380
|
+
try {
|
|
381
|
+
let revision = await sessionRevision(detail)
|
|
382
|
+
for (;;) {
|
|
383
|
+
if (stopped) break
|
|
384
|
+
if (revision !== previous) {
|
|
385
|
+
previous = revision
|
|
386
|
+
controller.enqueue(
|
|
387
|
+
encoder.encode(
|
|
388
|
+
`event: invalidate\ndata: ${JSON.stringify({ revision })}\n\n`,
|
|
389
|
+
),
|
|
390
|
+
)
|
|
391
|
+
} else if (
|
|
392
|
+
Date.now() - lastHeartbeat >=
|
|
393
|
+
DEVTOOLS_TIMINGS.heartbeatMs
|
|
394
|
+
) {
|
|
395
|
+
controller.enqueue(encoder.encode(': ping\n\n'))
|
|
396
|
+
lastHeartbeat = Date.now()
|
|
397
|
+
}
|
|
398
|
+
const active = detail.events.some(
|
|
399
|
+
(event) => event.processedAt === null && event.failedAt === null,
|
|
400
|
+
)
|
|
401
|
+
// oxlint-disable-next-line no-await-in-loop -- each revision read follows its poll interval
|
|
402
|
+
await wait(
|
|
403
|
+
active ? DEVTOOLS_TIMINGS.activeMs : DEVTOOLS_TIMINGS.idleMs,
|
|
404
|
+
)
|
|
405
|
+
if (!stopped) {
|
|
406
|
+
// oxlint-disable-next-line no-await-in-loop -- poll state advances from the prior read
|
|
407
|
+
const next = await readRevision(inspection, sessionId)
|
|
408
|
+
detail = next.detail
|
|
409
|
+
revision = next.revision
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
controller.close()
|
|
413
|
+
} catch (error) {
|
|
414
|
+
if (!stopped) controller.error(error)
|
|
415
|
+
}
|
|
416
|
+
})()
|
|
417
|
+
},
|
|
418
|
+
cancel() {
|
|
419
|
+
stop()
|
|
420
|
+
},
|
|
421
|
+
})
|
|
422
|
+
|
|
423
|
+
return new Response(stream, {
|
|
424
|
+
headers: {
|
|
425
|
+
...headers('text/event-stream; charset=utf-8'),
|
|
426
|
+
connection: 'keep-alive',
|
|
427
|
+
'x-accel-buffering': 'no',
|
|
428
|
+
},
|
|
429
|
+
})
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
const authorizeRequest = async (
|
|
433
|
+
request: Request,
|
|
434
|
+
authorize: DevtoolsAuthorization | undefined,
|
|
435
|
+
): Promise<Response | null> => {
|
|
436
|
+
if (!authorize) return isDevelopment() ? null : notFound()
|
|
437
|
+
const result = await authorize(request)
|
|
438
|
+
if (result instanceof Response) return result
|
|
439
|
+
return result === true ? null : notFound()
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
export function createDevtools(options: DevtoolsOptions): Devtools {
|
|
443
|
+
if (
|
|
444
|
+
!options ||
|
|
445
|
+
!Array.isArray(options.servers) ||
|
|
446
|
+
options.servers.length === 0
|
|
447
|
+
) {
|
|
448
|
+
throw new TypeError('createDevtools expects at least one A2 server')
|
|
449
|
+
}
|
|
450
|
+
const servers = new Map<string, DevtoolsServer>()
|
|
451
|
+
for (const server of options.servers) {
|
|
452
|
+
const name = server?.contract?.name
|
|
453
|
+
if (typeof name !== 'string' || name.length === 0) {
|
|
454
|
+
throw new TypeError('createDevtools received a server without a contract')
|
|
455
|
+
}
|
|
456
|
+
if (servers.has(name)) {
|
|
457
|
+
throw new TypeError(`createDevtools received contract '${name}' twice`)
|
|
458
|
+
}
|
|
459
|
+
servers.set(name, server)
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
return {
|
|
463
|
+
handler() {
|
|
464
|
+
return async (request) => {
|
|
465
|
+
const denied = await authorizeRequest(request, options.authorize)
|
|
466
|
+
if (denied) return denied
|
|
467
|
+
if (request.method !== 'GET') {
|
|
468
|
+
return new Response('Method not allowed', {
|
|
469
|
+
status: 405,
|
|
470
|
+
headers: { ...headers('text/plain'), allow: 'GET' },
|
|
471
|
+
})
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
const url = new URL(request.url)
|
|
475
|
+
const resource = resourceOf(url.pathname)
|
|
476
|
+
if (resource === null) {
|
|
477
|
+
const basePath = `${url.pathname.replace(/\/$/, '')}/`
|
|
478
|
+
const bootstrap = await bootstrapState(url, servers)
|
|
479
|
+
return new Response(devtoolsHtml(basePath, bootstrap), {
|
|
480
|
+
headers: {
|
|
481
|
+
...headers('text/html; charset=utf-8'),
|
|
482
|
+
'content-security-policy':
|
|
483
|
+
"default-src 'none'; script-src 'self'; style-src 'self'; connect-src 'self'; img-src 'self' data:; base-uri 'self'; frame-ancestors 'none'",
|
|
484
|
+
},
|
|
485
|
+
})
|
|
486
|
+
}
|
|
487
|
+
if (resource === 'app.js') {
|
|
488
|
+
return new Response(DEVTOOLS_APP_JS, {
|
|
489
|
+
headers: headers('text/javascript; charset=utf-8'),
|
|
490
|
+
})
|
|
491
|
+
}
|
|
492
|
+
if (resource === 'app.css') {
|
|
493
|
+
return new Response(DEVTOOLS_APP_CSS, {
|
|
494
|
+
headers: headers('text/css; charset=utf-8'),
|
|
495
|
+
})
|
|
496
|
+
}
|
|
497
|
+
if (resource === 'contracts') {
|
|
498
|
+
return json({
|
|
499
|
+
protocolVersion: DEVTOOLS_PROTOCOL_VERSION,
|
|
500
|
+
contracts: [...servers.keys()].map((name) => ({ name })),
|
|
501
|
+
})
|
|
502
|
+
}
|
|
503
|
+
if (resource === 'capabilities') {
|
|
504
|
+
return json(DEVTOOLS_CAPABILITIES)
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
const contractName = url.searchParams.get('contract')
|
|
508
|
+
const inspection = inspectionOf(servers, contractName)
|
|
509
|
+
if (inspection instanceof Response) return inspection
|
|
510
|
+
try {
|
|
511
|
+
if (resource === 'sessions') {
|
|
512
|
+
const requestedLimit = Number(url.searchParams.get('limit') ?? 50)
|
|
513
|
+
if (!Number.isInteger(requestedLimit) || requestedLimit < 1) {
|
|
514
|
+
return badRequest('limit must be a positive integer')
|
|
515
|
+
}
|
|
516
|
+
const limit = Math.min(requestedLimit, 100)
|
|
517
|
+
const cursor = url.searchParams.get('cursor')
|
|
518
|
+
const page = await inspection.listSessions({
|
|
519
|
+
limit,
|
|
520
|
+
...(cursor !== null ? { cursor } : {}),
|
|
521
|
+
})
|
|
522
|
+
return json({
|
|
523
|
+
protocolVersion: DEVTOOLS_PROTOCOL_VERSION,
|
|
524
|
+
cursor: page.cursor,
|
|
525
|
+
sessions: page.sessions.map(wireSummary),
|
|
526
|
+
})
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
const sessionId = url.searchParams.get('session')
|
|
530
|
+
if (!sessionId) return badRequest('session is required')
|
|
531
|
+
if (
|
|
532
|
+
resource === 'session' &&
|
|
533
|
+
(url.searchParams.has('limit') || url.searchParams.has('cursor'))
|
|
534
|
+
) {
|
|
535
|
+
const requestedLimit = Number(
|
|
536
|
+
url.searchParams.get('limit') ?? DEVTOOLS_SESSION_PAGE_LIMIT,
|
|
537
|
+
)
|
|
538
|
+
if (!Number.isSafeInteger(requestedLimit) || requestedLimit < 1) {
|
|
539
|
+
return badRequest('limit must be a positive integer')
|
|
540
|
+
}
|
|
541
|
+
const limit = Math.min(
|
|
542
|
+
requestedLimit,
|
|
543
|
+
DEVTOOLS_SESSION_PAGE_MAX_LIMIT,
|
|
544
|
+
)
|
|
545
|
+
const cursorValue = url.searchParams.get('cursor')
|
|
546
|
+
const cursor =
|
|
547
|
+
cursorValue === null
|
|
548
|
+
? { afterIndex: 0, throughIndex: undefined }
|
|
549
|
+
: parseSessionCursor(cursorValue, contractName!, sessionId)
|
|
550
|
+
if (cursor instanceof Response) return cursor
|
|
551
|
+
const page = await inspection.readSessionPage(sessionId, {
|
|
552
|
+
afterIndex: cursor.afterIndex,
|
|
553
|
+
limit,
|
|
554
|
+
...(cursor.throughIndex === undefined
|
|
555
|
+
? {}
|
|
556
|
+
: { throughIndex: cursor.throughIndex }),
|
|
557
|
+
})
|
|
558
|
+
if (page.throughIndex === 0) return notFound()
|
|
559
|
+
const response: DevtoolsSessionPage = {
|
|
560
|
+
protocolVersion: DEVTOOLS_PROTOCOL_VERSION,
|
|
561
|
+
contract: contractName!,
|
|
562
|
+
sessionId,
|
|
563
|
+
throughIndex: page.throughIndex,
|
|
564
|
+
cursor:
|
|
565
|
+
page.nextIndex === null
|
|
566
|
+
? null
|
|
567
|
+
: encodeSessionCursor(contractName!, sessionId, {
|
|
568
|
+
throughIndex: page.throughIndex,
|
|
569
|
+
afterIndex: page.nextIndex,
|
|
570
|
+
}),
|
|
571
|
+
events: page.events.map(wireEvent),
|
|
572
|
+
snapshots: page.snapshots.map(wireSnapshot),
|
|
573
|
+
}
|
|
574
|
+
return json(response)
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
const detail = await readCompleteSession(inspection, sessionId)
|
|
578
|
+
if (detail.events.length === 0) return notFound()
|
|
579
|
+
const wired = await wireDetail(contractName!, sessionId, detail)
|
|
580
|
+
if (resource === 'session') {
|
|
581
|
+
return json(wired)
|
|
582
|
+
}
|
|
583
|
+
if (resource === 'export') {
|
|
584
|
+
return new Response(await encodeDevtoolsCapture(wired), {
|
|
585
|
+
headers: {
|
|
586
|
+
...headers(`${DEVTOOLS_CAPTURE_MEDIA_TYPE}; charset=utf-8`),
|
|
587
|
+
'content-disposition': `attachment; filename="${captureFilename(wired.contract, sessionId)}"`,
|
|
588
|
+
},
|
|
589
|
+
})
|
|
590
|
+
}
|
|
591
|
+
if (resource === 'watch') {
|
|
592
|
+
return watchResponse(request, inspection, sessionId, detail)
|
|
593
|
+
}
|
|
594
|
+
} catch (error) {
|
|
595
|
+
if (error instanceof InspectionUnsupportedError) {
|
|
596
|
+
return unavailable(error.message)
|
|
597
|
+
}
|
|
598
|
+
throw error
|
|
599
|
+
}
|
|
600
|
+
return notFound()
|
|
601
|
+
}
|
|
602
|
+
},
|
|
603
|
+
}
|
|
604
|
+
}
|