@fabrika/canvas-kernel 0.1.44

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +74 -0
  3. package/annotate.ts +94 -0
  4. package/daemon.ts +46 -0
  5. package/index.ts +84 -0
  6. package/package.json +74 -0
  7. package/src/client/AnnotationDraftFooter.tsx +76 -0
  8. package/src/client/AnnotationEditor.tsx +320 -0
  9. package/src/client/AnnotationList.tsx +190 -0
  10. package/src/client/AnnotationProvider.tsx +294 -0
  11. package/src/client/AnnotationSidebar.tsx +374 -0
  12. package/src/client/FileIcon.tsx +76 -0
  13. package/src/client/PlanRenderer.tsx +587 -0
  14. package/src/client/Popover.tsx +361 -0
  15. package/src/client/RenderErrorContext.ts +11 -0
  16. package/src/client/ResponsePreview.tsx +186 -0
  17. package/src/client/SessionContext.tsx +3 -0
  18. package/src/client/annotationContext.ts +130 -0
  19. package/src/client/annotationDom.ts +115 -0
  20. package/src/client/annotationDraft.ts +130 -0
  21. package/src/client/annotationMarkdown.ts +102 -0
  22. package/src/client/annotationTarget.ts +73 -0
  23. package/src/client/appContext.tsx +61 -0
  24. package/src/client/blockTarget.ts +127 -0
  25. package/src/client/components/Callout.tsx +27 -0
  26. package/src/client/components/Checklist.tsx +37 -0
  27. package/src/client/components/Choice.tsx +178 -0
  28. package/src/client/components/CodeBlock.tsx +35 -0
  29. package/src/client/components/Diff.tsx +36 -0
  30. package/src/client/components/FilePreview.tsx +88 -0
  31. package/src/client/components/Image.tsx +48 -0
  32. package/src/client/components/Markdown.tsx +44 -0
  33. package/src/client/components/Mermaid.tsx +576 -0
  34. package/src/client/components/Note.tsx +14 -0
  35. package/src/client/components/Priority.tsx +19 -0
  36. package/src/client/components/ResponseNote.tsx +19 -0
  37. package/src/client/components/Section.tsx +33 -0
  38. package/src/client/components/Table.tsx +35 -0
  39. package/src/client/components/Task.tsx +76 -0
  40. package/src/client/components/UserInput.tsx +176 -0
  41. package/src/client/components/index.ts +18 -0
  42. package/src/client/components/renderMarkdownChildren.tsx +100 -0
  43. package/src/client/diffLines.ts +50 -0
  44. package/src/client/generateMarkdown.ts +250 -0
  45. package/src/client/highlightRange.ts +262 -0
  46. package/src/client/hostContext.tsx +55 -0
  47. package/src/client/index.ts +128 -0
  48. package/src/client/popoverPosition.ts +46 -0
  49. package/src/client/preact-all.ts +4 -0
  50. package/src/client/regionTarget.ts +180 -0
  51. package/src/client/runtime.ts +149 -0
  52. package/src/client/styles.css +492 -0
  53. package/src/client/testing/dom.ts +58 -0
  54. package/src/client/textTarget.ts +31 -0
  55. package/src/client/theme.css +66 -0
  56. package/src/client/useRegionAnnotation.tsx +188 -0
  57. package/src/client/useTextAnnotation.tsx +194 -0
  58. package/src/client/utils.ts +33 -0
  59. package/src/engine/channel.ts +278 -0
  60. package/src/engine/compiler.ts +415 -0
  61. package/src/engine/http-utils.ts +14 -0
  62. package/src/engine/lifecycle.ts +302 -0
  63. package/src/engine/paths.ts +63 -0
  64. package/src/engine/router.ts +16 -0
  65. package/src/engine/session.ts +678 -0
  66. package/src/engine/waiter.ts +188 -0
  67. package/src/engine/watcher.ts +84 -0
  68. package/src/engine/websocket.ts +159 -0
@@ -0,0 +1,188 @@
1
+ /**
2
+ * The CLI half of the notification channel: connect to a daemon's wait socket
3
+ * and block until the host says the wait is over.
4
+ *
5
+ * What settles the wait is a host predicate, so one helper covers "feedback
6
+ * arrived", "screens confirmed" and "a run was requested". The socket factory
7
+ * is injectable, which is what makes the retry and settle logic testable
8
+ * without a live daemon.
9
+ */
10
+
11
+ /** The slice of `WebSocket` a waiter uses; a real `WebSocket` satisfies it. */
12
+ export interface WaiterSocket {
13
+ close(): void;
14
+ onopen: ((event: Event) => void) | null;
15
+ onmessage: ((event: MessageEvent) => void) | null;
16
+ onerror: ((event: Event) => void) | null;
17
+ onclose: ((event: CloseEvent) => void) | null;
18
+ }
19
+
20
+ /** What a frame means to the waiter; `undefined` means "not mine, keep waiting". */
21
+ export type WaitVerdict<T> =
22
+ | { type: "settle"; value: T }
23
+ | { type: "fail"; error: Error }
24
+ /** Wake the probe — the frame announces state the waiter has to read back. */
25
+ | { type: "recheck" };
26
+
27
+ export interface WaitMessages {
28
+ /** Deadline passed. Default: "Timed out waiting." */
29
+ timeout?: string;
30
+ /** Socket closed before the wait settled. Default: "Connection closed before the wait settled." */
31
+ closed?: string;
32
+ /** The socket errored. Default: "WebSocket connection failed." */
33
+ connectionFailed?: string;
34
+ /**
35
+ * `createSocket` threw, so no socket ever existed; the factory's own message
36
+ * is appended after a colon. Default: "Could not create the WebSocket".
37
+ */
38
+ socketCreateFailed?: string;
39
+ }
40
+
41
+ export interface WaitOptions<T> {
42
+ /** The daemon's wait socket, e.g. `ws://localhost:19400/ws/wait/<topic>`. */
43
+ url: string;
44
+ /** Reads a frame; returns how it settles the wait, if at all. */
45
+ onFrame(frame: unknown): WaitVerdict<T> | undefined;
46
+ /**
47
+ * Fast path against server state. Runs before connecting (unless the caller
48
+ * already did that read — see `alreadyProbed`), again once the socket opens —
49
+ * closing the race where the event lands between the two — and whenever a
50
+ * frame asks for a recheck. A probe that rejects is dropped: the socket, not
51
+ * the probe, is what the wait ultimately hangs on.
52
+ */
53
+ probe?(): Promise<{ value: T } | undefined>;
54
+ /**
55
+ * The caller has already made the probe's read itself and it came up empty,
56
+ * so the wait starts at the socket. Set it when that read is the caller's own
57
+ * error channel — a probe that rejects is dropped here, so a caller that must
58
+ * report *why* the read failed has to make it before calling. The on-open
59
+ * re-read still runs; it is what closes the subscribe race.
60
+ */
61
+ alreadyProbed?: boolean;
62
+ createSocket?(url: string): WaiterSocket;
63
+ /** Deadline for the whole wait, spanning reconnects. */
64
+ timeoutMs: number;
65
+ reconnect?: {
66
+ /** Reconnects after a dropped connection. Default 0. */
67
+ attempts?: number;
68
+ /** Delay before a reconnect, capped by the remaining deadline. Default 1000. */
69
+ delayMs?: number;
70
+ };
71
+ messages?: WaitMessages;
72
+ }
73
+
74
+ /** Why the wait ended without an answer; hosts map these onto their own errors. */
75
+ export type WaitFailure =
76
+ | "timeout"
77
+ | "closed"
78
+ /** The socket was created, then errored. */
79
+ | "connection-failed"
80
+ /** `createSocket` threw — nothing was ever connected to. */
81
+ | "socket-create-failed";
82
+
83
+ export class WaitError extends Error {
84
+ constructor(
85
+ message: string,
86
+ readonly reason: WaitFailure,
87
+ readonly retryable: boolean,
88
+ options?: ErrorOptions,
89
+ ) {
90
+ super(message, options);
91
+ this.name = "WaitError";
92
+ }
93
+ }
94
+
95
+ const DEFAULT_MESSAGES: Required<WaitMessages> = {
96
+ timeout: "Timed out waiting.",
97
+ closed: "Connection closed before the wait settled.",
98
+ connectionFailed: "WebSocket connection failed.",
99
+ socketCreateFailed: "Could not create the WebSocket",
100
+ };
101
+
102
+ function parseFrame(data: unknown): unknown {
103
+ if (typeof data !== "string") return undefined;
104
+ try {
105
+ return JSON.parse(data);
106
+ } catch {
107
+ return undefined;
108
+ }
109
+ }
110
+
111
+ function connectAndWait<T>(
112
+ options: WaitOptions<T>,
113
+ createSocket: (url: string) => WaiterSocket,
114
+ messages: Required<WaitMessages>,
115
+ remainingMs: number,
116
+ ): Promise<T> {
117
+ return new Promise<T>((resolve, reject) => {
118
+ let socket: WaiterSocket;
119
+ try {
120
+ socket = createSocket(options.url);
121
+ } catch (error) {
122
+ const detail = error instanceof Error ? error.message : String(error);
123
+ const message = `${messages.socketCreateFailed}: ${detail}`;
124
+ reject(new WaitError(message, "socket-create-failed", true, { cause: error }));
125
+ return;
126
+ }
127
+
128
+ let settled = false;
129
+ // The value is wrapped so a settling `T` of any shape stays distinguishable.
130
+ const finish = (outcome: { value: T } | Error) => {
131
+ if (settled) return;
132
+ settled = true;
133
+ clearTimeout(timer);
134
+ socket.close();
135
+ if (outcome instanceof Error) reject(outcome);
136
+ else resolve(outcome.value);
137
+ };
138
+
139
+ const timer = setTimeout(() => finish(new WaitError(messages.timeout, "timeout", false)), remainingMs);
140
+
141
+ const runProbe = () => {
142
+ if (!options.probe) return;
143
+ options.probe().then((result) => {
144
+ if (result) finish(result);
145
+ }).catch(() => {});
146
+ };
147
+
148
+ socket.onopen = () => runProbe();
149
+
150
+ socket.onmessage = (event) => {
151
+ const frame = parseFrame(event.data);
152
+ if (frame === undefined) return;
153
+ const verdict = options.onFrame(frame);
154
+ if (!verdict) return;
155
+ if (verdict.type === "settle") finish({ value: verdict.value });
156
+ else if (verdict.type === "fail") finish(verdict.error);
157
+ else runProbe();
158
+ };
159
+
160
+ socket.onerror = () => finish(new WaitError(messages.connectionFailed, "connection-failed", true));
161
+ socket.onclose = () => finish(new WaitError(messages.closed, "closed", true));
162
+ });
163
+ }
164
+
165
+ export async function waitForEvent<T>(options: WaitOptions<T>): Promise<T> {
166
+ const createSocket = options.createSocket ?? ((url: string) => new WebSocket(url));
167
+ const messages = { ...DEFAULT_MESSAGES, ...options.messages };
168
+ const attempts = options.reconnect?.attempts ?? 0;
169
+ const delayMs = options.reconnect?.delayMs ?? 1_000;
170
+ const deadline = Date.now() + options.timeoutMs;
171
+
172
+ if (!options.alreadyProbed) {
173
+ const known = await options.probe?.().catch(() => undefined);
174
+ if (known) return known.value;
175
+ }
176
+
177
+ for (let attempt = 0; ; attempt++) {
178
+ const remainingMs = deadline - Date.now();
179
+ if (remainingMs <= 0) throw new WaitError(messages.timeout, "timeout", false);
180
+
181
+ try {
182
+ return await connectAndWait(options, createSocket, messages, remainingMs);
183
+ } catch (error) {
184
+ if (!(error instanceof WaitError) || !error.retryable || attempt >= attempts) throw error;
185
+ await Bun.sleep(Math.min(delayMs, Math.max(0, deadline - Date.now())));
186
+ }
187
+ }
188
+ }
@@ -0,0 +1,84 @@
1
+ import { watch, existsSync, readFileSync, readdirSync } from "fs";
2
+ import { join } from "path";
3
+ import { compileJsx, type CanvasComponents } from "./compiler";
4
+ import type { SessionManager } from "./session";
5
+
6
+ type BroadcastFn = (sessionId: string) => void;
7
+
8
+ export interface WatchOptions {
9
+ /** Scratch dir for recompiles, from `createCanvasPaths()`. */
10
+ tempDir?: string;
11
+ /** Host components, same value the push path compiles with — a recompile
12
+ * that dropped them would fail on canvases the original push accepted. */
13
+ components?: CanvasComponents;
14
+ }
15
+
16
+ const watchers = new Map<string, ReturnType<typeof watch>[]>();
17
+
18
+ export function watchSession(
19
+ sessionId: string,
20
+ sessionManager: SessionManager,
21
+ broadcast: BroadcastFn,
22
+ options: WatchOptions = {},
23
+ ) {
24
+ // Close existing watchers for this session
25
+ unwatchSession(sessionId);
26
+
27
+ const session = sessionManager.get(sessionId);
28
+ if (!session) return;
29
+
30
+ const rev = session.currentRevision;
31
+ const canvasFiles = sessionManager.getRevisionCanvasFiles(sessionId, rev);
32
+ if (canvasFiles.length === 0) return;
33
+
34
+ const fileWatchers: ReturnType<typeof watch>[] = [];
35
+ const debounceTimers = new Map<string, ReturnType<typeof setTimeout>>();
36
+
37
+ for (const filename of canvasFiles) {
38
+ const filePath = sessionManager.getRevisionJsxPath(sessionId, rev, filename);
39
+ if (!existsSync(filePath)) continue;
40
+
41
+ const watcher = watch(filePath, async (event) => {
42
+ if (event !== "change") return;
43
+
44
+ // Debounce per-file
45
+ const existing = debounceTimers.get(filename);
46
+ if (existing) clearTimeout(existing);
47
+
48
+ debounceTimers.set(filename, setTimeout(async () => {
49
+ debounceTimers.delete(filename);
50
+ try {
51
+ const jsx = readFileSync(filePath, "utf-8");
52
+ const result = await compileJsx(jsx, {
53
+ projectRoot: session.projectRoot,
54
+ tempDir: options.tempDir,
55
+ ...(options.components ? { components: options.components } : {}),
56
+ });
57
+ if (result.ok) {
58
+ sessionManager.saveCompiled(sessionId, filename, result.js, rev);
59
+ broadcast(sessionId);
60
+ }
61
+ } catch {}
62
+ }, 200));
63
+ });
64
+
65
+ fileWatchers.push(watcher);
66
+ }
67
+
68
+ watchers.set(sessionId, fileWatchers);
69
+ }
70
+
71
+ export function unwatchSession(sessionId: string) {
72
+ const sessionWatchers = watchers.get(sessionId);
73
+ if (sessionWatchers) {
74
+ for (const w of sessionWatchers) w.close();
75
+ watchers.delete(sessionId);
76
+ }
77
+ }
78
+
79
+ export function unwatchAll() {
80
+ for (const [, sessionWatchers] of watchers) {
81
+ for (const w of sessionWatchers) w.close();
82
+ }
83
+ watchers.clear();
84
+ }
@@ -0,0 +1,159 @@
1
+ /**
2
+ * The canvas vocabulary on top of the generic channel: topic = session id,
3
+ * `submit` persists feedback, and a waiter is the CLI blocking on one answer,
4
+ * so a delivery closes it.
5
+ */
6
+
7
+ import { browserFrame, createNotificationChannel, type ChannelSocket, type TopicChannel } from "./channel";
8
+ import type { SessionManager, RemoteFeedbackEntry } from "./session";
9
+
10
+ export type WSData = { type: "browser" | "wait"; sessionId: string };
11
+
12
+ export interface CanvasSocket extends ChannelSocket {
13
+ data: WSData;
14
+ }
15
+
16
+ interface CanvasRenderError {
17
+ revision: number;
18
+ filename: string;
19
+ message: string;
20
+ stack?: string;
21
+ componentStack?: string;
22
+ }
23
+
24
+ interface SubmitFrame {
25
+ type: "submit";
26
+ feedback: string;
27
+ }
28
+
29
+ interface RenderErrorFrame {
30
+ type: "render-error";
31
+ error: CanvasRenderError;
32
+ }
33
+
34
+ function isRecord(value: unknown): value is Record<string, unknown> {
35
+ return typeof value === "object" && value !== null && !Array.isArray(value);
36
+ }
37
+
38
+ function isCanvasRenderError(value: unknown): value is CanvasRenderError {
39
+ return typeof value === "object" && value !== null
40
+ && "revision" in value && Number.isInteger(value.revision)
41
+ && "filename" in value && typeof value.filename === "string"
42
+ && "message" in value && typeof value.message === "string"
43
+ && (!("stack" in value) || value.stack === undefined || typeof value.stack === "string")
44
+ && (!("componentStack" in value) || value.componentStack === undefined || typeof value.componentStack === "string");
45
+ }
46
+
47
+ function isSubmitFrame(value: unknown): value is SubmitFrame {
48
+ return isRecord(value) && value.type === "submit" && typeof value.feedback === "string";
49
+ }
50
+
51
+ function isRenderErrorFrame(value: unknown): value is RenderErrorFrame {
52
+ return isRecord(value) && value.type === "render-error" && isCanvasRenderError(value.error);
53
+ }
54
+
55
+ export interface WebSocketManagerOptions {
56
+ /**
57
+ * Who marks feedback consumed once a waiting client has been handed it.
58
+ *
59
+ * `"on-delivery"` (default) clears it in the same step, so the same feedback
60
+ * can never be delivered twice — right when the waiting client *is* the
61
+ * consumer. `"external"` leaves it pending for a separate gate to claim
62
+ * through the consume endpoint, for hosts where delivery and consumption are
63
+ * different steps.
64
+ */
65
+ feedbackConsumption?: "on-delivery" | "external";
66
+ }
67
+
68
+ export function createWebSocketManager(
69
+ sessionManager: SessionManager,
70
+ options: WebSocketManagerOptions = {},
71
+ ) {
72
+ const consumeOnDelivery = (options.feedbackConsumption ?? "on-delivery") === "on-delivery";
73
+ const pendingRenderErrors = new Map<string, CanvasRenderError>();
74
+
75
+ /** Hands a held error to the waiters, if any showed up; the caller announces the departure. */
76
+ function deliverPendingRenderError(sessionId: string): boolean {
77
+ const error = pendingRenderErrors.get(sessionId);
78
+ if (!error) return false;
79
+ if (!channel.relayToWaiters(sessionId, { type: "render-error", error })) return false;
80
+ pendingRenderErrors.delete(sessionId);
81
+ return true;
82
+ }
83
+
84
+ function submit(frame: SubmitFrame, topic: TopicChannel) {
85
+ const sessionId = topic.topic;
86
+ const session = sessionManager.get(sessionId);
87
+ if (!session) return;
88
+
89
+ sessionManager.saveFeedback(sessionId, session.currentRevision, frame.feedback);
90
+ broadcastRevisionUpdate(sessionId);
91
+ if (!topic.hasWaiter()) return;
92
+
93
+ if (consumeOnDelivery) {
94
+ sessionManager.consumeFeedback(sessionId, session.currentRevision);
95
+ broadcastRevisionUpdate(sessionId);
96
+ }
97
+ topic.relayToWaiters({ type: "submit", feedback: frame.feedback });
98
+ }
99
+
100
+ function renderError(frame: RenderErrorFrame, topic: TopicChannel) {
101
+ const sessionId = topic.topic;
102
+ const session = sessionManager.get(sessionId);
103
+ if (!session) return;
104
+ if (frame.error.revision !== session.currentRevision) return;
105
+ if (!session.canvasFiles.includes(frame.error.filename)) return;
106
+
107
+ pendingRenderErrors.set(sessionId, frame.error);
108
+ if (deliverPendingRenderError(sessionId)) topic.broadcastPresence();
109
+ }
110
+
111
+ const channel = createNotificationChannel<CanvasSocket>({
112
+ roleOf: (socket) => socket.data.type,
113
+ topicOf: (socket) => socket.data.sessionId,
114
+ waiterPolicy: "close-on-delivery",
115
+ presenceFrame: (watching) => ({ type: "watcher-status", watching }),
116
+ // A render error raised before the CLI watcher connected is held for it.
117
+ onWaiterOpen: (topic) => { deliverPendingRenderError(topic.topic); },
118
+ frames: [
119
+ browserFrame(isSubmitFrame, submit),
120
+ browserFrame(isRenderErrorFrame, renderError),
121
+ ],
122
+ });
123
+
124
+ function broadcastPlanUpdate(sessionId: string) {
125
+ pendingRenderErrors.delete(sessionId);
126
+ const session = sessionManager.get(sessionId);
127
+ if (!session) return;
128
+ channel.broadcast(sessionId, {
129
+ type: "plan-updated",
130
+ currentRevision: session.currentRevision,
131
+ revisions: session.revisions,
132
+ });
133
+ }
134
+
135
+ function broadcastRevisionUpdate(sessionId: string) {
136
+ const session = sessionManager.get(sessionId);
137
+ if (!session) return;
138
+ channel.broadcast(sessionId, {
139
+ type: "revision-updated",
140
+ revisions: session.revisions,
141
+ });
142
+ }
143
+
144
+ function broadcastRemoteFeedback(sessionId: string, revision: number, entries: RemoteFeedbackEntry[]) {
145
+ channel.broadcast(sessionId, { type: "remote-feedback", revision, entries });
146
+ }
147
+
148
+ function broadcastWatcherStatus(sessionId: string) {
149
+ channel.broadcastPresence(sessionId);
150
+ }
151
+
152
+ return {
153
+ handlers: channel.handlers,
154
+ broadcastPlanUpdate,
155
+ broadcastRevisionUpdate,
156
+ broadcastWatcherStatus,
157
+ broadcastRemoteFeedback,
158
+ };
159
+ }