agent-canvas 0.2.0 → 0.6.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 (98) hide show
  1. package/README.md +42 -17
  2. package/bin/agent-canvas.js +7 -7
  3. package/bin/agent-canvas.ts +48 -37
  4. package/bin/lib/commands/daemon.ts +46 -43
  5. package/bin/lib/commands/exec.ts +100 -100
  6. package/bin/lib/commands/fetch.ts +15 -15
  7. package/bin/lib/commands/install.ts +36 -36
  8. package/bin/lib/commands/instructions.ts +51 -51
  9. package/bin/lib/commands/push.ts +115 -97
  10. package/bin/lib/commands/review.ts +190 -0
  11. package/bin/lib/commands/watch.ts +24 -24
  12. package/bin/lib/config.ts +12 -12
  13. package/bin/lib/daemon-lifecycle.ts +47 -51
  14. package/bin/lib/helpers.ts +82 -76
  15. package/bin/lib/instructions/app-review.md +88 -0
  16. package/bin/lib/instructions/component-checklist.md +7 -5
  17. package/bin/lib/instructions/component-custom.md +20 -14
  18. package/bin/lib/instructions/component-diff.md +4 -3
  19. package/bin/lib/instructions/component-item.md +18 -8
  20. package/bin/lib/instructions/component-markdown.md +4 -2
  21. package/bin/lib/instructions/component-mermaid.md +12 -6
  22. package/bin/lib/instructions/component-multichoice.md +6 -2
  23. package/bin/lib/instructions/component-note.md +1 -1
  24. package/bin/lib/instructions/component-priority.md +1 -1
  25. package/bin/lib/instructions/component-rangeinput.md +9 -2
  26. package/bin/lib/instructions/component-secretinput.md +12 -11
  27. package/bin/lib/instructions/component-svg.md +62 -16
  28. package/bin/lib/instructions/component-table.md +6 -6
  29. package/bin/lib/instructions/component-usefeedback.md +8 -8
  30. package/bin/lib/instructions/component-userinput.md +6 -2
  31. package/bin/lib/instructions/flow-feature.md +22 -5
  32. package/bin/lib/instructions/flow-plan.md +5 -5
  33. package/bin/lib/instructions/flow-runbook.md +28 -25
  34. package/bin/lib/instructions/overview.md +27 -18
  35. package/bin/lib/instructions/theming.md +5 -0
  36. package/daemon/build.ts +171 -152
  37. package/daemon/client/App.tsx +1205 -1077
  38. package/daemon/client/CompareView.tsx +478 -458
  39. package/daemon/client/FileBrowser.tsx +241 -203
  40. package/daemon/client/FileViewer.tsx +220 -211
  41. package/daemon/client/OverviewView.tsx +283 -267
  42. package/daemon/client/ReviewerIdentityDialog.tsx +72 -71
  43. package/daemon/client/RevisionSelect.tsx +111 -95
  44. package/daemon/client/SessionSwitcher.tsx +81 -81
  45. package/daemon/client/ShareDialog.tsx +211 -212
  46. package/daemon/client/apiSchemas.ts +78 -0
  47. package/daemon/client/canvas-runtime.ts +1 -1
  48. package/daemon/client/clientApi.ts +194 -206
  49. package/daemon/client/components/SecretInput.tsx +138 -138
  50. package/daemon/client/components/index.ts +2 -2
  51. package/daemon/client/domDiff.ts +341 -333
  52. package/daemon/client/errorMessage.ts +3 -0
  53. package/daemon/client/exportMarkdown.ts +222 -201
  54. package/daemon/client/lcs.ts +19 -14
  55. package/daemon/client/shareCrypto.ts +57 -57
  56. package/daemon/client/unifiedDiff.ts +660 -649
  57. package/daemon/dist/annotations-runtime.js +1 -1
  58. package/daemon/dist/client.css +2 -2
  59. package/daemon/dist/client.js +77 -80
  60. package/daemon/dist/components.js +59 -59
  61. package/daemon/dist/preact-compat.js +1 -1
  62. package/daemon/dist/review.js +361 -0
  63. package/daemon/dist/runtime.js +1 -1
  64. package/daemon/langMap.ts +24 -6
  65. package/daemon/review-widget/anchor.ts +187 -0
  66. package/daemon/review-widget/client.ts +95 -0
  67. package/daemon/review-widget/index.tsx +36 -0
  68. package/daemon/review-widget/markup.ts +71 -0
  69. package/daemon/review-widget/screenshot.ts +91 -0
  70. package/daemon/review-widget/source.ts +103 -0
  71. package/daemon/review-widget/styles.css +359 -0
  72. package/daemon/review-widget/text-modules.d.ts +4 -0
  73. package/daemon/review-widget/tsconfig.json +16 -0
  74. package/daemon/review-widget/widget.tsx +436 -0
  75. package/daemon/src/components.ts +3 -3
  76. package/daemon/src/handlers/api.ts +330 -328
  77. package/daemon/src/handlers/files.ts +112 -105
  78. package/daemon/src/handlers/static.ts +47 -46
  79. package/daemon/src/handlers/uploads.ts +61 -56
  80. package/daemon/src/paths.ts +13 -10
  81. package/daemon/src/remote-feedback.ts +192 -192
  82. package/daemon/src/review/markdown.ts +52 -0
  83. package/daemon/src/review/model.ts +132 -0
  84. package/daemon/src/review/proxy.ts +177 -0
  85. package/daemon/src/review/server.ts +199 -0
  86. package/daemon/src/review/store.ts +151 -0
  87. package/daemon/src/server.ts +127 -86
  88. package/daemon/src/share.ts +163 -169
  89. package/daemon/src/stubFetch.ts +7 -0
  90. package/daemon/tsconfig.json +19 -19
  91. package/package.json +47 -47
  92. package/skills/canvas/SKILL.md +9 -1
  93. package/bin/lib/commands/exec.test.ts +0 -27
  94. package/bin/lib/helpers.test.ts +0 -79
  95. package/daemon/src/remote-feedback.test.ts +0 -165
  96. package/daemon/src/secrets-api.test.ts +0 -123
  97. package/daemon/src/share-e2e.test.ts +0 -277
  98. package/daemon/src/share.test.ts +0 -289
@@ -1,95 +1,136 @@
1
- import { writeFileSync, mkdirSync, readFileSync, chmodSync } from "fs";
2
- import { join, dirname } from "path";
3
- import { tmpdir } from "os";
4
- import { randomBytes } from "crypto";
5
- import {
6
- createWebSocketManager,
7
- SessionManager,
8
- type WSData,
9
- } from "@fabrika/canvas-kernel/server";
10
- import { corsHeaders, dispatch, jsonResponse } from "@fabrika/daemon-kit";
11
- import { createApiHandlers } from "./handlers/api";
12
- import { createFileHandlers } from "./handlers/files";
13
- import { createStaticHandlers } from "./handlers/static";
14
- import { createUploadHandlers } from "./handlers/uploads";
15
- import { startRemoteFeedbackPoller } from "./remote-feedback";
16
- import { CLI_AUTH_FILE, PID_FILE, SESSIONS_DIR, TEMP_DIR } from "./paths";
17
-
18
- const PORT = parseInt(process.env.CANVAS_PORT || "19400", 10);
19
- const VERSION = JSON.parse(readFileSync(join(dirname(import.meta.dir), "..", "package.json"), "utf-8")).version as string;
1
+ import { createWebSocketManager, SessionManager, type WSData } from '@fabrika/canvas-kernel/server'
2
+ import { corsHeaders, dispatch, jsonResponse } from '@fabrika/daemon-kit'
3
+ import { randomBytes } from 'crypto'
4
+ import { chmodSync, mkdirSync, readFileSync, writeFileSync } from 'fs'
5
+ import { dirname, join } from 'path'
6
+ import { createApiHandlers } from './handlers/api'
7
+ import { createFileHandlers } from './handlers/files'
8
+ import { createStaticHandlers } from './handlers/static'
9
+ import { createUploadHandlers } from './handlers/uploads'
10
+ import { CLI_AUTH_FILE, PID_FILE, REVIEWS_DIR, SESSIONS_DIR, TEMP_DIR } from './paths'
11
+ import { startRemoteFeedbackPoller } from './remote-feedback'
12
+ import { ReviewProxies } from './review/proxy'
13
+ import { createReviewServer, type ReviewSocketData } from './review/server'
14
+ import { ReviewStore } from './review/store'
15
+
16
+ const PORT = parseInt(process.env.CANVAS_PORT || '19400', 10)
17
+ const VERSION = JSON.parse(readFileSync(join(dirname(import.meta.dir), '..', 'package.json'), 'utf-8')).version as string
20
18
 
21
19
  // Write PID file so CLI can find and stop us
22
- mkdirSync(TEMP_DIR, { recursive: true, mode: 0o700 });
23
- chmodSync(TEMP_DIR, 0o700);
24
- writeFileSync(PID_FILE, String(process.pid));
25
- const cliAuthToken = randomBytes(32).toString("base64url");
26
- writeFileSync(CLI_AUTH_FILE, cliAuthToken, { mode: 0o600 });
27
- chmodSync(CLI_AUTH_FILE, 0o600);
20
+ mkdirSync(TEMP_DIR, { recursive: true, mode: 0o700 })
21
+ chmodSync(TEMP_DIR, 0o700)
22
+ writeFileSync(PID_FILE, String(process.pid))
23
+ const cliAuthToken = randomBytes(32).toString('base64url')
24
+ writeFileSync(CLI_AUTH_FILE, cliAuthToken, { mode: 0o600 })
25
+ chmodSync(CLI_AUTH_FILE, 0o600)
26
+
27
+ const sessionManager = new SessionManager(SESSIONS_DIR)
28
+ const wsManager = createWebSocketManager(sessionManager)
29
+ const reviewStore = new ReviewStore(REVIEWS_DIR)
30
+ const reviewProxies = new ReviewProxies(reviewStore, `http://localhost:${PORT}/assets/review.js`)
31
+ const review = createReviewServer(reviewStore, reviewProxies)
32
+
33
+ type SocketData = WSData | ReviewSocketData
34
+ type Socket = Bun.ServerWebSocket<SocketData>
35
+
36
+ function isReviewSocket(socket: Socket): socket is Bun.ServerWebSocket<ReviewSocketData> {
37
+ return socket.data.type === 'review'
38
+ }
28
39
 
29
- const sessionManager = new SessionManager(SESSIONS_DIR);
30
- const wsManager = createWebSocketManager(sessionManager);
40
+ function isCanvasSocket(socket: Socket): socket is Bun.ServerWebSocket<WSData> {
41
+ return socket.data.type !== 'review'
42
+ }
31
43
 
32
44
  const routes = [
33
- ...createApiHandlers({ sessionManager, broadcastPlanUpdate: wsManager.broadcastPlanUpdate, broadcastRevisionUpdate: wsManager.broadcastRevisionUpdate, port: PORT, version: VERSION, cliAuthToken }),
34
- ...createFileHandlers(sessionManager),
35
- ...createUploadHandlers(sessionManager),
36
- ...createStaticHandlers(),
37
- ];
38
-
39
- const server = Bun.serve<WSData>({
40
- port: PORT,
41
- hostname: "localhost",
42
- fetch(req, server) {
43
- const url = new URL(req.url);
44
-
45
- // CORS preflight
46
- if (req.method === "OPTIONS") {
47
- return new Response(null, { status: 204, headers: corsHeaders(PORT) });
48
- }
49
-
50
- // WebSocket upgrade: /ws/session/:id
51
- const wsBrowserMatch = url.pathname.match(/^\/ws\/session\/([^/]+)$/);
52
- if (wsBrowserMatch) {
53
- const upgraded = server.upgrade(req, { data: { type: "browser", sessionId: wsBrowserMatch[1] } });
54
- if (!upgraded) return new Response("WebSocket upgrade failed", { status: 400 });
55
- return undefined as any;
56
- }
57
-
58
- // WebSocket upgrade: /ws/wait/:id
59
- const wsWaitMatch = url.pathname.match(/^\/ws\/wait\/([^/]+)$/);
60
- if (wsWaitMatch) {
61
- const upgraded = server.upgrade(req, { data: { type: "wait", sessionId: wsWaitMatch[1] } });
62
- if (!upgraded) return new Response("WebSocket upgrade failed", { status: 400 });
63
- return undefined as any;
64
- }
65
-
66
- // Route dispatch
67
- const response = dispatch(routes, req, url);
68
- if (response) return response;
69
-
70
- // Root redirect
71
- if (url.pathname === "/" && req.method === "GET") {
72
- const sessions = sessionManager.list();
73
- if (sessions.length > 0) {
74
- const latest = sessions.sort((a, b) =>
75
- new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()
76
- )[0];
77
- return Response.redirect(`/s/${latest.id}`, 302);
78
- }
79
- return jsonResponse({ message: "No active sessions", sessions: [] });
80
- }
81
-
82
- return jsonResponse({ error: "Not found" }, 404);
83
- },
84
-
85
- websocket: wsManager.handlers,
86
- });
87
-
88
- export { sessionManager, server };
89
- export const broadcastPlanUpdate = wsManager.broadcastPlanUpdate;
45
+ ...createApiHandlers({
46
+ sessionManager,
47
+ broadcastPlanUpdate: wsManager.broadcastPlanUpdate,
48
+ broadcastRevisionUpdate: wsManager.broadcastRevisionUpdate,
49
+ port: PORT,
50
+ version: VERSION,
51
+ cliAuthToken,
52
+ }),
53
+ ...createFileHandlers(sessionManager),
54
+ ...createUploadHandlers(sessionManager),
55
+ ...createStaticHandlers(),
56
+ ]
57
+
58
+ const server = Bun.serve<SocketData>({
59
+ port: PORT,
60
+ hostname: 'localhost',
61
+ fetch(req, server) {
62
+ const url = new URL(req.url)
63
+
64
+ // Review mode answers its own preflights: the application under review runs on another port.
65
+ const reviewResponse = review.fetch(req, url, server)
66
+ if (reviewResponse === 'upgraded') return undefined
67
+ if (reviewResponse !== null) return reviewResponse
68
+
69
+ // CORS preflight
70
+ if (req.method === 'OPTIONS') {
71
+ return new Response(null, { status: 204, headers: corsHeaders(PORT) })
72
+ }
73
+
74
+ // WebSocket upgrade: /ws/session/:id
75
+ const wsBrowserMatch = url.pathname.match(/^\/ws\/session\/([^/]+)$/)
76
+ if (wsBrowserMatch) {
77
+ const upgraded = server.upgrade(req, { data: { type: 'browser', sessionId: wsBrowserMatch[1] } })
78
+ if (!upgraded) return new Response('WebSocket upgrade failed', { status: 400 })
79
+ return undefined
80
+ }
81
+
82
+ // WebSocket upgrade: /ws/wait/:id
83
+ const wsWaitMatch = url.pathname.match(/^\/ws\/wait\/([^/]+)$/)
84
+ if (wsWaitMatch) {
85
+ const upgraded = server.upgrade(req, { data: { type: 'wait', sessionId: wsWaitMatch[1] } })
86
+ if (!upgraded) return new Response('WebSocket upgrade failed', { status: 400 })
87
+ return undefined
88
+ }
89
+
90
+ // Route dispatch
91
+ const response = dispatch(routes, req, url)
92
+ if (response) return response
93
+
94
+ // Root redirect
95
+ if (url.pathname === '/' && req.method === 'GET') {
96
+ const sessions = sessionManager.list()
97
+ if (sessions.length > 0) {
98
+ const latest = sessions.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime())[0]
99
+ return Response.redirect(`/s/${latest.id}`, 302)
100
+ }
101
+ return jsonResponse({ message: 'No active sessions', sessions: [] })
102
+ }
103
+
104
+ return jsonResponse({ error: 'Not found' }, 404)
105
+ },
106
+
107
+ websocket: {
108
+ open(socket) {
109
+ if (isReviewSocket(socket)) review.channel.handlers.open(socket)
110
+ else if (isCanvasSocket(socket)) wsManager.handlers.open(socket)
111
+ },
112
+ message(socket, message) {
113
+ if (isReviewSocket(socket)) review.channel.handlers.message(socket, message)
114
+ else if (isCanvasSocket(socket)) wsManager.handlers.message(socket, message)
115
+ },
116
+ pong(socket) {
117
+ if (isReviewSocket(socket)) review.channel.handlers.pong(socket)
118
+ else if (isCanvasSocket(socket)) wsManager.handlers.pong(socket)
119
+ },
120
+ close(socket) {
121
+ if (isReviewSocket(socket)) review.channel.handlers.close(socket)
122
+ else if (isCanvasSocket(socket)) wsManager.handlers.close(socket)
123
+ },
124
+ },
125
+ })
126
+
127
+ export { server, sessionManager }
128
+ export const broadcastPlanUpdate = wsManager.broadcastPlanUpdate
90
129
 
91
130
  // Start polling for remote feedback from shared canvases.
92
131
  // No-ops if CANVAS_SHARE_ENDPOINT is not set.
93
- startRemoteFeedbackPoller(sessionManager, wsManager.broadcastRemoteFeedback, VERSION);
132
+ startRemoteFeedbackPoller(sessionManager, wsManager.broadcastRemoteFeedback, VERSION)
133
+
134
+ reviewProxies.restore()
94
135
 
95
- console.log(`Planner daemon listening on http://localhost:${PORT}`);
136
+ console.log(`Planner daemon listening on http://localhost:${PORT}`)
@@ -1,11 +1,5 @@
1
- import type { SessionManager, ShareEntry } from "@fabrika/canvas-kernel/server";
2
- import {
3
- generateShareKey,
4
- exportShareKey,
5
- encryptString,
6
- ENCRYPTION_META,
7
- type EncryptionMeta,
8
- } from "../client/shareCrypto";
1
+ import type { SessionManager, ShareEntry } from '@fabrika/canvas-kernel/server'
2
+ import { ENCRYPTION_META, type EncryptionMeta, encryptString, exportShareKey, generateShareKey } from '../client/shareCrypto'
9
3
 
10
4
  /**
11
5
  * Canvas sharing: packages a specific revision into a self-contained
@@ -33,58 +27,58 @@ import {
33
27
  */
34
28
 
35
29
  export interface SharePayload {
36
- version: 1;
37
- /** Present iff payload fields below are ciphertext rather than plaintext. */
38
- encryption?: EncryptionMeta;
39
- origin: {
40
- sessionId: string;
41
- revision: number;
42
- label?: string;
43
- createdAt: string;
44
- };
45
- canvasFiles: Array<{
46
- filename: string;
47
- compiledJs: string;
48
- sourceJsx?: string;
49
- }>;
50
- runtime: {
51
- componentsVersion: string;
52
- };
30
+ version: 1
31
+ /** Present iff payload fields below are ciphertext rather than plaintext. */
32
+ encryption?: EncryptionMeta
33
+ origin: {
34
+ sessionId: string
35
+ revision: number
36
+ label?: string
37
+ createdAt: string
38
+ }
39
+ canvasFiles: Array<{
40
+ filename: string
41
+ compiledJs: string
42
+ sourceJsx?: string
43
+ }>
44
+ runtime: {
45
+ componentsVersion: string
46
+ }
53
47
  }
54
48
 
55
49
  export interface ShareResponse {
56
- shareId: string;
57
- url: string;
58
- ownerToken?: string;
59
- expiresAt?: string;
50
+ shareId: string
51
+ url: string
52
+ ownerToken?: string
53
+ expiresAt?: string
60
54
  }
61
55
 
62
56
  export interface ShareConfig {
63
- /** Base URL of the share worker, e.g. `https://canvas-share.example.workers.dev` */
64
- endpoint: string;
65
- /** Version of the @agent-canvas runtime bundles — worker uses this to pick matching runtime */
66
- componentsVersion: string;
67
- /** Optional bearer token. If the worker has SHARE_AUTH_TOKEN set, this
68
- * must match. Loaded from CANVAS_SHARE_AUTH_TOKEN env var on the daemon. */
69
- authToken?: string;
70
- /** End-to-end encryption setting. Defaults to true. */
71
- encryption?: boolean;
57
+ /** Base URL of the share worker, e.g. `https://canvas-share.example.workers.dev` */
58
+ endpoint: string
59
+ /** Version of the @agent-canvas runtime bundles — worker uses this to pick matching runtime */
60
+ componentsVersion: string
61
+ /** Optional bearer token. If the worker has SHARE_AUTH_TOKEN set, this
62
+ * must match. Loaded from CANVAS_SHARE_AUTH_TOKEN env var on the daemon. */
63
+ authToken?: string
64
+ /** End-to-end encryption setting. Defaults to true. */
65
+ encryption?: boolean
72
66
  }
73
67
 
74
68
  export function loadShareConfig(version: string): ShareConfig | null {
75
- const endpoint = process.env.CANVAS_SHARE_ENDPOINT ?? "https://canvas.contember.com";
76
- if (!endpoint) return null;
77
- return {
78
- endpoint: endpoint.replace(/\/+$/, ""),
79
- componentsVersion: version,
80
- encryption: !isEncryptionDisabled(),
81
- ...(process.env.CANVAS_SHARE_AUTH_TOKEN ? { authToken: process.env.CANVAS_SHARE_AUTH_TOKEN } : {}),
82
- };
69
+ const endpoint = process.env.CANVAS_SHARE_ENDPOINT ?? 'https://canvas.contember.com'
70
+ if (!endpoint) return null
71
+ return {
72
+ endpoint: endpoint.replace(/\/+$/, ''),
73
+ componentsVersion: version,
74
+ encryption: !isEncryptionDisabled(),
75
+ ...(process.env.CANVAS_SHARE_AUTH_TOKEN ? { authToken: process.env.CANVAS_SHARE_AUTH_TOKEN } : {}),
76
+ }
83
77
  }
84
78
 
85
79
  function isEncryptionDisabled(): boolean {
86
- const v = (process.env.CANVAS_SHARE_ENCRYPTION ?? "").trim().toLowerCase();
87
- return v === "0" || v === "off" || v === "false" || v === "no";
80
+ const v = (process.env.CANVAS_SHARE_ENCRYPTION ?? '').trim().toLowerCase()
81
+ return v === '0' || v === 'off' || v === 'false' || v === 'no'
88
82
  }
89
83
 
90
84
  /**
@@ -93,42 +87,42 @@ function isEncryptionDisabled(): boolean {
93
87
  * original JSX source for "view source" functionality on the shared canvas.
94
88
  */
95
89
  export function buildSharePayload(
96
- sessionManager: SessionManager,
97
- sessionId: string,
98
- revision: number,
99
- componentsVersion: string,
90
+ sessionManager: SessionManager,
91
+ sessionId: string,
92
+ revision: number,
93
+ componentsVersion: string,
100
94
  ): SharePayload {
101
- const session = sessionManager.get(sessionId);
102
- if (!session) throw new Error(`Session ${sessionId} not found`);
95
+ const session = sessionManager.get(sessionId)
96
+ if (!session) throw new Error(`Session ${sessionId} not found`)
103
97
 
104
- const revInfo = session.revisions.find((r) => r.revision === revision);
105
- if (!revInfo) throw new Error(`Revision ${revision} not found in session ${sessionId}`);
98
+ const revInfo = session.revisions.find((r) => r.revision === revision)
99
+ if (!revInfo) throw new Error(`Revision ${revision} not found in session ${sessionId}`)
106
100
 
107
- const canvasFiles: SharePayload["canvasFiles"] = [];
108
- for (const cf of revInfo.canvasFiles) {
109
- const compiledJs = sessionManager.getCompiled(sessionId, cf.filename, revision);
110
- if (!compiledJs) {
111
- throw new Error(`No compiled JS for ${cf.filename} at revision ${revision}`);
112
- }
113
- const sourceJsx = sessionManager.readRevisionJsx(sessionId, revision, cf.filename) ?? undefined;
114
- canvasFiles.push({
115
- filename: cf.filename,
116
- compiledJs,
117
- ...(sourceJsx ? { sourceJsx } : {}),
118
- });
119
- }
101
+ const canvasFiles: SharePayload['canvasFiles'] = []
102
+ for (const cf of revInfo.canvasFiles) {
103
+ const compiledJs = sessionManager.getCompiled(sessionId, cf.filename, revision)
104
+ if (!compiledJs) {
105
+ throw new Error(`No compiled JS for ${cf.filename} at revision ${revision}`)
106
+ }
107
+ const sourceJsx = sessionManager.readRevisionJsx(sessionId, revision, cf.filename) ?? undefined
108
+ canvasFiles.push({
109
+ filename: cf.filename,
110
+ compiledJs,
111
+ ...(sourceJsx ? { sourceJsx } : {}),
112
+ })
113
+ }
120
114
 
121
- return {
122
- version: 1,
123
- origin: {
124
- sessionId,
125
- revision,
126
- ...(revInfo.label ? { label: revInfo.label } : {}),
127
- createdAt: revInfo.createdAt,
128
- },
129
- canvasFiles,
130
- runtime: { componentsVersion },
131
- };
115
+ return {
116
+ version: 1,
117
+ origin: {
118
+ sessionId,
119
+ revision,
120
+ ...(revInfo.label ? { label: revInfo.label } : {}),
121
+ createdAt: revInfo.createdAt,
122
+ },
123
+ canvasFiles,
124
+ runtime: { componentsVersion },
125
+ }
132
126
  }
133
127
 
134
128
  /**
@@ -141,30 +135,30 @@ export function buildSharePayload(
141
135
  * Everything else (sessionId, label, compiledJs, sourceJsx) is encrypted.
142
136
  */
143
137
  export async function encryptSharePayload(
144
- payload: SharePayload,
145
- key: CryptoKey,
138
+ payload: SharePayload,
139
+ key: CryptoKey,
146
140
  ): Promise<SharePayload> {
147
- const canvasFiles = await Promise.all(
148
- payload.canvasFiles.map(async (cf) => ({
149
- filename: cf.filename,
150
- compiledJs: await encryptString(key, cf.compiledJs),
151
- ...(cf.sourceJsx ? { sourceJsx: await encryptString(key, cf.sourceJsx) } : {}),
152
- })),
153
- );
154
- return {
155
- version: payload.version,
156
- encryption: ENCRYPTION_META,
157
- origin: {
158
- sessionId: await encryptString(key, payload.origin.sessionId),
159
- revision: payload.origin.revision,
160
- ...(payload.origin.label
161
- ? { label: await encryptString(key, payload.origin.label) }
162
- : {}),
163
- createdAt: payload.origin.createdAt,
164
- },
165
- canvasFiles,
166
- runtime: payload.runtime,
167
- };
141
+ const canvasFiles = await Promise.all(
142
+ payload.canvasFiles.map(async (cf) => ({
143
+ filename: cf.filename,
144
+ compiledJs: await encryptString(key, cf.compiledJs),
145
+ ...(cf.sourceJsx ? { sourceJsx: await encryptString(key, cf.sourceJsx) } : {}),
146
+ })),
147
+ )
148
+ return {
149
+ version: payload.version,
150
+ encryption: ENCRYPTION_META,
151
+ origin: {
152
+ sessionId: await encryptString(key, payload.origin.sessionId),
153
+ revision: payload.origin.revision,
154
+ ...(payload.origin.label
155
+ ? { label: await encryptString(key, payload.origin.label) }
156
+ : {}),
157
+ createdAt: payload.origin.createdAt,
158
+ },
159
+ canvasFiles,
160
+ runtime: payload.runtime,
161
+ }
168
162
  }
169
163
 
170
164
  /**
@@ -174,31 +168,31 @@ export async function encryptSharePayload(
174
168
  * fix their config.
175
169
  */
176
170
  export async function pushShareToWorker(
177
- payload: SharePayload,
178
- config: ShareConfig,
171
+ payload: SharePayload,
172
+ config: ShareConfig,
179
173
  ): Promise<ShareResponse> {
180
- const headers: Record<string, string> = { "Content-Type": "application/json" };
181
- if (config.authToken) headers["Authorization"] = `Bearer ${config.authToken}`;
174
+ const headers: Record<string, string> = { 'Content-Type': 'application/json' }
175
+ if (config.authToken) headers['Authorization'] = `Bearer ${config.authToken}`
182
176
 
183
- const res = await fetch(`${config.endpoint}/shares`, {
184
- method: "POST",
185
- headers,
186
- body: JSON.stringify(payload),
187
- });
188
- if (!res.ok) {
189
- const text = await res.text().catch(() => "");
190
- throw new Error(`Share endpoint returned ${res.status}: ${text || res.statusText}`);
191
- }
192
- const data = (await res.json()) as { shareId?: string; url?: string; ownerToken?: string; expiresAt?: string };
193
- if (!data.shareId || !data.url) {
194
- throw new Error(`Share endpoint returned invalid response: ${JSON.stringify(data)}`);
195
- }
196
- return {
197
- shareId: data.shareId,
198
- url: data.url,
199
- ...(data.ownerToken ? { ownerToken: data.ownerToken } : {}),
200
- ...(data.expiresAt ? { expiresAt: data.expiresAt } : {}),
201
- };
177
+ const res = await fetch(`${config.endpoint}/shares`, {
178
+ method: 'POST',
179
+ headers,
180
+ body: JSON.stringify(payload),
181
+ })
182
+ if (!res.ok) {
183
+ const text = await res.text().catch(() => '')
184
+ throw new Error(`Share endpoint returned ${res.status}: ${text || res.statusText}`)
185
+ }
186
+ const data = (await res.json()) as { shareId?: string; url?: string; ownerToken?: string; expiresAt?: string }
187
+ if (!data.shareId || !data.url) {
188
+ throw new Error(`Share endpoint returned invalid response: ${JSON.stringify(data)}`)
189
+ }
190
+ return {
191
+ shareId: data.shareId,
192
+ url: data.url,
193
+ ...(data.ownerToken ? { ownerToken: data.ownerToken } : {}),
194
+ ...(data.expiresAt ? { expiresAt: data.expiresAt } : {}),
195
+ }
202
196
  }
203
197
 
204
198
  /**
@@ -208,36 +202,36 @@ export async function pushShareToWorker(
208
202
  * paste it to reviewers.
209
203
  */
210
204
  export async function shareRevision(
211
- sessionManager: SessionManager,
212
- sessionId: string,
213
- revision: number,
214
- config: ShareConfig,
205
+ sessionManager: SessionManager,
206
+ sessionId: string,
207
+ revision: number,
208
+ config: ShareConfig,
215
209
  ): Promise<ShareEntry> {
216
- const plaintext = buildSharePayload(sessionManager, sessionId, revision, config.componentsVersion);
210
+ const plaintext = buildSharePayload(sessionManager, sessionId, revision, config.componentsVersion)
217
211
 
218
- const useEncryption = config.encryption !== false;
219
- let payload = plaintext;
220
- let encodedKey: string | undefined;
221
- if (useEncryption) {
222
- const key = await generateShareKey();
223
- encodedKey = await exportShareKey(key);
224
- payload = await encryptSharePayload(plaintext, key);
225
- }
212
+ const useEncryption = config.encryption !== false
213
+ let payload = plaintext
214
+ let encodedKey: string | undefined
215
+ if (useEncryption) {
216
+ const key = await generateShareKey()
217
+ encodedKey = await exportShareKey(key)
218
+ payload = await encryptSharePayload(plaintext, key)
219
+ }
226
220
 
227
- const response = await pushShareToWorker(payload, config);
228
- const url = encodedKey ? `${response.url}#k=${encodedKey}` : response.url;
221
+ const response = await pushShareToWorker(payload, config)
222
+ const url = encodedKey ? `${response.url}#k=${encodedKey}` : response.url
229
223
 
230
- const entry: ShareEntry = {
231
- shareId: response.shareId,
232
- url,
233
- revision,
234
- createdAt: new Date().toISOString(),
235
- ...(response.ownerToken ? { ownerToken: response.ownerToken } : {}),
236
- ...(response.expiresAt ? { expiresAt: response.expiresAt } : {}),
237
- ...(encodedKey ? { encryptionKey: encodedKey } : {}),
238
- };
239
- sessionManager.addShare(sessionId, entry);
240
- return entry;
224
+ const entry: ShareEntry = {
225
+ shareId: response.shareId,
226
+ url,
227
+ revision,
228
+ createdAt: new Date().toISOString(),
229
+ ...(response.ownerToken ? { ownerToken: response.ownerToken } : {}),
230
+ ...(response.expiresAt ? { expiresAt: response.expiresAt } : {}),
231
+ ...(encodedKey ? { encryptionKey: encodedKey } : {}),
232
+ }
233
+ sessionManager.addShare(sessionId, entry)
234
+ return entry
241
235
  }
242
236
 
243
237
  /**
@@ -245,23 +239,23 @@ export async function shareRevision(
245
239
  * it from the local session metadata on success.
246
240
  */
247
241
  export async function revokeShare(
248
- sessionManager: SessionManager,
249
- sessionId: string,
250
- shareId: string,
251
- config: ShareConfig,
242
+ sessionManager: SessionManager,
243
+ sessionId: string,
244
+ shareId: string,
245
+ config: ShareConfig,
252
246
  ): Promise<void> {
253
- const session = sessionManager.get(sessionId);
254
- const entry = session?.shares?.find((s) => s.shareId === shareId);
255
- if (!entry) throw new Error(`Share ${shareId} not found`);
256
- if (!entry.ownerToken) throw new Error("No owner token recorded for this share");
247
+ const session = sessionManager.get(sessionId)
248
+ const entry = session?.shares?.find((s) => s.shareId === shareId)
249
+ if (!entry) throw new Error(`Share ${shareId} not found`)
250
+ if (!entry.ownerToken) throw new Error('No owner token recorded for this share')
257
251
 
258
- const res = await fetch(`${config.endpoint}/shares/${shareId}/revoke`, {
259
- method: "POST",
260
- headers: { "Authorization": `Bearer ${entry.ownerToken}` },
261
- });
262
- if (!res.ok) {
263
- const text = await res.text().catch(() => "");
264
- throw new Error(`Revoke failed: ${res.status} ${text}`);
265
- }
266
- sessionManager.removeShare(sessionId, shareId);
252
+ const res = await fetch(`${config.endpoint}/shares/${shareId}/revoke`, {
253
+ method: 'POST',
254
+ headers: { 'Authorization': `Bearer ${entry.ownerToken}` },
255
+ })
256
+ if (!res.ok) {
257
+ const text = await res.text().catch(() => '')
258
+ throw new Error(`Revoke failed: ${res.status} ${text}`)
259
+ }
260
+ sessionManager.removeShare(sessionId, shareId)
267
261
  }
@@ -0,0 +1,7 @@
1
+ /** Replace `globalThis.fetch` for a test. The caller restores the original. */
2
+ export function stubFetch(handler: (url: string, init: RequestInit | undefined) => Response | Promise<Response>): void {
3
+ globalThis.fetch = Object.assign(
4
+ async (input: Parameters<typeof fetch>[0], init?: RequestInit) => handler(String(input), init),
5
+ { preconnect: fetch.preconnect },
6
+ )
7
+ }