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.
Files changed (205) hide show
  1. package/CHANGELOG.md +207 -0
  2. package/dist/ai-CFNeCrRl.d.ts +334 -0
  3. package/dist/ai-CFNeCrRl.d.ts.map +1 -0
  4. package/dist/ai-server.browser.js +2 -0
  5. package/dist/ai-server.browser.js.map +1 -0
  6. package/dist/ai-server.d.ts +49 -9
  7. package/dist/ai-server.d.ts.map +1 -0
  8. package/dist/ai-server.js +123 -49
  9. package/dist/ai-server.js.map +1 -0
  10. package/dist/ai.d.ts +2 -303
  11. package/dist/ai.js +233 -86
  12. package/dist/ai.js.map +1 -0
  13. package/dist/cli-B3VuxoDe.js +599 -0
  14. package/dist/cli-B3VuxoDe.js.map +1 -0
  15. package/dist/cli-bin.d.ts +1 -0
  16. package/dist/cli-bin.js +7 -0
  17. package/dist/cli-bin.js.map +1 -0
  18. package/dist/cli.d.ts +20 -0
  19. package/dist/cli.d.ts.map +1 -0
  20. package/dist/cli.js +2 -0
  21. package/dist/client-BKlyLiOU.js +1008 -0
  22. package/dist/client-BKlyLiOU.js.map +1 -0
  23. package/dist/client-D7mvIXrF.d.ts +191 -0
  24. package/dist/client-D7mvIXrF.d.ts.map +1 -0
  25. package/dist/client.d.ts +2 -90
  26. package/dist/client.js +1 -409
  27. package/dist/{contract-CG_adnu_.js → contract-48bUMgcL.js} +12 -2
  28. package/dist/contract-48bUMgcL.js.map +1 -0
  29. package/dist/{contract-C_3dIIEU.d.ts → contract-jIfaR085.d.ts} +63 -8
  30. package/dist/contract-jIfaR085.d.ts.map +1 -0
  31. package/dist/devtools-J_jZ2vQf.d.ts +152 -0
  32. package/dist/devtools-J_jZ2vQf.d.ts.map +1 -0
  33. package/dist/devtools-kJJaORn-.js +340 -0
  34. package/dist/devtools-kJJaORn-.js.map +1 -0
  35. package/dist/devtools-server.browser.js +3 -1
  36. package/dist/devtools-server.browser.js.map +1 -0
  37. package/dist/devtools-server.d.ts +4 -3
  38. package/dist/devtools-server.d.ts.map +1 -0
  39. package/dist/devtools-server.js +226 -43
  40. package/dist/devtools-server.js.map +1 -0
  41. package/dist/devtools.d.ts +2 -0
  42. package/dist/devtools.js +2 -0
  43. package/dist/{errors-BJRMd-h6.js → errors-BQuJpe82.js} +6 -4
  44. package/dist/errors-BQuJpe82.js.map +1 -0
  45. package/dist/{errors-xL_JTXsY.d.ts → errors-W6nwJ-fm.d.ts} +3 -2
  46. package/dist/errors-W6nwJ-fm.d.ts.map +1 -0
  47. package/dist/http.d.ts +135 -28
  48. package/dist/http.d.ts.map +1 -0
  49. package/dist/http.js +647 -61
  50. package/dist/http.js.map +1 -0
  51. package/dist/{idempotent-replay-BMyHrP0L.js → idempotent-replay-DuqEkYA7.js} +4 -2
  52. package/dist/idempotent-replay-DuqEkYA7.js.map +1 -0
  53. package/dist/index.d.ts +5 -5
  54. package/dist/index.js +2 -2
  55. package/dist/{inspection-E7qbD0Xj.js → inspection-DaxB5jM2.js} +4 -1
  56. package/dist/inspection-DaxB5jM2.js.map +1 -0
  57. package/dist/internal-DstsI6Re.js +211 -0
  58. package/dist/internal-DstsI6Re.js.map +1 -0
  59. package/dist/otel.d.ts +3 -2
  60. package/dist/otel.d.ts.map +1 -0
  61. package/dist/otel.js +2 -0
  62. package/dist/otel.js.map +1 -0
  63. package/dist/platform-B4TnJtWu.js +34 -0
  64. package/dist/platform-B4TnJtWu.js.map +1 -0
  65. package/dist/react.d.ts +47 -15
  66. package/dist/react.d.ts.map +1 -0
  67. package/dist/react.js +30 -15
  68. package/dist/react.js.map +1 -0
  69. package/dist/retryable-lazy-DZWmHpii.js +2 -0
  70. package/dist/retryable-lazy-DZWmHpii.js.map +1 -0
  71. package/dist/scheduler-qstash.d.ts +79 -0
  72. package/dist/scheduler-qstash.d.ts.map +1 -0
  73. package/dist/scheduler-qstash.js +501 -0
  74. package/dist/scheduler-qstash.js.map +1 -0
  75. package/dist/scheduler-task-BpzhPnRS.js +56 -0
  76. package/dist/scheduler-task-BpzhPnRS.js.map +1 -0
  77. package/dist/{recovery-vercel.d.ts → scheduler-vercel.d.ts} +18 -24
  78. package/dist/scheduler-vercel.d.ts.map +1 -0
  79. package/dist/scheduler-vercel.js +228 -0
  80. package/dist/scheduler-vercel.js.map +1 -0
  81. package/dist/server-C72KOw51.js +1457 -0
  82. package/dist/server-C72KOw51.js.map +1 -0
  83. package/dist/server-DUF9pjsx.d.ts +261 -0
  84. package/dist/server-DUF9pjsx.d.ts.map +1 -0
  85. package/dist/server.browser.js +2 -0
  86. package/dist/server.browser.js.map +1 -0
  87. package/dist/server.d.ts +4 -155
  88. package/dist/server.js +2 -2
  89. package/dist/{log-ldf5g8Cx.d.ts → store-DysUkTH3.d.ts} +120 -35
  90. package/dist/store-DysUkTH3.d.ts.map +1 -0
  91. package/dist/{log-yJbXUf72.js → store-N8PXxDAS.js} +3 -1
  92. package/dist/store-N8PXxDAS.js.map +1 -0
  93. package/dist/store-codec-DTG0Ftek.js +10 -0
  94. package/dist/store-codec-DTG0Ftek.js.map +1 -0
  95. package/dist/store-memory.d.ts +12 -0
  96. package/dist/store-memory.d.ts.map +1 -0
  97. package/dist/{log-memory.js → store-memory.js} +145 -34
  98. package/dist/store-memory.js.map +1 -0
  99. package/dist/{log-polling-DZ1MiKLg.js → store-polling-dSeLxzfb.js} +7 -5
  100. package/dist/store-polling-dSeLxzfb.js.map +1 -0
  101. package/dist/{log-postgres.d.ts → store-postgres.d.ts} +7 -6
  102. package/dist/store-postgres.d.ts.map +1 -0
  103. package/dist/{log-postgres.js → store-postgres.js} +214 -24
  104. package/dist/store-postgres.js.map +1 -0
  105. package/dist/{log-redis-core-CyJ5L8yR.js → store-redis-core-BFLwz0Wj.js} +271 -66
  106. package/dist/store-redis-core-BFLwz0Wj.js.map +1 -0
  107. package/dist/{log-redis-http.d.ts → store-redis-http.d.ts} +8 -7
  108. package/dist/store-redis-http.d.ts.map +1 -0
  109. package/dist/{log-redis-http.js → store-redis-http.js} +22 -12
  110. package/dist/store-redis-http.js.map +1 -0
  111. package/dist/{log-redis.d.ts → store-redis.d.ts} +7 -6
  112. package/dist/store-redis.d.ts.map +1 -0
  113. package/dist/{log-redis.js → store-redis.js} +101 -39
  114. package/dist/store-redis.js.map +1 -0
  115. package/dist/{log-sqlite.d.ts → store-sqlite.d.ts} +7 -6
  116. package/dist/store-sqlite.d.ts.map +1 -0
  117. package/dist/{log-sqlite.js → store-sqlite.js} +118 -22
  118. package/dist/store-sqlite.js.map +1 -0
  119. package/dist/{telemetry-Cso0qyHQ.d.ts → telemetry-BjYHTfh2.d.ts} +3 -2
  120. package/dist/telemetry-BjYHTfh2.d.ts.map +1 -0
  121. package/dist/testing.browser.d.ts +1 -0
  122. package/dist/testing.browser.js +6 -0
  123. package/dist/testing.browser.js.map +1 -0
  124. package/dist/testing.d.ts +32 -0
  125. package/dist/testing.d.ts.map +1 -0
  126. package/dist/testing.js +103 -0
  127. package/dist/testing.js.map +1 -0
  128. package/dist/validate-XKT4FSNn.js +2 -0
  129. package/dist/validate-XKT4FSNn.js.map +1 -0
  130. package/dist/wire-BFQmSJ-9.js +284 -0
  131. package/dist/wire-BFQmSJ-9.js.map +1 -0
  132. package/docs/01-quickstart.mdx +3 -3
  133. package/docs/concepts/01-contracts.mdx +18 -13
  134. package/docs/concepts/02-handlers.mdx +7 -7
  135. package/docs/concepts/03-durability.mdx +21 -21
  136. package/docs/concepts/04-state.mdx +18 -21
  137. package/docs/guides/01-timers.mdx +154 -49
  138. package/docs/guides/02-cancellation.mdx +30 -4
  139. package/docs/guides/03-react.mdx +76 -57
  140. package/docs/guides/04-local-first.mdx +1 -1
  141. package/docs/guides/05-production.mdx +310 -61
  142. package/docs/guides/06-ai-agents.mdx +252 -74
  143. package/docs/guides/07-devtools.mdx +137 -12
  144. package/docs/guides/08-application-data.mdx +1 -1
  145. package/docs/guides/09-presence.mdx +263 -0
  146. package/docs/guides/10-transports.mdx +140 -0
  147. package/docs/index.mdx +18 -14
  148. package/docs/reference/01-api.mdx +839 -110
  149. package/docs/reference/02-errors.mdx +13 -6
  150. package/package.json +24 -7
  151. package/src/ai-coordinator.ts +358 -0
  152. package/src/ai-projector.ts +524 -0
  153. package/src/ai-sdk-step.ts +261 -0
  154. package/src/ai-server.browser.ts +5 -0
  155. package/src/ai-server.ts +1719 -0
  156. package/src/ai.ts +2155 -0
  157. package/src/cache-indexeddb.ts +10 -0
  158. package/src/cli-bin.ts +5 -0
  159. package/src/cli.ts +1046 -0
  160. package/src/client.ts +1826 -0
  161. package/src/contract.ts +206 -0
  162. package/src/deterministic-id.ts +72 -0
  163. package/src/devtools-app.ts +989 -0
  164. package/src/devtools-server.browser.ts +5 -0
  165. package/src/devtools-server.ts +604 -0
  166. package/src/devtools.ts +716 -0
  167. package/src/errors.ts +50 -0
  168. package/src/http.ts +394 -0
  169. package/src/idempotent-replay.ts +53 -0
  170. package/src/index.ts +37 -0
  171. package/src/inspection.ts +39 -0
  172. package/src/internal.ts +426 -0
  173. package/src/otel.ts +59 -0
  174. package/src/platform.ts +60 -0
  175. package/src/push-envelope.ts +137 -0
  176. package/src/react.ts +284 -0
  177. package/src/reducer.ts +108 -0
  178. package/src/retryable-lazy.ts +27 -0
  179. package/src/scheduler-qstash.ts +915 -0
  180. package/src/scheduler-task.ts +106 -0
  181. package/src/scheduler-vercel.ts +437 -0
  182. package/src/server.browser.ts +12 -0
  183. package/src/server.ts +2700 -0
  184. package/src/session-socket.ts +548 -0
  185. package/src/sse.ts +141 -0
  186. package/src/standard-schema.ts +77 -0
  187. package/src/store-codec.ts +10 -0
  188. package/src/store-memory.ts +788 -0
  189. package/src/store-polling.ts +102 -0
  190. package/src/store-postgres.ts +1212 -0
  191. package/src/store-redis-core.ts +1494 -0
  192. package/src/store-redis-http.ts +116 -0
  193. package/src/store-redis.ts +458 -0
  194. package/src/store-sqlite.ts +1108 -0
  195. package/src/store.ts +385 -0
  196. package/src/telemetry.ts +47 -0
  197. package/src/testing.browser.ts +5 -0
  198. package/src/testing.ts +185 -0
  199. package/src/validate.ts +39 -0
  200. package/src/wire.ts +454 -0
  201. package/dist/internal-gCd5qMry.js +0 -44
  202. package/dist/log-memory.d.ts +0 -11
  203. package/dist/recovery-vercel.js +0 -119
  204. package/dist/server-BcLa4RFL.js +0 -877
  205. package/dist/wire-BVsgR8o9.js +0 -62
@@ -0,0 +1,5 @@
1
+ /** experimental-a2/devtools/server owns store access and is server-only. */
2
+ throw new Error(
3
+ 'experimental-a2/devtools/server is server-only — mount its handler in a server route and open that URL in the browser.',
4
+ )
5
+ export {}
@@ -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
+ }