experimental-a2 0.0.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 (68) hide show
  1. package/CHANGELOG.md +128 -0
  2. package/dist/ai-server.browser.d.ts +1 -0
  3. package/dist/ai-server.browser.js +4 -0
  4. package/dist/ai-server.d.ts +65 -0
  5. package/dist/ai-server.js +494 -0
  6. package/dist/ai.d.ts +282 -0
  7. package/dist/ai.js +922 -0
  8. package/dist/cache-indexeddb.d.ts +1 -0
  9. package/dist/cache-indexeddb.js +0 -0
  10. package/dist/client.d.ts +90 -0
  11. package/dist/client.js +410 -0
  12. package/dist/contract-B0kAXoaL.js +60 -0
  13. package/dist/contract-DL8btVd9.d.ts +161 -0
  14. package/dist/devtools-server.browser.d.ts +1 -0
  15. package/dist/devtools-server.browser.js +4 -0
  16. package/dist/devtools-server.d.ts +22 -0
  17. package/dist/devtools-server.js +1087 -0
  18. package/dist/errors-BJRMd-h6.js +23 -0
  19. package/dist/errors-xL_JTXsY.d.ts +20 -0
  20. package/dist/http.d.ts +44 -0
  21. package/dist/http.js +119 -0
  22. package/dist/index.d.ts +5 -0
  23. package/dist/index.js +3 -0
  24. package/dist/inspection-E7qbD0Xj.js +10 -0
  25. package/dist/internal-Dm8Ejnud.js +36 -0
  26. package/dist/log-Dg1I8NRr.d.ts +245 -0
  27. package/dist/log-memory.d.ts +11 -0
  28. package/dist/log-memory.js +345 -0
  29. package/dist/log-polling-RO7kclzR.js +83 -0
  30. package/dist/log-postgres.d.ts +40 -0
  31. package/dist/log-postgres.js +628 -0
  32. package/dist/log-redis.d.ts +31 -0
  33. package/dist/log-redis.js +711 -0
  34. package/dist/log-sqlite.d.ts +17 -0
  35. package/dist/log-sqlite.js +450 -0
  36. package/dist/log-yJbXUf72.js +5 -0
  37. package/dist/otel.d.ts +12 -0
  38. package/dist/otel.js +41 -0
  39. package/dist/react.d.ts +54 -0
  40. package/dist/react.js +85 -0
  41. package/dist/recovery-vercel.d.ts +60 -0
  42. package/dist/recovery-vercel.js +120 -0
  43. package/dist/retryable-lazy-DZWmHpii.js +19 -0
  44. package/dist/server-DYsnKTTy.js +780 -0
  45. package/dist/server.browser.d.ts +1 -0
  46. package/dist/server.browser.js +11 -0
  47. package/dist/server.d.ts +136 -0
  48. package/dist/server.js +2 -0
  49. package/dist/telemetry-C78al20p.d.ts +32 -0
  50. package/dist/validate-XKT4FSNn.js +28 -0
  51. package/dist/wire-2QpU1EtJ.js +62 -0
  52. package/docs/01-quickstart.mdx +214 -0
  53. package/docs/concepts/01-contracts.mdx +138 -0
  54. package/docs/concepts/02-handlers.mdx +146 -0
  55. package/docs/concepts/03-durability.mdx +230 -0
  56. package/docs/concepts/04-state.mdx +133 -0
  57. package/docs/guides/01-timers.mdx +85 -0
  58. package/docs/guides/02-cancellation.mdx +107 -0
  59. package/docs/guides/03-react.mdx +234 -0
  60. package/docs/guides/04-local-first.mdx +88 -0
  61. package/docs/guides/05-production.mdx +179 -0
  62. package/docs/guides/06-ai-agents.mdx +659 -0
  63. package/docs/guides/07-devtools.mdx +101 -0
  64. package/docs/guides/08-application-data.mdx +114 -0
  65. package/docs/index.mdx +282 -0
  66. package/docs/reference/01-api.mdx +637 -0
  67. package/docs/reference/02-errors.mdx +77 -0
  68. package/package.json +111 -0
@@ -0,0 +1,1087 @@
1
+ import { n as serverInspection, t as DEVTOOLS_TIMINGS } from "./inspection-E7qbD0Xj.js";
2
+ //#region src/devtools-app.ts
3
+ const escapeText = (value) => value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;");
4
+ const escapeAttribute = (value) => escapeText(value).replaceAll("\"", "&quot;");
5
+ const formatDuration = (milliseconds) => {
6
+ if (milliseconds < 1e3) return `${Math.max(0, Math.round(milliseconds))} ms`;
7
+ if (milliseconds < 6e4) return `${(milliseconds / 1e3).toFixed(milliseconds < 1e4 ? 2 : 1)} s`;
8
+ return `${(milliseconds / 6e4).toFixed(1)} min`;
9
+ };
10
+ const formatRelative = (value) => {
11
+ const delta = Date.now() - new Date(value).getTime();
12
+ if (delta < 1e3) return "now";
13
+ if (delta < 6e4) return `${Math.floor(delta / 1e3)}s ago`;
14
+ if (delta < 36e5) return `${Math.floor(delta / 6e4)}m ago`;
15
+ if (delta < 864e5) return `${Math.floor(delta / 36e5)}h ago`;
16
+ return `${Math.floor(delta / 864e5)}d ago`;
17
+ };
18
+ const sessionStatus = (session) => session.failedCount > 0 ? "failed" : session.pendingCount > 0 ? "pending" : "settled";
19
+ const metricHtml = (label, value) => `<div class="card"><span>${escapeText(label)}</span><strong>${escapeText(value)}</strong></div>`;
20
+ const shellHtml = (bootstrap) => {
21
+ const options = bootstrap.contracts.map(({ name }) => `<option value="${escapeAttribute(name)}"${name === bootstrap.contract ? " selected" : ""}>${escapeText(name)}</option>`).join("");
22
+ const sessions = bootstrap.sessions.length ? bootstrap.sessions.map((session) => {
23
+ const active = session.sessionId === bootstrap.session ? " active" : "";
24
+ const events = `${session.eventCount} ${session.eventCount === 1 ? "event" : "events"}`;
25
+ return `<button class="session-button${active}" type="button"><span class="session-name">${escapeText(session.sessionId)}</span><span class="dot ${sessionStatus(session)}"></span><span class="session-meta">${events}</span><span class="session-meta">${formatRelative(session.updatedAt)}</span></button>`;
26
+ }).join("") : "<div class=\"session-meta\">No durable sessions yet.</div>";
27
+ const more = bootstrap.cursor ? "<button class=\"load-more\" type=\"button\">Load more</button>" : "";
28
+ let main;
29
+ if (!bootstrap.session) main = "<main class=\"main\"><div class=\"empty\"><div><strong>No session selected</strong><span>Choose a durable session to inspect its lifecycle.</span></div></div></main>";
30
+ else if (!bootstrap.detail) main = `<main class="main">${bootstrap.error ? `<div class="error-box">${escapeText(bootstrap.error)}</div>` : ""}<div class="empty"><div><strong>Session unavailable</strong><span>The selected durable session could not be read.</span></div></div></main>`;
31
+ else {
32
+ const { events } = bootstrap.detail;
33
+ const pending = events.filter((event) => event.processedAt === null && event.failedAt === null).length;
34
+ const attempts = events.reduce((total, event) => total + event.attemptCount, 0);
35
+ const failures = events.reduce((total, event) => total + event.failureCount, 0);
36
+ const first = events[0];
37
+ const last = events.at(-1);
38
+ const lifetime = first && last ? formatDuration(new Date(last.failedAt ?? last.processedAt ?? Date.now()).getTime() - new Date(first.createdAt).getTime()) : "0 ms";
39
+ main = `<main class="main">${bootstrap.error ? `<div class="error-box">${escapeText(bootstrap.error)}</div>` : ""}<header class="session-header"><div class="session-title"><p class="eyebrow">${escapeText(bootstrap.contract ?? "")} / session</p><h1>${escapeText(bootstrap.session)}</h1><p>Read-only · durable data · refreshed by SSE</p></div><span class="live"><i></i>live</span></header><div class="cards">${metricHtml("Events", String(events.length))}${metricHtml("Pending", String(pending))}${metricHtml("Dispatches", String(attempts))}${metricHtml("Caught failures", String(failures))}${metricHtml("Lifetime", lifetime)}</div></main>`;
40
+ }
41
+ return `<div class="shell"><aside class="sidebar"><div class="brand"><span class="mark">A2</span><div class="brand-copy"><strong>A2 Devtools</strong><span>Durable execution data</span></div></div><div class="side-controls"><p class="eyebrow">Contract</p><select class="contract-select" aria-label="Contract">${options}</select></div><div class="sessions"><p class="eyebrow">Sessions</p>${sessions}${more}</div></aside>${main}</div>`;
42
+ };
43
+ const bootstrapJson = (bootstrap) => JSON.stringify(bootstrap).replaceAll("<", "\\u003c").replaceAll("\u2028", "\\u2028").replaceAll("\u2029", "\\u2029");
44
+ const devtoolsHtml = (basePath, bootstrap) => `<!doctype html>
45
+ <html lang="en">
46
+ <head>
47
+ <meta charset="utf-8">
48
+ <meta name="viewport" content="width=device-width, initial-scale=1">
49
+ <meta name="color-scheme" content="light">
50
+ <base href="${escapeAttribute(basePath)}">
51
+ <title>A2 Devtools</title>
52
+ <link rel="stylesheet" href="_a2/app.css">
53
+ </head>
54
+ <body>
55
+ <div id="app">${shellHtml(bootstrap)}</div>
56
+ <script id="a2-bootstrap" type="application/json">${bootstrapJson(bootstrap)}<\/script>
57
+ <script type="module" src="_a2/app.js"><\/script>
58
+ </body>
59
+ </html>`;
60
+ const DEVTOOLS_APP_CSS = String.raw`
61
+ :root {
62
+ color: #1a1a1a;
63
+ background: #fff;
64
+ font-family: Geist, "Geist Sans", ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
65
+ font-synthesis: none;
66
+ --surface: #fafafa;
67
+ --line: #eaeaea;
68
+ --line-strong: #d4d4d4;
69
+ --muted: #666;
70
+ --faint: #8f8f8f;
71
+ --success: #15803d;
72
+ --warning: #b45309;
73
+ --danger: #dc2626;
74
+ }
75
+
76
+ * { box-sizing: border-box; }
77
+ html, body, #app { min-height: 100%; margin: 0; }
78
+ body { min-width: 320px; background: #fff; }
79
+ button, select { font: inherit; }
80
+ button { color: inherit; }
81
+ button:focus-visible, select:focus-visible, summary:focus-visible { outline: 2px solid #000; outline-offset: 2px; }
82
+
83
+ .mark {
84
+ display: inline-grid;
85
+ place-items: center;
86
+ width: 30px;
87
+ height: 30px;
88
+ border-radius: 6px;
89
+ background: #000;
90
+ color: #fff;
91
+ font-size: 12px;
92
+ font-weight: 700;
93
+ letter-spacing: -.04em;
94
+ }
95
+
96
+ .shell {
97
+ min-height: 100vh;
98
+ display: grid;
99
+ grid-template-columns: 248px minmax(0, 1fr);
100
+ }
101
+
102
+ .sidebar {
103
+ position: sticky;
104
+ top: 0;
105
+ height: 100vh;
106
+ display: flex;
107
+ flex-direction: column;
108
+ border-right: 1px solid var(--line);
109
+ background: var(--surface);
110
+ }
111
+
112
+ .brand {
113
+ display: flex;
114
+ align-items: center;
115
+ gap: 10px;
116
+ padding: 18px;
117
+ border-bottom: 1px solid var(--line);
118
+ }
119
+
120
+ .brand-copy strong { display: block; font-size: 13px; font-weight: 600; letter-spacing: -.01em; }
121
+ .brand-copy span { color: var(--muted); font-size: 10px; }
122
+ .side-controls { padding: 14px; border-bottom: 1px solid var(--line); }
123
+ .eyebrow { margin: 0 0 8px; color: var(--muted); font-size: 10px; font-weight: 500; letter-spacing: .08em; text-transform: uppercase; }
124
+
125
+ .contract-select {
126
+ width: 100%;
127
+ padding: 8px 9px;
128
+ color: #1a1a1a;
129
+ border: 1px solid var(--line-strong);
130
+ border-radius: 6px;
131
+ outline: none;
132
+ background: #fff;
133
+ }
134
+ .contract-select:focus { border-color: #000; }
135
+
136
+ .sessions { min-height: 0; flex: 1; overflow: auto; padding: 12px 8px; }
137
+ .session-button {
138
+ width: 100%;
139
+ display: grid;
140
+ grid-template-columns: minmax(0, 1fr) auto;
141
+ gap: 5px 9px;
142
+ padding: 9px 10px;
143
+ margin-bottom: 2px;
144
+ text-align: left;
145
+ border: 1px solid transparent;
146
+ border-radius: 6px;
147
+ background: transparent;
148
+ cursor: pointer;
149
+ }
150
+ .session-button:hover { background: #f0f0f0; }
151
+ .session-button.active { border-color: var(--line-strong); background: #fff; }
152
+ .session-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; }
153
+ .session-meta { color: var(--muted); font-size: 10px; }
154
+ .dot { align-self: center; width: 6px; height: 6px; border-radius: 99px; background: #999; }
155
+ .dot.pending { background: var(--warning); }
156
+ .dot.failed { background: var(--danger); }
157
+ .dot.settled { background: var(--success); }
158
+ .load-more { width: 100%; margin: 6px 0 10px; padding: 8px; border: 1px solid var(--line-strong); border-radius: 6px; background: #fff; cursor: pointer; color: var(--muted); }
159
+ .load-more:hover { color: #000; border-color: #999; }
160
+
161
+ .main { min-width: 0; width: 100%; max-width: 1440px; padding: 38px clamp(24px, 5vw, 72px) 72px; }
162
+ .empty { min-height: 70vh; display: grid; place-items: center; color: var(--muted); text-align: center; }
163
+ .empty strong { display: block; margin-bottom: 7px; color: #1a1a1a; font-size: 16px; font-weight: 500; }
164
+
165
+ .session-header { display: flex; justify-content: space-between; align-items: flex-start; gap: 24px; margin-bottom: 28px; }
166
+ .session-title { min-width: 0; }
167
+ .session-title h1 { margin: 4px 0 8px; overflow-wrap: anywhere; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: clamp(22px, 3vw, 32px); font-weight: 500; letter-spacing: -.04em; }
168
+ .session-title p { margin: 0; color: var(--muted); font-size: 12px; }
169
+ .live { display: inline-flex; align-items: center; gap: 7px; padding: 6px 9px; color: var(--muted); border: 1px solid var(--line); border-radius: 99px; font-size: 11px; }
170
+ .live i { width: 6px; height: 6px; border-radius: 99px; background: var(--success); }
171
+
172
+ .cards { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); margin-bottom: 32px; overflow: hidden; border: 1px solid var(--line); border-radius: 8px; }
173
+ .card { padding: 15px 16px; border-right: 1px solid var(--line); background: #fff; }
174
+ .card:last-child { border-right: 0; }
175
+ .card span { display: block; margin-bottom: 8px; color: var(--muted); font-size: 10px; font-weight: 500; }
176
+ .card strong { font-size: 20px; font-weight: 500; letter-spacing: -.03em; }
177
+
178
+ .section { margin-top: 32px; }
179
+ .section-head { display: flex; align-items: end; justify-content: space-between; gap: 20px; margin-bottom: 11px; }
180
+ .section-head h2 { margin: 0; font-size: 14px; font-weight: 500; letter-spacing: -.01em; }
181
+ .section-head p { margin: 0; color: var(--muted); font-size: 11px; }
182
+
183
+ .forest { overflow: hidden; border: 1px solid var(--line); border-radius: 8px; background: #fff; }
184
+ .forest-row { --depth: 0; position: relative; display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 16px; min-height: 42px; padding: 9px 13px 9px calc(13px + var(--depth) * 22px); border-bottom: 1px solid var(--line); }
185
+ .forest-row:last-child { border-bottom: 0; }
186
+ .forest-row::before { content: ""; position: absolute; top: 0; bottom: 0; left: calc(20px + (var(--depth) - 1) * 22px); width: 1px; background: var(--depth-line, transparent); }
187
+ .forest-row.nested { --depth-line: var(--line-strong); }
188
+ .forest-main { min-width: 0; display: flex; align-items: center; gap: 8px; }
189
+ .forest-main .dot { flex: none; }
190
+ .forest-type { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font: 11px ui-monospace, SFMono-Regular, Menlo, monospace; }
191
+ .forest-edge { color: var(--muted); font-size: 10px; white-space: nowrap; }
192
+ .forest-edge.unknown { color: var(--warning); }
193
+
194
+ .timeline { overflow: hidden; border: 1px solid var(--line); border-radius: 8px; background: #fff; }
195
+ .timeline-axis, .event-row { display: grid; grid-template-columns: 310px minmax(320px, 1fr); }
196
+ .timeline-axis { min-height: 37px; border-bottom: 1px solid var(--line); color: var(--muted); font-size: 9px; }
197
+ .axis-label { padding: 12px 14px; border-right: 1px solid var(--line); font-weight: 500; }
198
+ .axis-track { position: relative; display: flex; justify-content: space-between; padding: 12px 16px; }
199
+ .event-row { min-height: 84px; border-bottom: 1px solid var(--line); }
200
+ .event-row:last-child { border-bottom: 0; }
201
+ .event-info { min-width: 0; padding: 11px 14px; border-right: 1px solid var(--line); }
202
+ .event-line { display: flex; align-items: center; gap: 8px; min-width: 0; }
203
+ .event-index { color: var(--muted); font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 10px; }
204
+ .event-type { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; }
205
+ .event-sub { display: flex; flex-wrap: wrap; gap: 5px 9px; margin-top: 7px; color: var(--muted); font-size: 9px; }
206
+ .event-sub .danger { color: var(--danger); }
207
+ .event-sub .warning { color: var(--warning); }
208
+ .event-facts { display: flex; flex-wrap: wrap; gap: 4px 9px; margin-top: 7px; color: var(--muted); font-size: 9px; }
209
+ .event-facts .warning { color: var(--warning); }
210
+ .event-details { margin-top: 8px; }
211
+ .event-details summary { color: var(--muted); cursor: pointer; font-size: 9px; }
212
+ .event-details pre { max-height: 220px; overflow: auto; margin: 8px 0 0; padding: 10px; border: 1px solid var(--line); border-radius: 6px; background: var(--surface); color: #333; font: 10px/1.55 ui-monospace, SFMono-Regular, Menlo, monospace; white-space: pre-wrap; overflow-wrap: anywhere; }
213
+ .event-track { position: relative; min-height: 84px; }
214
+ .event-track::before { content: ""; position: absolute; top: 42px; right: 0; left: 0; height: 1px; background: var(--line); }
215
+ .event-bar { position: absolute; top: 39px; height: 7px; min-width: 4px; border-radius: 99px; background: #111; }
216
+ .event-bar.processed { background: #111; }
217
+ .event-bar.pending { top: 38px; height: 9px; border: 1px solid #777; background: #fff; }
218
+ .event-bar.failed { background: var(--danger); }
219
+ .lifecycle-marker { position: absolute; z-index: 2; width: 11px; height: 11px; margin-left: -5px; border: 2px solid #fff; border-radius: 99px; background: #777; box-shadow: 0 0 0 1px #777; }
220
+ .lifecycle-marker.created { top: 27px; border-radius: 2px; background: #fff; box-shadow: 0 0 0 1px #777; }
221
+ .lifecycle-marker.claim { top: 37px; background: #111; box-shadow: 0 0 0 1px #111; }
222
+ .lifecycle-marker.failure { top: 47px; background: var(--danger); box-shadow: 0 0 0 1px var(--danger); }
223
+ .lifecycle-marker.complete { top: 47px; background: var(--success); box-shadow: 0 0 0 1px var(--success); }
224
+ .lifecycle-marker.dead-letter { top: 57px; background: #fff; box-shadow: 0 0 0 1px var(--danger); }
225
+ .timeline-legend { display: flex; flex-wrap: wrap; gap: 7px 14px; margin: 9px 2px 0; color: var(--muted); font-size: 9px; }
226
+ .legend-item { display: inline-flex; align-items: center; gap: 6px; }
227
+ .legend-key { width: 7px; height: 7px; border-radius: 99px; background: #777; }
228
+ .legend-key.created { border: 1px solid #777; border-radius: 1px; background: #fff; }
229
+ .legend-key.claim { background: #111; }
230
+ .legend-key.failure { background: var(--danger); }
231
+ .legend-key.dead-letter { border: 1px solid var(--danger); background: #fff; }
232
+ .legend-key.complete { background: var(--success); }
233
+
234
+ .snapshots { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 8px; }
235
+ .snapshot { padding: 14px; border: 1px solid var(--line); border-radius: 8px; background: #fff; }
236
+ .snapshot strong { display: block; margin-bottom: 8px; font: 12px ui-monospace, SFMono-Regular, Menlo, monospace; }
237
+ .snapshot div { display: flex; justify-content: space-between; color: var(--muted); font-size: 10px; }
238
+ .snapshot b { color: #111; font-weight: 500; }
239
+ .error-box { margin-bottom: 20px; padding: 12px 14px; border: 1px solid #fecaca; border-radius: 7px; background: #fffafa; color: var(--danger); font: 11px/1.5 ui-monospace, SFMono-Regular, Menlo, monospace; white-space: pre-wrap; }
240
+
241
+ @media (max-width: 850px) {
242
+ .shell { grid-template-columns: 1fr; }
243
+ .sidebar { position: static; height: auto; max-height: 310px; border-right: 0; border-bottom: 1px solid var(--line); }
244
+ .sessions { max-height: 180px; }
245
+ .cards { grid-template-columns: repeat(2, minmax(0, 1fr)); }
246
+ .card { border-bottom: 1px solid var(--line); }
247
+ .card:nth-child(2n) { border-right: 0; }
248
+ .card:last-child { border-bottom: 0; }
249
+ .timeline-axis, .event-row { grid-template-columns: 260px minmax(300px, 1fr); }
250
+ .timeline { overflow-x: auto; }
251
+ .timeline-axis, .event-row { min-width: 610px; }
252
+ .main { padding: 28px 20px 60px; }
253
+ }
254
+ `;
255
+ const DEVTOOLS_APP_JS = String.raw`
256
+ const root = document.querySelector('#app')
257
+ const readBootstrap = () => {
258
+ const element = document.querySelector('#a2-bootstrap')
259
+ if (!element) return null
260
+ try {
261
+ const bootstrap = JSON.parse(element.textContent || 'null')
262
+ element.remove()
263
+ return bootstrap
264
+ } catch {
265
+ return null
266
+ }
267
+ }
268
+ const navigationFromUrl = () => {
269
+ const params = new URL(window.location.href).searchParams
270
+ return {
271
+ contract: params.get('contract'),
272
+ session: params.get('session'),
273
+ }
274
+ }
275
+ const initialNavigation = navigationFromUrl()
276
+ const bootstrap = readBootstrap()
277
+ const state = {
278
+ contracts: bootstrap?.contracts || [],
279
+ contract: bootstrap ? bootstrap.contract : initialNavigation.contract,
280
+ sessions: bootstrap?.sessions || [],
281
+ cursor: bootstrap?.cursor || null,
282
+ session: bootstrap ? bootstrap.session : initialNavigation.session,
283
+ detail: bootstrap?.detail || null,
284
+ watch: null,
285
+ error: bootstrap?.error || null,
286
+ }
287
+
288
+ const syncUrl = (replace) => {
289
+ const url = new URL(window.location.href)
290
+ if (state.contract) url.searchParams.set('contract', state.contract)
291
+ else url.searchParams.delete('contract')
292
+ if (state.session) url.searchParams.set('session', state.session)
293
+ else url.searchParams.delete('session')
294
+ const historyState = { contract: state.contract, session: state.session }
295
+ if (replace) window.history.replaceState(historyState, '', url)
296
+ else window.history.pushState(historyState, '', url)
297
+ }
298
+
299
+ const node = (tag, className, text) => {
300
+ const element = document.createElement(tag)
301
+ if (className) element.className = className
302
+ if (text !== undefined) element.textContent = text
303
+ return element
304
+ }
305
+
306
+ const append = (parent, ...children) => {
307
+ for (const child of children) if (child) parent.append(child)
308
+ return parent
309
+ }
310
+
311
+ const request = async (resource, params = {}) => {
312
+ const url = new URL('_a2/' + resource, document.baseURI)
313
+ for (const [key, value] of Object.entries(params)) {
314
+ if (value !== null && value !== undefined) url.searchParams.set(key, String(value))
315
+ }
316
+ const response = await fetch(url, { headers: { accept: 'application/json' } })
317
+ if (!response.ok) {
318
+ let message = response.status + ' ' + response.statusText
319
+ try { message = (await response.json()).error || message } catch {}
320
+ throw new Error(message)
321
+ }
322
+ return response.json()
323
+ }
324
+
325
+ const watchUrl = () => {
326
+ const url = new URL('_a2/watch', document.baseURI)
327
+ url.searchParams.set('contract', state.contract)
328
+ url.searchParams.set('session', state.session)
329
+ if (state.detail?.revision) url.searchParams.set('revision', state.detail.revision)
330
+ return url
331
+ }
332
+
333
+ const formatTime = (value) => new Intl.DateTimeFormat(undefined, {
334
+ hour: '2-digit', minute: '2-digit', second: '2-digit', fractionalSecondDigits: 3,
335
+ }).format(new Date(value))
336
+
337
+ const formatRelative = (value) => {
338
+ const delta = Date.now() - new Date(value).getTime()
339
+ if (delta < 1000) return 'now'
340
+ if (delta < 60000) return Math.floor(delta / 1000) + 's ago'
341
+ if (delta < 3600000) return Math.floor(delta / 60000) + 'm ago'
342
+ if (delta < 86400000) return Math.floor(delta / 3600000) + 'h ago'
343
+ return Math.floor(delta / 86400000) + 'd ago'
344
+ }
345
+
346
+ const formatDuration = (milliseconds) => {
347
+ if (milliseconds < 1000) return Math.max(0, Math.round(milliseconds)) + ' ms'
348
+ if (milliseconds < 60000) return (milliseconds / 1000).toFixed(milliseconds < 10000 ? 2 : 1) + ' s'
349
+ return (milliseconds / 60000).toFixed(1) + ' min'
350
+ }
351
+
352
+ const sessionStatus = (session) => session.failedCount > 0 ? 'failed' : session.pendingCount > 0 ? 'pending' : 'settled'
353
+ const eventStatus = (event) => event.failedAt ? 'failed' : event.processedAt ? 'processed' : 'pending'
354
+ const eventStatusLabel = (event) => event.failedAt ? 'dead-lettered' : event.processedAt ? 'completed' : 'pending'
355
+
356
+ const durableOutcomeGap = (event) => Math.max(
357
+ 0,
358
+ event.attemptCount - event.failureCount - (event.processedByAttempt === null ? 0 : 1),
359
+ )
360
+
361
+ const causalEntries = (events) => {
362
+ const byIndex = new Map(events.map((event) => [event.index, event]))
363
+ const children = new Map()
364
+ for (const event of events) {
365
+ if (!event.cause || !byIndex.has(event.cause.index)) continue
366
+ const siblings = children.get(event.cause.index) || []
367
+ siblings.push(event)
368
+ children.set(event.cause.index, siblings)
369
+ }
370
+
371
+ const roots = events.filter((event) => !event.cause || !byIndex.has(event.cause.index))
372
+ const entries = []
373
+ const visited = new Set()
374
+ const visit = (event, depth) => {
375
+ if (visited.has(event.index)) return
376
+ visited.add(event.index)
377
+ entries.push({ event, depth })
378
+ for (const child of children.get(event.index) || []) visit(child, depth + 1)
379
+ }
380
+ for (const root of roots) visit(root, 0)
381
+ for (const event of events) visit(event, 0)
382
+ return entries
383
+ }
384
+
385
+ const loadContracts = async () => {
386
+ const data = await request('contracts')
387
+ state.contracts = data.contracts
388
+ if (!data.contracts.some((contract) => contract.name === state.contract)) {
389
+ state.contract = data.contracts[0]?.name || null
390
+ state.session = null
391
+ }
392
+ if (state.contract) await loadSessions(true)
393
+ syncUrl(true)
394
+ render()
395
+ }
396
+
397
+ const loadSessions = async (reset) => {
398
+ if (!state.contract) return
399
+ const data = await request('sessions', {
400
+ contract: state.contract,
401
+ cursor: reset ? null : state.cursor,
402
+ limit: 50,
403
+ })
404
+ state.sessions = reset ? data.sessions : state.sessions.concat(data.sessions)
405
+ state.cursor = data.cursor
406
+ if (reset && !state.session) {
407
+ state.session = state.sessions[0]?.sessionId || null
408
+ }
409
+ if (state.session) await loadDetail()
410
+ }
411
+
412
+ const loadDetail = async () => {
413
+ if (!state.contract || !state.session) return
414
+ state.detail = await request('session', {
415
+ contract: state.contract,
416
+ session: state.session,
417
+ })
418
+ state.error = null
419
+ render()
420
+ }
421
+
422
+ const beginWatch = () => {
423
+ state.watch?.close()
424
+ if (!state.session) return
425
+ const watchedContract = state.contract
426
+ const watchedSession = state.session
427
+ const source = new EventSource(watchUrl())
428
+ source.addEventListener('invalidate', async () => {
429
+ if (state.contract !== watchedContract || state.session !== watchedSession) return
430
+ try {
431
+ await Promise.all([loadDetail(), refreshSelectedSummary()])
432
+ } catch (error) {
433
+ state.error = error instanceof Error ? error.message : String(error)
434
+ render()
435
+ }
436
+ })
437
+ source.onerror = () => {
438
+ // EventSource reconnects. Durable state is fetched on the next
439
+ // invalidation, so a dropped frame cannot make the view incorrect.
440
+ }
441
+ state.watch = source
442
+ }
443
+
444
+ const refreshSelectedSummary = async () => {
445
+ const data = await request('sessions', { contract: state.contract, limit: 100 })
446
+ const fresh = data.sessions.find((session) => session.sessionId === state.session)
447
+ const index = state.sessions.findIndex((session) => session.sessionId === state.session)
448
+ if (fresh && index !== -1) state.sessions[index] = fresh
449
+ render()
450
+ }
451
+
452
+ const selectContract = async (name) => {
453
+ state.watch?.close()
454
+ state.contract = name
455
+ state.session = null
456
+ state.detail = null
457
+ state.sessions = []
458
+ state.cursor = null
459
+ state.error = null
460
+ syncUrl(false)
461
+ render()
462
+ try {
463
+ await loadSessions(true)
464
+ syncUrl(true)
465
+ beginWatch()
466
+ } catch (error) {
467
+ state.error = error instanceof Error ? error.message : String(error)
468
+ render()
469
+ }
470
+ }
471
+
472
+ const selectSession = async (id) => {
473
+ if (id === state.session) return
474
+
475
+ state.watch?.close()
476
+ state.session = id
477
+ state.detail = null
478
+ state.error = null
479
+ syncUrl(false)
480
+ render()
481
+ try {
482
+ await loadDetail()
483
+ beginWatch()
484
+ } catch (error) {
485
+ state.error = error instanceof Error ? error.message : String(error)
486
+ render()
487
+ }
488
+ }
489
+
490
+ const renderSidebar = () => {
491
+ const sidebar = node('aside', 'sidebar')
492
+ const brand = node('div', 'brand')
493
+ const copy = node('div', 'brand-copy')
494
+ append(copy, node('strong', '', 'A2 Devtools'), node('span', '', 'Durable execution data'))
495
+ append(brand, node('span', 'mark', 'A2'), copy)
496
+ sidebar.append(brand)
497
+
498
+ const controls = node('div', 'side-controls')
499
+ controls.append(node('p', 'eyebrow', 'Contract'))
500
+ const select = node('select', 'contract-select')
501
+ select.setAttribute('aria-label', 'Contract')
502
+ for (const contract of state.contracts) {
503
+ const option = node('option', '', contract.name)
504
+ option.value = contract.name
505
+ option.selected = contract.name === state.contract
506
+ select.append(option)
507
+ }
508
+ select.addEventListener('change', () => void selectContract(select.value))
509
+ controls.append(select)
510
+ sidebar.append(controls)
511
+
512
+ const sessions = node('div', 'sessions')
513
+ sessions.append(node('p', 'eyebrow', 'Sessions'))
514
+ if (state.sessions.length === 0) sessions.append(node('div', 'session-meta', 'No durable sessions yet.'))
515
+ for (const session of state.sessions) {
516
+ const status = sessionStatus(session)
517
+ const button = node('button', 'session-button' + (session.sessionId === state.session ? ' active' : ''))
518
+ button.type = 'button'
519
+ button.addEventListener('click', () => void selectSession(session.sessionId))
520
+ append(
521
+ button,
522
+ node('span', 'session-name', session.sessionId),
523
+ node('span', 'dot ' + status),
524
+ node('span', 'session-meta', session.eventCount + (session.eventCount === 1 ? ' event' : ' events')),
525
+ node('span', 'session-meta', formatRelative(session.updatedAt)),
526
+ )
527
+ sessions.append(button)
528
+ }
529
+ if (state.cursor) {
530
+ const more = node('button', 'load-more', 'Load more')
531
+ more.type = 'button'
532
+ more.addEventListener('click', async () => {
533
+ more.disabled = true
534
+ try { await loadSessions(false); render() } catch (error) {
535
+ state.error = error instanceof Error ? error.message : String(error)
536
+ render()
537
+ }
538
+ })
539
+ sessions.append(more)
540
+ }
541
+ sidebar.append(sessions)
542
+ return sidebar
543
+ }
544
+
545
+ const metric = (label, value) => {
546
+ const card = node('div', 'card')
547
+ append(card, node('span', '', label), node('strong', '', value))
548
+ return card
549
+ }
550
+
551
+ const renderCausalForest = (events) => {
552
+ const forest = node('div', 'forest')
553
+ const indexes = new Set(events.map((event) => event.index))
554
+ for (const { event, depth } of causalEntries(events)) {
555
+ const row = node('div', 'forest-row' + (depth > 0 ? ' nested' : ''))
556
+ row.style.setProperty('--depth', String(depth))
557
+ const main = node('div', 'forest-main')
558
+ append(
559
+ main,
560
+ node('span', 'dot ' + eventStatus(event)),
561
+ node('span', 'event-index', '#' + event.index),
562
+ node('span', 'forest-type', event.type),
563
+ )
564
+ row.append(main)
565
+
566
+ let edge
567
+ if (!event.cause) {
568
+ edge = node('span', 'forest-edge unknown', 'origin unknown')
569
+ edge.title = 'This may be a root append or an event written before causal metadata existed.'
570
+ } else if (!indexes.has(event.cause.index)) {
571
+ edge = node('span', 'forest-edge unknown', 'parent #' + event.cause.index + ' unavailable · attempt ' + event.cause.attempt)
572
+ } else {
573
+ edge = node('span', 'forest-edge', 'from #' + event.cause.index + ' · attempt ' + event.cause.attempt)
574
+ }
575
+ row.append(edge)
576
+ forest.append(row)
577
+ }
578
+ return forest
579
+ }
580
+
581
+ const renderTimeline = (events) => {
582
+ const timeline = node('div', 'timeline')
583
+ const now = Date.now()
584
+ const times = events.flatMap((event) => [
585
+ event.createdAt,
586
+ event.firstClaimedAt,
587
+ event.lastClaimedAt,
588
+ event.lastFailedAt,
589
+ event.processedAt,
590
+ event.failedAt,
591
+ eventStatus(event) === 'pending' ? now : null,
592
+ ].filter((value) => value !== null).map((value) => new Date(value).getTime()))
593
+ const minimum = Math.min(...events.map((event) => new Date(event.createdAt).getTime()))
594
+ const maximum = Math.max(...times, minimum + 1)
595
+ const range = maximum - minimum
596
+ const position = (value) => Math.max(0, Math.min(100, (new Date(value).getTime() - minimum) / range * 100))
597
+ const markerPosition = (value) => Math.max(.75, Math.min(99.25, position(value)))
598
+ const lifecycleMarker = (kind, value, title) => {
599
+ const marker = node('span', 'lifecycle-marker ' + kind)
600
+ marker.style.left = markerPosition(value) + '%'
601
+ marker.title = title + ' · ' + formatTime(value)
602
+ return marker
603
+ }
604
+
605
+ const axis = node('div', 'timeline-axis')
606
+ axis.append(node('div', 'axis-label', 'Event'))
607
+ const track = node('div', 'axis-track')
608
+ for (const percent of [0, 25, 50, 75, 100]) {
609
+ track.append(node('span', '', formatDuration(range * percent / 100)))
610
+ }
611
+ axis.append(track)
612
+ timeline.append(axis)
613
+
614
+ for (const event of events) {
615
+ const status = eventStatus(event)
616
+ const start = new Date(event.createdAt).getTime()
617
+ const end = new Date(event.failedAt || event.processedAt || now).getTime()
618
+ const left = position(event.createdAt)
619
+ const width = Math.max((end - start) / range * 100, .45)
620
+ const row = node('div', 'event-row')
621
+ const info = node('div', 'event-info')
622
+ const line = node('div', 'event-line')
623
+ append(line, node('span', 'event-index', '#' + event.index), node('span', 'event-type', event.type))
624
+ info.append(line)
625
+ const sub = node('div', 'event-sub')
626
+ sub.append(node('span', status === 'failed' ? 'danger' : '', eventStatusLabel(event)))
627
+ sub.append(node('span', '', event.attemptCount + (event.attemptCount === 1 ? ' dispatch' : ' dispatches')))
628
+ sub.append(node('span', event.failureCount > 0 ? 'danger' : '', event.failureCount + (event.failureCount === 1 ? ' caught failure' : ' caught failures')))
629
+ sub.append(node('span', '', 'created ' + formatTime(event.createdAt)))
630
+ info.append(sub)
631
+
632
+ const facts = node('div', 'event-facts')
633
+ if (event.attemptCount === 0) {
634
+ facts.append(node('span', '', 'not dispatched'))
635
+ } else if (!event.firstClaimedAt) {
636
+ facts.append(node('span', 'warning', 'first dispatch time unknown'))
637
+ }
638
+ if (event.attemptCount > 1) {
639
+ facts.append(node('span', '', (event.attemptCount - 1) + (event.attemptCount === 2 ? ' redispatch' : ' redispatches')))
640
+ }
641
+ const outcomeGap = durableOutcomeGap(event)
642
+ if (outcomeGap > 0) {
643
+ const hint = node('span', 'warning', outcomeGap + (outcomeGap === 1 ? ' dispatch without outcome' : ' dispatches without outcomes'))
644
+ hint.title = 'May indicate active work, a hard crash, or a superseded attempt.'
645
+ facts.append(hint)
646
+ }
647
+ if (event.lastFailedAttempt !== null) {
648
+ facts.append(node('span', 'danger', 'latest failure · attempt ' + event.lastFailedAttempt))
649
+ }
650
+ if (event.processedAt) {
651
+ facts.append(node('span', '', event.processedByAttempt === null ? 'completing attempt not recorded' : 'completed by attempt ' + event.processedByAttempt))
652
+ }
653
+ if (facts.childNodes.length > 0) info.append(facts)
654
+
655
+ const details = node('details', 'event-details')
656
+ details.append(node('summary', '', 'payload'))
657
+ details.append(node('pre', '', JSON.stringify(event.payload, null, 2)))
658
+ info.append(details)
659
+ if (event.lastError) {
660
+ const failure = node('details', 'event-details')
661
+ failure.append(node('summary', 'danger', 'last error'))
662
+ failure.append(node('pre', '', event.lastError))
663
+ info.append(failure)
664
+ }
665
+ row.append(info)
666
+
667
+ const eventTrack = node('div', 'event-track')
668
+ const bar = node('span', 'event-bar ' + status)
669
+ bar.style.left = left + '%'
670
+ bar.style.width = Math.min(width, 100 - left) + '%'
671
+ bar.title = eventStatusLabel(event) + ' · ' + formatDuration(end - start)
672
+ append(eventTrack, bar)
673
+ eventTrack.append(lifecycleMarker('created', event.createdAt, 'Created'))
674
+ if (event.firstClaimedAt && event.lastClaimedAt === event.firstClaimedAt) {
675
+ eventTrack.append(lifecycleMarker('claim', event.firstClaimedAt, 'First and latest dispatch'))
676
+ } else {
677
+ if (event.firstClaimedAt) eventTrack.append(lifecycleMarker('claim', event.firstClaimedAt, 'First dispatch'))
678
+ if (event.lastClaimedAt) eventTrack.append(lifecycleMarker('claim', event.lastClaimedAt, 'Latest dispatch'))
679
+ }
680
+ if (event.lastFailedAt) {
681
+ eventTrack.append(lifecycleMarker('failure', event.lastFailedAt, 'Latest caught failure' + (event.lastFailedAttempt === null ? '' : ' · attempt ' + event.lastFailedAttempt)))
682
+ }
683
+ if (event.processedAt) {
684
+ eventTrack.append(lifecycleMarker('complete', event.processedAt, 'Completed' + (event.processedByAttempt === null ? '' : ' · attempt ' + event.processedByAttempt)))
685
+ }
686
+ if (event.failedAt) eventTrack.append(lifecycleMarker('dead-letter', event.failedAt, 'Dead-lettered'))
687
+ row.append(eventTrack)
688
+ timeline.append(row)
689
+ }
690
+
691
+ const legend = node('div', 'timeline-legend')
692
+ for (const [kind, label] of [
693
+ ['created', 'Created'],
694
+ ['claim', 'First / latest dispatch'],
695
+ ['failure', 'Latest caught failure'],
696
+ ['complete', 'Completion'],
697
+ ['dead-letter', 'Dead-letter'],
698
+ ]) {
699
+ const item = node('span', 'legend-item')
700
+ append(item, node('i', 'legend-key ' + kind), document.createTextNode(label))
701
+ legend.append(item)
702
+ }
703
+ return append(node('div'), timeline, legend)
704
+ }
705
+
706
+ const renderMain = () => {
707
+ const main = node('main', 'main')
708
+ if (state.error) main.append(node('div', 'error-box', state.error))
709
+ if (!state.session) {
710
+ const empty = node('div', 'empty')
711
+ const copy = node('div')
712
+ append(copy, node('strong', '', 'No session selected'), node('span', '', 'Choose a durable session to inspect its lifecycle.'))
713
+ empty.append(copy)
714
+ main.append(empty)
715
+ return main
716
+ }
717
+ if (!state.detail) {
718
+ main.append(node('div', 'empty', 'Reading the durable log…'))
719
+ return main
720
+ }
721
+
722
+ const events = state.detail.events
723
+ const snapshots = state.detail.snapshots
724
+ const pending = events.filter((event) => eventStatus(event) === 'pending').length
725
+ const failed = events.filter((event) => eventStatus(event) === 'failed').length
726
+ const attempts = events.reduce((total, event) => total + event.attemptCount, 0)
727
+ const failures = events.reduce((total, event) => total + event.failureCount, 0)
728
+ const first = new Date(events[0].createdAt).getTime()
729
+ const lastEvent = events[events.length - 1]
730
+ const last = new Date(lastEvent.failedAt || lastEvent.processedAt || Date.now()).getTime()
731
+
732
+ const header = node('header', 'session-header')
733
+ const title = node('div', 'session-title')
734
+ append(title, node('p', 'eyebrow', state.contract + ' / session'), node('h1', '', state.session), node('p', '', 'Read-only · durable data · refreshed by SSE'))
735
+ const live = node('span', 'live')
736
+ append(live, node('i'), document.createTextNode('live'))
737
+ append(header, title, live)
738
+ main.append(header)
739
+
740
+ const cards = node('div', 'cards')
741
+ append(cards, metric('Events', String(events.length)), metric('Pending', String(pending)), metric('Dispatches', String(attempts)), metric('Caught failures', String(failures)), metric('Lifetime', formatDuration(last - first)))
742
+ main.append(cards)
743
+
744
+ const forestSection = node('section', 'section')
745
+ const forestHead = node('div', 'section-head')
746
+ append(forestHead, node('h2', '', 'Causal forest'), node('p', '', 'Unknown origins may be roots or legacy events'))
747
+ append(forestSection, forestHead, renderCausalForest(events))
748
+ main.append(forestSection)
749
+
750
+ const section = node('section', 'section')
751
+ const head = node('div', 'section-head')
752
+ append(head, node('h2', '', 'Event lifecycle'), node('p', '', 'Durable operation boundaries · missing history stays unknown'))
753
+ append(section, head, renderTimeline(events))
754
+ main.append(section)
755
+
756
+ const snapshotSection = node('section', 'section')
757
+ const snapshotHead = node('div', 'section-head')
758
+ append(snapshotHead, node('h2', '', 'Snapshots'), node('p', '', failed ? 'Session is stalled' : snapshots.length ? 'Latest durable reducer frontiers' : 'No cached folds'))
759
+ snapshotSection.append(snapshotHead)
760
+ if (snapshots.length) {
761
+ const grid = node('div', 'snapshots')
762
+ for (const snapshot of snapshots) {
763
+ const card = node('div', 'snapshot')
764
+ card.append(node('strong', '', snapshot.reducerName))
765
+ const detail = node('div')
766
+ append(detail, node('span', '', snapshot.updatedAt ? formatRelative(snapshot.updatedAt) : 'time unknown'), node('b', '', '#' + snapshot.index))
767
+ card.append(detail)
768
+ grid.append(card)
769
+ }
770
+ snapshotSection.append(grid)
771
+ }
772
+ main.append(snapshotSection)
773
+ return main
774
+ }
775
+
776
+ const render = () => {
777
+ root.replaceChildren(append(node('div', 'shell'), renderSidebar(), renderMain()))
778
+ }
779
+
780
+ const restoreFromUrl = async () => {
781
+ state.watch?.close()
782
+ const navigation = navigationFromUrl()
783
+ state.contract = state.contracts.some((contract) => contract.name === navigation.contract)
784
+ ? navigation.contract
785
+ : state.contracts[0]?.name || null
786
+ state.session = navigation.session
787
+ state.sessions = []
788
+ state.cursor = null
789
+ state.detail = null
790
+ state.error = null
791
+ render()
792
+ try {
793
+ if (state.contract) await loadSessions(true)
794
+ syncUrl(true)
795
+ beginWatch()
796
+ } catch (error) {
797
+ state.error = error instanceof Error ? error.message : String(error)
798
+ render()
799
+ }
800
+ }
801
+
802
+ window.addEventListener('popstate', () => void restoreFromUrl())
803
+
804
+ setInterval(() => {
805
+ if (state.detail?.events.some((event) => eventStatus(event) === 'pending')) render()
806
+ }, 1000)
807
+
808
+ if (bootstrap) {
809
+ syncUrl(true)
810
+ render()
811
+ beginWatch()
812
+ } else {
813
+ loadContracts().then(() => beginWatch()).catch((error) => {
814
+ state.error = error instanceof Error ? error.message : String(error)
815
+ render()
816
+ })
817
+ }
818
+ `;
819
+ //#endregion
820
+ //#region src/devtools-server.ts
821
+ /**
822
+ * a2/devtools/server — a read-only dashboard over A2's durable data.
823
+ * One Request handler serves the application, its private browser bundle,
824
+ * JSON inspection endpoints, and live SSE invalidations.
825
+ */
826
+ const MARKER = "/_a2/";
827
+ const encoder = new TextEncoder();
828
+ const isProduction = () => typeof process !== "undefined" && process.env?.["NODE_ENV"] === "production";
829
+ const headers = (contentType) => ({
830
+ "content-type": contentType,
831
+ "cache-control": "no-store",
832
+ "x-content-type-options": "nosniff",
833
+ "referrer-policy": "no-referrer"
834
+ });
835
+ const json = (value, init) => Response.json(value, {
836
+ ...init,
837
+ headers: {
838
+ ...headers("application/json; charset=utf-8"),
839
+ ...init?.headers
840
+ }
841
+ });
842
+ const notFound = () => new Response("Not found", {
843
+ status: 404,
844
+ headers: headers("text/plain")
845
+ });
846
+ const badRequest = (message) => json({ error: message }, { status: 400 });
847
+ const unavailable = (message) => json({ error: message }, { status: 501 });
848
+ const wireSummary = (session) => ({
849
+ ...session,
850
+ firstEventAt: session.firstEventAt.toISOString(),
851
+ updatedAt: session.updatedAt.toISOString()
852
+ });
853
+ const wireEvent = (event) => ({
854
+ ...event,
855
+ createdAt: event.createdAt.toISOString(),
856
+ processedAt: event.processedAt?.toISOString() ?? null,
857
+ firstClaimedAt: event.firstClaimedAt?.toISOString() ?? null,
858
+ lastClaimedAt: event.lastClaimedAt?.toISOString() ?? null,
859
+ lastFailedAt: event.lastFailedAt?.toISOString() ?? null,
860
+ failedAt: event.failedAt?.toISOString() ?? null
861
+ });
862
+ const wireSnapshot = (snapshot) => ({
863
+ ...snapshot,
864
+ updatedAt: snapshot.updatedAt.getTime() === 0 ? null : snapshot.updatedAt.toISOString()
865
+ });
866
+ const sessionRevision = async (detail) => {
867
+ const serialized = JSON.stringify({
868
+ events: detail.events.map((event) => [
869
+ event.id,
870
+ event.index,
871
+ event.cause?.index ?? null,
872
+ event.cause?.attempt ?? null,
873
+ event.processedAt?.getTime() ?? null,
874
+ event.processedByAttempt,
875
+ event.firstClaimedAt?.getTime() ?? null,
876
+ event.lastClaimedAt?.getTime() ?? null,
877
+ event.attemptCount,
878
+ event.failureCount,
879
+ event.lastFailedAt?.getTime() ?? null,
880
+ event.lastFailedAttempt,
881
+ event.lastError,
882
+ event.failedAt?.getTime() ?? null
883
+ ]),
884
+ snapshots: detail.snapshots.map((snapshot) => [
885
+ snapshot.reducerName,
886
+ snapshot.index,
887
+ snapshot.updatedAt.getTime()
888
+ ])
889
+ });
890
+ const digest = await crypto.subtle.digest("SHA-256", encoder.encode(serialized));
891
+ return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join("");
892
+ };
893
+ const wireDetail = async (contract, sessionId, detail) => ({
894
+ contract,
895
+ sessionId,
896
+ revision: await sessionRevision(detail),
897
+ events: detail.events.map(wireEvent),
898
+ snapshots: detail.snapshots.map(wireSnapshot)
899
+ });
900
+ const readRevision = async (inspection, sessionId) => {
901
+ const detail = await inspection.readSession(sessionId);
902
+ return {
903
+ detail,
904
+ revision: await sessionRevision(detail)
905
+ };
906
+ };
907
+ const bootstrapState = async (url, servers) => {
908
+ const contracts = [...servers.keys()].map((name) => ({ name }));
909
+ const requestedContract = url.searchParams.get("contract");
910
+ const contract = requestedContract && servers.has(requestedContract) ? requestedContract : contracts[0]?.name ?? null;
911
+ const bootstrap = {
912
+ contracts,
913
+ contract,
914
+ sessions: [],
915
+ cursor: null,
916
+ session: null,
917
+ detail: null,
918
+ error: null
919
+ };
920
+ if (!contract) return bootstrap;
921
+ const server = servers.get(contract);
922
+ const inspection = server ? serverInspection.get(server) : void 0;
923
+ if (!inspection) {
924
+ bootstrap.error = `contract '${contract}' is not an inspectable A2 server`;
925
+ return bootstrap;
926
+ }
927
+ try {
928
+ const page = await inspection.listSessions({ limit: 50 });
929
+ bootstrap.sessions = page.sessions.map(wireSummary);
930
+ bootstrap.cursor = page.cursor;
931
+ bootstrap.session = (requestedContract === contract ? url.searchParams.get("session") : null) ?? page.sessions[0]?.sessionId ?? null;
932
+ if (!bootstrap.session) return bootstrap;
933
+ const detail = await inspection.readSession(bootstrap.session);
934
+ if (detail.events.length === 0) {
935
+ bootstrap.error = `session '${bootstrap.session}' was not found`;
936
+ return bootstrap;
937
+ }
938
+ bootstrap.detail = await wireDetail(contract, bootstrap.session, detail);
939
+ } catch (error) {
940
+ bootstrap.error = error instanceof Error ? error.message : String(error);
941
+ }
942
+ return bootstrap;
943
+ };
944
+ const resourceOf = (pathname) => {
945
+ const marker = pathname.lastIndexOf(MARKER);
946
+ return marker === -1 ? null : pathname.slice(marker + 5);
947
+ };
948
+ const inspectionOf = (servers, contractName) => {
949
+ if (!contractName) return badRequest("contract is required");
950
+ const server = servers.get(contractName);
951
+ if (!server) return notFound();
952
+ return serverInspection.get(server) ?? unavailable(`contract '${contractName}' is not an inspectable A2 server`);
953
+ };
954
+ const watchResponse = (request, inspection, sessionId, initial) => {
955
+ let stopped = false;
956
+ let timeout = null;
957
+ let wake = null;
958
+ const stop = () => {
959
+ stopped = true;
960
+ if (timeout !== null) clearTimeout(timeout);
961
+ timeout = null;
962
+ wake?.();
963
+ wake = null;
964
+ };
965
+ const wait = (milliseconds) => new Promise((resolve) => {
966
+ wake = resolve;
967
+ timeout = setTimeout(() => {
968
+ timeout = null;
969
+ wake = null;
970
+ resolve();
971
+ }, milliseconds);
972
+ });
973
+ const stream = new ReadableStream({
974
+ start(controller) {
975
+ request.signal.addEventListener("abort", stop, { once: true });
976
+ controller.enqueue(encoder.encode("retry: 1000\n: connected\n\n"));
977
+ (async () => {
978
+ let detail = initial;
979
+ let previous = new URL(request.url).searchParams.get("revision") ?? "";
980
+ let lastHeartbeat = Date.now();
981
+ try {
982
+ let revision = await sessionRevision(detail);
983
+ for (;;) {
984
+ if (stopped) break;
985
+ if (revision !== previous) {
986
+ previous = revision;
987
+ controller.enqueue(encoder.encode(`event: invalidate\ndata: ${JSON.stringify({ revision })}\n\n`));
988
+ } else if (Date.now() - lastHeartbeat >= DEVTOOLS_TIMINGS.heartbeatMs) {
989
+ controller.enqueue(encoder.encode(": ping\n\n"));
990
+ lastHeartbeat = Date.now();
991
+ }
992
+ const active = detail.events.some((event) => event.processedAt === null && event.failedAt === null);
993
+ await wait(active ? DEVTOOLS_TIMINGS.activeMs : DEVTOOLS_TIMINGS.idleMs);
994
+ if (!stopped) {
995
+ const next = await readRevision(inspection, sessionId);
996
+ detail = next.detail;
997
+ revision = next.revision;
998
+ }
999
+ }
1000
+ controller.close();
1001
+ } catch (error) {
1002
+ if (!stopped) controller.error(error);
1003
+ }
1004
+ })();
1005
+ },
1006
+ cancel() {
1007
+ stop();
1008
+ }
1009
+ });
1010
+ return new Response(stream, { headers: {
1011
+ ...headers("text/event-stream; charset=utf-8"),
1012
+ connection: "keep-alive",
1013
+ "x-accel-buffering": "no"
1014
+ } });
1015
+ };
1016
+ const authorizeRequest = async (request, authorize) => {
1017
+ if (!authorize) return isProduction() ? notFound() : null;
1018
+ const result = await authorize(request);
1019
+ if (result instanceof Response) return result;
1020
+ return result ? null : notFound();
1021
+ };
1022
+ function createDevtools(options) {
1023
+ if (!options || !Array.isArray(options.servers) || options.servers.length === 0) throw new TypeError("createDevtools expects at least one A2 server");
1024
+ const servers = /* @__PURE__ */ new Map();
1025
+ for (const server of options.servers) {
1026
+ const name = server?.contract?.name;
1027
+ if (typeof name !== "string" || name.length === 0) throw new TypeError("createDevtools received a server without a contract");
1028
+ if (servers.has(name)) throw new TypeError(`createDevtools received contract '${name}' twice`);
1029
+ servers.set(name, server);
1030
+ }
1031
+ return { handler() {
1032
+ return async (request) => {
1033
+ const denied = await authorizeRequest(request, options.authorize);
1034
+ if (denied) return denied;
1035
+ if (request.method !== "GET") return new Response("Method not allowed", {
1036
+ status: 405,
1037
+ headers: {
1038
+ ...headers("text/plain"),
1039
+ allow: "GET"
1040
+ }
1041
+ });
1042
+ const url = new URL(request.url);
1043
+ const resource = resourceOf(url.pathname);
1044
+ if (resource === null) {
1045
+ const basePath = `${url.pathname.replace(/\/$/, "")}/`;
1046
+ const bootstrap = await bootstrapState(url, servers);
1047
+ return new Response(devtoolsHtml(basePath, bootstrap), { headers: {
1048
+ ...headers("text/html; charset=utf-8"),
1049
+ "content-security-policy": "default-src 'none'; script-src 'self'; style-src 'self'; connect-src 'self'; img-src 'self' data:; base-uri 'self'; frame-ancestors 'none'"
1050
+ } });
1051
+ }
1052
+ if (resource === "app.js") return new Response(DEVTOOLS_APP_JS, { headers: headers("text/javascript; charset=utf-8") });
1053
+ if (resource === "app.css") return new Response(DEVTOOLS_APP_CSS, { headers: headers("text/css; charset=utf-8") });
1054
+ if (resource === "contracts") return json({ contracts: [...servers.keys()].map((name) => ({ name })) });
1055
+ const inspection = inspectionOf(servers, url.searchParams.get("contract"));
1056
+ if (inspection instanceof Response) return inspection;
1057
+ try {
1058
+ if (resource === "sessions") {
1059
+ const requestedLimit = Number(url.searchParams.get("limit") ?? 50);
1060
+ if (!Number.isInteger(requestedLimit) || requestedLimit < 1) return badRequest("limit must be a positive integer");
1061
+ const limit = Math.min(requestedLimit, 100);
1062
+ const cursor = url.searchParams.get("cursor");
1063
+ const page = await inspection.listSessions({
1064
+ limit,
1065
+ ...cursor !== null ? { cursor } : {}
1066
+ });
1067
+ return json({
1068
+ cursor: page.cursor,
1069
+ sessions: page.sessions.map(wireSummary)
1070
+ });
1071
+ }
1072
+ const sessionId = url.searchParams.get("session");
1073
+ if (!sessionId) return badRequest("session is required");
1074
+ const detail = await inspection.readSession(sessionId);
1075
+ if (detail.events.length === 0) return notFound();
1076
+ if (resource === "session") return json(await wireDetail(url.searchParams.get("contract"), sessionId, detail));
1077
+ if (resource === "watch") return watchResponse(request, inspection, sessionId, detail);
1078
+ } catch (error) {
1079
+ if (error instanceof TypeError && error.message.includes("does not support inspection")) return unavailable(error.message);
1080
+ throw error;
1081
+ }
1082
+ return notFound();
1083
+ };
1084
+ } };
1085
+ }
1086
+ //#endregion
1087
+ export { createDevtools };