diff --git a/README.md b/README.md index 217ba9a..a245dc3 100644 --- a/README.md +++ b/README.md @@ -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 `/canvases/.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 `/canvases/.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 @@ -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. @@ -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. diff --git a/bun.lock b/bun.lock index 7854263..3bf5f07 100644 --- a/bun.lock +++ b/bun.lock @@ -13,6 +13,7 @@ "react": "^19.2.8", "react-dom": "^19.2.8", "react-router": "^7", + "semver": "7.7.3", "typescript": "5.9.3", }, "devDependencies": { @@ -21,6 +22,7 @@ "@cloudflare/workers-types": "5.20260907.1", "@modelcontextprotocol/sdk": "^1.30.0", "@types/bun": "^1.3.14", + "@types/semver": "7.7.1", "ajv": "8.20.0", "hono": "4.13.7", "jose": "6.2.12", @@ -224,6 +226,8 @@ "@types/react-dom": ["@types/react-dom@19.2.4", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-Bsc+QHgp+P/F02XDzNCY9jnZNCUuLki36KT7VKrTXXLdHf+vHMNZnW1rVu5DNW/rCK+fya3DATySbLM4yhtKUw=="], + "@types/semver": ["@types/semver@7.7.1", "", {}, "sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA=="], + "@typescript/vfs": ["@typescript/vfs@1.6.4", "", { "dependencies": { "debug": "^4.4.3" }, "peerDependencies": { "typescript": "*" } }, "sha512-PJFXFS4ZJKiJ9Qiuix6Dz/OwEIqHD7Dme1UwZhTK11vR+5dqW2ACbdndWQexBzCx+CPuMe5WBYQWCsFyGlQLlQ=="], "accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="], @@ -434,7 +438,7 @@ "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], - "semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], + "semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="], "send": ["send@1.2.1", "", { "dependencies": { "debug": "^4.4.3", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", "http-errors": "^2.0.1", "mime-types": "^3.0.2", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.2" } }, "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ=="], @@ -510,6 +514,8 @@ "zod-to-json-schema": ["zod-to-json-schema@3.25.2", "", { "peerDependencies": { "zod": "^3.25.28 || ^4" } }, "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA=="], + "@cloudflare/worker-bundler/semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], + "@cloudflare/worker-bundler/typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="], "@jridgewell/gen-mapping/@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], @@ -526,6 +532,8 @@ "router/path-to-regexp": ["path-to-regexp@8.4.2", "", {}, "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA=="], + "sharp/semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], + "type-is/content-type": ["content-type@2.1.0", "", {}, "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag=="], } } diff --git a/cloudflare/compiled-canvas.test.ts b/cloudflare/compiled-canvas.test.ts index a59d610..01d2525 100644 --- a/cloudflare/compiled-canvas.test.ts +++ b/cloudflare/compiled-canvas.test.ts @@ -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 []; } @@ -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 }); @@ -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); diff --git a/cloudflare/compiler.test.ts b/cloudflare/compiler.test.ts index 3401daa..5a1245c 100644 --- a/cloudflare/compiler.test.ts +++ b/cloudflare/compiler.test.ts @@ -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({ @@ -319,3 +319,50 @@ export default function Canvas() { return ; }`; } finally { await browser.close(); } expect(outbound).toEqual([]); }, 60000); + +async function callProject(source: string, project: object, operation = "compile"): Promise { + 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; +} +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
{greeting} {answer}
}'; + 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 }',{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('
');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); diff --git a/cloudflare/compiler.ts b/cloudflare/compiler.ts index 70ea822..40c8044 100644 --- a/cloudflare/compiler.ts +++ b/cloudflare/compiler.ts @@ -1,3 +1,4 @@ +import { emptyProject, normalizeProject, type ArtifactProject } from "./project"; import { createWorker } from "@cloudflare/worker-bundler"; import ts from "../dist/cloudflare/typescript.js"; import files from "../dist/cloudflare/compiler-files.json"; @@ -20,18 +21,26 @@ const options: ts.CompilerOptions = { }; const normalize = (path: string) => path.replace(/^\//, ""); let currentSource = ""; +let currentProject = emptyProject(); let serverMode = false; let scriptMode = false; let sourceVersion = 0; const serverOptions: ts.CompilerOptions = { ...options, paths: {}, lib: ["lib.es2022.d.ts"] }; -const read = (path: string) => normalize(path) === (serverMode ? serverPath : sourcePath) ? currentSource : compilerFiles[normalize(path)]; +const read = (path: string) => normalize(path) === (serverMode ? serverPath : sourcePath) ? currentSource : currentProject.files[normalize(path)] ?? currentProject.lock[normalize(path)] ?? compilerFiles[normalize(path)]; // A single bounded language-service project retains the immutable SDK/lib ASTs. // Typechecking is synchronous, so overlapping requests cannot observe another // source while checking. No per-user project/program cache accumulates here. const host: ts.LanguageServiceHost = { getCompilationSettings: () => scriptMode ? { ...serverOptions, noImplicitAny: false, types: ["node"] } : serverMode ? serverOptions : options, getScriptFileNames: () => serverMode ? [`/${serverPath}`, "/node_modules/@cloudflare/workers-types/index.d.ts"] : [`/${sourcePath}`], - getScriptVersion: path => [sourcePath, serverPath].includes(normalize(path)) ? String(sourceVersion) : "0", + getScriptVersion: path => { + const file = normalize(path); + // Keep immutable SDK/lib ASTs cached while invalidating every project + // module, including modules removed since the previous compilation. + return [sourcePath, serverPath].includes(file) || Object.hasOwn(currentProject.files, file) + || Object.hasOwn(currentProject.lock, file) || !Object.hasOwn(compilerFiles, file) + ? String(sourceVersion) : "0"; + }, getProjectVersion: () => String(sourceVersion), getScriptSnapshot: path => { const text = read(path); @@ -42,22 +51,58 @@ const host: ts.LanguageServiceHost = { getCurrentDirectory: () => "/", useCaseSensitiveFileNames: () => true, getNewLine: () => "\n", fileExists: path => read(path) !== undefined, readFile: read, - directoryExists: path => Object.keys(compilerFiles).some(key => key.startsWith(`${normalize(path).replace(/\/$/, "")}/`)), + directoryExists: path => { + const prefix = `${normalize(path).replace(/\/$/, "")}/`; + return [currentProject.files, currentProject.lock, compilerFiles].some(files => Object.keys(files).some(key => key.startsWith(prefix))); + }, getDirectories: () => [], }; let languageService: ts.LanguageService | undefined; -export function typecheckCanvasSource(source: string): Diagnostic[] { - const violations = scanCanvasSource(source, Object.keys(browserPlugins.paths)); - if (violations.length) return sandboxToDiagnostics(sourcePath, violations); - return typecheckSource(source, false); +export function typecheckCanvasSource(source: string, project = emptyProject()): Diagnostic[] { + const diagnostics: Diagnostic[] = []; + const reachable = new Map([[sourcePath, source]]); + for (const [path, code] of reachable) { + for (const imported of ts.preProcessFile(code, true, true).importedFiles) { + if (!imported.fileName.startsWith("./") && !imported.fileName.startsWith("../")) continue; + const parts = [...path.split("/").slice(0, -1), ...imported.fileName.split("/")]; + const resolved: string[] = []; + let escaped = false; + for (const part of parts) { + if (part === "." || part === "") continue; + if (part === "..") { if (!resolved.length) { escaped = true; break; } resolved.pop(); } + else resolved.push(part); + } + if (escaped) continue; // The virtual compiler reports the unresolved import. + const base = resolved.join("/"); + const candidates = [base, ...[".ts", ".tsx", ".mts", ".cts", ".js", ".jsx", ".mjs", ".cjs", ".json"].map(extension => base + extension), ...[".ts", ".tsx", ".js", ".jsx"].map(extension => `${base}/index${extension}`)]; + // Bundler-mode TS also permits .js import specifiers that point at .ts source. + if (/\.[cm]?jsx?$/.test(base)) candidates.push(base.replace(/\.[cm]?jsx?$/, ".ts"), base.replace(/\.[cm]?jsx?$/, ".tsx")); + const found = candidates.find(candidate => Object.hasOwn(project.files, candidate)); + if (found && !reachable.has(found)) reachable.set(found, project.files[found]!); + } + } + for (const [path, code] of reachable) { + if (path.endsWith(".json")) continue; + const imports: string[] = [...Object.keys(browserPlugins.paths), "@sidequery/canvas", "react", "react-dom", "react-dom/client", "react-router", "react/jsx-runtime", "react/jsx-dev-runtime"]; + // Permit local paths and declared package subpaths; the virtual compiler still resolves every import. + for (const match of code.matchAll(/(?:from\s*|import\s*)["']([^"']+)["']/g)) { + const specifier = match[1]!; + if (specifier.startsWith("./") || specifier.startsWith("../") || Object.keys(project.dependencies).some(name => specifier === name || specifier.startsWith(`${name}/`))) imports.push(specifier); + } + const violations = scanCanvasSource(code, imports).filter(item => path === sourcePath || item.kind !== "export"); + diagnostics.push(...sandboxToDiagnostics(path, violations)); + } + if (diagnostics.length) return diagnostics; + return typecheckSource(source, false, false, project); } -export function typecheckCanvasServerSource(source: string): Diagnostic[] { - return typecheckSource(source, true); +export function typecheckCanvasServerSource(source: string, project = emptyProject()): Diagnostic[] { + return typecheckSource(source, true, false, project); } -function typecheckSource(source: string, server: boolean, script = false): Diagnostic[] { +function typecheckSource(source: string, server: boolean, script = false, project = emptyProject()): Diagnostic[] { + currentProject = normalizeProject(project); scriptMode = script; serverMode = server; currentSource = source; @@ -126,20 +171,15 @@ let pendingCompilations = 0; let nextCompilation = 0; let activeCompilation = 0; -export async function compileCanvasSource(source: string) { - return queueCompile(source, false); +export async function compileCanvasSource(source: string, project = emptyProject()) { + return queueCompile(source, false, false, project); } -let lastServer: { source: string; result: Awaited> } | undefined; -export async function compileCanvasServerSource(source: string) { - if (lastServer?.source === source) return lastServer.result; - const result = await queueCompile(source, true); - // Share completed code, never another request's in-flight native operations. - if (result.ok) lastServer = { source, result }; - return result; +export async function compileCanvasServerSource(source: string, project = emptyProject()) { + return queueCompile(source, true, false, project); } -async function queueCompile(source: string, server: boolean, script = false) { +async function queueCompile(source: string, server: boolean, script = false, project = emptyProject()) { // esbuild WASM and semantic checking share the isolate's 128 MiB budget. // Serialize builds and bound retained request sources under load. if (pendingCompilations >= 8) return { ok: false, diagnostics: [{ severity: "error" as const, message: "Compiler is busy; retry shortly", file: server ? serverPath : sourcePath }] }; @@ -149,17 +189,18 @@ async function queueCompile(source: string, server: boolean, script = false) { // Wait on this request's own timer instead of another request's promise, so // disconnecting a preview cannot cancel the next build's esbuild operations. while (ticket !== activeCompilation) await new Promise(resolve => setTimeout(resolve, 5)); - try { return await (script ? compileScript(source) : server ? compileServer(source) : compileSource(source)); } + try { return await (script ? compileScript(source, project) : server ? compileServer(source, project) : compileSource(source, project)); } finally { activeCompilation++; pendingCompilations--; } } -async function compileServer(source: string) { - const diagnostics = typecheckCanvasServerSource(source); +async function compileServer(source: string, project: ArtifactProject) { + const diagnostics = typecheckCanvasServerSource(source, project); if (diagnostics.length) return { ok: false, diagnostics }; try { // celld 0.4.1 requires a default Worker export even when only the named // Durable Object class is used. Keep that packaging detail out of user code. const result = await createWorker({ files: { + ...project.lock, ...project.files, [serverPath]: source, "server-entry.ts": `export { CanvasServer } from "./${serverPath}"; export default { fetch() { return new Response("Not found", { status: 404 }); } };`, }, entryPoint: "server-entry.ts", target: "es2022", sourcemap: false }); @@ -172,13 +213,14 @@ async function compileServer(source: string) { } } -async function compileSource(source: string) { +async function compileSource(source: string, project: ArtifactProject) { const started = performance.now(); - const diagnostics = typecheckCanvasSource(source); + const diagnostics = typecheckCanvasSource(source, project); if (diagnostics.length) return { ok: false, diagnostics }; try { const result = await createWorker({ files: { + ...project.lock, ...project.files, [sourcePath]: source, "entry.ts": `import Canvas from "./${sourcePath}"; globalThis.__herdrCanvasRuntime.mount(Canvas);`, }, @@ -190,8 +232,8 @@ async function compileSource(source: string) { "@sidequery/canvas": browserRuntime.sdkModule, "herdr/canvas": browserRuntime.sdkModule, "cursor/canvas": browserRuntime.sdkModule, - "react/jsx-runtime": "export const { jsx, jsxs, Fragment } = globalThis.__herdrCanvasRuntime.jsx;", ...browserPlugins.modules, + ...browserRuntime.sharedModules, }, }); if (result.warnings?.length) throw new Error(result.warnings.join("\n")); @@ -215,11 +257,11 @@ async function compileSource(source: string) { } /** Scripts use Workers globals and can import supported builtins, without the canvas UI restrictions. */ -export function compileScriptSource(source: string) { return queueCompile(source, true, true); } +export function compileScriptSource(source: string, project = emptyProject()) { return queueCompile(source, true, true, project); } -async function compileScript(source: string) { +async function compileScript(source: string, project: ArtifactProject) { const declarations = '\ninterface ScriptEnv { secrets: Record; sql: SqlStorage }\ntype __CanvasScriptResult = Response | Promise;\n'; - const diagnostics = typecheckSource(source + declarations, true, true); + const diagnostics = typecheckSource(source + declarations, true, true, project); const program = languageService!.getProgram()!; const file = program.getSourceFiles().find(item => normalize(item.fileName) === serverPath)!; const checker = program.getTypeChecker(); @@ -235,7 +277,7 @@ async function compileScript(source: string) { } if (diagnostics.length) return {ok: false, diagnostics: diagnostics.map(item => ({...item, file: item.file === serverPath ? "script.ts" : item.file}))}; try { - const result = await createWorker({ files: { "script.ts": source }, entryPoint: "script.ts", target: "es2022", sourcemap: false }); + const result = await createWorker({ files: { ...project.lock, ...project.files, "script.ts": source }, entryPoint: "script.ts", target: "es2022", sourcemap: false }); if (result.warnings?.length) throw new Error(result.warnings.join("\n")); const js = result.modules[result.mainModule]; if (typeof js !== "string") throw new Error("Script compiler did not return JavaScript"); diff --git a/cloudflare/dev.ts b/cloudflare/dev.ts index 4fbbf24..761db9e 100644 --- a/cloudflare/dev.ts +++ b/cloudflare/dev.ts @@ -1,3 +1,4 @@ +import { normalizeProject, emptyProject } from "./project"; import { compileCanvasSource, compileCanvasServerSource, compileScriptSource, typecheckCanvasSource, typecheckCanvasServerSource } from "./compiler"; import { readRequestText } from "./http"; import type { ExecutionContext } from "@cloudflare/workers-types"; @@ -10,16 +11,21 @@ export default { if (request.method === "GET" && url.pathname === "/health") return Response.json({ ok: true, runtime: navigator.userAgent }); if (request.method !== "POST" || !["/compile", "/compile-server", "/compile-script", "/typecheck", "/typecheck-server"].includes(url.pathname)) return new Response("Not found", { status: 404 }); let source: string; - try { source = await readRequestText(request, 256 * 1024); } + let project = emptyProject(); + try { source = await readRequestText(request, request.headers.get("content-type")?.includes("application/json") ? 10 * 1024 * 1024 : 256 * 1024); } catch (error) { if (error instanceof RangeError) return new Response("Canvas source exceeds 256 KiB", { status: 413 }); throw error; } - const compilation = url.pathname === "/compile-script" ? compileScriptSource(source) - : url.pathname === "/compile-server" ? compileCanvasServerSource(source) - : url.pathname === "/compile" ? compileCanvasSource(source) : undefined; + if (request.headers.get("content-type")?.includes("application/json")) { + const input = JSON.parse(source); + source = input.source; project = normalizeProject(input.project); + } + const compilation = url.pathname === "/compile-script" ? compileScriptSource(source, project) + : url.pathname === "/compile-server" ? compileCanvasServerSource(source, project) + : url.pathname === "/compile" ? compileCanvasSource(source, project) : undefined; if (compilation) ctx.waitUntil(compilation); return Response.json(compilation ? await compilation - : { diagnostics: url.pathname === "/typecheck-server" ? typecheckCanvasServerSource(source) : typecheckCanvasSource(source) }); + : { diagnostics: url.pathname === "/typecheck-server" ? typecheckCanvasServerSource(source, project) : typecheckCanvasSource(source, project) }); }, }; diff --git a/cloudflare/library.test.ts b/cloudflare/library.test.ts index d609924..91fbdf2 100644 --- a/cloudflare/library.test.ts +++ b/cloudflare/library.test.ts @@ -318,12 +318,14 @@ test("constructor migrates legacy draft and version tables without losing client await storage.exec("alter table drafts drop column server_source"); await storage.exec("alter table versions drop column server_source"); await storage.exec("alter table versions drop column compiled_id"); + await storage.exec("alter table drafts drop column project"); + await storage.exec("alter table versions drop column project"); await runtime.unsafeEvictDurableObject("canvas-library-test", "CanvasLibrary", { name: library }); const draft = await call<{ source: string; server_source: string | null }>("preview", { workspace: "legacy", name: "overview" }, library); const version = await call<{ source: string; server_source: string | null }>("version", { workspace: "legacy", id: served.version.id }, library); - expect(draft).toMatchObject({ source, server_source: null }); - expect(version).toMatchObject({ source, server_source: null }); + expect(draft).toMatchObject({ source, server_source: null, project: { files: {}, dependencies: {}, lock: {} } }); + expect(version).toMatchObject({ source, server_source: null, project: { files: {}, dependencies: {}, lock: {} } }); const draftColumns = await storage.exec<{ name: string }>("pragma table_info(drafts)"); const versionColumns = await storage.exec<{ name: string }>("pragma table_info(versions)"); expect(draftColumns.map(column => column.name)).toContain("server_source"); @@ -425,3 +427,111 @@ test("remix captures source pairs and provenance with fresh state and rejects co await fails("remix",{workspace:"other",version_id:served.version.id,new_name:"foreign",runtime:"test"},"not found"); await fails("remix",{workspace,version_id:served.version.id,new_name:"foreign",runtime:"test"},"not found","bob"); }); + +test("projects persist through targeted edits, immutable versions, preview, and restore", async () => { + const identity = { workspace: "projects", name: "modules" }; + const project = { files: { "helper.ts": "export const answer = 1;", "other.ts": "unchanged" }, dependencies: { "lodash-es": "4.17.21" }, lock: { "node_modules/lodash-es/index.js": "export const version = 1;" } }; + const source = "client source\n"; + const written = await call("writeDraft", { ...identity, source, server_source: "server", project }); + expect(written.project).toEqual(project); + const omitted = await call("writeDraft", { ...identity, source }); + expect(omitted.project).toEqual(project); + const serve = { ...identity, source, server_source: "server", project, runtime: "sdk", initial_state: {}, mode: "live" }; + const first = await call("recordServe", serve); + expect((await call("recordServe", serve)).version.id).toBe(first.version.id); + const read = await call("readRange", { ...identity, file: "helper.ts" }); + expect(read.source).toBe(project.files["helper.ts"]); + const edited = await call("editDraft", { ...identity, file: "helper.ts", expected_hash: read.source_hash, edits: [{ old_text: "1", new_text: "2" }] }); + expect(edited.source).toBe(source); + expect(edited.server_source).toBe("server"); + expect(edited.project).toEqual({ ...project, files: { ...project.files, "helper.ts": "export const answer = 2;" } }); + await fails("editDraft", { ...identity, file: "helper.ts", expected_hash: read.source_hash, edits: [{ old_text: "2", new_text: "3" }] }, "changed since read"); + await fails("editDraft", { ...identity, file: "helper.ts", edits: [{ old_text: "2", new_text: "3" }, { old_text: "missing", new_text: "x" }] }, "no changes written"); + await fails("readRange", { ...identity, file: "missing.ts" }, "file not found"); + await fails("editDraft", { ...identity, file: "missing.ts", edits: [{ old_text: "x", new_text: "y" }] }, "file not found"); + expect((await call("preview", identity)).project).toEqual(edited.project); + const newer = await call("recordServe", { ...serve, project: edited.project }); + expect(newer.version.revision).toBe(2); + expect(newer.version.source_hash).toBe(first.version.source_hash); + expect((await call("version", { workspace: identity.workspace, id: first.version.id })).project).toEqual(project); + await fails("recordServe", { ...serve, project: edited.project, version_id: first.version.id }, "does not match"); + expect((await call("preview", { workspace: identity.workspace, version_id: first.version.id })).project).toEqual(project); + const restored = await call("restore", { workspace: identity.workspace, id: first.version.id, runtime: "sdk" }); + expect(restored.project).toEqual(project); + const empty = { files: {}, dependencies: {}, lock: {} }; + expect((await call("writeDraft", { ...identity, source, project: empty })).project).toEqual(empty); +}); + +test("large project snapshots round-trip through chunked drafts, edits, history, and restore", async () => { + const library = "large-project-chunks"; + const identity = { workspace: "chunks", name: "large" }; + const project = { files: { "helper.ts": "export const value = 1;" }, dependencies: { example: "1.0.0" }, lock: { "node_modules/example/index.js": `export const text = "${"😀漢字".repeat(240000)}";` } }; + expect(new TextEncoder().encode(JSON.stringify(project)).byteLength).toBeGreaterThan(2 * 1024 * 1024); + const source = "client\n"; + expect((await call("writeDraft", { ...identity, source, project }, library)).project).toEqual(project); + const served = await call("recordServe", { ...identity, source, project, runtime: "sdk", mode: "live", initial_state: {} }, library); + const storage = await runtime.unsafeGetDurableObjectStorage("canvas-library-test", "CanvasLibrary", { name: library }); + const before = await storage.exec<{n: number; largest: number}>("select count(*) as n,max(length(cast(chunk as blob))) as largest from project_chunks"); + expect(before[0]!.n).toBeGreaterThan(1); + expect(before[0]!.largest).toBeLessThanOrEqual(512 * 1024); + await call("writeDraft", { ...identity, source }, library); + const edited = await call("editDraft", { ...identity, file: "helper.ts", edits: [{ old_text: "1", new_text: "2" }] }, library); + expect(edited.project.lock).toEqual(project.lock); + expect((await call("readRange", identity, library)).project.files["helper.ts"]).toContain("2"); + expect((await call("version", { workspace: identity.workspace, id: served.version.id }, library)).project).toEqual(project); + expect((await call("restore", { workspace: identity.workspace, id: served.version.id, runtime: "sdk" }, library)).project).toEqual(project); + await runtime.unsafeEvictDurableObject("canvas-library-test", "CanvasLibrary", { name: library }); + expect((await call("preview", identity, library)).project).toEqual(project); + const after = await storage.exec<{n: number}>("select count(*) as n from project_chunks"); + expect(after[0]!.n).toBeLessThanOrEqual(before[0]!.n + 1); +}); + +test("inline project snapshots remain readable after upgrading to chunk storage", async () => { + const library = "inline-project-upgrade", identity = { workspace: "upgrade", name: "canvas" }; + const project = { files: { "helper.ts": "original" }, dependencies: {}, lock: {} }; + await call("writeDraft", { ...identity, source: "client", project }, library); + const served = await call("recordServe", { ...identity, source: "client", project, runtime: "sdk", mode: "live", initial_state: {} }, library); + const storage = await runtime.unsafeGetDurableObjectStorage("canvas-library-test", "CanvasLibrary", { name: library }); + await storage.exec("update drafts set project = ?", JSON.stringify(project)); + await storage.exec("update versions set project = ?", JSON.stringify(project)); + await runtime.unsafeEvictDurableObject("canvas-library-test", "CanvasLibrary", { name: library }); + expect((await call("preview", identity, library)).project).toEqual(project); + expect((await call("version", { workspace: identity.workspace, id: served.version.id }, library)).project).toEqual(project); + const edited = await call("editDraft", { ...identity, file: "helper.ts", edits: [{ old_text: "original", new_text: "changed" }] }, library); + expect(edited.project.files["helper.ts"]).toBe("changed"); + expect((await call("restore", { workspace: identity.workspace, id: served.version.id, runtime: "sdk" }, library)).project).toEqual(project); +}); + +test("compiled identity includes normalized project files, dependencies and lock without changing legacy bundles", async () => { + const library = "compiled-projects", identity = { workspace: "projects", name: "compiled" }; + const input = { ...identity, source: "same source\n", server_source: null, runtime: "sdk", client_js: "original", server_js: null }; + const empty = { files: {}, dependencies: {}, lock: {} }; + const legacy = await call("saveCompiled", input, library); + expect(legacy.id).toBe(createHash("sha256").update(JSON.stringify([input.source, null, input.runtime])).digest("hex")); + const storage = await runtime.unsafeGetDurableObjectStorage("canvas-library-test", "CanvasLibrary", { name: library }); + await storage.exec("alter table compiled_canvases drop column project_hash"); + await runtime.unsafeEvictDurableObject("canvas-library-test", "CanvasLibrary", { name: library }); + expect(await call("compiled", { ...input, project: empty }, library)).toEqual(legacy); + const project = { files: { "b.ts": "b", "a.ts": "a" }, dependencies: { example: "1.0.0" }, lock: { "node_modules/example/index.js": "one" } }; + expect(await call("compiled", { ...input, project }, library)).toBeNull(); + const original = await call("saveCompiled", { ...input, project, client_js: "project original" }, library); + expect(await call("compiled", { ...input, project: { ...project, files: { "a.ts": "a", "b.ts": "b" } } }, library)).toEqual(original); + const serve = { ...input, project, compiled_id: original.id, mode: "live", initial_state: {} }; + const archived = await call("recordServe", serve, library); + for (const changed of [ + { ...project, files: { ...project.files, "a.ts": "changed" } }, + { ...project, dependencies: { example: "2.0.0" } }, + { ...project, lock: { "node_modules/example/index.js": "two" } }, + ]) { + expect(await call("compiled", { ...input, project: changed }, library)).toBeNull(); + const compiled = await call("saveCompiled", { ...input, project: changed, client_js: "changed" }, library); + expect(compiled.id).not.toBe(original.id); + await fails("recordServe", { ...serve, project: changed }, "does not match", library); + await fails("attachCompiled", { ...identity, version_id: archived.version.id, compiled_id: compiled.id }, "does not match", library); + const next = await call("recordServe", { ...serve, project: changed, compiled_id: compiled.id }, library); + expect(next.version.id).not.toBe(archived.version.id); + } + expect(await call("preview", { workspace: identity.workspace, version_id: archived.version.id }, library)).toMatchObject({ project, compiled_id: original.id }); + expect(await call("compiled", { ...identity, id: original.id }, library)).toEqual(original); + expect(await call("compiled", { ...identity, id: original.id, project: empty }, library)).toBeNull(); +}); diff --git a/cloudflare/library.ts b/cloudflare/library.ts index 5bc5478..390e69c 100644 --- a/cloudflare/library.ts +++ b/cloudflare/library.ts @@ -1,5 +1,7 @@ import { DurableObject } from "cloudflare:workers"; import { createHash } from "node:crypto"; +import { ProjectStorage } from "./project-storage"; +import { emptyProject, normalizeProject, type ArtifactProject } from "./project"; import type { HistoryEntry, ServeEvent, Version } from "../src/historyTypes"; export const MAX_SOURCE_BYTES = 256 * 1024; @@ -11,12 +13,19 @@ export type CanvasEdit = { old_text: string; new_text: string }; type DraftRow = { workspace: string; name: string; source: string; server_source: string | null; source_hash: string; - state: string; created_at: string; updated_at: string; + project: string; state: string; created_at: string; updated_at: string; }; type DraftListRow = Pick; -type StoredVersion = Version & { server_source: string | null; compiled_id: string | null }; +type StoredVersion = Version & { server_source: string | null; compiled_id: string | null; project: ArtifactProject }; export type CompiledCanvas = { id: string; runtime: string; client_js: string; server_js: string | null }; -type CompiledRow = { id: string; runtime: string; source_hash: string; server_hash: string | null }; +type CompiledRow = { id: string; runtime: string; source_hash: string; server_hash: string | null; project_hash: string }; +type VersionRow = Omit & { project: string }; +function projectFile(project: ArtifactProject, file: string): string { + if (!Object.hasOwn(project.files, file)) throw new Error("project file not found"); + return project.files[file]!; +} + +function projectHash(project?: ArtifactProject): string { return sha256(JSON.stringify(normalizeProject(project))); } function bytes(value: string): number { return encoder.encode(value).byteLength; } @@ -84,11 +93,13 @@ export function sha256(value: string): string { return createHash("sha256").upda export class CanvasLibrary extends DurableObject { private readonly state: DurableObjectState; private readonly sql: SqlStorage; + private readonly projectStorage: ProjectStorage; constructor(state: DurableObjectState, env: unknown) { super(state, env); this.state = state; this.sql = state.storage.sql; + this.projectStorage = new ProjectStorage(this.sql); state.storage.transactionSync(() => { this.sql.exec("pragma foreign_keys = on"); this.sql.exec(`create table if not exists drafts ( @@ -122,6 +133,9 @@ export class CanvasLibrary extends DurableObject { workspace text not null, name text not null, id text not null, runtime text not null, source_hash text not null, server_hash text, primary key(workspace,name,id) )`); + if (!rows(this.sql.exec<{ name: string }>("pragma table_info(compiled_canvases)")).some(column => column.name === "project_hash")) { + this.sql.exec(`alter table compiled_canvases add column project_hash text not null default '${projectHash()}'`); + } this.sql.exec(`create table if not exists compiled_canvas_chunks ( workspace text not null, name text not null, compiled_id text not null, part text not null, ordinal integer not null, code text not null, @@ -129,25 +143,33 @@ export class CanvasLibrary extends DurableObject { foreign key(workspace,name,compiled_id) references compiled_canvases(workspace,name,id) )`); this.sql.exec("create table if not exists remix_origins (workspace text not null, name text not null, source_name text not null, source_version_id text not null references versions(id), primary key(workspace,name))"); + for (const table of ["drafts", "versions"]) { + if (!rows(this.sql.exec<{ name: string }>(`pragma table_info(${table})`)).some(column => column.name === "project")) { + this.sql.exec(`alter table ${table} add column project text not null default '{"files":{},"dependencies":{},"lock":{}}'`); + } + } this.sql.exec("create index if not exists serve_events_version on serve_events(version_id, served_at)"); }); } - saveCompiled(input: { workspace: string; name: string; source: string; server_source: string | null; runtime: string; client_js: string; server_js: string | null }): CompiledCanvas { + saveCompiled(input: { workspace: string; name: string; source: string; server_source: string | null; runtime: string; client_js: string; server_js: string | null; project?: ArtifactProject }): CompiledCanvas { const workspace = workspaceName(input.workspace), name = canvasName(input.name); const source = sourceText(input.source), server = input.server_source === null ? null : sourceText(input.server_source); const runtime = runtimeName(input.runtime); if (typeof input.client_js !== "string" || !input.client_js || (server === null ? input.server_js !== null : typeof input.server_js !== "string" || !input.server_js)) throw new Error("invalid compiled canvas output"); - const id = sha256(JSON.stringify([source, server, runtime])); + const project = normalizeProject(input.project); + const project_hash = projectHash(project); + // Preserve IDs of bundles written before projects were supported. + const id = sha256(JSON.stringify(project_hash === projectHash() ? [source, server, runtime] : [source, server, runtime, project])); return this.state.storage.transactionSync(() => { const existing = this.compiled({ workspace, name, id }); if (existing) { if (existing.client_js !== input.client_js || existing.server_js !== input.server_js) throw new Error("compiled canvas output is immutable"); return existing; } - this.sql.exec("insert into compiled_canvases(workspace,name,id,runtime,source_hash,server_hash) values(?,?,?,?,?,?)", - workspace, name, id, runtime, sha256(source), server === null ? null : sha256(server)); + this.sql.exec("insert into compiled_canvases(workspace,name,id,runtime,source_hash,server_hash,project_hash) values(?,?,?,?,?,?,?)", + workspace, name, id, runtime, sha256(source), server === null ? null : sha256(server), project_hash); // Keep every SQLite row small, without splitting a Unicode surrogate pair. for (const [part, code] of [["client", input.client_js], ["server", input.server_js]] as const) { if (code === null) continue; @@ -164,7 +186,7 @@ export class CanvasLibrary extends DurableObject { }); } - compiled(input: { workspace: string; name: string; id?: string; source?: string; server_source?: string | null; runtime?: string }): CompiledCanvas | null { + compiled(input: { workspace: string; name: string; id?: string; source?: string; server_source?: string | null; runtime?: string; project?: ArtifactProject }): CompiledCanvas | null { const workspace = workspaceName(input.workspace), name = canvasName(input.name); let metadata: CompiledRow | null; if (input.id !== undefined) { @@ -173,10 +195,10 @@ export class CanvasLibrary extends DurableObject { const source = sourceText(input.source!); const server = input.server_source == null ? null : sourceText(input.server_source); metadata = first(this.sql.exec(`select * from compiled_canvases where workspace=? and name=? and source_hash=? and server_hash is ? - and (? is null or runtime=?) order by rowid desc limit 1`, workspace, name, sha256(source), server === null ? null : sha256(server), + and project_hash=? and (? is null or runtime=?) order by rowid desc limit 1`, workspace, name, sha256(source), server === null ? null : sha256(server), projectHash(input.project), input.runtime === undefined ? null : runtimeName(input.runtime), input.runtime ?? null)); } - if (!metadata) return null; + if (!metadata || (input.project !== undefined && metadata.project_hash !== projectHash(input.project))) return null; const chunks = rows(this.sql.exec<{ part: string; code: string }>("select part,code from compiled_canvas_chunks where workspace=? and name=? and compiled_id=? order by part,ordinal", workspace, name, metadata.id)); return { id: metadata.id, runtime: metadata.runtime, client_js: chunks.filter(chunk => chunk.part === "client").map(chunk => chunk.code).join(""), @@ -195,7 +217,7 @@ export class CanvasLibrary extends DurableObject { })); } - writeDraft(input: { workspace: string; name: string; source: string; server_source?: string | null }) { + writeDraft(input: { workspace: string; name: string; source: string; server_source?: string | null; project?: ArtifactProject }) { const workspace = workspaceName(input.workspace); const name = canvasName(input.name); const source = sourceText(input.source, true); const source_hash = sha256(source); const timestamp = now(); const requestedServer = input.server_source === undefined ? undefined @@ -203,14 +225,15 @@ export class CanvasLibrary extends DurableObject { return this.state.storage.transactionSync(() => { const current = first(this.sql.exec("select * from drafts where workspace = ? and name = ?", workspace, name)); const server_source = requestedServer === undefined ? current?.server_source ?? null : requestedServer; - this.sql.exec(`insert into drafts (workspace,name,source,server_source,source_hash,state,created_at,updated_at) - values (?, ?, ?, ?, ?, '{}', ?, ?) + const project = input.project === undefined ? current?.project ?? this.projectStorage.encode(emptyProject()) : this.projectStorage.encode(input.project); + this.sql.exec(`insert into drafts (workspace,name,source,server_source,source_hash,project,state,created_at,updated_at) + values (?, ?, ?, ?, ?, ?, '{}', ?, ?) on conflict(workspace,name) do update set source=excluded.source, server_source=excluded.server_source, - source_hash=excluded.source_hash, updated_at=excluded.updated_at`, - workspace, name, source, server_source, source_hash, timestamp, timestamp); + source_hash=excluded.source_hash, project=excluded.project, updated_at=excluded.updated_at`, + workspace, name, source, server_source, source_hash, project, timestamp, timestamp); const saved = this.draft(workspace, name); return { ok: true, path: sourcePath(workspace, name), workspace, name, source, server_source: saved.server_source, - source_hash, state: JSON.parse(saved.state) as Record }; + source_hash, project: this.projectStorage.read(saved.project), state: JSON.parse(saved.state) as Record }; }); } @@ -226,24 +249,25 @@ export class CanvasLibrary extends DurableObject { origin = version; } else { const draft = this.draft(workspace, canvasName(input.name)); - origin = this.capture(workspace, draft.name, draft.source, draft.server_source, runtime, "remix-source", null, false); + origin = this.capture(workspace, draft.name, draft.source, draft.server_source, this.projectStorage.read(draft.project), runtime, "remix-source", null, false); } const timestamp = now(); // Copy the immutable source pair only. State and backend identity start fresh. - this.sql.exec("insert into drafts(workspace,name,source,server_source,source_hash,state,created_at,updated_at) values(?,?,?,?,?,'{}',?,?)", workspace, name, origin.source, origin.server_source, origin.source_hash, timestamp, timestamp); + this.sql.exec("insert into drafts(workspace,name,source,server_source,source_hash,project,state,created_at,updated_at) values(?,?,?,?,?,?,'{}',?,?)", workspace, name, origin.source, origin.server_source, sha256(origin.source), this.projectStorage.encode(origin.project), timestamp, timestamp); this.sql.exec("insert into remix_origins values(?,?,?,?)", workspace, name, origin.name, origin.id); - const version = this.capture(workspace, name, origin.source, origin.server_source, runtime, "remix", null, true); - return { ok: true, remixed: true, workspace, name, path: sourcePath(workspace,name), source: origin.source, server_source: origin.server_source, source_hash: origin.source_hash, state: {}, versionId: version.id, revision: version.revision, origin: { source_name: origin.name, source_version_id: origin.id } }; + const version = this.capture(workspace, name, origin.source, origin.server_source, origin.project, runtime, "remix", null, true); + return { ok: true, remixed: true, workspace, name, path: sourcePath(workspace,name), source: origin.source, server_source: origin.server_source, project: origin.project, source_hash: sha256(origin.source), state: {}, versionId: version.id, revision: version.revision, origin: { source_name: origin.name, source_version_id: origin.id } }; }); } - readRange(input: { workspace: string; name: string; part?: "client" | "server"; start_line?: number; end_line?: number }) { + readRange(input: { workspace: string; name: string; part?: "client" | "server"; file?: string; start_line?: number; end_line?: number }) { const workspace = workspaceName(input.workspace); const name = canvasName(input.name); const row = this.draft(workspace, name); const part = input.part ?? "client"; if (part !== "client" && part !== "server") throw new Error("part must be client or server"); - if (part === "server" && row.server_source === null) throw new Error("canvas has no server source"); - const selected = part === "client" ? row.source : row.server_source!; + if (input.file === undefined && part === "server" && row.server_source === null) throw new Error("canvas has no server source"); + const project = this.projectStorage.read(row.project); + const selected = input.file === undefined ? (part === "client" ? row.source : row.server_source!) : projectFile(project, input.file); const lines = selected === "" ? [""] : selected.match(/[^\n]*\n|[^\n]+$/g) ?? []; const start = input.start_line === undefined ? 1 : input.start_line; const end = input.end_line === undefined ? start + 199 : input.end_line; @@ -253,15 +277,15 @@ export class CanvasLibrary extends DurableObject { if (start > Math.max(1, lines.length)) throw new Error("start_line is past the end of the canvas"); const actualEnd = Math.min(end, lines.length); return { - path: part === "client" ? sourcePath(workspace, name) : serverSourcePath(workspace, name), - part, source_hash: part === "client" ? row.source_hash : sha256(selected), + path: input.file ?? (part === "client" ? sourcePath(workspace, name) : serverSourcePath(workspace, name)), + part, file: input.file, project, source_hash: input.file === undefined && part === "client" ? row.source_hash : sha256(selected), total_lines: lines.length, start_line: start, end_line: actualEnd, source: lines.slice(start - 1, actualEnd).join(""), next_line: actualEnd < lines.length ? actualEnd + 1 : null, }; } - editDraft(input: { workspace: string; name: string; part?: "client" | "server"; edits: CanvasEdit[]; expected_hash?: string }) { + editDraft(input: { workspace: string; name: string; part?: "client" | "server"; file?: string; edits: CanvasEdit[]; expected_hash?: string }) { const workspace = workspaceName(input.workspace); const name = canvasName(input.name); const part = input.part ?? "client"; if (part !== "client" && part !== "server") throw new Error("part must be client or server"); @@ -271,9 +295,10 @@ export class CanvasLibrary extends DurableObject { } return this.state.storage.transactionSync(() => { const original = this.draft(workspace, name); - if (part === "server" && original.server_source === null) throw new Error("canvas has no server source"); - const originalSelected = part === "client" ? original.source : original.server_source!; - const originalHash = part === "client" ? original.source_hash : sha256(originalSelected); + if (input.file === undefined && part === "server" && original.server_source === null) throw new Error("canvas has no server source"); + let project = this.projectStorage.read(original.project); + const originalSelected = input.file === undefined ? (part === "client" ? original.source : original.server_source!) : projectFile(project, input.file); + const originalHash = input.file === undefined && part === "client" ? original.source_hash : sha256(originalSelected); if (input.expected_hash !== undefined && originalHash !== input.expected_hash) { throw new Error("canvas changed since read; read it again before editing"); } @@ -289,11 +314,15 @@ export class CanvasLibrary extends DurableObject { } sourceText(selected); const changed = selected !== originalSelected; const source_hash = sha256(selected); - if (changed && part === "client") this.sql.exec("update drafts set source = ?, source_hash = ?, updated_at = ? where workspace = ? and name = ?", selected, source_hash, now(), workspace, name); - if (changed && part === "server") this.sql.exec("update drafts set server_source = ?, updated_at = ? where workspace = ? and name = ?", selected, now(), workspace, name); - return { ok: true, applied: true, changed, path: part === "client" ? sourcePath(workspace, name) : serverSourcePath(workspace, name), - workspace, name, source: part === "client" ? selected : original.source, - server_source: part === "server" ? selected : original.server_source, source_part: part, + if (changed && input.file === undefined && part === "client") this.sql.exec("update drafts set source = ?, source_hash = ?, updated_at = ? where workspace = ? and name = ?", selected, source_hash, now(), workspace, name); + if (changed && input.file === undefined && part === "server") this.sql.exec("update drafts set server_source = ?, updated_at = ? where workspace = ? and name = ?", selected, now(), workspace, name); + if (changed && input.file !== undefined) { + project = normalizeProject({ ...project, files: { ...project.files, [input.file]: selected } }); + this.sql.exec("update drafts set project = ?, updated_at = ? where workspace = ? and name = ?", this.projectStorage.encode(project), now(), workspace, name); + } + return { ok: true, applied: true, changed, project, file: input.file, path: input.file ?? (part === "client" ? sourcePath(workspace, name) : serverSourcePath(workspace, name)), + workspace, name, source: input.file === undefined && part === "client" ? selected : original.source, + server_source: input.file === undefined && part === "server" ? selected : original.server_source, source_part: part, state: JSON.parse(original.state) as Record, edits_applied: input.edits.length, source_hash }; }); } @@ -320,7 +349,7 @@ export class CanvasLibrary extends DurableObject { return this.state.storage.transactionSync(() => { const version = this.findVersion(workspace, input.version_id); const compiled = first(this.sql.exec("select * from compiled_canvases where workspace=? and name=? and id=?", workspace, name, input.compiled_id)); - if (!version || version.name !== name || !compiled || compiled.source_hash !== sha256(version.source) || compiled.server_hash !== (version.server_source === null ? null : sha256(version.server_source))) { + if (!version || version.name !== name || !compiled || compiled.project_hash !== projectHash(version.project) || compiled.source_hash !== sha256(version.source) || compiled.server_hash !== (version.server_source === null ? null : sha256(version.server_source))) { throw new Error("compiled canvas does not match version in this workspace"); } if (version.compiled_id !== null && version.compiled_id !== input.compiled_id) throw new Error("version compiled canvas is immutable"); @@ -330,13 +359,14 @@ export class CanvasLibrary extends DurableObject { } recordServe(input: { - workspace: string; name: string; source: string; server_source?: string | null; runtime: string; + workspace: string; name: string; source: string; server_source?: string | null; project?: ArtifactProject; runtime: string; initial_state: Record; mode: "live" | "replay" | "preview"; pane_id?: string; session_id?: string; version_id?: string; compiled_id?: string; }) { const workspace = workspaceName(input.workspace); const name = canvasName(input.name); const source = sourceText(input.source); const runtime = runtimeName(input.runtime); const server_source = input.server_source == null ? null : sourceText(input.server_source); + const project = input.project === undefined ? emptyProject() : normalizeProject(input.project); const initial_state = jsonState(input.initial_state); if (!(["live", "replay", "preview"] as const).includes(input.mode)) throw new Error("invalid serve mode"); for (const [label, value] of [["pane_id", input.pane_id], ["session_id", input.session_id]] as const) { @@ -346,13 +376,13 @@ export class CanvasLibrary extends DurableObject { let version: StoredVersion; if (input.compiled_id !== undefined) { const compiled = first(this.sql.exec("select * from compiled_canvases where workspace=? and name=? and id=?", workspace, name, input.compiled_id)); - if (!compiled || compiled.source_hash !== sha256(source) || compiled.server_hash !== (server_source === null ? null : sha256(server_source)) || compiled.runtime !== runtime) { + if (!compiled || compiled.project_hash !== projectHash(project) || compiled.source_hash !== sha256(source) || compiled.server_hash !== (server_source === null ? null : sha256(server_source)) || compiled.runtime !== runtime) { throw new Error("compiled canvas does not match serve snapshot"); } } if (input.version_id !== undefined) { const archived = this.findVersion(workspace, input.version_id); - if (!archived || archived.name !== name || archived.source !== source || archived.server_source !== server_source) { + if (!archived || archived.name !== name || archived.source !== source || archived.server_source !== server_source || JSON.stringify(archived.project) !== JSON.stringify(project)) { throw new Error("archived serve snapshot does not match version in this workspace"); } if (input.compiled_id !== undefined) { @@ -362,7 +392,7 @@ export class CanvasLibrary extends DurableObject { } version = archived; } else { - version = this.capture(workspace, name, source, server_source, runtime, "served", null, false, input.compiled_id); + version = this.capture(workspace, name, source, server_source, project, runtime, "served", null, false, input.compiled_id); } const event: ServeEvent = { id: uuid(), version_id: version.id, served_at: now(), pane_id: input.pane_id ?? null, @@ -407,17 +437,17 @@ export class CanvasLibrary extends DurableObject { const target = this.findVersion(workspace, input.id); if (!target) throw new Error("version not found in this workspace"); const current = first(this.sql.exec("select * from drafts where workspace = ? and name = ?", workspace, target.name)); - if (current) this.capture(workspace, target.name, current.source, current.server_source, runtime, "before-restore", null, false); + if (current) this.capture(workspace, target.name, current.source, current.server_source, this.projectStorage.read(current.project), runtime, "before-restore", null, false); const timestamp = now(); - this.sql.exec(`insert into drafts (workspace,name,source,server_source,source_hash,state,created_at,updated_at) - values (?, ?, ?, ?, ?, '{}', ?, ?) + this.sql.exec(`insert into drafts (workspace,name,source,server_source,source_hash,project,state,created_at,updated_at) + values (?, ?, ?, ?, ?, ?, '{}', ?, ?) on conflict(workspace,name) do update set source=excluded.source, server_source=excluded.server_source, - source_hash=excluded.source_hash, updated_at=excluded.updated_at`, - workspace, target.name, target.source, target.server_source, target.source_hash, timestamp, timestamp); - const revision = this.capture(workspace, target.name, target.source, target.server_source, runtime, "restore", target.id, true); + source_hash=excluded.source_hash, project=excluded.project, updated_at=excluded.updated_at`, + workspace, target.name, target.source, target.server_source, target.source_hash, this.projectStorage.encode(target.project), timestamp, timestamp); + const revision = this.capture(workspace, target.name, target.source, target.server_source, target.project, runtime, "restore", target.id, true); const restored = this.draft(workspace, target.name); return { ok: true, restored: true, path: sourcePath(workspace, target.name), workspace, name: target.name, - source: target.source, server_source: target.server_source, source_hash: target.source_hash, + source: target.source, server_source: target.server_source, project: target.project, source_hash: target.source_hash, state: JSON.parse(restored.state) as Record, versionId: revision.id, revision: revision.revision }; }); } @@ -428,7 +458,7 @@ export class CanvasLibrary extends DurableObject { if (input.name) { if (input.event_id !== undefined) throw new Error("event_id requires version_id"); const name = canvasName(input.name); const draft = this.draft(workspace, name); - return { workspace, name, path: sourcePath(workspace, name), source: draft.source, server_source: draft.server_source, + return { workspace, name, path: sourcePath(workspace, name), source: draft.source, server_source: draft.server_source, project: this.projectStorage.read(draft.project), state: JSON.parse(draft.state) as Record, version_id: null, event_id: null, compiled_id: null }; } const version = this.findVersion(workspace, input.version_id!); @@ -437,7 +467,7 @@ export class CanvasLibrary extends DurableObject { ? first(this.sql.exec("select * from serve_events where id = ? and version_id = ?", input.event_id, version.id)) : first(this.sql.exec("select * from serve_events where version_id = ? order by (mode = 'live') desc, rowid desc limit 1", version.id)); if (input.event_id && !event) throw new Error("serve event not found for version"); - return { workspace, name: version.name, path: version.source_path, source: version.source, server_source: version.server_source, + return { workspace, name: version.name, path: version.source_path, source: version.source, server_source: version.server_source, project: version.project, state: event ? JSON.parse(event.initial_state) as Record : {}, version_id: version.id, event_id: event?.id ?? null, compiled_id: version.compiled_id }; } @@ -449,22 +479,23 @@ export class CanvasLibrary extends DurableObject { private findVersion(workspace: string, id: unknown): StoredVersion | null { if (typeof id !== "string" || !id) return null; - return first(this.sql.exec(`select v.*, a.workspace, a.name, a.source_path from versions v + const row = first(this.sql.exec(`select v.*, a.workspace, a.name, a.source_path from versions v join artifacts a on a.id = v.artifact_id where v.id = ? and a.workspace = ?`, id, workspace)); + return row ? { ...row, project: this.projectStorage.read(row.project) } : null; } - private capture(workspace: string, name: string, source: string, server_source: string | null, runtime: string, reason: string, restoredFrom: string | null, force: boolean, compiledId?: string): StoredVersion { + private capture(workspace: string, name: string, source: string, server_source: string | null, project: ArtifactProject, runtime: string, reason: string, restoredFrom: string | null, force: boolean, compiledId?: string): StoredVersion { let artifact = first(this.sql.exec<{ id: string }>("select id from artifacts where workspace = ? and name = ?", workspace, name)); if (!artifact) { artifact = { id: uuid() }; this.sql.exec("insert into artifacts values (?, ?, ?, ?, ?)", artifact.id, workspace, name, sourcePath(workspace, name), now()); } const source_hash = sha256(source); - const latest = first(this.sql.exec<{ id: string; revision: number; source: string; server_source: string | null; source_hash: string; compiled_id: string | null }>("select id,revision,source,server_source,source_hash,compiled_id from versions where artifact_id = ? order by revision desc limit 1", artifact.id)); - if (!force && latest && latest.source_hash === source_hash && latest.source === source && latest.server_source === server_source && (compiledId === undefined || latest.compiled_id === compiledId)) return this.findVersion(workspace, latest.id)!; + const latest = first(this.sql.exec<{ id: string; revision: number; source: string; server_source: string | null; source_hash: string; project: string; compiled_id: string | null }>("select id,revision,source,server_source,source_hash,project,compiled_id from versions where artifact_id = ? order by revision desc limit 1", artifact.id)); + if (!force && latest && latest.source_hash === source_hash && latest.source === source && latest.server_source === server_source && JSON.stringify(this.projectStorage.read(latest.project)) === JSON.stringify(project) && (compiledId === undefined || latest.compiled_id === compiledId)) return this.findVersion(workspace, latest.id)!; const id = uuid(); const created_at = now(); const revision = (latest?.revision ?? 0) + 1; - this.sql.exec(`insert into versions (id,artifact_id,revision,source,server_source,source_hash,runtime,created_at,reason,restored_from,compiled_id) - values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, id, artifact.id, revision, source, server_source, source_hash, runtime, created_at, reason, restoredFrom, compiledId ?? null); + this.sql.exec(`insert into versions (id,artifact_id,revision,source,server_source,source_hash,runtime,created_at,reason,restored_from,project,compiled_id) + values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, id, artifact.id, revision, source, server_source, source_hash, runtime, created_at, reason, restoredFrom, this.projectStorage.encode(project), compiledId ?? null); return this.findVersion(workspace, id)!; } } diff --git a/cloudflare/project-storage.ts b/cloudflare/project-storage.ts new file mode 100644 index 0000000..bb354a1 --- /dev/null +++ b/cloudflare/project-storage.ts @@ -0,0 +1,44 @@ +import { createHash } from "node:crypto"; +import { normalizeProject, type ArtifactProject } from "./project"; + +// At most 384 KiB of UTF-8 per chunk (three bytes per UTF-16 code unit). +// Leave headroom below the SQLite Durable Object's 2 MiB row/string limit. +const CHUNK_CODE_UNITS = 128 * 1024; +const hash = (value: string) => createHash("sha256").update(value).digest("hex"); +type ChunkReference = { project_chunks: string[] }; + +/** Immutable chunks are retained for every historical snapshot. Encode inside the caller's write transaction. */ +export class ProjectStorage { + constructor(private readonly sql: SqlStorage) { + sql.exec("create table if not exists project_chunks (hash text primary key, chunk text not null)"); + } + + encode(project: ArtifactProject): string { + const text = JSON.stringify(normalizeProject(project)); + const chunks: string[] = []; + for (let start = 0; start < text.length;) { + let end = Math.min(start + CHUNK_CODE_UNITS, text.length); + // Keep surrogate pairs together so the UTF-8 SQL binding preserves exact text. + const last = text.charCodeAt(end - 1); + if (end < text.length && last >= 0xd800 && last <= 0xdbff) end--; + const chunk = text.slice(start, end), id = hash(chunk); + this.sql.exec("insert into project_chunks (hash,chunk) values (?,?) on conflict(hash) do nothing", id, chunk); + chunks.push(id); start = end; + } + return JSON.stringify({ project_chunks: chunks } satisfies ChunkReference); + } + + read(encoded: string): ArtifactProject { + const value: unknown = JSON.parse(encoded); + // Existing inline snapshots and the column's empty-project default remain readable. + if (!value || typeof value !== "object" || Array.isArray(value) || !Object.hasOwn(value, "project_chunks")) return normalizeProject(value); + const reference = value as ChunkReference; + if (Object.keys(reference).length !== 1 || !Array.isArray(reference.project_chunks) || reference.project_chunks.length === 0 || reference.project_chunks.length > 128 || reference.project_chunks.some(id => typeof id !== "string" || !/^[a-f0-9]{64}$/.test(id))) throw new Error("Invalid project chunk reference"); + const chunks = reference.project_chunks.map(id => { + const row = this.sql.exec<{chunk: string}>("select chunk from project_chunks where hash = ?", id).toArray()[0]; + if (!row || hash(row.chunk) !== id) throw new Error("Project snapshot chunk is missing or corrupt"); + return row.chunk; + }); + return normalizeProject(JSON.parse(chunks.join(""))); + } +} diff --git a/cloudflare/project.test.ts b/cloudflare/project.test.ts new file mode 100644 index 0000000..bca8f5e --- /dev/null +++ b/cloudflare/project.test.ts @@ -0,0 +1,72 @@ +import { expect, test } from "bun:test"; +import { createHash } from "node:crypto"; +import { gzipSync } from "node:zlib"; +import { emptyProject, normalizeProject, packageFiles, resolveProject } from "./project"; +function tar(files: Record) { + const parts: Buffer[] = []; + for (const [name, content] of Object.entries(files)) { + const data = Buffer.from(content), header = Buffer.alloc(512); + header.write(`package/${name}`); header.write(data.length.toString(8).padStart(11,"0"),124); header[156] = 48; + parts.push(header,data,Buffer.alloc((512-data.length%512)%512)); + } + return gzipSync(Buffer.concat([...parts,Buffer.alloc(1024)])); +} +const archive = tar({"package.json": JSON.stringify({name:"example",version:"1.0.0",main:"index.js"}), "index.js":"export const answer = 42;", "index.d.ts":"export const answer: number;"}); +const manifest = {name:"example",version:"1.0.0",dist:{tarball:"https://registry.npmjs.org/example/-/example-1.0.0.tgz",integrity:`sha512-${createHash("sha512").update(archive).digest("base64")}`}}; +const fetcher = (async (input: string | URL | Request) => String(input).endsWith(".tgz") ? new Response(archive) : Response.json({versions:{"1.0.0":manifest}})) as typeof fetch; +test("exact dependency resolution verifies integrity and freezes source for offline edits",async()=>{ + const project=await resolveProject({files:{"helper.ts":"export const label = 'ok'"},dependencies:{example:"1.0.0"}},undefined,fetcher); + expect(project.lock["node_modules/example/index.js"]).toContain("42"); + const offline = (async()=>{throw new Error("must not fetch")}) as typeof fetch; + const edited=await resolveProject({files:{"helper.ts":"export const label = 'edited'"},dependencies:{example:"1.0.0"}},project,offline); + expect(edited.lock).toEqual(project.lock); + expect(edited.files["helper.ts"]).toContain("edited"); + await expect(packageFiles({...manifest,dist:{...manifest.dist,integrity:"sha512-invalid"}},fetcher)).rejects.toThrow("integrity mismatch"); + await expect(packageFiles({...manifest,dist:{...manifest.dist,tarball:"https://evil.example/package.tgz"}},fetcher)).rejects.toThrow("registry.npmjs.org"); +}); +test("project rejects traversal, reserved entrypoints, mutable versions and uploaded locks",async()=>{ + for (const path of ["../escape.ts","/absolute.ts","entry.ts","node_modules/foo/index.js","a/../b.ts","a//b.ts"]) expect(()=>normalizeProject({files:{[path]:""}})).toThrow(); + for (const version of ["latest","^1.0.0","https://example.com/x","github:a/b"]) expect(()=>normalizeProject({dependencies:{foo:version}})).toThrow("exact semver"); + await expect(resolveProject({lock:{"node_modules/a/index.js":"evil"}})).rejects.toThrow("read-only"); + expect(normalizeProject(undefined)).toEqual(emptyProject()); +}); +test("transitive version conflicts and required peers produce actionable errors",async()=>{ + const manifests: Record = { + alpha:{name:"alpha",version:"1.0.0",dependencies:{shared:"^1.0.0"}}, + beta:{name:"beta",version:"1.0.0",dependencies:{shared:"^2.0.0"}}, + shared:{name:"shared",version:"1.0.0"}, + peer:{name:"peer",version:"1.0.0",peerDependencies:{missing:"^1.0.0"}}, + }; + const fake=(async(input:string|URL|Request)=>{ + const url=String(input), name=new URL(url).pathname.slice(1).split("/")[0]!; + if(url.endsWith(".tgz"))return new Response(archive); + return Response.json({versions:{"1.0.0":{...manifests[name],dist:{...manifest.dist,tarball:`https://registry.npmjs.org/${name}/archive.tgz`}}}}); + }) as typeof fetch; + await expect(resolveProject({dependencies:{alpha:"1.0.0",beta:"1.0.0"}},undefined,fake)).rejects.toThrow("dependency conflict"); + await expect(resolveProject({dependencies:{peer:"1.0.0"}},undefined,fake)).rejects.toThrow("declare a compatible exact version"); +}); + +test("direct pins take precedence over newer compatible transitive candidates",async()=>{ + const manifests: Record> = { + alpha:{"1.0.0":{name:"alpha",version:"1.0.0",dependencies:{shared:"^1.0.0"}}}, + shared:{"1.0.0":{name:"shared",version:"1.0.0"},"1.1.0":{name:"shared",version:"1.1.0"}}, + }; + const fake=(async(input:string|URL|Request)=>{ + const url=String(input), name=new URL(url).pathname.slice(1).split("/")[0]!; + if(url.endsWith(".tgz"))return new Response(archive); + return Response.json({versions:Object.fromEntries(Object.entries(manifests[name]!).map(([version,value])=>[version,{...value,dist:{...manifest.dist,tarball:`https://registry.npmjs.org/${name}/${version}.tgz`}}]))}); + }) as typeof fetch; + const result=await resolveProject({dependencies:{alpha:"1.0.0",shared:"1.0.0"}},undefined,fake); + expect(Object.keys(result.lock)).toContain("node_modules/shared/index.js"); +}); +test("browser package peers use compatible host React and reject mismatches without downloading another React",async()=>{ + const requests: string[]=[]; + const fake=(async(input:string|URL|Request)=>{ + const url=String(input); requests.push(url); + if(url.endsWith(".tgz"))return new Response(archive); + return Response.json({versions:{"1.0.0":{...manifest,peerDependencies:{react:"^19.0.0"}}}}); + }) as typeof fetch; + await resolveProject({dependencies:{example:"1.0.0"}},undefined,fake,{react:"19.2.8"}); + expect(requests.some(url=>url.includes("/react"))).toBe(false); + await expect(resolveProject({dependencies:{example:"1.0.0"}},undefined,fake,{react:"18.3.1"})).rejects.toThrow("requires peer react"); +}); diff --git a/cloudflare/project.ts b/cloudflare/project.ts new file mode 100644 index 0000000..70c8680 --- /dev/null +++ b/cloudflare/project.ts @@ -0,0 +1,128 @@ +import { createHash } from "node:crypto"; +import { valid, satisfies, maxSatisfying } from "semver"; + +export type ArtifactProject = { files: Record; dependencies: Record; lock: Record }; +export const emptyProject = (): ArtifactProject => ({ files: {}, dependencies: {}, lock: {} }); +const size = (value: string) => new TextEncoder().encode(value).byteLength; +const packageName = /^(?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/; +const reserved = new Set(["canvas.canvas.tsx", "canvas.canvas.server.ts", "script.ts", "entry.ts", "server-entry.ts", "package.json", "__proto__", "constructor", "prototype"]); +function record(value: unknown, label: string): Record { + if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(`${label} must be an object`); + const result: Record = {}; + for (const key of Object.keys(value).sort()) { + const item = (value as Record)[key]; + if (typeof item !== "string" || ["__proto__", "constructor", "prototype"].includes(key)) throw new Error(`invalid ${label} entry: ${key}`); + result[key] = item; + } + return result; +} +export function normalizeProject(input: unknown): ArtifactProject { + if (input === undefined || input === null) return emptyProject(); + if (typeof input !== "object" || Array.isArray(input)) throw new Error("project must be an object"); + const value = input as Record; + for (const key of Object.keys(value)) if (!["files", "dependencies", "lock"].includes(key)) throw new Error(`unknown project field: ${key}`); + const files = record(value.files ?? {}, "files"), dependencies = record(value.dependencies ?? {}, "dependencies"), lock = record(value.lock ?? {}, "lock"); + if (Object.keys(files).length > 64 || size(JSON.stringify(files)) > 1024 * 1024) throw new Error("project files exceed 64 files or 1 MiB"); + for (const path of Object.keys(files)) { + if (!/^[a-zA-Z0-9_./-]+\.(?:[cm]?[jt]sx?|json)$/.test(path) || path.split("/").some(part => !part || part === "." || part === ".." || part === "node_modules") || reserved.has(path)) throw new Error(`invalid project file path: ${path}`); + } + if (Object.keys(dependencies).length > 32) throw new Error("project exceeds 32 direct dependencies"); + for (const [name, version] of Object.entries(dependencies)) if (!packageName.test(name) || valid(version) !== version) throw new Error(`dependency ${name} requires an exact semver version, not a tag, URL, or range`); + if (Object.keys(lock).length > 4096 || size(JSON.stringify(lock)) > 8 * 1024 * 1024) throw new Error("dependency lock exceeds 4096 files or 8 MiB"); + for (const path of Object.keys(lock)) if (!path.startsWith("node_modules/") || path.includes("\\") || path.includes("\0") || path.split("/").some(part => !part || part === "." || part === "..")) throw new Error(`invalid dependency lock path: ${path}`); + return { files, dependencies, lock }; +} +export function projectSourceHash(source: string, project: ArtifactProject): string { + return createHash("sha256").update(Object.values(project).every(value => Object.keys(value).length === 0) ? source : JSON.stringify([source, project])).digest("hex"); +} + +type Manifest = { name: string; version: string; dependencies?: Record; peerDependencies?: Record; peerDependenciesMeta?: Record; dist?: { tarball: string; integrity?: string } }; +type Registry = { versions: Record }; +const registryOrigin = "https://registry.npmjs.org"; +async function boundedBody(body: ReadableStream | null, limit: number): Promise { + if (!body) throw new Error("empty npm response"); + const reader = body.getReader(), chunks: Uint8Array[] = []; let length = 0; + try { + while (true) { + const { done, value } = await reader.read(); if (done) break; + length += value.length; + if (length > limit) throw new Error(`npm response exceeds ${limit} bytes`); + chunks.push(value); + } + } catch (error) { await reader.cancel().catch(() => {}); throw error; } + const result = new Uint8Array(length); let offset = 0; + for (const chunk of chunks) { result.set(chunk, offset); offset += chunk.length; } + return result; +} +async function download(url: string, fetcher: typeof fetch): Promise { + const parsed = new URL(url); + if (parsed.origin !== registryOrigin || parsed.username || parsed.password) throw new Error("packages must come from registry.npmjs.org"); + const response = await fetcher(url, { redirect: "error", signal: AbortSignal.timeout(20_000), headers: { Accept: "application/vnd.npm.install-v1+json" } }); + if (!response.ok) throw new Error(`npm request failed (${response.status}) for ${parsed.pathname}`); + return boundedBody(response.body, 8 * 1024 * 1024); +} +export async function packageFiles(manifest: Manifest, fetcher: typeof fetch): Promise> { + if (!manifest.dist?.tarball || !manifest.dist.integrity) throw new Error(`${manifest.name}@${manifest.version} has no integrity-protected tarball`); + const compressed = await download(manifest.dist.tarball, fetcher); + const integrity = manifest.dist.integrity.split(/\s+/).find(value => /^sha512-/.test(value)) ?? manifest.dist.integrity.split(/\s+/).find(value => /^sha256-/.test(value)); + if (!integrity) throw new Error(`unsupported integrity algorithm for ${manifest.name}`); + const [algorithm, expected] = integrity.split("-"); + if (createHash(algorithm!).update(compressed).digest("base64") !== expected) throw new Error(`npm integrity mismatch for ${manifest.name}@${manifest.version}`); + const decompressed = new Blob([compressed.buffer as ArrayBuffer]).stream().pipeThrough(new DecompressionStream("gzip")); + const data = await boundedBody(decompressed, 24 * 1024 * 1024); + const decoder = new TextDecoder("utf-8", { fatal: true, ignoreBOM: false }), files: Record = {}; + const text = (start: number, length: number) => new TextDecoder().decode(data.subarray(start,start+length)).replace(/\0.*$/s, ""); + for (let offset = 0; offset + 512 <= data.length;) { + if (data.subarray(offset,offset+512).every(value => value === 0)) break; + const prefix = text(offset + 345, 155), name = text(offset,100), path = `${prefix ? `${prefix}/` : ""}${name}`; + const length = parseInt(text(offset+124,12).trim(),8), type = data[offset+156]; + if (!Number.isSafeInteger(length) || length < 0 || offset+512+length > data.length) throw new Error("invalid npm tar archive"); + offset += 512; + if (type === 0 || type === 48) { + if (!path.startsWith("package/") || path.includes("\\") || path.split("/").some(part => part === ".." || part === ".")) throw new Error("unsafe npm tar path"); + const relative = path.slice(8); + if (/\.(?:[cm]?[jt]sx?|json)$/.test(relative)) files[`node_modules/${manifest.name}/${relative}`] = decoder.decode(data.subarray(offset,offset+length)); + } else if (type !== 53) throw new Error(`unsupported npm tar entry in ${manifest.name}`); + offset += Math.ceil(length/512)*512; + } + return files; +} +/** Resolve once at authoring time. Replay and execution consume this immutable source snapshot without network requests. */ +export async function resolveProject(input: unknown, previous?: ArtifactProject, fetcher: typeof fetch = fetch, hostPackages: Record = {}): Promise { + const requested = normalizeProject(input); + // Callers may reuse an existing lock, but cannot upload executable vendor code disguised as registry packages. + if (Object.keys(requested.lock).length) throw new Error("project.lock is read-only; submit files and dependencies"); + if (previous && JSON.stringify(previous.dependencies) === JSON.stringify(requested.dependencies)) return normalizeProject({ ...requested, lock: previous.lock }); + const manifests = new Map(), pending = new Map(); + const install = async (name: string, range: string, requestedBy: string): Promise => { + if (!packageName.test(name)) throw new Error(`unsupported dependency ${name} requested by ${requestedBy}`); + if (hostPackages[name]) { + if (!satisfies(hostPackages[name]!, range)) throw new Error(`${requestedBy} requires ${name}@${range}, but Canvas supplies ${name}@${hostPackages[name]}; choose a compatible package version`); + return; + } + const existing = manifests.get(name); + if (existing) { + if (!satisfies(existing.version, range)) throw new Error(`dependency conflict: ${requestedBy} requires ${name}@${range}, but ${name}@${existing.version} is already selected; this compiler supports one version per package`); + return; + } + if (manifests.size >= 64) throw new Error("project exceeds 64 resolved packages"); + let metadata = pending.get(name); + if (!metadata) { metadata = JSON.parse(new TextDecoder().decode(await download(`${registryOrigin}/${name.replace("/", "%2f")}`,fetcher))) as Registry; pending.set(name,metadata); } + const version = maxSatisfying(Object.keys(metadata.versions ?? {}), requested.dependencies[name] ?? range); + if (version && !satisfies(version, range)) throw new Error(`dependency conflict: ${requestedBy} requires ${name}@${range}, but project pins ${name}@${version}`); + if (!version) throw new Error(`cannot resolve ${name}@${range} required by ${requestedBy}`); + const manifest = metadata.versions[version]!; + if (manifest.name !== name || manifest.version !== version) throw new Error("npm manifest identity mismatch"); + manifests.set(name,manifest); + Object.assign(requested.lock,await packageFiles(manifest,fetcher)); + normalizeProject(requested); + for (const [dep, constraint] of Object.entries(manifest.dependencies ?? {}).sort()) await install(dep,constraint,`${name}@${version}`); + }; + for (const [name, version] of Object.entries(requested.dependencies)) await install(name,version,"project"); + for (const manifest of manifests.values()) for (const [name, range] of Object.entries(manifest.peerDependencies ?? {})) { + if (hostPackages[name] && satisfies(hostPackages[name]!,range)) continue; + if (!manifests.has(name) && manifest.peerDependenciesMeta?.[name]?.optional) continue; + if (!manifests.has(name) || !satisfies(manifests.get(name)!.version,range)) throw new Error(`${manifest.name}@${manifest.version} requires peer ${name}@${range}; declare a compatible exact version in project.dependencies`); + } + return normalizeProject(requested); +} diff --git a/cloudflare/script-guide.ts b/cloudflare/script-guide.ts index 07f3615..1aec33e 100644 --- a/cloudflare/script-guide.ts +++ b/cloudflare/script-guide.ts @@ -2,7 +2,7 @@ export const HOSTED_SCRIPT_GUIDE = `Hosted standalone scripts and root URLs Canvases and scripts coexist. Use canvas_write with an optional slug, or artifact_link({kind:"canvas",name,slug,access}), to give a canvas a self-contained root URL. Script writes choose slug explicitly or default to the script name. Root slugs are unique across this deployment, use lowercase letters/numbers/hyphens, and exclude application routes. No namespace or type prefix is added. Names and slugs are separate. Private is the default; access:"public" enables external callers. Management remains authenticated. -Create arbitrary Workers-compatible TypeScript using script_write({name,slug,contents,access}). Export default {async fetch(request, env, ctx) { return new Response("Hello"); }}. A handler can return HTML, JSON, binary responses, or streams, receive webhooks, or call external APIs through fetch. Supported runtime bindings are env.secrets (explicitly configured per-script values) and env.sql (persistent native SQLite, with .exec and bound parameters). ScriptEnv and ExecutionContext types are available. No application credentials are implicitly supplied. Workers-supported cloudflare: and node: imports are available. Unbundled third-party imports, relative modules and package.json dependency installation are not supported by script_write. The server accepts one self-contained module, at most 256 KiB of UTF-8 source. +Create arbitrary Workers-compatible TypeScript using script_write({name,slug,contents,access}). Export default {async fetch(request, env, ctx) { return new Response("Hello"); }}. A handler can return HTML, JSON, binary responses, or streams, receive webhooks, or call external APIs through fetch. Supported runtime bindings are env.secrets (explicitly configured per-script values) and env.sql (persistent native SQLite, with .exec and bound parameters). ScriptEnv and ExecutionContext types are available. No application credentials are implicitly supplied. Workers-supported cloudflare: and node: imports are available. Use project.files for relative helper modules and project.dependencies for exact npm versions. The entrypoint accepts at most 256 KiB UTF-8; helpers support 64 files and 1 MiB total. The service resolves dependencies when their declarations change and archives integrity-verified source and types. Source compilation and execution use the archived lock without downloading dependencies. Use script_secrets({name,secrets:{TOKEN:"value",OLD_TOKEN:null}}) to set/delete values, or omit secrets to list names. Values never appear in source history or management reads. Use env.secrets.TOKEN inside the handler. Avoid returning secrets in HTTP responses. Initialize database tables with create table if not exists; source edits/restores preserve the database. @@ -10,8 +10,13 @@ script_write and script_edit save source before validation. Inspect applied, ok, script_run({name,request:{path:"/chosen-slug?key=value",method:"POST",headers:[["content-type","text/plain"]],body:"aGVsbG8="}}) explicitly invokes the last validated handler. The request path and query are passed exactly as provided, using the deployment origin; include the slug in path to reproduce a direct URL request. Body is base64. MCP requests and responses are limited to 256 KiB; use direct URLs for streaming or larger responses. script_logs reads bounded recent execution logs and errors. Direct URLs preserve incoming paths, methods and body bytes and return the handler response. Management cookies and Cloudflare Access credentials are stripped; private routes also strip Authorization. Public Authorization is passed to the handler. Script HTML gets an opaque-origin sandbox and responses cannot set cookies. Public handlers can implement their own authentication, including provider signature checks. Cloudflare Access policies protecting the entire hostname need a bypass for intended public paths. No schedules or separate publishing workflow are provided. Execution has 30 seconds CPU and 50 subrequests; logs retain 100 entries of up to 2 KiB with literal secret redaction. +Remix +Use canvas_remix/script_remix with new_name and exactly one source name or version_id. Source provenance is retained, runtime data and configured secrets start empty, and hosted links start private. + Third-party dependencies -Keep the editable source, package.json and lockfile locally. Install pinned packages with bun add --exact @. Typecheck your original TypeScript before bundling. Bundle locally with Bun.build({entrypoints:["handler.ts"],target:"browser",format:"esm",external:["node:*","cloudflare:*"],banner:"// @ts-nocheck",minify:true}). Check build.success, then read build.outputs[0].text() and pass that entire string as script_write.contents. The generated-JavaScript banner avoids implicit-any errors after type erasure; it is only for the generated bundle, not handwritten source. Retain your original sources and rebuild after edits rather than editing bundled internals. Workers cannot execute arbitrary Node processes/native addons; use Workers-compatible packages. If the bundle exceeds 256 KiB, reduce the dependency footprint; splitting it into relative imports is not supported. +Pass project:{files:{"lib/message.ts":"export const message = 'Hello';"},dependencies:{hono:"4.13.7"}} to script_write or canvas_write. Import helper modules with relative paths and packages by name. Omit project on write to preserve it; supply files/dependencies as full replacements to change or clear them. Dependency declarations require exact versions; tags, ranges, arbitrary registry URLs and uploaded lock contents are rejected. Registry requests and compressed/uncompressed package sizes are bounded, tarball integrity is checked, install scripts never run, and the complete dependency source snapshot is restored with each revision. The compiler supports one resolved version of each package; conflicting transitive requirements produce an explicit error. Required peers must be declared, except supported browser packages such as React supplied by Canvas's own runtime. Native addons and packages requiring a Node process cannot run in Workers. + +Use file:"lib/message.ts" with script_read/script_edit or canvas_read/canvas_edit to read/edit a helper. Entry sources, helper files and the dependency lock travel together in source history, restore and remix. The gallery source editor has a file picker, add/remove file controls, and an exact-version dependency editor. Historical source is read-only until restored. Browser module checks apply to reachable authored modules; vendor modules still execute inside the restricted browser frame. After writing, check ok/diagnostics and the returned URL. Creation loads the module for validation but does not invoke the handler. Explicit script_run or an HTTP request invokes it and may mutate state or call external services. Inspect the HTTP status/body and script_logs before reporting the handler working. script_guide and canvas_guide describe the hosted contracts; local Bun stdio does not expose script tools. `; diff --git a/cloudflare/script-service.ts b/cloudflare/script-service.ts index 0dc8132..3a8474f 100644 --- a/cloudflare/script-service.ts +++ b/cloudflare/script-service.ts @@ -1,3 +1,4 @@ +import { resolveProject } from "./project"; import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; import { createHash } from "node:crypto"; import type { CanvasEdit } from "./library"; @@ -36,13 +37,15 @@ export class CloudScriptService { return text({ scripts: entries, next_offset: entries.length === 100 ? offset + 100 : null }); } case "script_read": { - if (!args.version_id) return text(await scripts.readRange({ ...input, start_line: args.start_line as number | undefined, end_line: args.end_line as number | undefined })); + if (!args.version_id) return text(await scripts.readRange({ ...input, file: args.file as string | undefined, start_line: args.start_line as number | undefined, end_line: args.end_line as number | undefined })); const version = await this.readSource({ name, version_id: args.version_id as string }); - const lines = version.source.match(/[^\n]*\n|[^\n]+$/g) ?? [""]; + const selected = args.file === undefined ? version.source : Object.hasOwn(version.project.files, args.file as string) ? version.project.files[args.file as string] : undefined; + if (selected === undefined) throw new Error("project file not found"); + const lines = selected.match(/[^\n]*\n|[^\n]+$/g) ?? [""]; const start = args.start_line as number | undefined ?? 1, end = args.end_line as number | undefined ?? start + 199; if (!Number.isSafeInteger(start) || !Number.isSafeInteger(end) || start < 1 || end < start || start > lines.length) throw new Error("invalid line range"); const actualEnd = Math.min(end, lines.length); - return text({ ...version, source: lines.slice(start - 1, actualEnd).join(""), start_line: start, end_line: actualEnd, total_lines: lines.length, next_line: actualEnd < lines.length ? actualEnd + 1 : null }); + return text({ ...version, ...(args.file === undefined ? {} : { file: args.file, path: args.file, source_hash: createHash("sha256").update(selected).digest("hex") }), source: lines.slice(start - 1, actualEnd).join(""), start_line: start, end_line: actualEnd, total_lines: lines.length, next_line: actualEnd < lines.length ? actualEnd + 1 : null }); } case "script_history": { const versions = await scripts.history({ workspace: this.artifacts.workspace, name, offset }); @@ -86,12 +89,14 @@ export class CloudScriptService { if (version.name !== target.name) throw new Error("Version does not belong to this script"); } const destination = {...input,name:target.name}; - const mutation = tool === "script_remix" ? await scripts.remix({workspace:this.artifacts.workspace,name:args.name as string | undefined,version_id:args.version_id as string | undefined,new_name:args.new_name as string}) : tool === "script_write" ? await scripts.writeDraft({ ...input, source: args.contents as string }) - : tool === "script_edit" ? await scripts.editDraft({ ...input, edits: args.edits as CanvasEdit[], expected_hash: args.expected_hash as string | undefined }) + const previous = tool === "script_write" && args.project !== undefined ? await scripts.readRange(input).catch(error => { if (error instanceof Error && error.message.includes("script not found")) return undefined; throw error; }) : undefined; + const project = tool === "script_write" && args.project !== undefined ? await resolveProject(args.project, previous?.project) : undefined; + const mutation = tool === "script_remix" ? await scripts.remix({workspace:this.artifacts.workspace,name:args.name as string | undefined,version_id:args.version_id as string | undefined,new_name:args.new_name as string}) : tool === "script_write" ? await scripts.writeDraft({ ...input, source: args.contents as string, project }) + : tool === "script_edit" ? await scripts.editDraft({ ...input, file: args.file as string | undefined, edits: args.edits as CanvasEdit[], expected_hash: args.expected_hash as string | undefined }) : await scripts.restore({ workspace: this.artifacts.workspace, id: args.version_id as string }); generation ??= await hosted.links.begin(target); await hosted.links.stage(target, generation, settings); - const compiled = await compileScriptSource(mutation.source); + const compiled = await compileScriptSource(mutation.source, mutation.project); if (compiled.ok && compiled.js) { const secrets = await scripts.executionSecrets(destination); const backend = hosted.scriptBackends.getByName(JSON.stringify([this.artifacts.libraryKey, this.artifacts.workspace, target.name])); @@ -101,12 +106,12 @@ export class CloudScriptService { const activated = await scripts.activate({ ...destination, source_hash: mutation.source_hash, code: compiled.js }); const link = await hosted.links.commit(target, generation, { ...settings, script_hash: activated.hash }); if (!link) { - const { source, ...summary } = mutation; + const { source, project: snapshotProject, ...summary } = mutation; const superseded = { ...summary, ...await this.artifacts.linkDetails(target), applied: true, ok: false, superseded: true, error: "A newer update superseded this URL activation" }; return { ...text(superseded, true), structuredContent: superseded }; } } catch (error) { - const { source, ...summary } = mutation; + const { source, project: snapshotProject, ...summary } = mutation; const message = error instanceof Error ? error.message : "Script URL activation failed"; const failed = { ...summary, ...await this.artifacts.linkDetails(target), applied: true, ok: false, ...(message.includes("changed during validation") ? { superseded: true } : {}), error: message }; return { ...text(failed, true), structuredContent: failed }; @@ -116,7 +121,7 @@ export class CloudScriptService { compiled.diagnostics.push({ severity: "error", message: "Script module could not initialize" }); } } - const { source, ...summary } = mutation; + const { source, project: snapshotProject, ...summary } = mutation; const payload = { ...summary, ...await this.artifacts.linkDetails(target), applied: true, ok: compiled.ok, check: formatCanvasCheck(compiled.diagnostics), diagnostics: compiled.diagnostics }; return { ...text(payload, !compiled.ok), structuredContent: payload }; } diff --git a/cloudflare/scripts.test.ts b/cloudflare/scripts.test.ts index e54951a..6625d5c 100644 --- a/cloudflare/scripts.test.ts +++ b/cloudflare/scripts.test.ts @@ -2,9 +2,9 @@ import {afterAll,beforeAll,expect,test} from "bun:test"; import {Miniflare, Response as MFResponse} from "miniflare"; let runtime: Miniflare; beforeAll(async()=>{ - const build=await Bun.build({entrypoints:[new URL("./scripts-test-worker.ts",import.meta.url).pathname],target:"node",format:"esm",external:["cloudflare:workers"]}); + const build=await Bun.build({entrypoints:[new URL("./scripts-test-worker.ts",import.meta.url).pathname],target:"browser",format:"esm",external:["cloudflare:workers","node:*","fs","fs/promises"]}); if (!build.success) throw new Error(build.logs.join("\n")); - runtime=new Miniflare({cf:false,port:0,workers:[{config:{name:"scripts-test",type:"worker",compatibilityDate:"2026-09-06",compatibilityFlags:["nodejs_compat"],manifest:{mainModule:"test.js",modulesRoot:import.meta.dir,modules:{"test.js":{type:"esm",contents:await build.outputs[0]!.text()}}},env:{SCRIPTS:{type:"durable-object",worker:"scripts-test",exportName:"ScriptLibrary"},SCRIPT_BACKENDS:{type:"durable-object",worker:"scripts-test",exportName:"ScriptBackend"},LOADER:{type:"worker-loader"}},exports:{ScriptLibrary:{type:"durable-object",storage:"sqlite"},ScriptBackend:{type:"durable-object",storage:"sqlite"}}},dev:{outboundService:{type:"fetcher",handler:()=>new MFResponse("upstream success")}}}]}); + runtime=new Miniflare({unsafeInspectDurableObjects:true,cf:false,port:0,workers:[{config:{name:"scripts-test",type:"worker",compatibilityDate:"2026-09-06",compatibilityFlags:["nodejs_compat"],manifest:{mainModule:"test.js",modulesRoot:import.meta.dir,modules:{"test.js":{type:"esm",contents:await build.outputs[0]!.text()}}},env:{SCRIPTS:{type:"durable-object",worker:"scripts-test",exportName:"ScriptLibrary"},SCRIPT_BACKENDS:{type:"durable-object",worker:"scripts-test",exportName:"ScriptBackend"},LOADER:{type:"worker-loader"}},exports:{ScriptLibrary:{type:"durable-object",storage:"sqlite"},ScriptBackend:{type:"durable-object",storage:"sqlite"}}},dev:{outboundService:{type:"fetcher",handler:()=>new MFResponse("upstream success")}}}]}); await runtime.ready; }); afterAll(async()=>{await runtime?.dispose();}); @@ -103,3 +103,87 @@ test("remix copies an immutable source revision, never secrets or activation, an await expect(call("remix",{workspace,version_id:version.id,new_name:"copy"},"bob")).rejects.toThrow("not found"); await expect(call("remix",{workspace,name,version_id:version.id,new_name:"ambiguous"})).rejects.toThrow("not both"); }); + +test("project changes invalidate script validation and restore complete module snapshots", async () => { + const identity = { workspace: "projects", name: "handler" }; + const project = { files: { "helper.ts": "export const answer = 1;", "other.ts": "unchanged" }, dependencies: { "lodash-es": "4.17.21" }, lock: { "node_modules/lodash-es/index.js": "export const version = 1;" } }; + const source = "entry source"; + const legacy = await call("writeDraft", { ...identity, source }); + const first = await call("writeDraft", { ...identity, source, project }); + expect(first.source_hash).not.toBe(legacy.source_hash); + expect(first.project).toEqual(project); + const history = await call("history", identity); + const versionId = history[0].id; + expect((await call("writeDraft", { ...identity, source })).project).toEqual(project); + expect(await call("history", identity)).toHaveLength(2); + const read = await call("readRange", { ...identity, file: "helper.ts" }); + const edited = await call("editDraft", { ...identity, file: "helper.ts", expected_hash: read.source_hash, edits: [{ old_text: "1", new_text: "2" }] }); + expect(edited.source).toBe(source); + expect(edited.project).toEqual({ ...project, files: { ...project.files, "helper.ts": "export const answer = 2;" } }); + expect(edited.source_hash).not.toBe(first.source_hash); + await expect(call("activate", { ...identity, source_hash: first.source_hash, code: "compiled" })).rejects.toThrow("changed during validation"); + await expect(call("editDraft", { ...identity, file: "helper.ts", expected_hash: read.source_hash, edits: [{ old_text: "2", new_text: "3" }] })).rejects.toThrow("changed since read"); + await expect(call("editDraft", { ...identity, file: "helper.ts", edits: [{ old_text: "2", new_text: "3" }, { old_text: "missing", new_text: "x" }] })).rejects.toThrow("no changes written"); + expect((await call("readRange", identity)).project).toEqual(edited.project); + await expect(call("readRange", { ...identity, file: "missing.ts" })).rejects.toThrow("file not found"); + await expect(call("editDraft", { ...identity, file: "missing.ts", edits: [{ old_text: "x", new_text: "y" }] })).rejects.toThrow("file not found"); + expect((await call("version", { workspace: identity.workspace, id: versionId })).project).toEqual(project); + expect((await call("restore", { workspace: identity.workspace, id: versionId })).project).toEqual(project); + const empty = { files: {}, dependencies: {}, lock: {} }; + expect((await call("writeDraft", { ...identity, source, project: empty })).source_hash).toBe(legacy.source_hash); +}); + +test("legacy script tables acquire empty project snapshots without losing drafts or history", async () => { + const library = "legacy-project-migration"; + const identity = { workspace: "legacy", name: "handler" }; + const written = await call("writeDraft", { ...identity, source: "legacy source" }, library); + const history = await call("history", identity, library); + const storage = await runtime.unsafeGetDurableObjectStorage("scripts-test", "ScriptLibrary", { name: library }); + await storage.exec("alter table scripts drop column project"); + await storage.exec("alter table script_versions drop column project"); + await runtime.unsafeEvictDurableObject("scripts-test", "ScriptLibrary", { name: library }); + const empty = { files: {}, dependencies: {}, lock: {} }; + expect(await call("readRange", identity, library)).toMatchObject({ source: written.source, source_hash: written.source_hash, project: empty }); + expect(await call("version", { workspace: identity.workspace, id: history[0].id }, library)).toMatchObject({ source: written.source, source_hash: written.source_hash, project: empty }); +}); + +test("large script projects retain deduplicated chunks across edits and historical restore", async () => { + const library = "large-project-chunks"; + const identity = { workspace: "chunks", name: "large" }; + const project = { files: { "helper.ts": "export const value = 1;" }, dependencies: { example: "1.0.0" }, lock: { "node_modules/example/index.js": `export const text = "${"😀漢字".repeat(240000)}";` } }; + expect(new TextEncoder().encode(JSON.stringify(project)).byteLength).toBeGreaterThan(2 * 1024 * 1024); + const source = "entry"; + const original = await call("writeDraft", { ...identity, source, project }, library); + expect(original.project).toEqual(project); + const history = await call("history", identity, library); + const storage = await runtime.unsafeGetDurableObjectStorage("scripts-test", "ScriptLibrary", { name: library }); + const before = await storage.exec<{n: number; largest: number}>("select count(*) as n,max(length(cast(chunk as blob))) as largest from project_chunks"); + expect(before[0]!.n).toBeGreaterThan(1); + expect(before[0]!.largest).toBeLessThanOrEqual(512 * 1024); + expect((await call("writeDraft", { ...identity, source }, library)).project).toEqual(project); + const edited = await call("editDraft", { ...identity, file: "helper.ts", edits: [{ old_text: "1", new_text: "2" }] }, library); + expect(edited.source_hash).not.toBe(original.source_hash); + expect(edited.project.lock).toEqual(project.lock); + expect((await call("version", { workspace: identity.workspace, id: history[0].id }, library)).project).toEqual(project); + expect((await call("restore", { workspace: identity.workspace, id: history[0].id }, library)).project).toEqual(project); + await runtime.unsafeEvictDurableObject("scripts-test", "ScriptLibrary", { name: library }); + expect((await call("readRange", identity, library)).project).toEqual(project); + const after = await storage.exec<{n: number}>("select count(*) as n from project_chunks"); + expect(after[0]!.n).toBeLessThanOrEqual(before[0]!.n + 1); +}); + +test("inline script project snapshots upgrade transparently on edit and restore", async () => { + const library = "inline-project-upgrade", identity = { workspace: "upgrade", name: "script" }; + const project = { files: { "helper.ts": "original" }, dependencies: {}, lock: {} }; + await call("writeDraft", { ...identity, source: "entry", project }, library); + const history = await call("history", identity, library); + const storage = await runtime.unsafeGetDurableObjectStorage("scripts-test", "ScriptLibrary", { name: library }); + await storage.exec("update scripts set project = ?", JSON.stringify(project)); + await storage.exec("update script_versions set project = ?", JSON.stringify(project)); + await runtime.unsafeEvictDurableObject("scripts-test", "ScriptLibrary", { name: library }); + expect((await call("readRange", identity, library)).project).toEqual(project); + expect((await call("version", { workspace: identity.workspace, id: history[0].id }, library)).project).toEqual(project); + const edited = await call("editDraft", { ...identity, file: "helper.ts", edits: [{ old_text: "original", new_text: "changed" }] }, library); + expect(edited.project.files["helper.ts"]).toBe("changed"); + expect((await call("restore", { workspace: identity.workspace, id: history[0].id }, library)).project).toEqual(project); +}); diff --git a/cloudflare/scripts.ts b/cloudflare/scripts.ts index 33c6ffa..cea458b 100644 --- a/cloudflare/scripts.ts +++ b/cloudflare/scripts.ts @@ -1,6 +1,9 @@ +import { ProjectStorage } from "./project-storage"; import { DurableObject } from "cloudflare:workers"; import { sha256, MAX_SOURCE_BYTES, type CanvasEdit } from "./library"; +import { emptyProject, normalizeProject, projectSourceHash, type ArtifactProject } from "./project"; + const bytes = (value: string) => new TextEncoder().encode(value).byteLength; function text(value: unknown, label: string, max = 4096): string { if (typeof value !== "string" || !value.trim() || value.includes("\0") || bytes(value) > max) throw new Error(`invalid ${label}`); @@ -21,17 +24,24 @@ function page(input: {offset?: number; limit?: number}) { if (!Number.isSafeInteger(offset) || offset < 0 || !Number.isSafeInteger(limit) || limit < 1 || limit > 100) throw new Error("invalid pagination"); return {offset, limit}; } -type Row = { workspace: string; name: string; source: string; source_hash: string; updated_at: string; active_code: string | null; active_hash: string | null; secrets: string }; -type VersionRow = { id: string; workspace: string; name: string; revision: number; source: string; source_hash: string; created_at: string; reason: string; restored_from: string | null }; +type Row = { workspace: string; name: string; source: string; project: string; source_hash: string; updated_at: string; active_code: string | null; active_hash: string | null; secrets: string }; +type VersionRow = { id: string; workspace: string; name: string; revision: number; source: string; project: string; source_hash: string; created_at: string; reason: string; restored_from: string | null }; export class ScriptLibrary extends DurableObject { private sql: SqlStorage; + private readonly projectStorage: ProjectStorage; constructor(state: DurableObjectState, env: unknown) { super(state, env); this.sql = state.storage.sql; + this.projectStorage = new ProjectStorage(this.sql); this.sql.exec(`create table if not exists scripts (workspace text not null,name text not null,source text not null,source_hash text not null,updated_at text not null,active_code text,active_hash text,secrets text not null default '{}',primary key(workspace,name))`); this.sql.exec(`create table if not exists script_versions (id text primary key,workspace text not null,name text not null,revision integer not null,source text not null,source_hash text not null,created_at text not null,reason text not null,restored_from text,unique(workspace,name,revision))`); this.sql.exec("create table if not exists script_remix_origins (workspace text not null,name text not null,source_name text not null,source_version_id text not null,primary key(workspace,name))"); this.sql.exec(`create table if not exists compiled_scripts (workspace text not null,name text not null,hash text not null,code text not null,primary key(workspace,name,hash))`); + for (const table of ["scripts", "script_versions"]) { + if (!this.sql.exec<{name: string}>(`pragma table_info(${table})`).toArray().some(column => column.name === "project")) { + this.sql.exec(`alter table ${table} add column project text not null default '{"files":{},"dependencies":{},"lock":{}}'`); + } + } } private row(input: {workspace: string; name: string}): Row { const {workspace,name} = key(input); @@ -39,16 +49,19 @@ export class ScriptLibrary extends DurableObject { if (!row) throw new Error("script not found"); return row; } - private save(input: {workspace: string; name: string; source: string}, reason: string, restoredFrom: string | null = null) { - const {workspace,name} = key(input), code = source(input.source), source_hash = sha256(code), updated_at = new Date().toISOString(); + private save(input: {workspace: string; name: string; source: string; project?: ArtifactProject}, reason: string, restoredFrom: string | null = null) { + const {workspace,name} = key(input), code = source(input.source), updated_at = new Date().toISOString(); return this.ctx.storage.transactionSync(() => { - this.sql.exec(`insert into scripts(workspace,name,source,source_hash,updated_at) values(?,?,?,?,?) on conflict(workspace,name) do update set source=excluded.source,source_hash=excluded.source_hash,updated_at=excluded.updated_at`,workspace,name,code,source_hash,updated_at); + const current = this.sql.exec("select * from scripts where workspace=? and name=?",workspace,name).toArray()[0]; + const project = input.project === undefined ? (current ? this.projectStorage.read(current.project) : emptyProject()) : normalizeProject(input.project); + const source_hash = projectSourceHash(code, project); + this.sql.exec(`insert into scripts(workspace,name,source,source_hash,updated_at,project) values(?,?,?,?,?,?) on conflict(workspace,name) do update set source=excluded.source,source_hash=excluded.source_hash,updated_at=excluded.updated_at,project=excluded.project`,workspace,name,code,source_hash,updated_at,this.projectStorage.encode(project)); const previous = this.sql.exec<{revision: number; source_hash: string}>("select revision,source_hash from script_versions where workspace=? and name=? order by revision desc limit 1",workspace,name).toArray()[0]; - if (previous?.source_hash !== source_hash || reason === "restore") this.sql.exec("insert into script_versions values(?,?,?,?,?,?,?,?,?)",crypto.randomUUID(),workspace,name,(previous?.revision ?? 0)+1,code,source_hash,updated_at,reason,restoredFrom); - return {ok:true,workspace,name,path:`${workspace}/${name}.script.ts`,source:code,source_hash,updated_at}; + if (previous?.source_hash !== source_hash || reason === "restore") this.sql.exec("insert into script_versions(id,workspace,name,revision,source,source_hash,created_at,reason,restored_from,project) values(?,?,?,?,?,?,?,?,?,?)",crypto.randomUUID(),workspace,name,(previous?.revision ?? 0)+1,code,source_hash,updated_at,reason,restoredFrom,this.projectStorage.encode(project)); + return {ok:true,workspace,name,path:`${workspace}/${name}.script.ts`,source:code,project,source_hash,updated_at}; }); } - writeDraft(input: {workspace: string; name: string; source: string}) { return this.save(input,"edit"); } + writeDraft(input: {workspace: string; name: string; source: string; project?: ArtifactProject}) { return this.save(input,"edit"); } remix(input: {workspace: string; name?: string; version_id?: string; new_name: string}) { const {workspace,name} = key({workspace:input.workspace,name:input.new_name}); if (Boolean(input.name) === Boolean(input.version_id)) throw new Error("provide name or version_id, but not both"); @@ -57,10 +70,10 @@ export class ScriptLibrary extends DurableObject { const origin = input.version_id ? this.version({workspace,id:input.version_id}) : (() => { const draft = this.row({workspace,name:input.name!}); const latest = this.sql.exec("select * from script_versions where workspace=? and name=? order by revision desc limit 1",workspace,draft.name).toArray()[0]; - if (!latest || latest.source !== draft.source) throw new Error("Script draft has no matching revision"); - return latest; + if (!latest || latest.source_hash !== draft.source_hash) throw new Error("Script draft has no matching revision"); + return {...latest,project:this.projectStorage.read(latest.project)}; })(); - const result = this.save({workspace,name,source:origin.source},"remix"); + const result = this.save({workspace,name,source:origin.source,project:origin.project},"remix"); this.sql.exec("insert into script_remix_origins values(?,?,?,?)",workspace,name,origin.name,origin.id); return {...result,remixed:true,origin:{source_name:origin.name,source_version_id:origin.id}}; }); @@ -69,27 +82,37 @@ export class ScriptLibrary extends DurableObject { const {offset,limit} = page(input), workspace = input.workspace === undefined ? null : text(input.workspace,"workspace"); return this.sql.exec<{workspace: string; name: string; source_hash: string; updated_at: string; active_hash: string | null}>("select workspace,name,source_hash,updated_at,active_hash from scripts where (? is null or workspace=?) order by workspace,name limit ? offset ?",workspace,workspace,limit,offset).toArray().map(row=>({...row,id:row.name,path:`${row.workspace}/${row.name}.script.ts`,kind:"script" as const})); } - readRange(input: {workspace: string; name: string; start_line?: number; end_line?: number}) { - const row = this.row(input), lines = row.source.match(/[^\n]*\n|[^\n]+$/g) ?? [""]; + readRange(input: {workspace: string; name: string; file?: string; start_line?: number; end_line?: number}) { + const row = this.row(input), project = this.projectStorage.read(row.project); + if (input.file !== undefined && !Object.hasOwn(project.files,input.file)) throw new Error("project file not found"); + const selected = input.file === undefined ? row.source : project.files[input.file]!; + const lines = selected.match(/[^\n]*\n|[^\n]+$/g) ?? [""]; const start = input.start_line ?? 1, end = input.end_line ?? start+199; if (!Number.isSafeInteger(start) || !Number.isSafeInteger(end) || start<1 || endlines.length) throw new Error("invalid line range"); const actualEnd = Math.min(end,lines.length); - return {workspace:row.workspace,name:row.name,path:`${row.workspace}/${row.name}.script.ts`,source:lines.slice(start-1,actualEnd).join(""),source_hash:row.source_hash,total_lines:lines.length,start_line:start,end_line:actualEnd,next_line:actualEnd100) throw new Error("edits must contain 1 to 100 edits"); - let code = row.source; - for (const edit of input.edits) { - if (!edit || typeof edit.old_text!=="string" || !edit.old_text || typeof edit.new_text!=="string") throw new Error("invalid edit"); - const at = code.indexOf(edit.old_text); - if (at<0) throw new Error("old_text not found; no changes written"); - if (code.indexOf(edit.old_text,at+1)!==-1) throw new Error("old_text is ambiguous; no changes written"); - code = code.slice(0,at)+edit.new_text+code.slice(at+edit.old_text.length); - source(code); - } - return {...this.save({...row,source:code},"edit"),changed:code!==row.source,applied:true,edits_applied:input.edits.length}; + editDraft(input: {workspace: string; name: string; file?: string; edits: CanvasEdit[]; expected_hash?: string}) { + return this.ctx.storage.transactionSync(() => { + const row = this.row(input), project = this.projectStorage.read(row.project); + if (input.file !== undefined && !Object.hasOwn(project.files,input.file)) throw new Error("project file not found"); + const selected = input.file === undefined ? row.source : project.files[input.file]!; + if (input.expected_hash !== undefined && input.expected_hash !== (input.file === undefined ? row.source_hash : sha256(selected))) throw new Error("script changed since read; read it again before editing"); + if (!Array.isArray(input.edits) || !input.edits.length || input.edits.length>100) throw new Error("edits must contain 1 to 100 edits"); + let code = selected; + for (const edit of input.edits) { + if (!edit || typeof edit.old_text!=="string" || !edit.old_text || typeof edit.new_text!=="string") throw new Error("invalid edit"); + const at = code.indexOf(edit.old_text); + if (at<0) throw new Error("old_text not found; no changes written"); + if (code.indexOf(edit.old_text,at+1)!==-1) throw new Error("old_text is ambiguous; no changes written"); + code = code.slice(0,at)+edit.new_text+code.slice(at+edit.old_text.length); + source(code); + } + const result = input.file === undefined + ? this.save({workspace:row.workspace,name:row.name,source:code,project},"edit") + : this.save({workspace:row.workspace,name:row.name,source:row.source,project:{...project,files:{...project.files,[input.file]:code}}},"edit"); + return {...result,file:input.file,changed:code!==selected,applied:true,edits_applied:input.edits.length}; + }); } activate(input: {workspace: string; name: string; source_hash: string; code: string}) { const row = this.row(input); @@ -135,7 +158,7 @@ export class ScriptLibrary extends DurableObject { const workspace=text(input.workspace,"workspace"), id=text(input.id,"version id"); const row=this.sql.exec("select * from script_versions where workspace=? and id=?",workspace,id).toArray()[0]; if (!row) throw new Error("version not found in this workspace"); - return {...row,origin:this.sql.exec<{source_name: string;source_version_id: string}>("select source_name,source_version_id from script_remix_origins where workspace=? and name=?",workspace,row.name).toArray()[0] ?? null}; + return {...row,project:this.projectStorage.read(row.project),origin:this.sql.exec<{source_name: string;source_version_id: string}>("select source_name,source_version_id from script_remix_origins where workspace=? and name=?",workspace,row.name).toArray()[0] ?? null}; } restore(input: {workspace: string; id: string}) { const version=this.version(input); diff --git a/cloudflare/service.ts b/cloudflare/service.ts index dce6e0d..d747ffa 100644 --- a/cloudflare/service.ts +++ b/cloudflare/service.ts @@ -1,3 +1,5 @@ +import browserRuntime from "../dist/cloudflare/browser-runtime.json"; +import { resolveProject, type ArtifactProject } from "./project"; import type { DurableObjectStub, DurableObjectNamespace } from "@cloudflare/workers-types"; import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; import type { CanvasLibrary, CanvasEdit, CompiledCanvas } from "./library"; @@ -25,6 +27,7 @@ import type { PluginRequest } from "../src/plugins/types"; type Snapshot = { workspace: string; name: string; path: string; source: string; server_source: string | null; + project?: ArtifactProject; state: Record; version_id?: string | null; compiled_id?: string | null; }; type Mutation = Snapshot & { ok: boolean; applied?: boolean; changed?: boolean; restored?: boolean; source_hash?: string; edits_applied?: number; versionId?: string; revision?: number }; @@ -66,7 +69,7 @@ export class CloudCanvasService { const entries = await Promise.all(canvases.map(async canvas => ({ ...canvas, ...await this.artifacts.linkDetails(this.artifacts.target("canvas", canvas.id)) }))); return text({ canvases: entries, next_offset: canvases.length === 100 ? offset + 100 : null }); } - case "canvas_read": return text(await this.library.readRange({ workspace: this.workspace, name: args.name as string, part: args.part as "client" | "server" | undefined, start_line: args.start_line as number | undefined, end_line: args.end_line as number | undefined })); + case "canvas_read": return text(await this.library.readRange({ workspace: this.workspace, name: args.name as string, file: args.file as string | undefined, part: args.part as "client" | "server" | undefined, start_line: args.start_line as number | undefined, end_line: args.end_line as number | undefined })); case "canvas_history": { const offset = args.offset as number | undefined ?? 0; const versions = await this.library.history({ workspace: this.workspace, name: args.name as string | undefined, offset }); @@ -85,11 +88,13 @@ export class CloudCanvasService { const target = this.artifacts.target("canvas", args.name as string); if (args.slug !== undefined) await this.artifacts.requireHosted().links.check(target, args.slug as string); const generation = await this.hosted?.links.begin(target); - return this.mutationResult(await this.library.writeDraft({ workspace: this.workspace, name: args.name as string, source: args.contents as string, server_source: args.server as string | null | undefined }), generation, { ...(args.slug === undefined ? {} : { slug: args.slug as string }), ...(args.access === undefined ? {} : { access: args.access as "private" | "public" }) }); + const previous = args.project === undefined ? undefined : await this.snapshot({name: args.name as string}).catch(error => { if (error instanceof Error && error.message.includes("canvas not found")) return undefined; throw error; }); + const project = args.project === undefined ? undefined : await resolveProject(args.project, previous?.project, fetch, browserRuntime.sharedVersions); + return this.mutationResult(await this.library.writeDraft({ workspace: this.workspace, name: args.name as string, source: args.contents as string, server_source: args.server as string | null | undefined, project }), generation, { ...(args.slug === undefined ? {} : { slug: args.slug as string }), ...(args.access === undefined ? {} : { access: args.access as "private" | "public" }) }); } case "canvas_edit": { const generation = await this.hosted?.links.begin(this.artifacts.target("canvas", args.name as string)); - return this.mutationResult(await this.library.editDraft({ workspace: this.workspace, name: args.name as string, part: args.part as "client" | "server" | undefined, edits: args.edits as CanvasEdit[], expected_hash: args.expected_hash as string | undefined }), generation); + return this.mutationResult(await this.library.editDraft({ workspace: this.workspace, name: args.name as string, file: args.file as string | undefined, part: args.part as "client" | "server" | undefined, edits: args.edits as CanvasEdit[], expected_hash: args.expected_hash as string | undefined }), generation); } case "canvas_restore": { const version = await this.library.version({ workspace: this.workspace, id: args.version_id as string }); @@ -98,7 +103,7 @@ export class CloudCanvasService { } case "canvas_typecheck": { const snapshot = await this.snapshot({ name: args.name as string }); - const diagnostics = [...typecheckCanvasSource(snapshot.source), ...(snapshot.server_source === null ? [] : typecheckCanvasServerSource(snapshot.server_source))]; + const diagnostics = [...typecheckCanvasSource(snapshot.source, snapshot.project), ...(snapshot.server_source === null ? [] : typecheckCanvasServerSource(snapshot.server_source, snapshot.project))]; return text({ path: snapshot.path, check: formatCanvasCheck(diagnostics), diagnostics }, diagnostics.length > 0); } case "canvas_compile": { @@ -135,7 +140,7 @@ export class CloudCanvasService { if (!compiled.ok || !compiled.js || !compiled.artifact) return { ...base, ok: false, _meta: undefined }; const { version, event } = await this.library.recordServe({ workspace: snapshot.workspace, name: snapshot.name, source: snapshot.source, - server_source: snapshot.server_source, + server_source: snapshot.server_source, project: snapshot.project, runtime: compiled.artifact.runtime, compiled_id: compiled.artifact.id, initial_state: snapshot.state, mode: "preview", ...(snapshot.version_id ? { version_id: snapshot.version_id } : {}), }); @@ -144,7 +149,7 @@ export class CloudCanvasService { } private async mutationResult(mutation: Mutation, generation?: number | null, settings: LinkUpdate = {}): Promise { - const { source, server_source, state, ...summary } = mutation; + const { source, server_source, state, project, ...summary } = mutation; const target = this.artifacts.target("canvas", mutation.name); if (generation != null) { await this.hosted!.links.stage(target, generation, settings); @@ -181,22 +186,22 @@ export class CloudCanvasService { // The exact pair and compiler identity prevent client-only hashes from // hiding server edits, and let unchanged writes reuse completed work. const saved = await this.library.compiled({ workspace: snapshot.workspace, name: snapshot.name, - ...(snapshot.compiled_id ? { id: snapshot.compiled_id } : { source: snapshot.source, server_source: snapshot.server_source, runtime }), + ...(snapshot.compiled_id ? { id: snapshot.compiled_id } : { source: snapshot.source, server_source: snapshot.server_source, project: snapshot.project, runtime }), }); if (saved) return { ok: true, js: saved.client_js, diagnostics: [], artifact: saved }; - const client = await compileCanvasSource(snapshot.source); + const client = await compileCanvasSource(snapshot.source, snapshot.project); if (!client.ok || !client.js) return client; - const server = snapshot.server_source === null ? null : await compileCanvasServerSource(snapshot.server_source); + const server = snapshot.server_source === null ? null : await compileCanvasServerSource(snapshot.server_source, snapshot.project); if (server && (!server.ok || !server.js)) return { ok: false, diagnostics: server.diagnostics }; const artifact = await this.library.saveCompiled({ workspace: snapshot.workspace, name: snapshot.name, - source: snapshot.source, server_source: snapshot.server_source, runtime, client_js: client.js, server_js: server?.js ?? null, + source: snapshot.source, server_source: snapshot.server_source, project: snapshot.project, runtime, client_js: client.js, server_js: server?.js ?? null, }); return { ok: true, js: artifact.client_js, diagnostics: [], artifact }; } private async savedCompilation(snapshot: Snapshot): Promise { const artifact = snapshot.version_id && !snapshot.compiled_id ? null : await this.library.compiled({ workspace: snapshot.workspace, name: snapshot.name, - ...(snapshot.compiled_id ? { id: snapshot.compiled_id } : { source: snapshot.source, server_source: snapshot.server_source }), + ...(snapshot.compiled_id ? { id: snapshot.compiled_id } : { source: snapshot.source, server_source: snapshot.server_source, project: snapshot.project }), }); if (artifact) return { ok: true, js: artifact.client_js, diagnostics: [], artifact }; // Old installations have source/history but no saved bundles. Backfill via diff --git a/cloudflare/tool-contract.ts b/cloudflare/tool-contract.ts index abd1338..29307ee 100644 --- a/cloudflare/tool-contract.ts +++ b/cloudflare/tool-contract.ts @@ -88,7 +88,7 @@ addScriptTool("artifact_link", "Give a canvas or script a chosen root URL. Canva }, ["kind", "name", "slug"]); addScriptTool("script_guide", "Read the hosted script and root-URL authoring guide before creating a script. Covers runtime, access, storage, secrets, validation, HTTP requests, and bundling third-party dependencies.", {}); CLOUD_MCP_TOOLS[CLOUD_MCP_TOOLS.length - 1]!.annotations = { readOnlyHint: true }; -addScriptTool("script_write", "Call script_guide first. Create or replace arbitrary Workers-compatible TypeScript exporting default { fetch(request, env, ctx) }. Saved drafts and history are retained even when validation fails; only valid updates replace the running code. Scripts have outbound fetch, env.secrets, and env.sql for persistent SQLite. Slug defaults to the name; URLs use /. One module only: Workers/Node builtins or prebundled third-party code (see script_guide). No package installation at request time. This loads the module for validation but does not execute the handler.", { +addScriptTool("script_write", "Call script_guide first. Create or replace arbitrary Workers-compatible TypeScript exporting default { fetch(request, env, ctx) }. Saved drafts and history are retained even when validation fails; only valid updates replace the running code. Scripts have outbound fetch, env.secrets, and env.sql for persistent SQLite. Slug defaults to the name; URLs use /. Use project.files for relative modules and project.dependencies for exact package versions. Dependencies are resolved and integrity-verified at write time, then archived as a source lock; execution and replay do not install packages. This loads the module for validation but does not execute the handler.", { ...nameProperty, contents: { type: "string", maxLength: 262144 }, ...slugProperties, }, ["name", "contents"]); addScriptTool("script_read", "Read script source with bounded line ranges, optionally from a historical version. Does not execute the script.", { @@ -113,3 +113,13 @@ addScriptTool("canvas_plugin_call", "Call a deployment-installed server function plugin: { type: "string", minLength: 1 }, operation: { type: "string", minLength: 1 }, input: {}, }, ["plugin", "operation", "input"]); CLOUD_MCP_TOOLS[CLOUD_MCP_TOOLS.length - 1]!.annotations = { readOnlyHint: false, destructiveHint: true, openWorldHint: true }; + +// Project source is additive to legacy entrypoints; dependency locks are generated by the service. +const projectProperty = { type: "object", properties: { + files: { type: "object", maxProperties: 64, additionalProperties: { type: "string" }, description: "Relative helper modules (.ts/.tsx/.js/.json). Import from ./path. Full replacement; omit project to preserve existing project." }, + dependencies: { type: "object", maxProperties: 32, additionalProperties: { type: "string" }, description: "npm package names mapped to exact versions, for example {hono: '4.13.7'}. Resolved only on dependency changes; source and transitive dependency contents are archived together." }, +}, additionalProperties: false }; +for (const tool of CLOUD_MCP_TOOLS) { + if (tool.name === "canvas_write" || tool.name === "script_write") tool.inputSchema.properties!.project = projectProperty; + if (["canvas_read", "canvas_edit", "script_read", "script_edit"].includes(tool.name)) tool.inputSchema.properties!.file = {type:"string", description:"Select a relative project.files helper module instead of the entrypoint."}; +} diff --git a/cloudflare/worker.ts b/cloudflare/worker.ts index ad6f2fe..84b30ad 100644 --- a/cloudflare/worker.ts +++ b/cloudflare/worker.ts @@ -153,11 +153,13 @@ app.post("/api/tools", async c => { app.get("/api/source", async c => { if (c.req.query("kind") === "script") { const source = await c.get("service").scriptReadSource({ name: c.req.query("name"), version_id: c.req.query("version") }); + if (c.req.query("format") === "json") return c.json(source); c.header("Content-Type", "text/plain; charset=utf-8"); if (c.req.query("download") === "1") c.header("Content-Disposition", 'attachment; filename="script.ts"'); return c.body(source.source); } const snapshot = await c.get("service").snapshot({ name: c.req.query("name"), version_id: c.req.query("version") }); + if (c.req.query("format") === "json") return c.json(snapshot); c.header("Content-Type", "text/plain; charset=utf-8"); if (c.req.query("download") === "1") c.header("Content-Disposition", `attachment; filename="canvas.canvas.tsx"; filename*=UTF-8''${encodeURIComponent(snapshot.name + ".canvas.tsx")}`); return c.body(snapshot.source); diff --git a/docs/scripts.md b/docs/scripts.md index 5e48dbd..69f2906 100644 --- a/docs/scripts.md +++ b/docs/scripts.md @@ -34,7 +34,7 @@ export default { } satisfies ExportedHandler; ``` -`ScriptEnv` is available during type checking. It contains `secrets: Record` and `sql: SqlStorage`. Outbound `fetch` is available. `ctx.waitUntil` can extend background work associated with a request. Scripts can import supported `cloudflare:` and `node:` builtins. Source is currently a single TypeScript module; third-party packages must be bundled into that source beforehand. Code must be compatible with the Workers runtime; this is not a general server process or scheduling system. +`ScriptEnv` is available during type checking. It contains `secrets: Record` and `sql: SqlStorage`. Outbound `fetch` is available. `ctx.waitUntil` can extend background work associated with a request. Scripts can import supported `cloudflare:` and `node:` builtins. Use relative helper modules and exact npm dependencies through the project source editor or MCP project fields. Code must be compatible with the Workers runtime; this is not a general server process or scheduling system. Use `env.secrets.NAME` for a configured secret. For example, a handler can verify a provider signature against the original request body. Script HTTP requests preserve their methods, query strings, and body bytes. The gateway strips cookies and Cloudflare Access assertion/service-token headers; private routes also strip the authorization credential used for management authentication. @@ -86,28 +86,24 @@ Agents use `script_write`, `script_read`, `script_edit`, `script_list`, `script_ ## Third-party dependencies -The hosted compiler does not install bun dependencies or resolve a multi-file project. For a Workers-compatible package, keep your original TypeScript, `package.json`, and lockfile locally. Install an exact package version with `bun add --exact @` and typecheck the original source with your project's TypeScript setup. +Scripts and canvases accept an optional `project` alongside their existing entrypoint source: -Bundle the handler locally before calling `script_write`. For example, a Hono handler can be bundled with this `bundle.ts`: - -```ts -const build = await Bun.build({ - entrypoints: ["handler.ts"], - target: "browser", - format: "esm", - external: ["node:*", "cloudflare:*"], - banner: "// @ts-nocheck", - minify: true, -}); -if (!build.success) throw new Error(build.logs.join("\n")); -await Bun.write("handler.bundle.js", await build.outputs[0]!.text()); +```json +{ + "files": { "lib/message.ts": "export const message = 'Hello';" }, + "dependencies": { "hono": "4.13.7" } +} ``` -Run `bun run bundle.ts`, read `handler.bundle.js`, and pass its complete contents as `script_write.contents`. The banner applies only to generated JavaScript: it prevents strict implicit-any diagnostics caused by erased TypeScript types. Typecheck handwritten source before bundling. Runtime module initialization and handler-shape validation still apply. +Import helpers with relative paths such as `./lib/message`, and import packages normally. The gallery source editor provides a file picker, add/remove helper controls, and an exact-version dependency editor. `script_read`, `script_edit`, `canvas_read`, and `canvas_edit` accept `file` to target a helper. Omit `project` on write to retain it; when supplied, its files and dependency declarations replace the whole project. Read responses include the complete project snapshot. Historical views are read-only and restoring a revision restores every source file and its dependency lock. + +Dependency declarations accept exact npm versions only. The service resolves packages when declarations change, verifies tarball integrity, and archives the package source and types with the artifact revision. Edits with unchanged dependencies, replay, and execution use this archived snapshot without registry access. Package installation scripts never execute. Download/decompression, file counts, and stored content are bounded. The service only accesses the public npm registry and rejects arbitrary tarball origins. + +The compiler currently supports one version of each package. Conflicting transitive requirements and missing required peers produce explicit diagnostics. Browser React, ReactDOM and React Router dependencies use Canvas's own compatible runtime so hooks do not load a second React. Packages must support the browser or Workers environment; native addons and packages requiring a Node process are unsupported. Source archives contain up to 64 helper files (1 MiB total) and up to 64 resolved packages with a dependency lock bounded to 4,096 files and 8 MiB. The entrypoint remains limited to 256 KiB. -Keep the bundle within the 256 KiB UTF-8 source limit. Packages requiring native addons or a Node process cannot run in Workers. Rebuild from original source when changing bundled scripts; do not use targeted text edits on generated dependency internals. Worker and Node builtins remain external because the runtime supplies them. Scripts cannot import dependencies through adjacent files or package URLs. +Hosted Cloudflare and celld deployments use the same project contract. Local canvas CLI and MCP also persist the project beside the canvas and carry its files and dependency snapshot through history and remix. Use `canvas write NAME --file SOURCE --project PROJECT_JSON`, where the JSON contains files and exact dependency declarations, or pass `project` to local MCP `canvas_write`. Source-only local writes preserve the project; update helpers by replacing `project.files`. Standalone script tools remain hosted-only. -Agents can call `script_guide` to retrieve this workflow through MCP. `script_write` points to that guide; hosted `canvas_guide` includes the same script guidance. +Agents can call `script_guide` to retrieve the authoring contract through MCP. ## Remix diff --git a/e2e/gallery-scripts.test.ts b/e2e/gallery-scripts.test.ts index de72a8c..965b8b9 100644 --- a/e2e/gallery-scripts.test.ts +++ b/e2e/gallery-scripts.test.ts @@ -15,7 +15,12 @@ test("gallery edits scripts without execution and runs requests only on demand", if (url.pathname === "/api/session") return Response.json({}); if (url.pathname === "/api/gallery") return Response.json({ workspace: "default", artifacts: hosted ? artifacts : [{ key: "local", name: "local", workspace: "default", working: true, versions: [] }], ...(hosted ? { capabilities: { scripts: true, links: true } } : {}) }); if (url.pathname === "/gallery/preview") return new Response("

Canvas preview

", { headers: { "content-type": "text/html" } }); - if (url.pathname === "/api/source") return new Response('export default { fetch() { return new Response("hello"); } };'); + if (url.pathname === "/api/source") { + const source = 'export default { fetch() { return new Response("hello"); } };'; + return url.searchParams.get("format") === "json" + ? Response.json({ source, project: { files: {}, dependencies: {}, lock: {} } }) + : new Response(source); + } if (url.pathname === "/api/tools") { const call = await request.json() as typeof calls[number]; calls.push(call); if (call.name === "script_write" && call.arguments.name !== "hello") artifacts.push({ key: "script:new", kind: "script", name: call.arguments.name, workspace: "default", working: true, versions: [] }); diff --git a/package.json b/package.json index a05e6c1..8289728 100644 --- a/package.json +++ b/package.json @@ -88,7 +88,10 @@ "src/serve.ts", "src/service.ts", "src/typecheck.ts", - "src/viewer.ts" + "src/viewer.ts", + "src/localProject.ts", + "cloudflare/project.ts", + "src/gallery/project-editor.tsx" ], "publishConfig": { "access": "public" @@ -126,6 +129,7 @@ "react": "^19.2.8", "react-dom": "^19.2.8", "react-router": "^7", + "semver": "7.7.3", "typescript": "5.9.3" }, "devDependencies": { @@ -134,6 +138,7 @@ "@cloudflare/workers-types": "5.20260907.1", "@modelcontextprotocol/sdk": "^1.30.0", "@types/bun": "^1.3.14", + "@types/semver": "7.7.1", "ajv": "8.20.0", "hono": "4.13.7", "jose": "6.2.12", diff --git a/scripts/prepare-cloudflare.ts b/scripts/prepare-cloudflare.ts index f7e76c2..190a5fe 100644 --- a/scripts/prepare-cloudflare.ts +++ b/scripts/prepare-cloudflare.ts @@ -8,6 +8,12 @@ import standaloneCode from "ajv/dist/standalone"; import { CLOUD_MCP_TOOLS } from "../cloudflare/tool-contract"; import { createHash } from "node:crypto"; import * as sdk from "../src/sdk"; +import * as react from "react"; +import * as reactDom from "react-dom"; +import * as reactDomClient from "react-dom/client"; +import * as reactRouter from "react-router"; +import * as jsx from "react/jsx-runtime"; +import * as jsxDev from "react/jsx-dev-runtime"; import { preparePlugins } from "./prepare-plugins"; // Ship the lockfile-resolved dependencies with the compiler. Compiling a canvas @@ -53,6 +59,8 @@ if (!runtimeBuild.success) throw new Error(runtimeBuild.logs.join("\n")); const runtimeJs = await runtimeBuild.outputs[0]!.text(); await writeFile(join(dirname(output), "browser-runtime.json"), JSON.stringify({ js: runtimeJs, + sharedVersions: Object.fromEntries(await Promise.all(["react", "react-dom", "react-router"].map(async name => [name, JSON.parse(await readFile(join(root, "node_modules", name, "package.json"), "utf8")).version]))), + sharedModules: Object.fromEntries(Object.entries({ react: { value: react, key: "react" }, "react-dom": { value: reactDom, key: "reactDom" }, "react-dom/client": { value: reactDomClient, key: "reactDomClient" }, "react-router": { value: reactRouter, key: "reactRouter" }, "react/jsx-runtime": { value: jsx, key: "jsx" }, "react/jsx-dev-runtime": { value: jsxDev, key: "jsxDev" } }).map(([name, {value, key}]) => [name, `export default globalThis.__herdrCanvasRuntime.${key};\n` + Object.keys(value).filter(item => item !== "default" && /^[A-Za-z_$][\w$]*$/.test(item)).map(item => `export const ${item} = globalThis.__herdrCanvasRuntime.${key}.${item};`).join("\n")])), sdkModule: Object.keys(sdk).sort().map(name => `export const ${name} = globalThis.__herdrCanvasRuntime.sdk.${name};`).join("\n"), })); const identity = createHash("sha256").update(JSON.stringify(files)).update(runtimeJs) diff --git a/src/cli.ts b/src/cli.ts index a402e5c..89710d1 100755 --- a/src/cli.ts +++ b/src/cli.ts @@ -164,7 +164,8 @@ async function main(): Promise { const name = args.positionals[0]; requireArg(name, "missing canvas name"); const contents = await readContents(args); - printJson(service.write(name, contents)); + const projectFile = flagString(args.flags, "project"); + printJson(projectFile ? await service.writeProject(name, contents, JSON.parse(await Bun.file(projectFile).text())) : service.write(name, contents)); return; } @@ -172,7 +173,7 @@ async function main(): Promise { const name = args.positionals[0]; requireArg(name, "missing canvas name"); const lineFlag = (key: string) => args.flags[key] === undefined ? undefined : Number(args.flags[key] === true ? NaN : args.flags[key]); - printJson(service.readRange(name, { start_line: lineFlag("start-line"), end_line: lineFlag("end-line") })); + printJson(service.readRange(name, { file: flagString(args.flags, "source-file"), start_line: lineFlag("start-line"), end_line: lineFlag("end-line") })); return; } @@ -182,7 +183,7 @@ async function main(): Promise { const payload: unknown = JSON.parse(await readContents(args)); if (!payload || typeof payload !== "object" || Array.isArray(payload)) throw new Error("edit input must be an object with edits and optional expected_hash"); const input = payload as { edits: Parameters[1]; expected_hash?: string }; - const result = service.edit(name, input.edits, input.expected_hash); + const result = service.edit(name, input.edits, input.expected_hash, flagString(args.flags, "source-file")); printJson(result); if (!result.ok) process.exitCode = 1; return; @@ -283,9 +284,9 @@ function printHelp(): void { Usage: canvas --version canvas list [--dir PATH] - canvas write NAME [--file PATH | --stdin] - canvas read NAME [--start-line N] [--end-line N] - canvas edit NAME [--file PATH | --stdin] + canvas write NAME [--file PATH | --stdin] [--project PROJECT_JSON] + canvas read NAME [--source-file PATH] [--start-line N] [--end-line N] + canvas edit NAME [--source-file PATH] [--file PATH | --stdin] canvas typecheck NAME canvas compile NAME canvas open NAME [--placement split|tab|zoomed|overlay] [--no-open] @@ -308,11 +309,11 @@ edit accepts JSON: {"edits":[{"old_text":"exact match","new_text":"replacement"} Edits run sequentially in memory; every old_text must match exactly once. Invalid batches write nothing. Successful batches replace atomically, then typecheck once; diagnostics do not roll back applied edits. Results omit source text. -History keeps raw TSX only. Opening a saved version builds with the installed SDK and uses +History keeps source and dependency snapshots. Opening a saved version builds with the installed SDK and uses isolated UI state. Restore archives the working copy and adds a new revision. -Canvases live in /canvases/*.canvas.tsx and may import only from -"sidequery/canvas". open compiles the artifact and creates a managed Canvas pane +Canvases live in /canvases/*.canvas.tsx and import the SDK from +"sidequery/canvas", plus declared project helpers and dependencies. open creates a managed Canvas pane powered internally by Terminal Browser in app mode. `); } diff --git a/src/compile.ts b/src/compile.ts index 49abf4a..b606028 100644 --- a/src/compile.ts +++ b/src/compile.ts @@ -1,13 +1,14 @@ +import { readLocalProject, materializeProject, projectDiagnostics } from "./localProject"; +import type { ArtifactProject } from "../cloudflare/project"; import type { BunPlugin } from "bun"; import { createRequire } from "node:module"; import { readFileSync } from "node:fs"; -import { join, resolve } from "node:path"; +import { dirname, join, resolve } from "node:path"; -import { sandboxToDiagnostics, type Diagnostic } from "./diagnostics"; +import { type Diagnostic } from "./diagnostics"; import { PLUGIN_ROOT, SDK_ENTRY, WRAPPER_ENTRY } from "./paths"; import { loadBrowserPlugins } from "./plugins/browser"; import type { BrowserPlugins } from "./plugins/types"; -import { scanCanvasSource } from "./sandbox"; const requireFromPlugin = createRequire(join(PLUGIN_ROOT, "package.json")); @@ -41,21 +42,30 @@ export function canvasAliasPlugin(canvasPath: string, source?: string, plugins: build.onResolve({ filter: /^(react|react-dom|react-router)(\/.*)?$/ }, (args) => ({ path: requireFromPlugin.resolve(args.path), })); + // User modules resolve only inside the materialized snapshot, never host files. + build.onResolve({ filter: /.*/ }, args => { + if (!args.importer.startsWith(dirname(absCanvas) + "/")) return; + const path = Bun.resolveSync(args.path, args.resolveDir || dirname(args.importer)); + if (!path.startsWith(dirname(absCanvas) + "/")) throw new Error(`import escapes canvas project: ${args.path}`); + return { path }; + }); + }, }; } -export async function compileCanvas(canvasPath: string, snapshot?: string, plugins: BrowserPlugins = loadBrowserPlugins()): Promise { +export async function compileCanvas(canvasPath: string, snapshot?: string, plugins: BrowserPlugins = loadBrowserPlugins(), project: ArtifactProject = readLocalProject(canvasPath)): Promise { const absCanvas = resolve(canvasPath); const source = snapshot ?? readFileSync(absCanvas, "utf8"); - const violations = scanCanvasSource(source, Object.keys(plugins.paths)); + const violations = projectDiagnostics(absCanvas, source, project, Object.keys(plugins.paths)); if (violations.length > 0) { return { ok: false, - diagnostics: sandboxToDiagnostics(absCanvas, violations), + diagnostics: violations, }; } + const snapshotProject = materializeProject(source, project); let result: Awaited>; try { result = await Bun.build({ @@ -67,21 +77,23 @@ export async function compileCanvas(canvasPath: string, snapshot?: string, plugi define: { "process.env.NODE_ENV": JSON.stringify("production"), }, - plugins: [canvasAliasPlugin(absCanvas, source, plugins)], + plugins: [canvasAliasPlugin(snapshotProject.path, source, plugins)], }); } catch (error) { + snapshotProject.dispose(); return { ok: false, diagnostics: [ { severity: "error", - message: error instanceof Error ? error.message : String(error), + message: error instanceof AggregateError ? [...error.errors].map(String).join("\n") : error instanceof Error ? error.message : String(error), file: absCanvas, }, ], }; } + snapshotProject.dispose(); if (!result.success) { return { ok: false, diff --git a/src/gallery/client.tsx b/src/gallery/client.tsx index 27bdd1e..a844457 100644 --- a/src/gallery/client.tsx +++ b/src/gallery/client.tsx @@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { createRoot } from "react-dom/client"; import { authClient, signInUrl } from "../auth/client-api"; import type { GalleryArtifact, GalleryData } from "./types"; -import { LinkSettings, ScriptPanel } from "./hosted"; +import { CanvasSourcePanel, LinkSettings, ScriptPanel } from "./hosted"; import { canvasFileTransferUrl } from "../sdk/files"; import { RemixPanel } from "./remix"; @@ -518,6 +518,8 @@ function App() { onLoad={() => setPreviewLoading(false)} /> + ) : gallery?.capabilities?.links ? ( + { setSelectedVersion("working"); await loadGallery(); }} /> ) : source.status === "ready" ? (
{source.text}
) : source.status === "error" ? ( diff --git a/src/gallery/hosted.tsx b/src/gallery/hosted.tsx index dcc75ff..a424799 100644 --- a/src/gallery/hosted.tsx +++ b/src/gallery/hosted.tsx @@ -1,4 +1,5 @@ import { useEffect, useState } from "react"; +import { ProjectEditor, editableProject, emptyEditableProject, type EditableProject } from "./project-editor"; import type { GalleryArtifact } from "./types"; type ToolResult = { error?: string; isError?: boolean; content?: { type: string; text?: string }[]; structuredContent?: unknown }; @@ -44,6 +45,15 @@ export function LinkSettings({ artifact, onSaved }: { artifact: GalleryArtifact; {status} ; } +type SourceSnapshot = { source: string; server_source?: string | null; project: EditableProject }; +async function loadSourceSnapshot(sourceUrl: string, signal: AbortSignal): Promise { + const url = new URL(sourceUrl, window.location.href); url.searchParams.set("format", "json"); + const response = await fetch(url, { signal }); + if (!response.ok) throw new Error(`Source request failed (${response.status})`); + const value = await response.json() as SourceSnapshot; + if (typeof value.source !== "string" || (value.server_source !== undefined && value.server_source !== null && typeof value.server_source !== "string")) throw new Error("Invalid source snapshot."); + return { ...value, project: editableProject(value.project) }; +} const initialSource = `export default {\n async fetch(request, env, ctx) {\n return Response.json({ message: "Hello" });\n },\n} satisfies ExportedHandler;\n`; export function ScriptPanel({ artifact, workspace, version, sourceUrl, onSaved, onCancel }: { artifact?: GalleryArtifact; workspace: string; version?: string; sourceUrl?: string; onSaved: (name: string) => Promise; onCancel?: () => void }) { const [name, setName] = useState(artifact?.name ?? ""); @@ -51,6 +61,10 @@ export function ScriptPanel({ artifact, workspace, version, sourceUrl, onSaved, const [access, setAccess] = useState(artifact?.access ?? "private"); const [contents, setContents] = useState(artifact ? "" : initialSource); const [loaded, setLoaded] = useState(!artifact); + const [loadedUrl, setLoadedUrl] = useState(undefined); + const [project, setProject] = useState(emptyEditableProject); + const [projectValid, setProjectValid] = useState(true); + const [projectLoad, setProjectLoad] = useState(0); const [busy, setBusy] = useState(false); const [error, setError] = useState(""); const [status, setStatus] = useState(""); @@ -66,9 +80,10 @@ export function ScriptPanel({ artifact, workspace, version, sourceUrl, onSaved, useEffect(() => { if (!sourceUrl) return; const controller = new AbortController(); setLoaded(false); setError(""); - void fetch(sourceUrl, { signal: controller.signal }).then(async response => { if (!response.ok) throw new Error(`Source request failed (${response.status})`); return response.text(); }).then(text => { if (!controller.signal.aborted) { setContents(text); setLoaded(true); } }).catch(error => { if (!controller.signal.aborted) setError(message(error)); }); + void loadSourceSnapshot(sourceUrl, controller.signal).then(snapshot => { if (!controller.signal.aborted) { setContents(snapshot.source); setProject(snapshot.project); setProjectValid(true); setProjectLoad(value => value + 1); setLoadedUrl(sourceUrl); setLoaded(true); } }).catch(error => { if (!controller.signal.aborted) setError(message(error)); }); return () => controller.abort(); }, [sourceUrl]); + const sourceReady = loaded && (!artifact || (!!sourceUrl && loadedUrl === sourceUrl)); async function perform(action: () => Promise) { setBusy(true); setError(""); setStatus(""); try { await action(); } catch (error) { setError(message(error)); } finally { setBusy(false); } @@ -77,9 +92,9 @@ export function ScriptPanel({ artifact, workspace, version, sourceUrl, onSaved, {!artifact ?
: null} {error ?

{error}

: null} {status ?

{status}

: null} -