agent-canvas 0.1.44 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +14 -0
- package/bin/lib/commands/daemon.ts +3 -6
- package/bin/lib/commands/push.ts +1 -1
- package/bin/lib/daemon-lifecycle.ts +65 -72
- package/bin/lib/helpers.test.ts +1 -0
- package/bin/lib/helpers.ts +30 -74
- package/daemon/build.ts +37 -16
- package/daemon/client/App.tsx +27 -61
- package/daemon/client/CompareView.tsx +4 -4
- package/daemon/client/FileBrowser.tsx +4 -4
- package/daemon/client/FileViewer.tsx +4 -4
- package/daemon/client/OverviewView.tsx +2 -2
- package/daemon/client/RevisionSelect.tsx +1 -1
- package/daemon/client/canvas-runtime.ts +1 -0
- package/daemon/client/clientApi.ts +18 -0
- package/daemon/client/components/index.ts +3 -16
- package/daemon/client/styles.css +4 -457
- package/daemon/dist/annotations-runtime.js +1 -0
- package/daemon/dist/client.css +1 -1
- package/daemon/dist/client.js +76 -76
- package/daemon/dist/components.js +59 -59
- package/daemon/dist/index.html +1 -0
- package/daemon/dist/preact-compat.js +1 -1
- package/daemon/dist/runtime.js +1 -1
- package/daemon/src/components.ts +13 -0
- package/daemon/src/handlers/api.ts +13 -9
- package/daemon/src/handlers/files.ts +2 -3
- package/daemon/src/handlers/static.ts +1 -1
- package/daemon/src/handlers/uploads.ts +2 -3
- package/daemon/src/paths.ts +10 -24
- package/daemon/src/remote-feedback.test.ts +1 -1
- package/daemon/src/remote-feedback.ts +1 -1
- package/daemon/src/secrets-api.test.ts +2 -2
- package/daemon/src/server.ts +11 -10
- package/daemon/src/share-e2e.test.ts +1 -1
- package/daemon/src/share.test.ts +1 -1
- package/daemon/src/share.ts +1 -1
- package/daemon/tsconfig.json +1 -3
- package/package.json +11 -4
- package/.github/dark.png +0 -0
- package/.github/light.png +0 -0
- package/.github/workflows/ci.yml +0 -48
- package/.github/workflows/deploy-worker-prod.yml +0 -19
- package/.github/workflows/deploy-worker-stage.yml +0 -21
- package/.github/workflows/deploy-worker.yml +0 -72
- package/.github/workflows/release.yml +0 -36
- package/CLAUDE.md +0 -86
- package/bun.lock +0 -377
- package/daemon/client/AnnotationEditor.tsx +0 -315
- package/daemon/client/AnnotationProvider.tsx +0 -195
- package/daemon/client/AnnotationSidebar.tsx +0 -627
- package/daemon/client/FileIcon.tsx +0 -76
- package/daemon/client/PlanRenderer.tsx +0 -614
- package/daemon/client/Popover.tsx +0 -350
- package/daemon/client/RenderErrorContext.ts +0 -11
- package/daemon/client/ResponsePreview.tsx +0 -186
- package/daemon/client/SessionContext.tsx +0 -3
- package/daemon/client/annotationContext.ts +0 -114
- package/daemon/client/annotationDom.ts +0 -104
- package/daemon/client/annotationDraft.test.ts +0 -188
- package/daemon/client/annotationDraft.ts +0 -130
- package/daemon/client/compat/preact-all.ts +0 -4
- package/daemon/client/components/Callout.tsx +0 -26
- package/daemon/client/components/Checklist.tsx +0 -37
- package/daemon/client/components/Choice.tsx +0 -176
- package/daemon/client/components/CodeBlock.tsx +0 -35
- package/daemon/client/components/Diff.tsx +0 -65
- package/daemon/client/components/FilePreview.tsx +0 -88
- package/daemon/client/components/Image.tsx +0 -45
- package/daemon/client/components/Markdown.tsx +0 -44
- package/daemon/client/components/Mermaid.tsx +0 -565
- package/daemon/client/components/Note.tsx +0 -14
- package/daemon/client/components/Priority.tsx +0 -19
- package/daemon/client/components/ResponseNote.tsx +0 -19
- package/daemon/client/components/Section.tsx +0 -33
- package/daemon/client/components/Table.tsx +0 -35
- package/daemon/client/components/Task.tsx +0 -76
- package/daemon/client/components/UserInput.tsx +0 -174
- package/daemon/client/components/renderMarkdownChildren.tsx +0 -97
- package/daemon/client/generateMarkdown.ts +0 -273
- package/daemon/client/highlightRange.ts +0 -224
- package/daemon/client/popoverPosition.ts +0 -31
- package/daemon/client/runtime.ts +0 -123
- package/daemon/client/theme.css +0 -66
- package/daemon/client/useTextAnnotation.tsx +0 -188
- package/daemon/client/utils.ts +0 -13
- package/daemon/src/compiler.test.ts +0 -57
- package/daemon/src/compiler.ts +0 -360
- package/daemon/src/handlers/utils.ts +0 -14
- package/daemon/src/router.ts +0 -16
- package/daemon/src/session-secrets.test.ts +0 -83
- package/daemon/src/session.ts +0 -612
- package/daemon/src/watcher.ts +0 -71
- package/daemon/src/websocket.test.ts +0 -99
- package/daemon/src/websocket.ts +0 -211
- package/tsconfig.json +0 -14
- package/workers/canvas-share/README.md +0 -159
- package/workers/canvas-share/oblaka.ts +0 -56
- package/workers/canvas-share/package.json +0 -25
- package/workers/canvas-share/public/assets/.gitkeep +0 -0
- package/workers/canvas-share/public/assets/client.css +0 -2
- package/workers/canvas-share/public/assets/client.js +0 -91
- package/workers/canvas-share/public/assets/components.js +0 -65
- package/workers/canvas-share/public/assets/jsx-runtime.js +0 -3
- package/workers/canvas-share/public/assets/preact-compat.js +0 -1
- package/workers/canvas-share/public/assets/runtime.js +0 -1
- package/workers/canvas-share/public/index.html +0 -385
- package/workers/canvas-share/public/screenshot-dark.png +0 -0
- package/workers/canvas-share/public/screenshot-light.png +0 -0
- package/workers/canvas-share/src/index.test.ts +0 -209
- package/workers/canvas-share/src/index.ts +0 -518
- package/workers/canvas-share/src/types.ts +0 -119
- package/workers/canvas-share/src/util.ts +0 -156
- package/workers/canvas-share/src/validation.ts +0 -160
- package/workers/canvas-share/tsconfig.json +0 -16
|
@@ -1,99 +0,0 @@
|
|
|
1
|
-
import { afterEach, describe, expect, test } from "bun:test";
|
|
2
|
-
import { mkdtempSync, rmSync } from "fs";
|
|
3
|
-
import { tmpdir } from "os";
|
|
4
|
-
import { join } from "path";
|
|
5
|
-
import { SessionManager } from "./session";
|
|
6
|
-
import { createWebSocketManager, type CanvasSocket, type WSData } from "./websocket";
|
|
7
|
-
|
|
8
|
-
const testDirectories: string[] = [];
|
|
9
|
-
|
|
10
|
-
afterEach(() => {
|
|
11
|
-
for (const directory of testDirectories.splice(0)) {
|
|
12
|
-
rmSync(directory, { recursive: true, force: true });
|
|
13
|
-
}
|
|
14
|
-
});
|
|
15
|
-
|
|
16
|
-
class FakeSocket implements CanvasSocket {
|
|
17
|
-
readonly sent: string[] = [];
|
|
18
|
-
closed = false;
|
|
19
|
-
|
|
20
|
-
constructor(readonly data: WSData) {}
|
|
21
|
-
|
|
22
|
-
send(message: string) {
|
|
23
|
-
this.sent.push(message);
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
close() {
|
|
27
|
-
this.closed = true;
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
ping() {}
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
function createSessionManager(): SessionManager {
|
|
34
|
-
const directory = mkdtempSync(join(tmpdir(), "agent-canvas-websocket-test-"));
|
|
35
|
-
testDirectories.push(directory);
|
|
36
|
-
const manager = new SessionManager(directory);
|
|
37
|
-
manager.upsert(
|
|
38
|
-
"session",
|
|
39
|
-
new Map([["architecture.jsx", "<Section title=\"Architecture\" />"]]),
|
|
40
|
-
directory,
|
|
41
|
-
);
|
|
42
|
-
return manager;
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
describe("canvas render errors", () => {
|
|
46
|
-
test("delivers an error that arrived before the CLI watcher connected", () => {
|
|
47
|
-
const sessionManager = createSessionManager();
|
|
48
|
-
const websocket = createWebSocketManager(sessionManager);
|
|
49
|
-
const browser = new FakeSocket({ type: "browser", sessionId: "session" });
|
|
50
|
-
websocket.handlers.open(browser);
|
|
51
|
-
|
|
52
|
-
websocket.handlers.message(browser, JSON.stringify({
|
|
53
|
-
type: "render-error",
|
|
54
|
-
error: {
|
|
55
|
-
revision: 1,
|
|
56
|
-
filename: "architecture.jsx",
|
|
57
|
-
message: "Cannot read properties of undefined (reading 'map')",
|
|
58
|
-
},
|
|
59
|
-
}));
|
|
60
|
-
|
|
61
|
-
const watcher = new FakeSocket({ type: "wait", sessionId: "session" });
|
|
62
|
-
websocket.handlers.open(watcher);
|
|
63
|
-
|
|
64
|
-
expect(watcher.sent).toHaveLength(1);
|
|
65
|
-
expect(JSON.parse(watcher.sent[0])).toEqual({
|
|
66
|
-
type: "render-error",
|
|
67
|
-
error: {
|
|
68
|
-
revision: 1,
|
|
69
|
-
filename: "architecture.jsx",
|
|
70
|
-
message: "Cannot read properties of undefined (reading 'map')",
|
|
71
|
-
},
|
|
72
|
-
});
|
|
73
|
-
expect(watcher.closed).toBe(true);
|
|
74
|
-
expect(sessionManager.get("session")?.revisions[0].hasFeedback).toBe(false);
|
|
75
|
-
});
|
|
76
|
-
|
|
77
|
-
test("drops a pending error when a new revision is pushed", () => {
|
|
78
|
-
const sessionManager = createSessionManager();
|
|
79
|
-
const websocket = createWebSocketManager(sessionManager);
|
|
80
|
-
const browser = new FakeSocket({ type: "browser", sessionId: "session" });
|
|
81
|
-
websocket.handlers.open(browser);
|
|
82
|
-
websocket.handlers.message(browser, JSON.stringify({
|
|
83
|
-
type: "render-error",
|
|
84
|
-
error: { revision: 1, filename: "architecture.jsx", message: "Broken revision" },
|
|
85
|
-
}));
|
|
86
|
-
|
|
87
|
-
sessionManager.upsert(
|
|
88
|
-
"session",
|
|
89
|
-
new Map([["architecture.jsx", "<Section title=\"Fixed\" />"]]),
|
|
90
|
-
"/project",
|
|
91
|
-
);
|
|
92
|
-
websocket.broadcastPlanUpdate("session");
|
|
93
|
-
|
|
94
|
-
const watcher = new FakeSocket({ type: "wait", sessionId: "session" });
|
|
95
|
-
websocket.handlers.open(watcher);
|
|
96
|
-
expect(watcher.sent).toEqual([]);
|
|
97
|
-
expect(watcher.closed).toBe(false);
|
|
98
|
-
});
|
|
99
|
-
});
|
package/daemon/src/websocket.ts
DELETED
|
@@ -1,211 +0,0 @@
|
|
|
1
|
-
import type { SessionManager, RemoteFeedbackEntry } from "./session";
|
|
2
|
-
|
|
3
|
-
export type WSData = { type: "browser" | "wait"; sessionId: string };
|
|
4
|
-
|
|
5
|
-
export interface CanvasSocket {
|
|
6
|
-
data: WSData;
|
|
7
|
-
send(message: string): unknown;
|
|
8
|
-
close(): void;
|
|
9
|
-
ping(): unknown;
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
interface CanvasRenderError {
|
|
13
|
-
revision: number;
|
|
14
|
-
filename: string;
|
|
15
|
-
message: string;
|
|
16
|
-
stack?: string;
|
|
17
|
-
componentStack?: string;
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
function isCanvasRenderError(value: unknown): value is CanvasRenderError {
|
|
21
|
-
return typeof value === "object" && value !== null
|
|
22
|
-
&& "revision" in value && Number.isInteger(value.revision)
|
|
23
|
-
&& "filename" in value && typeof value.filename === "string"
|
|
24
|
-
&& "message" in value && typeof value.message === "string"
|
|
25
|
-
&& (!("stack" in value) || value.stack === undefined || typeof value.stack === "string")
|
|
26
|
-
&& (!("componentStack" in value) || value.componentStack === undefined || typeof value.componentStack === "string");
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
export function createWebSocketManager(sessionManager: SessionManager) {
|
|
30
|
-
const browserSockets = new Map<string, Set<CanvasSocket>>();
|
|
31
|
-
const waitSockets = new Map<string, Set<CanvasSocket>>();
|
|
32
|
-
const pendingRenderErrors = new Map<string, CanvasRenderError>();
|
|
33
|
-
|
|
34
|
-
function broadcastPlanUpdate(sessionId: string) {
|
|
35
|
-
pendingRenderErrors.delete(sessionId);
|
|
36
|
-
const sockets = browserSockets.get(sessionId);
|
|
37
|
-
if (!sockets) return;
|
|
38
|
-
const session = sessionManager.get(sessionId);
|
|
39
|
-
if (!session) return;
|
|
40
|
-
const payload = JSON.stringify({
|
|
41
|
-
type: "plan-updated",
|
|
42
|
-
currentRevision: session.currentRevision,
|
|
43
|
-
revisions: session.revisions,
|
|
44
|
-
});
|
|
45
|
-
for (const ws of sockets) {
|
|
46
|
-
ws.send(payload);
|
|
47
|
-
}
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
function broadcastRevisionUpdate(sessionId: string) {
|
|
51
|
-
const sockets = browserSockets.get(sessionId);
|
|
52
|
-
if (!sockets) return;
|
|
53
|
-
const session = sessionManager.get(sessionId);
|
|
54
|
-
if (!session) return;
|
|
55
|
-
const payload = JSON.stringify({
|
|
56
|
-
type: "revision-updated",
|
|
57
|
-
revisions: session.revisions,
|
|
58
|
-
});
|
|
59
|
-
for (const ws of sockets) {
|
|
60
|
-
ws.send(payload);
|
|
61
|
-
}
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
function broadcastRemoteFeedback(sessionId: string, revision: number, entries: RemoteFeedbackEntry[]) {
|
|
65
|
-
const sockets = browserSockets.get(sessionId);
|
|
66
|
-
if (!sockets || sockets.size === 0) return;
|
|
67
|
-
const payload = JSON.stringify({
|
|
68
|
-
type: "remote-feedback",
|
|
69
|
-
revision,
|
|
70
|
-
entries,
|
|
71
|
-
});
|
|
72
|
-
for (const ws of sockets) {
|
|
73
|
-
ws.send(payload);
|
|
74
|
-
}
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
function broadcastWatcherStatus(sessionId: string) {
|
|
78
|
-
const sockets = browserSockets.get(sessionId);
|
|
79
|
-
if (!sockets) return;
|
|
80
|
-
const watching = (waitSockets.get(sessionId)?.size ?? 0) > 0;
|
|
81
|
-
const payload = JSON.stringify({ type: "watcher-status", watching });
|
|
82
|
-
for (const ws of sockets) {
|
|
83
|
-
ws.send(payload);
|
|
84
|
-
}
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
function deliverPendingRenderError(sessionId: string): boolean {
|
|
88
|
-
const error = pendingRenderErrors.get(sessionId);
|
|
89
|
-
const waiters = waitSockets.get(sessionId);
|
|
90
|
-
if (!error || !waiters || waiters.size === 0) return false;
|
|
91
|
-
|
|
92
|
-
pendingRenderErrors.delete(sessionId);
|
|
93
|
-
waitSockets.delete(sessionId);
|
|
94
|
-
const payload = JSON.stringify({ type: "render-error", error });
|
|
95
|
-
for (const waiter of waiters) {
|
|
96
|
-
waiter.send(payload);
|
|
97
|
-
waiter.close();
|
|
98
|
-
}
|
|
99
|
-
broadcastWatcherStatus(sessionId);
|
|
100
|
-
return true;
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
// Ping wait sockets periodically to detect dead connections.
|
|
104
|
-
// We can't rely on ws.close() triggering the close handler for dead sockets,
|
|
105
|
-
// so we manually remove dead sockets and broadcast status.
|
|
106
|
-
const PING_INTERVAL = 5_000;
|
|
107
|
-
const pongReceived = new WeakSet<CanvasSocket>();
|
|
108
|
-
|
|
109
|
-
function removeWaitSocket(ws: CanvasSocket, sessionId: string) {
|
|
110
|
-
const sockets = waitSockets.get(sessionId);
|
|
111
|
-
if (sockets) {
|
|
112
|
-
sockets.delete(ws);
|
|
113
|
-
if (sockets.size === 0) waitSockets.delete(sessionId);
|
|
114
|
-
}
|
|
115
|
-
try { ws.close(); } catch {}
|
|
116
|
-
broadcastWatcherStatus(sessionId);
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
setInterval(() => {
|
|
120
|
-
for (const [sessionId, sockets] of waitSockets) {
|
|
121
|
-
for (const ws of sockets) {
|
|
122
|
-
if (!pongReceived.has(ws)) {
|
|
123
|
-
// No pong since last ping — connection is dead
|
|
124
|
-
removeWaitSocket(ws, sessionId);
|
|
125
|
-
continue;
|
|
126
|
-
}
|
|
127
|
-
pongReceived.delete(ws);
|
|
128
|
-
try { ws.ping(); } catch {
|
|
129
|
-
removeWaitSocket(ws, sessionId);
|
|
130
|
-
}
|
|
131
|
-
}
|
|
132
|
-
}
|
|
133
|
-
}, PING_INTERVAL);
|
|
134
|
-
|
|
135
|
-
const handlers = {
|
|
136
|
-
open(ws: CanvasSocket) {
|
|
137
|
-
const { type, sessionId } = ws.data;
|
|
138
|
-
const map = type === "browser" ? browserSockets : waitSockets;
|
|
139
|
-
if (!map.has(sessionId)) map.set(sessionId, new Set());
|
|
140
|
-
map.get(sessionId)!.add(ws);
|
|
141
|
-
if (type === "browser") {
|
|
142
|
-
// Send current watcher status to newly connected browser
|
|
143
|
-
const watching = (waitSockets.get(sessionId)?.size ?? 0) > 0;
|
|
144
|
-
ws.send(JSON.stringify({ type: "watcher-status", watching }));
|
|
145
|
-
} else {
|
|
146
|
-
// CLI waiter connected — notify browsers
|
|
147
|
-
pongReceived.add(ws); // Give it a free pass on first interval
|
|
148
|
-
if (!deliverPendingRenderError(sessionId)) {
|
|
149
|
-
broadcastWatcherStatus(sessionId);
|
|
150
|
-
}
|
|
151
|
-
}
|
|
152
|
-
},
|
|
153
|
-
message(ws: CanvasSocket, message: string | Buffer) {
|
|
154
|
-
const { type, sessionId } = ws.data;
|
|
155
|
-
if (type === "browser") {
|
|
156
|
-
try {
|
|
157
|
-
const data = JSON.parse(typeof message === "string" ? message : new TextDecoder().decode(message));
|
|
158
|
-
if (data.type === "submit") {
|
|
159
|
-
const feedback = data.feedback as string;
|
|
160
|
-
|
|
161
|
-
const session = sessionManager.get(sessionId);
|
|
162
|
-
if (session) {
|
|
163
|
-
sessionManager.saveFeedback(sessionId, session.currentRevision, feedback);
|
|
164
|
-
broadcastRevisionUpdate(sessionId);
|
|
165
|
-
|
|
166
|
-
const waiters = waitSockets.get(sessionId);
|
|
167
|
-
if (waiters && waiters.size > 0) {
|
|
168
|
-
sessionManager.consumeFeedback(sessionId, session.currentRevision);
|
|
169
|
-
broadcastRevisionUpdate(sessionId);
|
|
170
|
-
const payload = JSON.stringify({ type: "submit", feedback });
|
|
171
|
-
for (const waiter of waiters) {
|
|
172
|
-
waiter.send(payload);
|
|
173
|
-
waiter.close();
|
|
174
|
-
}
|
|
175
|
-
waitSockets.delete(sessionId);
|
|
176
|
-
}
|
|
177
|
-
}
|
|
178
|
-
}
|
|
179
|
-
if (data.type === "render-error" && isCanvasRenderError(data.error)) {
|
|
180
|
-
const session = sessionManager.get(sessionId);
|
|
181
|
-
if (
|
|
182
|
-
session
|
|
183
|
-
&& data.error.revision === session.currentRevision
|
|
184
|
-
&& session.canvasFiles.includes(data.error.filename)
|
|
185
|
-
) {
|
|
186
|
-
pendingRenderErrors.set(sessionId, data.error);
|
|
187
|
-
deliverPendingRenderError(sessionId);
|
|
188
|
-
}
|
|
189
|
-
}
|
|
190
|
-
} catch {}
|
|
191
|
-
}
|
|
192
|
-
},
|
|
193
|
-
pong(ws: CanvasSocket) {
|
|
194
|
-
if (ws.data.type === "wait") {
|
|
195
|
-
pongReceived.add(ws);
|
|
196
|
-
}
|
|
197
|
-
},
|
|
198
|
-
close(ws: CanvasSocket) {
|
|
199
|
-
const { type, sessionId } = ws.data;
|
|
200
|
-
const map = type === "browser" ? browserSockets : waitSockets;
|
|
201
|
-
map.get(sessionId)?.delete(ws);
|
|
202
|
-
if (map.get(sessionId)?.size === 0) map.delete(sessionId);
|
|
203
|
-
if (type === "wait") {
|
|
204
|
-
// CLI waiter disconnected — notify browsers
|
|
205
|
-
broadcastWatcherStatus(sessionId);
|
|
206
|
-
}
|
|
207
|
-
},
|
|
208
|
-
};
|
|
209
|
-
|
|
210
|
-
return { handlers, broadcastPlanUpdate, broadcastRevisionUpdate, broadcastWatcherStatus, broadcastRemoteFeedback };
|
|
211
|
-
}
|
package/tsconfig.json
DELETED
|
@@ -1,14 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"compilerOptions": {
|
|
3
|
-
"target": "ESNext",
|
|
4
|
-
"module": "ESNext",
|
|
5
|
-
"moduleResolution": "bundler",
|
|
6
|
-
"strict": true,
|
|
7
|
-
"esModuleInterop": true,
|
|
8
|
-
"skipLibCheck": true,
|
|
9
|
-
"allowImportingTsExtensions": true,
|
|
10
|
-
"noEmit": true,
|
|
11
|
-
"types": ["@types/bun"]
|
|
12
|
-
},
|
|
13
|
-
"include": ["bin/**/*"]
|
|
14
|
-
}
|
|
@@ -1,159 +0,0 @@
|
|
|
1
|
-
# canvas-share
|
|
2
|
-
|
|
3
|
-
Cloudflare Worker that hosts shared agent-canvas snapshots.
|
|
4
|
-
|
|
5
|
-
## Architecture
|
|
6
|
-
|
|
7
|
-
- **R2 (`BLOBS`)** stores share payloads, compiled canvas JS, JSX source, and reviewer-uploaded annotation images.
|
|
8
|
-
- **KV (`FEEDBACK`)** stores individual feedback entries (sortable keys for `since` queries) and rate limit counters (TTL 120s).
|
|
9
|
-
- **Static assets (`ASSETS`)** serve runtime bundles (`runtime.js`, `components.js`, `preact-compat.js`, `client.js`, `client.css`) — these are mirrored from `daemon/dist/` automatically by `bun run build`.
|
|
10
|
-
|
|
11
|
-
Validation is enforced with [Zod](https://zod.dev). Limits are centralized in [`src/types.ts`](src/types.ts).
|
|
12
|
-
|
|
13
|
-
## Infrastructure as code
|
|
14
|
-
|
|
15
|
-
The Cloudflare config is defined programmatically in [`oblaka.ts`](oblaka.ts)
|
|
16
|
-
using [`oblaka-iac`](https://github.com/contember/oblaka). The committed
|
|
17
|
-
[`oblaka.ts`](oblaka.ts) is the source of truth; `wrangler.jsonc` is
|
|
18
|
-
auto-generated and **gitignored** — never edit it by hand.
|
|
19
|
-
|
|
20
|
-
| Command | Generates | Used for |
|
|
21
|
-
|---|---|---|
|
|
22
|
-
| `bun run oblaka` | `wrangler.jsonc` (env `local`) | local dev via Miniflare |
|
|
23
|
-
| `bun run oblaka:stage` | `wrangler.jsonc` (env `stage`) | staging deploy |
|
|
24
|
-
| `bun run oblaka:prod` | `wrangler.jsonc` (env `prod`) | production deploy |
|
|
25
|
-
|
|
26
|
-
`oblaka-iac` auto-prefixes R2 bucket and KV namespace names with the env
|
|
27
|
-
(e.g. `canvas-share-blobs` becomes `stage-canvas-share-blobs`) so the same
|
|
28
|
-
config produces isolated stage and prod resources.
|
|
29
|
-
|
|
30
|
-
## Local development
|
|
31
|
-
|
|
32
|
-
```bash
|
|
33
|
-
cd workers/canvas-share
|
|
34
|
-
bun install
|
|
35
|
-
|
|
36
|
-
# Build daemon bundles AND auto-copy them into ./public/assets
|
|
37
|
-
cd ../..
|
|
38
|
-
bun run build
|
|
39
|
-
|
|
40
|
-
# Start the worker locally via Miniflare (no Cloudflare account needed).
|
|
41
|
-
# `bun run dev` runs `oblaka` first to regenerate wrangler.jsonc, then
|
|
42
|
-
# starts wrangler dev --local.
|
|
43
|
-
cd workers/canvas-share
|
|
44
|
-
bun run dev
|
|
45
|
-
|
|
46
|
-
# Point the daemon at it
|
|
47
|
-
cd ../..
|
|
48
|
-
CANVAS_SHARE_ENDPOINT=http://127.0.0.1:8787 bun bin/agent-canvas.ts daemon restart
|
|
49
|
-
```
|
|
50
|
-
|
|
51
|
-
Then click **Share** on any revision in the canvas UI.
|
|
52
|
-
|
|
53
|
-
## Testing
|
|
54
|
-
|
|
55
|
-
```bash
|
|
56
|
-
cd workers/canvas-share
|
|
57
|
-
bun test # unit tests for validation, util
|
|
58
|
-
bun run typecheck # typecheck src/
|
|
59
|
-
```
|
|
60
|
-
|
|
61
|
-
## Continuous deployment
|
|
62
|
-
|
|
63
|
-
The repo ships GitHub Actions workflows that deploy the worker automatically:
|
|
64
|
-
|
|
65
|
-
| Workflow | Trigger | Env |
|
|
66
|
-
|---|---|---|
|
|
67
|
-
| `deploy-worker-stage.yml` | Push to `main` touching `workers/canvas-share/**`, `daemon/client/**`, or `daemon/build.ts` | `stage` |
|
|
68
|
-
| `deploy-worker-prod.yml` | Version tag push (`v*`) | `prod` |
|
|
69
|
-
|
|
70
|
-
Both call the reusable `deploy-worker.yml` which:
|
|
71
|
-
|
|
72
|
-
1. Installs deps + builds the daemon runtime bundles (auto-mirrored into `public/assets/`)
|
|
73
|
-
2. Runs `bunx oblaka oblaka.ts --env <env> --remote` to create or update the R2 bucket and KV namespace on Cloudflare
|
|
74
|
-
3. Sets `SHARE_AUTH_TOKEN` via `wrangler secret bulk` (skipped if no token is configured for the env)
|
|
75
|
-
4. Runs `bunx wrangler deploy`
|
|
76
|
-
|
|
77
|
-
### Required GitHub secrets
|
|
78
|
-
|
|
79
|
-
| Secret | Purpose |
|
|
80
|
-
|---|---|
|
|
81
|
-
| `CLOUDFLARE_API_TOKEN` | Token with Workers + R2 + KV permissions on the target account |
|
|
82
|
-
| `CLOUDFLARE_ACCOUNT_ID` | Account id the worker deploys to |
|
|
83
|
-
| `SHARE_AUTH_TOKEN_STAGE` | Bearer token daemons must use to POST to stage (optional) |
|
|
84
|
-
| `SHARE_AUTH_TOKEN_PROD` | Bearer token daemons must use to POST to prod (optional) |
|
|
85
|
-
|
|
86
|
-
Both stage and prod use the same Cloudflare account; oblaka auto-prefixes resource names so stage and prod get separate `stage-canvas-share-blobs` and `prod-canvas-share-blobs` buckets (same for KV).
|
|
87
|
-
|
|
88
|
-
Use GitHub Environments (`Settings → Environments → stage / prod`) to gate prod deploys behind a manual approval if you want belt-and-suspenders safety.
|
|
89
|
-
|
|
90
|
-
## Manual production deploy
|
|
91
|
-
|
|
92
|
-
If you'd rather deploy by hand instead of via CI:
|
|
93
|
-
|
|
94
|
-
1. (Recommended) Generate a shared bearer token to gate share creation:
|
|
95
|
-
```bash
|
|
96
|
-
openssl rand -hex 32 | bunx wrangler secret put SHARE_AUTH_TOKEN --env prod
|
|
97
|
-
```
|
|
98
|
-
Without this, anyone on the internet can pump shares into your R2 bucket.
|
|
99
|
-
Set the same value as `CANVAS_SHARE_AUTH_TOKEN` on the daemon side.
|
|
100
|
-
|
|
101
|
-
2. (Optional) Override default 30-day share expiration:
|
|
102
|
-
```bash
|
|
103
|
-
echo "604800" | bunx wrangler secret put SHARE_TTL_SECONDS --env prod # 7 days
|
|
104
|
-
```
|
|
105
|
-
|
|
106
|
-
3. Build daemon bundles + deploy. The deploy script regenerates
|
|
107
|
-
`wrangler.jsonc` for the target env, then runs `wrangler deploy`.
|
|
108
|
-
The R2 bucket and KV namespace are created automatically by oblaka
|
|
109
|
-
on first deploy:
|
|
110
|
-
```bash
|
|
111
|
-
cd ../..
|
|
112
|
-
bun run build
|
|
113
|
-
cd workers/canvas-share
|
|
114
|
-
bun run deploy:prod # or deploy:stage
|
|
115
|
-
```
|
|
116
|
-
|
|
117
|
-
4. Configure the daemon:
|
|
118
|
-
```bash
|
|
119
|
-
export CANVAS_SHARE_ENDPOINT=https://canvas-share.<subdomain>.workers.dev
|
|
120
|
-
export CANVAS_SHARE_AUTH_TOKEN=<the token you generated>
|
|
121
|
-
bunx agent-canvas daemon restart
|
|
122
|
-
```
|
|
123
|
-
|
|
124
|
-
The Share button in the canvas UI lights up once the daemon sees `CANVAS_SHARE_ENDPOINT`.
|
|
125
|
-
|
|
126
|
-
## Endpoints
|
|
127
|
-
|
|
128
|
-
| Method | Path | Purpose | Auth |
|
|
129
|
-
|--------|------------------------------------|-------------------------------------------------|---------------------|
|
|
130
|
-
| GET | `/health` | Health check | none |
|
|
131
|
-
| POST | `/shares` | Create a new share | `SHARE_AUTH_TOKEN` |
|
|
132
|
-
| POST | `/shares/:shareId/revoke` | Revoke / delete a share | `ownerToken` |
|
|
133
|
-
| GET | `/shares/:shareId/feedback?since=` | List feedback entries (called by daemon poller) | none (capability) |
|
|
134
|
-
| GET | `/s/:shareId` | HTML shell for reviewers | none (capability) |
|
|
135
|
-
| GET | `/s/:shareId/meta` | Share metadata | none |
|
|
136
|
-
| GET | `/s/:shareId/canvas/:filename` | Serve compiled canvas JS / JSX source | none |
|
|
137
|
-
| POST | `/s/:shareId/feedback` | Submit feedback (called by reviewer browser) | none |
|
|
138
|
-
| POST | `/s/:shareId/upload` | Upload annotation image | none |
|
|
139
|
-
| GET | `/s/:shareId/uploads/:filename` | Serve uploaded image | none |
|
|
140
|
-
|
|
141
|
-
## Rate limits
|
|
142
|
-
|
|
143
|
-
Per-IP, per-minute, sliding window (KV-backed). Tweak in [`src/types.ts`](src/types.ts).
|
|
144
|
-
|
|
145
|
-
| Bucket | Limit |
|
|
146
|
-
|-----------------|-------|
|
|
147
|
-
| Share creation | 5/min |
|
|
148
|
-
| Feedback POST | 20/min/share |
|
|
149
|
-
| Upload | 30/min/share |
|
|
150
|
-
|
|
151
|
-
## Security model
|
|
152
|
-
|
|
153
|
-
- **Capability URLs** — anyone with `/s/:shareId` (24-char hex, ~96 bits entropy) has full view+comment access. Brute-forcing is impractical.
|
|
154
|
-
- **Owner token** — returned to the daemon at create time, required to revoke. Stored in the daemon's session metadata so the canvas author can revoke from the Share dialog.
|
|
155
|
-
- **Optional shared bearer token** (`SHARE_AUTH_TOKEN`) — gates share creation so only authorized daemons can push to the worker.
|
|
156
|
-
- **No reviewer auth** — reviewers self-report a name (stored locally in their browser), there is no signup. Suitable for "send to a colleague" flows, **not** public publishing.
|
|
157
|
-
- **Per-share TTL** — defaults to 30 days. Expired shares 404 and are eventually purged by KV's TTL.
|
|
158
|
-
- **Size limits** — share payload max 2 MB, feedback body max 256 KB, image upload max 5 MB.
|
|
159
|
-
- **Validation** — all incoming bodies are validated by Zod schemas before any storage write.
|
|
@@ -1,56 +0,0 @@
|
|
|
1
|
-
import { define, KVNamespace, R2Bucket, Worker } from 'oblaka-iac'
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* Per-environment overrides. `local` is what `bunx oblaka oblaka.ts` uses
|
|
5
|
-
* when generating wrangler.jsonc for `wrangler dev --local`. `stage` and
|
|
6
|
-
* `prod` are picked when running `bunx oblaka oblaka.ts --env stage|prod`
|
|
7
|
-
* for actual Cloudflare deploys.
|
|
8
|
-
*
|
|
9
|
-
* Secrets (SHARE_AUTH_TOKEN, SHARE_TTL_SECONDS overrides, etc.) are NOT
|
|
10
|
-
* declared here — set them with `wrangler secret put` after deploy.
|
|
11
|
-
*/
|
|
12
|
-
const vars = {
|
|
13
|
-
local: {
|
|
14
|
-
// Empty share auth in local dev so smoke tests don't need a token.
|
|
15
|
-
},
|
|
16
|
-
stage: {},
|
|
17
|
-
prod: {},
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
export default define(({ env }) => {
|
|
21
|
-
if (!(env in vars)) {
|
|
22
|
-
throw new Error(`Unknown environment ${env}`)
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
const blobs = new R2Bucket({
|
|
26
|
-
name: 'canvas-share-blobs',
|
|
27
|
-
})
|
|
28
|
-
|
|
29
|
-
const feedback = new KVNamespace({
|
|
30
|
-
name: 'canvas-share-feedback',
|
|
31
|
-
})
|
|
32
|
-
|
|
33
|
-
return new Worker({
|
|
34
|
-
dir: '.',
|
|
35
|
-
name: 'canvas-share',
|
|
36
|
-
main: './src/index.ts',
|
|
37
|
-
compatibility_flags: ['nodejs_compat'],
|
|
38
|
-
compatibility_date: '2025-01-01',
|
|
39
|
-
observability: { enabled: true },
|
|
40
|
-
// Runtime bundles (preact-compat, runtime, components, client.js + css)
|
|
41
|
-
// are mirrored here from daemon/dist/ by `bun run build` in the repo
|
|
42
|
-
// root. The worker serves them via the ASSETS binding.
|
|
43
|
-
assets: {
|
|
44
|
-
directory: './public',
|
|
45
|
-
binding: 'ASSETS',
|
|
46
|
-
},
|
|
47
|
-
bindings: {
|
|
48
|
-
BLOBS: blobs,
|
|
49
|
-
FEEDBACK: feedback,
|
|
50
|
-
},
|
|
51
|
-
vars: {
|
|
52
|
-
ENVIRONMENT: env,
|
|
53
|
-
...vars[env as keyof typeof vars],
|
|
54
|
-
},
|
|
55
|
-
})
|
|
56
|
-
})
|
|
@@ -1,25 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "canvas-share",
|
|
3
|
-
"version": "0.1.0",
|
|
4
|
-
"private": true,
|
|
5
|
-
"type": "module",
|
|
6
|
-
"scripts": {
|
|
7
|
-
"oblaka": "bunx oblaka oblaka.ts",
|
|
8
|
-
"oblaka:stage": "bunx oblaka oblaka.ts --env stage",
|
|
9
|
-
"oblaka:prod": "bunx oblaka oblaka.ts --env prod",
|
|
10
|
-
"dev": "bun run oblaka && wrangler dev --local",
|
|
11
|
-
"deploy:stage": "bun run oblaka:stage && wrangler deploy --env stage",
|
|
12
|
-
"deploy:prod": "bun run oblaka:prod && wrangler deploy --env prod",
|
|
13
|
-
"typecheck": "tsc --noEmit",
|
|
14
|
-
"test": "bun test"
|
|
15
|
-
},
|
|
16
|
-
"dependencies": {
|
|
17
|
-
"zod": "^3.23.8"
|
|
18
|
-
},
|
|
19
|
-
"devDependencies": {
|
|
20
|
-
"@cloudflare/workers-types": "^4.20250101.0",
|
|
21
|
-
"oblaka-iac": "latest",
|
|
22
|
-
"typescript": "^5.6.0",
|
|
23
|
-
"wrangler": "^4.24.0"
|
|
24
|
-
}
|
|
25
|
-
}
|
|
File without changes
|