Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 21 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,8 +80,9 @@ Import standard React hooks from `sidequery/canvas` for component state and effe
Use `useCanvasState(key, defaultValue)` when you want host-backed state; persistence
depends on the view.

Canvases live at `<workspace>/canvases/<name>.canvas.tsx` and may import only
from `sidequery/canvas` (`herdr/canvas` and `cursor/canvas` remain compatibility aliases). Use the CLI or MCP:
Canvases live at `<workspace>/canvases/<name>.canvas.tsx`. Import the SDK from
`sidequery/canvas` (`herdr/canvas` and `cursor/canvas` remain compatibility aliases).
Projects can also import their declared helper files and pinned dependencies. Use the CLI or MCP:

```bash
canvas list
Expand Down Expand Up @@ -291,9 +292,9 @@ unchanged: MCP supports `canvas_write` with `open: true`; CLI uses separate
## Artifact history

Every successfully served source revision is archived automatically, including
edits made directly to a `.canvas.tsx` file. History stores **raw TSX source and
metadata only**, using Bun's built-in `bun:sqlite`; compiled JavaScript stays in
memory. Unchanged source reuses its latest revision. Each page response records
edits made directly to a `.canvas.tsx` file. History stores **authored source,
dependency snapshots, and metadata**, using Bun's built-in `bun:sqlite`; compiled
canvas JavaScript stays in memory. Unchanged source and project reuse their latest revision. Each page response records
a separate serve event with its initial UI state, pane/session context, and
runtime identity. A serve event means the server returned a page, not that a
human viewed it. Unserved intermediate edits are not captured automatically.
Expand Down Expand Up @@ -401,3 +402,18 @@ for hostile JavaScript. Gallery previews run in isolated browser frames. Inline
canvases execute inside the chat host's MCP Apps sandbox; use trusted local canvas
source, as for the other local views. Artifact
source and saved state can contain private data; keep the history database private.

## Projects and remix

Canvases and scripts support helper source files and pinned package dependencies.
Use the gallery's source file picker and Dependencies editor, or pass
`project: { files: { "lib/helper.ts": "..." }, dependencies: { "package": "1.2.3" } }`
to the write tool. Local CLI writes accept `--project PROJECT_JSON`; targeted
reads and edits accept `--source-file lib/helper.ts`. Source history retains the
resolved dependency snapshot as well as authored files.

Use **Remix** in the gallery, `canvas_remix` / `script_remix` through MCP, or
`canvas remix SOURCE NEW_NAME` locally. Copies retain source provenance and start
with fresh state, storage, secrets, and private hosted URLs.

See [project and remix contracts](docs/scripts.md) for details and limits.
10 changes: 9 additions & 1 deletion bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

33 changes: 27 additions & 6 deletions cloudflare/compiled-canvas.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,14 @@ async function start(directory: string, identity: string) {
plugins: [{ name: "compiler-probe", setup(build) {
build.onLoad({ filter: /\/cloudflare\/compiler\.ts$/ }, () => ({ loader: "ts", contents: `
globalThis.compilerProbe = { allowed: true, client: 0, server: 0 };
function compile(source, kind) {
function compile(source, kind, project) {
if (!globalThis.compilerProbe.allowed) throw new Error("Compilation was invoked by a read");
globalThis.compilerProbe[kind]++;
return source.includes("invalid") ? { ok: false, diagnostics: [{ severity: "error", message: "Invalid source" }] }
: { ok: true, js: JSON.stringify({ source, kind, runtime: ${JSON.stringify(identity)} }), diagnostics: [] };
: { ok: true, js: JSON.stringify({ source, kind, project, runtime: ${JSON.stringify(identity)} }), diagnostics: [] };
}
export async function compileCanvasSource(source) { return compile(source, "client"); }
export async function compileCanvasServerSource(source) { return compile(source, "server"); }
export async function compileCanvasSource(source, project) { return compile(source, "client", project); }
export async function compileCanvasServerSource(source, project) { return compile(source, "server", project); }
export async function compileScriptSource() { throw new Error("Unexpected script compile"); }
export function typecheckCanvasSource() { return []; }
export function typecheckCanvasServerSource() { return []; }
Expand Down Expand Up @@ -100,10 +100,31 @@ test("edits persist compiled revisions; every read and restart works with the co
expect((await call("/preview", { version_id: firstVersion }))._meta.canvas.js).toBe(firstCanvas.js);
expect((await call("/active", { slug: "example" }))._meta.canvas.js).toBe(upgraded._meta.canvas.js);

// Project-only edits compile fresh output and preserve archived replay and remix snapshots.
await call("/compiler", { allowed: true });
const projectWrite = await tool("canvas_write", { name: "project", contents: "project-client", server: "project-server", project: { files: { "helper.ts": "original-helper" } } });
expect(projectWrite.isError).not.toBe(true);
const projectFirst = projectWrite._meta.canvas;
const projectEdit = await tool("canvas_edit", { name: "project", file: "helper.ts", edits: [{ old_text: "original-helper", new_text: "updated-helper" }] });
expect(projectEdit.isError).not.toBe(true);
expect(projectEdit._meta.canvas.js).toContain("updated-helper");
expect(projectEdit._meta.canvas.js).not.toBe(projectFirst.js);
expect(projectEdit._meta.canvas.versionId).not.toBe(projectFirst.versionId);
await call("/compiler", { allowed: false });
expect((await call("/preview", { version_id: projectFirst.versionId }))._meta.canvas.js).toBe(projectFirst.js);
expect((await tool("canvas_open", { name: "project" }))._meta.canvas.js).toBe(projectEdit._meta.canvas.js);
expect(JSON.parse(atob((await call("/request", { version_id: projectFirst.versionId })).body)).code).toContain("original-helper");
await call("/compiler", { allowed: true });
const remixed = await tool("canvas_remix", { version_id: projectFirst.versionId, new_name: "project-remix" });
expect(remixed.isError).not.toBe(true);
expect(remixed._meta.canvas.js).toContain("original-helper");
expect(remixed._meta.canvas.js).not.toContain("updated-helper");
await call("/compiler", { allowed: false });

// Source-only legacy revisions are migrated explicitly, never during reads.
const legacy = await call("/legacy", { name: "legacy", source: "legacy-source", server_source: null });
expect((await call("/preview", { version_id: legacy.version.id })).check).toContain("canvas_compile");
expect(await call("/compiler", { allowed: true })).toMatchObject({ client: 1, server: 1 });
expect(await call("/compiler", { allowed: true })).toMatchObject({ client: 4, server: 4 });
expect(result(await tool("canvas_compile", { name: "legacy" })).ok).toBe(true);
// Compiling a draft must not silently pin or change an archived revision.
await call("/compiler", { allowed: false });
Expand All @@ -112,6 +133,6 @@ test("edits persist compiled revisions; every read and restart works with the co
expect(result(await tool("canvas_compile", { version_id: legacy.version.id })).ok).toBe(true);
await call("/compiler", { allowed: false });
expect((await call("/preview", { version_id: legacy.version.id }))._meta.canvas.js).toContain("legacy-source");
expect(await call("/compiler", { allowed: false })).toMatchObject({ client: 2, server: 1 });
expect(await call("/compiler", { allowed: false })).toMatchObject({ client: 5, server: 4 });
} finally { await runtime.dispose(); await rm(directory, { recursive: true, force: true }); }
}, 60000);
49 changes: 48 additions & 1 deletion cloudflare/compiler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -236,7 +236,7 @@ test("a prebundled third-party Hono handler compiles and serves through the scri

const host = await Bun.build({
entrypoints: [new URL("./scripts-test-worker.ts", import.meta.url).pathname],
target: "node", format: "esm", external: ["cloudflare:workers"],
target: "browser", format: "esm", external: ["cloudflare:workers", "node:*", "fs", "fs/promises"],
});
if (!host.success) throw new Error(host.logs.join("\n"));
const execution = new Miniflare({
Expand Down Expand Up @@ -319,3 +319,50 @@ export default function Canvas() { return <PluginCounter {...label} />; }`;
} finally { await browser.close(); }
expect(outbound).toEqual([]);
}, 60000);

async function callProject(source: string, project: object, operation = "compile"): Promise<Result> {
const response = await runtime.dispatchFetch(`http://localhost/${operation}`, { method: "POST", headers: {"content-type":"application/json"}, body: JSON.stringify({source,project}) });
expect(response.status).toBe(200);
return response.json() as Promise<Result>;
}
test("compiles archived multi-file projects and typed packages without network", async () => {
const project = {files:{"lib/greeting.ts":"export const greeting: string = 'project-hello';"},dependencies:{"tiny-example":"1.0.0"},lock:{
"node_modules/tiny-example/package.json":JSON.stringify({name:"tiny-example",version:"1.0.0",main:"index.js",types:"index.d.ts"}),
"node_modules/tiny-example/index.js":"export const answer = 42;",
"node_modules/tiny-example/index.d.ts":"export const answer: number;",
}};
const source = 'import {greeting} from "./lib/greeting"; import {answer} from "tiny-example"; export default function Canvas(){return <div>{greeting} {answer}</div>}';
const result=await callProject(source,project);
expect(result.diagnostics).toEqual([]); expect(result.ok).toBe(true); expect(result.js).toContain("project-hello");
const script=await callProject('import {greeting} from "./lib/greeting"; import {answer} from "tiny-example"; export default {fetch(){return new Response(greeting+answer)}}',project,"compile-script");
expect(script.diagnostics).toEqual([]); expect(script.ok).toBe(true);
const bad=await callProject(source,{...project,files:{"lib/greeting.ts":"export const greeting: number = 'bad';"}});
expect(bad.ok).toBe(false); expect(bad.diagnostics.some(item=>item.file?.includes("greeting.ts"))).toBe(true);
const separateServer=await callProject(source,{...project,files:{...project.files,"server-only.ts":"export const load = () => fetch('https://example.com');"}});
expect(separateServer.ok).toBe(true);
const transitiveUnsafe=await callProject(source,{...project,files:{"lib/greeting.ts":"export {greeting} from './nested';","lib/nested.ts":"export const greeting = fetch('https://example.com');"}});
expect(transitiveUnsafe.ok).toBe(false); expect(transitiveUnsafe.diagnostics.some(item=>item.file === "lib/nested.ts")).toBe(true);
const unsafe=await callProject(source,{...project,files:{"lib/greeting.ts":"export const greeting = fetch('https://example.com');"}});
expect(unsafe.ok).toBe(false); expect(unsafe.diagnostics.some(item=>item.message.includes("fetch()"))).toBe(true);
expect(outbound).toEqual([]);
},60000);

test("package components use the host React hook runtime",async()=>{
const result=await callProject('import {Counter} from "counter-package"; export default function Canvas(){return <Counter/>}',{dependencies:{"counter-package":"1.0.0"},lock:{
"node_modules/counter-package/package.json":JSON.stringify({name:"counter-package",version:"1.0.0",main:"index.js",types:"index.d.ts"}),
"node_modules/counter-package/index.js":'import {useState,createElement} from "react"; export function Counter(){const [n,setN]=useState(0); return createElement("button",{onClick:()=>setN(n+1)},"Package count "+n)}',
"node_modules/counter-package/index.d.ts":'export function Counter(): import("react").ReactElement;',
}});
expect(result.diagnostics).toEqual([]); expect(result.ok).toBe(true);
const browser=await chromium.launch({headless:true});
try {const page=await browser.newPage();await page.setContent('<div id="root"></div>');await page.addScriptTag({type:"module",content:result.js!}); await page.getByRole("button",{name:"Package count 0"}).click();await page.getByRole("button",{name:"Package count 1"}).waitFor();}finally{await browser.close();}
expect(outbound).toEqual([]);
},60000);

test.skipIf(process.env.CANVAS_PACKAGE_INTEGRATION !== "1")("compiles an integrity-verified Hono dependency snapshot without compiler network",async()=>{
const {resolveProject}=await import("./project");
const project=await resolveProject({dependencies:{hono:"4.13.7"},files:{"lib/message.ts":"export const message = 'hello-locked-hono';"}});
const result=await callProject('import {Hono} from "hono"; import {message} from "./lib/message"; const app=new Hono(); app.get("/",c=>c.text(message)); export default app;',project,"compile-script");
expect(result.diagnostics).toEqual([]); expect(result.ok).toBe(true); expect(result.js).toContain("hello-locked-hono");
expect(outbound).toEqual([]);
},120000);
Loading
Loading