@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,415 @@
1
+ import { writeFileSync, unlinkSync, readFileSync, mkdirSync } from "fs";
2
+ import { join } from "path";
3
+ import { tmpdir } from "os";
4
+ import { randomUUID } from "crypto";
5
+ import { h, Fragment, type ComponentChildren } from "preact";
6
+ import renderToString from "preact-render-to-string";
7
+ import { parse as parseMermaid } from "mermaid-parser-bundle";
8
+
9
+ export type CompileResult =
10
+ | { ok: true; js: string }
11
+ | { ok: false; error: string };
12
+
13
+ type RequiredPropKind = "array" | "string";
14
+
15
+ /**
16
+ * One name the compiler makes available to authored JSX without an import.
17
+ *
18
+ * `requiredProps` is enforced during the render validation pass below, so a
19
+ * canvas that forgets a prop fails at push time with a precise message instead
20
+ * of rendering blank in the browser.
21
+ */
22
+ export interface CanvasComponentSpec {
23
+ requiredProps?: Record<string, RequiredPropKind>;
24
+ }
25
+
26
+ export type CanvasComponents = Record<string, CanvasComponentSpec>;
27
+
28
+ /**
29
+ * The components every canvas host provides. Order is the order they appear in
30
+ * the injected destructure, so keep it stable for readable generated code.
31
+ */
32
+ export const KERNEL_COMPONENTS: CanvasComponents = {
33
+ Section: { requiredProps: { title: "string" } },
34
+ Item: { requiredProps: { id: "string", label: "string" } },
35
+ Task: { requiredProps: { id: "string", label: "string" } },
36
+ FilePreview: { requiredProps: { path: "string" } },
37
+ CodeBlock: {},
38
+ Callout: {},
39
+ Mermaid: {},
40
+ Table: { requiredProps: { headers: "array", rows: "array" } },
41
+ Priority: { requiredProps: { level: "string" } },
42
+ Checklist: { requiredProps: { items: "array" } },
43
+ Note: {},
44
+ Diff: { requiredProps: { before: "string", after: "string" } },
45
+ Choice: { requiredProps: { id: "string", label: "string", options: "array" } },
46
+ MultiChoice: { requiredProps: { id: "string", label: "string", options: "array" } },
47
+ UserInput: { requiredProps: { id: "string", label: "string" } },
48
+ RangeInput: { requiredProps: { id: "string", label: "string" } },
49
+ ImageView: { requiredProps: { src: "string" } },
50
+ Markdown: {},
51
+ useFeedback: {},
52
+ useAnnotations: {},
53
+ };
54
+
55
+ export interface CompileOptions {
56
+ /** Resolves `<FilePreview>` / `<Markdown file>` references at compile time. */
57
+ projectRoot?: string;
58
+ /** Scratch dir for the temp `.jsx` handed to Bun.build. Created on demand. */
59
+ tempDir?: string;
60
+ /**
61
+ * Host components to add on top of KERNEL_COMPONENTS, keyed by name. Each one
62
+ * must also be exported from the host's `#canvas/components` barrel — that is
63
+ * what the injected import actually reads at runtime. Re-declaring a kernel
64
+ * name overrides its spec.
65
+ */
66
+ components?: CanvasComponents;
67
+ }
68
+
69
+ /** Fallback scratch dir for hosts that compile without wiring CanvasPaths. */
70
+ const FALLBACK_COMPILE_DIR = join(tmpdir(), "canvas-kernel", "compile");
71
+
72
+ function componentImports(components: CanvasComponents): string {
73
+ return `import React from 'react';
74
+ import * as C from '#canvas/components';
75
+ const { ${Object.keys(components).join(", ")} } = C;
76
+ `;
77
+ }
78
+
79
+ // --- Validation via Preact --------------------------------------------------
80
+ // After Bun.build() succeeds we strip ESM syntax from the compiled output,
81
+ // provide Preact as the React implementation with lightweight stub components,
82
+ // and actually render the component tree. Any runtime error (undefined vars,
83
+ // broken template literals, …) is caught and reported back to the CLI.
84
+
85
+ /** jsxDEV(type, props, key, isStaticChildren, source, self) → VNode */
86
+ function mockJsxDEV(type: any, props: any, key?: any) {
87
+ const { children, ...rest } = props || {};
88
+ return h(type, { ...rest, key }, children);
89
+ }
90
+
91
+ interface StubProps {
92
+ children?: ComponentChildren;
93
+ [key: string]: unknown;
94
+ }
95
+
96
+ /** Passthrough component — just renders its children */
97
+ const Stub = ({ children }: StubProps) => h(Fragment, null, children);
98
+
99
+ function validatingStub(component: string, requiredProps: Record<string, RequiredPropKind>) {
100
+ return (props: StubProps) => {
101
+ for (const [prop, kind] of Object.entries(requiredProps)) {
102
+ const value = props[prop];
103
+ const valid = kind === "array" ? Array.isArray(value) : typeof value === kind;
104
+ if (!valid) {
105
+ throw new Error(`${component}: required prop \`${prop}\` must be ${kind === "array" ? "an array" : `a ${kind}`}`);
106
+ }
107
+ }
108
+ return h(Fragment, null, props.children);
109
+ };
110
+ }
111
+
112
+ // Mermaid stub collects diagram sources for post-render validation
113
+ let collectedMermaidSources: string[] = [];
114
+
115
+ /** Names whose stub does more than check props, so a manifest entry cannot
116
+ * describe them. Hooks return shapes that survive destructuring. */
117
+ const SPECIAL_STUBS: Record<string, any> = {
118
+ Mermaid: ({ children }: any) => {
119
+ const source = typeof children === "string" ? children : String(children ?? "");
120
+ if (source.trim()) collectedMermaidSources.push(source.trim());
121
+ return null;
122
+ },
123
+ useFeedback: () => ({ submit: () => {}, value: null }),
124
+ useAnnotations: () => ({ annotations: [], addAnnotation: () => {} }),
125
+ };
126
+
127
+ function buildStubs(components: CanvasComponents): Record<string, any> {
128
+ const stubs: Record<string, any> = {};
129
+ for (const [name, spec] of Object.entries(components)) {
130
+ const special = SPECIAL_STUBS[name];
131
+ stubs[name] = special ?? (spec.requiredProps ? validatingStub(name, spec.requiredProps) : Stub);
132
+ }
133
+ return stubs;
134
+ }
135
+
136
+ const MOCK_REACT = {
137
+ createElement: h,
138
+ Fragment,
139
+ useState: (init: any) => [init, () => {}],
140
+ useEffect: () => {},
141
+ useRef: (init: any) => ({ current: init }),
142
+ useMemo: (fn: any) => fn(),
143
+ useCallback: (fn: any) => fn,
144
+ useReducer: (r: any, init: any) => [init, () => {}],
145
+ useContext: () => ({}),
146
+ createContext: () => ({ Provider: Stub, Consumer: Stub }),
147
+ useLayoutEffect: () => {},
148
+ useImperativeHandle: () => {},
149
+ useDebugValue: () => {},
150
+ useDeferredValue: (v: any) => v,
151
+ useTransition: () => [false, (fn: any) => fn()],
152
+ useId: () => "mock",
153
+ useSyncExternalStore: (sub: any, get: any) => get(),
154
+ memo: (c: any) => c,
155
+ forwardRef: (c: any) => c,
156
+ lazy: (c: any) => c,
157
+ Children: { map: (c: any, fn: any) => (Array.isArray(c) ? c : [c]).map(fn), toArray: (c: any) => (Array.isArray(c) ? c : [c]) },
158
+ isValidElement: () => true,
159
+ cloneElement: h,
160
+ Suspense: Stub,
161
+ StrictMode: Stub,
162
+ };
163
+
164
+ async function validateCompiledPlan(js: string, stubs: Record<string, any>): Promise<{ ok: true } | { ok: false; error: string }> {
165
+ let code = js;
166
+
167
+ // Strip ESM import statements
168
+ code = code.replace(/import\s+\w+\s+from\s+"[^"]+";?/g, "");
169
+ code = code.replace(/import\s+\*\s+as\s+\w+\s+from\s+"[^"]+";?/g, "");
170
+ code = code.replace(/import\s*\{[^}]*\}\s*from\s+"[^"]+";?/g, "");
171
+
172
+ // Strip export, extract default name
173
+ let defaultName: string | null = null;
174
+ code = code.replace(/export\s*\{\s*(\w+)\s+as\s+default\s*\};?/g, (_, name) => {
175
+ defaultName = name;
176
+ return "";
177
+ });
178
+ if (!defaultName) {
179
+ code = code.replace(/export\s+default\s+(\w+);?/g, (_, name) => {
180
+ defaultName = name;
181
+ return "";
182
+ });
183
+ }
184
+
185
+ const callDefault = defaultName
186
+ ? `\nreturn typeof ${defaultName} === "function" ? ${defaultName}() : null;`
187
+ : "\nreturn null;";
188
+
189
+ try {
190
+ collectedMermaidSources = [];
191
+ const fn = new Function("React", "C", "jsxDEV", "jsx", "Fragment", code + callDefault);
192
+ const vnode = fn(MOCK_REACT, stubs, mockJsxDEV, mockJsxDEV, Fragment);
193
+ if (vnode) renderToString(vnode);
194
+ } catch (e: any) {
195
+ return { ok: false, error: e?.message || String(e) };
196
+ }
197
+
198
+ // Validate collected Mermaid diagram sources
199
+ for (const source of collectedMermaidSources) {
200
+ try {
201
+ await parseMermaid(source);
202
+ } catch (e: any) {
203
+ const msg = (e?.message || "Invalid diagram").split("\n")[0];
204
+ return { ok: false, error: `Mermaid syntax error: ${msg}` };
205
+ }
206
+ }
207
+
208
+ return { ok: true };
209
+ }
210
+
211
+ /**
212
+ * Normalize a Bun build/transpile failure into a precise, actionable string.
213
+ *
214
+ * Bun surfaces JSX syntax errors through three different shapes, all handled
215
+ * here so the CLI never again prints a bare "Parse error" / "Bundle failed":
216
+ * - `Bun.Transpiler.transformSync` throws a `BuildMessage` (has `.position`).
217
+ * - `Bun.build()` returns `{ success: false, logs: [BuildMessage] }`.
218
+ * - `Bun.build()` throws an `AggregateError` whose `.errors[0]` is a
219
+ * `BuildMessage` — the case the old catch discarded, returning only the
220
+ * useless top-level "Bundle failed".
221
+ *
222
+ * `lineOffset` is the number of lines Canvas prepended (component imports +
223
+ * wrapper) so the reported line maps back to the user's authored file. When a
224
+ * position is available we append `(line N, col M)` plus a one-line code frame
225
+ * with a caret — that suffix is also what lets the CLI classify the error as a
226
+ * user error and skip a pointless daemon restart (see push.ts).
227
+ */
228
+ function formatBuildError(err: any, lineOffset: number): string {
229
+ // Collect every candidate that might carry a `.position`, most specific first.
230
+ const candidates: any[] = [];
231
+ if (err?.position) candidates.push(err);
232
+ if (Array.isArray(err?.errors)) candidates.push(...err.errors);
233
+ if (!candidates.length && err && typeof err[Symbol.iterator] === "function") {
234
+ try { candidates.push(...err); } catch {}
235
+ }
236
+ const detail = candidates.find((c) => c?.position) ?? candidates[0] ?? err;
237
+ const [message = "Syntax error"] = String(detail?.message || err?.message || "Syntax error").split("\n");
238
+
239
+ const pos = detail?.position;
240
+ if (!pos || typeof pos.line !== "number") return message;
241
+
242
+ const loc = ` (line ${pos.line - lineOffset}, col ${pos.column})`;
243
+ let frame = "";
244
+ if (typeof pos.lineText === "string") {
245
+ const caret = " ".repeat(Math.max(0, pos.column ?? 0)) + "^";
246
+ frame = `\n ${pos.lineText}\n ${caret}`;
247
+ }
248
+ return `${message}${loc}${frame}`;
249
+ }
250
+
251
+ export async function compileJsx(jsx: string, options: CompileOptions = {}): Promise<CompileResult> {
252
+ const { projectRoot } = options;
253
+ const components = options.components ? { ...KERNEL_COMPONENTS, ...options.components } : KERNEL_COMPONENTS;
254
+ const componentPreamble = componentImports(components);
255
+ // Strip leading pragma / block / line comments (e.g. `/** @jsxImportSource ... */`).
256
+ // Authored JSX sometimes carries a JSX-runtime pragma from another toolchain;
257
+ // Canvas injects its own imports and uses the React automatic runtime, so the
258
+ // pragma is unused. Left in place it sits at the top level of the wrapped
259
+ // fragment as plain text (JSX only treats `{/* */}` as a comment) and renders
260
+ // literally on the page.
261
+ let stripped: string;
262
+ do {
263
+ stripped = jsx;
264
+ jsx = jsx.replace(/^\s*(?:\/\*[\s\S]*?\*\/|\/\/[^\n]*)\s*/, "");
265
+ } while (jsx !== stripped);
266
+
267
+ // Resolve file contents at compile time
268
+ if (projectRoot) {
269
+ jsx = resolveFilePreviews(jsx, projectRoot);
270
+ jsx = resolveMarkdownFiles(jsx, projectRoot);
271
+ }
272
+
273
+ // Strip template literals and strings so `export default` inside CodeBlock
274
+ // content (e.g. {`export default defineConfig(...)`}) doesn't false-positive.
275
+ const strippedJsx = jsx
276
+ .replace(/\{`[^`]*`\}/gs, "") // {`...`} template expressions
277
+ .replace(/`[^`]*`/gs, "") // standalone template literals
278
+ .replace(/"[^"]*"/g, "") // double-quoted strings
279
+ .replace(/'[^']*'/g, ""); // single-quoted strings
280
+ const hasDefaultExport = /export\s+default\b/.test(strippedJsx);
281
+
282
+ const source = hasDefaultExport
283
+ ? `${componentPreamble}\n${jsx}`
284
+ : `${componentPreamble}\nexport default function Plan() {\n return (<>${jsx}</>);\n}\n`;
285
+
286
+ // Offset: injected import lines + wrapper lines (export default function
287
+ // Plan() + return). Used to map Bun's line numbers back to the authored file.
288
+ const wrapperLines = hasDefaultExport ? 1 : 2;
289
+ const lineOffset = componentPreamble.split("\n").length - 1 + wrapperLines;
290
+
291
+ // Pre-validate syntax with Bun.Transpiler — gives precise error positions,
292
+ // unlike Bun.build() which throws opaque "Bundle failed" for syntax errors.
293
+ try {
294
+ const transpiler = new Bun.Transpiler({ loader: "jsx" });
295
+ transpiler.transformSync(source);
296
+ } catch (syntaxError: any) {
297
+ return { ok: false, error: formatBuildError(syntaxError, lineOffset) };
298
+ }
299
+
300
+ const compileDir = options.tempDir ?? FALLBACK_COMPILE_DIR;
301
+ mkdirSync(compileDir, { recursive: true });
302
+ const tmpFile = join(compileDir, `plan-${randomUUID()}.jsx`);
303
+
304
+ try {
305
+ writeFileSync(tmpFile, source);
306
+
307
+ try {
308
+ const result = await Bun.build({
309
+ entrypoints: [tmpFile],
310
+ format: "esm",
311
+ external: ["react", "react-dom", "#canvas/components", "#canvas/runtime"],
312
+ jsx: { runtime: "automatic", importSource: "react" },
313
+ });
314
+
315
+ if (!result.success) {
316
+ const errorLogs = result.logs.filter((l) => l.level === "error");
317
+ const errors = errorLogs
318
+ .map((l) => formatBuildError(l, lineOffset))
319
+ .join("\n");
320
+ return { ok: false, error: errors || "Bundle failed" };
321
+ }
322
+
323
+ const [output] = result.outputs;
324
+ if (!output) return { ok: false, error: "Bundle produced no output" };
325
+
326
+ const js = await output.text();
327
+ const validation = await validateCompiledPlan(js, buildStubs(components));
328
+ if (!validation.ok) {
329
+ return { ok: false, error: `Runtime error: ${validation.error}` };
330
+ }
331
+ return { ok: true, js };
332
+ } catch (buildError: any) {
333
+ // A real syntax error surfaces as an AggregateError carrying BuildMessages
334
+ // (with positions) — format it precisely. The opaque "Unknown Error, TODO"
335
+ // corruption case (internal bundler state gets corrupted in long-running
336
+ // processes) has no sub-errors; pass it through so the CLI restarts the
337
+ // daemon and retries.
338
+ const hasDetail =
339
+ buildError?.position ||
340
+ (Array.isArray(buildError?.errors) && buildError.errors.length > 0);
341
+ if (hasDetail) {
342
+ return { ok: false, error: formatBuildError(buildError, lineOffset) };
343
+ }
344
+ return { ok: false, error: buildError?.message || "Unknown Error" };
345
+ }
346
+ } catch (e: any) {
347
+ return { ok: false, error: e.message };
348
+ } finally {
349
+ try { unlinkSync(tmpFile); } catch {}
350
+ }
351
+ }
352
+
353
+ /**
354
+ * Find <Markdown file="..." /> tags and inject __content with the file contents
355
+ * resolved at compile time.
356
+ */
357
+ function resolveMarkdownFiles(jsx: string, projectRoot: string): string {
358
+ return jsx.replace(
359
+ /<Markdown\b([^>]*?)\/>/g,
360
+ (match, attrs: string) => {
361
+ const filePath = attrs.match(/file=["']([^"']+)["']/)?.[1];
362
+ if (!filePath) return match;
363
+
364
+ const absPath = join(projectRoot, filePath);
365
+
366
+ try {
367
+ const content = readFileSync(absPath, "utf-8");
368
+ const escaped = JSON.stringify(content);
369
+ return `<Markdown${attrs} __content={${escaped}} />`;
370
+ } catch {
371
+ return match;
372
+ }
373
+ }
374
+ );
375
+ }
376
+
377
+ /**
378
+ * Find <FilePreview path="..." /> tags and inject __content with the file contents
379
+ * resolved at compile time so the browser doesn't need to fetch.
380
+ */
381
+ function resolveFilePreviews(jsx: string, projectRoot: string): string {
382
+ // Match <FilePreview ... /> (self-closing)
383
+ return jsx.replace(
384
+ /<FilePreview\b([^>]*?)\/>/g,
385
+ (match, attrs: string) => {
386
+ const filePath = attrs.match(/path=["']([^"']+)["']/)?.[1];
387
+ if (!filePath) return match;
388
+
389
+ const absPath = join(projectRoot, filePath);
390
+
391
+ try {
392
+ let content = readFileSync(absPath, "utf-8");
393
+
394
+ // Apply lines filter if present
395
+ const linesMatch = attrs.match(/lines=\{?\[(\d+)\s*,\s*(\d+)\]\}?/);
396
+ if (linesMatch?.[1] && linesMatch[2]) {
397
+ const start = parseInt(linesMatch[1], 10);
398
+ const end = parseInt(linesMatch[2], 10);
399
+ content = content.split("\n").slice(start - 1, end).join("\n");
400
+ }
401
+
402
+ // Escape for embedding in JSX string
403
+ const escaped = JSON.stringify(content);
404
+ return `<FilePreview${attrs} __content={${escaped}} />`;
405
+ } catch {
406
+ return match;
407
+ }
408
+ }
409
+ );
410
+ }
411
+
412
+ /** @deprecated Use `compileJsx(jsx, { projectRoot, tempDir })`. */
413
+ export async function compilePlan(jsx: string, projectRoot?: string): Promise<CompileResult> {
414
+ return compileJsx(jsx, { projectRoot });
415
+ }
@@ -0,0 +1,14 @@
1
+ export function jsonResponse(data: unknown, status = 200): Response {
2
+ return new Response(JSON.stringify(data), {
3
+ status,
4
+ headers: { "Content-Type": "application/json" },
5
+ });
6
+ }
7
+
8
+ export function corsHeaders(port: number): Record<string, string> {
9
+ return {
10
+ "Access-Control-Allow-Origin": "http://localhost:" + port,
11
+ "Access-Control-Allow-Methods": "GET, POST, DELETE, OPTIONS",
12
+ "Access-Control-Allow-Headers": "Content-Type",
13
+ };
14
+ }