@rwoll/canvas-kit 0.0.0-stage → 0.1.1

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 (93) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +506 -2
  3. package/cli/canvas-kit.mjs +148 -0
  4. package/cli/lib.mjs +697 -0
  5. package/cli/release.json +4 -0
  6. package/dist/client.d.ts +72 -0
  7. package/dist/client.d.ts.map +1 -0
  8. package/dist/client.js +156 -0
  9. package/dist/client.js.map +1 -0
  10. package/dist/define.d.ts +249 -0
  11. package/dist/define.d.ts.map +1 -0
  12. package/dist/define.js +197 -0
  13. package/dist/define.js.map +1 -0
  14. package/dist/errors.d.ts +12 -0
  15. package/dist/errors.d.ts.map +1 -0
  16. package/dist/errors.js +39 -0
  17. package/dist/errors.js.map +1 -0
  18. package/dist/extension.d.ts +25 -0
  19. package/dist/extension.d.ts.map +1 -0
  20. package/dist/extension.js +70 -0
  21. package/dist/extension.js.map +1 -0
  22. package/dist/index.d.ts +5 -0
  23. package/dist/index.d.ts.map +1 -0
  24. package/dist/index.js +4 -0
  25. package/dist/index.js.map +1 -0
  26. package/dist/jobs.d.ts +56 -0
  27. package/dist/jobs.d.ts.map +1 -0
  28. package/dist/jobs.js +33 -0
  29. package/dist/jobs.js.map +1 -0
  30. package/dist/playwright.d.ts +79 -0
  31. package/dist/playwright.d.ts.map +1 -0
  32. package/dist/playwright.js +124 -0
  33. package/dist/playwright.js.map +1 -0
  34. package/dist/react.d.ts +55 -0
  35. package/dist/react.d.ts.map +1 -0
  36. package/dist/react.js +72 -0
  37. package/dist/react.js.map +1 -0
  38. package/dist/server/headless.d.ts +20 -0
  39. package/dist/server/headless.d.ts.map +1 -0
  40. package/dist/server/headless.js +35 -0
  41. package/dist/server/headless.js.map +1 -0
  42. package/dist/server/host.d.ts +146 -0
  43. package/dist/server/host.d.ts.map +1 -0
  44. package/dist/server/host.js +1078 -0
  45. package/dist/server/host.js.map +1 -0
  46. package/dist/server/index.d.ts +9 -0
  47. package/dist/server/index.d.ts.map +1 -0
  48. package/dist/server/index.js +5 -0
  49. package/dist/server/index.js.map +1 -0
  50. package/dist/server/release.d.ts +30 -0
  51. package/dist/server/release.d.ts.map +1 -0
  52. package/dist/server/release.js +71 -0
  53. package/dist/server/release.js.map +1 -0
  54. package/dist/server/storage.d.ts +63 -0
  55. package/dist/server/storage.d.ts.map +1 -0
  56. package/dist/server/storage.js +253 -0
  57. package/dist/server/storage.js.map +1 -0
  58. package/dist/testing.d.ts +49 -0
  59. package/dist/testing.d.ts.map +1 -0
  60. package/dist/testing.js +42 -0
  61. package/dist/testing.js.map +1 -0
  62. package/dist/vite.d.ts +15 -0
  63. package/dist/vite.d.ts.map +1 -0
  64. package/dist/vite.js +213 -0
  65. package/dist/vite.js.map +1 -0
  66. package/package.json +115 -3
  67. package/styles.css +122 -0
  68. package/templates/canvas/README.md +19 -0
  69. package/templates/canvas/e2e/canvas.spec.ts +15 -0
  70. package/templates/canvas/index.html +13 -0
  71. package/templates/canvas/package.json +29 -0
  72. package/templates/canvas/playwright.config.ts +5 -0
  73. package/templates/canvas/server/canvas.test.ts +22 -0
  74. package/templates/canvas/server/canvas.ts +27 -0
  75. package/templates/canvas/src/App.css +21 -0
  76. package/templates/canvas/src/App.tsx +31 -0
  77. package/templates/canvas/src/index.css +3 -0
  78. package/templates/canvas/src/main.tsx +11 -0
  79. package/templates/canvas/tsconfig.app.json +1 -0
  80. package/templates/canvas/tsconfig.json +7 -0
  81. package/templates/canvas/tsconfig.node.json +1 -0
  82. package/templates/canvas/vite.config.ts +7 -0
  83. package/templates/ui/README.md +18 -0
  84. package/templates/ui/package.json +22 -0
  85. package/templates/ui/src/Checklist.tsx +42 -0
  86. package/templates/ui/src/Header.tsx +17 -0
  87. package/templates/ui/src/Markdown.tsx +140 -0
  88. package/templates/ui/src/TextButton.tsx +6 -0
  89. package/templates/ui/src/index.ts +4 -0
  90. package/templates/ui/theme.css +143 -0
  91. package/templates/ui/tsconfig.json +18 -0
  92. package/tsconfig/canvas.json +27 -0
  93. package/tsconfig/node.json +24 -0
@@ -0,0 +1,1078 @@
1
+ import { randomBytes, timingSafeEqual } from 'node:crypto';
2
+ import { mkdirSync, watch } from 'node:fs';
3
+ import { readFile, stat } from 'node:fs/promises';
4
+ import { createServer } from 'node:http';
5
+ import { basename, extname, join, resolve, sep } from 'node:path';
6
+ import { jobForId, parseWith, } from "../define.js";
7
+ import { CanvasKitError, errorMessage } from "../errors.js";
8
+ import { JOB_ACTIONS, formatInterval, intervalMs } from "../jobs.js";
9
+ import { escapeHtml, injectReleaseFooter, insertBefore } from "./release.js";
10
+ import { StateStore, canvasDataDir } from "./storage.js";
11
+ const allows = (controls, source) => controls === 'both' || controls === source;
12
+ const MAX_BODY_BYTES = 1024 * 1024;
13
+ const HEARTBEAT_MS = 15_000;
14
+ const INSTANCE_PATH = /^\/c\/([0-9a-f]{32})(\/.*)?$/;
15
+ const CONTENT_TYPES = {
16
+ '.html': 'text/html; charset=utf-8',
17
+ '.js': 'text/javascript; charset=utf-8',
18
+ '.mjs': 'text/javascript; charset=utf-8',
19
+ '.css': 'text/css; charset=utf-8',
20
+ '.json': 'application/json; charset=utf-8',
21
+ '.map': 'application/json; charset=utf-8',
22
+ '.txt': 'text/plain; charset=utf-8',
23
+ '.svg': 'image/svg+xml',
24
+ '.png': 'image/png',
25
+ '.jpg': 'image/jpeg',
26
+ '.jpeg': 'image/jpeg',
27
+ '.gif': 'image/gif',
28
+ '.webp': 'image/webp',
29
+ '.avif': 'image/avif',
30
+ '.ico': 'image/x-icon',
31
+ '.woff': 'font/woff',
32
+ '.woff2': 'font/woff2',
33
+ '.ttf': 'font/ttf',
34
+ '.wasm': 'application/wasm',
35
+ };
36
+ /** The page's Content-Security-Policy: strict defaults plus the canvas's own `csp` sources. */
37
+ export function contentSecurityPolicy(csp) {
38
+ return [
39
+ "default-src 'none'",
40
+ "script-src 'self'",
41
+ "style-src 'self' 'unsafe-inline'",
42
+ "img-src 'self' data: blob:",
43
+ "font-src 'self' data:",
44
+ "media-src 'self' data: blob:",
45
+ ["connect-src 'self'", ...csp.connectSrc].join(' '),
46
+ "base-uri 'none'",
47
+ "form-action 'none'",
48
+ ].join('; ');
49
+ }
50
+ export function renderBootstrap(bootstrap) {
51
+ const json = JSON.stringify(bootstrap)
52
+ .replace(/</g, '\\u003c')
53
+ .replace(/\u2028/g, '\\u2028')
54
+ .replace(/\u2029/g, '\\u2029');
55
+ return `<script type="application/json" id="canvas-kit-bootstrap">${json}</script>`;
56
+ }
57
+ export function injectBootstrap(html, bootstrap) {
58
+ return insertBefore(html, /<\/head>/, renderBootstrap(bootstrap), 'start');
59
+ }
60
+ function sendJson(response, status, body) {
61
+ response.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8' });
62
+ response.end(JSON.stringify(body));
63
+ }
64
+ async function readJsonBody(request) {
65
+ const type = request.headers['content-type']?.split(';')[0]?.trim();
66
+ if (type !== 'application/json')
67
+ throw new CanvasKitError('invalid_input', 'Send JSON with Content-Type: application/json');
68
+ const chunks = [];
69
+ let size = 0;
70
+ for await (const chunk of request) {
71
+ size += chunk.length;
72
+ if (size > MAX_BODY_BYTES)
73
+ throw new CanvasKitError('payload_too_large', 'Request body is too large');
74
+ chunks.push(chunk);
75
+ }
76
+ if (size === 0)
77
+ return undefined;
78
+ try {
79
+ return JSON.parse(Buffer.concat(chunks).toString('utf8'));
80
+ }
81
+ catch {
82
+ throw new CanvasKitError('invalid_input', 'Request body is not valid JSON');
83
+ }
84
+ }
85
+ function tokenMatches(supplied, expected) {
86
+ if (typeof supplied !== 'string')
87
+ return false;
88
+ const a = Buffer.from(supplied);
89
+ const b = Buffer.from(expected);
90
+ return a.length === b.length && timingSafeEqual(a, b);
91
+ }
92
+ /**
93
+ * Runs one canvas definition: instance lifecycle, serialized actions, state
94
+ * persistence, and the loopback HTTP server that serves the UI and live state.
95
+ *
96
+ * Locks: `i:<instanceId>` serializes open, close, and actions for one instance;
97
+ * the slot lock serializes state changes for one document. Instance locks are
98
+ * always taken before slot locks.
99
+ */
100
+ export class CanvasHost {
101
+ canvas;
102
+ #options;
103
+ #instances = new Map();
104
+ #byKey = new Map();
105
+ #slots = new Map();
106
+ #locks = new Map();
107
+ #store;
108
+ #dataDir;
109
+ #csp;
110
+ #server;
111
+ #indexHtml;
112
+ #pendingOpens = 0;
113
+ #watcher;
114
+ #poll;
115
+ #mtimes = new Map();
116
+ #reloads = new Map();
117
+ #jobTimer;
118
+ #ticking = false;
119
+ #now;
120
+ constructor(canvas, options = {}) {
121
+ if (canvas?.kind !== 'canvas-kit/canvas')
122
+ throw new Error('Expected a canvas created with defineCanvas()');
123
+ this.canvas = canvas;
124
+ this.#options = options;
125
+ this.#now = options.now ?? Date.now;
126
+ this.#dataDir = canvasDataDir(canvas.id, options.dataRoot);
127
+ this.#csp = contentSecurityPolicy(canvas.csp);
128
+ this.#store = canvas.persist ? new StateStore(this.#dataDir, canvas.state, canvas.persist.version, options.store) : undefined;
129
+ }
130
+ log(message, level = 'info') {
131
+ this.#options.log?.(message, level);
132
+ }
133
+ /** Open or reopen an instance and return the URL of its page. */
134
+ async open(request) {
135
+ const instanceId = this.#checkInstanceId(request.instanceId);
136
+ this.#pendingOpens += 1;
137
+ try {
138
+ return await this.#withLock(`i:${instanceId}`, async () => {
139
+ const instance = await this.#openInstance(instanceId, request.input);
140
+ const { origin } = await this.#ensureServer();
141
+ return {
142
+ url: `${origin}/c/${instance.key}/`,
143
+ title: this.canvas.title,
144
+ ...this.#status(instance),
145
+ };
146
+ });
147
+ }
148
+ finally {
149
+ this.#pendingOpens -= 1;
150
+ }
151
+ }
152
+ /** Open an instance without starting the HTTP server, as the Vite dev server does. */
153
+ openLocal(request) {
154
+ const instanceId = this.#checkInstanceId(request.instanceId);
155
+ return this.#withLock(`i:${instanceId}`, async () => this.#bootstrap(await this.#openInstance(instanceId, request.input), '/'));
156
+ }
157
+ /** Page bootstrap data for an open instance, without reopening it. */
158
+ bootstrapFor(instanceId, base) {
159
+ const instance = this.#instances.get(instanceId);
160
+ return instance && this.#bootstrap(instance, base);
161
+ }
162
+ invoke(request) {
163
+ const instanceId = this.#checkInstanceId(request.instanceId);
164
+ return this.#withLock(`i:${instanceId}`, async () => {
165
+ const instance = this.#instances.get(instanceId);
166
+ if (!instance)
167
+ throw new CanvasKitError('not_open', `Open the ${this.canvas.title} canvas before using its actions`);
168
+ return this.#withLock(instance.slot.lock, () => this.#runAction(instance, request));
169
+ });
170
+ }
171
+ snapshot(instanceId) {
172
+ const slot = this.#instances.get(instanceId)?.slot;
173
+ return slot && this.#snapshotOf(slot);
174
+ }
175
+ /** Close an instance. The HTTP server stops once no instances remain. */
176
+ async closeInstance(instanceId) {
177
+ await this.#withLock(`i:${instanceId}`, async () => {
178
+ const instance = this.#instances.get(instanceId);
179
+ if (!instance)
180
+ return;
181
+ this.#instances.delete(instanceId);
182
+ this.#byKey.delete(instance.key);
183
+ for (const stream of instance.streams)
184
+ stream.end();
185
+ await this.#detach(instance);
186
+ });
187
+ if (this.#instances.size === 0 && this.#pendingOpens === 0)
188
+ await this.#stopServer();
189
+ }
190
+ /** Close every instance, stop jobs and file watching, and stop the HTTP server. */
191
+ async close() {
192
+ clearInterval(this.#jobTimer);
193
+ this.#jobTimer = undefined;
194
+ await Promise.all([...this.#instances.keys()].map(id => this.closeInstance(id)));
195
+ this.#unwatch();
196
+ await this.#stopServer();
197
+ }
198
+ /**
199
+ * Run every due job once for every open document. With `jobs: 'auto'` the host
200
+ * calls this on a timer; failures are logged.
201
+ */
202
+ async tickJobs() {
203
+ if (this.#ticking || this.canvas.jobs.size === 0)
204
+ return;
205
+ this.#ticking = true;
206
+ try {
207
+ const slots = new Set([...this.#instances.values()].map(instance => instance.slot));
208
+ for (const slot of slots) {
209
+ for (const entry of this.#entries(slot.jobs)) {
210
+ // A cheap check first; the job re-checks against the saved document under its lock.
211
+ if (!this.#due(entry))
212
+ continue;
213
+ try {
214
+ await this.#withLock(slot.lock, () => this.#runJobFor(slot, entry.id, false, false));
215
+ }
216
+ catch (error) {
217
+ this.log(`Job ${entry.id} could not run: ${errorMessage(error)}`, 'warning');
218
+ }
219
+ }
220
+ }
221
+ }
222
+ finally {
223
+ this.#ticking = false;
224
+ }
225
+ }
226
+ /**
227
+ * Run a job now for one document: an open instance's (`instanceId`), a saved
228
+ * document's (`key`), or the only open document. `id` is a job's name, or the ID
229
+ * of a job added from a job with input. Runs even if not due unless `force: false`.
230
+ */
231
+ async runJob(id, options = {}) {
232
+ const job = this.#jobFor(id);
233
+ if (job.input && id === job.name) {
234
+ throw new CanvasKitError('invalid_input', `${id} takes input, so only the jobs added from it run; pass one of their IDs`);
235
+ }
236
+ const slot = await this.#slotForJob(options);
237
+ const outcome = await this.#withLock(slot.lock, () => this.#runJobFor(slot, id, options.force ?? true, options.headless ?? false));
238
+ if (!outcome)
239
+ throw new CanvasKitError('not_found', `No job ${id} in this document`);
240
+ return outcome;
241
+ }
242
+ /**
243
+ * Run one document's due jobs, as `canvas-kit run` does: every job, the jobs with
244
+ * one name (including the jobs added from a job with input), or the job with one ID.
245
+ * `force` runs them even if not due or paused.
246
+ */
247
+ async runJobs(selector, options = {}) {
248
+ if (selector !== undefined)
249
+ this.#jobFor(selector);
250
+ const slot = await this.#slotForJob(options);
251
+ const outcomes = [];
252
+ const selected = this.#entries(slot.jobs).filter(entry => selector === undefined || entry.id === selector || entry.job.name === selector);
253
+ for (const entry of selected) {
254
+ try {
255
+ const outcome = await this.#withLock(slot.lock, () => this.#runJobFor(slot, entry.id, options.force ?? false, options.headless ?? false));
256
+ if (outcome)
257
+ outcomes.push(outcome);
258
+ }
259
+ catch (error) {
260
+ outcomes.push({ id: entry.id, job: entry.job.name, key: slot.persistKey, ran: false, error: errorMessage(error) });
261
+ }
262
+ }
263
+ return outcomes;
264
+ }
265
+ /** The persist keys of every saved document, for running jobs without an open canvas. */
266
+ async savedKeys() {
267
+ return this.#store ? this.#store.keys() : [];
268
+ }
269
+ async #stopServer() {
270
+ const pending = this.#server;
271
+ this.#server = undefined;
272
+ if (!pending)
273
+ return;
274
+ const { server } = await pending.catch(() => ({ server: undefined }));
275
+ if (!server)
276
+ return;
277
+ await new Promise((done, fail) => {
278
+ server.close(error => (error ? fail(error) : done()));
279
+ server.closeAllConnections();
280
+ });
281
+ }
282
+ /**
283
+ * Handle `__canvas/*` API requests for one instance. `path` is relative to the
284
+ * instance base, for example `__canvas/events`.
285
+ */
286
+ async handleApi(instanceId, path, request, response) {
287
+ const instance = this.#instances.get(instanceId);
288
+ if (!instance)
289
+ throw new CanvasKitError('not_found', 'This canvas was closed; ask the agent to reopen it');
290
+ if (!tokenMatches(request.headers['x-canvas-token'], instance.token)) {
291
+ throw new CanvasKitError('forbidden', 'Canvas token required; reload the canvas');
292
+ }
293
+ if (path === '__canvas/state' && request.method === 'GET') {
294
+ sendJson(response, 200, this.#snapshotOf(instance.slot));
295
+ return;
296
+ }
297
+ if (path === '__canvas/events' && request.method === 'GET') {
298
+ this.#stream(instance, request, response);
299
+ return;
300
+ }
301
+ const action = /^__canvas\/actions\/([a-z][a-z0-9_]*)$/.exec(path);
302
+ if (action && request.method === 'POST') {
303
+ const input = await readJsonBody(request);
304
+ const result = await this.invoke({ instanceId, actionName: action[1], input, source: 'ui' });
305
+ sendJson(response, 200, { ok: true, result: result ?? null });
306
+ return;
307
+ }
308
+ throw new CanvasKitError('not_found', 'Route not found');
309
+ }
310
+ /**
311
+ * Run an action as the agent would through the Copilot host. Only the Vite
312
+ * dev server uses this, so canvas UIs can be checked without an agent.
313
+ */
314
+ async runDevAgentAction(instanceId, actionName, request, response) {
315
+ const input = await readJsonBody(request);
316
+ const result = await this.invoke({ instanceId, actionName, input, source: 'agent' });
317
+ sendJson(response, 200, { ok: true, result: result ?? null });
318
+ }
319
+ /** Report a request failure as JSON without leaking unexpected error details. */
320
+ sendError(response, error) {
321
+ const known = error instanceof CanvasKitError;
322
+ if (!known)
323
+ this.log(`Canvas request failed: ${errorMessage(error)}`, 'error');
324
+ if (response.headersSent) {
325
+ response.end();
326
+ return;
327
+ }
328
+ sendJson(response, known ? error.status : 500, {
329
+ ok: false,
330
+ code: known ? error.code : 'internal_error',
331
+ message: known ? error.message : 'The canvas request failed; see the extension log.',
332
+ });
333
+ }
334
+ #checkInstanceId(instanceId) {
335
+ if (typeof instanceId !== 'string' || instanceId.length === 0 || instanceId.length > 256) {
336
+ throw new CanvasKitError('invalid_input', 'instanceId must be a non-empty string');
337
+ }
338
+ return instanceId;
339
+ }
340
+ #bootstrap(instance, base) {
341
+ const { release } = this.#options;
342
+ return {
343
+ base,
344
+ token: instance.token,
345
+ ...this.#snapshotOf(instance.slot),
346
+ ...(release && { release }),
347
+ ...(this.canvas.jobs.size > 0 && { jobDefinitions: this.#jobDefinitions() }),
348
+ };
349
+ }
350
+ #status(instance) {
351
+ const status = this.canvas.status(instance.slot.state);
352
+ return status ? { status } : {};
353
+ }
354
+ async #withLock(name, run) {
355
+ const previous = this.#locks.get(name) ?? Promise.resolve();
356
+ const next = previous.then(run, run);
357
+ const settled = next.catch(() => { });
358
+ this.#locks.set(name, settled);
359
+ try {
360
+ return await next;
361
+ }
362
+ finally {
363
+ if (this.#locks.get(name) === settled)
364
+ this.#locks.delete(name);
365
+ }
366
+ }
367
+ // ---------- saved documents shared across processes ----------
368
+ /** Run `work` holding the document's cross-process lock, starting from the latest saved state. */
369
+ async #transact(slot, work) {
370
+ const store = this.#store;
371
+ const key = slot.persistKey;
372
+ if (!store || key === undefined)
373
+ return work();
374
+ return store.lock(key, async () => {
375
+ await this.#reload(slot);
376
+ return work();
377
+ });
378
+ }
379
+ /** Load the saved document if another process saved a newer revision, and show it. */
380
+ async #reload(slot) {
381
+ if (!this.#store || slot.persistKey === undefined)
382
+ return;
383
+ const saved = await this.#store.read(slot.persistKey);
384
+ if (!saved || saved.revision === slot.fileRevision)
385
+ return;
386
+ slot.state = saved.state;
387
+ slot.fileRevision = saved.revision;
388
+ slot.jobs = saved.jobs;
389
+ slot.revision += 1;
390
+ this.#publish(slot);
391
+ }
392
+ /** Save the document as the next revision. Call it inside `#transact`. */
393
+ async #persist(slot, state = slot.state, jobs = slot.jobs) {
394
+ if (!this.#store || slot.persistKey === undefined)
395
+ return;
396
+ const revision = slot.fileRevision + 1;
397
+ await this.#store.write(slot.persistKey, { state, revision, jobs });
398
+ slot.fileRevision = revision;
399
+ }
400
+ #watch() {
401
+ if (!this.#store || this.#options.watch === false)
402
+ return;
403
+ if (!this.#watcher) {
404
+ try {
405
+ mkdirSync(this.#store.stateDir, { recursive: true, mode: 0o700 });
406
+ this.#watcher = watch(this.#store.stateDir, (_event, name) => {
407
+ if (name)
408
+ this.#onFileChange(String(name));
409
+ });
410
+ this.#watcher.on('error', error => {
411
+ this.log(`Stopped watching saved documents: ${errorMessage(error)}`, 'warning');
412
+ this.#watcher?.close();
413
+ this.#watcher = undefined;
414
+ });
415
+ this.#watcher.unref();
416
+ }
417
+ catch (error) {
418
+ this.log(`Checking saved documents every few seconds instead of watching them: ${errorMessage(error)}`, 'warning');
419
+ }
420
+ }
421
+ // File watching can miss changes, for example when macOS restarts a process's
422
+ // event stream, so also check the open documents' modification times.
423
+ if (!this.#poll) {
424
+ this.#poll = setInterval(() => void this.#pollFiles(), this.#options.watchPollMs ?? 2_000);
425
+ this.#poll.unref();
426
+ }
427
+ }
428
+ async #pollFiles() {
429
+ const store = this.#store;
430
+ if (!store)
431
+ return;
432
+ for (const slot of this.#slots.values()) {
433
+ if (!slot.file)
434
+ continue;
435
+ const mtime = await stat(join(store.stateDir, slot.file)).then(info => info.mtimeMs, () => undefined);
436
+ if (mtime === undefined || this.#mtimes.get(slot.file) === mtime)
437
+ continue;
438
+ this.#mtimes.set(slot.file, mtime);
439
+ this.#onFileChange(slot.file);
440
+ }
441
+ }
442
+ #unwatch() {
443
+ this.#watcher?.close();
444
+ this.#watcher = undefined;
445
+ clearInterval(this.#poll);
446
+ this.#poll = undefined;
447
+ this.#mtimes.clear();
448
+ for (const timer of this.#reloads.values())
449
+ clearTimeout(timer);
450
+ this.#reloads.clear();
451
+ }
452
+ #onFileChange(name) {
453
+ const slot = [...this.#slots.values()].find(candidate => candidate.file === name);
454
+ if (!slot)
455
+ return;
456
+ clearTimeout(this.#reloads.get(name));
457
+ const timer = setTimeout(() => {
458
+ this.#reloads.delete(name);
459
+ void this.#withLock(slot.lock, () => this.#reload(slot)).catch(error => this.log(`Could not load changes to ${slot.persistKey}: ${errorMessage(error)}`, 'warning'));
460
+ }, 25);
461
+ timer.unref();
462
+ this.#reloads.set(name, timer);
463
+ }
464
+ // ---------- jobs ----------
465
+ #startJobs() {
466
+ if (this.#jobTimer || this.canvas.jobs.size === 0 || this.#options.jobs === 'manual')
467
+ return;
468
+ const shortest = Math.min(...[...this.canvas.jobs.values()].map(job => job.minEveryMs));
469
+ this.#jobTimer = setInterval(() => void this.tickJobs(), Math.min(Math.max(shortest / 4, 1_000), 30_000));
470
+ this.#jobTimer.unref();
471
+ }
472
+ /** Run due jobs soon, for example after a job is added or resumed. */
473
+ #kickJobs() {
474
+ if (this.canvas.jobs.size === 0 || this.#options.jobs === 'manual')
475
+ return;
476
+ this.#startJobs();
477
+ setTimeout(() => void this.tickJobs(), 0).unref();
478
+ }
479
+ #jobFor(id) {
480
+ return jobForId(this.canvas, id);
481
+ }
482
+ /** A document's jobs: each declared job without input, then the jobs added from jobs with input, oldest first. */
483
+ #entries(records) {
484
+ const entries = [];
485
+ for (const job of this.canvas.jobs.values())
486
+ if (!job.input)
487
+ entries.push({ id: job.name, job, record: records[job.name] });
488
+ const added = Object.entries(records)
489
+ .filter(([id, record]) => record.job !== undefined && this.canvas.jobs.get(record.job)?.input !== undefined && id.startsWith(`${record.job}-`))
490
+ .sort(([, a], [, b]) => (a.createdAt ?? '').localeCompare(b.createdAt ?? ''));
491
+ for (const [id, record] of added)
492
+ entries.push({ id, job: this.canvas.jobs.get(record.job), record });
493
+ return entries;
494
+ }
495
+ #entry(slot, id) {
496
+ return this.#entries(slot.jobs).find(entry => entry.id === id);
497
+ }
498
+ /** The interval in use: the one a user chose, kept within the job's current limits. */
499
+ #everyMs(entry) {
500
+ // Settings users chose apply only while the job still has controls, so a later
501
+ // version that removes them never strands a job paused or retimed.
502
+ const every = entry.job.controls === undefined ? entry.job.everyMs : (entry.record?.every ?? entry.job.everyMs);
503
+ return Math.min(entry.job.maxEveryMs, Math.max(entry.job.minEveryMs, every));
504
+ }
505
+ #enabled(entry) {
506
+ return entry.job.controls === undefined || entry.record?.enabled !== false;
507
+ }
508
+ #due(entry) {
509
+ if (!this.#enabled(entry))
510
+ return false;
511
+ const last = entry.record?.lastRunAt;
512
+ return last === undefined || this.#now() - Date.parse(last) >= this.#everyMs(entry);
513
+ }
514
+ #jobStatus(entry) {
515
+ const { id, job, record } = entry;
516
+ const every = this.#everyMs(entry);
517
+ const enabled = this.#enabled(entry);
518
+ const last = record?.lastRunAt;
519
+ return {
520
+ id,
521
+ job: job.name,
522
+ ...(job.description !== undefined && { description: job.description }),
523
+ ...(job.input !== undefined && { input: record?.input }),
524
+ every,
525
+ minEvery: job.minEveryMs,
526
+ maxEvery: job.maxEveryMs,
527
+ enabled,
528
+ ...(last !== undefined && { lastRunAt: last }),
529
+ ...(record?.lastError !== undefined && { lastError: record.lastError }),
530
+ ...(enabled && last !== undefined && { nextRunAt: new Date(Date.parse(last) + every).toISOString() }),
531
+ ...(job.controls !== undefined && { controls: job.controls }),
532
+ added: job.input !== undefined,
533
+ };
534
+ }
535
+ #jobDefinitions() {
536
+ return [...this.canvas.jobs.values()].map(job => ({
537
+ name: job.name,
538
+ ...(job.description !== undefined && { description: job.description }),
539
+ every: job.everyMs,
540
+ minEvery: job.minEveryMs,
541
+ maxEvery: job.maxEveryMs,
542
+ ...(job.controls !== undefined && { controls: job.controls }),
543
+ hasInput: job.input !== undefined,
544
+ maxJobs: job.maxJobs,
545
+ }));
546
+ }
547
+ #snapshotOf(slot) {
548
+ return {
549
+ revision: slot.revision,
550
+ state: slot.state,
551
+ ...(this.canvas.jobs.size > 0 && { jobs: this.#entries(slot.jobs).map(entry => this.#jobStatus(entry)) }),
552
+ };
553
+ }
554
+ /** Save new job records and show them. Call it inside `#transact`. */
555
+ async #saveJobs(slot, jobs) {
556
+ await this.#persist(slot, slot.state, jobs);
557
+ slot.jobs = jobs;
558
+ slot.revision += 1;
559
+ this.#publish(slot);
560
+ }
561
+ #checkEvery(job, every) {
562
+ const ms = intervalMs(every);
563
+ if (!(ms >= job.minEveryMs && ms <= job.maxEveryMs)) {
564
+ throw new CanvasKitError('invalid_input', `${job.name} runs every ${formatInterval(job.minEveryMs)} to ${formatInterval(job.maxEveryMs)}`);
565
+ }
566
+ return ms;
567
+ }
568
+ /** A job that `source` may control, or not_found, so hidden jobs look absent. */
569
+ #controlled(slot, id, source) {
570
+ const entry = this.#entry(slot, id);
571
+ if (!entry || !allows(entry.job.controls, source))
572
+ throw new CanvasKitError('not_found', `No job ${id}; list_jobs shows the IDs`);
573
+ return entry;
574
+ }
575
+ /** The built-in job actions. They take the document's lock themselves; call them holding the slot lock. */
576
+ async #jobAction(slot, action, input, raw, source) {
577
+ const id = input.id;
578
+ switch (action) {
579
+ case JOB_ACTIONS.list:
580
+ return this.#transact(slot, async () => this.#entries(slot.jobs)
581
+ .filter(entry => allows(entry.job.controls, source))
582
+ .map(entry => this.#jobStatus(entry)));
583
+ case JOB_ACTIONS.update:
584
+ return this.#transact(slot, async () => {
585
+ const entry = this.#controlled(slot, id, source);
586
+ const record = { ...entry.record };
587
+ if (input.enabled === true)
588
+ delete record.enabled;
589
+ if (input.enabled === false)
590
+ record.enabled = false;
591
+ if (input.every !== undefined)
592
+ record.every = this.#checkEvery(entry.job, input.every);
593
+ await this.#saveJobs(slot, { ...slot.jobs, [id]: record });
594
+ if (input.enabled === true)
595
+ this.#kickJobs();
596
+ return this.#jobStatus({ ...entry, record });
597
+ });
598
+ case JOB_ACTIONS.run: {
599
+ if (!allows(this.#jobFor(id).controls, source))
600
+ throw new CanvasKitError('not_found', `No job ${id}; list_jobs shows the IDs`);
601
+ const outcome = await this.#runJobFor(slot, id, true, false);
602
+ if (!outcome)
603
+ throw new CanvasKitError('not_found', `No job ${id}; list_jobs shows the IDs`);
604
+ if (outcome.error)
605
+ throw new CanvasKitError('job_failed', `${id} failed: ${outcome.error}`);
606
+ const entry = this.#entry(slot, id);
607
+ return entry && this.#jobStatus(entry);
608
+ }
609
+ case JOB_ACTIONS.add: {
610
+ const job = this.canvas.jobs.get(input.job);
611
+ if (!job?.input || !allows(job.controls, source))
612
+ throw new CanvasKitError('not_found', `No job ${String(input.job)} to add jobs from`);
613
+ return this.#transact(slot, async () => {
614
+ if (Object.values(slot.jobs).filter(record => record.job === job.name).length >= job.maxJobs) {
615
+ throw new CanvasKitError('limit_reached', `This document already has ${job.maxJobs} ${job.name} jobs; remove one first`);
616
+ }
617
+ let added;
618
+ do
619
+ added = `${job.name}-${randomBytes(3).toString('hex')}`;
620
+ while (slot.jobs[added]);
621
+ const record = {
622
+ job: job.name,
623
+ // Keep the input as given, so it validates again on every run.
624
+ input: raw.input,
625
+ createdAt: new Date(this.#now()).toISOString(),
626
+ ...(input.every !== undefined && { every: this.#checkEvery(job, input.every) }),
627
+ ...(input.enabled === false && { enabled: false }),
628
+ };
629
+ await this.#saveJobs(slot, { ...slot.jobs, [added]: record });
630
+ this.#kickJobs();
631
+ return this.#jobStatus({ id: added, job, record });
632
+ });
633
+ }
634
+ case JOB_ACTIONS.remove:
635
+ return this.#transact(slot, async () => {
636
+ const entry = this.#controlled(slot, id, source);
637
+ if (!entry.job.input)
638
+ throw new CanvasKitError('invalid_input', `${id} is declared by the canvas; pause it instead`);
639
+ const { [id]: _removed, ...rest } = slot.jobs;
640
+ await this.#saveJobs(slot, rest);
641
+ return { removed: id };
642
+ });
643
+ }
644
+ }
645
+ async #slotForJob(options) {
646
+ if (options.instanceId !== undefined) {
647
+ const instance = this.#instances.get(options.instanceId);
648
+ if (!instance)
649
+ throw new CanvasKitError('not_open', `No open instance ${options.instanceId}`);
650
+ return instance.slot;
651
+ }
652
+ if (options.key !== undefined) {
653
+ const open = this.#slots.get(options.key);
654
+ if (open)
655
+ return open;
656
+ if (!this.#store)
657
+ throw new CanvasKitError('invalid_input', `${this.canvas.title} does not save documents, so it has no key ${options.key}`);
658
+ const saved = await this.#store.read(options.key);
659
+ if (!saved)
660
+ throw new CanvasKitError('not_found', `No saved ${this.canvas.title} document for ${options.key}`);
661
+ return this.#newSlot(options.key, saved.state, saved.revision, saved.jobs);
662
+ }
663
+ const slots = new Set([...this.#instances.values()].map(instance => instance.slot));
664
+ if (slots.size === 1)
665
+ return [...slots][0];
666
+ throw new CanvasKitError('invalid_input', 'Pass the instanceId or key of the document to run the job for');
667
+ }
668
+ #newSlot(persistKey, state, fileRevision = 0, jobs = {}, instanceId) {
669
+ return {
670
+ lock: persistKey === undefined ? `s:i:${instanceId}` : `s:p:${persistKey}`,
671
+ persistKey,
672
+ file: persistKey !== undefined && this.#store ? basename(this.#store.fileFor(persistKey)) : undefined,
673
+ state,
674
+ revision: 1,
675
+ fileRevision,
676
+ jobs,
677
+ instances: new Set(),
678
+ };
679
+ }
680
+ /**
681
+ * Run one job for one document; call it holding the slot lock. A failed job's state
682
+ * changes roll back, and its run is still recorded. Undefined when the document has
683
+ * no job with this ID, for example because another chat removed it.
684
+ */
685
+ async #runJobFor(slot, id, force, headless) {
686
+ return this.#transact(slot, async () => {
687
+ const entry = this.#entry(slot, id);
688
+ if (!entry)
689
+ return undefined;
690
+ const { job } = entry;
691
+ const outcome = { id, job: job.name, key: slot.persistKey, ran: false };
692
+ if (!force && !this.#due(entry))
693
+ return this.#enabled(entry) ? outcome : { ...outcome, paused: true };
694
+ const before = slot.state;
695
+ let changed = false;
696
+ const ctx = {
697
+ canvasId: this.canvas.id,
698
+ job: job.name,
699
+ id,
700
+ key: slot.persistKey,
701
+ headless,
702
+ lastRunAt: entry.record?.lastRunAt,
703
+ get state() {
704
+ return slot.state;
705
+ },
706
+ dataDir: this.#dataDir,
707
+ setState: this.#setter(slot, `Job ${id}`, () => {
708
+ changed = true;
709
+ }),
710
+ sendToAgent: this.#sender(),
711
+ log: (message, level) => this.log(message, level),
712
+ now: () => this.#now(),
713
+ };
714
+ const { lastError: _previous, ...kept } = entry.record ?? {};
715
+ const record = { ...kept, lastRunAt: new Date(this.#now()).toISOString() };
716
+ try {
717
+ const input = job.input ? await parseWith(job.input, entry.record?.input, `${id} has input that ${job.name} no longer accepts`) : undefined;
718
+ await job.run(ctx, input);
719
+ }
720
+ catch (error) {
721
+ record.lastError = errorMessage(error);
722
+ outcome.error = record.lastError;
723
+ this.log(`Job ${id} failed: ${record.lastError}`, 'warning');
724
+ if (changed) {
725
+ slot.state = before;
726
+ slot.revision += 1;
727
+ this.#publish(slot);
728
+ }
729
+ }
730
+ // Record the run even when it failed, so every chat waits a full interval before retrying.
731
+ try {
732
+ await this.#saveJobs(slot, { ...slot.jobs, [id]: record });
733
+ }
734
+ catch (error) {
735
+ // Nothing was saved, so undo the run here too; it is retried at the next tick.
736
+ if (slot.state !== before) {
737
+ slot.state = before;
738
+ slot.revision += 1;
739
+ this.#publish(slot);
740
+ }
741
+ throw error;
742
+ }
743
+ outcome.ran = true;
744
+ return outcome;
745
+ });
746
+ }
747
+ #setter(slot, label, onChange) {
748
+ return (next) => {
749
+ const value = typeof next === 'function' ? next(slot.state) : next;
750
+ const parsed = this.canvas.state.safeParse(value);
751
+ if (!parsed.success)
752
+ throw new CanvasKitError('invalid_state', `${label} produced invalid state: ${parsed.error.message}`, 500);
753
+ slot.state = parsed.data;
754
+ slot.revision += 1;
755
+ onChange();
756
+ this.#publish(slot);
757
+ return slot.state;
758
+ };
759
+ }
760
+ #sender() {
761
+ return async (message) => {
762
+ const send = this.#options.sendToAgent;
763
+ if (!send)
764
+ throw new CanvasKitError('agent_unavailable', 'No agent session is connected to this canvas', 503);
765
+ try {
766
+ await send(message);
767
+ }
768
+ catch (error) {
769
+ throw new CanvasKitError('send_failed', `Could not message the agent: ${errorMessage(error)}`, 502);
770
+ }
771
+ };
772
+ }
773
+ async #parseOpenInput(input) {
774
+ if (!this.canvas.input)
775
+ return undefined;
776
+ return parseWith(this.canvas.input, input ?? {}, `Invalid ${this.canvas.title} input`);
777
+ }
778
+ #persistKeyFor(instanceId, input) {
779
+ if (!this.#store || !this.canvas.persist)
780
+ return undefined;
781
+ const key = this.canvas.persist.key({ instanceId, input });
782
+ if (typeof key !== 'string' || key.length === 0)
783
+ throw new Error('persist.key() must return a non-empty string');
784
+ return key;
785
+ }
786
+ async #openInstance(instanceId, rawInput) {
787
+ const input = await this.#parseOpenInput(rawInput);
788
+ const persistKey = this.#persistKeyFor(instanceId, input);
789
+ const existing = this.#instances.get(instanceId);
790
+ if (existing && existing.slot.persistKey === persistKey) {
791
+ const slot = existing.slot;
792
+ await this.#withLock(slot.lock, () => this.#reopen(slot, instanceId, input));
793
+ return existing;
794
+ }
795
+ if (existing) {
796
+ // The new input names a different document: switch this instance to it.
797
+ await this.#detach(existing);
798
+ await this.#attach(existing, persistKey, input);
799
+ this.#send(existing);
800
+ return existing;
801
+ }
802
+ const instance = {
803
+ key: randomBytes(16).toString('hex'),
804
+ token: randomBytes(32).toString('hex'),
805
+ instanceId,
806
+ streams: new Set(),
807
+ };
808
+ await this.#attach(instance, persistKey, input);
809
+ this.#instances.set(instanceId, instance);
810
+ this.#byKey.set(instance.key, instance);
811
+ return instance;
812
+ }
813
+ /** Join the document for `persistKey`, loading or creating it on first use. */
814
+ async #attach(instance, persistKey, input) {
815
+ const lock = persistKey === undefined ? `s:i:${instance.instanceId}` : `s:p:${persistKey}`;
816
+ await this.#withLock(lock, async () => {
817
+ let slot = persistKey === undefined ? undefined : this.#slots.get(persistKey);
818
+ if (slot) {
819
+ await this.#reopen(slot, instance.instanceId, input);
820
+ }
821
+ else {
822
+ const init = async () => parseWith(this.canvas.state, await this.canvas.init({ instanceId: instance.instanceId, input }), 'init() returned invalid state');
823
+ const store = persistKey === undefined ? undefined : this.#store;
824
+ if (store && persistKey !== undefined) {
825
+ // Load or create the document atomically, so every chat starts from the same
826
+ // init() result and `canvas-kit run` sees documents that were only opened.
827
+ const { document, created } = await store.lock(persistKey, async () => {
828
+ const saved = await store.read(persistKey);
829
+ if (saved)
830
+ return { document: saved, created: false };
831
+ const document = { state: await init(), revision: 1, jobs: {} };
832
+ await store.write(persistKey, document);
833
+ return { document, created: true };
834
+ });
835
+ slot = this.#newSlot(persistKey, document.state, document.revision, document.jobs, instance.instanceId);
836
+ if (!created)
837
+ await this.#reopen(slot, instance.instanceId, input);
838
+ }
839
+ else {
840
+ slot = this.#newSlot(persistKey, await init(), 0, {}, instance.instanceId);
841
+ }
842
+ if (persistKey !== undefined) {
843
+ this.#slots.set(persistKey, slot);
844
+ this.#watch();
845
+ }
846
+ }
847
+ slot.instances.add(instance);
848
+ instance.slot = slot;
849
+ });
850
+ if (this.canvas.jobs.size > 0 && this.#options.jobs !== 'manual') {
851
+ this.#startJobs();
852
+ // Run overdue jobs soon after opening, without delaying the open.
853
+ setTimeout(() => void this.tickJobs(), 0).unref();
854
+ }
855
+ }
856
+ async #detach(instance) {
857
+ const slot = instance.slot;
858
+ await this.#withLock(slot.lock, async () => {
859
+ slot.instances.delete(instance);
860
+ if (slot.persistKey !== undefined && slot.instances.size === 0 && this.#slots.get(slot.persistKey) === slot) {
861
+ this.#slots.delete(slot.persistKey);
862
+ if (this.#slots.size === 0)
863
+ this.#unwatch();
864
+ }
865
+ });
866
+ }
867
+ /**
868
+ * Catch up with changes saved by other chats, then apply reopen(). The state is
869
+ * saved before it becomes visible, so a failed save changes nothing.
870
+ */
871
+ async #reopen(slot, instanceId, input) {
872
+ await this.#transact(slot, async () => {
873
+ const next = await this.canvas.reopen({ instanceId, input, state: slot.state });
874
+ if (next === undefined)
875
+ return;
876
+ const state = await parseWith(this.canvas.state, next, 'reopen() returned invalid state');
877
+ await this.#persist(slot, state);
878
+ slot.state = state;
879
+ slot.revision += 1;
880
+ this.#publish(slot);
881
+ });
882
+ }
883
+ /** Run an action. If it throws or its state cannot be saved, the state is rolled back. */
884
+ async #runAction(instance, request) {
885
+ const action = this.canvas.actions.get(request.actionName);
886
+ const hidden = action && (request.source === 'ui' ? action.expose === 'agent' : action.expose === 'ui');
887
+ if (!action || hidden)
888
+ throw new CanvasKitError('not_found', `Unknown action: ${request.actionName}`);
889
+ const input = await parseWith(action.input, request.input ?? {}, `Invalid input for ${action.name}`);
890
+ const slot = instance.slot;
891
+ if (action.jobAction)
892
+ return this.#jobAction(slot, action.jobAction, input, request.input, request.source);
893
+ // Start from the latest saved state, holding the document so other chats' changes wait.
894
+ return this.#transact(slot, async () => {
895
+ const before = slot.state;
896
+ let changed = false;
897
+ const ctx = {
898
+ canvasId: this.canvas.id,
899
+ instanceId: instance.instanceId,
900
+ source: request.source,
901
+ get state() {
902
+ return slot.state;
903
+ },
904
+ dataDir: this.#dataDir,
905
+ setState: this.#setter(slot, action.name, () => {
906
+ changed = true;
907
+ }),
908
+ sendToAgent: this.#sender(),
909
+ log: (message, level) => this.log(message, level),
910
+ now: () => this.#now(),
911
+ };
912
+ try {
913
+ const result = await action.run(ctx, input);
914
+ if (changed)
915
+ await this.#persist(slot);
916
+ return result;
917
+ }
918
+ catch (error) {
919
+ if (changed) {
920
+ slot.state = before;
921
+ slot.revision += 1;
922
+ this.#publish(slot);
923
+ }
924
+ throw error;
925
+ }
926
+ });
927
+ }
928
+ #publish(slot) {
929
+ for (const instance of slot.instances)
930
+ this.#send(instance);
931
+ }
932
+ #send(instance) {
933
+ const snapshot = this.#snapshotOf(instance.slot);
934
+ for (const stream of instance.streams)
935
+ stream.send(snapshot);
936
+ }
937
+ #stream(instance, request, response) {
938
+ response.writeHead(200, {
939
+ 'Content-Type': 'text/event-stream; charset=utf-8',
940
+ 'Cache-Control': 'no-store',
941
+ 'X-Accel-Buffering': 'no',
942
+ });
943
+ const heartbeat = setInterval(() => response.write(': ping\n\n'), HEARTBEAT_MS);
944
+ heartbeat.unref();
945
+ const stream = {
946
+ send: snapshot => response.write(`event: state\ndata: ${JSON.stringify(snapshot)}\n\n`),
947
+ end: () => {
948
+ cleanup();
949
+ response.end();
950
+ },
951
+ };
952
+ const cleanup = () => {
953
+ clearInterval(heartbeat);
954
+ instance.streams.delete(stream);
955
+ };
956
+ request.on('close', cleanup);
957
+ instance.streams.add(stream);
958
+ stream.send(this.#snapshotOf(instance.slot));
959
+ }
960
+ #ensureServer() {
961
+ this.#server ??= this.#startServer().catch(error => {
962
+ this.#server = undefined;
963
+ throw new CanvasKitError('listen_failed', `Cannot start the canvas server: ${errorMessage(error)}`, 500);
964
+ });
965
+ return this.#server;
966
+ }
967
+ async #startServer() {
968
+ let origin = '';
969
+ const server = createServer((request, response) => {
970
+ this.#handle(request, response, origin).catch(error => this.sendError(response, error));
971
+ });
972
+ await new Promise((done, fail) => {
973
+ server.once('error', fail);
974
+ server.listen(0, '127.0.0.1', () => {
975
+ server.off('error', fail);
976
+ done();
977
+ });
978
+ });
979
+ origin = `http://127.0.0.1:${server.address().port}`;
980
+ return { server, origin };
981
+ }
982
+ async #handle(request, response, origin) {
983
+ response.setHeader('Cache-Control', 'no-store');
984
+ response.setHeader('X-Content-Type-Options', 'nosniff');
985
+ response.setHeader('Referrer-Policy', 'no-referrer');
986
+ response.setHeader('Content-Security-Policy', this.#csp);
987
+ if (request.headers.host !== new URL(origin).host)
988
+ throw new CanvasKitError('forbidden', 'Invalid host');
989
+ const requestOrigin = request.headers.origin;
990
+ if (requestOrigin !== undefined && requestOrigin !== origin)
991
+ throw new CanvasKitError('forbidden', 'Cross-origin requests are not allowed');
992
+ if (request.method === 'POST' && requestOrigin !== origin)
993
+ throw new CanvasKitError('forbidden', 'Same-origin POST required');
994
+ const url = new URL(request.url ?? '/', origin);
995
+ if (url.pathname === '/favicon.ico') {
996
+ response.writeHead(204);
997
+ response.end();
998
+ return;
999
+ }
1000
+ const match = INSTANCE_PATH.exec(url.pathname);
1001
+ const instance = match && this.#byKey.get(match[1]);
1002
+ if (!match || !instance) {
1003
+ response.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
1004
+ response.end('This canvas was closed. Ask the agent to reopen it.');
1005
+ return;
1006
+ }
1007
+ const rest = match[2];
1008
+ if (rest === undefined) {
1009
+ response.writeHead(308, { Location: `/c/${instance.key}/` });
1010
+ response.end();
1011
+ return;
1012
+ }
1013
+ let path;
1014
+ try {
1015
+ path = decodeURIComponent(rest.slice(1));
1016
+ }
1017
+ catch {
1018
+ throw new CanvasKitError('not_found', 'Route not found');
1019
+ }
1020
+ if (path.startsWith('__canvas/')) {
1021
+ await this.handleApi(instance.instanceId, path, request, response);
1022
+ return;
1023
+ }
1024
+ if (request.method !== 'GET' && request.method !== 'HEAD') {
1025
+ response.writeHead(405, { Allow: 'GET, HEAD' });
1026
+ response.end();
1027
+ return;
1028
+ }
1029
+ if (path === '' || path === 'index.html') {
1030
+ let html = injectBootstrap(await this.#loadIndex(), this.#bootstrap(instance, `/c/${instance.key}/`));
1031
+ const { release } = this.#options;
1032
+ if (release && this.canvas.versionFooter)
1033
+ html = injectReleaseFooter(html, release);
1034
+ response.writeHead(200, { 'Content-Type': CONTENT_TYPES['.html'] });
1035
+ response.end(request.method === 'HEAD' ? undefined : html);
1036
+ return;
1037
+ }
1038
+ await this.#sendAsset(path, request, response);
1039
+ }
1040
+ #loadIndex() {
1041
+ const { uiDir } = this.#options;
1042
+ if (!uiDir) {
1043
+ return Promise.resolve(`<!doctype html>\n<html lang="en">\n<head><meta charset="utf-8"><title>${escapeHtml(this.canvas.title)}</title></head>\n<body><div id="root"></div></body>\n</html>\n`);
1044
+ }
1045
+ this.#indexHtml ??= readFile(join(uiDir, 'index.html'), 'utf8').catch(error => {
1046
+ this.#indexHtml = undefined;
1047
+ throw new CanvasKitError('not_found', `The canvas UI is missing: ${errorMessage(error)}`, 500);
1048
+ });
1049
+ return this.#indexHtml;
1050
+ }
1051
+ async #sendAsset(path, request, response) {
1052
+ const { uiDir } = this.#options;
1053
+ const root = uiDir && resolve(uiDir);
1054
+ const file = root && resolve(root, path);
1055
+ const type = CONTENT_TYPES[extname(path).toLowerCase()];
1056
+ if (!root || !file || !file.startsWith(root + sep) || !type) {
1057
+ response.writeHead(404);
1058
+ response.end();
1059
+ return;
1060
+ }
1061
+ let body;
1062
+ try {
1063
+ body = await readFile(file);
1064
+ }
1065
+ catch {
1066
+ response.writeHead(404);
1067
+ response.end();
1068
+ return;
1069
+ }
1070
+ const immutable = path.startsWith('assets/');
1071
+ response.writeHead(200, {
1072
+ 'Content-Type': type,
1073
+ 'Cache-Control': immutable ? 'public, max-age=31536000, immutable' : 'no-store',
1074
+ });
1075
+ response.end(request.method === 'HEAD' ? undefined : body);
1076
+ }
1077
+ }
1078
+ //# sourceMappingURL=host.js.map