From 0c38513e294f94aa719fca2be112f4a6a35946c0 Mon Sep 17 00:00:00 2001 From: Nico Ritschel Date: Mon, 7 Sep 2026 20:03:41 -0700 Subject: [PATCH 1/3] Rename Canvas to Artifacts --- .dockerignore | 7 +- .github/workflows/ci.yml | 4 +- .github/workflows/publish.yml | 12 +- .gitignore | 11 +- Dockerfile | 8 +- LICENSE | 2 +- README.md | 225 +++++++------ ...anvas-api.test.ts => artifact-api.test.ts} | 18 +- cloudflare/{canvas-api.ts => artifact-api.ts} | 12 +- ...ion.test.ts => artifact-execution.test.ts} | 10 +- ...vas-execution.ts => artifact-execution.ts} | 38 +-- cloudflare/artifact-routes.ts | 70 ++-- cloudflare/artifact-service.ts | 6 +- cloudflare/auth-flow.test.ts | 60 ++-- cloudflare/auth.test.ts | 12 +- cloudflare/backend-test-worker.ts | 14 + cloudflare/backend.test.ts | 51 +++ cloudflare/backend.ts | 49 +-- cloudflare/better-auth.ts | 47 +-- cloudflare/celld.test.ts | 30 +- ...er.ts => compiled-artifact-test-worker.ts} | 10 +- ...nvas.test.ts => compiled-artifact.test.ts} | 98 +++--- cloudflare/compiler.test.ts | 108 +++--- cloudflare/compiler.ts | 63 ++-- cloudflare/dev.ts | 10 +- cloudflare/files-test-worker.ts | 10 +- cloudflare/files.test.ts | 55 +-- cloudflare/files.ts | 59 ++-- cloudflare/library-test-worker.ts | 2 +- cloudflare/library.test.ts | 72 ++-- cloudflare/library.ts | 87 ++--- cloudflare/links.test.ts | 41 ++- cloudflare/links.ts | 14 +- cloudflare/mcp.ts | 16 +- cloudflare/migrations/0001_auth.sql | 2 +- cloudflare/plugins.test.ts | 4 +- cloudflare/plugins.ts | 6 +- cloudflare/project.ts | 4 +- cloudflare/scheduling-celld.test.ts | 6 +- cloudflare/script-backend.ts | 12 +- cloudflare/script-guide.ts | 10 +- cloudflare/script-service-http.ts | 4 +- cloudflare/script-service.ts | 12 +- cloudflare/scripts.test.ts | 2 +- cloudflare/scripts.ts | 4 +- cloudflare/service.ts | 164 ++++----- cloudflare/team-auth.test.ts | 42 ++- cloudflare/team-auth.ts | 2 +- cloudflare/tool-contract.ts | 66 ++-- cloudflare/worker.test.ts | 312 +++++++++--------- cloudflare/worker.ts | 62 ++-- cloudflare/wrangler.compiler.jsonc | 2 +- docs/architecture.md | 28 +- docs/cloudflare.md | 122 +++---- docs/daemon.md | 48 +-- docs/files.md | 46 +-- docs/releasing.md | 14 +- docs/routing.md | 18 +- docs/runtime-plugins.md | 22 +- docs/scripts.md | 30 +- e2e/browser-plugins.test.ts | 8 +- e2e/celld-auth-runtime.ts | 4 +- e2e/gallery-scripts.test.ts | 2 +- e2e/herdr-pty.py | 2 +- e2e/herdr.e2e.test.ts | 6 +- e2e/inside.ts | 82 ++--- e2e/mcp-app-host.ts | 6 +- e2e/mcp-app.test.ts | 222 +++++++------ e2e/oidc-fixture.ts | 4 +- e2e/plugin-bridge.test.ts | 18 +- e2e/routing.test.ts | 39 ++- e2e/runner-status.test.ts | 40 +-- ...s.server.ts => counter.artifact.server.ts} | 2 +- ...ounter.canvas.tsx => counter.artifact.tsx} | 6 +- .../{files.canvas.tsx => files.artifact.tsx} | 16 +- examples/overview.artifact.tsx | 20 ++ examples/overview.canvas.tsx | 20 -- examples/runner-status/README.md | 50 +-- ...canvas.plugins.ts => artifacts.plugins.ts} | 2 +- examples/runner-status/collector.ts | 2 +- examples/runner-status/local.test.ts | 18 +- examples/runner-status/local.ts | 22 +- examples/runner-status/local.worker.ts | 10 +- examples/runner-status/plugin.test.ts | 2 +- examples/runner-status/plugin.ts | 4 +- ....canvas.tsx => runner-status.artifact.tsx} | 6 +- examples/runner-status/worker.test.ts | 4 +- herdr-plugin.toml | 14 +- package.json | 20 +- scripts/generate-auth-migration.ts | 6 +- scripts/package.integration.test.ts | 84 ++--- scripts/prepare-browser-plugins.ts | 12 +- scripts/prepare-cloudflare.ts | 12 +- scripts/prepare-plugins.test.ts | 15 +- scripts/prepare-plugins.ts | 18 +- scripts/seed-cloudflare.ts | 10 +- skills/{herdr-canvas => artifacts}/SKILL.md | 46 +-- src/args.test.ts | 4 +- src/artifactFile.test.ts | 60 ++++ src/{canvasFile.ts => artifactFile.ts} | 55 +-- src/auth/client.tsx | 8 +- src/auth/server.ts | 2 +- src/canvasFile.test.ts | 41 --- src/cli.test.ts | 72 ++-- src/cli.ts | 90 ++--- src/compile.test.ts | 60 ++-- src/compile.ts | 36 +- src/diagnostics.test.ts | 14 +- src/diagnostics.ts | 6 +- src/edit.test.ts | 44 +-- src/gallery.test.ts | 38 +-- src/gallery/client.tsx | 74 ++--- src/gallery/execution-controls.tsx | 2 +- src/gallery/hosted.tsx | 12 +- src/gallery/project-editor.test.tsx | 4 +- src/gallery/project-editor.tsx | 2 +- src/gallery/remix.tsx | 2 +- src/gallery/server.ts | 21 +- src/gallery/types.ts | 2 +- src/herdr.test.ts | 24 +- src/herdr.ts | 20 +- src/history-serving.test.ts | 130 +++++--- src/history.test.ts | 40 ++- src/history.ts | 18 +- src/html.ts | 15 +- src/httpTypes.ts | 4 +- src/local/celld-runtime.test.ts | 25 +- src/local/celld-runtime.ts | 10 +- src/local/preview-daemon.test.ts | 40 +-- src/local/preview-daemon.ts | 32 +- src/local/server-daemon.test.ts | 57 +++- src/local/server-daemon.ts | 48 ++- src/local/server.ts | 8 +- src/localProject.test.ts | 66 ++-- src/localProject.ts | 22 +- src/mcp/app-contract.ts | 12 +- src/mcp/app.test.ts | 96 +++--- src/mcp/app.ts | 60 ++-- src/mcp/guide.ts | 58 ++-- src/mcp/local-tools.test.ts | 82 ++--- src/mcp/local-tools.ts | 62 ++-- src/mcp/stdio.ts | 4 +- src/mcp/tools.ts | 64 ++-- src/open.test.ts | 32 +- src/open.ts | 62 ++-- src/plugins/browser.test.ts | 40 +-- src/plugins/config.ts | 4 +- src/remix.test.ts | 34 +- src/runtime/artifacts-entry.d.ts | 5 + src/runtime/cloud-runtime.ts | 12 +- src/runtime/gallery-request.ts | 28 +- src/runtime/herdr-canvas-entry.d.ts | 5 - src/runtime/mcp-app.ts | 104 +++--- src/runtime/navigation-host.ts | 24 +- src/runtime/wrapper.tsx | 10 +- src/sandbox.test.ts | 41 ++- src/sandbox.ts | 30 +- src/sdk/files.test.ts | 82 +++-- src/sdk/files.ts | 56 ++-- src/sdk/hooks.ts | 34 +- src/sdk/index.ts | 27 +- src/sdk/plugins.test.ts | 16 +- src/sdk/plugins.ts | 2 +- src/sdk/routing.tsx | 20 +- src/sdk/server.test.ts | 50 +-- src/sdk/server.ts | 30 +- src/sdk/tokens.ts | 24 +- src/sdk/ui.tsx | 10 +- src/serve.test.ts | 18 +- src/serve.ts | 107 +++--- src/service.test.ts | 18 +- src/service.ts | 176 +++++----- src/test/fixtures.ts | 18 +- src/test/plugins/browser.tsx | 4 +- src/test/routing.ts | 4 +- src/test/setup.ts | 2 +- src/typecheck.test.ts | 26 +- src/typecheck.ts | 24 +- src/viewer.test.ts | 43 ++- src/viewer.ts | 82 ++--- tsconfig.json | 31 +- 181 files changed, 3356 insertions(+), 2854 deletions(-) rename cloudflare/{canvas-api.test.ts => artifact-api.test.ts} (63%) rename cloudflare/{canvas-api.ts => artifact-api.ts} (75%) rename cloudflare/{canvas-execution.test.ts => artifact-execution.test.ts} (92%) rename cloudflare/{canvas-execution.ts => artifact-execution.ts} (71%) create mode 100644 cloudflare/backend-test-worker.ts create mode 100644 cloudflare/backend.test.ts rename cloudflare/{compiled-canvas-test-worker.ts => compiled-artifact-test-worker.ts} (86%) rename cloudflare/{compiled-canvas.test.ts => compiled-artifact.test.ts} (57%) rename examples/{counter.canvas.server.ts => counter.artifact.server.ts} (95%) rename examples/{counter.canvas.tsx => counter.artifact.tsx} (79%) rename examples/{files.canvas.tsx => files.artifact.tsx} (55%) create mode 100644 examples/overview.artifact.tsx delete mode 100644 examples/overview.canvas.tsx rename examples/runner-status/{canvas.plugins.ts => artifacts.plugins.ts} (78%) rename examples/runner-status/{runner-status.canvas.tsx => runner-status.artifact.tsx} (99%) rename skills/{herdr-canvas => artifacts}/SKILL.md (52%) create mode 100644 src/artifactFile.test.ts rename src/{canvasFile.ts => artifactFile.ts} (52%) delete mode 100644 src/canvasFile.test.ts create mode 100644 src/runtime/artifacts-entry.d.ts delete mode 100644 src/runtime/herdr-canvas-entry.d.ts diff --git a/.dockerignore b/.dockerignore index 5ec02d3..047df2f 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,14 +1,17 @@ node_modules .git -.herdr-canvas +.artifacts *.log .DS_Store .agents .env .env.* -canvases +artifacts e2e/*.png *.sqlite* coverage dist *.tgz + +.herdr-canvas +canvases diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 52c2bb2..598b92f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,8 +24,8 @@ jobs: - run: bun run test:mcp-ui - name: Test configured browser plugin in workerd run: | - CANVAS_PLUGINS_CONFIG=src/test/plugins/config.ts bun run build:cloudflare-compiler - CANVAS_PLUGIN_TEST=1 bun test cloudflare/compiler.test.ts --test-name-pattern 'deployment browser plugin' --timeout 60000 + ARTIFACTS_PLUGINS_CONFIG=src/test/plugins/config.ts bun run build:cloudflare-compiler + ARTIFACTS_PLUGIN_TEST=1 bun test cloudflare/compiler.test.ts --test-name-pattern 'deployment browser plugin' --timeout 60000 # Rebuild the default deployment before the normal runtime and package checks. - run: bun run test:cloudflare - run: bun run typecheck:cloudflare diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 71b9ebf..5af6b37 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -17,7 +17,7 @@ concurrency: jobs: package: - if: github.repository == 'sidequery/canvas' && github.ref == 'refs/heads/main' + if: github.repository == 'sidequery/artifacts' && github.ref == 'refs/heads/main' runs-on: ubuntu-24.04 timeout-minutes: 15 steps: @@ -31,23 +31,23 @@ jobs: env: RELEASE_VERSION: ${{ inputs.version }} run: | - bun -e 'const p = await Bun.file("package.json").json(); if (p.name !== "@sidequery/canvas" || p.version !== process.env.RELEASE_VERSION) throw new Error("Release input must match @sidequery/canvas package.json version");' + bun -e 'const p = await Bun.file("package.json").json(); if (p.name !== "@sidequery/artifacts" || p.version !== process.env.RELEASE_VERSION) throw new Error("Release input must match @sidequery/artifacts package.json version");' - run: bun install --frozen-lockfile - run: bun run typecheck - run: bun run test - run: bun run build:package - run: bun run typecheck:cloudflare - name: Pack once - run: bun pm pack --ignore-scripts --filename "$RUNNER_TEMP/canvas.tgz" + run: bun pm pack --ignore-scripts --filename "$RUNNER_TEMP/artifacts.tgz" - name: Test release archive including native server persistence env: - CANVAS_PACKAGE_ARCHIVE: ${{ runner.temp }}/canvas.tgz + ARTIFACTS_PACKAGE_ARCHIVE: ${{ runner.temp }}/artifacts.tgz CELLD_PACKAGE_INTEGRATION: '1' run: bun test scripts/package.integration.test.ts --timeout 360000 - uses: actions/upload-artifact@v4 with: name: npm-package - path: ${{ runner.temp }}/canvas.tgz + path: ${{ runner.temp }}/artifacts.tgz if-no-files-found: error retention-days: 7 @@ -68,4 +68,4 @@ jobs: name: npm-package path: release # npm CLI performs the OIDC exchange. No NPM_TOKEN or NODE_AUTH_TOKEN. - - run: npm publish ./release/canvas.tgz --access public --ignore-scripts + - run: npm publish ./release/artifacts.tgz --access public --ignore-scripts diff --git a/.gitignore b/.gitignore index f76a5f5..b1648ae 100644 --- a/.gitignore +++ b/.gitignore @@ -1,12 +1,12 @@ node_modules/ -.herdr-canvas/ +.artifacts/ *.log .DS_Store .agents/worklog.md # Local artifacts and state; keep reusable samples in examples/. -/canvases/ -*.canvas.data.json +/artifacts/ +*.artifact.data.json *.sqlite *.sqlite-shm *.sqlite-wal @@ -24,3 +24,8 @@ node_modules/ .celld/ /wrangler.celld.jsonc *.tgz + +# Compatibility: keep existing local Canvas files and state ignored. +.herdr-canvas/ +/canvases/ +*.canvas.data.json diff --git a/Dockerfile b/Dockerfile index 1bd2738..b69f940 100644 --- a/Dockerfile +++ b/Dockerfile @@ -4,7 +4,7 @@ ENV DEBIAN_FRONTEND=noninteractive ENV SHELL=/bin/bash ENV TERM=xterm-256color ENV HERDR_E2E=1 -ENV HERDR_SESSION=canvas-e2e +ENV HERDR_SESSION=artifact-e2e ENV PATH="/usr/local/bin:${PATH}" RUN apt-get update && apt-get install -y --no-install-recommends \ @@ -27,7 +27,7 @@ RUN curl -fsSL https://herdr.dev/install.sh | sh RUN install -m 0755 /root/.bun/bin/bun /usr/local/bin/bun \ && install -m 0755 /root/.local/bin/herdr /usr/local/bin/herdr \ - && useradd --create-home --shell /bin/bash canvas + && useradd --create-home --shell /bin/bash artifact RUN curl -fsSL https://terminal-browser.sh/install \ | XDG_DATA_HOME=/opt XDG_BIN_HOME=/usr/local/bin AGENT_SKILLS_HOME=/tmp/terminal-browser-skills TERMINAL_BROWSER_SKIP_EDITOR_SETUP=1 bash @@ -41,7 +41,7 @@ COPY skills ./skills RUN bun install --frozen-lockfile -ENV HOME=/home/canvas -USER canvas +ENV HOME=/home/artifact +USER artifact CMD ["bun", "e2e/inside.ts"] diff --git a/LICENSE b/LICENSE index 2a4c56a..2986bc8 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2026 Canvas contributors +Copyright (c) 2026 Artifacts contributors Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index 12c2dc1..31318cb 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# Sidequery Canvas +# Sidequery Artifacts Screenshot 2026-09-06 at 10 36 49 PM @@ -7,9 +7,8 @@ read and edit them through CLI or MCP, and retain raw-source version history. Herdr is an optional integration for opening artifacts beside a terminal agent. The local runtime uses Bun with local files and SQLite. MCP Apps hosts render the -interactive canvas directly inside chat when an agent creates or shows it. -An experimental Cloudflare runtime provides an HTTP MCP server and gallery in your own or a shared account; see [Cloudflare setup](docs/cloudflare.md). The repository is named `canvas`; an existing -checkout can remain in a folder named `herdr-canvas`. +interactive artifact directly inside chat when an agent creates or shows it. +An experimental Cloudflare runtime provides an HTTP MCP server and gallery in your own or a shared account; see [Cloudflare setup](docs/cloudflare.md). ## Requirements @@ -26,21 +25,21 @@ After the first registry release, install the CLI with Bun; a source checkout is not required. The package is currently being prepared and is not published yet. ```bash -bun add --global @sidequery/canvas -canvas write overview --file overview.canvas.tsx -canvas web --port 4784 +bun add --global @sidequery/artifacts +artifacts write overview --file overview.artifact.tsx +artifacts web --port 4784 ``` Open `http://127.0.0.1:4784`. The package includes the local CLI, stdio MCP server, gallery, and the prebuilt Worker and assets used by the optional native -canvas server. It does not build from source during installation. +artifacts server. It does not build from source during installation. For a stdio MCP client, configure the installed command directly: ```json { - "command": "canvas", - "args": ["mcp", "--dir", "/absolute/path/to/workspace/canvases"] + "command": "artifacts", + "args": ["mcp", "--dir", "/absolute/path/to/workspace/artifacts"] } ``` @@ -52,63 +51,73 @@ From the checkout: ```bash bun install --frozen-lockfile -bun run canvas write overview --file examples/overview.canvas.tsx -bun run canvas web --port 4784 +bun run artifacts write overview --file examples/overview.artifact.tsx +bun run artifacts web --port 4784 ``` -Open `http://127.0.0.1:4784`. Your working canvases and UI state are ignored by +Open `http://127.0.0.1:4784`. Your working artifacts and UI state are ignored by this repository; reusable checked-in samples belong in `examples/`. -`bun run canvas` uses the same CLI directly from this checkout. +`bun run artifacts` uses the same CLI directly from this checkout. ## Optional Herdr integration ```bash herdr plugin install zenbu-labs/terminal-browser/herdr-plugin --yes -herdr plugin link /path/to/herdr-canvas +herdr plugin link /path/to/artifacts ``` Run `bun install --frozen-lockfile` in the checkout before linking it. The plugin -ID remains `herdr.canvas`. Existing SDK imports (`herdr/canvas`, `cursor/canvas`), -`HERDR_CANVAS_*` environment variables and history paths are retained for -compatibility; the project rename does not migrate or reset stored artifacts. +ID and pane entrypoint are `herdr.artifacts` and `artifacts`. Relink an existing +checkout to register the new plugin name. + +## Upgrading from Canvas + +Use the `artifacts` CLI and `artifact_*` MCP tools. New sources use `.artifact.tsx`, +`sidequery/artifacts`, and SDK names such as `useArtifactState` and `artifactFetch`. +Existing `.canvas.tsx` sources, their sidecars, historical SDK imports and exported +names remain readable and compilable. Existing workspace and application-data +directories are reused when no new directory exists; files and databases are not +moved. New `ARTIFACTS_DIR`, `ARTIFACTS_HISTORY_DB`, and `ARTIFACTS_DATA_HOME` overrides +take precedence over their legacy equivalents. Existing hosted storage identities +are retained; see [Cloudflare setup](docs/cloudflare.md). ## Agent workflow -Call `canvas_guide` before creating a canvas if you have not read it in the current +Call `artifact_guide` before creating an artifact if you have not read it in the current conversation. It covers the SDK contract, host APIs, restrictions, and validation. -Import standard React hooks from `sidequery/canvas` for component state and effects. -Use `useCanvasState(key, defaultValue)` when you want host-backed state; persistence +Import standard React hooks from `sidequery/artifacts` for component state and effects. +Use `useArtifactState(key, defaultValue)` when you want host-backed state; persistence depends on the view. -Canvases live at `/canvases/.canvas.tsx`. Import the SDK from -`sidequery/canvas` (`herdr/canvas` and `cursor/canvas` remain compatibility aliases). +Artifacts live at `/artifacts/.artifact.tsx`. Import the SDK from +`sidequery/artifacts` (`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 -canvas write overview --file examples/overview.canvas.tsx -canvas typecheck overview -canvas open overview -canvas mcp +artifacts list +artifacts write overview --file examples/overview.artifact.tsx +artifacts typecheck overview +artifacts open overview +artifacts mcp ``` -`open` typechecks and bundles with Bun, then opens the `herdr.canvas` `canvas` +`open` typechecks and bundles with Bun, then opens the `herdr.artifacts` `artifacts` pane entrypoint. That pane starts its own loopback server and runs Terminal Browser internally in `--app-mode`. Closing the pane closes its browser session and loopback server. `--no-open` is the explicit development-only path that starts a detached server and returns its URL. -MCP tools: `canvas_list`, `canvas_read`, `canvas_edit`, `canvas_write`, `canvas_typecheck`, `canvas_compile`, -`canvas_open`. `canvas_write` returns `Canvas TypeScript check` diagnostics in +MCP tools: `artifact_list`, `artifact_read`, `artifact_edit`, `artifact_write`, `artifact_typecheck`, `artifact_compile`, +`artifact_open`. `artifact_write` returns `Artifact TypeScript check` diagnostics in the tool result. -## Inline canvases in chat +## Inline artifacts in chat Configure the stdio MCP server above in a client that supports MCP Apps. Ask the -agent to create a canvas: a successful `canvas_write` displays the actual React -canvas inline, including charts, forms and interactive controls. To show an -existing canvas, call `canvas_open({name: "overview"})`. Successful `canvas_edit` -and `canvas_restore` also return an updated inline preview. These are canvas +agent to create an artifact: a successful `artifact_write` displays the actual React +artifact inline, including charts, forms and interactive controls. To show an +existing artifact, call `artifact_open({name: "overview"})`. Successful `artifact_edit` +and `artifact_restore` also return an updated inline preview. These are artifact views, without the gallery or tool-management controls. Inline views fit their content, grow and shrink as it changes, and scroll within @@ -116,66 +125,66 @@ a 600px maximum height (or a smaller maximum supplied by the host). A host with a fixed container height takes precedence. The chat controls the width; narrow views wrap text and keep oversized content scrollable. -When the host advertises fullscreen support, **Expand** opens the canvas in the +When the host advertises fullscreen support, **Expand** opens the artifact in the host's fullscreen view. **Exit fullscreen** returns it to the conversation. -Fullscreen uses the available viewport with an independently scrolling canvas. +Fullscreen uses the available viewport with an independently scrolling artifact. Switching modes preserves controls and their current state. Hosts can also change the mode themselves; unavailable controls are hidden, and rejected requests leave the current view usable. This uses MCP Apps display modes, not browser fullscreen. -The viewer is a self-contained `ui://canvas/viewer.html` MCP App resource. Tool +The viewer is a self-contained `ui://artifacts/viewer.html` MCP App resource. Tool results carry the compiled snapshot in UI-only `_meta`; normal text results stay compact. No web server, external asset hosting or Herdr installation is needed. Clients without MCP Apps support receive the normal text/diagnostic results; -they cannot display the interactive canvas. +they cannot display the interactive artifact. -Inline views begin with saved canvas state and keep subsequent interactions local -to that view, like gallery previews. They do not overwrite the working canvas's +Inline views begin with saved artifact state and keep subsequent interactions local +to that view, like gallery previews. They do not overwrite the working artifact's state sidecar. Archived views support `version_id` and optional `event_id`, and recompile the saved raw source with the installed SDK. Delivering a preview archives raw source and records a preview event; it does not prove a human saw it. Compiled JavaScript is never stored in the history database. -The canvas follows the chat host's theme. `promptAgent` requests a user message +The artifact follows the chat host's theme. `promptAgent` requests a user message through the host, and `openUrl` requests opening an HTTP(S) link. Host rejection is shown in the view. `openFile` reports that local file opening is unavailable in chat. These actions remain subject to the host's capabilities and permissions. -MCP `canvas_open` now defaults to inline display. To open a Herdr pane explicitly, -use `canvas_open({name: "overview", target: "herdr"})`. Similarly, -`canvas_write({name, contents, target: "herdr"})` also opens a pane. The legacy +MCP `artifact_open` now defaults to inline display. To open a Herdr pane explicitly, +use `artifact_open({name: "overview", target: "herdr"})`. Similarly, +`artifact_write({name, contents, target: "herdr"})` also opens a pane. The legacy write `open` flag is accepted, but inline display is automatic regardless of it. CLI `open` continues to open a Herdr pane. -## Native canvas servers and SQLite +## Native artifact servers and SQLite -The optional local native runtime starts in the foreground with `canvas server`. +The optional local native runtime starts in the foreground with `artifacts server`. It downloads a pinned, checksummed `celld` binary on first use and stores its runtime and SQLite state under the operating system's application-data directory. -Use `canvas server start`, `canvas server stop`, `canvas server status`, and -`canvas server logs` for the opt-in background service. See +Use `artifacts server start`, `artifacts server stop`, `artifacts server status`, and +`artifacts server logs` for the opt-in background service. See [`docs/daemon.md`](docs/daemon.md) for lifecycle and login-service details. The native server exposes its gallery at `http://127.0.0.1:4786` and its HTTP MCP endpoint at `http://127.0.0.1:4786/mcp`. Configure an HTTP MCP client against that -endpoint to create canvases with server source and call `canvas_request`. The -`canvas web` gallery and `canvas mcp` stdio server continue to use workspace files -and the local history database; they do not migrate or synchronize canvases with +endpoint to create artifacts with server source and call `artifact_request`. The +`artifacts web` gallery and `artifacts mcp` stdio server continue to use workspace files +and the local history database; they do not migrate or synchronize artifacts with the native server. -Hosted Cloudflare canvases can pair the browser `.canvas.tsx` with a native -`CanvasServer`. The server is a generated Durable Object class and uses its own +Hosted Cloudflare artifacts can pair the browser `.artifact.tsx` with a native +`ArtifactServer`. The server is a generated Durable Object class and uses its own SQLite through `ctx.storage.sql` and key/value storage through `ctx.storage.kv` directly. The browser calls it with -`canvasFetch`, which accepts a path and standard `RequestInit` and returns a +`artifactFetch`, which accepts a path and standard `RequestInit` and returns a standard `Response`: ```tsx -import { Button, Text, canvasFetch, useState, useEffect } from "sidequery/canvas"; +import { Button, Text, artifactFetch, useState, useEffect } from "sidequery/artifacts"; export default function Counter() { const [value, setValue] = useState(null); async function load(method = "GET") { - const response = await canvasFetch("/counter", { method }); + const response = await artifactFetch("/counter", { method }); if (!response.ok) throw new Error(`Counter failed (${response.status})`); setValue((await response.json() as { value: number }).value); } @@ -187,7 +196,7 @@ export default function Counter() { ```ts import { DurableObject } from "cloudflare:workers"; -export class CanvasServer extends DurableObject { +export class ArtifactServer extends DurableObject { fetch(request: Request): Response { this.ctx.storage.sql.exec("create table if not exists counter (id integer primary key, value integer not null)"); this.ctx.storage.sql.exec("insert or ignore into counter values (1, 0)"); @@ -199,41 +208,41 @@ export class CanvasServer extends DurableObject { ``` The complete checked-in pair is -[`examples/counter.canvas.tsx`](examples/counter.canvas.tsx) and -[`examples/counter.canvas.server.ts`](examples/counter.canvas.server.ts). +[`examples/counter.artifact.tsx`](examples/counter.artifact.tsx) and +[`examples/counter.artifact.server.ts`](examples/counter.artifact.server.ts). Seed it into the hosted runtime with `bun run seed:cloudflare default counter`. -`canvasFetch` carries ordinary HTTP method, headers and body through the Canvas -host; it does not expose an internal endpoint or bearer credential to canvas +`artifactFetch` carries ordinary HTTP method, headers and body through the Artifact +host; it does not expose an internal endpoint or bearer credential to artifact code. Use relative paths. Cross-origin and protocol-relative URLs are rejected, and request and response bodies are limited to 256 KiB. -Hosted `canvas_write` accepts `server` source alongside `contents`. Omitting +Hosted `artifact_write` accepts `server` source alongside `contents`. Omitting `server` preserves the existing server; passing `null` removes it without -deleting its database. Use `canvas_read` or `canvas_edit` with `part: "server"` +deleting its database. Use `artifact_read` or `artifact_edit` with `part: "server"` for targeted server changes. Client and server source are versioned and restored -together. The database is live state keyed by library, workspace and canvas name: +together. The database is live state keyed by library, workspace and artifact name: editing or restoring source reloads the code while preserving that state, and opening an archived version does not restore an old database snapshot. -Private canvases receive databases isolated to the verified signed-in user. A -team canvas shares one database with members authorized for that deployment's -team library. Private and team canvases with the same workspace and name remain +Private artifacts receive databases isolated to the verified signed-in user. A +team artifact shares one database with members authorized for that deployment's +team library. Private and team artifacts with the same workspace and name remain separate. Generated servers receive Durable Object storage; ordinary D1, R2 and custom Worker bindings are not provided. The local Bun CLI, stdio MCP server and -gallery do not execute canvas servers, so `canvasFetch` reports that server +gallery do not execute artifact servers, so `artifactFetch` reports that server requests are unavailable there. -Hosted canvases and the managed local celld server also provide **per-canvas -files** through `canvasFiles.upload`, `list`, `read`, `download`, and `delete`. +Hosted artifacts and the managed local celld server also provide **per-artifact +files** through `artifactFiles.upload`, `list`, `read`, `download`, and `delete`. Files use native R2, remain live across source edits, and need no server code. Uploads support up to 25 MiB; binary transfers bypass the 256 KiB request bridge. -Public canvas links expose files read-only. See [file storage and deployment](docs/files.md) -and [the file canvas example](examples/files.canvas.tsx). +Public artifact links expose files read-only. See [file storage and deployment](docs/files.md) +and [the file artifact example](examples/files.artifact.tsx). Hosted deployments choose `AUTH_MODE=access` for the existing Cloudflare Access setup or `AUTH_MODE=better-auth` for provider-configurable sign-in. Better Auth -runs inside the Canvas Worker with a deployment-owned D1 database; it does not -require a central Canvas authentication service or offer password registration. +runs inside the Artifacts Worker with a deployment-owned D1 database; it does not +require a central Artifactss authentication service or offer password registration. Deployers can pass any supported Better Auth social-provider configuration, configure generic OIDC, or extend the TypeScript provider seam. The gallery and remote MCP OAuth flow resolve to the same user identity. See @@ -242,18 +251,18 @@ admission rules. ## Scripts and direct URLs -Hosted canvases and standalone TypeScript scripts can have user-chosen root URLs -such as `/sales-dashboard` and `/my-handler`. Canvas links open the interactive +Hosted artifacts and standalone TypeScript scripts can have user-chosen root URLs +such as `/sales-dashboard` and `/my-handler`. Artifact links open the interactive UI with its backend; script links invoke a standard Workers HTTP handler. Manage scripts, secrets, history, logs and link access through the gallery or MCP. -See [Scripts and direct canvas links](docs/scripts.md). +See [Scripts and direct artifact links](docs/scripts.md). ## Targeted reads and edits -Use `read` / `canvas_read` to retrieve only the needed working-source lines, and -`edit` / `canvas_edit` to change them without resending the whole canvas. These -operations share the same implementation across CLI and MCP. They accept a canvas -name (with optional `.canvas.tsx` suffix), not a path; symlinks are rejected. +Use `read` / `artifact_read` to retrieve only the needed working-source lines, and +`edit` / `artifact_edit` to change them without resending the whole artifact. These +operations share the same implementation across CLI and MCP. They accept an artifact +name (with optional `.artifact.tsx` suffix), not a path; symlinks are rejected. ```bash bun run src/cli.ts read overview --start-line 5 --end-line 12 @@ -269,8 +278,8 @@ identifies the next page or is null. Explicit end lines are clamped to EOF; a start past EOF is rejected. Empty files return empty source and `end_line: 0`. Line endings are preserved so returned text can be used for exact replacements. -MCP uses `canvas_read({name, start_line?, end_line?})` and -`canvas_edit({name, edits, expected_hash?})`. CLI `edit` reads the same JSON body +MCP uses `artifact_read({name, start_line?, end_line?})` and +`artifact_edit({name, edits, expected_hash?})`. CLI `edit` reads the same JSON body without `name` from stdin or `--file PATH`. Each edit has `old_text` and `new_text`. Include the hash returned by `read` as `expected_hash` to reject stale edits. @@ -286,15 +295,15 @@ This is not a strict filesystem compare-and-swap against arbitrary external writ Edit results include `changed`, `edits_applied`, the new `source_hash`, and diagnostics, but no source echo. Edits do not modify saved UI state or create history revisions until successfully served. Existing write/open behavior is -unchanged: MCP supports `canvas_write` with `open: true`; CLI uses separate +unchanged: MCP supports `artifact_write` with `open: true`; CLI uses separate `write` and `open` commands. ## Artifact history Every successfully served source revision is archived automatically, including -edits made directly to a `.canvas.tsx` file. History stores **authored source, +edits made directly to a `.artifact.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 +artifact 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. @@ -309,17 +318,17 @@ bun run src/cli.ts open --version VERSION_ID --event EVENT_ID bun run src/cli.ts restore VERSION_ID # restore source as a new revision ``` -Equivalent MCP tools: `canvas_history` (optional `name`), `canvas_version` -(`version_id`), `canvas_open` (`version_id`, optional `event_id` and `placement`), -and `canvas_restore` (`version_id`). Existing `canvas_list` lists working files; -`canvas_history` also includes artifacts whose files have been deleted. -`canvas_open` accepts either `name` for a working file or `version_id` for history. +Equivalent MCP tools: `artifact_history` (optional `name`), `artifact_version` +(`version_id`), `artifact_open` (`version_id`, optional `event_id` and `placement`), +and `artifact_restore` (`version_id`). Existing `artifact_list` lists working files; +`artifact_history` also includes artifacts whose files have been deleted. +`artifact_open` accepts either `name` for a working file or `version_id` for history. Reopening compiles the saved source with the **currently installed SDK and Bun**. An SDK upgrade can change its appearance or make old source fail to compile. The archived view starts with the selected event's state (by default the latest live serve), and its interactions remain local to that page. It does not poll -the working file or write to the working canvas's saved state. This preserves +the working file or write to the working artifact's saved state. This preserves initial state, not a recording of every interaction. Page bundle URLs identify the source version and in-memory SDK generation so concurrent source/SDK edits cannot mix the page and bundle. If an old SDK generation has been evicted from @@ -327,7 +336,7 @@ memory, its bundle request asks for a page reload instead of serving different c Restore saves the current working source first—even an invalid, unserved draft— then writes the selected source and appends a new revision linked to the original. -It keeps later history and the working canvas's `.canvas.data.json` state. +It keeps later history and the working artifact's `.artifact.data.json` state. Restore uses an atomic file replacement and an optimistic source check; if an edit is detected after the preservation snapshot it stops and leaves that edit untouched. Symlinked source files are rejected for serving and restoration. @@ -337,12 +346,12 @@ replacement; an interrupted restore may be captured on the next successful serve The database survives pane closure and lives at: -- macOS: `~/Library/Application Support/herdr-canvas/history.sqlite` -- Linux: `$XDG_DATA_HOME/herdr-canvas/history.sqlite`, defaulting to - `~/.local/share/herdr-canvas/history.sqlite` +- macOS: `~/Library/Application Support/artifacts/history.sqlite` +- Linux: `$XDG_DATA_HOME/artifacts/history.sqlite`, defaulting to + `~/.local/share/artifacts/history.sqlite` -Override with `HERDR_CANVAS_HISTORY_DB` or `--history-db PATH`. All commands also -accept `--dir PATH` to select a canvases directory. Artifact IDs are stable within +Override with `ARTIFACTS_HISTORY_DB` or `--history-db PATH`. All commands also +accept `--dir PATH` to select an artifacts directory. Artifact IDs are stable within that directory and name; renames or different worktrees are separate artifacts. SQLite uses WAL mode with a busy timeout for concurrent panes. History is retained without automatic pruning; it may contain embedded data and saved UI state. @@ -355,8 +364,8 @@ file without its WAL. bun run src/cli.ts web --port 4784 ``` -Open `http://127.0.0.1:4784` to search working canvases and archived versions, -preview one at a time, inspect raw source, or download a `.canvas.tsx` file. +Open `http://127.0.0.1:4784` to search working artifacts and archived versions, +preview one at a time, inspect raw source, or download a `.artifact.tsx` file. The gallery defaults to this project; its all-projects filter includes archived artifacts from other projects in the same database. It does not scan other projects for unarchived working files. Use `--dir PATH` and `--history-db PATH` to select a @@ -383,12 +392,12 @@ bun run typecheck `bun run test` is the unit/service suite. `bun run test:e2e` builds a Docker image with real Herdr and Terminal Browser, starts Herdr on a PTY, verifies -that the pane is owned by `herdr.canvas`, and proves its server stops when the +that the pane is owned by `herdr.artifacts`, and proves its server stops when the pane closes. GitHub Actions runs the typecheck and unit/service suite with Bun 1.4.0. The root Dockerfile is an integration-test environment, not a production image. -`bun run test:mcp-ui` exercises the real canvas in Chromium with an MCP Apps host. +`bun run test:mcp-ui` exercises the real artifact in Chromium with an MCP Apps host. Install its browser first with `bun x playwright install chromium`. CI runs this browser suite, the unit/service suite, and a clean tarball install that exercises the installed CLI, gallery, compilation, and stdio MCP server. @@ -399,21 +408,21 @@ This is a local tool, not an authenticated multi-user service. Keep the web server on loopback; do not expose it through a public proxy. Source checks reject unsupported imports and APIs, but they are not a general-purpose security sandbox 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 +artifacts execute inside the chat host's MCP Apps sandbox; use trusted local artifact source, as for the other local views. Artifact source and saved state can contain private data; keep the history database private. ## Projects, remix, and scheduled runs -Canvases and scripts support helper source files and pinned package dependencies. +Artifacts 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 +Use **Remix** in the gallery, `artifact_remix` / `script_remix` through MCP, or +`artifacts remix SOURCE NEW_NAME` locally. Copies retain source provenance and start with fresh state, storage, secrets, and private hosted URLs. Hosted and celld backends expose **Schedule** and **Run history** controls. Native diff --git a/cloudflare/canvas-api.test.ts b/cloudflare/artifact-api.test.ts similarity index 63% rename from cloudflare/canvas-api.test.ts rename to cloudflare/artifact-api.test.ts index e38160b..350342f 100644 --- a/cloudflare/canvas-api.test.ts +++ b/cloudflare/artifact-api.test.ts @@ -1,9 +1,9 @@ import { expect, test } from "bun:test"; -import { canvasApiRequest, canvasApiResponse } from "./canvas-api"; +import { artifactApiRequest, artifactApiResponse } from "./artifact-api"; test("direct API envelopes preserve binary bodies, query strings and public application credentials", async () => { const bytes = new Uint8Array([0, 255, 128, 10, 13]); - const input = await canvasApiRequest(new Request("https://canvas.example/demo/api/items?a=1&a=%FF", { + const input = await artifactApiRequest(new Request("https://artifact.example/demo/api/items?a=1&a=%FF", { method: "PATCH", body: bytes, headers: { cookie: "session=private", "cf-access-jwt-assertion": "jwt", "cf-access-client-id": "id", "cf-access-client-secret": "secret", authorization: "Bearer app-token", "x-input": "kept" }, }), "/demo", false); @@ -14,7 +14,7 @@ test("direct API envelopes preserve binary bodies, query strings and public appl for (const name of ["cookie", "cf-access-jwt-assertion", "cf-access-client-id", "cf-access-client-secret"]) expect(headers.has(name)).toBe(false); expect(headers.get("authorization")).toBe("Bearer app-token"); expect(headers.get("x-input")).toBe("kept"); - const response = canvasApiResponse({ status: 201, statusText: "Created", headers: [["x-output", "kept"], ["set-cookie", "session=forged"], ["content-type", "application/octet-stream"]], body: input.body }, "PATCH"); + const response = artifactApiResponse({ status: 201, statusText: "Created", headers: [["x-output", "kept"], ["set-cookie", "session=forged"], ["content-type", "application/octet-stream"]], body: input.body }, "PATCH"); expect(response.status).toBe(201); expect(response.statusText).toBe("Created"); expect(new Uint8Array(await response.arrayBuffer())).toEqual(bytes); @@ -25,7 +25,7 @@ test("direct API envelopes preserve binary bodies, query strings and public appl test("private API requests remove authorization and GET/HEAD requests have no body", async () => { for (const method of ["GET", "HEAD"]) { - const input = await canvasApiRequest(new Request("https://canvas.example/demo/api", { method, headers: { authorization: "Bearer management" } }), "/demo", true); + const input = await artifactApiRequest(new Request("https://artifact.example/demo/api", { method, headers: { authorization: "Bearer management" } }), "/demo", true); expect(input.path).toBe("/api"); expect(input.body).toBeUndefined(); expect(new Headers(input.headers).has("authorization")).toBe(false); @@ -33,19 +33,19 @@ test("private API requests remove authorization and GET/HEAD requests have no bo }); test("direct API requests enforce the 256 KiB byte boundary", async () => { - const atLimit = await canvasApiRequest(new Request("https://canvas.example/demo/api", { method: "POST", body: new Uint8Array(256 * 1024) }), "/demo", false); + const atLimit = await artifactApiRequest(new Request("https://artifact.example/demo/api", { method: "POST", body: new Uint8Array(256 * 1024) }), "/demo", false); expect(atob(atLimit.body!).length).toBe(256 * 1024); - await expect(canvasApiRequest(new Request("https://canvas.example/demo/api", { method: "POST", body: new Uint8Array(256 * 1024 + 1) }), "/demo", false)).rejects.toThrow("256 KiB"); + await expect(artifactApiRequest(new Request("https://artifact.example/demo/api", { method: "POST", body: new Uint8Array(256 * 1024 + 1) }), "/demo", false)).rejects.toThrow("256 KiB"); }); test("API responses preserve not-found status, sandbox HTML and suppress bodyless payloads", async () => { - const response = canvasApiResponse({ status: 404, statusText: "Not Found", headers: [["content-type", "text/html"], ["content-security-policy", "default-src 'self'"]], body: btoa("

Missing

") }, "GET"); + const response = artifactApiResponse({ status: 404, statusText: "Not Found", headers: [["content-type", "text/html"], ["content-security-policy", "default-src 'self'"]], body: btoa("

Missing

") }, "GET"); expect(response.status).toBe(404); expect(await response.text()).toBe("

Missing

"); expect(response.headers.get("content-security-policy")).toContain("default-src 'self'"); expect(response.headers.get("content-security-policy")).toContain("sandbox allow-scripts allow-forms"); for (const [method, status] of [["HEAD", 200], ["GET", 204], ["GET", 205], ["GET", 304]] as const) { - expect(canvasApiResponse({ status, statusText: "", headers: [], body: btoa("ignored") }, method).body).toBeNull(); + expect(artifactApiResponse({ status, statusText: "", headers: [], body: btoa("ignored") }, method).body).toBeNull(); } - expect(() => canvasApiResponse({ status: 200, statusText: "OK", headers: [], body: btoa("x".repeat(256 * 1024 + 1)) }, "GET")).toThrow("256 KiB"); + expect(() => artifactApiResponse({ status: 200, statusText: "OK", headers: [], body: btoa("x".repeat(256 * 1024 + 1)) }, "GET")).toThrow("256 KiB"); }); diff --git a/cloudflare/canvas-api.ts b/cloudflare/artifact-api.ts similarity index 75% rename from cloudflare/canvas-api.ts rename to cloudflare/artifact-api.ts index b370970..bc8d0ed 100644 --- a/cloudflare/canvas-api.ts +++ b/cloudflare/artifact-api.ts @@ -1,9 +1,9 @@ -import type { CanvasHttpRequest, CanvasHttpResponse } from "../src/httpTypes"; +import type { ArtifactHttpRequest, ArtifactHttpResponse } from "../src/httpTypes"; const MAX_BODY = 256 * 1024; /** Adapt a standalone API request to the same bounded envelope used by the SDK. */ -export async function canvasApiRequest(request: Request, basePath: string, privateLink: boolean): Promise { +export async function artifactApiRequest(request: Request, basePath: string, privateLink: boolean): Promise { const headers = new Headers(request.headers); for (const name of ["cookie", "cf-access-jwt-assertion", "cf-access-client-id", "cf-access-client-secret"]) headers.delete(name); if (privateLink) headers.delete("authorization"); @@ -16,7 +16,7 @@ export async function canvasApiRequest(request: Request, basePath: string, priva if (chunk.done) break; if (binary.length + chunk.value.byteLength > MAX_BODY) { await reader.cancel(); - throw new RangeError("Canvas request body exceeds 256 KiB"); + throw new RangeError("Artifact request body exceeds 256 KiB"); } for (let i = 0; i < chunk.value.length; i += 8192) binary += String.fromCharCode(...chunk.value.subarray(i, i + 8192)); } @@ -24,7 +24,7 @@ export async function canvasApiRequest(request: Request, basePath: string, priva return { path: url.pathname.slice(basePath.length) + url.search, method: request.method, headers: [...headers], ...(reader ? { body: btoa(binary) } : {}) }; } -export function canvasApiResponse(input: Omit & { headers: string[][] }, method: string): Response { +export function artifactApiResponse(input: Omit & { headers: string[][] }, method: string): Response { // Durable Object RPC widens tuple arrays to string[][] on the caller side. const headers = new Headers(input.headers as [string, string][]); headers.delete("set-cookie"); @@ -33,9 +33,9 @@ export function canvasApiResponse(input: Omit & { headers.set("cache-control", "private, no-store"); let body: Uint8Array | null = null; if (input.body !== undefined && method !== "HEAD" && ![204, 205, 304].includes(input.status)) { - if (input.body.length > Math.ceil(MAX_BODY / 3) * 4) throw new RangeError("Canvas response body exceeds 256 KiB"); + if (input.body.length > Math.ceil(MAX_BODY / 3) * 4) throw new RangeError("Artifact response body exceeds 256 KiB"); const binary = atob(input.body); - if (binary.length > MAX_BODY) throw new RangeError("Canvas response body exceeds 256 KiB"); + if (binary.length > MAX_BODY) throw new RangeError("Artifact response body exceeds 256 KiB"); body = Uint8Array.from(binary, char => char.charCodeAt(0)); } return new Response(body, { status: input.status, statusText: input.statusText, headers }); diff --git a/cloudflare/canvas-execution.test.ts b/cloudflare/artifact-execution.test.ts similarity index 92% rename from cloudflare/canvas-execution.test.ts rename to cloudflare/artifact-execution.test.ts index 3e35193..5646b1c 100644 --- a/cloudflare/canvas-execution.test.ts +++ b/cloudflare/artifact-execution.test.ts @@ -1,6 +1,6 @@ import { afterAll, afterEach, expect, spyOn, test } from "bun:test"; import { Database } from "bun:sqlite"; -import { CanvasExecution } from "./canvas-execution"; +import { ArtifactExecution } from "./artifact-execution"; // Real SQLite for host history; the KV/alarm adapter exposes observable state // without requiring a Workers isolate for scheduling state transitions. @@ -19,7 +19,7 @@ function fixture() { deleteAlarm: async () => { alarm = null; }, transaction: async (callback: (storage: unknown) => Promise) => callback(storage), }; - return { execution: new CanvasExecution(storage as unknown as DurableObjectStorage), storage, alarm: () => alarm, db }; + return { execution: new ArtifactExecution(storage as unknown as DurableObjectStorage), storage, alarm: () => alarm, db }; } const now = spyOn(Date, "now"); afterEach(() => now.mockReset()); @@ -54,7 +54,7 @@ test("a claimed occurrence survives interruption without being replayed", async const claimed = await execution.claim(); expect(claimed).not.toBeNull(); expect(execution.runs()).toMatchObject([{ id: claimed!.run.id, revision: active.version_id, status: "running", trigger: "schedule" }]); - const restarted = new CanvasExecution(storage as unknown as DurableObjectStorage); + const restarted = new ArtifactExecution(storage as unknown as DurableObjectStorage); expect(await restarted.claim()).toBeNull(); expect(restarted.runs()).toMatchObject([{ id: claimed!.run.id, status: "interrupted" }]); }); @@ -93,7 +93,7 @@ test("history records revision, trigger, duration and failures; restart retains const failed = execution.start("version-2", "http"); execution.finish(failed, 500); const uncertain = execution.start("version-3", "manual"); - const recovered = new CanvasExecution(storage as unknown as DurableObjectStorage); + const recovered = new ArtifactExecution(storage as unknown as DurableObjectStorage); expect(recovered.runs().find(run => run.id === uncertain.id)).toMatchObject({ status: "interrupted", finished_at: null }); expect(recovered.runs().find(run => run.id === failed.id)).toMatchObject({ status: "failed", http_status: 500 }); expect(() => recovered.runs(101)).toThrow(); @@ -105,7 +105,7 @@ test("compiled source and scheduled bodies larger than KV values persist in host const source = "x".repeat(200_000), body = btoa("y".repeat(200_000)); await execution.activate({ ...active, code: source }); await execution.update({ action: "set", interval_seconds: 3600, request: { ...request, body } }); - const restarted = new CanvasExecution(storage as unknown as DurableObjectStorage); + const restarted = new ArtifactExecution(storage as unknown as DurableObjectStorage); expect((await restarted.active())?.code).toBe(source); expect((await restarted.get())?.request.body).toBe(body); }); diff --git a/cloudflare/canvas-execution.ts b/cloudflare/artifact-execution.ts similarity index 71% rename from cloudflare/canvas-execution.ts rename to cloudflare/artifact-execution.ts index 322ef3b..8554169 100644 --- a/cloudflare/canvas-execution.ts +++ b/cloudflare/artifact-execution.ts @@ -1,21 +1,21 @@ -import type { CanvasHttpRequest } from "../src/httpTypes"; +import type { ArtifactHttpRequest } from "../src/httpTypes"; import { normalizeTiming, nextOccurrence, type ScheduleTiming } from "./schedule"; -export type CanvasSchedule = ScheduleTiming & { paused: boolean; next_run_at: number | null; request: CanvasHttpRequest }; -export type CanvasScheduleInput = ScheduleTiming & { action?: "get" | "set" | "pause" | "resume" | "run_now"; request?: CanvasHttpRequest }; -export type CanvasRun = { id: string; revision: string; trigger: string; started_at: string; finished_at: string | null; duration_ms: number | null; status: string; http_status: number | null }; -export type CanvasActive = { code: string | null; hash: string; version_id: string; revision: number }; -export type CanvasClaim = { schedule: CanvasSchedule; active: CanvasActive | undefined; run: { id: string; started: number } }; +export type ArtifactSchedule = ScheduleTiming & { paused: boolean; next_run_at: number | null; request: ArtifactHttpRequest }; +export type ArtifactScheduleInput = ScheduleTiming & { action?: "get" | "set" | "pause" | "resume" | "run_now"; request?: ArtifactHttpRequest }; +export type ArtifactRun = { id: string; revision: string; trigger: string; started_at: string; finished_at: string | null; duration_ms: number | null; status: string; http_status: number | null }; +export type ArtifactActive = { code: string | null; hash: string; version_id: string; revision: number }; +export type ArtifactClaim = { schedule: ArtifactSchedule; active: ArtifactActive | undefined; run: { id: string; started: number } }; -/** Supervisor storage belongs to the host, separate from CanvasServer's facet. */ -export class CanvasExecution { +/** Supervisor storage belongs to the host, separate from ArtifactServer's facet. */ +export class ArtifactExecution { constructor(private storage: DurableObjectStorage) { storage.sql.exec("create table if not exists host_schedule(id integer primary key check(id=1),payload text not null)"); storage.sql.exec("create table if not exists active_server(id integer primary key check(id=1),payload text not null)"); storage.sql.exec("create table if not exists execution_runs(id text primary key,revision text not null,trigger text not null,started_at text not null,finished_at text,duration_ms integer,status text not null,http_status integer)"); storage.sql.exec("update execution_runs set status='interrupted' where status='running'"); } - async activate(active: CanvasActive) { + async activate(active: ArtifactActive) { await this.storage.transaction(async storage => { const previous = this.readActive(); if (previous && previous.revision > active.revision) return; @@ -27,9 +27,9 @@ export class CanvasExecution { } }); } - private readActive(): CanvasActive | undefined { + private readActive(): ArtifactActive | undefined { const row = this.storage.sql.exec<{ payload: string }>("select payload from active_server where id=1").toArray()[0]; - return row ? JSON.parse(row.payload) as CanvasActive : undefined; + return row ? JSON.parse(row.payload) as ArtifactActive : undefined; } async active() { return this.readActive(); } start(revision: string, trigger: string) { @@ -43,17 +43,17 @@ export class CanvasExecution { } runs(limit = 100) { if (!Number.isSafeInteger(limit) || limit < 1 || limit > 100) throw new Error("limit must be between 1 and 100"); - return this.storage.sql.exec("select * from execution_runs order by started_at desc,rowid desc limit ?", limit).toArray(); + return this.storage.sql.exec("select * from execution_runs order by started_at desc,rowid desc limit ?", limit).toArray(); } - private readSchedule(): CanvasSchedule | null { + private readSchedule(): ArtifactSchedule | null { const row = this.storage.sql.exec<{ payload: string }>("select payload from host_schedule where id=1").toArray()[0]; - return row ? JSON.parse(row.payload) as CanvasSchedule : null; + return row ? JSON.parse(row.payload) as ArtifactSchedule : null; } - private writeSchedule(schedule: CanvasSchedule) { + private writeSchedule(schedule: ArtifactSchedule) { this.storage.sql.exec("insert into host_schedule(id,payload) values(1,?) on conflict(id) do update set payload=excluded.payload", JSON.stringify(schedule)); } async get() { return this.readSchedule(); } - async update(input: CanvasScheduleInput) { + async update(input: ArtifactScheduleInput) { const action = input.action ?? "get"; if (action === "get") return this.get(); if (!["set", "pause", "resume"].includes(action)) throw new Error("Invalid schedule action"); @@ -61,17 +61,17 @@ export class CanvasExecution { const previous = this.readSchedule(); if (action !== "set" && !previous) throw new Error("No schedule configured"); const active = this.readActive(); - if (action !== "pause" && !active?.code) throw new Error("Canvas has no validated server"); + if (action !== "pause" && !active?.code) throw new Error("Artifact has no validated server"); const timing = action === "set" ? normalizeTiming(input) : previous!; const request = action === "set" ? input.request ?? { path: "/", method: "GET", headers: [] } : previous!.request; - const result: CanvasSchedule = { ...normalizeTiming(timing), request, paused: action === "pause", next_run_at: action === "pause" ? null : nextOccurrence(timing) }; + const result: ArtifactSchedule = { ...normalizeTiming(timing), request, paused: action === "pause", next_run_at: action === "pause" ? null : nextOccurrence(timing) }; this.writeSchedule(result); if (result.next_run_at === null) await storage.deleteAlarm(); else await storage.setAlarm(result.next_run_at); return result; }); } /** Claim before invoking; redelivered alarms cannot repeat side effects. */ - async claim(): Promise { + async claim(): Promise { return this.storage.transaction(async storage => { const schedule = this.readSchedule(); if (!schedule || schedule.paused || schedule.next_run_at === null) return null; diff --git a/cloudflare/artifact-routes.ts b/cloudflare/artifact-routes.ts index 2402138..21525a3 100644 --- a/cloudflare/artifact-routes.ts +++ b/cloudflare/artifact-routes.ts @@ -1,17 +1,17 @@ import type { Env } from "./worker"; import { authenticate } from "./auth"; import { authenticateBetterAuth } from "./better-auth"; -import { CloudCanvasService } from "./service"; +import { CloudArtifactService } from "./service"; import { readRequestText } from "./http"; -import { canvas_request as validateRequest } from "../dist/cloudflare/tool-validators.js"; -import type { CanvasHttpRequest } from "../src/httpTypes"; +import { artifact_request as validateRequest } from "../dist/cloudflare/tool-validators.js"; +import type { ArtifactHttpRequest } from "../src/httpTypes"; import { PluginError, PLUGIN_JSON_LIMIT, type PluginInvocationContext } from "./plugins"; -import { canvas_files as validateFiles } from "../dist/cloudflare/tool-validators.js"; -import type { CanvasFileRequest } from "../src/sdk/files"; +import { artifact_files as validateFiles } from "../dist/cloudflare/tool-validators.js"; +import type { ArtifactFileRequest } from "../src/sdk/files"; import type { PluginRequest, PluginUser } from "../src/plugins/types"; import galleryBridge from "../dist/cloudflare/gallery-request.json"; import navigationHost from "../dist/cloudflare/navigation-host.json"; -import { canvasApiRequest, canvasApiResponse } from "./canvas-api"; +import { artifactApiRequest, artifactApiResponse } from "./artifact-api"; export async function identify(request: Request, env: Env): Promise<{ privateKey: string; user: PluginUser } | Response> { if (env.AUTH_MODE && !["access", "better-auth"].includes(env.AUTH_MODE)) return Response.json({ error: "Invalid AUTH_MODE" }, { status: 503 }); @@ -37,7 +37,7 @@ export async function artifactRoute(request: Request, env: Env): Promise
`; - return html(`Canvas`; + return html(`Artifact`; }, result.js!); const frame = page.frameLocator("iframe"); - await frame.getByRole("heading", { name: "Worker canvas" }).waitFor(); + await frame.getByRole("heading", { name: "Worker artifact" }).waitFor(); await frame.getByRole("button", { name: "Count 0" }).click(); await frame.getByRole("button", { name: "Count 1" }).waitFor(); expect(errors).toEqual([]); @@ -81,17 +81,17 @@ export default function Canvas() { test("checks examples, canonical and legacy SDK imports, semantic errors and forbidden imports like the local compiler", async () => { const cases = [ example, - example.replaceAll("sidequery/canvas", "herdr/canvas"), - example.replaceAll("sidequery/canvas", "cursor/canvas"), - 'import { H1 } from "sidequery/canvas";\nconst value: number = "wrong";\nexport default function Canvas() { return

{value}

; }', - 'import { Button } from "sidequery/canvas";\nexport default function Canvas() { return ; }', - 'import { Missing } from "sidequery/canvas";\nexport default function Canvas() { return ; }', - 'import thing from "missing-package";\nexport default function Canvas() { return
{thing}
; }', + example.replaceAll("sidequery/artifacts", "herdr/canvas"), + example.replaceAll("sidequery/artifacts", "cursor/canvas"), + 'import { H1 } from "sidequery/artifacts";\nconst value: number = "wrong";\nexport default function Artifact() { return

{value}

; }', + 'import { Button } from "sidequery/artifacts";\nexport default function Artifact() { return ; }', + 'import { Missing } from "sidequery/artifacts";\nexport default function Artifact() { return ; }', + 'import thing from "missing-package";\nexport default function Artifact() { return
{thing}
; }', ]; for (const [index, source] of cases.entries()) { - const path = join(temporary, `${index}.canvas.tsx`); + const path = join(temporary, `${index}.artifact.tsx`); await writeFile(path, source); - const local = typecheckCanvas(path); + const local = typecheckArtifact(path); const remote = await call(source, "typecheck"); const comparable = (diagnostics: Diagnostic[]) => diagnostics.map(({ message, line, column }) => ({ message, line, column })); expect(comparable(remote.diagnostics)).toEqual(comparable(local)); @@ -104,7 +104,7 @@ test("checks examples, canonical and legacy SDK imports, semantic errors and for test("repeated and concurrent compilations keep source separate", async () => { const started = performance.now(); - const outputs = await Promise.all(["alpha", "beta", "gamma"].map(label => call(`import { H1 } from "sidequery/canvas";\nexport default function Canvas() { return

${label}-unique

; }`))); + const outputs = await Promise.all(["alpha", "beta", "gamma"].map(label => call(`import { H1 } from "sidequery/artifacts";\nexport default function Artifact() { return

${label}-unique

; }`))); for (const [index, result] of outputs.entries()) { expect(result.diagnostics).toEqual([]); expect(result.ok).toBe(true); @@ -122,7 +122,7 @@ test("bounds source input", async () => { test("compiles native Durable Object server APIs with semantic diagnostics and keeps client imports isolated", async () => { const source = `import { DurableObject } from "cloudflare:workers"; -export class CanvasServer extends DurableObject { +export class ArtifactServer extends DurableObject { fetch(request: Request) { this.ctx.storage.sql.exec("create table if not exists counter (n integer)"); return Response.json(this.ctx.storage.sql.exec("select count(*) as n from counter").toArray()); @@ -135,15 +135,15 @@ export class CanvasServer extends DurableObject { for (const valid of [ source.replace('import { DurableObject }', 'import { DurableObject as NativeDO }').replace('extends DurableObject', 'extends NativeDO'), source.replace('import { DurableObject } from "cloudflare:workers";', 'import * as workers from "cloudflare:workers";').replace('extends DurableObject', 'extends workers.DurableObject'), - source.replace('export class CanvasServer extends DurableObject', 'class LocalBase extends DurableObject {}\nexport class CanvasServer extends LocalBase'), + source.replace('export class ArtifactServer extends DurableObject', 'class LocalBase extends DurableObject {}\nexport class ArtifactServer extends LocalBase'), ]) { expect((await call(valid, "typecheck-server")).diagnostics).toEqual([]); expect((await call(valid, "compile-server")).ok).toBe(true); } for (const invalidClass of [ - 'export class CanvasServer { fetch() { return new Response("ok"); } }', - 'import { DurableObject } from "cloudflare:workers"; export default class CanvasServer extends DurableObject {}', - 'import { DurableObject } from "cloudflare:workers"; class CanvasServer extends DurableObject {}', + 'export class ArtifactServer { fetch() { return new Response("ok"); } }', + 'import { DurableObject } from "cloudflare:workers"; export default class ArtifactServer extends DurableObject {}', + 'import { DurableObject } from "cloudflare:workers"; class ArtifactServer extends DurableObject {}', ]) { const checked = await call(invalidClass, "typecheck-server"); expect(checked.diagnostics.some(diagnostic => diagnostic.message.includes("extending DurableObject"))).toBe(true); @@ -154,16 +154,16 @@ export class CanvasServer extends DurableObject { const invalid = await call(source.replace("sql.exec", "sql.missingMethod"), "compile-server"); expect(invalid.ok).toBe(false); expect(invalid.diagnostics[0]?.message).toContain("missingMethod"); - const client = await call('import { DurableObject } from "cloudflare:workers"; export default function Canvas() { return
; }'); + const client = await call('import { DurableObject } from "cloudflare:workers"; export default function Artifact() { return
; }'); expect(client.ok).toBe(false); - const next = await call('import { H1 } from "sidequery/canvas"; export default function Canvas() { return

Still works

; }'); + const next = await call('import { H1 } from "sidequery/artifacts"; export default function Artifact() { return

Still works

; }'); expect(next.ok).toBe(true); expect(outbound).toEqual([]); }, 60000); test("a transient server compiler queue overload can be retried", async () => { const sources = Array.from({ length: 12 }, (_, index) => `import { DurableObject } from "cloudflare:workers"; -export class CanvasServer extends DurableObject { fetch() { return new Response("${index}"); } }`); +export class ArtifactServer extends DurableObject { fetch() { return new Response("${index}"); } }`); const overloaded = await Promise.all(sources.map(item => call(item, "compile-server"))); const last = overloaded.at(-1)!; expect(last.ok).toBe(false); @@ -176,8 +176,8 @@ export class CanvasServer extends DurableObject { fetch() { return new Response( test("ordinary hooks render and update through canonical and legacy hosted SDK imports", async () => { const browser = await chromium.launch({ headless: true }); try { - for (const specifier of ["sidequery/canvas", "herdr/canvas", "cursor/canvas"]) { - const result = await call(HOOKS_CANVAS.replaceAll("sidequery/canvas", specifier)); + for (const specifier of ["sidequery/artifacts","@sidequery/artifacts","sidequery/canvas","@sidequery/canvas","herdr/canvas","cursor/canvas"]) { + const result = await call(HOOKS_ARTIFACT.replaceAll("sidequery/artifacts", specifier)); expect(result.diagnostics).toEqual([]); expect(result.ok).toBe(true); const page = await browser.newPage(); @@ -214,7 +214,7 @@ test("scripts compile arbitrary Workers handlers and reject missing handlers, in 'const count: number = "wrong"; export default {fetch() {return new Response(String(count));}};', 'import missing from "missing-package"; export default {fetch() {return new Response(missing);}};', ]) expect((await call(source,"compile-script")).ok).toBe(false); - expect((await call('import { H1 } from "sidequery/canvas"; export default function Canvas() { return

After script

; }')).ok).toBe(true); + expect((await call('import { H1 } from "sidequery/artifacts"; export default function Artifact() { return

After script

; }')).ok).toBe(true); },60000); test("a prebundled third-party Hono handler compiles and serves through the script runtime", async () => { @@ -283,13 +283,13 @@ test("a prebundled third-party Hono handler compiles and serves through the scri }, 60000); // Build the fixture deployment, then run this gated integration test: -// CANVAS_PLUGINS_CONFIG=src/test/plugins/config.ts bun run build:cloudflare-compiler -// CANVAS_PLUGIN_TEST=1 bun test cloudflare/compiler.test.ts --test-name-pattern 'deployment browser plugin' --timeout 60000 +// ARTIFACTS_PLUGINS_CONFIG=src/test/plugins/config.ts bun run build:cloudflare-compiler +// ARTIFACTS_PLUGIN_TEST=1 bun test cloudflare/compiler.test.ts --test-name-pattern 'deployment browser plugin' --timeout 60000 // Restore the default deployment afterward with: bun run build:cloudflare-compiler -test.skipIf(process.env.CANVAS_PLUGIN_TEST !== "1")("deployment browser plugin compiles, checks types, and shares React hooks in workerd", async () => { +test.skipIf(process.env.ARTIFACTS_PLUGIN_TEST !== "1")("deployment browser plugin compiles, checks types, and shares React hooks in workerd", async () => { const source = `import { PluginCounter, type Label } from "@test/counter"; const label: Label = { prefix: "Plugin" }; -export default function Canvas() { return ; }`; +export default function Artifact() { return ; }`; expect((await call(source, "typecheck")).diagnostics).toEqual([]); const invalidSdkType = source.replace("type Label", "type Label, type Tone").replace("const label", 'const tone: Tone = "invalid-tone"; const label'); expect((await call(invalidSdkType, "typecheck")).diagnostics.some(item => item.message.includes("invalid-tone"))).toBe(true); @@ -331,7 +331,7 @@ test("compiles archived multi-file projects and typed packages without network", "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 source = 'import {greeting} from "./lib/greeting"; import {answer} from "tiny-example"; export default function Artifact(){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"); @@ -348,7 +348,7 @@ test("compiles archived multi-file projects and typed packages without network", },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:{ + const result=await callProject('import {Counter} from "counter-package"; export default function Artifact(){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;', @@ -359,10 +359,42 @@ test("package components use the host React hook runtime",async()=>{ expect(outbound).toEqual([]); },60000); -test.skipIf(process.env.CANVAS_PACKAGE_INTEGRATION !== "1")("compiles an integrity-verified Hono dependency snapshot without compiler network",async()=>{ +test.skipIf(process.env.ARTIFACTS_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); + +test("historical Canvas SDK source compiles through every legacy hosted import", async () => { + for (const specifier of ["sidequery/canvas", "@sidequery/canvas", "herdr/canvas", "cursor/canvas"]) { + const result = await call(`import { useCanvasState, canvasFetch, canvasFiles, MAX_CANVAS_FILE_BYTES, type SetCanvasState } from "${specifier}"; +export default function HistoricalCanvas() { + const [count, setCount]: [number, SetCanvasState] = useCanvasState("count", 0); + return ; +}`); + expect(result.diagnostics).toEqual([]); + expect(result.ok).toBe(true); + } +}, 60000); + +test("legacy server class exports compile to canonical ArtifactServer", async () => { + for (const declaration of [ + "export class CanvasServer extends DurableObject {}", + "class HistoricalServer extends DurableObject {}; export { HistoricalServer as CanvasServer };", + "export class CanvasServer extends DurableObject {}; export class ArtifactServer extends DurableObject {}", + ]) { + const result = await call('import { DurableObject } from "cloudflare:workers"; ' + declaration, "compile-server"); + expect(result.diagnostics).toEqual([]); + expect(result.ok).toBe(true); + expect(result.js).toMatch(/export\s*\{[^}]*\bArtifactServer\b/); + } + for (const source of [ + 'export class CanvasServer {}', + 'import { DurableObject } from "cloudflare:workers"; export default class CanvasServer extends DurableObject {}', + 'import { DurableObject } from "cloudflare:workers"; export class CanvasServer extends DurableObject {}; export class ArtifactServer {}', + ]) { + expect((await call(source, "compile-server")).ok).toBe(false); + } +}, 60000); diff --git a/cloudflare/compiler.ts b/cloudflare/compiler.ts index 40c8044..be92b08 100644 --- a/cloudflare/compiler.ts +++ b/cloudflare/compiler.ts @@ -5,10 +5,10 @@ import files from "../dist/cloudflare/compiler-files.json"; import browserPlugins from "../dist/cloudflare/plugin-browser.json"; import browserRuntime from "../dist/cloudflare/browser-runtime.json"; import { sandboxToDiagnostics, type Diagnostic } from "../src/diagnostics"; -import { scanCanvasSource } from "../src/sandbox"; +import { scanArtifactSource } from "../src/sandbox"; -const sourcePath = "canvas.canvas.tsx"; -const serverPath = "canvas.canvas.server.ts"; +const sourcePath = "artifact.artifact.tsx"; +const serverPath = "artifact.artifact.server.ts"; const sdkPath = "src/sdk/index.ts"; const compilerFiles: Record = { ...files, ...browserPlugins.files }; const options: ts.CompilerOptions = { @@ -16,7 +16,7 @@ const options: ts.CompilerOptions = { moduleResolution: ts.ModuleResolutionKind.Bundler, jsx: ts.JsxEmit.ReactJSX, strict: true, noEmit: true, skipLibCheck: true, esModuleInterop: true, allowSyntheticDefaultImports: true, baseUrl: "/", - paths: { ...browserPlugins.paths, "@sidequery/canvas": [sdkPath], "sidequery/canvas": [sdkPath], "herdr/canvas": [sdkPath], "cursor/canvas": [sdkPath] }, + paths: { ...browserPlugins.paths, "@sidequery/artifacts": [sdkPath], "sidequery/artifacts": [sdkPath], "sidequery/canvas": [sdkPath], "@sidequery/canvas": [sdkPath], "herdr/canvas": [sdkPath], "cursor/canvas": [sdkPath] }, types: [], }; const normalize = (path: string) => path.replace(/^\//, ""); @@ -47,7 +47,7 @@ const host: ts.LanguageServiceHost = { return text === undefined ? undefined : ts.ScriptSnapshot.fromString(text); }, getDefaultLibFileName: () => `/node_modules/typescript/lib/lib.es2022${serverMode ? "" : ".full"}.d.ts`, - writeFile: () => { throw new Error("Canvas typechecking does not emit files"); }, + writeFile: () => { throw new Error("Artifact typechecking does not emit files"); }, getCurrentDirectory: () => "/", useCaseSensitiveFileNames: () => true, getNewLine: () => "\n", fileExists: path => read(path) !== undefined, readFile: read, @@ -59,7 +59,7 @@ const host: ts.LanguageServiceHost = { }; let languageService: ts.LanguageService | undefined; -export function typecheckCanvasSource(source: string, project = emptyProject()): Diagnostic[] { +export function typecheckArtifactSource(source: string, project = emptyProject()): Diagnostic[] { const diagnostics: Diagnostic[] = []; const reachable = new Map([[sourcePath, source]]); for (const [path, code] of reachable) { @@ -84,20 +84,20 @@ export function typecheckCanvasSource(source: string, project = emptyProject()): } 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"]; + const imports: string[] = [...Object.keys(browserPlugins.paths), "@sidequery/artifacts", "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"); + const violations = scanArtifactSource(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, project = emptyProject()): Diagnostic[] { +export function typecheckArtifactServerSource(source: string, project = emptyProject()): Diagnostic[] { return typecheckSource(source, true, false, project); } @@ -109,7 +109,7 @@ function typecheckSource(source: string, server: boolean, script = false, projec sourceVersion++; languageService ??= ts.createLanguageService(host); const program = languageService.getProgram(); - if (!program) throw new Error("Could not initialize Canvas TypeScript project"); + if (!program) throw new Error("Could not initialize Artifact TypeScript project"); const diagnostics: Diagnostic[] = ts.getPreEmitDiagnostics(program) .filter(diagnostic => diagnostic.category === ts.DiagnosticCategory.Error) .map(diagnostic => { @@ -122,11 +122,11 @@ function typecheckSource(source: string, server: boolean, script = false, projec ...(position ? { line: position.line + 1, column: position.character + 1 } : {}), }; }); - if (server && !script) diagnostics.push(...validateCanvasServerClass(program)); + if (server && !script) diagnostics.push(...validateArtifactServerClass(program)); return diagnostics; } -function validateCanvasServerClass(program: ts.Program): Diagnostic[] { +function validateArtifactServerClass(program: ts.Program): Diagnostic[] { const file = program.getSourceFiles().find(source => normalize(source.fileName) === serverPath); if (!file) return [serverClassDiagnostic()]; const checker = program.getTypeChecker(); @@ -140,10 +140,10 @@ function validateCanvasServerClass(program: ts.Program): Diagnostic[] { const durableObject = resolve(moduleSymbol && checker.getExportsOfModule(moduleSymbol) .find(symbol => symbol.name === "DurableObject")); const fileSymbol = checker.getSymbolAtLocation(file); - const canvasServer = resolve(fileSymbol && checker.getExportsOfModule(fileSymbol) - .find(symbol => symbol.name === "CanvasServer")); - const declaration = canvasServer?.declarations?.find(node => ts.isClassDeclaration(node) && node.getSourceFile() === file); - if (!durableObject || !canvasServer || !declaration) return [serverClassDiagnostic(declaration)]; + const artifactServer = resolve(fileSymbol && checker.getExportsOfModule(fileSymbol) + .find(symbol => symbol.name === serverExportName(program))); + const declaration = artifactServer?.declarations?.find(node => ts.isClassDeclaration(node) && node.getSourceFile() === file); + if (!durableObject || !artifactServer || !declaration) return [serverClassDiagnostic(declaration)]; const seen = new Set(); const inheritsDurableObject = (type: ts.Type): boolean => { @@ -154,14 +154,14 @@ function validateCanvasServerClass(program: ts.Program): Diagnostic[] { return symbol === durableObject || inheritsDurableObject(base); }); }; - return inheritsDurableObject(checker.getDeclaredTypeOfSymbol(canvasServer)) ? [] : [serverClassDiagnostic(declaration)]; + return inheritsDurableObject(checker.getDeclaredTypeOfSymbol(artifactServer)) ? [] : [serverClassDiagnostic(declaration)]; } function serverClassDiagnostic(node?: ts.Node): Diagnostic { const position = node?.getSourceFile().getLineAndCharacterOfPosition(node.getStart()); return { severity: "error", - message: "Server code must export class CanvasServer extending DurableObject from cloudflare:workers", + message: "Server code must export class ArtifactServer (or legacy CanvasServer) extending DurableObject from cloudflare:workers", file: serverPath, ...(position ? { line: position.line + 1, column: position.character + 1 } : {}), }; @@ -171,11 +171,11 @@ let pendingCompilations = 0; let nextCompilation = 0; let activeCompilation = 0; -export async function compileCanvasSource(source: string, project = emptyProject()) { +export async function compileArtifactSource(source: string, project = emptyProject()) { return queueCompile(source, false, false, project); } -export async function compileCanvasServerSource(source: string, project = emptyProject()) { +export async function compileArtifactServerSource(source: string, project = emptyProject()) { return queueCompile(source, true, false, project); } @@ -193,8 +193,15 @@ async function queueCompile(source: string, server: boolean, script = false, pro finally { activeCompilation++; pendingCompilations--; } } +function serverExportName(program: ts.Program): "ArtifactServer" | "CanvasServer" { + const file = program.getSourceFiles().find(source => normalize(source.fileName) === serverPath); + const checker = program.getTypeChecker(); + const symbol = file && checker.getSymbolAtLocation(file); + return symbol && checker.getExportsOfModule(symbol).some(item => item.name === "ArtifactServer") ? "ArtifactServer" : "CanvasServer"; +} + async function compileServer(source: string, project: ArtifactProject) { - const diagnostics = typecheckCanvasServerSource(source, project); + const diagnostics = typecheckArtifactServerSource(source, project); if (diagnostics.length) return { ok: false, diagnostics }; try { // celld 0.4.1 requires a default Worker export even when only the named @@ -202,7 +209,7 @@ async function compileServer(source: string, project: ArtifactProject) { 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 }); } };`, + "server-entry.ts": `export { ${serverExportName(languageService!.getProgram()!)} as ArtifactServer } from "./${serverPath}"; export default { fetch() { return new Response("Not found", { status: 404 }); } };`, }, entryPoint: "server-entry.ts", target: "es2022", sourcemap: false }); if (result.warnings?.length) throw new Error(result.warnings.join("\n")); const js = result.modules[result.mainModule]; @@ -215,19 +222,21 @@ async function compileServer(source: string, project: ArtifactProject) { async function compileSource(source: string, project: ArtifactProject) { const started = performance.now(); - const diagnostics = typecheckCanvasSource(source, project); + const diagnostics = typecheckArtifactSource(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);`, + "entry.ts": `import Artifact from "./${sourcePath}"; globalThis.__artifactsRuntime.mount(Artifact);`, }, entryPoint: "entry.ts", target: "es2022", jsx: "automatic", minify: false, sourcemap: false, define: { "process.env.NODE_ENV": '"production"' }, virtualModules: { + "sidequery/artifacts": browserRuntime.sdkModule, + "@sidequery/artifacts": browserRuntime.sdkModule, "sidequery/canvas": browserRuntime.sdkModule, "@sidequery/canvas": browserRuntime.sdkModule, "herdr/canvas": browserRuntime.sdkModule, @@ -256,11 +265,11 @@ async function compileSource(source: string, project: ArtifactProject) { } } -/** Scripts use Workers globals and can import supported builtins, without the canvas UI restrictions. */ +/** Scripts use Workers globals and can import supported builtins, without the artifact UI restrictions. */ export function compileScriptSource(source: string, project = emptyProject()) { return queueCompile(source, true, true, project); } async function compileScript(source: string, project: ArtifactProject) { - const declarations = '\ninterface ScriptEnv { secrets: Record; sql: SqlStorage }\ntype __CanvasScriptResult = Response | Promise;\n'; + const declarations = '\ninterface ScriptEnv { secrets: Record; sql: SqlStorage }\ntype __ArtifactScriptResult = Response | Promise;\n'; const diagnostics = typecheckSource(source + declarations, true, true, project); const program = languageService!.getProgram()!; const file = program.getSourceFiles().find(item => normalize(item.fileName) === serverPath)!; @@ -271,7 +280,7 @@ async function compileScript(source: string, project: ArtifactProject) { const fetch = handler && checker.getPropertyOfType(handler, "fetch"); const signatures = fetch ? checker.getTypeOfSymbolAtLocation(fetch, file).getCallSignatures() : []; if (!signatures.length) diagnostics.push({severity: "error", file: "script.ts", message: "Script must default-export an object with a fetch(request, env, ctx) handler"}); - const resultType = file.statements.find((node): node is ts.TypeAliasDeclaration => ts.isTypeAliasDeclaration(node) && node.name.text === "__CanvasScriptResult"); + const resultType = file.statements.find((node): node is ts.TypeAliasDeclaration => ts.isTypeAliasDeclaration(node) && node.name.text === "__ArtifactScriptResult"); if (resultType && signatures.some(signature => !checker.isTypeAssignableTo(checker.getReturnTypeOfSignature(signature), checker.getTypeFromTypeNode(resultType.type)))) { diagnostics.push({severity: "error", file: "script.ts", message: "Script fetch handler must return a Response or Promise"}); } diff --git a/cloudflare/dev.ts b/cloudflare/dev.ts index 761db9e..3d0241f 100644 --- a/cloudflare/dev.ts +++ b/cloudflare/dev.ts @@ -1,5 +1,5 @@ import { normalizeProject, emptyProject } from "./project"; -import { compileCanvasSource, compileCanvasServerSource, compileScriptSource, typecheckCanvasSource, typecheckCanvasServerSource } from "./compiler"; +import { compileArtifactSource, compileArtifactServerSource, compileScriptSource, typecheckArtifactSource, typecheckArtifactServerSource } from "./compiler"; import { readRequestText } from "./http"; import type { ExecutionContext } from "@cloudflare/workers-types"; @@ -14,7 +14,7 @@ export default { 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 }); + if (error instanceof RangeError) return new Response("Artifact source exceeds 256 KiB", { status: 413 }); throw error; } if (request.headers.get("content-type")?.includes("application/json")) { @@ -22,10 +22,10 @@ export default { 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; + : url.pathname === "/compile-server" ? compileArtifactServerSource(source, project) + : url.pathname === "/compile" ? compileArtifactSource(source, project) : undefined; if (compilation) ctx.waitUntil(compilation); return Response.json(compilation ? await compilation - : { diagnostics: url.pathname === "/typecheck-server" ? typecheckCanvasServerSource(source, project) : typecheckCanvasSource(source, project) }); + : { diagnostics: url.pathname === "/typecheck-server" ? typecheckArtifactServerSource(source, project) : typecheckArtifactSource(source, project) }); }, }; diff --git a/cloudflare/files-test-worker.ts b/cloudflare/files-test-worker.ts index c8927bb..f2cd41f 100644 --- a/cloudflare/files-test-worker.ts +++ b/cloudflare/files-test-worker.ts @@ -1,7 +1,7 @@ -import { CanvasFiles as BaseCanvasFiles, TRANSFER_PATH } from "./files"; +import { ArtifactFiles as BaseArtifactFiles, TRANSFER_PATH } from "./files"; import type { FilesEnv } from "./files"; -import type { CanvasFileRequest } from "../src/sdk/files"; -export class CanvasFiles extends BaseCanvasFiles { +import type { ArtifactFileRequest } from "../src/sdk/files"; +export class ArtifactFiles extends BaseArtifactFiles { private armDeleteFailure: () => void; constructor(ctx: DurableObjectState, env: FilesEnv) { @@ -27,7 +27,7 @@ export class CanvasFiles extends BaseCanvasFiles { async runCleanup() { await super.alarm(); } } -type Env = { FILE_BACKENDS: DurableObjectNamespace }; +type Env = { FILE_BACKENDS: DurableObjectNamespace }; export default { async fetch(request: Request, env: Env): Promise { @@ -45,7 +45,7 @@ export default { await stub.runCleanup(); return Response.json({ ok: true }); } - return Response.json(await stub.request(await request.json() as CanvasFileRequest)); + return Response.json(await stub.request(await request.json() as ArtifactFileRequest)); } catch (error) { return Response.json({ error: error instanceof Error ? error.message : String(error) }, { status: 400 }); } diff --git a/cloudflare/files.test.ts b/cloudflare/files.test.ts index d3ad49f..6ac68f5 100644 --- a/cloudflare/files.test.ts +++ b/cloudflare/files.test.ts @@ -1,10 +1,10 @@ import { afterAll, beforeAll, expect, test } from "bun:test"; import { Miniflare } from "miniflare"; import { request as httpRequest } from "node:http"; -import type { CanvasFile, CanvasFileList } from "../src/sdk/files"; +import type { ArtifactFile, ArtifactFileList } from "../src/sdk/files"; let runtime: Miniflare; -type Grant = { file: CanvasFile; path: string; expires: string }; +type Grant = { file: ArtifactFile; path: string; expires: string }; beforeAll(async () => { const build = await Bun.build({ @@ -15,16 +15,16 @@ beforeAll(async () => { runtime = new Miniflare({ cf: false, port: 0, unsafeInspectDurableObjects: true, workers: [{ config: { - name: "canvas-files-test", type: "worker", compatibilityDate: "2026-09-06", compatibilityFlags: ["nodejs_compat"], + name: "artifact-files-test", type: "worker", compatibilityDate: "2026-09-06", compatibilityFlags: ["nodejs_compat"], manifest: { mainModule: "files-test-worker.js", modulesRoot: import.meta.dir, modules: { "files-test-worker.js": { type: "esm", contents: await build.outputs[0]!.text() } }, }, env: { - FILES: { type: "r2", name: "canvas-files-test" }, - FILE_BACKENDS: { type: "durable-object", worker: "canvas-files-test", exportName: "CanvasFiles" }, + FILES: { type: "r2", name: "artifact-files-test" }, + FILE_BACKENDS: { type: "durable-object", worker: "artifact-files-test", exportName: "ArtifactFiles" }, }, - exports: { CanvasFiles: { type: "durable-object", storage: "sqlite" } }, + exports: { ArtifactFiles: { type: "durable-object", storage: "sqlite" } }, }, dev: {} }], }); await runtime.ready; @@ -54,7 +54,7 @@ function transfer(path: string, method = "GET", body?: Uint8Array) { ...(body ? { body } : {}), }); } -async function list(scope: string) { return call(scope, { operation: "list" }); } +async function list(scope: string) { return call(scope, { operation: "list" }); } test("native R2 roundtrips 2 MiB binary data, metadata, UTF-8 attachment names, and deletion", async () => { const scope = "roundtrip"; @@ -65,7 +65,7 @@ test("native R2 roundtrips 2 MiB binary data, metadata, UTF-8 attachment names, expect(await list(scope)).toEqual({ files: [] }); const written = await transfer(upload.path, "PUT", bytes); expect(written.status).toBe(201); - const { file } = await written.json() as { file: CanvasFile }; + const { file } = await written.json() as { file: ArtifactFile }; expect(file).toMatchObject({ id: upload.file.id, name: upload.file.name, size: bytes.length, type: "application/octet-stream" }); expect(Number.isNaN(Date.parse(file.uploaded))).toBe(false); expect(await list(scope)).toEqual({ files: [file] }); @@ -84,7 +84,7 @@ test("native R2 roundtrips 2 MiB binary data, metadata, UTF-8 attachment names, expect((await transfer(download.path)).status).toBe(404); }); -test("separate canvas scopes isolate identical filenames and cannot select each other's IDs or grants", async () => { +test("separate artifact scopes isolate identical filenames and cannot select each other's IDs or grants", async () => { const first = await grant("scope-a", 1); const second = await grant("scope-b", 1); expect((await transfer(first.path, "PUT", new Uint8Array([1]))).status).toBe(201); @@ -119,12 +119,12 @@ test("upload grants enforce method and size, preserve no pending objects, and co test("zero-byte uploads and issued grants survive Durable Object eviction", async () => { const scope = "eviction"; const upload = await grant(scope, 0, "empty.txt", ""); - await runtime.unsafeEvictDurableObject("canvas-files-test", "CanvasFiles", { name: scope }); + await runtime.unsafeEvictDurableObject("artifact-files-test", "ArtifactFiles", { name: scope }); // dispatchFetch drops content-length for an empty typed array; exercise real HTTP instead. const emptyPut = await fetch(new URL(upload.path, await runtime.ready), { method: "PUT", headers: { "content-length": "0" }, body: new Uint8Array() }); expect({ status: emptyPut.status, body: await emptyPut.json() }).toMatchObject({ status: 201 }); const download = await call(scope, { operation: "download", id: upload.file.id }); - await runtime.unsafeEvictDurableObject("canvas-files-test", "CanvasFiles", { name: scope }); + await runtime.unsafeEvictDurableObject("artifact-files-test", "ArtifactFiles", { name: scope }); expect((await list(scope)).files[0]).toMatchObject({ id: upload.file.id, size: 0, type: "application/octet-stream" }); const response = await transfer(download.path); expect(response.status).toBe(200); @@ -137,7 +137,7 @@ test("expired grants cannot transfer and cleanup removes them without deleting s await transfer(uploaded.path, "PUT", new Uint8Array([42])); const download = await call(scope, { operation: "download", id: uploaded.file.id }); const pending = await grant(scope, 1, "pending.bin"); - const storage = await runtime.unsafeGetDurableObjectStorage("canvas-files-test", "CanvasFiles", { name: scope }); + const storage = await runtime.unsafeGetDurableObjectStorage("artifact-files-test", "ArtifactFiles", { name: scope }); await storage.exec("update file_grants set expires = 0"); expect((await transfer(download.path)).status).toBe(404); expect((await transfer(pending.path, "PUT", new Uint8Array([1]))).status).toBe(404); @@ -169,7 +169,7 @@ test("invalid names, IDs, request shapes, and sizes above 25 MiB are rejected be test.each([false, true])("deleting an in-flight upload prevents resurrection, including cleanup failure=%s", async cleanupFailure => { const scope = `delete-during-upload-${cleanupFailure}`; const upload = await grant(scope, 2); - const storage = await runtime.unsafeGetDurableObjectStorage("canvas-files-test", "CanvasFiles", { name: scope }); + const storage = await runtime.unsafeGetDurableObjectStorage("artifact-files-test", "ArtifactFiles", { name: scope }); let resolveResponse!: (value: { status: number; body: string }) => void; let rejectResponse!: (error: Error) => void; const response = new Promise<{ status: number; body: string }>((resolve, reject) => { resolveResponse = resolve; rejectResponse = reject; }); @@ -206,7 +206,7 @@ test.each([false, true])("deleting an in-flight upload prevents resurrection, in ? { status: 500, body: JSON.stringify({ error: "File transfer failed" }) } : { status: 409, body: JSON.stringify({ error: "Upload was canceled" }) }); expect(await list(scope)).toEqual({ files: [] }); - const bucket = await runtime.getR2Bucket("FILES", "canvas-files-test"); + const bucket = await runtime.getR2Bucket("FILES", "artifact-files-test"); const key = `canvases/${upload.path.split("/")[5]}/${upload.file.id}`; if (cleanupFailure) { expect(await bucket.head(key)).not.toBeNull(); @@ -230,7 +230,7 @@ test("R2 pagination returns every scoped file once and preserves metadata across const scope = "pagination"; const upload = await grant(scope, 0); const prefix = `canvases/${upload.path.split("/")[5]}/`; - const bucket = await runtime.getR2Bucket("FILES", "canvas-files-test"); + const bucket = await runtime.getR2Bucket("FILES", "artifact-files-test"); const ids = Array.from({ length: 105 }, () => crypto.randomUUID()).sort(); await Promise.all(ids.map(id => bucket.put(prefix + id, new Uint8Array([7]), { customMetadata: { name: `${id}.txt` }, httpMetadata: { contentType: "text/plain" }, @@ -239,7 +239,7 @@ test("R2 pagination returns every scoped file once and preserves metadata across const first = await list(scope); expect(first.files).toHaveLength(100); expect(first.cursor).toBeString(); - const last = await call(scope, { operation: "list", cursor: first.cursor }); + const last = await call(scope, { operation: "list", cursor: first.cursor }); expect(last.files).toHaveLength(5); expect(last.cursor).toBeUndefined(); expect([...first.files, ...last.files].map(file => file.id)).toEqual(ids); @@ -252,7 +252,7 @@ test("pending grants are capped at 128 and expired grants free capacity", async const denied = await request(scope, { operation: "upload", name: "over-quota", size: 0, type: "" }); expect(denied.status).toBe(400); expect(await denied.text()).toContain("Too many pending file transfers"); - const storage = await runtime.unsafeGetDurableObjectStorage("canvas-files-test", "CanvasFiles", { name: scope }); + const storage = await runtime.unsafeGetDurableObjectStorage("artifact-files-test", "ArtifactFiles", { name: scope }); await storage.exec("update file_grants set expires = 0"); expect((await grant(scope, 0)).file.size).toBe(0); expect(await storage.exec("select count(*) as count from file_grants")).toEqual([{ count: 1 }]); @@ -264,12 +264,12 @@ test("cleanup reclaims stale uploaded objects while retaining live uploads and c const completed = await grant(scope, 1); expect((await transfer(completed.path, "PUT", new Uint8Array([1]))).status).toBe(201); const prefix = `canvases/${completed.path.split("/")[5]}/`; - const bucket = await runtime.getR2Bucket("FILES", "canvas-files-test"); + const bucket = await runtime.getR2Bucket("FILES", "artifact-files-test"); const staleId = crypto.randomUUID(); const activeId = crypto.randomUUID(); await bucket.put(prefix + staleId, "stale"); await bucket.put(prefix + activeId, "active"); - const storage = await runtime.unsafeGetDurableObjectStorage("canvas-files-test", "CanvasFiles", { name: scope }); + const storage = await runtime.unsafeGetDurableObjectStorage("artifact-files-test", "ArtifactFiles", { name: scope }); await storage.exec("insert into file_uploads values (?, 0, 0), (?, 0, ?)", staleId, activeId, Date.now() + 600_000); expect((await list(scope)).files.map(file => file.id)).toEqual([completed.file.id]); expect((await request(scope, { operation: "download", id: staleId })).status).toBe(400); @@ -279,3 +279,20 @@ test("cleanup reclaims stale uploaded objects while retaining live uploads and c expect(await storage.exec("select id, canceled from file_uploads")).toEqual([{ id: activeId, canceled: 0 }]); expect((await list(scope)).files.map(file => file.id)).toEqual([completed.file.id]); }); + + +test("previously stored uploads remain readable through old and new transfer routes", async () => { + const scope = "legacy-uploads"; + const upload = await grant(scope, 0); + const id = crypto.randomUUID(); + const bucket = await runtime.getR2Bucket("FILES", "artifact-files-test"); + await bucket.put(`canvases/${upload.path.split("/")[5]}/${id}`, "existing bytes", { customMetadata: { name: "existing.txt" } }); + expect((await list(scope)).files).toEqual([expect.objectContaining({ id, name: "existing.txt" })]); + const download = await call(scope, { operation: "download", id }); + expect(download.path).toStartWith("/api/canvas/files/transfer/"); + for (const path of [download.path, download.path.replace("/canvas/", "/artifact/")]) { + const response = await transfer(path); + expect(response.status).toBe(200); + expect(await response.text()).toBe("existing bytes"); + } +}); diff --git a/cloudflare/files.ts b/cloudflare/files.ts index 38c525f..4c49f99 100644 --- a/cloudflare/files.ts +++ b/cloudflare/files.ts @@ -1,36 +1,36 @@ import { DurableObject } from "cloudflare:workers"; -import type { CanvasFile, CanvasFileRequest } from "../src/sdk/files"; +import type { ArtifactFile, ArtifactFileRequest } from "../src/sdk/files"; export const MAX_FILE_BYTES = 25 * 1024 * 1024; const GRANT_LIFETIME_MS = 5 * 60 * 1000; const ID = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/; -export const TRANSFER_PATH = /^\/api\/canvas\/files\/transfer\/([0-9a-f]{64})\/([0-9a-f-]{36})$/; -type Grant = { method: "PUT" | "GET"; file: CanvasFile; expires: number }; -type FileReply = { files: CanvasFile[]; cursor?: string } | { file: CanvasFile; path: string; expires: string } | { deleted: true }; +export const TRANSFER_PATH = /^\/api\/(?:artifact|canvas)\/files\/transfer\/([0-9a-f]{64})\/([0-9a-f-]{36})$/; +type Grant = { method: "PUT" | "GET"; file: ArtifactFile; expires: number }; +type FileReply = { files: ArtifactFile[]; cursor?: string } | { file: ArtifactFile; path: string; expires: string } | { deleted: true }; export type FilesEnv = { FILES: R2Bucket }; -export class CanvasFileError extends Error { +export class ArtifactFileError extends Error { constructor(message: string, readonly status: 400 | 403 | 404 | 409 | 413 | 429 | 503 = 400) { super(message); } } -export function validateFileRequest(value: unknown): asserts value is CanvasFileRequest { - if (!value || typeof value !== "object" || Array.isArray(value)) throw new CanvasFileError("Invalid file request"); +export function validateFileRequest(value: unknown): asserts value is ArtifactFileRequest { + if (!value || typeof value !== "object" || Array.isArray(value)) throw new ArtifactFileError("Invalid file request"); const input = value as Record; const allowed: Record = { list: ["operation", "cursor"], upload: ["operation", "name", "size", "type"], download: ["operation", "id"], delete: ["operation", "id"] }; if (typeof input.operation !== "string" || !Object.hasOwn(allowed, input.operation) - || Object.keys(input).some(key => !allowed[input.operation as string]!.includes(key))) throw new CanvasFileError("Invalid file request"); + || Object.keys(input).some(key => !allowed[input.operation as string]!.includes(key))) throw new ArtifactFileError("Invalid file request"); if (input.operation === "list") { - if (input.cursor !== undefined && (typeof input.cursor !== "string" || input.cursor.length > 2048)) throw new CanvasFileError("Invalid file cursor"); + if (input.cursor !== undefined && (typeof input.cursor !== "string" || input.cursor.length > 2048)) throw new ArtifactFileError("Invalid file cursor"); } else if (input.operation === "upload") { if (typeof input.name !== "string" || !input.name.trim() || input.name === "." || input.name === ".." - || /[/\\\u0000-\u001f\u007f]/.test(input.name) || new TextEncoder().encode(input.name).byteLength > 255) throw new CanvasFileError("File name must be 1–255 bytes without slashes or control characters"); - if (typeof input.size !== "number" || !Number.isSafeInteger(input.size) || input.size < 0) throw new CanvasFileError("Invalid file size"); - if (input.size > MAX_FILE_BYTES) throw new CanvasFileError("File exceeds 25 MiB", 413); - if (typeof input.type !== "string" || input.type.length > 255 || /[^\x20-\x7e]/.test(input.type)) throw new CanvasFileError("Invalid file content type"); - } else if (typeof input.id !== "string" || !ID.test(input.id)) throw new CanvasFileError("Invalid file ID"); + || /[/\\\u0000-\u001f\u007f]/.test(input.name) || new TextEncoder().encode(input.name).byteLength > 255) throw new ArtifactFileError("File name must be 1–255 bytes without slashes or control characters"); + if (typeof input.size !== "number" || !Number.isSafeInteger(input.size) || input.size < 0) throw new ArtifactFileError("Invalid file size"); + if (input.size > MAX_FILE_BYTES) throw new ArtifactFileError("File exceeds 25 MiB", 413); + if (typeof input.type !== "string" || input.type.length > 255 || /[^\x20-\x7e]/.test(input.type)) throw new ArtifactFileError("Invalid file content type"); + } else if (typeof input.id !== "string" || !ID.test(input.id)) throw new ArtifactFileError("Invalid file ID"); } -function metadata(object: R2Object): CanvasFile { +function metadata(object: R2Object): ArtifactFile { return { id: object.key.split("/").at(-1)!, name: object.customMetadata?.name ?? "download", size: object.size, type: object.httpMetadata?.contentType ?? "application/octet-stream", uploaded: object.uploaded.toISOString() }; } @@ -41,21 +41,22 @@ function transferHeaders(): Headers { "cache-control": "private, no-store", "referrer-policy": "no-referrer", "x-content-type-options": "nosniff" }); } -/** Native R2 stays in the trusted host. One object owns one library/workspace/canvas, +/** Native R2 stays in the trusted host. One object owns one library/workspace/artifact, * including historical previews; caller-provided filenames never select storage keys. */ -export class CanvasFiles extends DurableObject { +export class ArtifactFiles extends DurableObject { constructor(ctx: DurableObjectState, env: FilesEnv) { super(ctx, env); ctx.storage.sql.exec("create table if not exists file_grants (token text primary key, expires integer not null, value text not null)"); ctx.storage.sql.exec("create table if not exists file_uploads (id text primary key, canceled integer not null, expires integer not null)"); } + // Keep the deployed R2 prefix so existing uploads remain accessible. private prefix() { return `canvases/${this.ctx.id.toString()}/`; } private key(id: string) { return this.prefix() + id; } - async request(input: CanvasFileRequest): Promise { + async request(input: ArtifactFileRequest): Promise { validateFileRequest(input); - if (!this.env.FILES) throw new CanvasFileError("Canvas file storage is not configured", 503); + if (!this.env.FILES) throw new ArtifactFileError("Artifact file storage is not configured", 503); if (input.operation === "list") { const result = await this.env.FILES.list({ prefix: this.prefix(), limit: 100, include: ["customMetadata", "httpMetadata"], ...(input.cursor ? { cursor: input.cursor } : {}) }); const pending = new Set(this.ctx.storage.sql.exec<{ id: string }>("select id from file_uploads").toArray().map(row => row.id)); @@ -74,7 +75,7 @@ export class CanvasFiles extends DurableObject { : await this.info(input.id); const expires = Date.now() + GRANT_LIFETIME_MS; this.ctx.storage.sql.exec("delete from file_grants where expires <= ?", Date.now()); - if (this.ctx.storage.sql.exec<{ n: number }>("select count(*) as n from file_grants").one().n >= 128) throw new CanvasFileError("Too many pending file transfers; retry after five minutes", 429); + if (this.ctx.storage.sql.exec<{ n: number }>("select count(*) as n from file_grants").one().n >= 128) throw new ArtifactFileError("Too many pending file transfers; retry after five minutes", 429); const token = crypto.randomUUID(); const grant: Grant = { method: input.operation === "upload" ? "PUT" : "GET", file, expires }; this.ctx.storage.sql.exec("insert into file_grants values (?, ?, ?)", token, expires, JSON.stringify(grant)); @@ -82,10 +83,10 @@ export class CanvasFiles extends DurableObject { return { file, path: `/api/canvas/files/transfer/${this.ctx.id.toString()}/${token}`, expires: new Date(expires).toISOString() }; } - private async info(id: string): Promise { - if (this.ctx.storage.sql.exec("select id from file_uploads where id = ?", id).toArray().length) throw new CanvasFileError("File not found", 404); + private async info(id: string): Promise { + if (this.ctx.storage.sql.exec("select id from file_uploads where id = ?", id).toArray().length) throw new ArtifactFileError("File not found", 404); const object = await this.env.FILES.head(this.key(id)); - if (!object) throw new CanvasFileError("File not found", 404); + if (!object) throw new ArtifactFileError("File not found", 404); return metadata(object); } @@ -111,9 +112,9 @@ export class CanvasFiles extends DurableObject { const headers = transferHeaders(); try { const match = new URL(request.url).pathname.match(TRANSFER_PATH); - if (!match || match[1] !== this.ctx.id.toString() || !ID.test(match[2]!)) throw new CanvasFileError("Transfer not found", 404); + if (!match || match[1] !== this.ctx.id.toString() || !ID.test(match[2]!)) throw new ArtifactFileError("Transfer not found", 404); const row = this.ctx.storage.sql.exec<{ value: string }>("select value from file_grants where token = ? and expires > ?", match[2]!, Date.now()).toArray()[0]; - if (!row) throw new CanvasFileError("File transfer expired or already used", 404); + if (!row) throw new ArtifactFileError("File transfer expired or already used", 404); const grant = JSON.parse(row.value) as Grant; if (request.method === "OPTIONS") return new Response(null, { status: 204, headers }); if (request.method !== grant.method && !(grant.method === "GET" && request.method === "HEAD")) { @@ -124,7 +125,7 @@ export class CanvasFiles extends DurableObject { // HTTP bodies retain their native known length, so R2 can stream without // buffering or FixedLengthStream shims that differ between runtimes. const length = request.headers.get("content-length"); - if (length === null || !/^\d+$/.test(length) || Number(length) !== grant.file.size) throw new CanvasFileError("Upload size does not match its transfer grant"); + if (length === null || !/^\d+$/.test(length) || Number(length) !== grant.file.size) throw new ArtifactFileError("Upload size does not match its transfer grant"); // Consume before yielding: two concurrent PUTs cannot overwrite one file. this.ctx.storage.sql.exec("delete from file_grants where token = ?", match[2]!); this.ctx.storage.sql.exec("insert into file_uploads values (?, 0, ?)", grant.file.id, Date.now() + 10 * 60 * 1000); @@ -138,7 +139,7 @@ export class CanvasFiles extends DurableObject { if (state?.canceled !== 0 || object.size !== grant.file.size) { await this.env.FILES.delete(this.key(grant.file.id)); settled = true; - throw new CanvasFileError(state?.canceled !== 0 ? "Upload was canceled" : "Upload size does not match its transfer grant", 409); + throw new ArtifactFileError(state?.canceled !== 0 ? "Upload was canceled" : "Upload size does not match its transfer grant", 409); } settled = true; headers.set("content-type", "application/json"); @@ -154,7 +155,7 @@ export class CanvasFiles extends DurableObject { } } const object = request.method === "HEAD" ? await this.env.FILES.head(this.key(grant.file.id)) : await this.env.FILES.get(this.key(grant.file.id)); - if (!object) throw new CanvasFileError("File not found", 404); + if (!object) throw new ArtifactFileError("File not found", 404); headers.set("content-type", grant.file.type); headers.set("content-length", String(object.size)); headers.set("etag", object.httpEtag); @@ -164,7 +165,7 @@ export class CanvasFiles extends DurableObject { return new Response(request.method === "HEAD" ? null : (object as R2ObjectBody).body, { headers }); } catch (error) { headers.set("content-type", "application/json"); - return new Response(JSON.stringify({ error: error instanceof CanvasFileError ? error.message : "File transfer failed" }), { status: error instanceof CanvasFileError ? error.status : 500, headers }); + return new Response(JSON.stringify({ error: error instanceof ArtifactFileError ? error.message : "File transfer failed" }), { status: error instanceof ArtifactFileError ? error.status : 500, headers }); } } } diff --git a/cloudflare/library-test-worker.ts b/cloudflare/library-test-worker.ts index 82a8020..83acf0e 100644 --- a/cloudflare/library-test-worker.ts +++ b/cloudflare/library-test-worker.ts @@ -1,4 +1,4 @@ -export { CanvasLibrary } from "./library"; +export { ArtifactLibrary } from "./library"; type LibraryStub = Record Promise>; type Env = { LIBRARY: { getByName(name: string): LibraryStub } }; diff --git a/cloudflare/library.test.ts b/cloudflare/library.test.ts index 91fbdf2..74739ae 100644 --- a/cloudflare/library.test.ts +++ b/cloudflare/library.test.ts @@ -1,7 +1,7 @@ import { afterAll, beforeAll, expect, test } from "bun:test"; import { createHash } from "node:crypto"; import { Miniflare } from "miniflare"; -import type { CompiledCanvas } from "./library"; +import type { CompiledArtifact } from "./library"; let runtime: Miniflare; @@ -26,13 +26,13 @@ beforeAll(async () => { cf: false, port: 0, unsafeInspectDurableObjects: true, workers: [{ config: { - name: "canvas-library-test", type: "worker", compatibilityDate: "2026-09-06", compatibilityFlags: ["nodejs_compat"], + name: "artifact-library-test", type: "worker", compatibilityDate: "2026-09-06", compatibilityFlags: ["nodejs_compat"], manifest: { mainModule: "library-test-worker.js", modulesRoot: import.meta.dir, modules: { "library-test-worker.js": { type: "esm", contents: worker } }, }, - env: { LIBRARY: { type: "durable-object", worker: "canvas-library-test", exportName: "CanvasLibrary" } }, - exports: { CanvasLibrary: { type: "durable-object", storage: "sqlite" } }, + env: { LIBRARY: { type: "durable-object", worker: "artifact-library-test", exportName: "ArtifactLibrary" } }, + exports: { ArtifactLibrary: { type: "durable-object", storage: "sqlite" } }, }, dev: {}, }], @@ -61,7 +61,7 @@ async function fails(method: string, input: unknown, expected: string, library = test("drafts preserve exact line endings, normalize names, hash the full source, and isolate libraries and workspaces", async () => { const source = Array.from({ length: 205 }, (_, i) => `line ${i + 1}\r\n`).join(""); - const written = await call("writeDraft", { workspace: "north", name: "overview.canvas.tsx", source }); + const written = await call("writeDraft", { workspace: "north", name: "overview.artifact.tsx", source }); expect(written).toMatchObject({ workspace: "north", name: "overview", source, state: {} }); expect(written.source_hash).toBe(createHash("sha256").update(source).digest("hex")); @@ -81,8 +81,8 @@ test("drafts preserve exact line endings, normalize names, hash the full source, expect(await call("listDrafts", {})).toHaveLength(2); expect(await call("listDrafts", { workspace: "north" })).toHaveLength(1); expect(await call("listDrafts", {}, "bob")).toHaveLength(1); - for (const name of ["../escape", "dir/file", "dir\\file", ".canvas.tsx"]) { - await fails("writeDraft", { workspace: "north", name, source: "bad" }, "canvas name"); + for (const name of ["../escape", "dir/file", "dir\\file", ".artifact.tsx"]) { + await fails("writeDraft", { workspace: "north", name, source: "bad" }, "artifact name"); } await fails("writeDraft", { workspace: "north", name: "é".repeat(123), source: "bad" }, "255 bytes"); }); @@ -111,14 +111,14 @@ test("edits are sequential, guarded, exact, and atomic on every failure", async }); test("paired server drafts preserve omission, support explicit removal, and keep client state", async () => { - const client = "export default function Canvas() { return null; }\n"; + const client = "export default function Artifact() { return null; }\n"; const server = "first server line\nsecond server line\nthird server line"; const paired = await call("writeDraft", { workspace: "paired", name: "overview", source: client, server_source: server }); expect(paired).toMatchObject({ source: client, server_source: server }); const serverRead = await call<{ source: string; source_hash: string; path: string; part: string }>("readRange", { workspace: "paired", name: "overview", part: "server", start_line: 2, end_line: 2, }); - expect(serverRead).toMatchObject({ source: "second server line\n", part: "server", path: "paired/overview.canvas.server.ts" }); + expect(serverRead).toMatchObject({ source: "second server line\n", part: "server", path: "paired/overview.artifact.server.ts" }); expect(serverRead.source_hash).toBe(createHash("sha256").update(server).digest("hex")); expect("server_source" in serverRead).toBe(false); await fails("writeDraft", { workspace: "paired", name: "overview", source: "replacement", server_source: "x".repeat(256 * 1024 + 1) }, "256 KiB"); @@ -162,7 +162,7 @@ test("server edits guard and mutate only the selected source atomically", async }); test("successful serve snapshots deduplicate versions while retaining UUID events and archived selection", async () => { - const source = "export default function Canvas() { return null; }\n"; + const source = "export default function Artifact() { return null; }\n"; await call("writeDraft", { workspace: "history", name: "overview", source }); const first = await call("recordServe", { workspace: "history", name: "overview", source, runtime: "sdk-a", initial_state: { count: 1 }, mode: "live" }); const second = await call("recordServe", { workspace: "history", name: "overview", source, runtime: "sdk-a", initial_state: { count: 2 }, mode: "live" }); @@ -204,7 +204,7 @@ test("version identity includes server source and client-only history stays comp }); test("restore preserves the current draft, links a forced revision, and retains working UI state", async () => { - const original = "export default function Canvas() { return

original

; }\n"; + const original = "export default function Artifact() { return

original

; }\n"; await call("writeDraft", { workspace: "restore", name: "overview", source: original }); await call("setState", { workspace: "restore", name: "overview", key: "count", value: 9 }); const served = await call("recordServe", { workspace: "restore", name: "overview", source: original, runtime: "sdk-a", initial_state: { count: 4 }, mode: "live" }); @@ -314,13 +314,13 @@ test("constructor migrates legacy draft and version tables without losing client const source = "legacy client\n"; await call("writeDraft", { workspace: "legacy", name: "overview", source }, library); const served = await call("recordServe", { workspace: "legacy", name: "overview", source, runtime: "old-sdk", initial_state: {}, mode: "live" }, library); - const storage = await runtime.unsafeGetDurableObjectStorage("canvas-library-test", "CanvasLibrary", { name: library }); + const storage = await runtime.unsafeGetDurableObjectStorage("artifact-library-test", "ArtifactLibrary", { name: library }); 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 }); + await runtime.unsafeEvictDurableObject("artifact-library-test", "ArtifactLibrary", { 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); @@ -337,7 +337,7 @@ test("constructor migrates legacy draft and version tables without losing client test("compiled pairs are immutable, scoped, runtime-specific and durable with large Unicode bundles", async () => { const input = { workspace: "compiled", name: "overview", source: "client\n", server_source: "server\n", runtime: "sdk-a", client_js: "a".repeat(16 * 1024 - 1) + "😀漢字".repeat(250_000), server_js: "server 😀".repeat(20_000) }; - const saved = await call("saveCompiled", input); + const saved = await call("saveCompiled", input); expect(saved).toMatchObject({ runtime: input.runtime, client_js: input.client_js, server_js: input.server_js }); expect(await call("saveCompiled", input)).toEqual(saved); await fails("saveCompiled", { ...input, client_js: "replacement" }, "immutable"); @@ -348,22 +348,22 @@ test("compiled pairs are immutable, scoped, runtime-specific and durable with la expect(await call("compiled", { ...input, server_source: "changed\n" })).toBeNull(); expect(await call("compiled", { ...input, source: "changed\n" })).toBeNull(); expect(await call("compiled", { ...input, server_source: null })).toBeNull(); - const updated = await call("saveCompiled", { ...input, runtime: "sdk-b", client_js: "new SDK" }); + const updated = await call("saveCompiled", { ...input, runtime: "sdk-b", client_js: "new SDK" }); expect(updated.id).not.toBe(saved.id); expect(await call("compiled", { workspace: input.workspace, name: input.name, source: input.source, server_source: input.server_source })).toEqual(updated); expect(await call("compiled", { ...input, runtime: "sdk-a" })).toEqual(saved); - const clientOnly = await call("saveCompiled", { ...input, server_source: null, server_js: null }); + const clientOnly = await call("saveCompiled", { ...input, server_source: null, server_js: null }); expect(clientOnly.server_js).toBeNull(); await fails("saveCompiled", { ...input, server_js: null }, "invalid compiled"); await fails("saveCompiled", { ...input, server_source: null }, "invalid compiled"); - await runtime.unsafeEvictDurableObject("canvas-library-test", "CanvasLibrary", { name: "alice" }); + await runtime.unsafeEvictDurableObject("artifact-library-test", "ArtifactLibrary", { name: "alice" }); expect(await call("compiled", { workspace: input.workspace, name: input.name, id: saved.id })).toEqual(saved); expect(await call("compiled", { workspace: input.workspace, name: input.name, id: updated.id })).toEqual(updated); }); test("served versions pin compiled outputs and legacy or restored versions attach once", async () => { const input = { workspace: "compiled-versions", name: "overview", source: "client\n", server_source: "server\n", runtime: "sdk-a", client_js: "client output", server_js: "server output" }; - const compiled = await call("saveCompiled", input); + const compiled = await call("saveCompiled", input); const serve = { ...input, compiled_id: compiled.id, mode: "preview", initial_state: {} }; const first = await call("recordServe", serve); expect((await call("recordServe", serve)).version.id).toBe(first.version.id); @@ -372,12 +372,12 @@ test("served versions pin compiled outputs and legacy or restored versions attac await fails("recordServe", { ...serve, source: "changed\n" }, "does not match"); await fails("recordServe", { ...serve, workspace: "another" }, "does not match"); await fails("recordServe", { ...serve, runtime: "sdk-b" }, "does not match"); - const newer = await call("saveCompiled", { ...input, runtime: "sdk-b", client_js: "new runtime" }); + const newer = await call("saveCompiled", { ...input, runtime: "sdk-b", client_js: "new runtime" }); const second = await call("recordServe", { ...serve, runtime: "sdk-b", compiled_id: newer.id }); expect(second.version.id).not.toBe(first.version.id); await fails("recordServe", { ...serve, version_id: first.version.id, runtime: "sdk-b", compiled_id: newer.id }, "immutable"); const legacy = await call("recordServe", { ...input, name: "legacy", mode: "preview", initial_state: {} }); - const legacyCompiled = await call("saveCompiled", { ...input, name: "legacy", runtime: "sdk-b" }); + const legacyCompiled = await call("saveCompiled", { ...input, name: "legacy", runtime: "sdk-b" }); const attachment = { workspace: input.workspace, name: "legacy", version_id: legacy.version.id, compiled_id: legacyCompiled.id }; expect(await call("attachCompiled", attachment)).toEqual({ ok: true }); expect(await call("attachCompiled", attachment)).toEqual({ ok: true }); @@ -470,7 +470,7 @@ test("large project snapshots round-trip through chunked drafts, edits, history, 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 storage = await runtime.unsafeGetDurableObjectStorage("artifact-library-test", "ArtifactLibrary", { 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); @@ -480,21 +480,21 @@ test("large project snapshots round-trip through chunked drafts, edits, history, 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 }); + await runtime.unsafeEvictDurableObject("artifact-library-test", "ArtifactLibrary", { 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 library = "inline-project-upgrade", identity = { workspace: "upgrade", name: "artifact" }; 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 }); + const storage = await runtime.unsafeGetDurableObjectStorage("artifact-library-test", "ArtifactLibrary", { 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 }); + await runtime.unsafeEvictDurableObject("artifact-library-test", "ArtifactLibrary", { 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); @@ -506,15 +506,15 @@ test("compiled identity includes normalized project files, dependencies and lock 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); + 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 }); + const storage = await runtime.unsafeGetDurableObjectStorage("artifact-library-test", "ArtifactLibrary", { name: library }); await storage.exec("alter table compiled_canvases drop column project_hash"); - await runtime.unsafeEvictDurableObject("canvas-library-test", "CanvasLibrary", { name: library }); + await runtime.unsafeEvictDurableObject("artifact-library-test", "ArtifactLibrary", { 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); + 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); @@ -524,7 +524,7 @@ test("compiled identity includes normalized project files, dependencies and lock { ...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); + 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); @@ -535,3 +535,15 @@ test("compiled identity includes normalized project files, dependencies and lock expect(await call("compiled", { ...identity, id: original.id }, library)).toEqual(original); expect(await call("compiled", { ...identity, id: original.id, project: empty }, library)).toBeNull(); }); + +test("compiled revisions stored under the original table names survive the product rename", async () => { + const library = "legacy-compiled-tables", selection = { workspace: "legacy", name: "existing", id: "before-rename" }; + await call("listDrafts", { workspace: selection.workspace }, library); + const storage = await runtime.unsafeGetDurableObjectStorage("artifact-library-test", "ArtifactLibrary", { name: library }); + await storage.exec("insert into compiled_canvases(workspace,name,id,runtime,source_hash,server_hash) values(?,?,?,?,?,?)", selection.workspace, selection.name, selection.id, "legacy-runtime", "source-hash", "server-hash"); + for (const [part, code] of [["client", "old client bundle"], ["server", "old server bundle"]]) { + await storage.exec("insert into compiled_canvas_chunks values(?,?,?,?,?,?)", selection.workspace, selection.name, selection.id, part!, 0, code!); + } + await runtime.unsafeEvictDurableObject("artifact-library-test", "ArtifactLibrary", { name: library }); + expect(await call("compiled", selection, library)).toEqual({ id: selection.id, runtime: "legacy-runtime", client_js: "old client bundle", server_js: "old server bundle" }); +}); diff --git a/cloudflare/library.ts b/cloudflare/library.ts index 390e69c..8a59792 100644 --- a/cloudflare/library.ts +++ b/cloudflare/library.ts @@ -6,10 +6,10 @@ import type { HistoryEntry, ServeEvent, Version } from "../src/historyTypes"; export const MAX_SOURCE_BYTES = 256 * 1024; export const MAX_STATE_BYTES = 64 * 1024; -const CANVAS_SUFFIX = ".canvas.tsx"; +const ARTIFACTS_SUFFIX = ".artifact.tsx"; const encoder = new TextEncoder(); -export type CanvasEdit = { old_text: string; new_text: string }; +export type ArtifactEdit = { old_text: string; new_text: string }; type DraftRow = { workspace: string; name: string; source: string; server_source: string | null; source_hash: string; @@ -17,7 +17,7 @@ type DraftRow = { }; type DraftListRow = Pick; 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 }; +export type CompiledArtifact = { id: string; runtime: string; client_js: string; server_js: 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 { @@ -48,22 +48,22 @@ function pagination(input: { offset?: number; limit?: number }, defaultLimit: nu return { offset, limit }; } -function canvasName(value: unknown): string { - if (typeof value !== "string") throw new Error("canvas name is required"); +function artifactName(value: unknown): string { + if (typeof value !== "string") throw new Error("artifact name is required"); const trimmed = value.trim(); if (trimmed === "" || trimmed === "." || trimmed === ".." || trimmed.includes("/") || trimmed.includes("\\") || trimmed.includes("\0")) { - throw new Error("canvas name must be a file name without paths"); + throw new Error("artifact name must be a file name without paths"); } - const name = trimmed.endsWith(CANVAS_SUFFIX) ? trimmed.slice(0, -CANVAS_SUFFIX.length) : trimmed; - if (!name || name === "." || name === "..") throw new Error("canvas name is required"); - if (bytes(`${name}${CANVAS_SUFFIX}`) > 255) throw new Error("canvas name exceeds 255 bytes including suffix"); + const name = trimmed.endsWith(ARTIFACTS_SUFFIX) ? trimmed.slice(0, -ARTIFACTS_SUFFIX.length) : trimmed; + if (!name || name === "." || name === "..") throw new Error("artifact name is required"); + if (bytes(`${name}${ARTIFACTS_SUFFIX}`) > 255) throw new Error("artifact name exceeds 255 bytes including suffix"); return name; } function sourceText(value: unknown, appendNewline = false): string { if (typeof value !== "string") throw new Error("source must be a string"); const source = appendNewline && !value.endsWith("\n") ? `${value}\n` : value; - if (bytes(source) > MAX_SOURCE_BYTES) throw new Error("Canvas source exceeds 256 KiB"); + if (bytes(source) > MAX_SOURCE_BYTES) throw new Error("Artifact source exceeds 256 KiB"); return source; } @@ -78,19 +78,19 @@ function jsonState(value: unknown): string { try { encoded = JSON.stringify(value); } catch { throw new Error("state must be JSON serializable"); } if (encoded === undefined) throw new Error("state must be JSON serializable"); - if (bytes(encoded) > MAX_STATE_BYTES) throw new Error("Canvas state exceeds 64 KiB"); + if (bytes(encoded) > MAX_STATE_BYTES) throw new Error("Artifact state exceeds 64 KiB"); return encoded; } function now(): string { return new Date().toISOString(); } function uuid(): string { return crypto.randomUUID(); } -function sourcePath(workspace: string, name: string): string { return `${workspace}/${name}${CANVAS_SUFFIX}`; } -function serverSourcePath(workspace: string, name: string): string { return `${workspace}/${name}.canvas.server.ts`; } +function sourcePath(workspace: string, name: string): string { return `${workspace}/${name}${ARTIFACTS_SUFFIX}`; } +function serverSourcePath(workspace: string, name: string): string { return `${workspace}/${name}.artifact.server.ts`; } function rows>(cursor: SqlStorageCursor): Row[] { return cursor.toArray(); } function first>(cursor: SqlStorageCursor): Row | null { return rows(cursor)[0] ?? null; } export function sha256(value: string): string { return createHash("sha256").update(value).digest("hex"); } -export class CanvasLibrary extends DurableObject { +export class ArtifactLibrary extends DurableObject { private readonly state: DurableObjectState; private readonly sql: SqlStorage; private readonly projectStorage: ProjectStorage; @@ -129,6 +129,7 @@ export class CanvasLibrary extends DurableObject { if (!rows(this.sql.exec<{ name: string }>("pragma table_info(versions)")).some(column => column.name === "compiled_id")) { this.sql.exec("alter table versions add column compiled_id text"); } + // Keep deployed SQL names so existing compiled revisions remain readable. this.sql.exec(`create table if not exists compiled_canvases ( 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) @@ -152,12 +153,12 @@ export class CanvasLibrary extends DurableObject { }); } - 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); + saveCompiled(input: { workspace: string; name: string; source: string; server_source: string | null; runtime: string; client_js: string; server_js: string | null; project?: ArtifactProject }): CompiledArtifact { + const workspace = workspaceName(input.workspace), name = artifactName(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"); + (server === null ? input.server_js !== null : typeof input.server_js !== "string" || !input.server_js)) throw new Error("invalid compiled artifact output"); const project = normalizeProject(input.project); const project_hash = projectHash(project); // Preserve IDs of bundles written before projects were supported. @@ -165,7 +166,7 @@ export class CanvasLibrary extends DurableObject { 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"); + if (existing.client_js !== input.client_js || existing.server_js !== input.server_js) throw new Error("compiled artifact output is immutable"); return existing; } this.sql.exec("insert into compiled_canvases(workspace,name,id,runtime,source_hash,server_hash,project_hash) values(?,?,?,?,?,?,?)", @@ -186,8 +187,8 @@ export class CanvasLibrary extends DurableObject { }); } - 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); + compiled(input: { workspace: string; name: string; id?: string; source?: string; server_source?: string | null; runtime?: string; project?: ArtifactProject }): CompiledArtifact | null { + const workspace = workspaceName(input.workspace), name = artifactName(input.name); let metadata: CompiledRow | null; if (input.id !== undefined) { metadata = first(this.sql.exec("select * from compiled_canvases where workspace=? and name=? and id=?", workspace, name, input.id)); @@ -212,13 +213,13 @@ export class CanvasLibrary extends DurableObject { ? rows(this.sql.exec("select workspace,name,source_hash,updated_at from drafts where workspace = ? order by workspace,name limit ? offset ?", workspace, limit, offset)) : rows(this.sql.exec("select workspace,name,source_hash,updated_at from drafts order by workspace,name limit ? offset ?", limit, offset)); return result.map(row => ({ - id: row.name, name: `${row.name}${CANVAS_SUFFIX}`, path: sourcePath(row.workspace, row.name), + id: row.name, name: `${row.name}${ARTIFACTS_SUFFIX}`, path: sourcePath(row.workspace, row.name), workspace: row.workspace, source_hash: row.source_hash, updated_at: row.updated_at, })); } 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 workspace = workspaceName(input.workspace); const name = artifactName(input.name); const source = sourceText(input.source, true); const source_hash = sha256(source); const timestamp = now(); const requestedServer = input.server_source === undefined ? undefined : input.server_source === null ? null : sourceText(input.server_source); @@ -238,17 +239,17 @@ export class CanvasLibrary extends DurableObject { } remix(input: { workspace: string; name?: string; version_id?: string; new_name: string; runtime: string }) { - const workspace = workspaceName(input.workspace), name = canvasName(input.new_name), runtime = runtimeName(input.runtime); + const workspace = workspaceName(input.workspace), name = artifactName(input.new_name), runtime = runtimeName(input.runtime); if (Boolean(input.name) === Boolean(input.version_id)) throw new Error("provide name or version_id, but not both"); return this.state.storage.transactionSync(() => { - if (first(this.sql.exec("select name from drafts where workspace=? and name=? union all select name from artifacts where workspace=? and name=?", workspace, name, workspace, name))) throw new Error("Destination canvas already exists; choose a new name"); + if (first(this.sql.exec("select name from drafts where workspace=? and name=? union all select name from artifacts where workspace=? and name=?", workspace, name, workspace, name))) throw new Error("Destination artifact already exists; choose a new name"); let origin: StoredVersion; if (input.version_id) { const version = this.findVersion(workspace, input.version_id); if (!version) throw new Error("version not found in this workspace"); origin = version; } else { - const draft = this.draft(workspace, canvasName(input.name)); + const draft = this.draft(workspace, artifactName(input.name)); origin = this.capture(workspace, draft.name, draft.source, draft.server_source, this.projectStorage.read(draft.project), runtime, "remix-source", null, false); } const timestamp = now(); @@ -261,11 +262,11 @@ export class CanvasLibrary extends DurableObject { } 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 workspace = workspaceName(input.workspace); const name = artifactName(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 (input.file === undefined && part === "server" && row.server_source === null) throw new Error("canvas has no server source"); + if (input.file === undefined && part === "server" && row.server_source === null) throw new Error("artifact 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) ?? []; @@ -274,7 +275,7 @@ export class CanvasLibrary extends DurableObject { if (!Number.isSafeInteger(start) || start < 1 || !Number.isSafeInteger(end) || end < start) { throw new Error("line range must contain positive integers with end_line >= start_line"); } - if (start > Math.max(1, lines.length)) throw new Error("start_line is past the end of the canvas"); + if (start > Math.max(1, lines.length)) throw new Error("start_line is past the end of the artifact"); const actualEnd = Math.min(end, lines.length); return { path: input.file ?? (part === "client" ? sourcePath(workspace, name) : serverSourcePath(workspace, name)), @@ -285,8 +286,8 @@ export class CanvasLibrary extends DurableObject { }; } - 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); + editDraft(input: { workspace: string; name: string; part?: "client" | "server"; file?: string; edits: ArtifactEdit[]; expected_hash?: string }) { + const workspace = workspaceName(input.workspace); const name = artifactName(input.name); const part = input.part ?? "client"; if (part !== "client" && part !== "server") throw new Error("part must be client or server"); if (!Array.isArray(input.edits) || input.edits.length === 0) throw new Error("edits must be a non-empty array"); @@ -295,12 +296,12 @@ export class CanvasLibrary extends DurableObject { } return this.state.storage.transactionSync(() => { const original = this.draft(workspace, name); - if (input.file === undefined && part === "server" && original.server_source === null) throw new Error("canvas has no server source"); + if (input.file === undefined && part === "server" && original.server_source === null) throw new Error("artifact 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"); + throw new Error("artifact changed since read; read it again before editing"); } let selected = originalSelected; for (const [index, edit] of input.edits.entries()) { @@ -328,12 +329,12 @@ export class CanvasLibrary extends DurableObject { } getState(input: { workspace: string; name: string }) { - const workspace = workspaceName(input.workspace); const name = canvasName(input.name); + const workspace = workspaceName(input.workspace); const name = artifactName(input.name); return JSON.parse(this.draft(workspace, name).state) as Record; } setState(input: { workspace: string; name: string; key: string; value: unknown }) { - const workspace = workspaceName(input.workspace); const name = canvasName(input.name); + const workspace = workspaceName(input.workspace); const name = artifactName(input.name); if (typeof input.key !== "string" || !input.key || ["__proto__", "constructor", "prototype"].includes(input.key)) throw new Error("invalid state key"); return this.state.storage.transactionSync(() => { const row = this.draft(workspace, name); @@ -345,14 +346,14 @@ export class CanvasLibrary extends DurableObject { } attachCompiled(input: { workspace: string; name: string; version_id: string; compiled_id: string }) { - const workspace = workspaceName(input.workspace), name = canvasName(input.name); + const workspace = workspaceName(input.workspace), name = artifactName(input.name); 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.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"); + throw new Error("compiled artifact 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"); + if (version.compiled_id !== null && version.compiled_id !== input.compiled_id) throw new Error("version compiled artifact is immutable"); if (version.compiled_id === null) this.sql.exec("update versions set compiled_id=? where id=?", input.compiled_id, version.id); return { ok: true }; }); @@ -363,7 +364,7 @@ export class CanvasLibrary extends DurableObject { 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 workspace = workspaceName(input.workspace); const name = artifactName(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); @@ -377,7 +378,7 @@ export class CanvasLibrary extends DurableObject { 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.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"); + throw new Error("compiled artifact does not match serve snapshot"); } } if (input.version_id !== undefined) { @@ -386,7 +387,7 @@ export class CanvasLibrary extends DurableObject { throw new Error("archived serve snapshot does not match version in this workspace"); } if (input.compiled_id !== undefined) { - if (archived.compiled_id !== null && archived.compiled_id !== input.compiled_id) throw new Error("version compiled canvas is immutable"); + if (archived.compiled_id !== null && archived.compiled_id !== input.compiled_id) throw new Error("version compiled artifact is immutable"); if (archived.compiled_id === null) this.sql.exec("update versions set compiled_id=? where id=?", input.compiled_id, archived.id); archived.compiled_id = input.compiled_id; } @@ -405,7 +406,7 @@ export class CanvasLibrary extends DurableObject { history(input: { workspace?: string; name?: string; offset?: number; limit?: number } = {}): HistoryEntry[] { const workspace = optionalWorkspace(input.workspace); - const name = input.name === undefined ? undefined : canvasName(input.name); + const name = input.name === undefined ? undefined : artifactName(input.name); const { offset, limit } = pagination(input, 100, 100); return rows(this.sql.exec(`select a.id as artifact_id, a.name, a.source_path, a.workspace, v.id as version_id, v.revision, v.created_at, v.source_hash, v.runtime, v.reason, v.restored_from, @@ -457,7 +458,7 @@ export class CanvasLibrary extends DurableObject { if (Boolean(input.name) === Boolean(input.version_id)) throw new Error("provide name or version_id, but not both"); 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); + const name = artifactName(input.name); const draft = this.draft(workspace, name); 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 }; } @@ -473,7 +474,7 @@ export class CanvasLibrary extends DurableObject { private draft(workspace: string, name: string): DraftRow { const result = first(this.sql.exec("select * from drafts where workspace = ? and name = ?", workspace, name)); - if (!result) throw new Error("canvas not found"); + if (!result) throw new Error("artifact not found"); return result; } diff --git a/cloudflare/links.test.ts b/cloudflare/links.test.ts index 8148afc..7527f5f 100644 --- a/cloudflare/links.test.ts +++ b/cloudflare/links.test.ts @@ -4,7 +4,7 @@ let runtime: Miniflare; beforeAll(async () => { const build = await Bun.build({ entrypoints: [new URL("./links-test-worker.ts", import.meta.url).pathname], target: "node", format: "esm", external: ["cloudflare:workers"] }); if (!build.success) throw new Error(build.logs.join("\n")); - runtime = new Miniflare({ cf: false, port: 0, workers: [{ config: { + runtime = new Miniflare({ cf: false, port: 0, unsafeInspectDurableObjects: true, workers: [{ config: { name: "links-test", type: "worker", compatibilityDate: "2026-09-06", manifest: { mainModule: "test.js", modulesRoot: import.meta.dir, modules: { "test.js": { type: "esm", contents: await build.outputs[0]!.text() } } }, env: { LINKS: { type: "durable-object", worker: "links-test", exportName: "ArtifactLinks" } }, @@ -25,7 +25,7 @@ test("slugs are unique across libraries and kinds; code and access change atomic expect(await call("get", "chosen")).toBeNull(); await call("commit", owner, generation, { slug: "chosen", access: "private", script_hash: "first" }); await expect(call("check", { ...owner, libraryKey: "bob" }, "chosen")).rejects.toThrow("already in use"); - await expect(call("check", { ...owner, kind: "canvas" }, "chosen")).rejects.toThrow("already in use"); + await expect(call("check", { ...owner, kind: "artifact" }, "chosen")).rejects.toThrow("already in use"); for (const slug of ["api", "mcp", "gallery", "health", "sign-in", "consent", "bad/path", "Upper", "-bad", "bad-", ""]) { await expect(call("check", owner, slug)).rejects.toThrow("Slug must"); } @@ -64,18 +64,39 @@ test("invalid drafts retain chosen metadata without exposing a URL, and successf }); test("explicit private access clears invalid pending public intent before subsequent source corrections", async () => { - const target = { ...owner, kind: "canvas", name: "private-canvas" }; + const target = { ...owner, kind: "artifact", name: "private-artifact" }; const first = await call("begin", target); - await call("commit", target, first, { slug: "private-canvas", access: "private", version_id: "first" }); + await call("commit", target, first, { slug: "private-artifact", access: "private", version_id: "first" }); const invalid = await call("begin", target); - await call("stage", target, invalid, { slug: "pending-canvas", access: "public" }); - expect(await call("get", "private-canvas")).toMatchObject({ access: "private", version_id: "first" }); - expect(await call("get", "pending-canvas")).toBeNull(); - await call("set", { ...target, slug: "private-canvas", access: "private" }); + await call("stage", target, invalid, { slug: "pending-artifact", access: "public" }); + expect(await call("get", "private-artifact")).toMatchObject({ access: "private", version_id: "first" }); + expect(await call("get", "pending-artifact")).toBeNull(); + await call("set", { ...target, slug: "private-artifact", access: "private" }); expect(await call("draft", target)).toEqual({}); expect(await call("stage", target, invalid, { access: "public" })).toBe(false); const fixed = await call("begin", target); await call("commit", target, fixed, { version_id: "fixed" }); - expect(await call("get", "private-canvas")).toMatchObject({ access: "private", version_id: "fixed" }); - expect(await call("get", "pending-canvas")).toBeNull(); + expect(await call("get", "private-artifact")).toMatchObject({ access: "private", version_id: "fixed" }); + expect(await call("get", "pending-artifact")).toBeNull(); +}); + + +test("existing link keys, generations, and pending metadata retain their identity after rename", async () => { + const target = { libraryKey: "legacy", workspace: "default", kind: "artifact", name: "existing" }; + const oldTarget = { ...target, kind: "canvas" }; + const oldKey = JSON.stringify([target.libraryKey, target.workspace, "canvas", target.name]); + const storage = await runtime.unsafeGetDurableObjectStorage("links-test", "ArtifactLinks", { name: "deployment" }); + await storage.exec("insert into links values (?, ?, ?)", "existing-link", oldKey, JSON.stringify({ ...oldTarget, slug: "existing-link", access: "private", version_id: "original", generation: 4 })); + await storage.exec("insert into generations values (?, ?)", oldKey, 5); + await storage.exec("insert into pending_links values (?, ?)", oldKey, JSON.stringify({ slug: "pending-legacy", access: "public" })); + await runtime.unsafeEvictDurableObject("links-test", "ArtifactLinks", { name: "deployment" }); + expect(await call("get", "existing-link")).toMatchObject({ ...target, access: "private", version_id: "original" }); + expect(await call("find", target)).toMatchObject({ ...target, slug: "existing-link" }); + expect(await call("check", target, "existing-link")).toBe("existing-link"); + expect(await call("draft", target)).toEqual({ slug: "pending-legacy", access: "public" }); + expect(await call("begin", target)).toBe(6); + expect(await call("commit", target, 5, { version_id: "stale" })).toBeNull(); + expect(await call("commit", target, 6, { version_id: "updated" })).toMatchObject({ ...target, slug: "pending-legacy", access: "public", version_id: "updated" }); + expect(await call("get", "existing-link")).toBeNull(); + expect(await call("draft", target)).toEqual({}); }); diff --git a/cloudflare/links.ts b/cloudflare/links.ts index 3bb479d..40548e5 100644 --- a/cloudflare/links.ts +++ b/cloudflare/links.ts @@ -1,6 +1,6 @@ import { DurableObject } from "cloudflare:workers"; -export type ArtifactTarget = { libraryKey: string; workspace: string; kind: "canvas" | "script"; name: string }; +export type ArtifactTarget = { libraryKey: string; workspace: string; kind: "artifact" | "script"; name: string }; export type LinkUpdate = { slug?: string; access?: "private" | "public"; version_id?: string; script_hash?: string }; export type ArtifactLink = ArtifactTarget & LinkUpdate & { slug: string; access: "private" | "public"; generation: number }; const reserved = new Set(["api", "mcp", "gallery", "health", "sign-in", "consent"]); @@ -10,7 +10,13 @@ export function validateSlug(value: unknown): string { } return value; } -function key(target: ArtifactTarget) { return JSON.stringify([target.libraryKey, target.workspace, target.kind, target.name]); } +// Persist the original kind in keys so links, pending writes, and generations share their existing identity. +function key(target: ArtifactTarget) { return JSON.stringify([target.libraryKey, target.workspace, target.kind === "artifact" ? "canvas" : target.kind, target.name]); } +function readLink(value: string): ArtifactLink { + const link = JSON.parse(value); + if (link.kind === "canvas") link.kind = "artifact"; + return link; +} /** Deployment-wide names; ownership is resolved before accessing any library. */ export class ArtifactLinks extends DurableObject { constructor(ctx: DurableObjectState, env: unknown) { @@ -21,11 +27,11 @@ export class ArtifactLinks extends DurableObject { } get(slug: string): ArtifactLink | null { const row = this.ctx.storage.sql.exec<{ value: string }>("select value from links where slug = ?", slug).toArray()[0]; - return row ? JSON.parse(row.value) : null; + return row ? readLink(row.value) : null; } find(target: ArtifactTarget): ArtifactLink | null { const row = this.ctx.storage.sql.exec<{ value: string }>("select value from links where target = ?", key(target)).toArray()[0]; - return row ? JSON.parse(row.value) : null; + return row ? readLink(row.value) : null; } check(target: ArtifactTarget, candidate: string): string { const slug = validateSlug(candidate); diff --git a/cloudflare/mcp.ts b/cloudflare/mcp.ts index ed8a0fa..906a0dc 100644 --- a/cloudflare/mcp.ts +++ b/cloudflare/mcp.ts @@ -5,24 +5,24 @@ import { ListResourceTemplatesRequestSchema, ReadResourceRequestSchema, ErrorCode, McpError, } from "@modelcontextprotocol/sdk/types.js"; -import { CANVAS_APP_URI, CANVAS_RESOURCE } from "../src/mcp/app-contract"; +import { ARTIFACTS_APP_URI, ARTIFACTS_RESOURCE } from "../src/mcp/app-contract"; import shell from "../dist/cloudflare/mcp-app.json"; import * as validators from "../dist/cloudflare/tool-validators.js"; import { CLOUD_MCP_TOOLS } from "./tool-contract"; -import type { CloudCanvasService } from "./service"; +import type { CloudArtifactService } from "./service"; -export async function handleCloudMcp(request: Request, service: CloudCanvasService, parsedBody?: unknown): Promise { +export async function handleCloudMcp(request: Request, service: CloudArtifactService, parsedBody?: unknown): Promise { // Low-level SDK registration lets local and hosted transports share the same // JSON schemas, with argument validators generated from them at build time. - const server = new Server({ name: "canvas", version: "0.1.0" }, { capabilities: { tools: {}, resources: {} } }); + const server = new Server({ name: "artifacts", version: "0.1.0" }, { capabilities: { tools: {}, resources: {} } }); server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: CLOUD_MCP_TOOLS })); - const resource = service.fileStorage ? { ...CANVAS_RESOURCE, _meta: { ui: { ...CANVAS_RESOURCE._meta.ui, - csp: { ...CANVAS_RESOURCE._meta.ui.csp, connectDomains: [service.fileStorage.origin] }, - } } } : CANVAS_RESOURCE; + const resource = service.fileStorage ? { ...ARTIFACTS_RESOURCE, _meta: { ui: { ...ARTIFACTS_RESOURCE._meta.ui, + csp: { ...ARTIFACTS_RESOURCE._meta.ui.csp, connectDomains: [service.fileStorage.origin] }, + } } } : ARTIFACTS_RESOURCE; server.setRequestHandler(ListResourcesRequestSchema, async () => ({ resources: [resource] })); server.setRequestHandler(ListResourceTemplatesRequestSchema, async () => ({ resourceTemplates: [] })); server.setRequestHandler(ReadResourceRequestSchema, async ({ params }) => { - if (params.uri !== CANVAS_APP_URI) throw new McpError(ErrorCode.InvalidParams, "Unknown resource"); + if (params.uri !== ARTIFACTS_APP_URI) throw new McpError(ErrorCode.InvalidParams, "Unknown resource"); return { contents: [{ ...resource, text: shell }] }; }); server.setRequestHandler(CallToolRequestSchema, async ({ params }) => { diff --git a/cloudflare/migrations/0001_auth.sql b/cloudflare/migrations/0001_auth.sql index 8b7824f..b623dd7 100644 --- a/cloudflare/migrations/0001_auth.sql +++ b/cloudflare/migrations/0001_auth.sql @@ -1,4 +1,4 @@ --- Generated from Better Auth 1.7.3 and Canvas auth options. +-- Generated from Better Auth 1.7.3 and Artifact auth options. create table "user" ("id" text not null primary key, "name" text not null, "email" text not null unique, "emailVerified" integer not null, "image" text, "createdAt" date not null, "updatedAt" date not null); create table "session" ("id" text not null primary key, "expiresAt" date not null, "token" text not null unique, "createdAt" date not null, "updatedAt" date not null, "ipAddress" text, "userAgent" text, "userId" text not null references "user" ("id") on delete cascade); diff --git a/cloudflare/plugins.test.ts b/cloudflare/plugins.test.ts index c375950..58e8e81 100644 --- a/cloudflare/plugins.test.ts +++ b/cloudflare/plugins.test.ts @@ -1,12 +1,12 @@ import { expect, test } from "bun:test"; import Ajv from "ajv"; import { createPluginDispatcher, PluginError, PLUGIN_JSON_LIMIT } from "./plugins"; -import type { CanvasPlugin, PluginOperation, PluginContext } from "../src/plugins/config"; +import type { ArtifactPlugin, PluginOperation, PluginContext } from "../src/plugins/config"; import { createAuthenticator } from "./auth"; const schema = { type: "object", properties: { value: { type: "integer" } }, required: ["value"], additionalProperties: false }; function setup(handler: PluginOperation["handler"], extra: Partial = {}, timeout = 30_000) { - const plugin: CanvasPlugin = { name: "data", description: "Test data", secrets: ["DATA_KEY"], operations: { read: { description: "Read", inputSchema: schema, outputSchema: schema, handler, ...extra } } }; + const plugin: ArtifactPlugin = { name: "data", description: "Test data", secrets: ["DATA_KEY"], operations: { read: { description: "Read", inputSchema: schema, outputSchema: schema, handler, ...extra } } }; const ajv = new Ajv({ strict: true }); const input = ajv.compile(schema), output = ajv.compile(schema); return createPluginDispatcher([plugin], { input: (_plugin, _op, value) => input(value), output: (_plugin, _op, value) => output(value) }, timeout); diff --git a/cloudflare/plugins.ts b/cloudflare/plugins.ts index 39149cd..ddb3de5 100644 --- a/cloudflare/plugins.ts +++ b/cloudflare/plugins.ts @@ -1,11 +1,11 @@ -import type { CanvasPlugin } from "../src/plugins/config"; +import type { ArtifactPlugin } from "../src/plugins/config"; import type { PluginRequest, PluginUser } from "../src/plugins/types"; import installed from "../dist/cloudflare/plugin-server"; import { validatePluginInput, validatePluginOutput } from "../dist/cloudflare/plugin-validators.js"; import catalog from "../dist/cloudflare/plugin-catalog.json"; export const PLUGIN_JSON_LIMIT = 256 * 1024; -export const PLUGIN_GUIDE = `Deployment plugins provide installed browser modules and authenticated server functions. Call plugins_list to discover operation names, schemas, and read-only hints. Call canvas_plugin_call with {plugin, operation, input}, or use pluginCall from sidequery/canvas in a hosted canvas. Calls run as the authenticated deployment user, independently of canvas, version, workspace, or library selectors. Plugins may restrict users and must check row-level access in their handlers. Public standalone canvases cannot call plugins. Provider credentials stay on the server. Canvas backends and scripts do not receive this bridge.`; +export const PLUGIN_GUIDE = `Deployment plugins provide installed browser modules and authenticated server functions. Call plugins_list to discover operation names, schemas, and read-only hints. Call artifact_plugin_call with {plugin, operation, input}, or use pluginCall from sidequery/artifacts in a hosted artifact. Calls run as the authenticated deployment user, independently of artifact, version, workspace, or library selectors. Plugins may restrict users and must check row-level access in their handlers. Public standalone artifacts cannot call plugins. Provider credentials stay on the server. Artifact backends and scripts do not receive this bridge.`; export const pluginCatalog = catalog; export type PluginInvocationContext = { user: PluginUser; env: object }; export class PluginError extends Error { @@ -25,7 +25,7 @@ function jsonValue(value: unknown, output = false): unknown { return JSON.parse(serialized); } -export function createPluginDispatcher(plugins: readonly CanvasPlugin[], validators: { +export function createPluginDispatcher(plugins: readonly ArtifactPlugin[], validators: { input: typeof validatePluginInput; output: typeof validatePluginOutput; }, timeoutMs = 30_000) { return async (request: PluginRequest, context?: PluginInvocationContext): Promise => { diff --git a/cloudflare/project.ts b/cloudflare/project.ts index 70c8680..558c8e0 100644 --- a/cloudflare/project.ts +++ b/cloudflare/project.ts @@ -5,7 +5,7 @@ export type ArtifactProject = { files: Record; dependencies: Rec 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"]); +const reserved = new Set(["artifact.artifact.tsx", "artifact.artifact.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 = {}; @@ -97,7 +97,7 @@ export async function resolveProject(input: unknown, previous?: ArtifactProject, 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`); + if (!satisfies(hostPackages[name]!, range)) throw new Error(`${requestedBy} requires ${name}@${range}, but Artifact supplies ${name}@${hostPackages[name]}; choose a compatible package version`); return; } const existing = manifests.get(name); diff --git a/cloudflare/scheduling-celld.test.ts b/cloudflare/scheduling-celld.test.ts index 34ba5ce..d7560e4 100644 --- a/cloudflare/scheduling-celld.test.ts +++ b/cloudflare/scheduling-celld.test.ts @@ -16,7 +16,7 @@ async function start(){ async function stop(){if(!child||child.exitCode!==null)return;child.kill("SIGINT");await child.exited;child=undefined;} beforeAll(async()=>{ if(!enabled)return; - directory=await mkdtemp(join(tmpdir(),"canvas-schedule-celld-")); + directory=await mkdtemp(join(tmpdir(),"artifact-schedule-celld-")); binary=process.env.CELLD_BIN??await ensureCelldRuntime({dataRoot:join(directory,"runtime")}); const listener=Bun.listen({hostname:"127.0.0.1",port:0,socket:{data(){}}});url=`http://127.0.0.1:${listener.port}`;listener.stop(true); const bundle=await Bun.build({entrypoints:[join(import.meta.dir,"scripts-test-worker.ts")],target:"browser",format:"esm",external:["cloudflare:workers","node:*","fs","fs/promises"]}); @@ -29,11 +29,11 @@ afterAll(async()=>{await stop();if(directory)await rm(directory,{recursive:true, async function call(path:string,input:unknown={}){const response=await fetch(`${url}/${path}?library=scheduled`,{method:"POST",body:JSON.stringify(input)});const value=await response.json() as any;if(!response.ok)throw new Error(JSON.stringify(value)+logs);return value.result;} (enabled?test:test.skip)("celld persists schedules and run history across process restart and delivers native alarms",async()=>{ const identity={workspace:"default",name:"handler"}; - const code=`export default {fetch(request,env){if(request.headers.get("x-canvas-internal-run")!=="caller-value")return new Response("header changed",{status:422});env.sql.exec("create table if not exists count(n integer)");env.sql.exec("insert into count values(1)");console.log("tick");return new Response("ok")}}`; + const code=`export default {fetch(request,env){if(request.headers.get("x-artifact-internal-run")!=="caller-value")return new Response("header changed",{status:422});env.sql.exec("create table if not exists count(n integer)");env.sql.exec("insert into count values(1)");console.log("tick");return new Response("ok")}}`; const draft=await call("writeDraft",{...identity,source:code}); const active=await call("activate",{...identity,source_hash:draft.source_hash,code}); await call("link",{hash:active.hash}); - await call("backend/schedule",{action:"set",identity:{...identity,libraryKey:"scheduled",origin:url},cron:"* * * * *",timezone:"UTC",request:{path:"/tick",method:"POST",headers:[["x-canvas-internal-run","caller-value"]]}}); + await call("backend/schedule",{action:"set",identity:{...identity,libraryKey:"scheduled",origin:url},cron:"* * * * *",timezone:"UTC",request:{path:"/tick",method:"POST",headers:[["x-artifact-internal-run","caller-value"]]}}); await call("backend/schedule",{action:"pause"}); await call("backend/schedule",{action:"run_now"}); expect((await call("backend/runs"))[0]).toMatchObject({status:"succeeded",trigger:"manual"}); diff --git a/cloudflare/script-backend.ts b/cloudflare/script-backend.ts index 2b7aee9..4903706 100644 --- a/cloudflare/script-backend.ts +++ b/cloudflare/script-backend.ts @@ -3,23 +3,23 @@ import { DurableObject } from "cloudflare:workers"; import type { ScriptLibrary } from "./scripts"; import type { ArtifactLinks } from "./links"; import { nativeRequest } from "./backend"; -import type { CanvasHttpRequest } from "../src/httpTypes"; +import type { ArtifactHttpRequest } from "../src/httpTypes"; import { sha256 } from "./library"; export type ScriptLog = { timestamp: string; level: string; message: string; run_id?: string }; export type ScriptRequest = {code: string; hash: string; secrets: Record; request: Request; trigger?: "http" | "manual" | "schedule"; run_id?: string}; export type ScheduleIdentity = {libraryKey: string; workspace: string; name: string; origin: string}; -export type ScriptSchedule = ScheduleTiming & { paused: boolean; next_run_at: number | null; request: CanvasHttpRequest}; +export type ScriptSchedule = ScheduleTiming & { paused: boolean; next_run_at: number | null; request: ArtifactHttpRequest}; export type ScriptRun = {id: string; revision: string; trigger: string; started_at: string; finished_at: string | null; duration_ms: number | null; status: string; http_status: number | null}; type Env = { LOADER: WorkerLoader; SCRIPTS: DurableObjectNamespace; LINKS: DurableObjectNamespace }; const runtimeConfig = { compatibilityDate: "2026-09-06", compatibilityFlags: ["nodejs_compat"], limits: { cpuMs: 30000, subRequests: 50 } }; declare abstract class ScriptFacet extends DurableObject { takeLogs(): ScriptLog[]; } -const RUN_HEADER = "x-canvas-internal-run"; +const RUN_HEADER = "x-artifact-internal-run"; // Each script has its own isolate and native SQLite facet. Only the explicit // script secrets are supplied; the application's environment is never copied. const logging = `import { AsyncLocalStorage } from "node:async_hooks"; -const secrets = __CANVAS_SCRIPT_SECRETS__; +const secrets = __ARTIFACTS_SCRIPT_SECRETS__; export const execution = new AsyncLocalStorage(); let logs = []; const clean = (value, secrets) => { @@ -80,7 +80,7 @@ export class ScriptBackend extends DurableObject { const hash=this.cacheKey(input); return this.env.LOADER.get(hash,async()=>({ ...runtimeConfig, - mainModule:"adapter.js",modules:{"adapter.js":adapter,"logging.js":logging.replace("__CANVAS_SCRIPT_SECRETS__", () => JSON.stringify(input.secrets)),"user.js":input.code}, + mainModule:"adapter.js",modules:{"adapter.js":adapter,"logging.js":logging.replace("__ARTIFACTS_SCRIPT_SECRETS__", () => JSON.stringify(input.secrets)),"user.js":input.code}, env:{secrets:input.secrets}, })); } @@ -133,7 +133,7 @@ export class ScriptBackend extends DurableObject { if (!Number.isSafeInteger(limit)||limit<1||limit>100) throw new Error("limit must be between 1 and 100"); return this.ctx.storage.sql.exec("select * from execution_runs order by started_at desc,rowid desc limit ?",limit).toArray(); } - async schedule(input: {identity?: ScheduleIdentity; interval_seconds?: number; cron?: string; timezone?: string; request?: CanvasHttpRequest; action?: "get"|"set"|"pause"|"resume"|"run_now"} = {}): Promise { + async schedule(input: {identity?: ScheduleIdentity; interval_seconds?: number; cron?: string; timezone?: string; request?: ArtifactHttpRequest; action?: "get"|"set"|"pause"|"resume"|"run_now"} = {}): Promise { const action=input.action??"get"; if (!["get","set","pause","resume","run_now"].includes(action)) throw new Error("Unknown schedule action"); if (action==="get") return await this.ctx.storage.get("schedule")??null; diff --git a/cloudflare/script-guide.ts b/cloudflare/script-guide.ts index 936707e..b375313 100644 --- a/cloudflare/script-guide.ts +++ b/cloudflare/script-guide.ts @@ -1,6 +1,6 @@ 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. +Artifacts and scripts coexist. Use artifact_write with an optional slug, or artifact_link({kind:"artifact",name,slug,access}), to give an artifact 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. 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. @@ -11,12 +11,12 @@ 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 separate publishing workflow is required. Execution has 30 seconds CPU and 50 subrequests; logs retain 100 entries of up to 2 KiB with literal secret redaction. Schedules and run history -Use script_schedule or canvas_schedule with name, action:set, request, and exactly one of interval_seconds (at least 60) or a five-field cron expression with optional IANA timezone (UTC default). Native Durable Object alarms run the latest validated revision on Cloudflare and celld. get, pause, resume, and run_now control the saved schedule. Missed occurrences are skipped; uncertain or failed runs are not automatically replayed. script_runs/canvas_runs expose up to 100 of 1000 retained host-owned run records, separate from application SQLite. Filter script_logs by run_id. HTTP-handler success does not imply waitUntil or streamed response completion. Remix with canvas_remix/script_remix, 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. +Use script_schedule or artifact_schedule with name, action:set, request, and exactly one of interval_seconds (at least 60) or a five-field cron expression with optional IANA timezone (UTC default). Native Durable Object alarms run the latest validated revision on Cloudflare and celld. get, pause, resume, and run_now control the saved schedule. Missed occurrences are skipped; uncertain or failed runs are not automatically replayed. script_runs/artifact_runs expose up to 100 of 1000 retained host-owned run records, separate from application SQLite. Filter script_logs by run_id. HTTP-handler success does not imply waitUntil or streamed response completion. Remix with artifact_remix/script_remix, 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 -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. +Pass project:{files:{"lib/message.ts":"export const message = 'Hello';"},dependencies:{hono:"4.13.7"}} to script_write or artifact_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 Artifact'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. +Use file:"lib/message.ts" with script_read/script_edit or artifact_read/artifact_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. +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 artifact_guide describe the hosted contracts; local Bun stdio does not expose script tools. `; diff --git a/cloudflare/script-service-http.ts b/cloudflare/script-service-http.ts index a37700f..48e3bd7 100644 --- a/cloudflare/script-service-http.ts +++ b/cloudflare/script-service-http.ts @@ -1,7 +1,7 @@ -import type { CanvasHttpResponse } from "../src/httpTypes"; +import type { ArtifactHttpResponse } from "../src/httpTypes"; /** MCP transport is bounded; direct script URLs retain ordinary streaming HTTP. */ -export async function scriptResponse(response: Response, method: string): Promise { +export async function scriptResponse(response: Response, method: string): Promise { const reader = response.body?.getReader(); let binary = ""; try { diff --git a/cloudflare/script-service.ts b/cloudflare/script-service.ts index 8019050..dddd46d 100644 --- a/cloudflare/script-service.ts +++ b/cloudflare/script-service.ts @@ -2,11 +2,11 @@ import type { ScriptSchedule } from "./script-backend"; import { resolveProject } from "./project"; import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; import { createHash } from "node:crypto"; -import type { CanvasEdit } from "./library"; +import type { ArtifactEdit } from "./library"; import type { ArtifactService } from "./artifact-service"; import type { LinkUpdate } from "./links"; -import type { CanvasHttpRequest } from "../src/httpTypes"; -import { formatCanvasCheck } from "../src/diagnostics"; +import type { ArtifactHttpRequest } from "../src/httpTypes"; +import { formatArtifactCheck } from "../src/diagnostics"; import { nativeRequest } from "./backend"; import { compileScriptSource } from "./compiler"; import { scriptResponse } from "./script-service-http"; @@ -75,7 +75,7 @@ export class CloudScriptService { const link = await hosted.links.find(this.artifacts.target("script", name)); if (!link?.script_hash) throw new Error("Script has no validated URL revision"); const active = await scripts.active({ ...input, hash: link.script_hash }); - const incoming = nativeRequest(args.request as CanvasHttpRequest); + const incoming = nativeRequest(args.request as ArtifactHttpRequest); const request = new Request(new URL(new URL(incoming.url).pathname + new URL(incoming.url).search, hosted.origin), incoming); const response = await hosted.scriptBackends.getByName(JSON.stringify([this.artifacts.libraryKey, this.artifacts.workspace, this.artifacts.target("script", name).name])).request({ ...active, request, trigger: "manual" }); const envelope = await scriptResponse(response, incoming.method); @@ -103,7 +103,7 @@ export class CloudScriptService { 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 }) + : tool === "script_edit" ? await scripts.editDraft({ ...input, file: args.file as string | undefined, edits: args.edits as ArtifactEdit[], 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); @@ -133,7 +133,7 @@ export class CloudScriptService { } } 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 }; + const payload = { ...summary, ...await this.artifacts.linkDetails(target), applied: true, ok: compiled.ok, check: formatArtifactCheck(compiled.diagnostics), diagnostics: compiled.diagnostics }; return { ...text(payload, !compiled.ok), structuredContent: payload }; } default: throw new Error("Unknown script tool"); diff --git a/cloudflare/scripts.test.ts b/cloudflare/scripts.test.ts index d26ec3b..eb3b044 100644 --- a/cloudflare/scripts.test.ts +++ b/cloudflare/scripts.test.ts @@ -105,7 +105,7 @@ test("remix copies an immutable source revision, never secrets or activation, an }); test("run history records HTTP outcomes and correlates concurrent logs without exposing host tables",async()=>{ - const code=`export default {async fetch(request,env){const tag=await request.text(); console.log('before',tag); await fetch('https://upstream.example'); console.log('after',tag); const host=env.sql.exec("select name from sqlite_master where name='execution_runs'").toArray();return Response.json({host,internal:request.headers.get("x-canvas-internal-run")});}}`; + const code=`export default {async fetch(request,env){const tag=await request.text(); console.log('before',tag); await fetch('https://upstream.example'); console.log('after',tag); const host=env.sql.exec("select name from sqlite_master where name='execution_runs'").toArray();return Response.json({host,internal:request.headers.get("x-artifact-internal-run")});}}`; const responses=await Promise.all([run(code,{},"history","first"),run(code,{},"history","second")]); for(const response of responses) expect(await response.json()).toEqual({host:[],internal:null}); const runs=await call("backend/runs",{},"history"); diff --git a/cloudflare/scripts.ts b/cloudflare/scripts.ts index cea458b..c140a19 100644 --- a/cloudflare/scripts.ts +++ b/cloudflare/scripts.ts @@ -1,6 +1,6 @@ import { ProjectStorage } from "./project-storage"; import { DurableObject } from "cloudflare:workers"; -import { sha256, MAX_SOURCE_BYTES, type CanvasEdit } from "./library"; +import { sha256, MAX_SOURCE_BYTES, type ArtifactEdit } from "./library"; import { emptyProject, normalizeProject, projectSourceHash, type ArtifactProject } from "./project"; @@ -92,7 +92,7 @@ export class ScriptLibrary extends DurableObject { const actualEnd = Math.min(end,lines.length); return {workspace:row.workspace,name:row.name,path:input.file ?? `${row.workspace}/${row.name}.script.ts`,file:input.file,project,source:lines.slice(start-1,actualEnd).join(""),source_hash:input.file === undefined ? row.source_hash : sha256(selected),total_lines:lines.length,start_line:start,end_line:actualEnd,next_line:actualEnd { 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"); diff --git a/cloudflare/service.ts b/cloudflare/service.ts index a77a90d..6be03f4 100644 --- a/cloudflare/service.ts +++ b/cloudflare/service.ts @@ -2,25 +2,25 @@ 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"; -import { compileCanvasSource, compileCanvasServerSource, typecheckCanvasSource, typecheckCanvasServerSource } from "./compiler"; -import type { CanvasScheduleInput } from "./canvas-execution"; -import type { CanvasBackend } from "./backend"; +import type { ArtifactLibrary, ArtifactEdit, CompiledArtifact } from "./library"; +import { compileArtifactSource, compileArtifactServerSource, typecheckArtifactSource, typecheckArtifactServerSource } from "./compiler"; +import type { ArtifactScheduleInput } from "./artifact-execution"; +import type { ArtifactBackend } from "./backend"; import type { LinkUpdate } from "./links"; import { ArtifactService, type HostedArtifacts } from "./artifact-service"; import { CloudScriptService } from "./script-service"; import { HOSTED_SCRIPT_GUIDE } from "./script-guide"; export type { HostedArtifacts } from "./artifact-service"; -import type { CanvasHttpRequest } from "../src/httpTypes"; +import type { ArtifactHttpRequest } from "../src/httpTypes"; import { createHash } from "node:crypto"; -import { formatCanvasCheck, type Diagnostic } from "../src/diagnostics"; -import type { CanvasAppPayload } from "../src/mcp/app-contract"; +import { formatArtifactCheck, type Diagnostic } from "../src/diagnostics"; +import type { ArtifactAppPayload } from "../src/mcp/app-contract"; import type { GalleryArtifact, GalleryData } from "../src/gallery/types"; import { runtime } from "../dist/cloudflare/identity.json"; -import { canvasGuideResult } from "../src/mcp/guide"; -import type { CanvasFiles } from "./files"; -import { CanvasFileError, validateFileRequest } from "./files"; -import type { CanvasFileRequest } from "../src/sdk/files"; +import { artifactGuideResult } from "../src/mcp/guide"; +import type { ArtifactFiles } from "./files"; +import { ArtifactFileError, validateFileRequest } from "./files"; +import type { ArtifactFileRequest } from "../src/sdk/files"; import { dispatchPlugin, pluginCatalog, PLUGIN_GUIDE, type PluginInvocationContext } from "./plugins"; import type { PluginRequest } from "../src/plugins/types"; @@ -32,15 +32,15 @@ type Snapshot = { 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 }; -type Compiled = { ok: boolean; js?: string; diagnostics: Diagnostic[]; artifact?: CompiledCanvas }; +type Compiled = { ok: boolean; js?: string; diagnostics: Diagnostic[]; artifact?: CompiledArtifact }; -export class CloudCanvasService { +export class CloudArtifactService { private readonly artifacts: ArtifactService; private readonly scripts: CloudScriptService; - constructor(readonly library: DurableObjectStub, readonly workspace: string, - readonly backends: DurableObjectNamespace, readonly libraryKey: string, readonly hosted?: HostedArtifacts, readonly plugins?: PluginInvocationContext, - readonly fileStorage?: { backends: DurableObjectNamespace; origin: string }) { + constructor(readonly library: DurableObjectStub, readonly workspace: string, + readonly backends: DurableObjectNamespace, readonly libraryKey: string, readonly hosted?: HostedArtifacts, readonly plugins?: PluginInvocationContext, + readonly fileStorage?: { backends: DurableObjectNamespace; origin: string }) { this.artifacts = new ArtifactService(workspace, libraryKey, hosted); this.scripts = new CloudScriptService(this.artifacts); } @@ -49,103 +49,103 @@ export class CloudCanvasService { if (name.startsWith("script_")) return this.scripts.callTool(name, args); if (name === "artifact_link") return this.artifactLink(args); switch (name) { - case "canvas_files": { - const result = await this.fileRequest(args as { name?: string; version_id?: string }, args.request as CanvasFileRequest); + case "artifact_files": { + const result = await this.fileRequest(args as { name?: string; version_id?: string }, args.request as ArtifactFileRequest); return { ...text({ result }), structuredContent: { result } }; } case "plugins_list": return { ...text({ plugins: pluginCatalog }), structuredContent: { plugins: pluginCatalog } }; case "plugin_guide": return text(PLUGIN_GUIDE); - case "canvas_plugin_call": { + case "artifact_plugin_call": { const result = await this.pluginCall(args as PluginRequest); return { ...text({ result }), structuredContent: { result } }; } - case "canvas_guide": { - const guide = canvasGuideResult(); + case "artifact_guide": { + const guide = artifactGuideResult(); if (this.hosted) guide.content.push({ type: "text", text: HOSTED_SCRIPT_GUIDE }); return guide; } - case "canvas_list": { + case "artifact_list": { const offset = args.offset as number | undefined ?? 0; - const canvases = await this.library.listDrafts({ workspace: this.workspace, offset }); - 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 }); + const artifacts = await this.library.listDrafts({ workspace: this.workspace, offset }); + const entries = await Promise.all(artifacts.map(async artifact => ({ ...artifact, ...await this.artifacts.linkDetails(this.artifacts.target("artifact", artifact.id)) }))); + return text({ artifacts: entries, next_offset: artifacts.length === 100 ? offset + 100 : null }); } - 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": { + case "artifact_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 "artifact_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 }); return text({ versions, next_offset: versions.length === 100 ? offset + 100 : null }); } - case "canvas_version": return text(await this.library.version({ workspace: this.workspace, id: args.version_id as string, events_offset: args.events_offset as number | undefined })); - case "canvas_remix": { - const target = this.artifacts.target("canvas", args.new_name as string); + case "artifact_version": return text(await this.library.version({ workspace: this.workspace, id: args.version_id as string, events_offset: args.events_offset as number | undefined })); + case "artifact_remix": { + const target = this.artifacts.target("artifact", args.new_name as string); const slug = args.slug as string | undefined ?? target.name; if (this.hosted) await this.hosted.links.check(target, slug); const mutation = await this.library.remix({workspace:this.workspace,name:args.name as string | undefined,version_id:args.version_id as string | undefined,new_name:args.new_name as string,runtime}); const generation = await this.hosted?.links.begin(target); return this.mutationResult(mutation, generation, this.hosted ? {slug,access:"private"} : {}); } - case "canvas_write": { - const target = this.artifacts.target("canvas", args.name as string); + case "artifact_write": { + const target = this.artifacts.target("artifact", 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); - 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 previous = args.project === undefined ? undefined : await this.snapshot({name: args.name as string}).catch(error => { if (error instanceof Error && error.message.includes("artifact 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, 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 "artifact_edit": { + const generation = await this.hosted?.links.begin(this.artifacts.target("artifact", args.name as string)); + 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 ArtifactEdit[], expected_hash: args.expected_hash as string | undefined }), generation); } - case "canvas_restore": { + case "artifact_restore": { const version = await this.library.version({ workspace: this.workspace, id: args.version_id as string }); - const generation = await this.hosted?.links.begin(this.artifacts.target("canvas", version.name)); + const generation = await this.hosted?.links.begin(this.artifacts.target("artifact", version.name)); return this.mutationResult(await this.library.restore({ workspace: this.workspace, id: args.version_id as string, runtime }), generation); } - case "canvas_typecheck": { + case "artifact_typecheck": { const snapshot = await this.snapshot({ name: args.name as string }); - 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); + const diagnostics = [...typecheckArtifactSource(snapshot.source, snapshot.project), ...(snapshot.server_source === null ? [] : typecheckArtifactServerSource(snapshot.server_source, snapshot.project))]; + return text({ path: snapshot.path, check: formatArtifactCheck(diagnostics), diagnostics }, diagnostics.length > 0); } - case "canvas_compile": { + case "artifact_compile": { const snapshot = await this.snapshot({ name: args.name as string | undefined, version_id: args.version_id as string | undefined }); const compiled = await this.compile(snapshot); if (compiled.artifact && snapshot.version_id) await this.library.attachCompiled({ workspace: snapshot.workspace, name: snapshot.name, version_id: snapshot.version_id, compiled_id: compiled.artifact.id, }); - return text({ ok: compiled.ok, path: snapshot.path, check: formatCanvasCheck(compiled.diagnostics), diagnostics: compiled.diagnostics, bytes: compiled.js?.length ?? 0 }, !compiled.ok); + return text({ ok: compiled.ok, path: snapshot.path, check: formatArtifactCheck(compiled.diagnostics), diagnostics: compiled.diagnostics, bytes: compiled.js?.length ?? 0 }, !compiled.ok); } - case "canvas_open": { + case "artifact_open": { const snapshot = await this.snapshot(args as { name?: string; version_id?: string; event_id?: string }); const preview = await this.preview(snapshot); const { _meta, ...payload } = preview; return { ...text(payload, !preview.ok), structuredContent: payload, ...(_meta ? { _meta } : {}) }; } - case "canvas_schedule": - case "canvas_runs": { + case "artifact_schedule": + case "artifact_runs": { const snapshot = await this.snapshot({ name: args.name as string }); const backend = this.backends.getByName(JSON.stringify([this.libraryKey, this.workspace, snapshot.name])); - if (name === "canvas_runs") { + if (name === "artifact_runs") { const payload = { runs: await backend.runs({ limit: args.limit as number | undefined }) }; return { ...text(payload), structuredContent: payload }; } - // Existing canvases gain a validated active revision when first scheduled. + // Existing artifacts gain a validated active revision when first scheduled. // Reads and pause remain usable while a draft is invalid. if (args.action === "set" || args.action === "resume") { const current = await backend.activeRevision(); if (!current) { const compiled = await this.compile(snapshot); - if (!compiled.ok) throw new Error(formatCanvasCheck(compiled.diagnostics)); + if (!compiled.ok) throw new Error(formatArtifactCheck(compiled.diagnostics)); const preview = await this.preview(snapshot, compiled); - if (preview.ok && "canvas" in preview) await this.activate(snapshot, preview.canvas.versionId, preview.canvas.revision, compiled); + if (preview.ok && "artifact" in preview) await this.activate(snapshot, preview.artifact.versionId, preview.artifact.revision, compiled); } } const { name: _name, ...input } = args; - const payload = { schedule: await backend.schedule(input as CanvasScheduleInput) }; + const payload = { schedule: await backend.schedule(input as ArtifactScheduleInput) }; return { ...text(payload), structuredContent: payload }; } - case "canvas_request": { - const response = await this.request({ name: args.name as string | undefined, version_id: args.version_id as string | undefined }, args.request as CanvasHttpRequest, "manual"); + case "artifact_request": { + const response = await this.request({ name: args.name as string | undefined, version_id: args.version_id as string | undefined }, args.request as ArtifactHttpRequest, "manual"); return { ...text({ status: response.status }), structuredContent: { response } }; } default: throw new Error("Unknown tool"); @@ -160,7 +160,7 @@ export class CloudCanvasService { async preview(snapshot: Snapshot, compiled?: Compiled) { compiled ??= await this.savedCompilation(snapshot); - const base = { ...await this.artifacts.linkDetails(this.artifacts.target("canvas", snapshot.name)), ok: compiled.ok, path: snapshot.path, check: formatCanvasCheck(compiled.diagnostics), diagnostics: compiled.diagnostics }; + const base = { ...await this.artifacts.linkDetails(this.artifacts.target("artifact", snapshot.name)), ok: compiled.ok, path: snapshot.path, check: formatArtifactCheck(compiled.diagnostics), diagnostics: compiled.diagnostics }; 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, @@ -168,13 +168,13 @@ export class CloudCanvasService { runtime: compiled.artifact.runtime, compiled_id: compiled.artifact.id, initial_state: snapshot.state, mode: "preview", ...(snapshot.version_id ? { version_id: snapshot.version_id } : {}), }); - const canvas = { name: version.name, versionId: version.id, revision: version.revision, eventId: event.id, sourceHash: version.source_hash }; - return { ...base, ok: true, canvas, _meta: { canvas: { ...canvas, js: compiled.js, state: snapshot.state, server: snapshot.server_source !== null, ...(this.plugins ? { plugins: true } : {}), ...(this.fileStorage ? { files: true } : {}) } satisfies CanvasAppPayload } }; + const artifact = { name: version.name, versionId: version.id, revision: version.revision, eventId: event.id, sourceHash: version.source_hash }; + return { ...base, ok: true, artifact, _meta: { artifact: { ...artifact, js: compiled.js, state: snapshot.state, server: snapshot.server_source !== null, ...(this.plugins ? { plugins: true } : {}), ...(this.fileStorage ? { files: true } : {}) } satisfies ArtifactAppPayload } }; } private async mutationResult(mutation: Mutation, generation?: number | null, settings: LinkUpdate = {}): Promise { const { source, server_source, state, project, ...summary } = mutation; - const target = this.artifacts.target("canvas", mutation.name); + const target = this.artifacts.target("artifact", mutation.name); if (generation != null) { await this.hosted!.links.stage(target, generation, settings); settings = { ...await this.hosted!.links.draft(target), ...settings }; @@ -183,21 +183,21 @@ export class CloudCanvasService { // that revision rather than capturing another revision during the preview. const snapshot = mutation.versionId ? { ...mutation, version_id: mutation.versionId } : mutation; const compiled = await this.compile(snapshot); - const payload = { ...summary, ...await this.artifacts.linkDetails(this.artifacts.target("canvas", mutation.name)), applied: true, ok: compiled.ok, check: formatCanvasCheck(compiled.diagnostics), diagnostics: compiled.diagnostics }; + const payload = { ...summary, ...await this.artifacts.linkDetails(this.artifacts.target("artifact", mutation.name)), applied: true, ok: compiled.ok, check: formatArtifactCheck(compiled.diagnostics), diagnostics: compiled.diagnostics }; if (!compiled.ok) return { ...text(payload, true), structuredContent: payload }; // The draft is already committed. A delivery error must never look like a // rollback, and compilation must use that exact committed source snapshot. try { const { _meta, ...details } = await this.preview(snapshot, compiled); - const target = this.artifacts.target("canvas", mutation.name); - if (generation != null && details.ok && "canvas" in details && (settings.slug !== undefined || await this.hosted!.links.find(target))) { - const link = await this.hosted!.links.commit(target, generation, { ...settings, version_id: details.canvas.versionId }); + const target = this.artifacts.target("artifact", mutation.name); + if (generation != null && details.ok && "artifact" in details && (settings.slug !== undefined || await this.hosted!.links.find(target))) { + const link = await this.hosted!.links.commit(target, generation, { ...settings, version_id: details.artifact.versionId }); if (!link) { const superseded = { ...payload, ok: false, superseded: true, error: "A newer update superseded this URL activation" }; return { ...text(superseded, true), structuredContent: superseded }; } } - if (details.ok && "canvas" in details) await this.activate(mutation, details.canvas.versionId, details.canvas.revision, compiled); + if (details.ok && "artifact" in details) await this.activate(mutation, details.artifact.versionId, details.artifact.revision, compiled); const result = { ...payload, ...await this.artifacts.linkDetails(target) }; return { ...text(result, !details.ok), structuredContent: { ...result, preview: { ...details, ...await this.artifacts.linkDetails(target) } }, ...(_meta ? { _meta } : {}) }; } catch (error) { @@ -214,9 +214,9 @@ export class CloudCanvasService { ...(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, snapshot.project); + const client = await compileArtifactSource(snapshot.source, snapshot.project); if (!client.ok || !client.js) return client; - const server = snapshot.server_source === null ? null : await compileCanvasServerSource(snapshot.server_source, snapshot.project); + const server = snapshot.server_source === null ? null : await compileArtifactServerSource(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, project: snapshot.project, runtime, client_js: client.js, server_js: server?.js ?? null, @@ -230,38 +230,38 @@ export class CloudCanvasService { }); if (artifact) return { ok: true, js: artifact.client_js, diagnostics: [], artifact }; // Old installations have source/history but no saved bundles. Backfill via - // canvas_compile; reads must never silently launch the compiler. + // artifact_compile; reads must never silently launch the compiler. return { ok: false, diagnostics: [{ severity: "error", file: snapshot.path, - message: "This canvas has no saved compiled artifact. Run canvas_compile with its name or version_id, or save a valid edit, before opening it.", + message: "This artifact has no saved compiled artifact. Run artifact_compile with its name or version_id, or save a valid edit, before opening it.", }] }; } private async activate(snapshot: Snapshot, version_id: string, revision: number, compiled: Compiled) { - if (!compiled.ok || !compiled.artifact) throw new Error(formatCanvasCheck(compiled.diagnostics)); + if (!compiled.ok || !compiled.artifact) throw new Error(formatArtifactCheck(compiled.diagnostics)); const code = compiled.artifact.server_js; const hash = createHash("sha256").update(code ?? "").digest("hex"); await this.backends.getByName(JSON.stringify([this.libraryKey, this.workspace, snapshot.name])).activate({ code, hash, version_id, revision }); } - async request(selection: { name?: string; version_id?: string }, request: CanvasHttpRequest, trigger: "http" | "manual" = "http") { + async request(selection: { name?: string; version_id?: string }, request: ArtifactHttpRequest, trigger: "http" | "manual" = "http") { const snapshot = await this.snapshot(selection.version_id ? { version_id: selection.version_id } : { name: selection.name }); - if (selection.name && snapshot.name !== selection.name.trim().replace(/\.canvas\.tsx$/, "")) throw new Error("Server version does not belong to this canvas"); - if (snapshot.server_source === null) throw new Error("Canvas has no server"); + if (selection.name && snapshot.name !== selection.name.trim().replace(/\.artifact\.tsx$/, "")) throw new Error("Server version does not belong to this artifact"); + if (snapshot.server_source === null) throw new Error("Artifact has no server"); const compiled = await this.savedCompilation(snapshot); - if (!compiled.ok || !compiled.artifact?.server_js) throw new Error(formatCanvasCheck(compiled.diagnostics)); + if (!compiled.ok || !compiled.artifact?.server_js) throw new Error(formatArtifactCheck(compiled.diagnostics)); const code = compiled.artifact.server_js; const hash = createHash("sha256").update(code).digest("hex"); const backend = this.backends.getByName(JSON.stringify([this.libraryKey, this.workspace, snapshot.name])); return backend.request({ code, hash, version_id: snapshot.version_id ?? undefined, request, trigger }); } - async fileRequest(selection: { name?: string; version_id?: string }, input: CanvasFileRequest, writable = true): Promise { + async fileRequest(selection: { name?: string; version_id?: string }, input: ArtifactFileRequest, writable = true): Promise { validateFileRequest(input); - if (!writable && input.operation !== "list" && input.operation !== "download") throw new CanvasFileError("Public canvases have read-only file access", 403); - if (!this.fileStorage) throw new CanvasFileError("Canvas file storage is not configured", 503); - if (!selection.name && !selection.version_id) throw new CanvasFileError("Select a canvas name or version_id"); + if (!writable && input.operation !== "list" && input.operation !== "download") throw new ArtifactFileError("Public artifacts have read-only file access", 403); + if (!this.fileStorage) throw new ArtifactFileError("Artifact file storage is not configured", 503); + if (!selection.name && !selection.version_id) throw new ArtifactFileError("Select an artifact name or version_id"); const snapshot = await this.snapshot(selection.version_id ? { version_id: selection.version_id } : { name: selection.name }); - if (selection.name && snapshot.name !== selection.name.trim().replace(/\.canvas\.tsx$/, "")) throw new CanvasFileError("File version does not belong to this canvas"); + if (selection.name && snapshot.name !== selection.name.trim().replace(/\.artifact\.tsx$/, "")) throw new ArtifactFileError("File version does not belong to this artifact"); const backend = this.fileStorage.backends.getByName(JSON.stringify([this.libraryKey, this.workspace, snapshot.name])); const result = await backend.request(input); if (result && typeof result === "object" && "path" in result && typeof result.path === "string") { @@ -273,10 +273,10 @@ export class CloudCanvasService { private async artifactLink(args: Record): Promise { const hosted = this.artifacts.requireHosted(); - if (args.kind !== "canvas" && args.kind !== "script") throw new Error("kind must be canvas or script"); + if (args.kind !== "artifact" && args.kind !== "script") throw new Error("kind must be artifact or script"); const target = this.artifacts.target(args.kind, args.name as string); const existing = await hosted.links.find(target); - if ((target.kind === "canvas" && existing?.version_id) || (target.kind === "script" && existing?.script_hash)) { + if ((target.kind === "artifact" && existing?.version_id) || (target.kind === "script" && existing?.script_hash)) { // Access changes must work even while the current draft is invalid. Keep // serving the already validated revision when renaming or restricting it. await hosted.links.set({ ...target, slug: args.slug as string, access: args.access as "private" | "public" | undefined }); @@ -292,10 +292,10 @@ export class CloudCanvasService { } const snapshot = await this.snapshot({ name: target.name }); const compiled = await this.compile(snapshot); - if (!compiled.ok) return text({ ok: false, diagnostics: compiled.diagnostics, check: formatCanvasCheck(compiled.diagnostics) }, true); + if (!compiled.ok) return text({ ok: false, diagnostics: compiled.diagnostics, check: formatArtifactCheck(compiled.diagnostics) }, true); const { _meta, ...preview } = await this.preview(snapshot, compiled); - if (preview.ok && "canvas" in preview) { - const link = await hosted.links.commit(target, generation, { ...settings, version_id: preview.canvas.versionId }); + if (preview.ok && "artifact" in preview) { + const link = await hosted.links.commit(target, generation, { ...settings, version_id: preview.artifact.versionId }); if (!link) return text({ ok: false, superseded: true, error: "A newer update superseded this URL activation" }, true); } return { ...text({ ...preview, ...await this.artifacts.linkDetails(target) }), ...(_meta ? { _meta } : {}) }; @@ -340,7 +340,7 @@ export class CloudCanvasService { artifacts.set(key, artifact); } for (const artifact of artifacts.values()) { - artifact.kind ??= "canvas"; + artifact.kind ??= "artifact"; Object.assign(artifact, await this.artifacts.linkDetails({ libraryKey: this.libraryKey, workspace: artifact.workspace, name: artifact.name, kind: artifact.kind })); } } diff --git a/cloudflare/team-auth.test.ts b/cloudflare/team-auth.test.ts index 7eeb795..8930c52 100644 --- a/cloudflare/team-auth.test.ts +++ b/cloudflare/team-auth.test.ts @@ -1,6 +1,6 @@ import { expect, test } from "bun:test"; import { Database } from "bun:sqlite"; -import { authOrigin, canvasAuthOptions } from "./better-auth"; +import { authOrigin, artifactAuthOptions, requireArtifactScope } from "./better-auth"; import { teamAllowsUser, teamProviderOptions } from "./team-auth"; const secret = "0123456789abcdef0123456789abcdef"; @@ -54,33 +54,33 @@ test("team admission requires a verified allowlisted email or domain unless the }); test("auth origin accepts HTTPS and explicit loopback development origins while rejecting unsafe or non-origin URLs", () => { - expect(authOrigin({ BETTER_AUTH_URL: "https://canvas.example" })).toBe("https://canvas.example"); - expect(authOrigin({ BETTER_AUTH_URL: "https://canvas.example:8443" })).toBe("https://canvas.example:8443"); + expect(authOrigin({ BETTER_AUTH_URL: "https://artifact.example" })).toBe("https://artifact.example"); + expect(authOrigin({ BETTER_AUTH_URL: "https://artifact.example:8443" })).toBe("https://artifact.example:8443"); for (const url of ["http://localhost:4785", "http://127.0.0.1:4785", "http://[::1]:4785"]) { expect(authOrigin({ BETTER_AUTH_URL: url })).toBe(url); } for (const url of [ - "http://canvas.example", + "http://artifact.example", "http://localhost.example:4785", - "https://user:password@canvas.example", - "https://canvas.example/auth", - "https://canvas.example?tenant=one", - "https://canvas.example#auth", + "https://user:password@artifact.example", + "https://artifact.example/auth", + "https://artifact.example?tenant=one", + "https://artifact.example#auth", ]) expect(() => authOrigin({ BETTER_AUTH_URL: url })).toThrow("must be the deployment's HTTPS origin"); expect(() => authOrigin({})).toThrow("Configure BETTER_AUTH_URL"); }); -test("Canvas auth options keep password auth disabled and configure the bounded MCP OAuth server", () => { +test("Artifact auth options keep password auth disabled and configure the bounded MCP OAuth server", () => { const database = new Database(":memory:"); try { - const options = canvasAuthOptions({ - BETTER_AUTH_URL: "https://canvas.example", + const options = artifactAuthOptions({ + BETTER_AUTH_URL: "https://artifact.example", BETTER_AUTH_SECRET: secret, BETTER_AUTH_ALLOW_ALL_USERS: "true", }, database); expect(options).toMatchObject({ - appName: "Canvas", - baseURL: "https://canvas.example", + appName: "Artifacts", + baseURL: "https://artifact.example", basePath: "/api/auth", emailAndPassword: { enabled: false }, rateLimit: { enabled: true, storage: "database" }, @@ -89,7 +89,7 @@ test("Canvas auth options keep password auth disabled and configure the bounded expect(oauth?.options).toMatchObject({ loginPage: "/sign-in", consentPage: "/consent", - scopes: ["openid", "profile", "email", "offline_access", "canvas"], + scopes: ["openid", "profile", "email", "offline_access", "artifacts", "canvas"], grantTypes: ["authorization_code", "refresh_token"], allowDynamicClientRegistration: true, allowUnauthenticatedClientRegistration: true, @@ -100,6 +100,16 @@ test("Canvas auth options keep password auth disabled and configure the bounded database.close(); } - expect(() => canvasAuthOptions({ BETTER_AUTH_URL: "https://canvas.example", BETTER_AUTH_SECRET: secret }, undefined as never)).toThrow("AUTH_DB"); - expect(() => canvasAuthOptions({ BETTER_AUTH_URL: "https://canvas.example", BETTER_AUTH_SECRET: "too-short" }, {} as never)).toThrow("at least 32"); + expect(() => artifactAuthOptions({ BETTER_AUTH_URL: "https://artifact.example", BETTER_AUTH_SECRET: secret }, undefined as never)).toThrow("AUTH_DB"); + expect(() => artifactAuthOptions({ BETTER_AUTH_URL: "https://artifact.example", BETTER_AUTH_SECRET: "too-short" }, {} as never)).toThrow("at least 32"); +}); + + +test("MCP authorization accepts new and existing scopes without accepting partial matches", () => { + for (const scope of ["artifacts", "canvas", "openid artifacts offline_access", "canvas offline_access"]) { + expect(() => requireArtifactScope(scope)).not.toThrow(); + } + for (const scope of [undefined, null, [], "", "artifact", "canvases", "openid offline_access", "not-artifacts"]) { + expect(() => requireArtifactScope(scope)).toThrow(); + } }); diff --git a/cloudflare/team-auth.ts b/cloudflare/team-auth.ts index dd8a896..9a9899a 100644 --- a/cloudflare/team-auth.ts +++ b/cloudflare/team-auth.ts @@ -34,7 +34,7 @@ export function teamAllowsUser(env: TeamAuthEnvironment, user: { email?: unknown /** This is the deployment's native Better Auth provider configuration seam. * JSON options pass through to Better Auth unchanged. Teams needing callbacks * or additional provider plugins can configure them here in ordinary TypeScript; - * the Canvas server and provider picker do not enumerate provider IDs. */ + * the Artifact server and provider picker do not enumerate provider IDs. */ export function teamProviderOptions(env: TeamAuthEnvironment) { const social = configuredJson(env.BETTER_AUTH_SOCIAL_PROVIDERS, "BETTER_AUTH_SOCIAL_PROVIDERS", {}); const oidc = configuredJson(env.BETTER_AUTH_OIDC_PROVIDERS, "BETTER_AUTH_OIDC_PROVIDERS", []); diff --git a/cloudflare/tool-contract.ts b/cloudflare/tool-contract.ts index 25d7171..ecb85d6 100644 --- a/cloudflare/tool-contract.ts +++ b/cloudflare/tool-contract.ts @@ -5,39 +5,39 @@ import { MCP_TOOLS } from "../src/mcp/tools"; export const CLOUD_MCP_TOOLS: Tool[] = JSON.parse(JSON.stringify(MCP_TOOLS)); for (const tool of CLOUD_MCP_TOOLS) { tool.description = tool.description?.replaceAll(" Rejects symlinks and names with slashes.", " Rejects names with slashes.") - .replace("Sandbox-scan and bundle a canvas file with Bun.", "Typecheck, sandbox-scan and compile a canvas for the browser.") - .replace("List .canvas.tsx files in the canvases directory.", "List working canvases in this workspace.") + .replace("Sandbox-scan and bundle an artifact file with Bun.", "Typecheck, sandbox-scan and compile an artifact for the browser.") + .replace("List .artifact.tsx files in the artifacts directory.", "List working artifacts in this workspace.") .replace(" Use target: herdr only to explicitly open a terminal pane instead.", ""); if (tool.inputSchema.properties?.target) { - tool.inputSchema.properties.target = { type: "string", enum: ["inline"], default: "inline", description: "Hosted canvases open inline in the MCP App viewer." }; + tool.inputSchema.properties.target = { type: "string", enum: ["inline"], default: "inline", description: "Hosted artifacts open inline in the MCP App viewer." }; delete tool.inputSchema.properties.placement; } - if (tool.name === "canvas_list" || tool.name === "canvas_history" || tool.name === "canvas_version") { - const field = tool.name === "canvas_version" ? "events_offset" : "offset"; + if (tool.name === "artifact_list" || tool.name === "artifact_history" || tool.name === "artifact_version") { + const field = tool.name === "artifact_version" ? "events_offset" : "offset"; tool.inputSchema.properties = { ...tool.inputSchema.properties, [field]: { type: "integer", minimum: 0, default: 0 } }; - tool.description += tool.name === "canvas_version" + tool.description += tool.name === "artifact_version" ? " Serve events are paginated, 20 per response; pass next_events_offset as events_offset for older events." : " Results are paginated, 100 per response; pass next_offset as offset for the next page."; } - if (tool.name === "canvas_write") { + if (tool.name === "artifact_write") { tool.inputSchema.properties = { ...tool.inputSchema.properties, - server: { type: ["string", "null"], description: "Optional native server TypeScript exporting class CanvasServer extends DurableObject from cloudflare:workers. Use this.ctx.storage.sql for native SQLite and this.ctx.storage.kv for key/value storage. Omit to preserve; null removes server code without deleting its database." }, + server: { type: ["string", "null"], description: "Optional native server TypeScript exporting class ArtifactServer extends DurableObject from cloudflare:workers. Use this.ctx.storage.sql for native SQLite and this.ctx.storage.kv for key/value storage. Omit to preserve; null removes server code without deleting its database." }, }; } - if (tool.name === "canvas_read" || tool.name === "canvas_edit") { + if (tool.name === "artifact_read" || tool.name === "artifact_edit") { tool.inputSchema.properties = { ...tool.inputSchema.properties, part: { type: "string", enum: ["client", "server"], default: "client", description: "Select browser TSX or native server TypeScript." }, }; } - if (tool.name === "canvas_compile") { - tool.description = "Compile and durably save the client/server bundle for a working canvas or an archived revision. Select name or version_id. Use this to prepare source-only revisions from older installations; reads never compile. Already compiled archived revisions keep their original bundle."; + if (tool.name === "artifact_compile") { + tool.description = "Compile and durably save the client/server bundle for a working artifact or an archived revision. Select name or version_id. Use this to prepare source-only revisions from older installations; reads never compile. Already compiled archived revisions keep their original bundle."; tool.inputSchema.properties = { name: { type: "string" }, version_id: { type: "string" } }; delete tool.inputSchema.required; tool.inputSchema.oneOf = [{ required: ["name"] }, { required: ["version_id"] }]; } } -export const CANVAS_REQUEST_SCHEMA = { +export const ARTIFACTS_REQUEST_SCHEMA = { type: "object", properties: { path: { type: "string", minLength: 1, maxLength: 8192 }, method: { type: "string", default: "GET" }, @@ -46,14 +46,14 @@ export const CANVAS_REQUEST_SCHEMA = { }, required: ["path"], additionalProperties: false, }; CLOUD_MCP_TOOLS.push({ - name: "canvas_request", - description: "Send an HTTP request to a canvas's native server. Its fetch handler can use native Durable Object SQLite/KV APIs. Select name or version_id. Database contents remain live across source edits and restores. Returns a response envelope with base64 body; canvasFetch provides ordinary Request/Response behavior in the UI.", + name: "artifact_request", + description: "Send an HTTP request to an artifact's native server. Its fetch handler can use native Durable Object SQLite/KV APIs. Select name or version_id. Database contents remain live across source edits and restores. Returns a response envelope with base64 body; artifactFetch provides ordinary Request/Response behavior in the UI.", inputSchema: { type: "object", properties: { - name: { type: "string" }, version_id: { type: "string" }, request: CANVAS_REQUEST_SCHEMA, + name: { type: "string" }, version_id: { type: "string" }, request: ARTIFACTS_REQUEST_SCHEMA, }, required: ["request"], anyOf: [{ required: ["name"] }, { required: ["version_id"] }], additionalProperties: false }, }); -export const CANVAS_FILE_REQUEST_SCHEMA = { +export const ARTIFACTS_FILE_REQUEST_SCHEMA = { oneOf: [ { type: "object", properties: { operation: { const: "list" }, cursor: { type: "string", maxLength: 2048 } }, required: ["operation"], additionalProperties: false }, { type: "object", properties: { operation: { const: "upload" }, name: { type: "string", minLength: 1, maxLength: 255 }, size: { type: "integer", minimum: 0, maximum: 26214400 }, type: { type: "string", maxLength: 255 } }, required: ["operation", "name", "size", "type"], additionalProperties: false }, @@ -61,9 +61,9 @@ export const CANVAS_FILE_REQUEST_SCHEMA = { ], }; CLOUD_MCP_TOOLS.push({ - name: "canvas_files", - description: "Manage files belonging to one canvas. List returns up to 100 files and an optional cursor. Upload allocates a new file and a single-use PUT URL (5 minutes, exact declared size, maximum 25 MiB); send raw bytes to that URL, never through this tool. Download returns a GET URL valid for 5 minutes. Delete removes a file. Files remain live across source edits and restores. Requires an existing canvas; no server code needed. Transfer URLs are bearer capabilities: do not publish them.", - inputSchema: { type: "object", properties: { name: { type: "string" }, version_id: { type: "string" }, request: CANVAS_FILE_REQUEST_SCHEMA }, required: ["request"], anyOf: [{ required: ["name"] }, { required: ["version_id"] }], additionalProperties: false }, + name: "artifact_files", + description: "Manage files belonging to one artifact. List returns up to 100 files and an optional cursor. Upload allocates a new file and a single-use PUT URL (5 minutes, exact declared size, maximum 25 MiB); send raw bytes to that URL, never through this tool. Download returns a GET URL valid for 5 minutes. Delete removes a file. Files remain live across source edits and restores. Requires an existing artifact; no server code needed. Transfer URLs are bearer capabilities: do not publish them.", + inputSchema: { type: "object", properties: { name: { type: "string" }, version_id: { type: "string" }, request: ARTIFACTS_FILE_REQUEST_SCHEMA }, required: ["request"], anyOf: [{ required: ["name"] }, { required: ["version_id"] }], additionalProperties: false }, annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: false }, }); @@ -71,20 +71,20 @@ const slugProperties = { slug: { type: "string", minLength: 1, maxLength: 80, pattern: "^[a-z0-9](?:[a-z0-9-]{0,78}[a-z0-9])?$", description: "Chosen root URL slug, unique across this deployment." }, access: { type: "string", enum: ["private", "public"], description: "Private uses library permissions; public allows external HTTP callers. Default private." }, }; -for (const kind of ["canvas", "script"]) { - // Canvas is shared with local MCP; hosted script authoring uses this contract too. +for (const kind of ["artifact", "script"]) { + // Artifact is shared with local MCP; hosted script authoring uses this contract too. if (!CLOUD_MCP_TOOLS.some(tool => tool.name === kind + "_remix")) CLOUD_MCP_TOOLS.push({name:kind + "_remix",description:"Copy a working artifact or immutable revision to a new name in this workspace. Preserves source provenance. Starts with fresh state, storage, and secrets and a private URL. Never overwrites an existing artifact.",inputSchema:{type:"object",properties:{name:{type:"string",minLength:1},version_id:{type:"string",minLength:1},new_name:{type:"string",minLength:1},slug:slugProperties.slug},required:["new_name"],oneOf:[{required:["name"]},{required:["version_id"]}],additionalProperties:false}}); else Object.assign(CLOUD_MCP_TOOLS.find(tool => tool.name === kind + "_remix")!.inputSchema.properties!, {slug:slugProperties.slug}); } -const canvasWrite = CLOUD_MCP_TOOLS.find(tool => tool.name === "canvas_write")!; -Object.assign(canvasWrite.inputSchema.properties!, slugProperties); +const artifactWrite = CLOUD_MCP_TOOLS.find(tool => tool.name === "artifact_write")!; +Object.assign(artifactWrite.inputSchema.properties!, slugProperties); function addScriptTool(name: string, description: string, properties: Record, required: string[] = []) { CLOUD_MCP_TOOLS.push({ name, description, inputSchema: { type: "object", properties, required, additionalProperties: false } }); } const nameProperty = { name: { type: "string", minLength: 1, description: "Script name in this workspace." } }; const pageProperty = { offset: { type: "integer", minimum: 0, default: 0 } }; -addScriptTool("artifact_link", "Give a canvas or script a chosen root URL. Canvas links require valid source and work independently of the gallery. Defaults to private access. Renaming a slug changes its URL.", { - kind: { type: "string", enum: ["canvas", "script"] }, name: { type: "string", minLength: 1 }, ...slugProperties, +addScriptTool("artifact_link", "Give an artifact or script a chosen root URL. Artifact links require valid source and work independently of the gallery. Defaults to private access. Renaming a slug changes its URL.", { + kind: { type: "string", enum: ["artifact", "script"] }, name: { type: "string", minLength: 1 }, ...slugProperties, }, ["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 }; @@ -98,11 +98,11 @@ addScriptTool("script_edit", "Apply ordered, uniquely matching text replacements ...nameProperty, edits: { type: "array", minItems: 1, maxItems: 100, items: { type: "object", properties: { old_text: { type: "string", minLength: 1 }, new_text: { type: "string" } }, required: ["old_text", "new_text"], additionalProperties: false } }, expected_hash: { type: "string", pattern: "^[a-f0-9]{64}$" }, }, ["name", "edits"]); addScriptTool("script_list", "List saved scripts and their URLs without running them. Paginated, 100 per response.", pageProperty); -addScriptTool("script_run", "Execute a script's last validated handler. request.path is passed unchanged at the deployment origin; include the slug to reproduce a direct URL request. Returns HTTP response headers, status, and base64 body (maximum 256 KiB); use its URL for streaming or larger responses. May perform arbitrary script-defined writes or outbound requests.", { ...nameProperty, request: CANVAS_REQUEST_SCHEMA }, ["name", "request"]); +addScriptTool("script_run", "Execute a script's last validated handler. request.path is passed unchanged at the deployment origin; include the slug to reproduce a direct URL request. Returns HTTP response headers, status, and base64 body (maximum 256 KiB); use its URL for streaming or larger responses. May perform arbitrary script-defined writes or outbound requests.", { ...nameProperty, request: ARTIFACTS_REQUEST_SCHEMA }, ["name", "request"]); addScriptTool("script_history", "List script source history, 100 entries per page.", { ...nameProperty, ...pageProperty }); addScriptTool("script_version", "Read an immutable script source revision. Never returns secrets.", { version_id: { type: "string" } }, ["version_id"]); addScriptTool("script_restore", "Restore a script revision as a new draft and activate it if validation succeeds. Persistent storage remains live.", { ...nameProperty, version_id: { type: "string" } }, ["name", "version_id"]); -addScriptTool("script_schedule", "Inspect or configure recurring execution of the latest validated script. Use action set with exactly one of interval_seconds or five-field cron (timezone defaults UTC) and an optional request. Pause/resume preserve settings; run_now explicitly invokes the saved request, including while paused. Failed or interrupted runs are never automatically replayed; missed occurrences are skipped.", {...nameProperty,action:{type:"string",enum:["get","set","pause","resume","run_now"]},interval_seconds:{type:"integer",minimum:60,maximum:31536000},cron:{type:"string",maxLength:256},timezone:{type:"string",maxLength:100},request:CANVAS_REQUEST_SCHEMA},["name"]); +addScriptTool("script_schedule", "Inspect or configure recurring execution of the latest validated script. Use action set with exactly one of interval_seconds or five-field cron (timezone defaults UTC) and an optional request. Pause/resume preserve settings; run_now explicitly invokes the saved request, including while paused. Failed or interrupted runs are never automatically replayed; missed occurrences are skipped.", {...nameProperty,action:{type:"string",enum:["get","set","pause","resume","run_now"]},interval_seconds:{type:"integer",minimum:60,maximum:31536000},cron:{type:"string",maxLength:256},timezone:{type:"string",maxLength:100},request:ARTIFACTS_REQUEST_SCHEMA},["name"]); addScriptTool("script_runs", "Inspect recent invocation outcomes, revision hashes, triggers, and HTTP-handler durations. Retains 1000 runs, returns the latest 100. Interrupted means completion is unknown; background waitUntil work and streamed response completion are not part of HTTP-handler success.", {...nameProperty,limit:{type:"integer",minimum:1,maximum:100}},["name"]); addScriptTool("script_logs", "Read bounded recent execution logs and errors without executing the script. Optionally filter by run_id from script_runs.", { ...nameProperty, run_id:{type:"string"}, limit: { type: "integer", minimum: 1, maximum: 100 } }, ["name"]); addScriptTool("script_secrets", "Set or delete per-script secrets (null deletes). Omit secrets to list names. Values are never returned or added to source history.", { ...nameProperty, secrets: { type: "object", maxProperties: 32, additionalProperties: { type: ["string", "null"], maxLength: 4096 } } }, ["name"]); @@ -111,16 +111,16 @@ addScriptTool("plugins_list", "List deployment-installed plugins, browser availa CLOUD_MCP_TOOLS[CLOUD_MCP_TOOLS.length - 1]!.annotations = { readOnlyHint: true }; addScriptTool("plugin_guide", "Read the authenticated deployment plugin calling and authoring guide.", {}); CLOUD_MCP_TOOLS[CLOUD_MCP_TOOLS.length - 1]!.annotations = { readOnlyHint: true }; -addScriptTool("canvas_plugin_call", "Call a deployment-installed server function as the authenticated user. May perform writes or outbound requests; consult plugins_list for operation schemas and read-only hints. Canvas or library selectors do not confer authority.", { +addScriptTool("artifact_plugin_call", "Call a deployment-installed server function as the authenticated user. May perform writes or outbound requests; consult plugins_list for operation schemas and read-only hints. Artifact or library selectors do not confer authority.", { 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 }; -addScriptTool("canvas_schedule", "Configure recurring requests to a canvas's latest validated native server. Uses Durable Object alarms, with no automatic retry after uncertain side effects. Exactly one of interval_seconds or cron is required for set; timezone defaults to UTC. run_now executes the configured request, including while paused. Historical previews do not change the scheduled revision.", { +addScriptTool("artifact_schedule", "Configure recurring requests to an artifact's latest validated native server. Uses Durable Object alarms, with no automatic retry after uncertain side effects. Exactly one of interval_seconds or cron is required for set; timezone defaults to UTC. run_now executes the configured request, including while paused. Historical previews do not change the scheduled revision.", { name: { type: "string", minLength: 1 }, action: { type: "string", enum: ["get", "set", "pause", "resume", "run_now"], default: "get" }, - interval_seconds: { type: "integer", minimum: 60, maximum: 31536000 }, cron: { type: "string", maxLength: 256 }, timezone: { type: "string", maxLength: 100 }, request: CANVAS_REQUEST_SCHEMA, + interval_seconds: { type: "integer", minimum: 60, maximum: 31536000 }, cron: { type: "string", maxLength: 256 }, timezone: { type: "string", maxLength: 100 }, request: ARTIFACTS_REQUEST_SCHEMA, }, ["name"]); -addScriptTool("canvas_runs", "Read recent native canvas server invocations, their source revision, trigger, duration and outcome. Host-owned history is separate from application SQLite. An interrupted run may already have performed writes and is never automatically retried.", { name: { type: "string", minLength: 1 }, limit: { type: "integer", minimum: 1, maximum: 100 } }, ["name"]); +addScriptTool("artifact_runs", "Read recent native artifacts server invocations, their source revision, trigger, duration and outcome. Host-owned history is separate from application SQLite. An interrupted run may already have performed writes and is never automatically retried.", { name: { type: "string", minLength: 1 }, limit: { type: "integer", minimum: 1, maximum: 100 } }, ["name"]); CLOUD_MCP_TOOLS[CLOUD_MCP_TOOLS.length - 1]!.annotations = { readOnlyHint: true }; // Project source is additive to legacy entrypoints; dependency locks are generated by the service. const projectProperty = { type: "object", properties: { @@ -128,6 +128,6 @@ const projectProperty = { type: "object", properties: { 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."}; + if (tool.name === "artifact_write" || tool.name === "script_write") tool.inputSchema.properties!.project = projectProperty; + if (["artifact_read", "artifact_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.test.ts b/cloudflare/worker.test.ts index 536c011..6cdba40 100644 --- a/cloudflare/worker.test.ts +++ b/cloudflare/worker.test.ts @@ -5,17 +5,17 @@ import { Miniflare } from "miniflare"; import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; import { chromium } from "playwright"; -import { ROUTING_CANVAS } from "../src/test/routing"; +import { ROUTING_ARTIFACT } from "../src/test/routing"; let runtime: Miniflare; let runtimeOptions: ConstructorParameters[0]; let client: Client; let origin: string; -const source = 'import { Button, H1, Stack, useCanvasState } from "sidequery/canvas";\nexport default function Canvas() { const [n, setN] = useCanvasState("n", 0); return

Hosted canvas

; }\n'; -const counterClient = await readFile(new URL("../examples/counter.canvas.tsx", import.meta.url), "utf8"); -const counterServer = await readFile(new URL("../examples/counter.canvas.server.ts", import.meta.url), "utf8"); +const source = 'import { Button, H1, Stack, useArtifactState } from "sidequery/artifacts";\nexport default function Artifact() { const [n, setN] = useArtifactState("n", 0); return

Hosted artifact

; }\n'; +const counterClient = await readFile(new URL("../examples/counter.artifact.tsx", import.meta.url), "utf8"); +const counterServer = await readFile(new URL("../examples/counter.artifact.server.ts", import.meta.url), "utf8"); const apiServer = `import { DurableObject } from "cloudflare:workers"; -export class CanvasServer extends DurableObject { +export class ArtifactServer extends DurableObject { async fetch(request: Request): Promise { const url = new URL(request.url); if (url.pathname === "/api/headers") return Response.json(Object.fromEntries(request.headers)); @@ -34,26 +34,26 @@ beforeAll(async () => { if (name.endsWith(".wasm")) modules[name] = { type: "wasm", contents: await readFile(join(modulesRoot, name)) }; } runtimeOptions = { cf: false, port: 0, workers: [{ config: { - name: "canvas-app-test", type: "worker", compatibilityDate: "2026-09-06", compatibilityFlags: ["nodejs_compat"], + name: "artifact-app-test", type: "worker", compatibilityDate: "2026-09-06", compatibilityFlags: ["nodejs_compat"], manifest: { mainModule: "worker.js", modulesRoot, modules }, env: { ENVIRONMENT: { type: "text", value: "local" }, - LIBRARIES: { type: "durable-object", worker: "canvas-app-test", exportName: "CanvasLibrary" }, - BACKENDS: { type: "durable-object", worker: "canvas-app-test", exportName: "CanvasBackend" }, - LINKS: { type: "durable-object", worker: "canvas-app-test", exportName: "ArtifactLinks" }, - SCRIPTS: { type: "durable-object", worker: "canvas-app-test", exportName: "ScriptLibrary" }, - SCRIPT_BACKENDS: { type: "durable-object", worker: "canvas-app-test", exportName: "ScriptBackend" }, - FILE_BACKENDS: { type: "durable-object", worker: "canvas-app-test", exportName: "CanvasFiles" }, - FILES: { type: "r2", name: "canvas-test-files" }, + LIBRARIES: { type: "durable-object", worker: "artifact-app-test", exportName: "ArtifactLibrary" }, + BACKENDS: { type: "durable-object", worker: "artifact-app-test", exportName: "ArtifactBackend" }, + LINKS: { type: "durable-object", worker: "artifact-app-test", exportName: "ArtifactLinks" }, + SCRIPTS: { type: "durable-object", worker: "artifact-app-test", exportName: "ScriptLibrary" }, + SCRIPT_BACKENDS: { type: "durable-object", worker: "artifact-app-test", exportName: "ScriptBackend" }, + FILE_BACKENDS: { type: "durable-object", worker: "artifact-app-test", exportName: "ArtifactFiles" }, + FILES: { type: "r2", name: "artifact-test-files" }, LOADER: { type: "worker-loader" }, ASSETS: { type: "assets" }, }, - exports: { CanvasFiles: { type: "durable-object", storage: "sqlite" }, ArtifactLinks: { type: "durable-object", storage: "sqlite" }, ScriptLibrary: { type: "durable-object", storage: "sqlite" }, ScriptBackend: { type: "durable-object", storage: "sqlite" }, CanvasLibrary: { type: "durable-object", storage: "sqlite" }, CanvasBackend: { type: "durable-object", storage: "sqlite" } }, + exports: { ArtifactFiles: { type: "durable-object", storage: "sqlite" }, ArtifactLinks: { type: "durable-object", storage: "sqlite" }, ScriptLibrary: { type: "durable-object", storage: "sqlite" }, ScriptBackend: { type: "durable-object", storage: "sqlite" }, ArtifactLibrary: { type: "durable-object", storage: "sqlite" }, ArtifactBackend: { type: "durable-object", storage: "sqlite" } }, assets: { directory: join(import.meta.dir, "../dist/cloudflare/assets"), hasUserWorker: true, runWorkerFirst: true, htmlHandling: "none" }, }, dev: {} }] }; runtime = new Miniflare(runtimeOptions); origin = (await runtime.ready).origin; - client = new Client({ name: "canvas-integration", version: "1" }); + client = new Client({ name: "artifact-integration", version: "1" }); await client.connect(new StreamableHTTPClientTransport(new URL(`${origin}/mcp?workspace=test`))); }, 30000); afterAll(async () => { await client?.close(); await runtime?.dispose(); }); @@ -65,56 +65,56 @@ function payload(result: Awaited>) { test("official HTTP MCP client lists contracts, writes, edits, restores and retrieves raw history", async () => { const tools = (await client.listTools()).tools; - expect(tools.some(tool => tool.name === "canvas_write")).toBe(true); - expect(tools.some(tool => tool.name === "canvas_guide")).toBe(true); - const guide = await client.callTool({ name: "canvas_guide", arguments: {} }); + expect(tools.some(tool => tool.name === "artifact_write")).toBe(true); + expect(tools.some(tool => tool.name === "artifact_guide")).toBe(true); + const guide = await client.callTool({ name: "artifact_guide", arguments: {} }); expect(guide.isError).not.toBe(true); const guideText = (guide.content as { text: string }[])[0]!.text; - expect(guideText).toContain("sidequery/canvas"); + expect(guideText).toContain("sidequery/artifacts"); expect(tools.find(tool => tool.name === "script_guide")?.annotations?.readOnlyHint).toBe(true); const scriptGuide = await client.callTool({ name: "script_guide", arguments: {} }); expect(scriptGuide.isError).not.toBe(true); expect((scriptGuide.content as { text: string }[])[0]!.text).toContain("Third-party dependencies"); - expect((tools.find(tool => tool.name === "canvas_open")!.inputSchema.properties!.target as { enum: string[] }).enum).toEqual(["inline"]); - const result = await client.callTool({ name: "canvas_write", arguments: { name: "overview", contents: source } }); + expect((tools.find(tool => tool.name === "artifact_open")!.inputSchema.properties!.target as { enum: string[] }).enum).toEqual(["inline"]); + const result = await client.callTool({ name: "artifact_write", arguments: { name: "overview", contents: source } }); expect(result.isError).not.toBe(true); expect(payload(result)).toMatchObject({ applied: true, ok: true }); - const canvas = (result._meta as { canvas: { js: string; versionId: string } }).canvas; - expect(canvas.js).toContain("Hosted canvas"); - expect(JSON.stringify(result.content)).not.toContain("__herdrCanvasRuntime"); - version = canvas.versionId; - const resource = await client.readResource({ uri: "ui://canvas/viewer.html" }); + const artifact = (result._meta as { artifact: { js: string; versionId: string } }).artifact; + expect(artifact.js).toContain("Hosted artifact"); + expect(JSON.stringify(result.content)).not.toContain("__artifactsRuntime"); + version = artifact.versionId; + const resource = await client.readResource({ uri: "ui://artifacts/viewer.html" }); expect(resource.contents[0]!.mimeType).toContain("text/html"); - expect(payload(await client.callTool({ name: "canvas_read", arguments: { name: "overview" } })).source).toBe(source); - const failedEdit = await client.callTool({ name: "canvas_edit", arguments: { name: "overview", expected_hash: "0".repeat(64), edits: [{ old_text: "Hosted canvas", new_text: "Wrong" }] } }); + expect(payload(await client.callTool({ name: "artifact_read", arguments: { name: "overview" } })).source).toBe(source); + const failedEdit = await client.callTool({ name: "artifact_edit", arguments: { name: "overview", expected_hash: "0".repeat(64), edits: [{ old_text: "Hosted artifact", new_text: "Wrong" }] } }); expect(failedEdit.isError).toBe(true); - const edit = await client.callTool({ name: "canvas_edit", arguments: { name: "overview", edits: [{ old_text: "Hosted canvas", new_text: "Changed canvas" }] } }); + const edit = await client.callTool({ name: "artifact_edit", arguments: { name: "overview", edits: [{ old_text: "Hosted artifact", new_text: "Changed artifact" }] } }); expect(payload(edit).ok).toBe(true); - const restored = await client.callTool({ name: "canvas_restore", arguments: { version_id: version } }); + const restored = await client.callTool({ name: "artifact_restore", arguments: { version_id: version } }); expect(payload(restored)).toMatchObject({ restored: true, ok: true }); - const historical = payload(await client.callTool({ name: "canvas_version", arguments: { version_id: version } })); + const historical = payload(await client.callTool({ name: "artifact_version", arguments: { version_id: version } })); expect(historical.source).toBe(source); - expect(payload(await client.callTool({ name: "canvas_history", arguments: { name: "overview" } })).versions.length).toBeGreaterThanOrEqual(3); - expect(payload(await client.callTool({ name: "canvas_history", arguments: { name: "overview", offset: 100 } }))).toMatchObject({ versions: [], next_offset: null }); - expect(payload(await client.callTool({ name: "canvas_list", arguments: { offset: 100 } }))).toMatchObject({ canvases: [], next_offset: null }); - expect(payload(await client.callTool({ name: "canvas_version", arguments: { version_id: version, events_offset: 100 } }))).toMatchObject({ events: [], events_offset: 100, next_events_offset: null }); - await expect(client.callTool({ name: "canvas_write", arguments: { name: "bad" } })).rejects.toThrow("Invalid tool arguments"); - await expect(client.callTool({ name: "canvas_open", arguments: { name: "overview", target: "herdr" } })).rejects.toThrow("Invalid tool arguments"); + expect(payload(await client.callTool({ name: "artifact_history", arguments: { name: "overview" } })).versions.length).toBeGreaterThanOrEqual(3); + expect(payload(await client.callTool({ name: "artifact_history", arguments: { name: "overview", offset: 100 } }))).toMatchObject({ versions: [], next_offset: null }); + expect(payload(await client.callTool({ name: "artifact_list", arguments: { offset: 100 } }))).toMatchObject({ artifacts: [], next_offset: null }); + expect(payload(await client.callTool({ name: "artifact_version", arguments: { version_id: version, events_offset: 100 } }))).toMatchObject({ events: [], events_offset: 100, next_events_offset: null }); + await expect(client.callTool({ name: "artifact_write", arguments: { name: "bad" } })).rejects.toThrow("Invalid tool arguments"); + await expect(client.callTool({ name: "artifact_open", arguments: { name: "overview", target: "herdr" } })).rejects.toThrow("Invalid tool arguments"); }, 60000); test("plugin HTTP and MCP routes expose catalog hints and validate requests", async () => { const tools = (await client.listTools()).tools; for (const name of ["plugins_list", "plugin_guide"]) expect(tools.find(tool => tool.name === name)?.annotations?.readOnlyHint).toBe(true); - expect(tools.find(tool => tool.name === "canvas_plugin_call")?.annotations?.readOnlyHint).toBe(false); + expect(tools.find(tool => tool.name === "artifact_plugin_call")?.annotations?.readOnlyHint).toBe(false); const list = await client.callTool({ name: "plugins_list", arguments: {} }); expect(list.isError).not.toBe(true); expect(Array.isArray((list.structuredContent as { plugins: unknown[] }).plugins)).toBe(true); expect((await client.callTool({ name: "plugin_guide", arguments: {} })).isError).not.toBe(true); const call = { plugin: "missing-plugin-for-test", operation: "read", input: {} }; - const missing = await client.callTool({ name: "canvas_plugin_call", arguments: call }); + const missing = await client.callTool({ name: "artifact_plugin_call", arguments: call }); expect(missing.isError).toBe(true); expect(JSON.stringify(missing)).toContain("Plugin operation not found"); - await expect(client.callTool({ name: "canvas_plugin_call", arguments: { ...call, library: "team" } })).rejects.toThrow("Invalid tool arguments"); + await expect(client.callTool({ name: "artifact_plugin_call", arguments: { ...call, library: "team" } })).rejects.toThrow("Invalid tool arguments"); for (const library of ["private", "team"]) { const response = await fetch(`${origin}/api/plugins/call?library=${library}`, { method: "POST", body: JSON.stringify(call) }); expect(response.status).toBe(404); @@ -137,21 +137,21 @@ test("plugin HTTP and MCP routes expose catalog hints and validate requests", as }); test("invalid drafts remain readable with diagnostics and no preview", async () => { - const result = await client.callTool({ name: "canvas_write", arguments: { name: "invalid", contents: 'const n: number = "bad"; export default function Canvas() { return
{n}
; }' } }); + const result = await client.callTool({ name: "artifact_write", arguments: { name: "invalid", contents: 'const n: number = "bad"; export default function Artifact() { return
{n}
; }' } }); expect(result.isError).toBe(true); expect(payload(result)).toMatchObject({ applied: true, ok: false }); expect(result._meta).toBeUndefined(); - expect(payload(await client.callTool({ name: "canvas_read", arguments: { name: "invalid" } })).source).toContain('"bad"'); + expect(payload(await client.callTool({ name: "artifact_read", arguments: { name: "invalid" } })).source).toContain('"bad"'); }, 30000); -test("native canvas SQLite works through MCP and the gallery, persists across code changes, and restores paired source", async () => { - const written = await client.callTool({ name: "canvas_write", arguments: { name: "counter", contents: counterClient, server: counterServer } }); +test("native artifact SQLite works through MCP and the gallery, persists across code changes, and restores paired source", async () => { + const written = await client.callTool({ name: "artifact_write", arguments: { name: "counter", contents: counterClient, server: counterServer } }); expect(written.isError).not.toBe(true); - const savedVersion = (written._meta as { canvas: { versionId: string; server: boolean } }).canvas; + const savedVersion = (written._meta as { artifact: { versionId: string; server: boolean } }).artifact; expect(savedVersion.server).toBe(true); expect(JSON.stringify(written.content)).not.toContain("create table"); const callCounter = async (method = "GET", name = "counter") => { - const result = await client.callTool({ name: "canvas_request", arguments: { name, request: { path: "/counter", method } } }); + const result = await client.callTool({ name: "artifact_request", arguments: { name, request: { path: "/counter", method } } }); expect(result.isError).not.toBe(true); const response = (result.structuredContent as { response: { status: number; body: string } }).response; expect(response.status).toBe(200); @@ -160,27 +160,27 @@ test("native canvas SQLite works through MCP and the gallery, persists across co expect((await callCounter()).value).toBe(0); expect((await callCounter("POST")).value).toBe(1); expect((await callCounter("POST")).lastUpdated).toBeString(); - await client.callTool({ name: "canvas_write", arguments: { name: "other-counter", contents: counterClient, server: counterServer } }); + await client.callTool({ name: "artifact_write", arguments: { name: "other-counter", contents: counterClient, server: counterServer } }); expect((await callCounter("GET", "other-counter")).value).toBe(0); const otherWorkspace = new Client({ name: "other-workspace", version: "1" }); try { await otherWorkspace.connect(new StreamableHTTPClientTransport(new URL(`${origin}/mcp?workspace=other`))); - expect((await otherWorkspace.callTool({ name: "canvas_write", arguments: { name: "counter", contents: counterClient, server: counterServer } })).isError).not.toBe(true); - const isolated = await otherWorkspace.callTool({ name: "canvas_request", arguments: { name: "counter", request: { path: "/counter" } } }); + expect((await otherWorkspace.callTool({ name: "artifact_write", arguments: { name: "counter", contents: counterClient, server: counterServer } })).isError).not.toBe(true); + const isolated = await otherWorkspace.callTool({ name: "artifact_request", arguments: { name: "counter", request: { path: "/counter" } } }); expect(isolated.isError).not.toBe(true); expect(JSON.parse(atob((isolated.structuredContent as { response: { body: string } }).response.body)).value).toBe(0); } finally { await otherWorkspace.close(); } - const edited = await client.callTool({ name: "canvas_edit", arguments: { name: "counter", part: "server", edits: [{ old_text: "value: row.value", new_text: "value: row.value, generation: 2" }] } }); + const edited = await client.callTool({ name: "artifact_edit", arguments: { name: "counter", part: "server", edits: [{ old_text: "value: row.value", new_text: "value: row.value, generation: 2" }] } }); expect(edited.isError).not.toBe(true); expect(await callCounter()).toMatchObject({ value: 2, generation: 2 }); - const restored = await client.callTool({ name: "canvas_restore", arguments: { version_id: savedVersion.versionId } }); + const restored = await client.callTool({ name: "artifact_restore", arguments: { version_id: savedVersion.versionId } }); expect(restored.isError).not.toBe(true); expect(await callCounter()).toMatchObject({ value: 2 }); expect((await callCounter()).generation).toBeUndefined(); - const serverRead = payload(await client.callTool({ name: "canvas_read", arguments: { name: "counter", part: "server", start_line: 1, end_line: 1 } })); + const serverRead = payload(await client.callTool({ name: "artifact_read", arguments: { name: "counter", part: "server", start_line: 1, end_line: 1 } })); expect(serverRead.source).toBe('import { DurableObject } from "cloudflare:workers";\n'); expect(serverRead.server_source).toBeUndefined(); - const mismatch = await client.callTool({ name: "canvas_request", arguments: { name: "other-counter", version_id: savedVersion.versionId, request: { path: "/counter" } } }); + const mismatch = await client.callTool({ name: "artifact_request", arguments: { name: "other-counter", version_id: savedVersion.versionId, request: { path: "/counter" } } }); expect(mismatch.isError).toBe(true); const browser = await chromium.launch({ headless: true }); try { @@ -220,7 +220,7 @@ test("gallery renders interactive sandboxed previews and serves exact archived s await page.goto(`${origin}/?workspace=test`); await page.getByRole("button", { name: "overview", exact: true }).click(); const frame = page.frameLocator("iframe"); - await frame.getByRole("heading", { name: "Hosted canvas" }).waitFor(); + await frame.getByRole("heading", { name: "Hosted artifact" }).waitFor(); await frame.getByRole("button", { name: "Count 0" }).click(); await frame.getByRole("button", { name: "Count 1" }).waitFor(); expect(await page.locator("iframe").getAttribute("sandbox")).toBe("allow-scripts"); @@ -231,16 +231,16 @@ test("gallery renders interactive sandboxed previews and serves exact archived s test("all hosted surfaces fail closed off loopback, and mutations enforce origin and request bounds", async () => { expect((await fetch(`${origin}/api/gallery`, { headers: { Origin: "https://evil.example" } })).status).toBe(403); - expect((await fetch(`${origin}/api/canvas/request`, { method: "POST", headers: { Origin: "https://evil.example" }, body: "{}" })).status).toBe(403); - expect((await fetch(`${origin}/api/canvas/request`, { method: "POST", body: "{}" })).status).toBe(400); - expect((await fetch(`${origin}/api/canvas/request`, { method: "POST", body: " ".repeat(1024 * 1024 + 1) })).status).toBe(413); + expect((await fetch(`${origin}/api/artifact/request`, { method: "POST", headers: { Origin: "https://evil.example" }, body: "{}" })).status).toBe(403); + expect((await fetch(`${origin}/api/artifact/request`, { method: "POST", body: "{}" })).status).toBe(400); + expect((await fetch(`${origin}/api/artifact/request`, { method: "POST", body: " ".repeat(1024 * 1024 + 1) })).status).toBe(413); expect((await fetch(`${origin}/mcp`, { method: "POST", body: "invalid" })).status).toBe(400); expect((await fetch(`${origin}/mcp`, { method: "POST", body: " ".repeat(1024 * 1024 + 1) })).status).toBe(413); expect((await fetch(`${origin}/api/source?workspace=other&version=${version}`)).status).toBe(404); const production = new Miniflare({ cf: false, port: 0, workers: [{ ...runtimeOptions.workers![0]!, config: { ...runtimeOptions.workers![0]!.config, env: { ...runtimeOptions.workers![0]!.config.env, ENVIRONMENT: { type: "text", value: "production" } } } }] }); try { - for (const path of ["/", "/index.html", "/gallery.js", "/mcp", "/api/gallery", "/api/source", "/gallery/preview", "/api/canvas/request", "/api/plugins/call"]) { - expect((await production.dispatchFetch(`https://canvas.example${path}`)).status).toBe(503); + for (const path of ["/", "/index.html", "/gallery.js", "/mcp", "/api/gallery", "/api/source", "/gallery/preview", "/api/artifact/request", "/api/plugins/call"]) { + expect((await production.dispatchFetch(`https://artifact.example${path}`)).status).toBe(503); } } finally { await production.dispose(); } }); @@ -250,9 +250,9 @@ test("verified users have isolated private libraries and can collaborate in the const { Response: RuntimeResponse } = await import("miniflare"); const keys = await generateKeyPair("RS256", { extractable: true }); const jwks = { keys: [{ ...await exportJWK(keys.publicKey), kid: "test", alg: "RS256", use: "sig" }] }; - const issuer = "https://canvas-test.cloudflareaccess.com"; + const issuer = "https://artifact-test.cloudflareaccess.com"; const sign = (sub: string) => new SignJWT({}).setProtectedHeader({ alg: "RS256", kid: "test" }) - .setSubject(sub).setIssuer(issuer).setAudience("canvas-app").setIssuedAt().setExpirationTime("5m").sign(keys.privateKey); + .setSubject(sub).setIssuer(issuer).setAudience("artifact-app").setIssuedAt().setExpirationTime("5m").sign(keys.privateKey); const aliceToken = await sign("alice"); const bobToken = await sign("bob"); const production = new Miniflare({ cf: false, port: 0, workers: [{ @@ -260,8 +260,8 @@ test("verified users have isolated private libraries and can collaborate in the ...runtimeOptions.workers![0]!.config, env: { ...runtimeOptions.workers![0]!.config.env, ENVIRONMENT: { type: "text", value: "production" }, - ACCESS_TEAM_DOMAIN: { type: "text", value: "canvas-test.cloudflareaccess.com" }, - ACCESS_AUD: { type: "text", value: "canvas-app" }, + ACCESS_TEAM_DOMAIN: { type: "text", value: "artifact-test.cloudflareaccess.com" }, + ACCESS_AUD: { type: "text", value: "artifact-app" }, }, }, dev: { outboundService: { type: "fetcher", handler: request => { @@ -284,7 +284,7 @@ test("verified users have isolated private libraries and can collaborate in the const bob = await connect(bobToken, "private"); const teamAlice = await connect(aliceToken, "team"); const teamBob = await connect(bobToken, "team"); - expect((await alice.callTool({ name: "canvas_write", arguments: { name: "private-api", slug: "private-api", contents: source, server: apiServer } })).isError).not.toBe(true); + expect((await alice.callTool({ name: "artifact_write", arguments: { name: "private-api", slug: "private-api", contents: source, server: apiServer } })).isError).not.toBe(true); expect((await fetch(`${address}/private-api/api/headers`)).status).toBe(401); expect((await fetch(`${address}/private-api/api/headers`, { headers: { "Cf-Access-Jwt-Assertion": bobToken } })).status).toBe(404); expect((await fetch(`${address}/private-api/api/headers`, { headers: { "Cf-Access-Jwt-Assertion": aliceToken, Origin: "https://evil.example" } })).status).toBe(403); @@ -293,32 +293,32 @@ test("verified users have isolated private libraries and can collaborate in the const privateHeaders = await privateApi.json() as Record; expect(privateHeaders["x-kept"]).toBe("yes"); for (const name of ["authorization", "cookie", "cf-access-jwt-assertion", "cf-access-client-id", "cf-access-client-secret"]) expect(privateHeaders[name]).toBeUndefined(); - const privateResult = await alice.callTool({ name: "canvas_write", arguments: { name: "secret", contents: source } }); + const privateResult = await alice.callTool({ name: "artifact_write", arguments: { name: "secret", contents: source } }); expect(privateResult.isError).not.toBe(true); - const privateVersion = (privateResult._meta as { canvas: { versionId: string } }).canvas.versionId; - expect(payload(await bob.callTool({ name: "canvas_list", arguments: {} })).canvases).toEqual([]); + const privateVersion = (privateResult._meta as { artifact: { versionId: string } }).artifact.versionId; + expect(payload(await bob.callTool({ name: "artifact_list", arguments: {} })).artifacts).toEqual([]); for (const other of [bob, teamAlice, teamBob]) { - expect((await other.callTool({ name: "canvas_read", arguments: { name: "secret" } })).isError).toBe(true); - expect((await other.callTool({ name: "canvas_version", arguments: { version_id: privateVersion } })).isError).toBe(true); - expect((await other.callTool({ name: "canvas_restore", arguments: { version_id: privateVersion } })).isError).toBe(true); + expect((await other.callTool({ name: "artifact_read", arguments: { name: "secret" } })).isError).toBe(true); + expect((await other.callTool({ name: "artifact_version", arguments: { version_id: privateVersion } })).isError).toBe(true); + expect((await other.callTool({ name: "artifact_restore", arguments: { version_id: privateVersion } })).isError).toBe(true); } - const shared = await teamAlice.callTool({ name: "canvas_write", arguments: { name: "shared", contents: source } }); + const shared = await teamAlice.callTool({ name: "artifact_write", arguments: { name: "shared", contents: source } }); expect(shared.isError).not.toBe(true); - expect(payload(await teamBob.callTool({ name: "canvas_read", arguments: { name: "shared" } })).source).toBe(source); - expect((await teamBob.callTool({ name: "canvas_edit", arguments: { name: "shared", edits: [{ old_text: "Hosted canvas", new_text: "Team canvas" }] } })).isError).not.toBe(true); - expect(payload(await teamAlice.callTool({ name: "canvas_read", arguments: { name: "shared" } })).source).toContain("Team canvas"); - expect((await alice.callTool({ name: "canvas_read", arguments: { name: "shared" } })).isError).toBe(true); + expect(payload(await teamBob.callTool({ name: "artifact_read", arguments: { name: "shared" } })).source).toBe(source); + expect((await teamBob.callTool({ name: "artifact_edit", arguments: { name: "shared", edits: [{ old_text: "Hosted artifact", new_text: "Team artifact" }] } })).isError).not.toBe(true); + expect(payload(await teamAlice.callTool({ name: "artifact_read", arguments: { name: "shared" } })).source).toContain("Team artifact"); + expect((await alice.callTool({ name: "artifact_read", arguments: { name: "shared" } })).isError).toBe(true); for (const path of [`/api/source?version=${privateVersion}`, `/gallery/preview?version=${privateVersion}`, `/api/source?name=secret&subject=alice`, `/api/source?library=team&version=${privateVersion}`]) { expect((await fetch(address + path + "&workspace=test", { headers: { "Cf-Access-Jwt-Assertion": bobToken } })).status).toBe(404); } expect((await fetch(`${address}/api/gallery?library=alice`, { headers: { "Cf-Access-Jwt-Assertion": bobToken } })).status).toBe(400); - // The same canvas name must identify different databases across private + // The same artifact name must identify different databases across private // subjects, while both team members operate on one shared database. for (const remote of [alice, bob, teamAlice]) { - expect((await remote.callTool({ name: "canvas_write", arguments: { name: "database", contents: counterClient, server: counterServer } })).isError).not.toBe(true); + expect((await remote.callTool({ name: "artifact_write", arguments: { name: "database", contents: counterClient, server: counterServer } })).isError).not.toBe(true); } const files = async (remote: Client, request: Record) => { - const result = await remote.callTool({ name: "canvas_files", arguments: { name: "database", request } }); + const result = await remote.callTool({ name: "artifact_files", arguments: { name: "database", request } }); expect(result.isError, JSON.stringify(result.content)).not.toBe(true); return (result.structuredContent as { result: any }).result; }; @@ -328,17 +328,17 @@ test("verified users have isolated private libraries and can collaborate in the expect((await files(alice, { operation: "list" })).files).toHaveLength(1); expect((await files(bob, { operation: "list" })).files).toEqual([]); expect((await files(teamAlice, { operation: "list" })).files).toEqual([]); - expect((await bob.callTool({ name: "canvas_files", arguments: { name: "database", request: { operation: "download", id: privateUpload.file.id } } })).isError).toBe(true); + expect((await bob.callTool({ name: "artifact_files", arguments: { name: "database", request: { operation: "download", id: privateUpload.file.id } } })).isError).toBe(true); const teamUpload = await files(teamAlice, { operation: "upload", name: "team.txt", size: 3, type: "text/plain" }); expect((await fetch(teamUpload.url, { method: "PUT", body: "two" })).status).toBe(201); expect((await files(teamBob, { operation: "list" })).files[0].id).toBe(teamUpload.file.id); - const otherWorkspace = await fetch(`${address}/api/tools?workspace=other`, { method: "POST", headers: { "Cf-Access-Jwt-Assertion": aliceToken }, body: JSON.stringify({ name: "canvas_write", arguments: { name: "database", contents: source } }) }); + const otherWorkspace = await fetch(`${address}/api/tools?workspace=other`, { method: "POST", headers: { "Cf-Access-Jwt-Assertion": aliceToken }, body: JSON.stringify({ name: "artifact_write", arguments: { name: "database", contents: source } }) }); expect(otherWorkspace.status).toBe(200); - const otherFiles = await fetch(`${address}/api/canvas/files?workspace=other`, { method: "POST", headers: { "Cf-Access-Jwt-Assertion": aliceToken }, body: JSON.stringify({ name: "database", request: { operation: "list" } }) }); + const otherFiles = await fetch(`${address}/api/artifact/files?workspace=other`, { method: "POST", headers: { "Cf-Access-Jwt-Assertion": aliceToken }, body: JSON.stringify({ name: "database", request: { operation: "list" } }) }); expect(await otherFiles.json()).toEqual({ result: { files: [] } }); - expect((await fetch(`${address}/api/canvas/files?workspace=test`, { method: "POST", body: JSON.stringify({ name: "database", request: { operation: "list" } }) })).status).toBe(401); + expect((await fetch(`${address}/api/artifact/files?workspace=test`, { method: "POST", body: JSON.stringify({ name: "database", request: { operation: "list" } }) })).status).toBe(401); const database = async (remote: Client, method = "GET") => { - const result = await remote.callTool({ name: "canvas_request", arguments: { name: "database", request: { path: "/counter", method } } }); + const result = await remote.callTool({ name: "artifact_request", arguments: { name: "database", request: { path: "/counter", method } } }); expect(result.isError).not.toBe(true); const response = (result.structuredContent as { response: { status: number; body: string } }).response; expect(response.status).toBe(200); @@ -352,7 +352,7 @@ test("verified users have isolated private libraries and can collaborate in the expect((await database(teamAlice)).value).toBe(2); expect((await database(alice)).value).toBe(1); expect((await database(bob)).value).toBe(0); - expect((await bob.callTool({ name: "canvas_request", arguments: { version_id: privateVersion, request: { path: "/counter" } } })).isError).toBe(true); + expect((await bob.callTool({ name: "artifact_request", arguments: { version_id: privateVersion, request: { path: "/counter" } } })).isError).toBe(true); const script = 'export default {fetch(request: Request) { return Response.json({message:"owner script",authorization:request.headers.get("authorization")}); }}'; expect((await alice.callTool({name:"script_write",arguments:{name:"private-handler",slug:"private-handler",contents:script}})).isError).not.toBe(true); expect((await fetch(address+"/private-handler")).status).toBe(401); @@ -369,27 +369,27 @@ test("verified users have isolated private libraries and can collaborate in the expect(external.status).toBe(200); expect(await external.json()).toMatchObject({authorization:"Bearer webhook-token"}); expect((await fetch(address+"/api/gallery")).status).toBe(401); - expect((await alice.callTool({name:"artifact_link",arguments:{kind:"canvas",name:"secret",slug:"private-canvas"}})).isError).not.toBe(true); - expect((await fetch(address+"/private-canvas")).status).toBe(401); - expect((await fetch(address+"/private-canvas",{headers:{"Cf-Access-Jwt-Assertion":bobToken}})).status).toBe(404); - expect((await fetch(address+"/private-canvas",{headers:{"Cf-Access-Jwt-Assertion":aliceToken}})).status).toBe(200); + expect((await alice.callTool({name:"artifact_link",arguments:{kind:"artifact",name:"secret",slug:"private-artifact"}})).isError).not.toBe(true); + expect((await fetch(address+"/private-artifact")).status).toBe(401); + expect((await fetch(address+"/private-artifact",{headers:{"Cf-Access-Jwt-Assertion":bobToken}})).status).toBe(404); + expect((await fetch(address+"/private-artifact",{headers:{"Cf-Access-Jwt-Assertion":aliceToken}})).status).toBe(200); const pluginBody = JSON.stringify({ plugin: "missing-plugin-for-test", operation: "read", input: {} }); - expect((await fetch(address+"/private-canvas/_canvas/plugins",{method:"POST",body:pluginBody})).status).toBe(401); - expect((await fetch(address+"/private-canvas/_canvas/plugins",{method:"POST",headers:{"Cf-Access-Jwt-Assertion":bobToken},body:pluginBody})).status).toBe(404); - const privatePlugin = await fetch(address+"/private-canvas/_canvas/plugins",{method:"POST",headers:{"Cf-Access-Jwt-Assertion":aliceToken},body:pluginBody}); + expect((await fetch(address+"/private-artifact/_artifact/plugins",{method:"POST",body:pluginBody})).status).toBe(401); + expect((await fetch(address+"/private-artifact/_artifact/plugins",{method:"POST",headers:{"Cf-Access-Jwt-Assertion":bobToken},body:pluginBody})).status).toBe(404); + const privatePlugin = await fetch(address+"/private-artifact/_artifact/plugins",{method:"POST",headers:{"Cf-Access-Jwt-Assertion":aliceToken},body:pluginBody}); expect(privatePlugin.status).toBe(404); expect(await privatePlugin.json()).toEqual({error:"Plugin operation not found"}); - expect((await alice.callTool({name:"canvas_write",arguments:{name:"secret",slug:"private-canvas",access:"public",contents:"export default function {"}})).isError).toBe(true); - expect((await fetch(address+"/private-canvas")).status).toBe(401); - expect((await alice.callTool({name:"artifact_link",arguments:{kind:"canvas",name:"secret",slug:"private-canvas",access:"public"}})).isError).not.toBe(true); - expect((await fetch(address+"/private-canvas")).status).toBe(200); + expect((await alice.callTool({name:"artifact_write",arguments:{name:"secret",slug:"private-artifact",access:"public",contents:"export default function {"}})).isError).toBe(true); + expect((await fetch(address+"/private-artifact")).status).toBe(401); + expect((await alice.callTool({name:"artifact_link",arguments:{kind:"artifact",name:"secret",slug:"private-artifact",access:"public"}})).isError).not.toBe(true); + expect((await fetch(address+"/private-artifact")).status).toBe(200); for (const headers of [{}, {"Cf-Access-Jwt-Assertion":aliceToken}, {Cookie:"session=ambient"}]) { - expect((await fetch(address+"/private-canvas/_canvas/plugins",{method:"POST",headers,body:pluginBody})).status).toBe(401); + expect((await fetch(address+"/private-artifact/_artifact/plugins",{method:"POST",headers,body:pluginBody})).status).toBe(401); } - expect((await alice.callTool({name:"artifact_link",arguments:{kind:"canvas",name:"secret",slug:"private-canvas",access:"private"}})).isError).not.toBe(true); - expect((await fetch(address+"/private-canvas")).status).toBe(401); + expect((await alice.callTool({name:"artifact_link",arguments:{kind:"artifact",name:"secret",slug:"private-artifact",access:"private"}})).isError).not.toBe(true); + expect((await fetch(address+"/private-artifact")).status).toBe(401); // Restore a valid draft for the existing gallery assertion below. - expect((await alice.callTool({name:"canvas_write",arguments:{name:"secret",contents:source}})).isError).not.toBe(true); + expect((await alice.callTool({name:"artifact_write",arguments:{name:"secret",contents:source}})).isError).not.toBe(true); expect((await teamAlice.callTool({name:"script_write",arguments:{name:"shared-handler",slug:"shared-handler",contents:script}})).isError).not.toBe(true); expect((await fetch(address+"/shared-handler",{headers:{"Cf-Access-Jwt-Assertion":bobToken}})).status).toBe(200); expect((await alice.callTool({name:"script_write",arguments:{name:"untrusted-page",slug:"untrusted-page",access:"public",contents:"export default {fetch(){return new Response(\"
pending
\",{headers:{\"content-type\":\"text/html\"}})}}"}})).isError).not.toBe(true); @@ -401,7 +401,7 @@ test("verified users have isolated private libraries and can collaborate in the expect(await page.getByLabel("Library", { exact: true }).inputValue()).toBe("private"); await page.getByLabel("Library", { exact: true }).selectOption("team"); await page.getByRole("button", { name: "shared", exact: true }).click(); - await page.frameLocator("iframe").getByRole("heading", { name: "Team canvas" }).waitFor(); + await page.frameLocator("iframe").getByRole("heading", { name: "Team artifact" }).waitFor(); expect(await page.getByRole("button", { name: "secret", exact: true }).count()).toBe(0); expect(new URL(page.url()).searchParams.get("library")).toBe("team"); const isolated = await browser.newPage({extraHTTPHeaders:{"Cf-Access-Jwt-Assertion":aliceToken}}); @@ -472,8 +472,8 @@ test("standalone scripts serve arbitrary HTTP responses at chosen root slugs and expect(await source.text()).toBe(code); }, 60000); -test("root canvas URLs render interactive backends and keep valid revisions through broken drafts", async () => { - const saved = await client.callTool({ name: "canvas_write", arguments: { name: "linked-counter", slug: "my-counter", access: "public", contents: counterClient, server: counterServer } }); +test("root artifact URLs render interactive backends and keep valid revisions through broken drafts", async () => { + const saved = await client.callTool({ name: "artifact_write", arguments: { name: "linked-counter", slug: "my-counter", access: "public", contents: counterClient, server: counterServer } }); expect(saved.isError).not.toBe(true); expect(payload(saved).url).toBe(`${origin}/my-counter`); const browser = await chromium.launch({ headless: true }); @@ -483,21 +483,21 @@ test("root canvas URLs render interactive backends and keep valid revisions thro await page.frameLocator("iframe").getByText("Count: 0", {exact:true}).waitFor(); await page.frameLocator("iframe").getByRole("button", {name:"Increment"}).click(); await page.frameLocator("iframe").getByText("Count: 1", {exact:true}).waitFor(); - const invalid = await client.callTool({ name: "canvas_write", arguments: { name: "linked-counter", contents: "export default function {" } }); + const invalid = await client.callTool({ name: "artifact_write", arguments: { name: "linked-counter", contents: "export default function {" } }); expect(invalid.isError).toBe(true); await page.reload(); await page.frameLocator("iframe").getByText("Count: 1", {exact:true}).waitFor(); - const fixed = await client.callTool({ name: "canvas_write", arguments: { name: "linked-counter", contents: counterClient.replace("Count:", "Total:") } }); + const fixed = await client.callTool({ name: "artifact_write", arguments: { name: "linked-counter", contents: counterClient.replace("Count:", "Total:") } }); expect(fixed.isError).not.toBe(true); await page.reload(); await page.frameLocator("iframe").getByText("Total: 1", {exact:true}).waitFor(); - const stolen = await client.callTool({ name: "artifact_link", arguments: { kind:"canvas",name:"linked-counter",slug:"my-handler" } }); + const stolen = await client.callTool({ name: "artifact_link", arguments: { kind:"artifact",name:"linked-counter",slug:"my-handler" } }); expect(stolen.isError).toBe(true); } finally { await browser.close(); } - const version = (saved._meta as {canvas:{versionId:string}}).canvas.versionId; - const mismatch=await fetch(`${origin}/my-counter/_canvas/request`,{method:"POST",headers:{"content-type":"application/json"},body:JSON.stringify({version_id:version,request:{path:"/counter"}})}); + const version = (saved._meta as {artifact:{versionId:string}}).artifact.versionId; + const mismatch=await fetch(`${origin}/my-counter/_artifact/request`,{method:"POST",headers:{"content-type":"application/json"},body:JSON.stringify({version_id:version,request:{path:"/counter"}})}); expect(mismatch.status).toBe(409); - expect((await fetch(`${origin}/my-counter/_canvas/request`,{method:"POST",headers:{Origin:"https://evil.example"},body:"{}"})).status).toBe(403); + expect((await fetch(`${origin}/my-counter/_artifact/request`,{method:"POST",headers:{Origin:"https://evil.example"},body:"{}"})).status).toBe(403); },60000); test("correcting initial invalid drafts retains the user-chosen slug and access", async () => { @@ -510,18 +510,18 @@ test("correcting initial invalid drafts retains the user-chosen slug and access" expect(fixed.isError).not.toBe(true); expect(payload(fixed).url).toBe(`${origin}/chosen-human`); expect(await (await fetch(`${origin}/chosen-human`)).text()).toBe("corrected"); - const badCanvas='export default function {'; - const canvas = await client.callTool({name:"canvas_write",arguments:{name:"Human canvas",slug:"chosen-canvas",access:"public",contents:badCanvas}}); - expect(canvas.isError).toBe(true); - expect(payload(canvas)).toMatchObject({slug:"chosen-canvas",access:"public"}); - const corrected=await client.callTool({name:"canvas_edit",arguments:{name:"Human canvas",edits:[{old_text:badCanvas,new_text:source}]}}); + const badArtifact='export default function {'; + const artifact = await client.callTool({name:"artifact_write",arguments:{name:"Human artifact",slug:"chosen-artifact",access:"public",contents:badArtifact}}); + expect(artifact.isError).toBe(true); + expect(payload(artifact)).toMatchObject({slug:"chosen-artifact",access:"public"}); + const corrected=await client.callTool({name:"artifact_edit",arguments:{name:"Human artifact",edits:[{old_text:badArtifact,new_text:source}]}}); expect(corrected.isError).not.toBe(true); - expect(payload(corrected).url).toBe(`${origin}/chosen-canvas`); - expect((await fetch(`${origin}/chosen-canvas`)).status).toBe(200); + expect(payload(corrected).url).toBe(`${origin}/chosen-artifact`); + expect((await fetch(`${origin}/chosen-artifact`)).status).toBe(200); }); -test("standalone canvas APIs preserve HTTP semantics and never fall back to the page shell", async () => { - const saved = await client.callTool({ name: "canvas_write", arguments: { name: "direct-api", slug: "direct-api", access: "public", contents: source, server: apiServer } }); +test("standalone artifact APIs preserve HTTP semantics and never fall back to the page shell", async () => { + const saved = await client.callTool({ name: "artifact_write", arguments: { name: "direct-api", slug: "direct-api", access: "public", contents: source, server: apiServer } }); expect(saved.isError).not.toBe(true); const bytes = new Uint8Array([0, 255, 128, 10, 13, 42]); for (const path of ["/api", "/api/echo?value=%FF&value=two"]) { @@ -548,13 +548,13 @@ test("standalone canvas APIs preserve HTTP semantics and never fall back to the expect(await html.text()).toBe("

API page

"); expect(html.headers.get("set-cookie")).toBeNull(); expect(html.headers.get("content-security-policy")).toContain("sandbox allow-scripts allow-forms"); - expect((await client.callTool({ name: "canvas_write", arguments: { name: "direct-api", contents: source, server: "export class CanvasServer { fetch( }" } })).isError).toBe(true); + expect((await client.callTool({ name: "artifact_write", arguments: { name: "direct-api", contents: source, server: "export class ArtifactServer { fetch( }" } })).isError).toBe(true); expect((await fetch(`${origin}/direct-api/api`)).status).toBe(201); - expect((await client.callTool({ name: "canvas_write", arguments: { name: "pages-only", slug: "pages-only", access: "public", contents: source } })).isError).not.toBe(true); + expect((await client.callTool({ name: "artifact_write", arguments: { name: "pages-only", slug: "pages-only", access: "public", contents: source } })).isError).not.toBe(true); const absent = await fetch(`${origin}/pages-only/api/missing`); expect(absent.status).toBe(404); - expect(await absent.json()).toEqual({ error: "Canvas has no server" }); + expect(await absent.json()).toEqual({ error: "Artifact has no server" }); const deep = await fetch(`${origin}/pages-only/projects/42?tab=detail`); expect(deep.status).toBe(200); expect(deep.headers.get("content-type")).toContain("text/html"); @@ -564,11 +564,11 @@ test("standalone canvas APIs preserve HTTP semantics and never fall back to the expect(deepHead.headers.get("content-type")).toContain("text/html"); expect(await deepHead.text()).toBe(""); expect((await fetch(`${origin}/pages-only/projects/42`, { method: "POST" })).status).toBe(405); - for (const path of ["/_canvas", "/_canvas/unknown", "/_canvas/request/extra", "/_canvas/plugins/extra"]) expect((await fetch(`${origin}/pages-only${path}`)).status).toBe(404); + for (const path of ["/_artifact", "/_artifact/unknown", "/_artifact/request/extra", "/_artifact/plugins/extra"]) expect((await fetch(`${origin}/pages-only${path}`)).status).toBe(404); }, 60000); -test("standalone nested canvas pages restore browser routes on direct load and refresh", async () => { - expect((await client.callTool({ name: "canvas_write", arguments: { name: "linked-routes", slug: "linked-routes", access: "public", contents: ROUTING_CANVAS } })).isError).not.toBe(true); +test("standalone nested artifact pages restore browser routes on direct load and refresh", async () => { + expect((await client.callTool({ name: "artifact_write", arguments: { name: "linked-routes", slug: "linked-routes", access: "public", contents: ROUTING_ARTIFACT } })).isError).not.toBe(true); const browser = await chromium.launch({ headless: true }); try { const page = await browser.newPage(); @@ -587,17 +587,17 @@ test("standalone nested canvas pages restore browser routes on direct load and r } finally { await browser.close(); } }, 60000); -test("canvas files use per-canvas storage through MCP and standalone controls", async () => { +test("artifact files use per-artifact storage through MCP and standalone controls", async () => { const write = async (name: string) => { - const result = await client.callTool({ name: "canvas_write", arguments: { name, slug: name, contents: source } }); + const result = await client.callTool({ name: "artifact_write", arguments: { name, slug: name, contents: source } }); expect(result.isError, JSON.stringify(result.content)).not.toBe(true); - return (result._meta as { canvas: { versionId: string; files: boolean } }).canvas; + return (result._meta as { artifact: { versionId: string; files: boolean } }).artifact; }; const a = await write("files-a"); const b = await write("files-b"); expect(a.files).toBe(true); const request = async (version: string, value: Record) => { - const response = await client.callTool({ name: "canvas_files", arguments: { version_id: version, request: value } }); + const response = await client.callTool({ name: "artifact_files", arguments: { version_id: version, request: value } }); expect(response.isError, JSON.stringify(response.content)).not.toBe(true); return (response.structuredContent as { result: any }).result; }; @@ -608,28 +608,28 @@ test("canvas files use per-canvas storage through MCP and standalone controls", expect((await request(b.versionId, { operation: "list" })).files).toEqual([]); const download = await request(a.versionId, { operation: "download", id: grant.file.id }); expect(Buffer.from(await (await fetch(download.url)).arrayBuffer())).toEqual(bytes); - const wrongCanvas = await client.callTool({ name: "canvas_files", arguments: { name: "files-b", version_id: a.versionId, request: { operation: "list" } } }); - expect(wrongCanvas.isError).toBe(true); - const otherFile = await client.callTool({ name: "canvas_files", arguments: { version_id: b.versionId, request: { operation: "download", id: grant.file.id } } }); + const wrongArtifact = await client.callTool({ name: "artifact_files", arguments: { name: "files-b", version_id: a.versionId, request: { operation: "list" } } }); + expect(wrongArtifact.isError).toBe(true); + const otherFile = await client.callTool({ name: "artifact_files", arguments: { version_id: b.versionId, request: { operation: "download", id: grant.file.id } } }); expect(otherFile.isError).toBe(true); const renamed = await write("files-a"); expect((await request(renamed.versionId, { operation: "list" })).files[0].id).toBe(grant.file.id); - expect((await client.callTool({ name: "artifact_link", arguments: { kind: "canvas", name: "files-a", slug: "files-public", access: "public" } })).isError).not.toBe(true); - const publicRequest = (value: Record, version_id = renamed.versionId) => fetch(`${origin}/files-public/_canvas/files`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ version_id, request: value }) }); + expect((await client.callTool({ name: "artifact_link", arguments: { kind: "artifact", name: "files-a", slug: "files-public", access: "public" } })).isError).not.toBe(true); + const publicRequest = (value: Record, version_id = renamed.versionId) => fetch(`${origin}/files-public/_artifact/files`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ version_id, request: value }) }); expect((await publicRequest({ operation: "list" })).status).toBe(200); expect((await publicRequest({ operation: "download", id: grant.file.id })).status).toBe(200); expect((await publicRequest({ operation: "upload", name: "bad", size: 1, type: "text/plain" })).status).toBe(403); expect((await publicRequest({ operation: "delete", id: grant.file.id })).status).toBe(403); expect((await publicRequest({ operation: "list" }, b.versionId)).status).toBe(409); - const forged = await fetch(`${origin}/api/canvas/files?workspace=test`, { method: "POST", headers: { Origin: "null" }, body: JSON.stringify({ name: "files-a", request: { operation: "list" } }) }); + const forged = await fetch(`${origin}/api/artifact/files?workspace=test`, { method: "POST", headers: { Origin: "null" }, body: JSON.stringify({ name: "files-a", request: { operation: "list" } }) }); expect(forged.status).toBe(403); - const resource = await client.readResource({ uri: "ui://canvas/viewer.html" }); + const resource = await client.readResource({ uri: "ui://artifacts/viewer.html" }); expect(JSON.stringify(resource)).toContain(`\"connectDomains\":[\"${origin}\"]`); }, 60_000); test("gallery and standalone file uploads and downloads work outside the sandboxed frame", async () => { - const contents = await Bun.file(new URL("../examples/files.canvas.tsx", import.meta.url)).text(); - const created = await client.callTool({ name: "canvas_write", arguments: { name: "browser-files", slug: "browser-files", contents } }); + const contents = await Bun.file(new URL("../examples/files.artifact.tsx", import.meta.url)).text(); + const created = await client.callTool({ name: "artifact_write", arguments: { name: "browser-files", slug: "browser-files", contents } }); expect(created.isError, JSON.stringify(created.content)).not.toBe(true); const browser = await chromium.launch({ headless: true }); try { @@ -638,7 +638,7 @@ test("gallery and standalone file uploads and downloads work outside the sandbox page.on("pageerror", error => errors.push(error.message)); await page.goto(`${origin}/browser-files`); const frame = page.frameLocator("iframe"); - await frame.getByRole("heading", { name: "Canvas files" }).waitFor(); + await frame.getByRole("heading", { name: "Artifact files" }).waitFor(); const bytes = Buffer.alloc(2 * 1024 * 1024, 193); await frame.getByLabel("Upload file").setInputFiles({ name: "browser.bin", mimeType: "application/octet-stream", buffer: bytes }); const downloadButton = frame.getByRole("button", { name: "Download browser.bin", exact: true }); @@ -665,12 +665,12 @@ test("gallery and standalone file uploads and downloads work outside the sandbox } finally { await browser.close(); } }, 90_000); test("hosted remix tools create private independent artifacts and reject overwrites", async () => { - const original = payload(await client.callTool({name:"canvas_write",arguments:{name:"remix-original",contents:counterClient,server:counterServer,slug:"remix-original",access:"public"}})); + const original = payload(await client.callTool({name:"artifact_write",arguments:{name:"remix-original",contents:counterClient,server:counterServer,slug:"remix-original",access:"public"}})); expect(original.ok).toBe(true); - const copied = payload(await client.callTool({name:"canvas_remix",arguments:{name:"remix-original",new_name:"remix-copy"}})); + const copied = payload(await client.callTool({name:"artifact_remix",arguments:{name:"remix-original",new_name:"remix-copy"}})); expect(copied).toMatchObject({ok:true,remixed:true,name:"remix-copy",access:"private",slug:"remix-copy"}); expect(copied.origin.source_version_id).toBeString(); - expect((await client.callTool({name:"canvas_remix",arguments:{name:"remix-original",new_name:"remix-copy"}})).isError).toBe(true); + expect((await client.callTool({name:"artifact_remix",arguments:{name:"remix-original",new_name:"remix-copy"}})).isError).toBe(true); const script='export default {fetch(request: Request,env: ScriptEnv) { env.sql.exec("create table if not exists counter(n integer)"); env.sql.exec("insert into counter values (1)"); return Response.json({count:env.sql.exec("select count(*) as n from counter").one().n,secret:env.secrets.TOKEN ?? null}); }}'; expect(payload(await client.callTool({name:"script_write",arguments:{name:"remix-script",contents:script,access:"public"}})).ok).toBe(true); await client.callTool({name:"script_secrets",arguments:{name:"remix-script",secrets:{TOKEN:"original-only"}}}); @@ -686,33 +686,33 @@ test("hosted remix tools create private independent artifacts and reject overwri expect((await client.callTool({name:"script_remix",arguments:{name:"remix-script",new_name:"remix-script-copy"}})).isError).toBe(true); }, 120000); -test("canvas schedules retain validated revisions, keep host history isolated, and pause on server removal", async () => { +test("artifact schedules retain validated revisions, keep host history isolated, and pause on server removal", async () => { const name = "scheduled-counter"; - const write = await client.callTool({ name: "canvas_write", arguments: { name, contents: counterClient, server: counterServer } }); + const write = await client.callTool({ name: "artifact_write", arguments: { name, contents: counterClient, server: counterServer } }); expect(write.isError).not.toBe(true); - const version = (write._meta as { canvas: { versionId: string } }).canvas.versionId; + const version = (write._meta as { artifact: { versionId: string } }).artifact.versionId; const tool = async (nameOfTool: string, args: Record = {}) => { const result = await client.callTool({ name: nameOfTool, arguments: { name, ...args } }); expect(result.isError).not.toBe(true); return result.structuredContent as any; }; - const saved = await tool("canvas_schedule", { action: "set", interval_seconds: 3600, request: { path: "/counter", method: "POST" } }); + const saved = await tool("artifact_schedule", { action: "set", interval_seconds: 3600, request: { path: "/counter", method: "POST" } }); expect(saved.schedule).toMatchObject({ paused: false, interval_seconds: 3600 }); - await tool("canvas_schedule", { action: "pause" }); - const invalid = await client.callTool({ name: "canvas_write", arguments: { name, contents: "export default function {" } }); + await tool("artifact_schedule", { action: "pause" }); + const invalid = await client.callTool({ name: "artifact_write", arguments: { name, contents: "export default function {" } }); expect(invalid.isError).toBe(true); - await tool("canvas_schedule", { action: "run_now" }); - const response = await tool("canvas_request", { version_id: version, request: { path: "/counter" } }); + await tool("artifact_schedule", { action: "run_now" }); + const response = await tool("artifact_request", { version_id: version, request: { path: "/counter" } }); expect(JSON.parse(atob(response.response.body)).value).toBe(1); - const runs = (await tool("canvas_runs")).runs; + const runs = (await tool("artifact_runs")).runs; expect(runs).toEqual(expect.arrayContaining([expect.objectContaining({ revision: version, trigger: "manual", status: "succeeded", http_status: 200 })])); - // A canvas's own SQL database cannot inspect supervisor execution history. + // An artifact's own SQL database cannot inspect supervisor execution history. const inspectServer = `import { DurableObject } from "cloudflare:workers"; -export class CanvasServer extends DurableObject { fetch() { return Response.json(this.ctx.storage.sql.exec("select name from sqlite_master where name='execution_runs'").toArray()); } }`; - expect((await client.callTool({ name: "canvas_write", arguments: { name, contents: counterClient, server: inspectServer } })).isError).not.toBe(true); - const inspected = await tool("canvas_request", { request: { path: "/" } }); +export class ArtifactServer extends DurableObject { fetch() { return Response.json(this.ctx.storage.sql.exec("select name from sqlite_master where name='execution_runs'").toArray()); } }`; + expect((await client.callTool({ name: "artifact_write", arguments: { name, contents: counterClient, server: inspectServer } })).isError).not.toBe(true); + const inspected = await tool("artifact_request", { request: { path: "/" } }); expect(JSON.parse(atob(inspected.response.body))).toEqual([]); - expect((await client.callTool({ name: "canvas_write", arguments: { name, contents: counterClient, server: null } })).isError).not.toBe(true); - expect((await tool("canvas_schedule")).schedule).toMatchObject({ paused: true, next_run_at: null }); - expect((await client.callTool({ name: "canvas_schedule", arguments: { name, action: "resume" } })).isError).toBe(true); + expect((await client.callTool({ name: "artifact_write", arguments: { name, contents: counterClient, server: null } })).isError).not.toBe(true); + expect((await tool("artifact_schedule")).schedule).toMatchObject({ paused: true, next_run_at: null }); + expect((await client.callTool({ name: "artifact_schedule", arguments: { name, action: "resume" } })).isError).toBe(true); }, 120000); diff --git a/cloudflare/worker.ts b/cloudflare/worker.ts index 84b30ad..70816db 100644 --- a/cloudflare/worker.ts +++ b/cloudflare/worker.ts @@ -1,18 +1,18 @@ import { Hono } from "hono"; import type { DurableObjectNamespace, Fetcher } from "@cloudflare/workers-types"; -import { CanvasLibrary } from "./library"; +import { ArtifactLibrary } from "./library"; import { authenticate, type AuthEnvironment } from "./auth"; -import { CloudCanvasService } from "./service"; +import { CloudArtifactService } from "./service"; // esbuild 0.28.1 must initialize Better Auth's shared Zod module before // the MCP SDK constructs its top-level schemas (Zod 4.5.4). -import { authenticateBetterAuth, getCanvasAuth, CanvasAuthConfigurationError, type BetterAuthEnvironment, type CanvasUser } from "./better-auth"; +import { authenticateBetterAuth, getArtifactAuth, ArtifactAuthConfigurationError, type BetterAuthEnvironment, type ArtifactUser } from "./better-auth"; import { handleCloudMcp } from "./mcp"; import { readRequestText } from "./http"; -import { CanvasBackend } from "./backend"; -import { CanvasFiles, CanvasFileError, TRANSFER_PATH } from "./files"; -import type { CanvasFileRequest } from "../src/sdk/files"; -import { canvas_request as validateRequest } from "../dist/cloudflare/tool-validators.js"; -import type { CanvasHttpRequest } from "../src/httpTypes"; +import { ArtifactBackend } from "./backend"; +import { ArtifactFiles, ArtifactFileError, TRANSFER_PATH } from "./files"; +import type { ArtifactFileRequest } from "../src/sdk/files"; +import { artifact_request as validateRequest } from "../dist/cloudflare/tool-validators.js"; +import type { ArtifactHttpRequest } from "../src/httpTypes"; import { PluginError, PLUGIN_JSON_LIMIT } from "./plugins"; import type { PluginRequest } from "../src/plugins/types"; import galleryBridge from "../dist/cloudflare/gallery-request.json"; @@ -22,19 +22,21 @@ import { ScriptLibrary } from "./scripts"; import { ScriptBackend } from "./script-backend"; import { artifactRoute } from "./artifact-routes"; import * as toolValidators from "../dist/cloudflare/tool-validators.js"; -export { CanvasLibrary, CanvasBackend, CanvasFiles, ArtifactLinks, ScriptLibrary, ScriptBackend }; +export { ArtifactLibrary, ArtifactBackend, ArtifactFiles, ArtifactLinks, ScriptLibrary, ScriptBackend }; +// Deployed Durable Object exports retain their storage identities. +export { ArtifactLibrary as CanvasLibrary, ArtifactBackend as CanvasBackend, ArtifactFiles as CanvasFiles }; export type Env = AuthEnvironment & BetterAuthEnvironment & { - LIBRARIES: DurableObjectNamespace; - BACKENDS: DurableObjectNamespace; + LIBRARIES: DurableObjectNamespace; + BACKENDS: DurableObjectNamespace; LINKS: DurableObjectNamespace; SCRIPTS: DurableObjectNamespace; SCRIPT_BACKENDS: DurableObjectNamespace; - FILE_BACKENDS?: DurableObjectNamespace; + FILE_BACKENDS?: DurableObjectNamespace; ASSETS: Fetcher; DEFAULT_WORKSPACE?: string; }; -const app = new Hono<{ Bindings: Env; Variables: { service: CloudCanvasService; libraryScope: "private" | "team"; user: CanvasUser | null } }>(); +const app = new Hono<{ Bindings: Env; Variables: { service: CloudArtifactService; libraryScope: "private" | "team"; user: ArtifactUser | null } }>(); app.get("/health", c => c.json({ ok: true, runtime: navigator.userAgent })); app.get("/sign-in", c => { if (c.env.AUTH_MODE !== "better-auth") return c.redirect("/"); @@ -51,17 +53,17 @@ app.get("/consent", c => { app.get("/auth.js", c => c.env.ASSETS.fetch(c.req.raw)); app.get("/api/auth/providers", async c => { if (c.env.AUTH_MODE !== "better-auth") return c.json({ providers: [] }); - const context = await (await getCanvasAuth(c.env)).$context; + const context = await (await getArtifactAuth(c.env)).$context; // Publish display metadata only, never provider configuration or credentials. return c.json({ providers: context.socialProviders.map(({ id, name }) => ({ id, name })) }); }); app.all("/api/auth/*", async c => c.env.AUTH_MODE === "better-auth" - ? (await getCanvasAuth(c.env)).handler(c.req.raw) : c.notFound()); + ? (await getArtifactAuth(c.env)).handler(c.req.raw) : c.notFound()); app.all("/.well-known/*", async c => c.env.AUTH_MODE === "better-auth" - ? (await getCanvasAuth(c.env)).handler(c.req.raw) : c.notFound()); -// Only bearer transfer URLs bypass login. A grant fixes the canvas, file, + ? (await getArtifactAuth(c.env)).handler(c.req.raw) : c.notFound()); +// Only bearer transfer URLs bypass login. A grant fixes the artifact, file, // method, size and expiry; management cookies confer no transfer authority. -app.all("/api/canvas/files/transfer/:object/:token", async c => { +for (const path of ["/api/artifact/files/transfer/:object/:token", "/api/canvas/files/transfer/:object/:token"]) app.all(path, async c => { const match = new URL(c.req.url).pathname.match(TRANSFER_PATH); if (!match || !c.env.FILE_BACKENDS) return c.notFound(); let id; @@ -94,7 +96,7 @@ app.use("*", async (c, next) => { ? JSON.stringify(["private", "better-auth", user.id]) : JSON.stringify(["private", c.env.ACCESS_TEAM_DOMAIN ?? "local", identity.subject]); c.set("libraryScope", libraryScope); - c.set("service", new CloudCanvasService(c.env.LIBRARIES.getByName(libraryKey), workspace, c.env.BACKENDS, libraryKey, { links: c.env.LINKS.getByName("deployment"), scripts: c.env.SCRIPTS.getByName(libraryKey), scriptBackends: c.env.SCRIPT_BACKENDS, origin: url.origin }, { user: identity, env: c.env }, c.env.FILE_BACKENDS ? { backends: c.env.FILE_BACKENDS, origin: url.origin } : undefined)); + c.set("service", new CloudArtifactService(c.env.LIBRARIES.getByName(libraryKey), workspace, c.env.BACKENDS, libraryKey, { links: c.env.LINKS.getByName("deployment"), scripts: c.env.SCRIPTS.getByName(libraryKey), scriptBackends: c.env.SCRIPT_BACKENDS, origin: url.origin }, { user: identity, env: c.env }, c.env.FILE_BACKENDS ? { backends: c.env.FILE_BACKENDS, origin: url.origin } : undefined)); // Compilation uses shared isolate resources. Let an admitted operation finish // after a client disconnects rather than abandoning its native compiler I/O. const operation = next(); @@ -125,20 +127,20 @@ app.post("/api/plugins/call", async c => { catch (error) { return c.json({ error: error instanceof RangeError ? "Plugin input exceeds 256 KiB" : "Invalid JSON" }, error instanceof RangeError ? 413 : 400); } return c.json({ result: await c.get("service").pluginCall(input) }); }); -app.post("/api/canvas/request", async c => { +app.post("/api/artifact/request", async c => { let input: unknown; try { input = JSON.parse(await readRequestText(c.req.raw, 1024 * 1024)); } catch (error) { return c.json({ error: error instanceof RangeError ? "Request exceeds 1 MiB" : "Invalid JSON" }, error instanceof RangeError ? 413 : 400); } - if (!validateRequest(input)) return c.json({ error: "Invalid canvas request" }, 400); - const args = input as { name?: string; version_id?: string; request: CanvasHttpRequest }; + if (!validateRequest(input)) return c.json({ error: "Invalid artifact request" }, 400); + const args = input as { name?: string; version_id?: string; request: ArtifactHttpRequest }; return c.json({ response: await c.get("service").request({ name: args.name, version_id: args.version_id }, args.request) }); }); -app.post("/api/canvas/files", async c => { +app.post("/api/artifact/files", async c => { let input: unknown; try { input = JSON.parse(await readRequestText(c.req.raw, 8192)); } catch (error) { return c.json({ error: error instanceof RangeError ? "File request exceeds 8 KiB" : "Invalid JSON" }, error instanceof RangeError ? 413 : 400); } - if (!toolValidators.canvas_files(input)) return c.json({ error: "Invalid file request" }, 400); - const args = input as { name?: string; version_id?: string; request: CanvasFileRequest }; + if (!toolValidators.artifact_files(input)) return c.json({ error: "Invalid file request" }, 400); + const args = input as { name?: string; version_id?: string; request: ArtifactFileRequest }; return c.json({ result: await c.get("service").fileRequest(args, args.request) }); }); app.post("/api/tools", async c => { @@ -161,7 +163,7 @@ app.get("/api/source", async c => { 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")}`); + if (c.req.query("download") === "1") c.header("Content-Disposition", `attachment; filename="artifact.artifact.tsx"; filename*=UTF-8''${encodeURIComponent(snapshot.name + ".artifact.tsx")}`); return c.body(snapshot.source); }); app.get("/gallery/preview", async c => { @@ -169,15 +171,15 @@ app.get("/gallery/preview", async c => { const snapshot = await service.snapshot({ name: c.req.query("name"), version_id: c.req.query("version"), event_id: c.req.query("event") }); const preview = await service.preview(snapshot); if (!preview.ok || !preview._meta) return c.text(preview.check, 400); - const payload = preview._meta.canvas; + const payload = preview._meta.artifact; // Enforce isolation even if somebody opens the preview URL directly instead // of through the gallery's sandboxed iframe. Preview state is page-local. - c.header("Content-Security-Policy", `sandbox allow-scripts; default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; img-src data:; connect-src ${new URL(c.req.url).origin}/api/canvas/files/transfer/; form-action 'none'; base-uri 'none'; frame-ancestors 'self'`); - return c.html(`Sidequery Canvas preview
`); + c.header("Content-Security-Policy", `sandbox allow-scripts; default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; img-src data:; connect-src ${new URL(c.req.url).origin}/api/artifact/files/transfer/ ${new URL(c.req.url).origin}/api/canvas/files/transfer/; form-action 'none'; base-uri 'none'; frame-ancestors 'self'`); + return c.html(`Sidequery Artifacts preview
`); }); app.get("/", c => c.env.ASSETS.fetch(new Request(new URL("/index.html", c.req.url)))); app.get("/gallery", c => c.env.ASSETS.fetch(new Request(new URL("/index.html", c.req.url)))); app.get("/gallery.js", c => c.env.ASSETS.fetch(c.req.raw)); -app.onError((error, c) => c.json({ error: error.message }, error instanceof PluginError || error instanceof CanvasFileError ? error.status : error instanceof CanvasAuthConfigurationError ? 503 : /not found/.test(error.message) ? 404 : 400)); +app.onError((error, c) => c.json({ error: error.message }, error instanceof PluginError || error instanceof ArtifactFileError ? error.status : error instanceof ArtifactAuthConfigurationError ? 503 : /not found/.test(error.message) ? 404 : 400)); export default app; diff --git a/cloudflare/wrangler.compiler.jsonc b/cloudflare/wrangler.compiler.jsonc index f9cc464..64d1be5 100644 --- a/cloudflare/wrangler.compiler.jsonc +++ b/cloudflare/wrangler.compiler.jsonc @@ -1,6 +1,6 @@ { "$schema": "../node_modules/wrangler/config-schema.json", - "name": "canvas-compiler-dev", + "name": "artifacts-compiler-dev", "main": "dev.ts", "compatibility_date": "2026-09-06", "compatibility_flags": [ diff --git a/docs/architecture.md b/docs/architecture.md index a29c1d1..d0a9bd2 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,19 +1,19 @@ # Source map -Canvas is one package with two execution paths. `canvas web`, filesystem commands, -and stdio MCP use Bun with local canvas files and a history database. The hosted +Artifacts is one package with two execution paths. `artifacts web`, filesystem commands, +and stdio MCP use Bun with local artifact files and a history database. The hosted application uses Workers and Durable Objects; it runs on Cloudflare or locally -through celld (`canvas server`). Running the Worker application locally does not +through celld (`artifacts server`). Running the Worker application locally does not turn it into the filesystem/stdio implementation. | Location | Responsibility | | --- | --- | | `src/cli.ts` | Public CLI entrypoint, including the Herdr plugin's list/open actions | -| `src/service.ts`, `canvasFile.ts`, `history.ts`, `compile.ts`, `typecheck.ts` | Bun filesystem canvas operations | +| `src/service.ts`, `artifactFile.ts`, `history.ts`, `compile.ts`, `typecheck.ts` | Bun filesystem artifact operations | | `src/local/` | Local process lifecycle: `preview-daemon.ts` reuses/starts a Bun preview server; `server-daemon.ts` manages the celld server through launchd/systemd; `server.ts` launches it; `celld-runtime.ts` supplies the pinned executable | -| `src/mcp/` | Shared MCP tool schemas (`tools.ts`), canvas guide (`guide.ts`), and app contract (`app-contract.ts`); Bun tool dispatch (`local-tools.ts`), stdio framing/transport (`protocol.ts`, `stdio.ts`), and app HTML/local preview assembly (`app.ts`) | -| `src/sdk/` | Public canvas authoring API, components, hooks, and native server helpers | -| `src/runtime/` | Browser entrypoints and bridges for Herdr, MCP Apps, and hosted canvases | +| `src/mcp/` | Shared MCP tool schemas (`tools.ts`), artifact guide (`guide.ts`), and app contract (`app-contract.ts`); Bun tool dispatch (`local-tools.ts`), stdio framing/transport (`protocol.ts`, `stdio.ts`), and app HTML/local preview assembly (`app.ts`) | +| `src/sdk/` | Public artifact authoring API, components, hooks, and native server helpers | +| `src/runtime/` | Browser entrypoints and bridges for Herdr, MCP Apps, and hosted artifacts | | `src/gallery/`, `src/auth/` | Gallery and sign-in UI, plus their asset assembly/serving helpers | | `src/httpTypes.ts`, `historyTypes.ts`, `diagnostics.ts`, `sandbox.ts` | Shared request/history contracts and source validation used by both execution paths | | `cloudflare/` | Worker application, HTTP MCP, authentication, storage, compilation and execution; also packaged for celld | @@ -24,8 +24,8 @@ turn it into the filesystem/stdio implementation. `cloudflare/worker.ts` connects authenticated requests to the application. `mcp.ts` owns the HTTP MCP transport and `tool-contract.ts` specializes the shared -schemas for hosted capabilities. `service.ts` coordinates canvas operations, -canvas activation, artifact links, and the combined gallery. `script-service.ts` +schemas for hosted capabilities. `service.ts` coordinates artifact operations, +artifact activation, artifact links, and the combined gallery. `script-service.ts` owns script reads, mutations, execution, secrets and logs; `script-guide.ts` contains its MCP authoring guidance. `artifact-service.ts` supplies the shared library/workspace identity and URL metadata used by both services. @@ -35,20 +35,20 @@ and revisions; `links.ts` owns slugs and active revision pointers; `backend.ts` and `script-backend.ts` execute code. Code edits retain the validation and atomic activation sequence before switching a working URL. -Successful canvas edits persist both compiled client and server code in the +Successful artifact edits persist both compiled client and server code in the library Durable Object before activating the revision. Each revision pins its compiled artifact, including its SDK/browser libraries and compiler identity. Gallery, standalone, MCP and backend reads load that artifact without invoking the compiler. Source-only revisions from older installations require an explicit -`canvas_compile` backfill by name or version ID; source/history remain intact. +`artifact_compile` backfill by name or version ID; source/history remain intact. ## Herdr integration `herdr-plugin.toml` declares the pane and actions. An action specifies the command Herdr runs; it does not need a separate source directory. Both list and open use -`src/cli.ts`. Open accepts an explicit canvas name or falls back to -`HERDR_CANVAS_NAME`; `--version` selects archived source without that fallback. -`src/herdr.ts` is the Herdr client, `src/open.ts` coordinates canvas opening, and +`src/cli.ts`. Open accepts an explicit artifact name or falls back to +`ARTIFACTS_NAME`; `--version` selects archived source without that fallback. +`src/herdr.ts` is the Herdr client, `src/open.ts` coordinates artifact opening, and `src/viewer.ts` owns the pane lifecycle. ## Packaging diff --git a/docs/cloudflare.md b/docs/cloudflare.md index b28b855..b2cc01d 100644 --- a/docs/cloudflare.md +++ b/docs/cloudflare.md @@ -1,8 +1,8 @@ # Cloudflare deployment and local development -Canvas runs as a Worker with SQLite Durable Objects, static assets and a Dynamic +Artifacts runs as a Worker with SQLite Durable Objects, static assets and a Dynamic Worker Loader. Generated React executes in the browser sandbox. Optional native -canvas servers execute in isolated Durable Object facets. The server uses +artifact servers execute in isolated Durable Object facets. The server uses `@cloudflare/worker-bundler` 0.2.3 and the same TypeScript 5.9.3 diagnostics as the local Bun adapter; it does not install Bun packages while handling requests. @@ -53,11 +53,11 @@ Chromium must be installed with `bun x playwright install chromium`. ## Deploy into your own or a shared account -[Deploy to Cloudflare](https://deploy.workers.cloudflare.com/?url=https%3A%2F%2Fgithub.com%2Fsidequery%2Fcanvas) +[Deploy to Cloudflare](https://deploy.workers.cloudflare.com/?url=https%3A%2F%2Fgithub.com%2Fsidequery%2Fartifacts) uses Cloudflare's account selection and repository setup flow. Choose the personal or shared account that should own the Worker, Durable Object storage, and billing. The deploying identity must have permission to deploy there. -This is a public self-deploy project; no central Canvas account is required. +This is a public self-deploy project; no central Artifacts account is required. For a manual deployment, authenticate with `bun x wrangler login`, verify the selected identity using `bun x wrangler whoami`, then run @@ -75,7 +75,7 @@ Choose one application authentication mode in `wrangler.jsonc`: - `AUTH_MODE = "better-auth"` runs Better Auth in this Worker with the identity providers and D1 database owned by this deployment. -There is no central Canvas account or authentication service in either mode. +There is no central Artifacts account or authentication service in either mode. ### Cloudflare Access mode @@ -101,12 +101,12 @@ signed assertion. Raw identity headers and unsigned JWT payloads are not trusted ### Better Auth mode -Better Auth is hosted at the Canvas deployment's own `/api/auth` routes and uses +Better Auth is hosted at the Artifacts deployment's own `/api/auth` routes and uses the `AUTH_DB` D1 binding for users, sessions, OAuth grants, signing keys and rate limits. It accepts provider sign-in only; email/password registration is disabled. Set `BETTER_AUTH_URL` to the stable, public origin users and MCP clients actually -open, for example `https://canvas.example.com`. It must be an HTTPS origin with no +open, for example `https://artifact.example.com`. It must be an HTTPS origin with no path, query or fragment. HTTP is accepted only for loopback development hosts. For example, production `vars` can select the mode, canonical URL and an admission rule: @@ -115,7 +115,7 @@ rule: { "ENVIRONMENT": "production", "AUTH_MODE": "better-auth", - "BETTER_AUTH_URL": "https://canvas.example.com", + "BETTER_AUTH_URL": "https://artifact.example.com", "BETTER_AUTH_ALLOWED_DOMAINS": "example.com", "BETTER_AUTH_TRUSTED_IP_HEADER": "cf-connecting-ip" } @@ -124,9 +124,9 @@ rule: Register provider callbacks against the same origin: ```text -https://canvas.example.com/api/auth/callback/google -https://canvas.example.com/api/auth/callback/github -https://canvas.example.com/api/auth/callback/company +https://artifact.example.com/api/auth/callback/google +https://artifact.example.com/api/auth/callback/github +https://artifact.example.com/api/auth/callback/company ``` Generate a deployment-specific secret of at least 32 characters and store it with @@ -162,7 +162,7 @@ set `BETTER_AUTH_OIDC_PROVIDERS` to a JSON array and store it as a Worker secret bun x wrangler secret put BETTER_AUTH_OIDC_PROVIDERS ``` -Both JSON settings are deployment configuration, so the Canvas server and sign-in +Both JSON settings are deployment configuration, so the Artifacts server and sign-in page do not contain a fixed provider list. If a provider needs callbacks, custom profile mapping or another Better Auth plugin, extend `teamProviderOptions` in [`cloudflare/team-auth.ts`](../cloudflare/team-auth.ts) with ordinary TypeScript. @@ -177,7 +177,7 @@ Provider authentication and admission are separate. Configure at least one of: The email and domain rules require the provider to return a verified email. With no admission setting, no user is admitted. Provider hints such as Google's hosted -domain can narrow provider sign-in, but do not replace Canvas admission policy. +domain can narrow provider sign-in, but do not replace Artifacts admission policy. Auth endpoints use persistent database rate limits. On Cloudflare, the example trusts the edge-owned `cf-connecting-ip` header. On celld, set @@ -190,7 +190,7 @@ resource. Add the returned ID and the checked-in migration directory to `wrangler.jsonc` only when enabling Better Auth: ```sh -bun x wrangler d1 create canvas-auth --binding AUTH_DB +bun x wrangler d1 create artifacts-auth --binding AUTH_DB ``` ```json @@ -198,7 +198,7 @@ bun x wrangler d1 create canvas-auth --binding AUTH_DB "d1_databases": [ { "binding": "AUTH_DB", - "database_name": "canvas-auth", + "database_name": "artifacts-auth", "database_id": "", "migrations_dir": "cloudflare/migrations" } @@ -236,9 +236,9 @@ Generate future schema deltas with `bun run scripts/generate-auth-migration.ts cloudflare/migrations/0002_description.sql`. An OAuth-capable MCP client pointed at -`https://canvas.example.com/mcp?workspace=` discovers this deployment's +`https://artifact.example.com/mcp?workspace=` discovers this deployment's authorization metadata, opens the same provider sign-in used by the gallery, and -asks the user to approve Canvas access. Browser sessions and MCP access tokens map +asks the user to approve Artifacts access. Browser sessions and MCP access tokens map to the same Better Auth user ID. Signing out invalidates the session used to authorize both surfaces. Public MCP clients can register dynamically with PKCE. Desktop clients that omit @@ -260,16 +260,16 @@ still administer the underlying storage. Switching auth modes does not migrate private identities. Existing Access private libraries remain keyed by the Access issuer and subject; Better Auth private -libraries use its user ID. The old data is not deleted, but Canvas does not infer +libraries use its user ID. The old data is not deleted, but Artifacts does not infer that an Access subject and a Better Auth account represent the same person. Plan an explicit data migration before changing modes if users must retain the same private library. The shared team-library key is unchanged, so authorized users in the new mode continue to address the existing team library. -Hosted MCP supports inline Canvas views, raw-source reads/writes/guarded edits, +Hosted MCP supports inline Artifact views, raw-source reads/writes/guarded edits, semantic diagnostics, history, archived views and restore. It rejects the local -`herdr` pane target. `useCanvasState` interactions remain local to their view; -requests made with `canvasFetch` can mutate durable server state. Raw source and +`herdr` pane target. `useArtifactState` interactions remain local to their view; +requests made with `artifactFetch` can mutate durable server state. Raw source and initial-state snapshots and compiled client/server bundles are persisted. Successful writes, edits and restores prepare the compiled artifact before activating the revision. Page loads, gallery previews, MCP opens and backend @@ -281,31 +281,31 @@ Sources are limited to 256 KiB, serialized state to 64 KiB, and MCP requests to When upgrading a deployment that previously stored only source, explicitly backfill the required revisions through its authenticated MCP endpoint before -reopening their links. List `canvas_history`, then call -`canvas_compile({version_id: "..."})` for each revision to retain as a runnable -view. `canvas_compile({name: "..."})` prepares a working draft. Already compiled +reopening their links. List `artifact_history`, then call +`artifact_compile({version_id: "..."})` for each revision to retain as a runnable +view. `artifact_compile({name: "..."})` prepares a working draft. Already compiled revisions reuse their pinned output. This does not edit source, rename links, execute backends, or erase history. Invalid historical source remains readable but cannot produce a runnable artifact until corrected. A missing artifact gives an actionable compile diagnostic; a read never starts an implicit build. -## Native canvas servers and storage +## Native artifact servers and storage -A hosted canvas may contain two source snapshots: browser React source and an -optional native server. Server source must export `CanvasServer`, a Durable +A hosted artifact may contain two source snapshots: browser React source and an +optional native server. Server source must export `ArtifactServer`, a Durable Object class from `cloudflare:workers`, with a normal `fetch(request)` method. -Each canvas with a server gets one native SQLite database. Different canvas names +Each artifact with a server gets one native SQLite database. Different artifact names get separate databases; multiple tabs and server restarts use the same database. Use `this.ctx.storage.sql.exec(sql, ...bindings)` with `?` placeholders for values, and `.toArray()` or `.one()` to read the cursor. Use `this.ctx.storage.kv.get/put/delete` for key/value data and -`this.ctx.storage.transactionSync(() => { ... })` for synchronous SQL/KV transactions. Canvas does +`this.ctx.storage.transactionSync(() => { ... })` for synchronous SQL/KV transactions. Artifacts does not add a database abstraction or query-hook layer. ### Application schema migrations -Canvas does not provide a migration command or automatic application-schema runner. +Artifacts does not provide a migration command or automatic application-schema runner. The sample initializes tables with `create table if not exists`; that does not -upgrade an existing table. For schema changes, keep a schema version in the canvas +upgrade an existing table. For schema changes, keep a schema version in the artifact database, check it during server initialization, and apply pending changes together with the new version in one `this.ctx.storage.transactionSync`. Complete that work before serving requests. Saving server source only stores and validates code; @@ -313,21 +313,21 @@ initialization and migrations run when the updated server is next requested. Prefer additive changes that remain compatible with earlier source revisions. Restoring source does not roll back schema or data, and archived previews can run -older code against the current database. Test changes on a separate canvas/database +older code against the current database. Test changes on a separate artifact/database before applying them to populated data. The deployment-level Durable Object migration -provisions the storage classes; it does not manage user tables inside a canvas. +provisions the storage classes; it does not manage user tables inside an artifact. ### Server capabilities Generated servers do not receive the outer Worker's ordinary bindings. In particular, do not assume D1, R2 or custom environment bindings are available. Global outbound access is disabled. The supported persistent boundary is the -canvas Durable Object's own SQL/KV storage. +artifact Durable Object's own SQL/KV storage. -Browser code imports `canvasFetch` from `sidequery/canvas`: +Browser code imports `artifactFetch` from `sidequery/artifacts`: ```ts -const response = await canvasFetch("/counter", { +const response = await artifactFetch("/counter", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ amount: 1 }), @@ -336,22 +336,22 @@ if (!response.ok) throw new Error(`Request failed (${response.status})`); const data = await response.json(); ``` -`canvasFetch(path, init)` uses native `Request` normalization, including JSON, +`artifactFetch(path, init)` uses native `Request` normalization, including JSON, `FormData`, URL-encoded and binary bodies, and returns a native `Response`. The host transports method, normalized headers and a base64 body to -`canvas_request`; canvas code sees no service URL or bearer secret. Paths are +`artifact_request`; artifact code sees no service URL or bearer secret. Paths are resolved as HTTP paths, fragments are omitted, and cross-origin or protocol-relative URLs are rejected. Request and response bodies are each limited to 256 KiB. `HEAD`, `204`, `205` and `304` responses have null bodies. -Create or replace a pair with hosted `canvas_write({name, contents, server})`. -If a canvas already has server source, omitting `server` preserves it; pass +Create or replace a pair with hosted `artifact_write({name, contents, server})`. +If an artifact already has server source, omitting `server` preserves it; pass `server: null` to remove the server without deleting stored data. Read or edit -one side with `canvas_read({name, part: "client" | "server"})` and -`canvas_edit({name, part, edits, expected_hash?})`. Typecheck and compile cover +one side with `artifact_read({name, part: "client" | "server"})` and +`artifact_edit({name, part, edits, expected_hash?})`. Typecheck and compile cover both sources when a server is present. The checked-in -[`counter.canvas.tsx`](../examples/counter.canvas.tsx) and -[`counter.canvas.server.ts`](../examples/counter.canvas.server.ts) demonstrate +[`counter.artifact.tsx`](../examples/counter.artifact.tsx) and +[`counter.artifact.server.ts`](../examples/counter.artifact.server.ts) demonstrate the complete pair. Seed it into workerd with: ```sh @@ -361,24 +361,24 @@ bun run seed:cloudflare default counter History captures client and server source as one revision. Restore changes both sources together. Database contents are not part of a revision: they remain live when server code is edited, removed, re-added or restored. An archived server -version runs its archived code against that canvas's current database. The -backend identity includes library scope, workspace and canvas name, so code +version runs its archived code against that artifact's current database. The +backend identity includes library scope, workspace and artifact name, so code changes restart the generated facet without changing its durable storage. The private library selects both source history and backend storage from the verified Access subject. The team library selects one shared source history and backend for all members authorized by the deployment's Access policy. Workspaces -and canvas names further partition storage; neither can cross the private/team -boundary. The local Bun CLI, stdio MCP server and gallery have no canvas-server -backend. Their browser views can use ordinary local canvas interactions, but -`canvasFetch` rejects with an unavailable error. +and artifact names further partition storage; neither can cross the private/team +boundary. The local Bun CLI, stdio MCP server and gallery have no artifact-server +backend. Their browser views can use ordinary local artifact interactions, but +`artifactFetch` rejects with an unavailable error. ## Optional celld compatibility [celld](https://github.com/denoland/celld) is a Cloudflare-compatible runtime. -Wrangler/workerd remains the default Cloudflare development path. Canvas also +Wrangler/workerd remains the default Cloudflare development path. Artifact also qualifies the released celld v0.4.1 against the built application, including -generated `CanvasServer` execution on raw SQL and KV facets. +generated `ArtifactServer` execution on raw SQL and KV facets. ```sh bun run dev:celld @@ -392,7 +392,7 @@ esbuild executable. Development state persists in the ignored `.celld/dev` directory. Seed the counter pair with: ```sh -CANVAS_MCP_URL=http://127.0.0.1:4786/mcp bun run seed:cloudflare default counter +ARTIFACTS_MCP_URL=http://127.0.0.1:4786/mcp bun run seed:cloudflare default counter ``` `test:celld` copies the built bundle and assets into a temporary directory with @@ -406,14 +406,14 @@ The worker-bundler patch in this checkout statically imports esbuild's WASM module and initializes its browser global before bundler startup; both are required by celld v0.4.1. Passing the compatibility test does not add D1, R2 or other outer-Worker -bindings to generated canvas servers: their supported state remains the raw +bindings to generated artifact servers: their supported state remains the raw Durable Object SQL/KV facets described above. When `AUTH_DB` is present in `wrangler.jsonc`, the generated celld configuration carries that D1 binding. celld v0.4.1 can run the checked-in Better Auth schema in local D1 and preserve it across a restart. Its `celld d1 migrations apply` command targets deployed bucket storage rather than the local development database, so -the Canvas celld integration uses a temporary bootstrap Worker for local schema +the Artifacts celld integration uses a temporary bootstrap Worker for local schema setup. This fixture qualifies the complete OAuth/session flow on celld v0.4.1; ordinary `dev:celld` does not apply auth migrations automatically. Use Wrangler/workerd's local migration command for routine Better Auth development. @@ -428,8 +428,18 @@ Wrangler/workerd's local migration command for routine Better Auth development. - [Durable Object facets](https://developers.cloudflare.com/dynamic-workers/usage/durable-object-facets/) Hosted list/history results contain at most 100 entries and a `next_offset`. -Pass that value as `offset` to continue. `canvas_version` returns at most 20 +Pass that value as `offset` to continue. `artifact_version` returns at most 20 serve events and `next_events_offset`; pass it as `events_offset` to retrieve older events. A null continuation means the end. Gallery pages are loaded sequentially. Pagination bounds individual Worker responses without deleting history; concurrent writes may shift offset-based pages, so refresh if needed. + +## Existing deployment identities + +The application is named Artifacts. The checked-in Worker name `canvas`, R2 bucket +`canvas-files`, historical Durable Object class exports and migration tags retain +their deployed identities so an upgrade continues using the same databases and +files. New source uses `ArtifactLibrary`, `ArtifactBackend`, and `ArtifactFiles`; +legacy exports remain aliases. Do not rename provisioned resources as part of a +normal code update. Existing OAuth clients with the `canvas` scope remain valid; +new clients request `artifacts`. diff --git a/docs/daemon.md b/docs/daemon.md index 197126c..a288397 100644 --- a/docs/daemon.md +++ b/docs/daemon.md @@ -1,8 +1,8 @@ -# Local Canvas server +# Local Artifacts server -The native Canvas server is optional. It runs the packaged Worker and its +The native Artifacts server is optional. It runs the packaged Worker and its SQLite/KV state through a pinned celld runtime on `127.0.0.1`. The ordinary -Canvas CLI, stdio MCP server, local gallery, and artifact commands do not start +Artifacts CLI, stdio MCP server, local gallery, and artifact commands do not start or download celld. ## Foreground server @@ -10,18 +10,18 @@ or download celld. Run the server in the current terminal: ```sh -canvas server +artifacts server ``` The default address is `http://127.0.0.1:4786`. `--port` selects another port. -The server keeps its project and native state under the Canvas data directory; +The server keeps its project and native state under the Artifacts data directory; `--state-dir PATH` overrides that project directory for a foreground run. Press Ctrl-C to request a graceful stop. SIGTERM uses the same shutdown path. -On first use, Canvas downloads celld 0.4.1 for a supported platform, verifies -the pinned archive and executable SHA-256 values, and installs it in the Canvas +On first use, Artifacts downloads celld 0.4.1 for a supported platform, verifies +the pinned archive and executable SHA-256 values, and installs it in the Artifact data directory with user-only permissions. This release supports Apple Silicon -macOS and glibc Linux on arm64 or x64. The ordinary Bun commands do not require this native runtime; `canvas server` +macOS and glibc Linux on arm64 or x64. The ordinary Bun commands do not require this native runtime; `artifacts server` reports an explicit error when the native platform is unsupported. ## Background service @@ -29,10 +29,10 @@ reports an explicit error when the native platform is unsupported. Use the operating system's user service manager for crash recovery: ```sh -canvas server start -canvas server status -canvas server logs -canvas server stop +artifacts server start +artifacts server status +artifacts server logs +artifacts server stop ``` `start` uses launchd on macOS and `systemd --user` on Linux. It first verifies @@ -50,13 +50,13 @@ for graceful shutdown. It retains the server project and all SQLite/KV data. readiness, and its loopback health endpoint responds successfully. The URL appears only when `ready` is true, so an unrelated process on the same -port cannot be reported as this Canvas service. Service identities include a -stable hash of `CANVAS_DATA_HOME`, which prevents commands for one data root +port cannot be reported as this Artifacts service. Service identities include a +stable hash of `ARTIFACTS_DATA_HOME`, which prevents commands for one data root from stopping the service for another. Two roots still cannot listen on the same port; `start` checks for that conflict before registration. `logs` prints the most recent 100 lines from the service log. Select between 1 -and 1000 lines with `canvas server logs --lines N`. Each read is capped at 256 +and 1000 lines with `artifacts server logs --lines N`. Each read is capped at 256 KiB even when the log is larger. ## Start at login @@ -65,21 +65,21 @@ If the server is already running, stop it first. Then start it and opt in to future login starts: ```sh -canvas server start --at-login +artifacts server start --at-login ``` -Install `@sidequery/canvas` globally before enabling this option, for example -with `bun add --global @sidequery/canvas`. The supervisor definition records +Install `@sidequery/artifacts` globally before enabling this option, for example +with `bun add --global @sidequery/artifacts`. The supervisor definition records the exact Bun executable and absolute installed `src/cli.ts` path. Do not create a login service from `bunx` or another transient package cache: cleanup or cache rotation can remove that recorded path. -`canvas server stop` stops the current process but preserves the login setting, +`artifacts server stop` stops the current process but preserves the login setting, so the service starts at the next login. Disable login startup, stop the service, and remove its installed supervisor definition with: ```sh -canvas server uninstall +artifacts server uninstall ``` Run `uninstall` before moving or removing the global package. Enabling login is @@ -87,16 +87,16 @@ never part of package installation, upgrade, or publication. ## Data and configuration -Set `CANVAS_DATA_HOME` to choose a data root. Otherwise Canvas uses: +Set `ARTIFACTS_DATA_HOME` to choose a data root. Otherwise Artifacts uses: -- macOS: `~/Library/Application Support/sidequery-canvas` -- Linux: `${XDG_DATA_HOME:-~/.local/share}/sidequery-canvas` +- macOS: `~/Library/Application Support/sidequery-artifacts` +- Linux: `${XDG_DATA_HOME:-~/.local/share}/sidequery-artifacts` The durable native project is under `server`, the managed runtime is under `runtimes`, and lifecycle state and logs are under `daemon`. On Linux, `XDG_CONFIG_HOME` selects the systemd user-unit directory when set. -Supervisor definitions forward only `CANVAS_DATA_HOME`; they do not copy the +Supervisor definitions forward only `ARTIFACTS_DATA_HOME`; they do not copy the invoking shell's environment or credentials. The native server binds only to loopback. Its state is local application data and is not uploaded or backed up automatically. diff --git a/docs/files.md b/docs/files.md index aa716b1..2b22562 100644 --- a/docs/files.md +++ b/docs/files.md @@ -1,15 +1,15 @@ -# Canvas files +# Artifact files -Hosted canvases and the managed local celld server support persistent files without -requiring canvas server code. Import `canvasFiles` from `sidequery/canvas`: +Hosted artifacts and the managed local celld server support persistent files without +requiring artifacts server code. Import `artifactFiles` from `sidequery/artifacts`: ```tsx -const file = await canvasFiles.upload(selectedFile); -const page = await canvasFiles.list(); // { files, cursor? }, up to 100 entries -const next = page.cursor ? await canvasFiles.list({ cursor: page.cursor }) : null; -const contents = await canvasFiles.read(file.id); // Blob -await canvasFiles.download(file.id); // ask the host to start a browser download -await canvasFiles.delete(file.id); +const file = await artifactFiles.upload(selectedFile); +const page = await artifactFiles.list(); // { files, cursor? }, up to 100 entries +const next = page.cursor ? await artifactFiles.list({ cursor: page.cursor }) : null; +const contents = await artifactFiles.read(file.id); // Blob +await artifactFiles.download(file.id); // ask the host to start a browser download +await artifactFiles.delete(file.id); ``` `upload` accepts a `File` or `Blob`, with optional `{ name, signal }`. `read` accepts @@ -18,53 +18,53 @@ and ISO `uploaded` timestamp. Uploads create new files even when names match. Ea file may be at most 25 MiB. Empty files are supported. Upload failures require a new upload request; multipart/resumable uploads are not part of this API. -Files belong to **library + workspace + canvas name**, matching the canvas's +Files belong to **library + workspace + artifact name**, matching the artifact's database identity. Private libraries isolate users; the team library shares files. Source changes, archived previews, source restores, and URL renames all use the same live files. Files are not embedded in source history or source downloads. -Deleting a file does not change the canvas source. Treat deletion as permanent. +Deleting a file does not change the artifact source. Treat deletion as permanent. -Public standalone canvases can list and download their files, but cannot upload -or delete through their public URL. Publishing a canvas therefore exposes its +Public standalone artifacts can list and download their files, but cannot upload +or delete through their public URL. Publishing an artifact therefore exposes its files for reading. Private standalone views use the same library authorization as the gallery. Plain file-based previews without the hosted/managed server do not have persistent file storage; their SDK calls report that files are unavailable. -The `canvas_files` MCP tool accepts `{ name?, version_id?, request }`. Requests are +The `artifact_files` MCP tool accepts `{ name?, version_id?, request }`. Requests are `{ operation: "list", cursor? }`, `{ operation: "upload", name, size, type }`, or `{ operation: "download" | "delete", id }`. Upload/download return `{ file, url, expires }`; list returns `{ files, cursor? }`. Upload bytes using HTTP PUT with exactly the declared `Content-Length`; the response is `201 { file }`. -Download with HTTP GET. File bytes never go through MCP or `canvasFetch`'s 256 KiB +Download with HTTP GET. File bytes never go through MCP or `artifactFetch`'s 256 KiB JSON bridge. The SDK performs transfers automatically, without sending cookies. Transfer URLs are bearer capabilities valid for five minutes. An upload URL is single-use; a download URL can be reused until it expires or the file is deleted. -Do not publish or log these URLs. Restricting a formerly public canvas does not +Do not publish or log these URLs. Restricting a formerly public artifact does not revoke already issued download URLs immediately; they expire within five minutes. Downloads are forced attachments with a sandbox policy, including uploaded HTML. -The gallery and standalone wrapper initiate downloads outside the canvas iframe; +The gallery and standalone wrapper initiate downloads outside the artifact iframe; MCP views ask the chat host to open the download link. The host may decline it. ## Deployment The canonical Wrangler configuration binds `FILES` to the `canvas-files` R2 bucket -and adds the SQLite `CanvasFiles` Durable Object (`FILE_BACKENDS`). Create the R2 +and adds the SQLite `ArtifactFiles` Durable Object (`FILE_BACKENDS`). Create the R2 bucket before deploying (`bun x wrangler r2 bucket create canvas-files`), or change the bucket name to a deployment-owned bucket. The `v4` Durable Object migration -creates the supervisor class; it does not alter existing canvas databases. +creates the supervisor class; it does not alter existing artifact databases. With celld, the same `r2_buckets` binding uses the runtime's object store. Local -`canvas server` persists files under its existing `.celld/dev` state directory; -no R2 account or credentials are required for local use. Generated `CanvasServer` +`artifacts server` persists files under its existing `.celld/dev` state directory; +no R2 account or credentials are required for local use. Generated `ArtifactServer` isolates do not receive the bucket or transfer credentials. MCP file transfers need the deployment origin in the resource's `connectDomains`; the server advertises it when file storage is configured. Gallery and standalone frames allow connections only to the transfer path. If Cloudflare Access or a -reverse proxy protects every path, exempt `/api/canvas/files/transfer/*` from its +reverse proxy protects every path, exempt `/api/artifact/files/transfer/*` from its interactive login requirement: those routes validate their own short-lived grants. Keep all management and grant-creation routes protected. Configure proxy upload limits to accept 25 MiB. -See [the file canvas example](../examples/files.canvas.tsx). +See [the file artifact example](../examples/files.artifact.tsx). diff --git a/docs/releasing.md b/docs/releasing.md index 0b4b33d..622bc43 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -1,6 +1,6 @@ -# Releasing @sidequery/canvas +# Releasing @sidequery/artifacts -Canvas requires Bun 1.4 or newer. The bun package includes the CLI, its source +Artifacts requires Bun 1.4 or newer. The bun package includes the CLI, its source assets used by Bun at runtime, and the prebuilt Worker/assets used by the optional local celld server. Consumers do not need Wrangler or a source checkout. @@ -8,7 +8,7 @@ local celld server. Consumers do not need Wrangler or a source checkout. The `Publish npm package` workflow (`.github/workflows/publish.yml`) uses npm trusted publishing (OIDC). It runs only when manually dispatched on `main` in -`sidequery/canvas`; merging a PR does not publish. Enter the exact `package.json` +`sidequery/artifacts`; merging a PR does not publish. Enter the exact `package.json` version when dispatching. The workflow checks types and source tests, builds one tarball, exercises that archive in an isolated consumer including native server persistence, then publishes the same archive from a separate GitHub-hosted job. @@ -21,13 +21,13 @@ is stored in GitHub or 1Password for routine releases. The package must first exist on npm before its trusted publisher can be configured. For the first approved release, use the local validation and publication steps below with interactive npm authentication (or a temporary bootstrap credential). -Then open `@sidequery/canvas` package settings on npm and add this trusted publisher: +Then open `@sidequery/artifacts` package settings on npm and add this trusted publisher: | Field | Value | | --- | --- | | Provider | GitHub Actions | | Organization | `sidequery` | -| Repository | `canvas` | +| Repository | `artifacts` | | Workflow filename | `publish.yml` | | Environment | Leave blank | | Allowed actions | Enable direct `npm publish` | @@ -48,10 +48,10 @@ publication verifies the connection. Subsequent releases need a new package vers 4. Run the native package check on a supported platform: `CELLD_PACKAGE_INTEGRATION=1 bun run test:package`. 5. Run `bun pm pack --destination /tmp` to build a release tarball. Test that exact - archive with `CANVAS_PACKAGE_ARCHIVE=/tmp/sidequery-canvas-VERSION.tgz + archive with `ARTIFACTS_PACKAGE_ARCHIVE=/tmp/sidequery-artifacts-VERSION.tgz CELLD_PACKAGE_INTEGRATION=1 bun test scripts/package.integration.test.ts --timeout 360000`. 6. After release approval and authentication for the `@sidequery` scope, - publish that tarball with `bun publish /tmp/sidequery-canvas-VERSION.tgz --access public`. + publish that tarball with `bun publish /tmp/sidequery-artifacts-VERSION.tgz --access public`. A package build is not a registry publication. CI validates packaging without publishing. Never enable a login daemon as an install or publish lifecycle script. diff --git a/docs/routing.md b/docs/routing.md index 9807098..f570375 100644 --- a/docs/routing.md +++ b/docs/routing.md @@ -1,9 +1,9 @@ -# Canvas pages and APIs +# Artifact pages and APIs -The runtime supplies React Router to every canvas. Import routing components and hooks from `sidequery/canvas`; no router setup or additional source files are required. +The runtime supplies React Router to every artifact. Import routing components and hooks from `sidequery/artifacts`; no router setup or additional source files are required. ```tsx -import { Routes, Route, Outlet, Link, useParams } from "sidequery/canvas"; +import { Routes, Route, Outlet, Link, useParams } from "sidequery/artifacts"; function Account() { const { id } = useParams(); @@ -24,16 +24,16 @@ export default function App() { } ``` -`Link to` navigates within the canvas. Existing `Link href` keeps ordinary anchor behavior. `NavLink`, `Navigate`, `useNavigate`, `useLocation`, `useParams`, `useSearchParams`, `useMatch`, and `useResolvedPath` are also available. Relative links, nested layouts, query strings, fragments, and navigation state use React Router semantics. +`Link to` navigates within the artifact. Existing `Link href` keeps ordinary anchor behavior. `NavLink`, `Navigate`, `useNavigate`, `useLocation`, `useParams`, `useSearchParams`, `useMatch`, and `useResolvedPath` are also available. Relative links, nested layouts, query strings, fragments, and navigation state use React Router semantics. -A canvas published as `/sales` opens `/sales/accounts/123` at the route `/accounts/123`. The trusted standalone wrapper owns browser history and synchronizes it with the sandboxed canvas. Reload, bookmarks, and browser back/forward work. The slug is supplied by the viewer; canvas source does not hard-code it. Gallery, MCP/chat, and local views use independent memory history starting at `/`, leaving their surrounding viewer's URL unchanged. Existing single-page canvases continue rendering normally. +An artifact published as `/sales` opens `/sales/accounts/123` at the route `/accounts/123`. The trusted standalone wrapper owns browser history and synchronizes it with the sandboxed artifact. Reload, bookmarks, and browser back/forward work. The slug is supplied by the viewer; artifact source does not hard-code it. Gallery, MCP/chat, and local views use independent memory history starting at `/`, leaving their surrounding viewer's URL unchanged. Existing single-page artifacts continue rendering normally. ## HTTP APIs -Requests to `/sales/api` and `/sales/api/*` go to that canvas's active backend before page dispatch. The backend receives `/api` or `/api/*` plus the original query string, HTTP method, and body bytes. Its status, response body, and application headers are returned. `canvasFetch("/api/accounts/123")` reaches the same handler in hosted standalone, gallery, and chat views. +Requests to `/sales/api` and `/sales/api/*` go to that artifact's active backend before page dispatch. The backend receives `/api` or `/api/*` plus the original query string, HTTP method, and body bytes. Its status, response body, and application headers are returned. `artifactFetch("/api/accounts/123")` reaches the same handler in hosted standalone, gallery, and chat views. -A canvas without a backend returns JSON 404 for API requests. A handler's own 404 is returned directly, never replaced by page HTML. Requests and responses retain the existing 256 KiB body limit. GET/HEAD page requests load the canvas shell; other page methods return 405. Unknown `/_canvas/*` paths return 404 because that namespace belongs to runtime bridges. +An artifact without a backend returns JSON 404 for API requests. A handler's own 404 is returned directly, never replaced by page HTML. Requests and responses retain the existing 256 KiB body limit. GET/HEAD page requests load the artifact shell; other page methods return 405. Unknown `/_artifact/*` paths return 404 because that namespace belongs to runtime bridges. -Private URLs require the existing authenticated owner or team access. Public URLs expose their API as well as their page, so handlers must implement any additional application authorization they need. Management cookies and Access credentials are removed before calling canvas code; private management Authorization is removed too. Backend Set-Cookie is suppressed, responses are not cached, and sandbox policy prevents returned HTML from gaining management-origin access. +Private URLs require the existing authenticated owner or team access. Public URLs expose their API as well as their page, so handlers must implement any additional application authorization they need. Management cookies and Access credentials are removed before calling artifact code; private management Authorization is removed too. Backend Set-Cookie is suppressed, responses are not cached, and sandbox policy prevents returned HTML from gaining management-origin access. -Standalone scripts continue handling their own methods and subpaths. Deployment plugin functions keep their separate authenticated bridge and authorization rules. Routing does not add SSR or React Router data loaders/actions; use the existing canvas backend or plugin functions for data. +Standalone scripts continue handling their own methods and subpaths. Deployment plugin functions keep their separate authenticated bridge and authorization rules. Routing does not add SSR or React Router data loaders/actions; use the existing artifact backend or plugin functions for data. diff --git a/docs/runtime-plugins.md b/docs/runtime-plugins.md index 3c2ad07..7ce333a 100644 --- a/docs/runtime-plugins.md +++ b/docs/runtime-plugins.md @@ -1,19 +1,19 @@ # Deployment plugins -A deployment can install ordinary browser packages and expose authenticated server functions. Canvases import the browser packages and call functions through the existing viewer bridge. Routing is a separate feature. +A deployment can install ordinary browser packages and expose authenticated server functions. Artifacts import the browser packages and call functions through the existing viewer bridge. Routing is a separate feature. ## Configure a deployment -Install dependencies with Bun and add `canvas.plugins.ts` at the repository root. `CANVAS_PLUGINS_CONFIG` can select another config file, relative to the repository root. Build both the compiler Worker and app Worker with the same configuration, then deploy them normally. The local CLI compiler reads the same generated browser registry. +Install dependencies with Bun and add `artifacts.plugins.ts` at the repository root. `ARTIFACTS_PLUGINS_CONFIG` can select another config file, relative to the repository root. Build both the compiler Worker and app Worker with the same configuration, then deploy them normally. The local CLI compiler reads the same generated browser registry. ```ts -import { definePlugins } from "@sidequery/canvas/plugins"; +import { definePlugins } from "@sidequery/artifacts/plugins"; export default definePlugins([ { name: "@company/ui", description: "Company components and API client", - browser: "@company/canvas-ui", + browser: "@company/artifact-ui", }, { name: "company-directory", @@ -46,14 +46,14 @@ export default definePlugins([ Configure secret values as app Worker bindings through the existing deployment tooling. The config lists binding names, never values. Plugins are trusted deployment code; the binding selection is an explicit handler interface, not isolation from other installed server code. -A browser entry can be an installed package or a source path relative to the config. Declarations are collected at build time; use `types` for an explicit declaration entry when needed. Only configured public package names are available to canvas imports. Add separate entries for public submodules under distinct names. Browser entries must bundle into one browser-compatible JavaScript module. React and the Canvas SDK share the host runtime. Browser bundles and their declarations must contain only public code; they are delivered to viewers. +A browser entry can be an installed package or a source path relative to the config. Declarations are collected at build time; use `types` for an explicit declaration entry when needed. Only configured public package names are available to artifact imports. Add separate entries for public submodules under distinct names. Browser entries must bundle into one browser-compatible JavaScript module. React and the Artifacts SDK share the host runtime. Browser bundles and their declarations must contain only public code; they are delivered to viewers. ## Call a function An installed browser library can wrap the bridge with its own typed API: ```ts -import { pluginCall } from "sidequery/canvas"; +import { pluginCall } from "sidequery/artifacts"; type DirectoryRecord = { id: string; name: string }; export function lookupRecord(id: string, signal?: AbortSignal) { @@ -61,18 +61,18 @@ export function lookupRecord(id: string, signal?: AbortSignal) { } ``` -Canvas source can import that wrapper or call `pluginCall` directly. `plugins_list` exposes installed names, descriptions, JSON schemas and read-only hints through MCP; `plugin_guide` explains the bridge. `canvas_plugin_call` uses `{ plugin, operation, input }` and returns `{ result }` in structured content. The generic return type is the library author's contract; optional `outputSchema` validates results on the server. +Artifact source can import that wrapper or call `pluginCall` directly. `plugins_list` exposes installed names, descriptions, JSON schemas and read-only hints through MCP; `plugin_guide` explains the bridge. `artifact_plugin_call` uses `{ plugin, operation, input }` and returns `{ result }` in structured content. The generic return type is the library author's contract; optional `outputSchema` validates results on the server. ## Authentication and behavior -Every call requires an authenticated deployment user. Handlers receive `{ user: { subject, authority }, secrets, signal }`. Omitting `authorize` allows any authenticated deployment user; an optional `authorize(user)` can restrict the whole operation, and the handler must enforce any input-dependent or record-level access. Workspace, library, canvas and version selectors do not change the caller's authority. +Every call requires an authenticated deployment user. Handlers receive `{ user: { subject, authority }, secrets, signal }`. Omitting `authorize` allows any authenticated deployment user; an optional `authorize(user)` can restrict the whole operation, and the handler must enforce any input-dependent or record-level access. Workspace, library, artifact and version selectors do not change the caller's authority. -Hosted gallery previews, authenticated private standalone canvases and hosted MCP Apps use the bridge. Public standalone canvases cannot call functions, even if the browser has a signed-in session. Local compilation supports the built browser libraries; a view without an authenticated hosted bridge rejects function calls. Canvas backends and scripts do not receive the bridge. +Hosted gallery previews, authenticated private standalone artifacts and hosted MCP Apps use the bridge. Public standalone artifacts cannot call functions, even if the browser has a signed-in session. Local compilation supports the built browser libraries; a view without an authenticated hosted bridge rejects function calls. Artifact backends and scripts do not receive the bridge. Inputs and outputs are JSON, limited to 256 KiB. Inputs are validated against `inputSchema`; `outputSchema` is optional. Calls time out after 30 seconds, and handlers receive an abort signal. Cancellation stops waiting in the browser; it does not roll back server work. Handlers should respect the signal and provide their own idempotency where needed. Server exceptions return generic errors without provider details or secrets. -Server-side plugin functions follow deployment upgrades. Browser libraries and the Canvas SDK are included in the compiled revision saved on a successful edit; reopening that revision reuses its bundle across restarts and upgrades. A subsequent edit or explicit compilation of the working canvas uses the current build configuration. Keep server APIs compatible with saved browser bundles. This foundation does not manage provider OAuth, install packages at runtime, add artifact permission manifests or implement routing. +Server-side plugin functions follow deployment upgrades. Browser libraries and the Artifacts SDK are included in the compiled revision saved on a successful edit; reopening that revision reuses its bundle across restarts and upgrades. A subsequent edit or explicit compilation of the working artifact uses the current build configuration. Keep server APIs compatible with saved browser bundles. This foundation does not manage provider OAuth, install packages at runtime, add artifact permission manifests or implement routing. ## Complete example -The [runner-status example](../examples/runner-status/README.md) adapts an existing GitHub Actions microapp into a private routed canvas and a read-only MCP operation. It includes a shared hosted collector, explicit caller authorization, persisted snapshots, setup instructions and credential-free tests. +The [runner-status example](../examples/runner-status/README.md) adapts an existing GitHub Actions microapp into a private routed artifact and a read-only MCP operation. It includes a shared hosted collector, explicit caller authorization, persisted snapshots, setup instructions and credential-free tests. diff --git a/docs/scripts.md b/docs/scripts.md index 069ac29..059bfd2 100644 --- a/docs/scripts.md +++ b/docs/scripts.md @@ -1,10 +1,10 @@ -# Scripts and direct canvas links +# Scripts and direct artifact links -The hosted gallery supports canvases and standalone scripts. Either can have a chosen root URL such as `/sales-dashboard` or `/stripe-webhook`. Canvas URLs open the interactive canvas and its backend; script URLs invoke the script's HTTP handler. Slugs are unique across the deployment, including across artifact types and libraries. Existing application routes are reserved. +The hosted gallery supports artifacts and standalone scripts. Either can have a chosen root URL such as `/sales-dashboard` or `/stripe-webhook`. Artifact URLs open the interactive artifact and its backend; script URLs invoke the script's HTTP handler. Slugs are unique across the deployment, including across artifact types and libraries. Existing application routes are reserved. ## Gallery -Choose **New script**, enter a name and URL slug, and edit the TypeScript source. Select **Private** or **Public**, then **Save script**. Scripts appear alongside canvases in the library. Selecting a script loads its source without running it. +Choose **New script**, enter a name and URL slug, and edit the TypeScript source. Select **Private** or **Public**, then **Save script**. Scripts appear alongside artifacts in the library. Selecting a script loads its source without running it. For either artifact, the **URL /** field and access selector control its direct link. **Save link** updates the settings; **Open** visits the URL and **Copy URL** copies its absolute address. Opening a script URL sends a GET request and runs its handler. Updating the source keeps the chosen URL. Changing the slug changes the URL; old URLs are not aliases. @@ -14,7 +14,7 @@ Expand **Run** to choose an HTTP method, path, headers as a JSON object, and a t Expand **Logs** and select **Load logs** to inspect recent execution output. Under **Secrets**, enter a name and value and select **Save secret**; the value field clears after saving. Enter a name and select **Remove secret** to delete it. Stored secret values are never loaded into the editor or source history. -These controls are available in the hosted gallery. The local gallery continues to support existing canvases. +These controls are available in the hosted gallery. The local gallery continues to support existing artifacts. ## Script handler @@ -65,7 +65,7 @@ HTML served at artifact URLs is sandboxed with a response Content Security Polic ## MCP -Agents use `script_write`, `script_read`, `script_edit`, `script_list`, `script_history`, `script_restore`, `script_run`, `script_logs`, and `script_secrets` to manage scripts. `artifact_link` sets the slug and access for either `kind: "canvas"` or `kind: "script"`. Creation and link results include the resulting URL. +Agents use `script_write`, `script_read`, `script_edit`, `script_list`, `script_history`, `script_restore`, `script_run`, `script_logs`, and `script_secrets` to manage scripts. `artifact_link` sets the slug and access for either `kind: "artifact"` or `kind: "script"`. Creation and link results include the resulting URL. `script_run` takes a request object with `path`, `method`, `headers` as an array of `[name, value]` pairs, and an optional base64 `body`. Its structured result contains `response` with `status`, `statusText`, `headers`, and an optional base64 `body`. For example: @@ -86,7 +86,7 @@ Agents use `script_write`, `script_read`, `script_edit`, `script_list`, `script_ ## Third-party dependencies -Scripts and canvases accept an optional `project` alongside their existing entrypoint source: +Scripts and artifacts accept an optional `project` alongside their existing entrypoint source: ```json { @@ -95,30 +95,30 @@ Scripts and canvases accept an optional `project` alongside their existing entry } ``` -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. +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`, `artifact_read`, and `artifact_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. +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 Artifacts' 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. -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. +Hosted Cloudflare and celld deployments use the same project contract. Local artifact CLI and MCP also persist the project beside the artifact and carry its files and dependency snapshot through history and remix. Use `artifacts write NAME --file SOURCE --project PROJECT_JSON`, where the JSON contains files and exact dependency declarations, or pass `project` to local MCP `artifact_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 the authoring contract through MCP. ## Remix -Select a working copy or historical revision in the gallery, then **Remix** and choose a new name. MCP offers `canvas_remix` and `script_remix`, with `new_name` and exactly one of `name` or `version_id`. Hosted calls optionally accept a new `slug`; otherwise it defaults to the destination name. Sources and destinations belong to the authenticated library and selected workspace. +Select a working copy or historical revision in the gallery, then **Remix** and choose a new name. MCP offers `artifact_remix` and `script_remix`, with `new_name` and exactly one of `name` or `version_id`. Hosted calls optionally accept a new `slug`; otherwise it defaults to the destination name. Sources and destinations belong to the authenticated library and selected workspace. -A remix creates a separate artifact and records its immutable source revision as provenance. Canvas browser and server sources stay paired. The new artifact starts with empty runtime data and no copied secret values; its hosted URL starts private. Destination names cannot overwrite existing drafts or archived artifacts, and URL collisions are rejected. Invalid source remains saved as a remix draft, with validation diagnostics; it receives a working URL only after successful validation. Remixing does not execute the request handler. +A remix creates a separate artifact and records its immutable source revision as provenance. Artifact browser and server sources stay paired. The new artifact starts with empty runtime data and no copied secret values; its hosted URL starts private. Destination names cannot overwrite existing drafts or archived artifacts, and URL collisions are rejected. Invalid source remains saved as a remix draft, with validation diagnostics; it receives a working URL only after successful validation. Remixing does not execute the request handler. ## Schedules and execution history -Hosted Cloudflare and celld servers can schedule scripts and canvas backends with native Durable Object alarms. Configure **Schedule** in the gallery, or call `script_schedule` / `canvas_schedule` with `name` and `action: "set"`. Supply exactly one of `interval_seconds` (at least 60) or a five-field `cron` expression. Cron accepts an IANA `timezone`, defaulting to `UTC`. Supply a relative HTTP `request` (path, method, headers, optional base64 body); the default is GET `/`. +Hosted Cloudflare and celld servers can schedule scripts and artifact backends with native Durable Object alarms. Configure **Schedule** in the gallery, or call `script_schedule` / `artifact_schedule` with `name` and `action: "set"`. Supply exactly one of `interval_seconds` (at least 60) or a five-field `cron` expression. Cron accepts an IANA `timezone`, defaulting to `UTC`. Supply a relative HTTP `request` (path, method, headers, optional base64 body); the default is GET `/`. -Use `action: "get"`, `"pause"`, `"resume"`, or `"run_now"` to inspect or control the schedule. Run-now executes the saved request, including while paused. Each artifact has one schedule. Removing a canvas server pauses its schedule. The local filesystem CLI/stdio runtime does not execute backends or run a scheduler; use `canvas server` for local scheduled execution. +Use `action: "get"`, `"pause"`, `"resume"`, or `"run_now"` to inspect or control the schedule. Run-now executes the saved request, including while paused. Each artifact has one schedule. Removing an artifacts server pauses its schedule. The local filesystem CLI/stdio runtime does not execute backends or run a scheduler; use `artifacts server` for local scheduled execution. Schedules execute the latest validated revision, not an invalid draft or a historical preview. An occurrence is durably claimed before calling user code; missed intervals are skipped rather than accumulated. Failures and uncertain interrupted invocations are not automatically replayed. Application handlers should make repeated writes idempotent when that matters. A local celld server must be running for alarms to execute. -`script_runs` and `canvas_runs` expose the latest 100 of up to 1,000 retained executions: revision, trigger, start/end times, duration, HTTP status and outcome. The gallery's **Run history** shows the same records. These records live in host-owned SQLite, separate from the user handler's database, and survive code updates and source restores. An `interrupted` record means completion is unknown and side effects may already have occurred. +`script_runs` and `artifact_runs` expose the latest 100 of up to 1,000 retained executions: revision, trigger, start/end times, duration, HTTP status and outcome. The gallery's **Run history** shows the same records. These records live in host-owned SQLite, separate from the user handler's database, and survive code updates and source restores. An `interrupted` record means completion is unknown and side effects may already have occurred. -Script logs can be filtered by `run_id` from `script_runs`; the existing 100-entry log retention still applies. Script success describes the HTTP handler returning a response, not completion of `waitUntil` tasks or streamed response bodies. Canvas history includes response serialization; canvas console capture is not provided. +Script logs can be filtered by `run_id` from `script_runs`; the existing 100-entry log retention still applies. Script success describes the HTTP handler returning a response, not completion of `waitUntil` tasks or streamed response bodies. Artifact history includes response serialization; artifact console capture is not provided. diff --git a/e2e/browser-plugins.test.ts b/e2e/browser-plugins.test.ts index 0cb7dea..1fa5adc 100644 --- a/e2e/browser-plugins.test.ts +++ b/e2e/browser-plugins.test.ts @@ -1,15 +1,15 @@ import { expect, test } from "bun:test"; import { chromium } from "playwright"; import { prepareBrowserPlugins } from "../scripts/prepare-browser-plugins"; -import { compileCanvas } from "../src/compile"; -import { tempDir, writeCanvas } from "../src/test/fixtures"; +import { compileArtifact } from "../src/compile"; +import { tempDir, writeArtifact } from "../src/test/fixtures"; import plugins from "../src/test/plugins/config"; const registry = await prepareBrowserPlugins(plugins, new URL("../src/test/plugins", import.meta.url).pathname); const source = `import { PluginCounter } from "@test/counter"; -export default function Canvas() { return ; }`; +export default function Artifact() { return ; }`; test("prepared plugin hooks share the renderer React and execute an installed transitive library", async () => { - const result = await compileCanvas(writeCanvas(tempDir(), "plugin", source), undefined, registry); + const result = await compileArtifact(writeArtifact(tempDir(), "plugin", source), undefined, registry); expect(result.diagnostics).toEqual([]); expect(result.ok).toBe(true); const browser = await chromium.launch({ headless: true }); diff --git a/e2e/celld-auth-runtime.ts b/e2e/celld-auth-runtime.ts index 1be0eea..90a3c9b 100644 --- a/e2e/celld-auth-runtime.ts +++ b/e2e/celld-auth-runtime.ts @@ -139,7 +139,7 @@ export async function startCelldAuthRuntime({ port, bindings }: RuntimeOptions): if (!existsSync(join(appDirectory, "worker.js"))) throw new Error("missing dist/worker-app/worker.js; run bun run build:cloudflare"); if (!existsSync(assetsDirectory)) throw new Error("missing dist/cloudflare/assets; run bun run build:cloudflare"); - const project = await mkdtemp(join(tmpdir(), "canvas-celld-auth-")); + const project = await mkdtemp(join(tmpdir(), "artifact-celld-auth-")); const configPath = join(project, "wrangler.jsonc"); const origin = `http://127.0.0.1:${port}`; let runtime: RunningCelld | undefined; @@ -153,7 +153,7 @@ export async function startCelldAuthRuntime({ port, bindings }: RuntimeOptions): const config = await prepareCelldConfig(resolve(root, "wrangler.jsonc"), configPath); config.d1_databases = [{ binding: "AUTH_DB", - database_name: "canvas-auth", + database_name: "artifact-auth", migrations_dir: "cloudflare/migrations", }]; config.vars = { ...(config.vars as Record | undefined), ...bindings }; diff --git a/e2e/gallery-scripts.test.ts b/e2e/gallery-scripts.test.ts index 965b8b9..3674ff0 100644 --- a/e2e/gallery-scripts.test.ts +++ b/e2e/gallery-scripts.test.ts @@ -14,7 +14,7 @@ test("gallery edits scripts without execution and runs requests only on demand", if (url.pathname === "/client.js") return new Response(js, { headers: { "content-type": "text/javascript" } }); 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 === "/gallery/preview") return new Response("

Artifact preview

", { headers: { "content-type": "text/html" } }); if (url.pathname === "/api/source") { const source = 'export default { fetch() { return new Response("hello"); } };'; return url.searchParams.get("format") === "json" diff --git a/e2e/herdr-pty.py b/e2e/herdr-pty.py index cd9e6a2..12de148 100644 --- a/e2e/herdr-pty.py +++ b/e2e/herdr-pty.py @@ -12,7 +12,7 @@ def main() -> int: - session = sys.argv[1] if len(sys.argv) > 1 else "canvas-e2e" + session = sys.argv[1] if len(sys.argv) > 1 else "artifact-e2e" os.environ.setdefault("TERM", "xterm-256color") os.environ.setdefault("SHELL", "/bin/bash") diff --git a/e2e/herdr.e2e.test.ts b/e2e/herdr.e2e.test.ts index 9b82759..3d2421d 100644 --- a/e2e/herdr.e2e.test.ts +++ b/e2e/herdr.e2e.test.ts @@ -3,7 +3,7 @@ import { expect, test } from "bun:test"; import { PLUGIN_ROOT } from "../src/paths"; test( - "opens a compiled canvas through a real Herdr PTY in Docker", + "opens a compiled artifact through a real Herdr PTY in Docker", async () => { if (process.env.HERDR_E2E === "1") { const inner = Bun.spawn(["bun", `${PLUGIN_ROOT}/e2e/inside.ts`], { @@ -15,14 +15,14 @@ test( return; } - const build = Bun.spawn(["docker", "build", "-t", "herdr-canvas-e2e", "."], { + const build = Bun.spawn(["docker", "build", "-t", "artifacts-e2e", "."], { cwd: PLUGIN_ROOT, stdout: "inherit", stderr: "inherit", }); expect(await build.exited).toBe(0); - const run = Bun.spawn(["docker", "run", "--rm", "-e", "HERDR_E2E=1", "herdr-canvas-e2e"], { + const run = Bun.spawn(["docker", "run", "--rm", "-e", "HERDR_E2E=1", "artifacts-e2e"], { stdout: "inherit", stderr: "inherit", }); diff --git a/e2e/inside.ts b/e2e/inside.ts index b867619..8517639 100644 --- a/e2e/inside.ts +++ b/e2e/inside.ts @@ -4,10 +4,10 @@ import { join } from "node:path"; import { spawnSync } from "node:child_process"; import { PLUGIN_ROOT } from "../src/paths"; -import { VALID_CANVAS } from "../src/test/fixtures"; +import { VALID_ARTIFACT } from "../src/test/fixtures"; -const SESSION = process.env.HERDR_SESSION ?? "canvas-e2e"; -const HOME = process.env.HOME ?? join(tmpdir(), "herdr-canvas-e2e-home"); +const SESSION = process.env.HERDR_SESSION ?? "artifact-e2e"; +const HOME = process.env.HOME ?? join(tmpdir(), "artifacts-e2e-home"); function herdr(args: string[]): { status: number; stdout: string; stderr: string } { const result = spawnSync("herdr", args, { @@ -90,34 +90,34 @@ async function main(): Promise { console.error("herdr session is ready"); mustHerdr(["plugin", "link", PLUGIN_ROOT]); - mustHerdr(["workspace", "create", "--cwd", PLUGIN_ROOT, "--label", "canvas-e2e", "--focus"]); + mustHerdr(["workspace", "create", "--cwd", PLUGIN_ROOT, "--label", "artifact-e2e", "--focus"]); const plugins = JSON.parse(mustHerdr(["plugin", "list", "--json"])) as { result?: { plugins?: Array<{ plugin_id?: string }> }; }; const pluginIds = (plugins.result?.plugins ?? []).map((plugin) => plugin.plugin_id ?? ""); - if (!pluginIds.includes("herdr.canvas")) { - throw new Error(`herdr.canvas not linked: ${JSON.stringify(pluginIds)}`); + if (!pluginIds.includes("herdr.artifacts")) { + throw new Error(`herdr.artifacts not linked: ${JSON.stringify(pluginIds)}`); } - const canvasesDir = mkdtempSync(join(tmpdir(), "herdr-canvas-e2e-")); - writeFileSync(join(canvasesDir, "overview.canvas.tsx"), VALID_CANVAS); - const historyDb = join(HOME, "canvas-history.sqlite"); - const canvasEnv = { + const artifactsDir = mkdtempSync(join(tmpdir(), "artifacts-e2e-")); + writeFileSync(join(artifactsDir, "overview.artifact.tsx"), VALID_ARTIFACT); + const historyDb = join(HOME, "artifact-history.sqlite"); + const artifactEnv = { ...process.env, HOME, HERDR_SESSION: SESSION, - HERDR_CANVAS_DIR: canvasesDir, - HERDR_CANVAS_HISTORY_DB: historyDb, - HERDR_PLUGIN_STATE_DIR: join(HOME, "canvas-state"), + ARTIFACTS_DIR: artifactsDir, + ARTIFACTS_HISTORY_DB: historyDb, + HERDR_PLUGIN_STATE_DIR: join(HOME, "artifact-state"), SHELL: "/bin/bash", TERM: "xterm-256color", }; - const opened = spawnSync("bun", [join(PLUGIN_ROOT, "src/cli.ts"), "open", "overview", "--dir", canvasesDir, "--placement", "tab"], { + const opened = spawnSync("bun", [join(PLUGIN_ROOT, "src/cli.ts"), "open", "overview", "--dir", artifactsDir, "--placement", "tab"], { encoding: "utf8", timeout: 30_000, - env: canvasEnv, + env: artifactEnv, }); if (opened.status !== 0) { - throw new Error(`canvas open failed\n${opened.stdout}\n${opened.stderr}`); + throw new Error(`artifacts open failed\n${opened.stdout}\n${opened.stderr}`); } const payload = JSON.parse(opened.stdout) as { ok: boolean; @@ -127,13 +127,13 @@ async function main(): Promise { herdr?: { stdout?: string }; }; if (!payload.ok) { - throw new Error(`canvas open returned failure: ${opened.stdout}`); + throw new Error(`artifacts open returned failure: ${opened.stdout}`); } - if (payload.check !== "Canvas TypeScript check: no errors") { + if (payload.check !== "Artifact TypeScript check: no errors") { throw new Error(payload.check ?? "missing typecheck"); } - if (payload.opened !== "canvas-pane") { - throw new Error(`open did not create a Canvas-owned pane: ${opened.stdout}`); + if (payload.opened !== "artifact-pane") { + throw new Error(`open did not create an Artifact-owned pane: ${opened.stdout}`); } const paneOpened = JSON.parse(payload.herdr?.stdout ?? "{}") as { @@ -146,41 +146,41 @@ async function main(): Promise { }; const pluginPane = paneOpened.result?.plugin_pane; const paneId = pluginPane?.pane?.pane_id; - if (pluginPane?.plugin_id !== "herdr.canvas" || pluginPane.pane?.label !== "Canvas" || !paneId) { - throw new Error(`pane is not owned by herdr.canvas: ${payload.herdr?.stdout}`); + if (pluginPane?.plugin_id !== "herdr.artifacts" || pluginPane.pane?.label !== "Artifact" || !paneId) { + throw new Error(`pane is not owned by herdr.artifacts: ${payload.herdr?.stdout}`); } - const canvasUrl = await waitForCanvasUrl(paneId); - const page = await fetch(canvasUrl); + const artifactUrl = await waitForArtifactUrl(paneId); + const page = await fetch(artifactUrl); const html = await page.text(); - if (!page.ok || !html.includes("__herdrCanvas") || !html.includes("bundle.js")) { - throw new Error(`canvas page was not served: ${page.status} ${html.slice(0, 500)}`); + if (!page.ok || !html.includes("__artifacts") || !html.includes("bundle.js")) { + throw new Error(`artifact page was not served: ${page.status} ${html.slice(0, 500)}`); } const scriptPath = html.match(/src="([^\"]+bundle\.js[^\"]*)"/)?.[1]; if (!scriptPath || !scriptPath.startsWith("/v/")) throw new Error("page bundle was not pinned to a version"); - const js = await fetch(new URL(scriptPath, canvasUrl)); + const js = await fetch(new URL(scriptPath, artifactUrl)); const bundle = await js.text(); if (!js.ok || !bundle.includes("Overview")) { - throw new Error(`canvas bundle missing compiled UI: ${js.status} ${bundle.slice(0, 200)}`); + throw new Error(`artifact bundle missing compiled UI: ${js.status} ${bundle.slice(0, 200)}`); } const paneList = mustHerdr(["pane", "list"]); - if (!paneList.includes("Canvas")) { - throw new Error(`Canvas pane missing after open\n${paneList}`); + if (!paneList.includes("Artifact")) { + throw new Error(`Artifact pane missing after open\n${paneList}`); } mustHerdr(["pane", "close", paneId]); - await waitForServerStop(canvasUrl); + await waitForServerStop(artifactUrl); - const archived = spawnSync("bun", [join(PLUGIN_ROOT, "src/cli.ts"), "history", "overview"], { encoding: "utf8", timeout: 10_000, env: canvasEnv }); + const archived = spawnSync("bun", [join(PLUGIN_ROOT, "src/cli.ts"), "history", "overview"], { encoding: "utf8", timeout: 10_000, env: artifactEnv }); if (archived.status !== 0) throw new Error(`history failed after close: ${archived.stderr}`); const versions = JSON.parse(archived.stdout).versions as Array<{ version_id: string; serve_count: number }>; if (versions.length !== 1 || !versions[0]?.serve_count) throw new Error(`missing durable history: ${archived.stdout}`); - unlinkSync(join(canvasesDir, "overview.canvas.tsx")); // Disposable E2E fixture. - const reopened = spawnSync("bun", [join(PLUGIN_ROOT, "src/cli.ts"), "open", "--version", versions[0].version_id, "--placement", "tab"], { encoding: "utf8", timeout: 30_000, env: canvasEnv }); + unlinkSync(join(artifactsDir, "overview.artifact.tsx")); // Disposable E2E fixture. + const reopened = spawnSync("bun", [join(PLUGIN_ROOT, "src/cli.ts"), "open", "--version", versions[0].version_id, "--placement", "tab"], { encoding: "utf8", timeout: 30_000, env: artifactEnv }); if (reopened.status !== 0) throw new Error(`archive reopen failed: ${reopened.stdout}\n${reopened.stderr}`); const replayPane = JSON.parse(JSON.parse(reopened.stdout).herdr.stdout).result.plugin_pane.pane.pane_id as string; - const replayUrl = await waitForCanvasUrl(replayPane); + const replayUrl = await waitForArtifactUrl(replayPane); if (!replayUrl.includes(`/v/${versions[0].version_id}`)) throw new Error(`wrong replay URL: ${replayUrl}`); const replayPage = await fetch(replayUrl); const replayHtml = await replayPage.text(); @@ -195,7 +195,7 @@ async function main(): Promise { JSON.stringify( { ok: true, - url: canvasUrl, + url: artifactUrl, opened: payload.opened, pluginId: pluginPane.plugin_id, paneId, @@ -218,7 +218,7 @@ async function main(): Promise { } } -async function waitForCanvasUrl(paneId: string, timeoutMs = 20_000): Promise { +async function waitForArtifactUrl(paneId: string, timeoutMs = 20_000): Promise { const started = Date.now(); let last = ""; while (Date.now() - started < timeoutMs) { @@ -231,20 +231,20 @@ async function waitForCanvasUrl(paneId: string, timeoutMs = 20_000): Promise { +async function waitForServerStop(artifactUrl: string, timeoutMs = 10_000): Promise { const started = Date.now(); while (Date.now() - started < timeoutMs) { try { - await fetch(canvasUrl, { signal: AbortSignal.timeout(500) }); + await fetch(artifactUrl, { signal: AbortSignal.timeout(500) }); } catch { return; } await Bun.sleep(200); } - throw new Error(`Canvas server remained alive after its pane closed: ${canvasUrl}`); + throw new Error(`Artifact server remained alive after its pane closed: ${artifactUrl}`); } await main(); diff --git a/e2e/mcp-app-host.ts b/e2e/mcp-app-host.ts index e2b8519..10226d2 100644 --- a/e2e/mcp-app-host.ts +++ b/e2e/mcp-app-host.ts @@ -10,7 +10,7 @@ type RequestBehavior = "accept" | "decline" | "throw"; declare global { interface Window { - canvasAppHtml: string; + artifactAppHtml: string; mcpHost?: { initialized: boolean; messages: unknown[]; @@ -51,7 +51,7 @@ let hostContext: McpUiHostContext = { const bridge = new AppBridge( null, - { name: "Canvas browser test host", version: "1.0.0" }, + { name: "Artifact browser test host", version: "1.0.0" }, { message: { text: {} }, openLinks: {}, serverTools: {} }, { hostContext }, ); @@ -144,4 +144,4 @@ bridge.oninitialized = () => { applyFrameSize(); await bridge.connect(new PostMessageTransport(iframe.contentWindow!, iframe.contentWindow!)); -iframe.srcdoc = window.canvasAppHtml; +iframe.srcdoc = window.artifactAppHtml; diff --git a/e2e/mcp-app.test.ts b/e2e/mcp-app.test.ts index 6d56cce..23ec1a5 100644 --- a/e2e/mcp-app.test.ts +++ b/e2e/mcp-app.test.ts @@ -3,38 +3,38 @@ import { readFileSync, rmSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { chromium, type Browser, type FrameLocator, type Page } from "playwright"; -import { canvasAppHtml, canvasAppResult, type CanvasAppPayload } from "../src/mcp/app"; -import { CanvasService } from "../src/service"; -import { tempDir, writeCanvas } from "../src/test/fixtures"; +import { artifactAppHtml, artifactAppResult, type ArtifactAppPayload } from "../src/mcp/app"; +import { ArtifactService } from "../src/service"; +import { tempDir, writeArtifact } from "../src/test/fixtures"; -const INTERACTIVE_CANVAS = `import { Button, H1, Stack, Text, useCanvasAction, useCanvasState, useHostTheme } from "sidequery/canvas"; +const INTERACTIVE_ARTIFACT = `import { Button, H1, Stack, Text, useArtifactAction, useArtifactState, useHostTheme } from "sidequery/artifacts"; export default function Interactive() { - const [count, setCount] = useCanvasState("count", 0); - const act = useCanvasAction(); + const [count, setCount] = useArtifactState("count", 0); + const act = useArtifactAction(); const theme = useHostTheme(); return ( -

Interactive canvas

+

Interactive artifact

Count: {count} Theme: {theme.kind} - - + +
); } `; -const RESIZABLE_CANVAS = `import { Button, H1, Stack, Text, useCanvasState } from "sidequery/canvas"; +const RESIZABLE_ARTIFACT = `import { Button, H1, Stack, Text, useArtifactState } from "sidequery/artifacts"; export default function Resizable() { - const [expanded, setExpanded] = useCanvasState("expanded", false); + const [expanded, setExpanded] = useArtifactState("expanded", false); const rows = expanded ? 45 : 2; return ( -

Resizable canvas

- This deliberately long sentence wraps onto more lines when the host makes the canvas narrow, while remaining fully readable. +

Resizable artifact

+ This deliberately long sentence wraps onto more lines when the host makes the artifact narrow, while remaining fully readable. {Array.from({ length: rows }, (_, index) => Content row {index + 1})} Last content @@ -43,16 +43,16 @@ export default function Resizable() { } `; -const SERVER_CANVAS = `import { Button, H1, Stack, Text, canvasFetch, useCanvasState } from "sidequery/canvas"; +const SERVER_ARTIFACT = `import { Button, H1, Stack, Text, artifactFetch, useArtifactState } from "sidequery/artifacts"; -export default function ServerCanvas() { - const [result, setResult] = useCanvasState("result", "idle"); +export default function ServerArtifact() { + const [result, setResult] = useArtifactState("result", "idle"); const load = async () => { try { - const response = await canvasFetch("/api/items?limit=2", { + const response = await artifactFetch("/api/items?limit=2", { method: "POST", headers: { "content-type": "application/json" }, - body: JSON.stringify({ source: "canvas" }), + body: JSON.stringify({ source: "artifact" }), }); setResult(String(response.status) + ":" + await response.text()); } catch (error) { @@ -61,7 +61,7 @@ export default function ServerCanvas() { }; return ( -

Server canvas

+

Server artifact

Result: {result}
@@ -74,17 +74,17 @@ let server: ReturnType; let hostHtml: string; const fixtureDirs = new Set(); -async function resultFor(source: string, name: string, state: Record = {}): Promise<{ canvas: CanvasAppPayload }> { - const dir = tempDir("canvas-mcp-browser-"); +async function resultFor(source: string, name: string, state: Record = {}): Promise<{ artifact: ArtifactAppPayload }> { + const dir = tempDir("artifact-mcp-browser-"); fixtureDirs.add(dir); - const path = writeCanvas(dir, name, source); - writeFileSync(path.replace(".canvas.tsx", ".canvas.data.json"), JSON.stringify(state)); - const result = await canvasAppResult(new CanvasService({ - canvasesDir: dir, + const path = writeArtifact(dir, name, source); + writeFileSync(path.replace(".artifact.tsx", ".artifact.data.json"), JSON.stringify(state)); + const result = await artifactAppResult(new ArtifactService({ + artifactsDir: dir, cwd: dir, - env: { HERDR_CANVAS_HISTORY_DB: join(dir, "history.sqlite") }, + env: { ARTIFACTS_HISTORY_DB: join(dir, "history.sqlite") }, }), { name }); - if (!("_meta" in result) || !result._meta) throw new Error("canvas result missing app metadata"); + if (!("_meta" in result) || !result._meta) throw new Error("artifact result missing app metadata"); return result._meta; } @@ -105,8 +105,8 @@ async function openHost(): Promise<{ page: Page; app: FrameLocator; errors: stri } async function layout(app: FrameLocator) { - return app.locator("#canvas-shell").evaluate(shell => { - const viewport = document.getElementById("canvas-viewport")!; + return app.locator("#artifact-shell").evaluate(shell => { + const viewport = document.getElementById("artifact-viewport")!; return { mode: document.documentElement.dataset.displayMode, shellHeight: shell.getBoundingClientRect().height, @@ -128,11 +128,11 @@ beforeAll(async () => { }); if (!hostBuild.success) throw new Error(hostBuild.logs.join("\n")); const hostJs = await hostBuild.outputs[0]!.text(); - const appHtml = (await canvasAppHtml()).replace( + const appHtml = (await artifactAppHtml()).replace( "", ``, ); - hostHtml = ``; + hostHtml = ``; server = Bun.serve({ hostname: "127.0.0.1", port: 0, @@ -154,31 +154,31 @@ afterEach(async () => { fixtureDirs.clear(); }); -test("renders and replaces interactive canvases through the MCP Apps bridge", async () => { - const dir = tempDir("canvas-mcp-browser-"); +test("renders and replaces interactive artifacts through the MCP Apps bridge", async () => { + const dir = tempDir("artifact-mcp-browser-"); fixtureDirs.add(dir); const db = join(dir, "history.sqlite"); - const sourcePath = writeCanvas(dir, "interactive", INTERACTIVE_CANVAS); - const statePath = sourcePath.replace(".canvas.tsx", ".canvas.data.json"); + const sourcePath = writeArtifact(dir, "interactive", INTERACTIVE_ARTIFACT); + const statePath = sourcePath.replace(".artifact.tsx", ".artifact.data.json"); writeFileSync(statePath, '{"count":4}'); const originalSource = readFileSync(sourcePath, "utf8"); const originalSidecar = readFileSync(statePath, "utf8"); - const service = new CanvasService({ - canvasesDir: dir, + const service = new ArtifactService({ + artifactsDir: dir, cwd: dir, - env: { HERDR_CANVAS_HISTORY_DB: db }, + env: { ARTIFACTS_HISTORY_DB: db }, }); - const delivered = await canvasAppResult(service, { name: "interactive" }); + const delivered = await artifactAppResult(service, { name: "interactive" }); expect(delivered.ok).toBe(true); - if (!("_meta" in delivered) || !delivered._meta) throw new Error("canvas result missing app metadata"); + if (!("_meta" in delivered) || !delivered._meta) throw new Error("artifact result missing app metadata"); const { page, app, errors } = await openHost(); await page.evaluate(result => window.mcpHost!.sendResult(result), { - content: [{ type: "text", text: "Canvas ready" }], + content: [{ type: "text", text: "Artifact ready" }], _meta: delivered._meta, }); - await app.getByRole("heading", { name: "Interactive canvas" }).waitFor(); + await app.getByRole("heading", { name: "Interactive artifact" }).waitFor(); await app.getByText("Count: 4").waitFor(); await app.getByText("Theme: dark").waitFor(); await app.getByRole("button", { name: "Increment" }).click(); @@ -187,11 +187,11 @@ test("renders and replaces interactive canvases through the MCP Apps bridge", as await app.getByRole("button", { name: "Prompt agent" }).click(); await page.waitForFunction(() => window.mcpHost!.messages.length === 1); expect(await page.evaluate(() => window.mcpHost!.messages)).toEqual([ - { role: "user", content: [{ type: "text", text: "Explain this canvas" }] }, + { role: "user", content: [{ type: "text", text: "Explain this artifact" }] }, ]); await app.getByRole("button", { name: "Open docs" }).click(); await page.waitForFunction(() => window.mcpHost!.links.length === 1); - expect(await page.evaluate(() => window.mcpHost!.links)).toEqual(["https://example.com/canvas"]); + expect(await page.evaluate(() => window.mcpHost!.links)).toEqual(["https://example.com/artifact"]); await page.evaluate(() => window.mcpHost!.setTheme("light")); await app.getByText("Theme: light").waitFor(); @@ -199,17 +199,17 @@ test("renders and replaces interactive canvases through the MCP Apps bridge", as expect(readFileSync(sourcePath, "utf8")).toBe(originalSource); expect(readFileSync(statePath, "utf8")).toBe(originalSidecar); - const replacementPath = writeCanvas(dir, "replacement", INTERACTIVE_CANVAS.replace("Interactive canvas", "Replacement canvas")); - writeFileSync(replacementPath.replace(".canvas.tsx", ".canvas.data.json"), '{"count":9}'); - const replacement = await canvasAppResult(service, { name: "replacement" }); + const replacementPath = writeArtifact(dir, "replacement", INTERACTIVE_ARTIFACT.replace("Interactive artifact", "Replacement artifact")); + writeFileSync(replacementPath.replace(".artifact.tsx", ".artifact.data.json"), '{"count":9}'); + const replacement = await artifactAppResult(service, { name: "replacement" }); if (!("_meta" in replacement) || !replacement._meta) throw new Error("replacement result missing app metadata"); await page.evaluate(result => window.mcpHost!.sendResult(result), { content: [{ type: "text", text: "Replacement ready" }], _meta: replacement._meta, }); - await app.getByRole("heading", { name: "Replacement canvas" }).waitFor(); + await app.getByRole("heading", { name: "Replacement artifact" }).waitFor(); await app.getByText("Count: 9").waitFor(); - expect(await app.getByRole("heading", { name: "Interactive canvas" }).count()).toBe(0); + expect(await app.getByRole("heading", { name: "Interactive artifact" }).count()).toBe(0); await page.evaluate(async results => { await Promise.all(results.map(result => window.mcpHost!.sendResult(result))); @@ -217,19 +217,19 @@ test("renders and replaces interactive canvases through the MCP Apps bridge", as { content: [{ type: "text", text: "Old result" }], _meta: delivered._meta }, { content: [{ type: "text", text: "Newest result" }], _meta: replacement._meta }, ]); - await app.getByRole("heading", { name: "Replacement canvas" }).waitFor(); + await app.getByRole("heading", { name: "Replacement artifact" }).waitFor(); await page.waitForTimeout(100); - expect(await app.getByRole("heading", { name: "Interactive canvas" }).count()).toBe(0); + expect(await app.getByRole("heading", { name: "Interactive artifact" }).count()).toBe(0); await page.evaluate(() => window.mcpHost!.cancel("Stopped by host")); await app.getByRole("status").waitFor(); - expect(await app.getByRole("status").textContent()).toBe("Canvas request cancelled."); - expect(await app.getByRole("heading", { name: "Replacement canvas" }).count()).toBe(0); + expect(await app.getByRole("status").textContent()).toBe("Artifact request cancelled."); + expect(await app.getByRole("heading", { name: "Replacement artifact" }).count()).toBe(0); await page.evaluate(result => window.mcpHost!.sendResult(result), { content: [{ type: "text", text: "Replacement ready again" }], _meta: replacement._meta, }); - await app.getByRole("heading", { name: "Replacement canvas" }).waitFor(); + await app.getByRole("heading", { name: "Replacement artifact" }).waitFor(); await page.evaluate(() => window.mcpHost!.sendResult({ content: [{ type: "text", text: "Compilation failed" }], @@ -237,19 +237,19 @@ test("renders and replaces interactive canvases through the MCP Apps bridge", as })); await app.getByRole("status").waitFor(); expect(await app.getByRole("status").textContent()).toBe("Compilation failed"); - expect(await app.getByRole("heading", { name: "Replacement canvas" }).count()).toBe(0); + expect(await app.getByRole("heading", { name: "Replacement artifact" }).count()).toBe(0); expect(errors).toEqual([]); await page.close(); }, 30_000); test("applies natural, capped, shrinking, narrow, and fixed inline sizes without feedback", async () => { - const meta = await resultFor(RESIZABLE_CANVAS, "resizable"); + const meta = await resultFor(RESIZABLE_ARTIFACT, "resizable"); const { page, app, errors } = await openHost(); await page.evaluate(meta => window.mcpHost!.sendResult({ content: [{ type: "text", text: "Resizable ready" }], _meta: meta, }), meta); - await app.getByRole("heading", { name: "Resizable canvas" }).waitFor(); + await app.getByRole("heading", { name: "Resizable artifact" }).waitFor(); await page.waitForFunction(() => document.querySelector("iframe")!.getBoundingClientRect().height > 100); const iframe = page.locator("#app"); @@ -269,7 +269,7 @@ test("applies natural, capped, shrinking, narrow, and fixed inline sizes without const last = app.getByText("Last content"); await last.scrollIntoViewIfNeeded(); const lastBox = (await last.boundingBox())!; - const viewportBox = (await app.locator("#canvas-viewport").boundingBox())!; + const viewportBox = (await app.locator("#artifact-viewport").boundingBox())!; expect(lastBox.y + lastBox.height).toBeLessThanOrEqual(viewportBox.y + viewportBox.height + 1); await page.evaluate(() => window.mcpHost!.configure({ containerDimensions: { width: 640, maxHeight: 360 } })); @@ -287,7 +287,7 @@ test("applies natural, capped, shrinking, narrow, and fixed inline sizes without expect(silentlyClamped.viewportScrollHeight).toBeGreaterThan(silentlyClamped.viewportClientHeight); await last.scrollIntoViewIfNeeded(); const clampedLastBox = (await last.boundingBox())!; - const clampedViewportBox = (await app.locator("#canvas-viewport").boundingBox())!; + const clampedViewportBox = (await app.locator("#artifact-viewport").boundingBox())!; expect(clampedLastBox.y + clampedLastBox.height).toBeLessThanOrEqual(clampedViewportBox.y + clampedViewportBox.height + 1); await page.evaluate(() => window.mcpHost!.setInlineFrameLimit()); await page.waitForFunction(() => document.querySelector("iframe")!.getBoundingClientRect().height === 600); @@ -325,7 +325,7 @@ test("applies natural, capped, shrinking, narrow, and fixed inline sizes without }, 30_000); test("negotiates fullscreen while preserving state and survives external, declined, and failed changes", async () => { - const meta = await resultFor(INTERACTIVE_CANVAS, "modes", { count: 4 }); + const meta = await resultFor(INTERACTIVE_ARTIFACT, "modes", { count: 4 }); const { page, app, errors } = await openHost(); await page.evaluate(meta => window.mcpHost!.sendResult({ content: [{ type: "text", text: "Modes ready" }], @@ -333,11 +333,11 @@ test("negotiates fullscreen while preserving state and survives external, declin }), meta); await app.getByText("Count: 4").waitFor(); const iframe = page.locator("#app"); - const expand = app.getByRole("button", { name: "Expand canvas" }); + const expand = app.getByRole("button", { name: "Expand artifact" }); await expand.waitFor(); const appearance = await expand.evaluate(button => { - const viewport = document.getElementById("canvas-viewport")!; - const shell = document.getElementById("canvas-shell")!; + const viewport = document.getElementById("artifact-viewport")!; + const shell = document.getElementById("artifact-shell")!; return { bodyBackground: getComputedStyle(document.body).backgroundColor, htmlBackground: getComputedStyle(document.documentElement).backgroundColor, @@ -405,9 +405,10 @@ test("negotiates fullscreen while preserving state and survives external, declin await page.close(); }, 30_000); -test("routes canvasFetch through canvas_request only for server-enabled MCP canvases", async () => { - const meta = await resultFor(SERVER_CANVAS, "server-data"); - meta.canvas.server = true; +test.each([false, true])("routes server requests for cached legacy runtime=%s only when enabled", async legacy => { + const meta = await resultFor(SERVER_ARTIFACT, "server-data"); + if (legacy) meta.artifact.js = meta.artifact.js.replaceAll("__artifacts", "__herdrCanvas"); + meta.artifact.server = true; const { page, app, errors } = await openHost(); await page.evaluate(() => window.mcpHost!.setServerToolResult({ content: [], @@ -421,26 +422,26 @@ test("routes canvasFetch through canvas_request only for server-enabled MCP canv }, })); await page.evaluate(meta => window.mcpHost!.sendResult({ - content: [{ type: "text", text: "Server canvas ready" }], + content: [{ type: "text", text: "Server artifact ready" }], _meta: meta, }), meta); - await app.getByRole("heading", { name: "Server canvas" }).waitFor(); + await app.getByRole("heading", { name: "Server artifact" }).waitFor(); const load = app.getByRole("button", { name: "Load server data" }); await load.click(); await app.getByText('Result: 202:{"rows":2}').waitFor(); const calls = await page.evaluate(() => window.mcpHost!.serverToolCalls); expect(calls).toHaveLength(1); - expect(calls[0]?.name).toBe("canvas_request"); + expect(calls[0]?.name).toBe("artifact_request"); expect(Object.keys(calls[0]?.arguments ?? {}).sort()).toEqual(["request", "version_id"]); - expect(calls[0]?.arguments?.version_id).toBe(meta.canvas.versionId); + expect(calls[0]?.arguments?.version_id).toBe(meta.artifact.versionId); const request = calls[0]?.arguments?.request as { path: string; method: string; headers: [string, string][]; body: string; }; expect(request.path).toBe("/api/items?limit=2"); expect(request.method).toBe("POST"); expect(new Headers(request.headers).get("content-type")).toBe("application/json"); - expect(atob(request.body)).toBe('{"source":"canvas"}'); + expect(atob(request.body)).toBe('{"source":"artifact"}'); await page.evaluate(() => window.mcpHost!.setServerToolResult({ content: [{ type: "text", text: "Database unavailable" }], @@ -450,22 +451,22 @@ test("routes canvasFetch through canvas_request only for server-enabled MCP canv await app.getByText("Result: error:Database unavailable").waitFor(); await page.evaluate(() => window.mcpHost!.setServerToolResult({ content: [], structuredContent: {} })); await load.click(); - await app.getByText("Result: error:Canvas server response was missing.").waitFor(); + await app.getByText("Result: error:Artifact server response was missing.").waitFor(); - const withoutServer = { canvas: { ...meta.canvas, server: false } }; + const withoutServer = { artifact: { ...meta.artifact, server: false } }; await page.evaluate(meta => window.mcpHost!.sendResult({ content: [], _meta: meta }), withoutServer); - await app.getByRole("heading", { name: "Server canvas" }).waitFor(); + await app.getByRole("heading", { name: "Server artifact" }).waitFor(); await load.click(); - await app.getByText("Result: error:Canvas server requests are unavailable in this view.").waitFor(); + await app.getByText("Result: error:Artifact server requests are unavailable in this view.").waitFor(); expect(await page.evaluate(() => window.mcpHost!.serverToolCalls.length)).toBe(3); expect(errors).toEqual([]); await page.close(); }, 30_000); test("ordinary React hooks update component state through the canonical and legacy SDK imports", async () => { - const { HOOKS_CANVAS } = await import("../src/test/fixtures"); - for (const specifier of ["sidequery/canvas", "herdr/canvas", "cursor/canvas"]) { - const meta = await resultFor(HOOKS_CANVAS.replaceAll("sidequery/canvas", specifier), "hooks"); + const { HOOKS_ARTIFACT } = await import("../src/test/fixtures"); + for (const specifier of ["sidequery/artifacts", "herdr/canvas", "cursor/canvas"]) { + const meta = await resultFor(HOOKS_ARTIFACT.replaceAll("sidequery/artifacts", specifier), "hooks"); const { page, app, errors } = await openHost(); await page.evaluate(meta => window.mcpHost!.sendResult({ content: [], _meta: meta }), meta); await app.getByRole("button", { name: "Hooks 0:0:0:0:0", exact: true }).click(); @@ -478,25 +479,25 @@ test("ordinary React hooks update component state through the canonical and lega test("routes plugin calls through MCP and removes the bridge for an unavailable view", async () => { - const source = `import { Button, pluginCall, useState } from "sidequery/canvas"; -export default function Canvas() { + const source = `import { Button, pluginCall, useState } from "sidequery/artifacts"; +export default function Artifact() { const [value, setValue] = useState("idle"); return <>

{value}

; }`; const meta = await resultFor(source, "plugin-call"); - meta.canvas.plugins = true; + meta.artifact.plugins = true; const { page, app, errors } = await openHost(); await page.evaluate(() => window.mcpHost!.setServerToolResult({ content: [], structuredContent: { result: "Found one" } })); await page.evaluate(meta => window.mcpHost!.sendResult({ content: [], _meta: meta }), meta); await app.getByRole("button", { name: "Lookup" }).click(); await app.getByText("Found one").waitFor(); expect(await page.evaluate(() => window.mcpHost!.serverToolCalls.map(({ name, arguments: args }) => ({ name, arguments: args })))).toEqual([ - { name: "canvas_plugin_call", arguments: { plugin: "directory", operation: "lookup", input: { id: "one" } } }, + { name: "artifact_plugin_call", arguments: { plugin: "directory", operation: "lookup", input: { id: "one" } } }, ]); await page.evaluate(() => window.mcpHost!.setServerToolResult({ content: [{ type: "text", text: "Operation denied" }], isError: true })); await app.getByRole("button", { name: "Lookup" }).click(); await app.getByText("Operation denied").waitFor(); - meta.canvas.plugins = false; + meta.artifact.plugins = false; await page.evaluate(meta => window.mcpHost!.sendResult({ content: [], _meta: meta }), meta); await app.getByRole("button", { name: "Lookup" }).click(); await app.getByText("Plugin functions are unavailable in this view").waitFor(); @@ -506,9 +507,9 @@ export default function Canvas() { }, 30_000); -test("routed canvases navigate inside MCP and reset when a new preview arrives", async () => { - const { ROUTING_CANVAS } = await import("../src/test/routing"); - const meta = await resultFor(ROUTING_CANVAS, "routed"); +test("routed artifacts navigate inside MCP and reset when a new preview arrives", async () => { + const { ROUTING_ARTIFACT } = await import("../src/test/routing"); + const meta = await resultFor(ROUTING_ARTIFACT, "routed"); const { page, app, errors } = await openHost(); const outerUrl = page.url(); await page.evaluate(meta => window.mcpHost!.sendResult({ content: [], _meta: meta }), meta); @@ -523,15 +524,16 @@ test("routed canvases navigate inside MCP and reset when a new preview arrives", await page.close(); }, 30000); -test("canvas files use pinned metadata tools and host downloads without a generated server", async () => { - const source = `import { Button, canvasFiles, useState } from "sidequery/canvas"; -export default function Canvas() { +test.each([false, true])("file tools and downloads support cached legacy runtime=%s without a server", async legacy => { + const source = `import { Button, artifactFiles, useState } from "sidequery/artifacts"; +export default function Artifact() { const [value, setValue] = useState("idle"); - return <>

{value}

; + return <>

{value}

; }`; const meta = await resultFor(source, "files"); - meta.canvas.files = true; - meta.canvas.server = false; + if (legacy) meta.artifact.js = meta.artifact.js.replaceAll("__artifacts", "__herdrCanvas"); + meta.artifact.files = true; + meta.artifact.server = false; const { page, app, errors } = await openHost(); const file = { id: "one", name: "report.csv", size: 123, type: "text/csv", uploaded: "2026-09-07T00:00:00Z" }; await page.evaluate(file => window.mcpHost!.setServerToolResult({ content: [], structuredContent: { result: { files: [file] } } }), file); @@ -539,22 +541,48 @@ export default function Canvas() { await app.getByRole("button", { name: "List files" }).click(); await app.getByText("report.csv", { exact: true }).waitFor(); expect(await page.evaluate(() => window.mcpHost!.serverToolCalls.map(({ name, arguments: args }) => ({ name, arguments: args })))).toEqual([ - { name: "canvas_files", arguments: { version_id: meta.canvas.versionId, request: { operation: "list" } } }, + { name: "artifact_files", arguments: { version_id: meta.artifact.versionId, request: { operation: "list" } } }, ]); - const url = `https://canvas.example/api/canvas/files/transfer/${"a".repeat(64)}/12345678-1234-1234-1234-123456789abc`; + const url = `https://artifact.example/api/${legacy ? "canvas" : "artifact"}/files/transfer/${"a".repeat(64)}/12345678-1234-1234-1234-123456789abc`; await page.evaluate(({ file, url }) => window.mcpHost!.setServerToolResult({ content: [], structuredContent: { result: { file, url, expires: "2026-09-07T00:05:00Z" } } }), { file, url }); await app.getByRole("button", { name: "Download file" }).click(); await app.getByText("downloaded", { exact: true }).waitFor(); expect(await page.evaluate(() => window.mcpHost!.links)).toEqual([url]); - expect(await page.evaluate(() => { const call = window.mcpHost!.serverToolCalls[1]!; return { name: call.name, arguments: call.arguments }; })).toEqual({ name: "canvas_files", arguments: { version_id: meta.canvas.versionId, request: { operation: "download", id: "one" } } }); + expect(await page.evaluate(() => { const call = window.mcpHost!.serverToolCalls[1]!; return { name: call.name, arguments: call.arguments }; })).toEqual({ name: "artifact_files", arguments: { version_id: meta.artifact.versionId, request: { operation: "download", id: "one" } } }); await page.evaluate(() => window.mcpHost!.setServerToolResult({ content: [{ type: "text", text: "File access denied" }], isError: true })); await app.getByRole("button", { name: "List files" }).click(); await app.getByText("File access denied", { exact: true }).waitFor(); - meta.canvas.files = false; + meta.artifact.files = false; await page.evaluate(meta => window.mcpHost!.sendResult({ content: [], _meta: meta }), meta); await app.getByRole("button", { name: "List files" }).click(); - await app.getByText("Canvas files are unavailable in this view.", { exact: true }).waitFor(); + await app.getByText("Artifact files are unavailable in this view.", { exact: true }).waitFor(); expect(await page.evaluate(() => window.mcpHost!.serverToolCalls.length)).toBe(3); expect(errors).toEqual([]); await page.close(); }, 30_000); + + +test("cached Canvas runtime receives state and themes and unmounts when replaced", async () => { + const meta = await resultFor(INTERACTIVE_ARTIFACT, "cached", { count: 7 }); + // Archived JS carries the original globals/event names independently of today's compiler aliases. + meta.artifact.js = meta.artifact.js.replaceAll("__artifacts", "__herdrCanvas").replaceAll("artifact-theme-change", "canvas-theme-change"); + const { page, app, errors } = await openHost(); + await page.evaluate(meta => window.mcpHost!.sendResult({ content: [], _meta: meta }), meta); + await app.getByText("Count: 7").waitFor(); + await app.getByRole("button", { name: "Increment" }).click(); + await app.getByText("Count: 8").waitFor(); + await page.evaluate(() => window.mcpHost!.setTheme("light")); + await app.getByText("Theme: light").waitFor(); + await app.locator("#root").evaluate(() => { + const host = window as typeof window & { __herdrCanvasUnmount?: () => void; legacyUnmounts?: number }; + const unmount = host.__herdrCanvasUnmount!; + host.__herdrCanvasUnmount = () => { host.legacyUnmounts = (host.legacyUnmounts ?? 0) + 1; unmount(); }; + }); + const replacement = await resultFor(INTERACTIVE_ARTIFACT.replace("Interactive artifact", "Fresh artifact"), "fresh", { count: 2 }); + await page.evaluate(meta => window.mcpHost!.sendResult({ content: [], _meta: meta }), replacement); + await app.getByRole("heading", { name: "Fresh artifact" }).waitFor(); + await app.getByText("Count: 2").waitFor(); + expect(await app.locator("#root").evaluate(() => (window as typeof window & { legacyUnmounts?: number }).legacyUnmounts)).toBe(1); + expect(errors).toEqual([]); + await page.close(); +}, 30000); diff --git a/e2e/oidc-fixture.ts b/e2e/oidc-fixture.ts index 6beb912..82dac62 100644 --- a/e2e/oidc-fixture.ts +++ b/e2e/oidc-fixture.ts @@ -1,8 +1,8 @@ import { SignJWT, exportJWK, generateKeyPair } from "jose"; -const CLIENT_ID = "canvas-test"; +const CLIENT_ID = "artifact-test"; const CLIENT_SECRET = "fixture-only-not-real-secret"; -const KEY_ID = "canvas-oidc-fixture"; +const KEY_ID = "artifact-oidc-fixture"; const USERS = { "test-alice": { id: "test-alice", email: "alice@example.test", name: "Alice" }, diff --git a/e2e/plugin-bridge.test.ts b/e2e/plugin-bridge.test.ts index d37fb29..c323287 100644 --- a/e2e/plugin-bridge.test.ts +++ b/e2e/plugin-bridge.test.ts @@ -13,23 +13,23 @@ test("gallery and standalone frame bridge requires enablement and accepts only i const errors: string[] = []; page.on("pageerror", error => errors.push(error.message)); await page.goto(`http://127.0.0.1:${server.port}`); - await page.setContent(''); + await page.setContent(''); await page.evaluate(bridge => { window.addEventListener("message", event => { - const frame = document.querySelector("#canvas")!; - if (event.source !== frame.contentWindow || event.data?.type !== "canvas/plugin-request") return; + const frame = document.querySelector("#artifact")!; + if (event.source !== frame.contentWindow || event.data?.type !== "artifact/plugin-request") return; // A sibling's forged response has the right ID but the wrong source. document.querySelector("#other")!.contentWindow!.postMessage(event.data, "*"); - setTimeout(() => frame.contentWindow!.postMessage({ type: "canvas/plugin-response", id: event.data.id, result: event.data.request.input }, "*"), 100); + setTimeout(() => frame.contentWindow!.postMessage({ type: "artifact/plugin-response", id: event.data.id, result: event.data.request.input }, "*"), 100); }); - document.querySelector("#other")!.srcdoc = `', () => ``; + return `Artifacts authentication
`; } diff --git a/src/canvasFile.test.ts b/src/canvasFile.test.ts deleted file mode 100644 index 5a863b1..0000000 --- a/src/canvasFile.test.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { expect, test } from "bun:test"; -import { writeFileSync } from "node:fs"; -import { join } from "node:path"; - -import { - canvasIdFromFile, - canvasesDirFrom, - ensureCanvasFileName, - listCanvasFiles, - resolveCanvasFile, -} from "./canvasFile"; -import { tempDir, VALID_CANVAS, writeCanvas } from "./test/fixtures"; - -test("canvasesDirFrom uses env override", () => { - expect(canvasesDirFrom("/tmp/proj", { HERDR_CANVAS_DIR: "/var/canvases" })).toBe("/var/canvases"); -}); - -test("canvasesDirFrom reads worktree context", () => { - expect( - canvasesDirFrom("/tmp/proj", { - HERDR_PLUGIN_CONTEXT_JSON: JSON.stringify({ worktree: { path: "/repo" } }), - }), - ).toBe("/repo/canvases"); -}); - -test("ensureCanvasFileName rejects slashes", () => { - expect(() => ensureCanvasFileName("../etc/passwd")).toThrow("slashes"); -}); - -test("resolveCanvasFile maps a bare name into the canvases dir", () => { - expect(resolveCanvasFile("billing-review", "/tmp/canvases")).toBe( - join("/tmp/canvases", "billing-review.canvas.tsx"), - ); -}); - -test("listCanvasFiles returns only canvas sources", () => { - const dir = tempDir(); - writeCanvas(dir, "one", VALID_CANVAS); - writeFileSync(join(dir, "notes.md"), "hi"); - expect(listCanvasFiles(dir).map((path) => canvasIdFromFile(path))).toEqual(["one"]); -}); diff --git a/src/cli.test.ts b/src/cli.test.ts index 11910d1..26a8765 100644 --- a/src/cli.test.ts +++ b/src/cli.test.ts @@ -3,11 +3,11 @@ import { join } from "node:path"; import { unlinkSync, readFileSync } from "node:fs"; import { PLUGIN_ROOT } from "./paths"; -import { VALID_CANVAS, tempDir, writeCanvas } from "./test/fixtures"; -import { CanvasHistory } from "./history"; -import { createCanvasServer } from "./serve"; +import { VALID_ARTIFACT, tempDir, writeArtifact } from "./test/fixtures"; +import { ArtifactHistory } from "./history"; +import { createArtifactServer } from "./serve"; import { handleMcpRequest } from "./mcp/local-tools"; -import { CanvasService } from "./service"; +import { ArtifactService } from "./service"; import { parseArgs } from "./args"; import { runServerCommand } from "./cli"; @@ -63,22 +63,22 @@ test("server CLI dispatches lifecycle commands and foreground readiness", async })).rejects.toThrow("shutdown cleanup failed"); }); -test("CLI list and typecheck work against a temp canvases dir", async () => { +test("CLI list and typecheck work against a temp artifacts dir", async () => { const dir = tempDir(); - writeCanvas(dir, "overview", VALID_CANVAS); + writeArtifact(dir, "overview", VALID_ARTIFACT); const list = await runCli(["list", "--dir", dir]); expect(list.exitCode).toBe(0); - const listed = JSON.parse(list.stdout) as { canvases: Array<{ id: string }> }; - expect(listed.canvases.map((item) => item.id)).toEqual(["overview"]); + const listed = JSON.parse(list.stdout) as { artifacts: Array<{ id: string }> }; + expect(listed.artifacts.map((item) => item.id)).toEqual(["overview"]); const check = await runCli(["typecheck", "overview", "--dir", dir]); expect(check.exitCode).toBe(0); - expect(JSON.parse(check.stdout).check).toBe("Canvas TypeScript check: no errors"); + expect(JSON.parse(check.stdout).check).toBe("Artifact TypeScript check: no errors"); }, { timeout: 30_000 }); -test("CLI compile bundles a valid canvas", async () => { +test("CLI compile bundles a valid artifact", async () => { const dir = tempDir(); - writeCanvas(dir, "overview", VALID_CANVAS); + writeArtifact(dir, "overview", VALID_ARTIFACT); const compiled = await runCli(["compile", "overview", "--dir", dir]); expect(compiled.exitCode).toBe(0); const payload = JSON.parse(compiled.stdout) as { ok: boolean; bytes: number }; @@ -86,14 +86,14 @@ test("CLI compile bundles a valid canvas", async () => { expect(payload.bytes).toBeGreaterThan(100); }); -test("CLI open uses the plugin canvas name fallback and prefers an explicit name", async () => { +test("CLI open uses the plugin artifact name fallback and prefers an explicit name", async () => { const dir = tempDir(); - const fallbackPath = writeCanvas(dir, "fallback", VALID_CANVAS); - const explicitPath = writeCanvas(dir, "explicit", VALID_CANVAS); + const fallbackPath = writeArtifact(dir, "fallback", VALID_ARTIFACT); + const explicitPath = writeArtifact(dir, "explicit", VALID_ARTIFACT); const dbPath = join(dir, "history.sqlite"); - const server = await createCanvasServer({ canvasesDir: dir, historyPath: dbPath }); + const server = await createArtifactServer({ artifactsDir: dir, historyPath: dbPath }); const common = ["--dir", dir, "--history-db", dbPath, "--no-open"]; - const env = { HERDR_CANVAS_NAME: "fallback", HERDR_CANVAS_SERVER_URL: server.url }; + const env = { ARTIFACTS_NAME: "fallback", ARTIFACTS_SERVER_URL: server.url }; try { const fallback = await runCli(["open", ...common], env); expect(fallback.exitCode).toBe(0); @@ -104,30 +104,30 @@ test("CLI open uses the plugin canvas name fallback and prefers an explicit name for (const versionFlags of [["--version"], ["--version="]]) { const invalidVersion = await runCli(["open", ...versionFlags, ...common], env); expect(invalidVersion.exitCode).toBe(1); - expect(invalidVersion.stderr).toContain("provide a canvas name or --version ID"); + expect(invalidVersion.stderr).toContain("provide an artifact name or --version ID"); } } finally { server.stop(); } }, { timeout: 30_000 }); -test("CLI history, show, open --version and restore work for an archived canvas whose file was deleted", async () => { +test("CLI history, show, open --version and restore work for an archived artifact whose file was deleted", async () => { const dir = tempDir(); - const path = writeCanvas(dir, "overview", VALID_CANVAS); + const path = writeArtifact(dir, "overview", VALID_ARTIFACT); const dbPath = join(dir, "history.sqlite"); - const history = new CanvasHistory(dbPath); - const version = history.capture({ workspace: dir, name: "overview", sourcePath: path, source: VALID_CANVAS, runtime: "test" }); + const history = new ArtifactHistory(dbPath); + const version = history.capture({ workspace: dir, name: "overview", sourcePath: path, source: VALID_ARTIFACT, runtime: "test" }); const eventId = history.served(version.id, { count: 3 }, "live"); history.close(); unlinkSync(path); const common = ["--dir", dir, "--history-db", dbPath]; - const server = await createCanvasServer({ canvasesDir: dir, historyPath: dbPath }); + const server = await createArtifactServer({ artifactsDir: dir, historyPath: dbPath }); try { const listing = await runCli(["history", "overview", ...common]); expect(listing.exitCode).toBe(0); expect(JSON.parse(listing.stdout).versions[0].version_id).toBe(version.id); const shown = await runCli(["show", version.id, ...common]); - expect(JSON.parse(shown.stdout).source).toBe(VALID_CANVAS); - expect((await runCli(["show", version.id, "--source", ...common])).stdout).toBe(VALID_CANVAS); - const reopened = await runCli(["open", "--version", version.id, "--event", eventId, ...common, "--no-open"], { HERDR_CANVAS_SERVER_URL: server.url, HERDR_CANVAS_NAME: "unrelated" }); + expect(JSON.parse(shown.stdout).source).toBe(VALID_ARTIFACT); + expect((await runCli(["show", version.id, "--source", ...common])).stdout).toBe(VALID_ARTIFACT); + const reopened = await runCli(["open", "--version", version.id, "--event", eventId, ...common, "--no-open"], { ARTIFACTS_SERVER_URL: server.url, ARTIFACTS_NAME: "unrelated" }); expect(reopened.exitCode).toBe(0); const url = JSON.parse(reopened.stdout).url; expect(url).toBe(`${server.url}/v/${version.id}?event=${eventId}`); @@ -138,47 +138,47 @@ test("CLI history, show, open --version and restore work for an archived canvas const restored = await runCli(["restore", version.id, ...common]); expect(restored.exitCode).toBe(0); expect(JSON.parse(restored.stdout)).toMatchObject({ restored: true, revision: 2 }); - expect(readFileSync(path, "utf8")).toBe(VALID_CANVAS); + expect(readFileSync(path, "utf8")).toBe(VALID_ARTIFACT); } finally { server.stop(); } }, { timeout: 30_000 }); test("CLI and MCP read/edit share range, batch, stale-source and diagnostic contracts", async () => { const cliDir = tempDir(); const mcpDir = tempDir(); - writeCanvas(cliDir, "overview", VALID_CANVAS); - writeCanvas(mcpDir, "overview", VALID_CANVAS); - const service = new CanvasService({ canvasesDir: mcpDir, env: { HERDR_CANVAS_HISTORY_DB: join(mcpDir, "history.sqlite") } }); + writeArtifact(cliDir, "overview", VALID_ARTIFACT); + writeArtifact(mcpDir, "overview", VALID_ARTIFACT); + const service = new ArtifactService({ artifactsDir: mcpDir, env: { ARTIFACTS_HISTORY_DB: join(mcpDir, "history.sqlite") } }); const call = async (name: string, args: Record) => { const response = await handleMcpRequest({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name, arguments: { name: "overview", ...args } } }, service); return response!; }; const payload = (response: Awaited>) => JSON.parse((response.result as { content: Array<{ text: string }> }).content[0]!.text); const cliRead = await runCli(["read", "overview", "--start-line", "5", "--end-line", "7", "--dir", cliDir]); - const mcpRead = payload(await call("canvas_read", { start_line: 5, end_line: 7 })); + const mcpRead = payload(await call("artifact_read", { start_line: 5, end_line: 7 })); const cliSource = JSON.parse(cliRead.stdout); expect(cliRead.exitCode).toBe(0); expect({ ...cliSource, path: undefined }).toEqual({ ...mcpRead, path: undefined }); const edit = { edits: [{ old_text: "

Overview

", new_text: "

Edited

" }], expected_hash: cliSource.source_hash }; const cliEdit = await runCli(["edit", "overview", "--stdin", "--dir", cliDir], {}, JSON.stringify(edit)); - const mcpEdit = await call("canvas_edit", edit); + const mcpEdit = await call("artifact_edit", edit); expect(cliEdit.exitCode).toBe(0); expect({ ...JSON.parse(cliEdit.stdout), path: undefined }).toEqual({ ...payload(mcpEdit), path: undefined }); expect(JSON.parse(cliEdit.stdout)).not.toHaveProperty("source"); expect((await runCli(["edit", "overview", "--stdin", "--dir", cliDir], {}, JSON.stringify(edit))).exitCode).toBe(1); - expect((await call("canvas_edit", edit)).error?.message).toContain("changed since read"); + expect((await call("artifact_edit", edit)).error?.message).toContain("changed since read"); const bad = { edits: [{ old_text: "gap={16}", new_text: 'gap="wide"' }] }; const editsPath = join(cliDir, "edits.json"); await Bun.write(editsPath, JSON.stringify(bad)); const cliBad = await runCli(["edit", "overview", "--file", editsPath, "--dir", cliDir]); - const mcpBad = await call("canvas_edit", bad); + const mcpBad = await call("artifact_edit", bad); expect(cliBad.exitCode).toBe(1); expect(JSON.parse(cliBad.stdout)).toMatchObject({ ok: false, applied: true }); expect(mcpBad.result).toMatchObject({ isError: true }); expect(payload(mcpBad)).toMatchObject({ ok: false, applied: true }); - expect(service.read("overview")).toBe(readFileSync(join(cliDir, "overview.canvas.tsx"), "utf8")); + expect(service.read("overview")).toBe(readFileSync(join(cliDir, "overview.artifact.tsx"), "utf8")); expect((await runCli(["read", "overview", "--start-line", "--dir", cliDir])).exitCode).toBe(1); - expect((await call("canvas_read", { start_line: "5" })).error).toBeDefined(); - expect((await call("canvas_edit", { edits: [{ old_text: "Edited", new_text: null }] })).error).toBeDefined(); + expect((await call("artifact_read", { start_line: "5" })).error).toBeDefined(); + expect((await call("artifact_edit", { edits: [{ old_text: "Edited", new_text: null }] })).error).toBeDefined(); expect((await runCli(["edit", "overview", "--stdin", "--dir", cliDir], {}, "null")).exitCode).toBe(1); }, { timeout: 60_000 }); diff --git a/src/cli.ts b/src/cli.ts index 89710d1..7386048 100755 --- a/src/cli.ts +++ b/src/cli.ts @@ -4,14 +4,14 @@ import { readFileSync } from "node:fs"; import { join, resolve } from "node:path"; import { flagBoolean, flagString, parseArgs, type ParsedArgs } from "./args"; -import { canvasesDirFrom } from "./canvasFile"; +import { artifactsDirFrom } from "./artifactFile"; import { clearServerReady, ServerDaemonManager, writeServerReady } from "./local/server-daemon"; import { runCelldServer } from "./local/server"; import { writeDaemonState, daemonStatePath } from "./local/preview-daemon"; import { createHerdrClient, type PanePlacement } from "./herdr"; import { runMcpServer } from "./mcp/stdio"; -import { createCanvasServer } from "./serve"; -import { CanvasService } from "./service"; +import { createArtifactServer } from "./serve"; +import { ArtifactService } from "./service"; import { historyPath } from "./history"; import { PLUGIN_ROOT } from "./paths"; @@ -34,7 +34,7 @@ export async function runServerCommand(args: ParsedArgs, dependencies: { if (action) { if (flagString(args.flags, "state-dir") || flagString(args.flags, "managed-ready")) { - throw new Error("--state-dir is available only for foreground `canvas server`"); + throw new Error("--state-dir is available only for foreground `artifacts server`"); } const manager = dependencies.manager ?? new ServerDaemonManager(); if (action === "start") { @@ -53,7 +53,7 @@ export async function runServerCommand(args: ParsedArgs, dependencies: { return true; } - if (flagBoolean(args.flags, "at-login")) throw new Error("use `canvas server start --at-login` to enable start at login"); + if (flagBoolean(args.flags, "at-login")) throw new Error("use `artifacts server start --at-login` to enable start at login"); const port = numberFlag("port"); const stateDir = flagString(args.flags, "state-dir"); const readyPath = flagString(args.flags, "managed-ready"); @@ -114,12 +114,12 @@ async function main(): Promise { if (await runServerCommand(args)) return; const cwd = process.cwd(); - const canvasesDir = resolve(flagString(args.flags, "dir") ?? canvasesDirFrom(cwd)); + const artifactsDir = resolve(flagString(args.flags, "dir") ?? artifactsDirFrom(cwd)); const historyDb = flagString(args.flags, "history-db"); - const env = historyDb ? { ...process.env, HERDR_CANVAS_HISTORY_DB: resolve(historyDb) } : process.env; - const service = new CanvasService({ + const env = historyDb ? { ...process.env, ARTIFACTS_HISTORY_DB: resolve(historyDb) } : process.env; + const service = new ArtifactService({ cwd, - canvasesDir, + artifactsDir, herdr: createHerdrClient(), inProcessServer: flagBoolean(args.flags, "in-process"), env, @@ -156,13 +156,13 @@ async function main(): Promise { } if (args.command === "list") { - printJson({ ok: true, canvases: service.list(), dir: canvasesDir }); + printJson({ ok: true, artifacts: service.list(), dir: artifactsDir }); return; } if (args.command === "write") { const name = args.positionals[0]; - requireArg(name, "missing canvas name"); + requireArg(name, "missing artifact name"); const contents = await readContents(args); const projectFile = flagString(args.flags, "project"); printJson(projectFile ? await service.writeProject(name, contents, JSON.parse(await Bun.file(projectFile).text())) : service.write(name, contents)); @@ -171,7 +171,7 @@ async function main(): Promise { if (args.command === "read") { const name = args.positionals[0]; - requireArg(name, "missing canvas name"); + requireArg(name, "missing artifact name"); const lineFlag = (key: string) => args.flags[key] === undefined ? undefined : Number(args.flags[key] === true ? NaN : args.flags[key]); printJson(service.readRange(name, { file: flagString(args.flags, "source-file"), start_line: lineFlag("start-line"), end_line: lineFlag("end-line") })); return; @@ -179,10 +179,10 @@ async function main(): Promise { if (args.command === "edit") { const name = args.positionals[0]; - requireArg(name, "missing canvas name"); + requireArg(name, "missing artifact name"); 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 input = payload as { edits: Parameters[1]; expected_hash?: string }; const result = service.edit(name, input.edits, input.expected_hash, flagString(args.flags, "source-file")); printJson(result); if (!result.ok) process.exitCode = 1; @@ -191,14 +191,14 @@ async function main(): Promise { if (args.command === "typecheck") { const name = args.positionals[0]; - requireArg(name, "missing canvas name"); + requireArg(name, "missing artifact name"); printJson(service.typecheck(name)); return; } if (args.command === "compile") { const name = args.positionals[0]; - requireArg(name, "missing canvas name"); + requireArg(name, "missing artifact name"); const result = await service.compile(name); printJson({ ok: result.ok, @@ -211,9 +211,9 @@ async function main(): Promise { } if (args.command === "open") { - const name = args.positionals[0] ?? (args.flags.version === undefined ? process.env.HERDR_CANVAS_NAME : undefined); + const name = args.positionals[0] ?? (args.flags.version === undefined ? process.env.ARTIFACTS_NAME : undefined); const versionId = flagString(args.flags, "version"); - if (Boolean(name) === Boolean(versionId)) throw new Error("provide a canvas name or --version ID, but not both"); + if (Boolean(name) === Boolean(versionId)) throw new Error("provide an artifact name or --version ID, but not both"); const result = await service.open(name ?? "", { versionId, eventId: flagString(args.flags, "event"), @@ -231,15 +231,15 @@ async function main(): Promise { if (args.command === "serve" || args.command === "web") { const gallery = args.command === "web"; const port = Number(flagString(args.flags, "port") ?? (gallery ? "4784" : "0")); - const server = await createCanvasServer({ canvasesDir, port, env, gallery }); - if (!gallery) writeDaemonState(daemonStatePath(canvasesDir, env), { + const server = await createArtifactServer({ artifactsDir, port, env, gallery }); + if (!gallery) writeDaemonState(daemonStatePath(artifactsDir, env), { pid: process.pid, port: server.port, url: server.url, - canvasesDir, + artifactsDir, historyDb: historyPath(env), }); - printJson({ ok: true, url: server.url, port: server.port, dir: canvasesDir }); + printJson({ ok: true, url: server.url, port: server.port, dir: artifactsDir }); if (gallery) { const stop = () => { server.stop(); process.exit(0); }; process.once("SIGINT", stop); @@ -279,29 +279,29 @@ function requireArg(value: string | undefined, message: string): asserts value i } function printHelp(): void { - console.log(`canvas + console.log(`artifacts Usage: - canvas --version - canvas list [--dir PATH] - 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] - canvas serve [--dir PATH] [--port N] - canvas web [--port 4784] [--dir PATH] - canvas mcp - canvas history [NAME] - canvas show VERSION_ID [--source] - canvas open --version VERSION_ID [--event EVENT_ID] [--placement split|tab|zoomed|overlay] - canvas restore VERSION_ID - canvas remix SOURCE NEW_NAME - canvas remix NEW_NAME --version VERSION_ID - canvas server [--port 4786] [--state-dir PATH] - canvas server start [--port 4786] [--at-login] - canvas server stop | status | logs [--lines 100] | uninstall + artifacts --version + artifacts list [--dir PATH] + artifacts write NAME [--file PATH | --stdin] [--project PROJECT_JSON] + artifacts read NAME [--source-file PATH] [--start-line N] [--end-line N] + artifacts edit NAME [--source-file PATH] [--file PATH | --stdin] + artifacts typecheck NAME + artifacts compile NAME + artifacts open NAME [--placement split|tab|zoomed|overlay] [--no-open] + artifacts serve [--dir PATH] [--port N] + artifacts web [--port 4784] [--dir PATH] + artifacts mcp + artifacts history [NAME] + artifacts show VERSION_ID [--source] + artifacts open --version VERSION_ID [--event EVENT_ID] [--placement split|tab|zoomed|overlay] + artifacts restore VERSION_ID + artifacts remix SOURCE NEW_NAME + artifacts remix NEW_NAME --version VERSION_ID + artifacts server [--port 4786] [--state-dir PATH] + artifacts server start [--port 4786] [--at-login] + artifacts server stop | status | logs [--lines 100] | uninstall All commands accept --dir PATH and --history-db PATH. read returns up to 200 lines by default, with a source_hash and next_line. @@ -312,8 +312,8 @@ do not roll back applied edits. Results omit source text. 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 import the SDK from -"sidequery/canvas", plus declared project helpers and dependencies. open creates a managed Canvas pane +Artifacts live in /artifacts/*.artifact.tsx and import the SDK from +"sidequery/artifacts", plus declared project helpers and dependencies. open creates a managed Artifact pane powered internally by Terminal Browser in app mode. `); } diff --git a/src/compile.test.ts b/src/compile.test.ts index cdea487..39d31b7 100644 --- a/src/compile.test.ts +++ b/src/compile.test.ts @@ -1,46 +1,62 @@ import { expect, test } from "bun:test"; -import { compileCanvas } from "./compile"; -import { FETCH_CANVAS, FORBIDDEN_IMPORT_CANVAS, VALID_CANVAS, tempDir, writeCanvas } from "./test/fixtures"; +import { compileArtifact } from "./compile"; +import { FETCH_ARTIFACT, FORBIDDEN_IMPORT_ARTIFACT, VALID_ARTIFACT, tempDir, writeArtifact } from "./test/fixtures"; -test("compileCanvas bundles a valid canvas", async () => { - const path = writeCanvas(tempDir(), "overview", VALID_CANVAS); - const result = await compileCanvas(path); +test("compileArtifact bundles a valid artifact", async () => { + const path = writeArtifact(tempDir(), "overview", VALID_ARTIFACT); + const result = await compileArtifact(path); expect(result.ok).toBe(true); expect(result.js).toContain("Overview"); expect(result.js).toContain("Open issues"); expect(result.js).toContain("createRoot"); }); -test("compileCanvas rejects forbidden imports before bundling", async () => { - const path = writeCanvas(tempDir(), "bad", FORBIDDEN_IMPORT_CANVAS); - const result = await compileCanvas(path); +test("compileArtifact rejects forbidden imports before bundling", async () => { + const path = writeArtifact(tempDir(), "bad", FORBIDDEN_IMPORT_ARTIFACT); + const result = await compileArtifact(path); expect(result.ok).toBe(false); expect(result.diagnostics[0]?.message).toContain("node:fs"); }); -test("compileCanvas rejects fetch", async () => { - const path = writeCanvas(tempDir(), "fetch", FETCH_CANVAS); - const result = await compileCanvas(path); +test("compileArtifact rejects fetch", async () => { + const path = writeArtifact(tempDir(), "fetch", FETCH_ARTIFACT); + const result = await compileArtifact(path); expect(result.ok).toBe(false); expect(result.diagnostics.some((item) => item.message.includes("fetch()"))).toBe(true); }); -test("compileCanvas bundles a relative canvas path", async () => { - const result = await compileCanvas("examples/overview.canvas.tsx"); +test("compileArtifact bundles a relative artifact path", async () => { + const result = await compileArtifact("examples/overview.artifact.tsx"); expect(result.ok).toBe(true); - expect(result.js).toContain("herdr-canvas"); + expect(result.js).toContain("artifacts"); expect(result.js).toContain("createRoot"); }); -test("canonical and legacy SDK imports typecheck and compile ordinary React hooks", async () => { - const { HOOKS_CANVAS } = await import("./test/fixtures"); - const { typecheckCanvas } = await import("./typecheck"); - for (const specifier of ["sidequery/canvas", "herdr/canvas", "cursor/canvas"]) { - const path = writeCanvas(tempDir(), "hooks", HOOKS_CANVAS.replaceAll("sidequery/canvas", specifier)); - expect(typecheckCanvas(path)).toEqual([]); - const result = await compileCanvas(path); +for (const specifier of ["sidequery/artifacts", "@sidequery/artifacts", "sidequery/canvas", "@sidequery/canvas", "herdr/canvas", "cursor/canvas"]) { +test(`SDK import ${specifier} typechecks and compiles ordinary React hooks`, async () => { + const { HOOKS_ARTIFACT } = await import("./test/fixtures"); + const { typecheckArtifact } = await import("./typecheck"); + const path = writeArtifact(tempDir(), "hooks", HOOKS_ARTIFACT.replaceAll("sidequery/artifacts", specifier)); + expect(typecheckArtifact(path)).toEqual([]); + const result = await compileArtifact(path); expect(result.diagnostics).toEqual([]); expect(result.ok).toBe(true); - } }, 30000); +} + +for (const specifier of ["sidequery/canvas", "@sidequery/canvas", "herdr/canvas", "cursor/canvas"]) { +test(`historical SDK exports still typecheck and bundle from ${specifier}`, async () => { + const { typecheckArtifact } = await import("./typecheck"); + const source = `import { useCanvasState, useCanvasAction, canvasFetch, canvasFiles, canvasPaletteDark, canvasPaletteLight, canvasTypography, MAX_CANVAS_FILE_BYTES, type CanvasAction, type SetCanvasState, type CanvasHttpRequest, type CanvasHttpResponse, type CanvasFile, type CanvasFileRequest, type CanvasFileResult, type CanvasFileList, type CanvasFileTransfer, type CanvasHostTheme, type CanvasPalette, type CanvasTokens } from "${specifier}"; +export default function HistoricalCanvas() { + const [count, setCount]: [number, SetCanvasState] = useCanvasState("count", 0); + return ; +}`; + const path = writeArtifact(tempDir(), "historical", source); + expect(typecheckArtifact(path)).toEqual([]); + const result = await compileArtifact(path); + expect(result.diagnostics).toEqual([]); + expect(result.ok).toBe(true); +}, 30000); +} diff --git a/src/compile.ts b/src/compile.ts index b606028..510bba0 100644 --- a/src/compile.ts +++ b/src/compile.ts @@ -18,24 +18,24 @@ export type CompileResult = { diagnostics: Diagnostic[]; }; -export function canvasAliasPlugin(canvasPath: string, source?: string, plugins: BrowserPlugins = loadBrowserPlugins()): BunPlugin { - const absCanvas = resolve(canvasPath); +export function artifactAliasPlugin(artifactPath: string, source?: string, plugins: BrowserPlugins = loadBrowserPlugins()): BunPlugin { + const absArtifact = resolve(artifactPath); return { - name: "herdr-canvas-alias", + name: "artifacts-alias", setup(build) { build.onResolve({ filter: /.*/ }, args => Object.hasOwn(plugins.paths, args.path) - ? { path: args.path, namespace: "canvas-browser-plugin" } : undefined); - build.onLoad({ filter: /.*/, namespace: "canvas-browser-plugin" }, args => ({ + ? { path: args.path, namespace: "artifact-browser-plugin" } : undefined); + build.onLoad({ filter: /.*/, namespace: "artifact-browser-plugin" }, args => ({ contents: plugins.modules[args.path], loader: "js", resolveDir: PLUGIN_ROOT, })); if (source !== undefined) { - build.onLoad({ filter: /\.canvas\.tsx$/ }, (args) => args.path === absCanvas ? { contents: source, loader: "tsx" } : undefined); + build.onLoad({ filter: /\.artifact\.tsx$/ }, (args) => args.path === absArtifact ? { contents: source, loader: "tsx" } : undefined); } - build.onResolve({ filter: /^(@sidequery\/canvas|sidequery\/canvas|herdr\/canvas|cursor\/canvas)$/ }, () => ({ + build.onResolve({ filter: /^((@sidequery|sidequery)\/(artifacts|canvas)|(herdr|cursor)\/canvas)$/ }, () => ({ path: SDK_ENTRY, })); - build.onResolve({ filter: /^herdr-canvas-entry$/ }, () => ({ - path: absCanvas, + build.onResolve({ filter: /^artifacts-entry$/ }, () => ({ + path: absArtifact, })); // Resolve shared runtimes to one package entry. Bun plugin builds can drop // React Router ESM re-export chunks; the package CJS entry preserves them. @@ -44,9 +44,9 @@ export function canvasAliasPlugin(canvasPath: string, source?: string, plugins: })); // User modules resolve only inside the materialized snapshot, never host files. build.onResolve({ filter: /.*/ }, args => { - if (!args.importer.startsWith(dirname(absCanvas) + "/")) return; + if (!args.importer.startsWith(dirname(absArtifact) + "/")) 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}`); + if (!path.startsWith(dirname(absArtifact) + "/")) throw new Error(`import escapes artifact project: ${args.path}`); return { path }; }); @@ -54,10 +54,10 @@ export function canvasAliasPlugin(canvasPath: string, source?: string, plugins: }; } -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 = projectDiagnostics(absCanvas, source, project, Object.keys(plugins.paths)); +export async function compileArtifact(artifactPath: string, snapshot?: string, plugins: BrowserPlugins = loadBrowserPlugins(), project: ArtifactProject = readLocalProject(artifactPath)): Promise { + const absArtifact = resolve(artifactPath); + const source = snapshot ?? readFileSync(absArtifact, "utf8"); + const violations = projectDiagnostics(absArtifact, source, project, Object.keys(plugins.paths)); if (violations.length > 0) { return { ok: false, @@ -77,7 +77,7 @@ export async function compileCanvas(canvasPath: string, snapshot?: string, plugi define: { "process.env.NODE_ENV": JSON.stringify("production"), }, - plugins: [canvasAliasPlugin(snapshotProject.path, source, plugins)], + plugins: [artifactAliasPlugin(snapshotProject.path, source, plugins)], }); } catch (error) { snapshotProject.dispose(); @@ -87,7 +87,7 @@ export async function compileCanvas(canvasPath: string, snapshot?: string, plugi { severity: "error", message: error instanceof AggregateError ? [...error.errors].map(String).join("\n") : error instanceof Error ? error.message : String(error), - file: absCanvas, + file: absArtifact, }, ], }; @@ -100,7 +100,7 @@ export async function compileCanvas(canvasPath: string, snapshot?: string, plugi diagnostics: result.logs.map((log) => ({ severity: "error" as const, message: String(log), - file: absCanvas, + file: absArtifact, })), }; } diff --git a/src/diagnostics.test.ts b/src/diagnostics.test.ts index 2569ce1..ae4fd0a 100644 --- a/src/diagnostics.test.ts +++ b/src/diagnostics.test.ts @@ -1,15 +1,15 @@ import { expect, test } from "bun:test"; -import { formatCanvasCheck } from "./diagnostics"; +import { formatArtifactCheck } from "./diagnostics"; -test("formatCanvasCheck reports no errors", () => { - expect(formatCanvasCheck([])).toBe("Canvas TypeScript check: no errors"); +test("formatArtifactCheck reports no errors", () => { + expect(formatArtifactCheck([])).toBe("Artifact TypeScript check: no errors"); }); -test("formatCanvasCheck counts errors", () => { +test("formatArtifactCheck counts errors", () => { expect( - formatCanvasCheck([ - { severity: "error", message: "nope", file: "a.canvas.tsx", line: 3, column: 1 }, + formatArtifactCheck([ + { severity: "error", message: "nope", file: "a.artifact.tsx", line: 3, column: 1 }, ]), - ).toContain("Canvas TypeScript check: 1 error"); + ).toContain("Artifact TypeScript check: 1 error"); }); diff --git a/src/diagnostics.ts b/src/diagnostics.ts index 9e27370..4b72ab1 100644 --- a/src/diagnostics.ts +++ b/src/diagnostics.ts @@ -6,12 +6,12 @@ export type Diagnostic = { column?: number; }; -export function formatCanvasCheck(diagnostics: Diagnostic[]): string { +export function formatArtifactCheck(diagnostics: Diagnostic[]): string { const errors = diagnostics.filter((diagnostic) => diagnostic.severity === "error"); if (errors.length === 0) { - return "Canvas TypeScript check: no errors"; + return "Artifact TypeScript check: no errors"; } - const header = `Canvas TypeScript check: ${errors.length} error${errors.length === 1 ? "" : "s"}`; + const header = `Artifact TypeScript check: ${errors.length} error${errors.length === 1 ? "" : "s"}`; const body = errors.map(formatDiagnosticLine); return [header, ...body].join("\n"); } diff --git a/src/edit.test.ts b/src/edit.test.ts index f242114..a1a87d4 100644 --- a/src/edit.test.ts +++ b/src/edit.test.ts @@ -1,15 +1,15 @@ import { expect, test } from "bun:test"; import { readFileSync, writeFileSync, symlinkSync, readdirSync } from "node:fs"; import { join } from "node:path"; -import { CanvasService, type CanvasEdit } from "./service"; -import { replaceCanvasSource } from "./canvasFile"; -import { VALID_CANVAS, tempDir, writeCanvas } from "./test/fixtures"; +import { ArtifactService, type ArtifactEdit } from "./service"; +import { replaceArtifactSource } from "./artifactFile"; +import { VALID_ARTIFACT, tempDir, writeArtifact } from "./test/fixtures"; test("read ranges preserve CRLF and final lines, paginate and hash the entire source", () => { const dir = tempDir(); const source = Array.from({ length: 205 }, (_, i) => `line ${i + 1}`).join("\r\n"); - writeCanvas(dir, "overview", source); - const service = new CanvasService({ canvasesDir: dir }); + writeArtifact(dir, "overview", source); + const service = new ArtifactService({ artifactsDir: dir }); const first = service.readRange("overview"); expect(first).toMatchObject({ start_line: 1, end_line: 200, total_lines: 205, next_line: 201 }); const last = service.readRange("overview", { start_line: 201 }); @@ -21,16 +21,16 @@ test("read ranges preserve CRLF and final lines, paginate and hash the entire so for (const options of [{ start_line: 0 }, { end_line: 1.5 }, { start_line: 3, end_line: 2 }, { start_line: 206 }, { start_line: NaN }]) { expect(() => service.readRange("overview", options)).toThrow(); } - writeCanvas(dir, "empty", ""); + writeArtifact(dir, "empty", ""); expect(service.readRange("empty")).toMatchObject({ source: "", total_lines: 0, end_line: 0, next_line: null }); - writeCanvas(dir, "blank", "\n"); + writeArtifact(dir, "blank", "\n"); expect(service.readRange("blank")).toMatchObject({ source: "\n", total_lines: 1 }); }); test("edit applies a guarded sequential batch, supports deletion, and returns no source", () => { const dir = tempDir(); - const path = writeCanvas(dir, "overview", VALID_CANVAS); - const service = new CanvasService({ canvasesDir: dir }); + const path = writeArtifact(dir, "overview", VALID_ARTIFACT); + const service = new ArtifactService({ artifactsDir: dir }); const before = service.readRange("overview"); const result = service.edit("overview", [ { old_text: "

Overview

", new_text: "

Draft

" }, @@ -40,21 +40,21 @@ test("edit applies a guarded sequential batch, supports deletion, and returns no expect(result).toMatchObject({ ok: true, applied: true, changed: true, edits_applied: 3, diagnostics: [] }); expect(result).not.toHaveProperty("source"); expect(result.source_hash).not.toBe(before.source_hash); - expect(readFileSync(path, "utf8")).toBe(VALID_CANVAS.replace("

Overview

", "

Final

").replace(" Embedded sample data.\n", "")); + expect(readFileSync(path, "utf8")).toBe(VALID_ARTIFACT.replace("

Overview

", "

Final

").replace(" Embedded sample data.\n", "")); expect(() => service.edit("overview", [{ old_text: "Final", new_text: "Stale" }], before.source_hash)).toThrow("changed since read"); expect(service.readRange("overview").source_hash).toBe(result.source_hash); }, { timeout: 30_000 }); test("invalid edit batches never partially write, including overlapping ambiguity", () => { const dir = tempDir(); - const source = VALID_CANVAS + "// aaa\n"; - const path = writeCanvas(dir, "overview", source); - const service = new CanvasService({ canvasesDir: dir }); + const source = VALID_ARTIFACT + "// aaa\n"; + const path = writeArtifact(dir, "overview", source); + const service = new ArtifactService({ artifactsDir: dir }); const batches: unknown[] = [[], null, {}, [{ old_text: "", new_text: "x" }], [{ old_text: "Overview", new_text: "x" }], [{ old_text: "aa", new_text: "x" }], [{ old_text: "missing", new_text: "x" }], [{ old_text: "return" }], [{ old_text: "

Overview

", new_text: "

Changed

" }, { old_text: "missing", new_text: "x" }]]; for (const batch of batches) { - expect(() => service.edit("overview", batch as CanvasEdit[])).toThrow(); + expect(() => service.edit("overview", batch as ArtifactEdit[])).toThrow(); expect(readFileSync(path, "utf8")).toBe(source); } expect(() => service.edit("overview", [{ old_text: "return", new_text: "return" }], "bad")).toThrow("SHA-256"); @@ -62,8 +62,8 @@ test("invalid edit batches never partially write, including overlapping ambiguit test("edit reports no-ops and leaves invalid resulting TSX applied with diagnostics", () => { const dir = tempDir(); - const path = writeCanvas(dir, "overview", VALID_CANVAS); - const service = new CanvasService({ canvasesDir: dir }); + const path = writeArtifact(dir, "overview", VALID_ARTIFACT); + const service = new ArtifactService({ artifactsDir: dir }); const noOp = service.edit("overview", [{ old_text: "gap={16}", new_text: "gap={16}" }]); expect(noOp).toMatchObject({ ok: true, applied: true, changed: false }); const invalid = service.edit("overview", [{ old_text: "gap={16}", new_text: 'gap="wide"' }]); @@ -74,18 +74,18 @@ test("edit reports no-ops and leaves invalid resulting TSX applied with diagnost test("read and edit reject missing, path and symlink targets; replacement detects changed source", () => { const dir = tempDir(); - const outside = writeCanvas(tempDir(), "outside", VALID_CANVAS); - symlinkSync(outside, join(dir, "linked.canvas.tsx")); - const service = new CanvasService({ canvasesDir: dir }); + const outside = writeArtifact(tempDir(), "outside", VALID_ARTIFACT); + symlinkSync(outside, join(dir, "linked.artifact.tsx")); + const service = new ArtifactService({ artifactsDir: dir }); const edits = [{ old_text: "return", new_text: "return" }]; for (const name of ["missing", "linked", outside, "../outside"]) { expect(() => service.readRange(name)).toThrow(); expect(() => service.edit(name, edits)).toThrow(); } - expect(readFileSync(outside, "utf8")).toBe(VALID_CANVAS); - const path = writeCanvas(dir, "overview", VALID_CANVAS); + expect(readFileSync(outside, "utf8")).toBe(VALID_ARTIFACT); + const path = writeArtifact(dir, "overview", VALID_ARTIFACT); writeFileSync(path, "concurrent edit"); - expect(() => replaceCanvasSource(path, "replacement", VALID_CANVAS, "edit")).toThrow("changed during edit"); + expect(() => replaceArtifactSource(path, "replacement", VALID_ARTIFACT, "edit")).toThrow("changed during edit"); expect(readFileSync(path, "utf8")).toBe("concurrent edit"); expect(readdirSync(dir).filter(name => name.endsWith(".tmp"))).toEqual([]); }); diff --git a/src/gallery.test.ts b/src/gallery.test.ts index 0952cc7..5a3cfae 100644 --- a/src/gallery.test.ts +++ b/src/gallery.test.ts @@ -1,23 +1,23 @@ import { expect, test } from "bun:test"; import { existsSync, readFileSync, writeFileSync } from "node:fs"; import { join } from "node:path"; -import { CanvasHistory } from "./history"; +import { ArtifactHistory } from "./history"; import { PLUGIN_ROOT } from "./paths"; -import { createCanvasServer } from "./serve"; -import { tempDir, VALID_CANVAS, writeCanvas } from "./test/fixtures"; +import { createArtifactServer } from "./serve"; +import { tempDir, VALID_ARTIFACT, writeArtifact } from "./test/fixtures"; import type { GalleryData } from "./gallery/types"; test("gallery lists working copies and archived/deleted versions with an explicit all-projects filter", async () => { const dir = tempDir(); - writeCanvas(dir, "working", VALID_CANVAS); + writeArtifact(dir, "working", VALID_ARTIFACT); const dbPath = join(dir, "history.sqlite"); - const history = new CanvasHistory(dbPath); - const archived = history.capture({ workspace: dir, name: "deleted", sourcePath: join(dir, "deleted.canvas.tsx"), source: VALID_CANVAS, runtime: "test" }); - const other = history.capture({ workspace: dir + "-other", name: "elsewhere", sourcePath: join(dir + "-other", "elsewhere.canvas.tsx"), source: VALID_CANVAS, runtime: "test" }); - const server = await createCanvasServer({ canvasesDir: dir, historyPath: dbPath, gallery: true }); + const history = new ArtifactHistory(dbPath); + const archived = history.capture({ workspace: dir, name: "deleted", sourcePath: join(dir, "deleted.artifact.tsx"), source: VALID_ARTIFACT, runtime: "test" }); + const other = history.capture({ workspace: dir + "-other", name: "elsewhere", sourcePath: join(dir + "-other", "elsewhere.artifact.tsx"), source: VALID_ARTIFACT, runtime: "test" }); + const server = await createArtifactServer({ artifactsDir: dir, historyPath: dbPath, gallery: true }); try { const page = await (await fetch(server.url)).text(); - expect(page).toContain("Canvas library"); + expect(page).toContain("Artifacts library"); const current = await (await fetch(`${server.url}/api/gallery`)).json() as GalleryData; expect(current.artifacts.map(item => [item.name, item.working])).toEqual([["deleted", false], ["working", true]]); expect(current.artifacts[0]?.versions[0]?.id).toBe(archived.id); @@ -26,8 +26,8 @@ test("gallery lists working copies and archived/deleted versions with an explici expect(history.list(dir)).toHaveLength(1); // Listing does not invent served versions. const downloaded = await fetch(`${server.url}/api/source?version=${other.id}&download=1`); expect(downloaded.headers.get("content-disposition")).toContain("attachment"); - expect(await downloaded.text()).toBe(VALID_CANVAS); - expect(await (await fetch(`${server.url}/api/source?name=working`)).text()).toBe(VALID_CANVAS); + expect(await downloaded.text()).toBe(VALID_ARTIFACT); + expect(await (await fetch(`${server.url}/api/source?name=working`)).text()).toBe(VALID_ARTIFACT); expect((await fetch(`${server.url}/api/source?name=..%2foutside`)).status).toBe(404); expect((await fetch(`${server.url}/api/source?version=${other.id}&name=working`)).status).toBe(400); expect((await fetch(`${server.url}/api/source?version=missing`)).status).toBe(404); @@ -36,19 +36,19 @@ test("gallery lists working copies and archived/deleted versions with an explici test("gallery previews carry a self-contained build and isolated state, including archived source from another project", async () => { const dir = tempDir(); - const path = writeCanvas(dir, "working", VALID_CANVAS); + const path = writeArtifact(dir, "working", VALID_ARTIFACT); const statePath = path.replace(".tsx", ".data.json"); writeFileSync(statePath, '{"count":7}'); const dbPath = join(dir, "history.sqlite"); - const history = new CanvasHistory(dbPath); - const other = history.capture({ workspace: dir + "-other", name: "deleted", sourcePath: join(dir + "-other", "deleted.canvas.tsx"), source: VALID_CANVAS, runtime: "test" }); + const history = new ArtifactHistory(dbPath); + const other = history.capture({ workspace: dir + "-other", name: "deleted", sourcePath: join(dir + "-other", "deleted.artifact.tsx"), source: VALID_ARTIFACT, runtime: "test" }); history.served(other.id, { count: 3 }, "live"); - const server = await createCanvasServer({ canvasesDir: dir, historyPath: dbPath, gallery: true }); + const server = await createArtifactServer({ artifactsDir: dir, historyPath: dbPath, gallery: true }); try { const response = await fetch(`${server.url}/gallery/preview?name=working`); expect(response.status).toBe(200); const html = await response.text(); - const bridge = JSON.parse(html.match(/window\.__herdrCanvas = (.*);<\/script>/)![1]); + const bridge = JSON.parse(html.match(/window\.__artifacts = (.*);<\/script>/)![1]); expect(bridge.state).toEqual({ count: 7 }); expect(bridge.persistUrl).toBeUndefined(); expect(bridge.actionUrl).toBeUndefined(); @@ -64,16 +64,16 @@ test("gallery previews carry a self-contained build and isolated state, includin } finally { server.stop(); history.close(); } }); -test("ordinary managed Canvas servers do not expose the cross-project gallery API", async () => { +test("ordinary managed Artifact servers do not expose the cross-project gallery API", async () => { const dir = tempDir(); - const server = await createCanvasServer({ canvasesDir: dir, historyPath: join(dir, "history.sqlite") }); + const server = await createArtifactServer({ artifactsDir: dir, historyPath: join(dir, "history.sqlite") }); try { expect((await fetch(`${server.url}/api/gallery?all=1`)).status).toBe(404); } finally { server.stop(); } }); test("CLI web starts a local gallery and shuts down on SIGTERM", async () => { const dir = tempDir(); - writeCanvas(dir, "working", VALID_CANVAS); + writeArtifact(dir, "working", VALID_ARTIFACT); const child = Bun.spawn([process.execPath, join(PLUGIN_ROOT, "src/cli.ts"), "web", "--port", "0", "--dir", dir, "--history-db", join(dir, "history.sqlite")], { stdout: "pipe", stderr: "pipe" }); try { const reader = child.stdout.getReader(); diff --git a/src/gallery/client.tsx b/src/gallery/client.tsx index f6ae467..2a88fa5 100644 --- a/src/gallery/client.tsx +++ b/src/gallery/client.tsx @@ -3,8 +3,8 @@ import { createRoot } from "react-dom/client"; import { authClient, signInUrl } from "../auth/client-api"; import type { GalleryArtifact, GalleryData } from "./types"; import { ExecutionControls } from "./execution-controls"; -import { CanvasSourcePanel, LinkSettings, ScriptPanel } from "./hosted"; -import { canvasFileTransferUrl } from "../sdk/files"; +import { ArtifactSourcePanel, LinkSettings, ScriptPanel } from "./hosted"; +import { artifactFileTransferUrl } from "../sdk/files"; import { RemixPanel } from "./remix"; type Scope = "current" | "all"; @@ -49,7 +49,7 @@ const styles = ` .artifact-name { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .workspace-name { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--muted); font-size: 11px; margin-top: 3px; } .artifact-detail { display: flex; flex-direction: column; min-width: 0; min-height: 0; } - .artifact-detail .canvas-stage { flex: 1; } + .artifact-detail .artifact-stage { flex: 1; } .link-settings, .script-fields { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; } .link-settings { padding: 8px; border-bottom: 1px solid var(--line); } .link-settings label { display: flex; align-items: center; gap: 4px; } @@ -60,16 +60,16 @@ const styles = ` .script-panel details { margin-top: 16px; border-top: 1px solid var(--line); padding-top: 12px; } .script-panel summary { cursor: pointer; } .script-panel pre { white-space: pre-wrap; overflow-wrap: anywhere; } - .canvas-execution { padding: 8px; border-bottom: 1px solid var(--line); max-height: 45vh; overflow: auto; } - .canvas-execution summary { cursor: pointer; } - .canvas-execution td, .canvas-execution th { padding: 4px 8px; text-align: left; } + .artifact-execution { padding: 8px; border-bottom: 1px solid var(--line); max-height: 45vh; overflow: auto; } + .artifact-execution summary { cursor: pointer; } + .artifact-execution td, .artifact-execution th { padding: 4px 8px; text-align: left; } .muted { color: var(--muted); } - .canvas-stage { position: relative; display: flex; min-width: 0; min-height: 0; overflow: hidden; } + .artifact-stage { position: relative; display: flex; min-width: 0; min-height: 0; overflow: hidden; } .preview-frame { display: block; width: 100%; height: 100%; border: 0; } .preview-loading { position: absolute; inset: 0; display: grid; place-items: center; background: var(--page); color: var(--muted); pointer-events: none; } .source-code { flex: 1; min-width: 0; margin: 0; padding: 16px; overflow: auto; font: 12px/1.6 "SFMono-Regular", Consolas, monospace; tab-size: 2; } .state-message { margin: 0; padding: 12px; color: var(--muted); } - .canvas-stage > .state-message { margin: auto; } + .artifact-stage > .state-message { margin: auto; } .error-message { color: #eeaaaa; } .refresh-error { padding: 8px 12px; margin: 0; border-bottom: 1px solid var(--line); } @media (hover: hover) and (pointer: fine) { @@ -108,7 +108,7 @@ function sessionUser(value: unknown): SessionUser | null { } function redirectExpiredSession(response: Response): boolean { - if (response.status !== 401 || response.headers.get("X-Canvas-Auth") !== "better-auth") return false; + if (response.status !== 401 || response.headers.get("X-Artifact-Auth") !== "better-auth") return false; window.location.assign(signInUrl()); return true; } @@ -213,14 +213,14 @@ function App() { const remix = createdRemix.current; createdRemix.current = null; setSelectedKey((current) => { - if (remix) return payload.artifacts.find(artifact => (artifact.kind ?? "canvas") === remix.kind && artifact.name === remix.name && artifact.workspace === remix.workspace)?.key ?? current; + if (remix) return payload.artifacts.find(artifact => (artifact.kind ?? "artifact") === remix.kind && artifact.name === remix.name && artifact.workspace === remix.workspace)?.key ?? current; if (created) return payload.artifacts.find(artifact => artifact.kind === "script" && artifact.name === created)?.key ?? current; if (current && payload.artifacts.some((artifact) => artifact.key === current)) return current; return payload.artifacts[0]?.key ?? null; }); } catch (error) { if (controller.signal.aborted || request !== galleryRequest.current) return; - setGalleryError(error instanceof Error ? error.message : "Could not load the canvas library."); + setGalleryError(error instanceof Error ? error.message : "Could not load the artifact library."); } finally { if (request === galleryRequest.current) setLoading(false); } @@ -240,7 +240,7 @@ function App() { const resolved = sessionUser(await response.json()); if (!controller.signal.aborted) setUser(resolved); } catch { - // Local Canvas servers do not expose an auth session endpoint. + // Local Artifact servers do not expose an auth session endpoint. } })(); return () => controller.abort(); @@ -288,19 +288,19 @@ function App() { const request = async (event: MessageEvent) => { const frame = previewFrame.current; if (!frame || event.source !== frame.contentWindow || !selectedArtifact - || !["canvas/http-request", "canvas/plugin-request", "canvas/files-request", "canvas/file-download"].includes(event.data?.type) || typeof event.data.id !== "string" - || event.data.id.length > 64 || (event.data.type !== "canvas/plugin-request" && typeof event.data.versionId !== "string") || pending.has(event.data.id)) return; - const plugin = event.data.type === "canvas/plugin-request"; - const files = event.data.type === "canvas/files-request"; - const download = event.data.type === "canvas/file-download"; - const responseType = plugin ? "canvas/plugin-response" : files ? "canvas/files-response" : download ? "canvas/file-download-response" : "canvas/http-response"; + || !["artifact/http-request", "artifact/plugin-request", "artifact/files-request", "artifact/file-download"].includes(event.data?.type) || typeof event.data.id !== "string" + || event.data.id.length > 64 || (event.data.type !== "artifact/plugin-request" && typeof event.data.versionId !== "string") || pending.has(event.data.id)) return; + const plugin = event.data.type === "artifact/plugin-request"; + const files = event.data.type === "artifact/files-request"; + const download = event.data.type === "artifact/file-download"; + const responseType = plugin ? "artifact/plugin-response" : files ? "artifact/files-response" : download ? "artifact/file-download-response" : "artifact/http-response"; const target = frame.contentWindow!; const id = event.data.id; try { - if (pending.size >= 16) throw new Error("Too many pending canvas requests"); + if (pending.size >= 16) throw new Error("Too many pending artifact requests"); pending.add(id); if (download) { - const url = canvasFileTransferUrl(event.data.url, window.location.origin); + const url = artifactFileTransferUrl(event.data.url, window.location.origin); const anchor = document.createElement("a"); anchor.href = url; anchor.download = ""; @@ -314,15 +314,15 @@ function App() { const params = new URLSearchParams({ workspace: selectedArtifact.workspace }); const library = new URLSearchParams(window.location.search).get("library"); if (library) params.set("library", library); - const response = await fetch(`${plugin ? "/api/plugins/call" : files ? "/api/canvas/files" : "/api/canvas/request"}?${params}`, { + const response = await fetch(`${plugin ? "/api/plugins/call" : files ? "/api/artifact/files" : "/api/artifact/request"}?${params}`, { method: "POST", headers: { "content-type": "application/json" }, // The frame selects its pinned code version, but cannot redirect a - // request to another canvas or private/team library. + // request to another artifact or private/team library. body: JSON.stringify(plugin ? event.data.request : { name: selectedArtifact.name, version_id: event.data.versionId, request: event.data.request }), }); if (redirectExpiredSession(response)) return; const result = await response.json() as { response?: unknown; result?: unknown; error?: string }; - if (!response.ok) throw new Error(result.error ?? `Canvas request failed (${response.status})`); + if (!response.ok) throw new Error(result.error ?? `Artifact request failed (${response.status})`); target.postMessage({ type: responseType, id, response: result.response, result: result.result }, "*"); } catch (error) { target.postMessage({ type: responseType, id, error: error instanceof Error ? error.message : String(error) }, "*"); @@ -401,7 +401,7 @@ function App() { const url = URL.createObjectURL(await response.blob()); const anchor = document.createElement("a"); anchor.href = url; - anchor.download = `${selectedArtifact?.name ?? "canvas"}${selectedArtifact?.kind === "script" ? ".ts" : ".canvas.tsx"}`; + anchor.download = `${selectedArtifact?.name ?? "artifact"}${selectedArtifact?.kind === "script" ? ".ts" : ".artifact.tsx"}`; anchor.click(); URL.revokeObjectURL(url); } catch (error) { @@ -413,7 +413,7 @@ function App() { <>
-
+
{gallery?.libraryScope ? ( setQuery(event.target.value)} placeholder="Search" @@ -450,15 +450,15 @@ function App() { ))} - {selectedArtifact.kind !== "script" ?
- - + {selectedArtifact.kind !== "script" ?
+ +
: null} Download source ) : null} - + {user ? (
{user.name} @@ -471,7 +471,7 @@ function App() { {accountError ?

{accountError}

: null}
-
) : filteredArtifacts.length === 0 ? ( -

{query ? "No matches" : gallery?.capabilities?.scripts ? "No canvases or scripts" : "No canvases"}

+

{query ? "No matches" : gallery?.capabilities?.scripts ? "No artifacts or scripts" : "No artifacts"}

) : filteredArtifacts.map((artifact) => ( + {!loaded ?

Loading source…

: setSnapshot({ ...snapshot, project })} onEntryChange={(id, source) => setSnapshot(id === "server" ? { ...snapshot, server_source: source } : { ...snapshot, source })} readOnly={historical} disabled={busy} onValidityChange={setProjectValid} />} +
; } diff --git a/src/gallery/project-editor.test.tsx b/src/gallery/project-editor.test.tsx index 33559d2..727f221 100644 --- a/src/gallery/project-editor.test.tsx +++ b/src/gallery/project-editor.test.tsx @@ -3,7 +3,7 @@ import { renderToStaticMarkup } from "react-dom/server"; import { editableProject, ProjectEditor } from "./project-editor"; const project = { files: { "lib/helper.ts": "export const value = 1;" }, dependencies: { "lodash-es": "4.17.21" } }; -const entries = [{ id: "client", label: "Canvas source", source: "client" }, { id: "server", label: "Server source", source: "server" }]; +const entries = [{ id: "client", label: "Artifact source", source: "client" }, { id: "server", label: "Server source", source: "server" }]; const props = { entries, project, onProjectChange: () => {}, onEntryChange: () => {}, onValidityChange: () => {} }; test("authoring snapshot retains every helper and dependency without uploading vendor lock files", () => { @@ -15,7 +15,7 @@ test("authoring snapshot retains every helper and dependency without uploading v expect(() => editableProject({ files: { "lib/helper.ts": null }, dependencies: {} })).toThrow("Invalid project files"); }); -test("project source picker exposes both canvas entrypoints and stored helpers", () => { +test("project source picker exposes both artifact entrypoints and stored helpers", () => { const markup = renderToStaticMarkup(); expect(markup).toContain('value="client"'); expect(markup).toContain('value="server"'); diff --git a/src/gallery/project-editor.tsx b/src/gallery/project-editor.tsx index 2d83edf..15133ad 100644 --- a/src/gallery/project-editor.tsx +++ b/src/gallery/project-editor.tsx @@ -32,7 +32,7 @@ export function ProjectEditor({ entries, project, onProjectChange, onEntryChange const locked = readOnly || disabled; function addFile() { const path = filename.trim(); - if (!/^[a-zA-Z0-9_./-]+\.(?:[cm]?[jt]sx?|json)$/.test(path) || path.split("/").some(part => !part || part === "." || part === ".." || part === "node_modules") || ["canvas.canvas.tsx", "canvas.canvas.server.ts", "script.ts", "entry.ts", "server-entry.ts", "package.json"].includes(path)) { + if (!/^[a-zA-Z0-9_./-]+\.(?:[cm]?[jt]sx?|json)$/.test(path) || path.split("/").some(part => !part || part === "." || part === ".." || part === "node_modules") || ["artifact.artifact.tsx", "artifact.artifact.server.ts", "script.ts", "entry.ts", "server-entry.ts", "package.json"].includes(path)) { setFileError("Use a relative .ts, .tsx, .js, or .json helper path, such as lib/helpers.ts."); return; } if (Object.hasOwn(project.files, path)) { setFileError("That file already exists."); return; } diff --git a/src/gallery/remix.tsx b/src/gallery/remix.tsx index d7c555b..8ae9aea 100644 --- a/src/gallery/remix.tsx +++ b/src/gallery/remix.tsx @@ -12,7 +12,7 @@ export function RemixPanel({ artifact, version, onSaved, onCancel }: { event.preventDefault(); setBusy(true); setError(""); try { - await galleryTool(artifact.workspace, `${artifact.kind ?? "canvas"}_remix`, { + await galleryTool(artifact.workspace, `${artifact.kind ?? "artifact"}_remix`, { ...(version === "working" ? { name: artifact.name } : { version_id: version }), new_name: name, }); await onSaved(name); diff --git a/src/gallery/server.ts b/src/gallery/server.ts index 4c10acb..5684b02 100644 --- a/src/gallery/server.ts +++ b/src/gallery/server.ts @@ -1,11 +1,11 @@ import { readFileSync } from "node:fs"; import { join } from "node:path"; -import { assertRegularCanvas, canvasIdFromFile, ensureCanvasFileName, listCanvasFiles } from "../canvasFile"; -import type { CanvasHistory } from "../history"; +import { assertRegularArtifact, artifactIdFromFile, ensureArtifactFileName, resolveArtifactFile, listArtifactFiles } from "../artifactFile"; +import type { ArtifactHistory } from "../history"; import { PLUGIN_ROOT } from "../paths"; import type { GalleryArtifact, GalleryData } from "./types"; -export function galleryData(history: CanvasHistory, workspace: string, all: boolean): GalleryData { +export function galleryData(history: ArtifactHistory, workspace: string, all: boolean): GalleryData { const artifacts = new Map(); const key = (dir: string, name: string) => JSON.stringify([dir, name]); for (const version of history.list(all ? undefined : workspace)) { @@ -17,9 +17,9 @@ export function galleryData(history: CanvasHistory, workspace: string, all: bool } artifact.versions.push({ id: version.version_id, revision: version.revision, createdAt: version.created_at, reason: version.reason, serveCount: version.serve_count }); } - for (const path of listCanvasFiles(workspace)) { - try { assertRegularCanvas(path); } catch { continue; } - const name = canvasIdFromFile(path); + for (const path of listArtifactFiles(workspace)) { + try { assertRegularArtifact(path); } catch { continue; } + const name = artifactIdFromFile(path); const id = key(workspace, name); const artifact = artifacts.get(id) ?? { key: id, name, workspace, working: false, versions: [] }; artifact.working = true; @@ -29,9 +29,10 @@ export function galleryData(history: CanvasHistory, workspace: string, all: bool } export function workingSource(workspace: string, name: string) { - const path = join(workspace, ensureCanvasFileName(name)); - assertRegularCanvas(path); - return { path, name: canvasIdFromFile(path), source: readFileSync(path, "utf8") }; + ensureArtifactFileName(name); + const path = resolveArtifactFile(name, workspace); + assertRegularArtifact(path); + return { path, name: artifactIdFromFile(path), source: readFileSync(path, "utf8") }; } export async function galleryBundle(): Promise { @@ -41,5 +42,5 @@ export async function galleryBundle(): Promise { } export function galleryHtml(): string { - return `Sidequery Canvas library
`; + return `Sidequery Artifacts library
`; } diff --git a/src/gallery/types.ts b/src/gallery/types.ts index 6101b11..91bcb37 100644 --- a/src/gallery/types.ts +++ b/src/gallery/types.ts @@ -1,3 +1,3 @@ export type GalleryVersion = { id: string; revision: number; createdAt: string; reason: string; serveCount: number }; -export type GalleryArtifact = { key: string; name: string; workspace: string; working: boolean; versions: GalleryVersion[]; kind?: "canvas" | "script"; slug?: string; url?: string; access?: "private" | "public" }; +export type GalleryArtifact = { key: string; name: string; workspace: string; working: boolean; versions: GalleryVersion[]; kind?: "artifact" | "script"; slug?: string; url?: string; access?: "private" | "public" }; export type GalleryData = { workspace: string; artifacts: GalleryArtifact[]; libraryScope?: "private" | "team"; nextOffset?: number | null; capabilities?: { scripts?: boolean; links?: boolean } }; diff --git a/src/herdr.test.ts b/src/herdr.test.ts index 77449be..bc62df9 100644 --- a/src/herdr.test.ts +++ b/src/herdr.test.ts @@ -1,16 +1,16 @@ import { expect, test } from "bun:test"; -import { canvasPaneOpenArgs } from "./herdr"; +import { artifactPaneOpenArgs } from "./herdr"; -test("canvasPaneOpenArgs opens a Canvas-owned pane beside its caller", () => { - const args = canvasPaneOpenArgs("/repo/canvases/overview.canvas.tsx", "/repo/canvases", { +test("artifactPaneOpenArgs opens an Artifact-owned pane beside its caller", () => { + const args = artifactPaneOpenArgs("/repo/artifacts/overview.artifact.tsx", "/repo/artifacts", { placement: "split", workspaceId: "w15", targetPaneId: "w15:p1", }); - expect(args).toContain("herdr.canvas"); - expect(args).toContain("HERDR_CANVAS_PATH=/repo/canvases/overview.canvas.tsx"); - expect(args).toContain("HERDR_CANVAS_DIR=/repo/canvases"); + expect(args).toContain("herdr.artifacts"); + expect(args).toContain("ARTIFACTS_PATH=/repo/artifacts/overview.artifact.tsx"); + expect(args).toContain("ARTIFACTS_DIR=/repo/artifacts"); expect(args).toContain("--target-pane"); expect(args).toContain("w15:p1"); expect(args).not.toContain("--workspace"); @@ -19,16 +19,16 @@ test("canvasPaneOpenArgs opens a Canvas-owned pane beside its caller", () => { expect(args).toContain("--focus"); }); -test("canvasPaneOpenArgs omits targets and split direction for overlays", () => { - const args = canvasPaneOpenArgs("/repo/canvases/overview.canvas.tsx", "/repo/canvases", { placement: "overlay", focus: false }); +test("artifactPaneOpenArgs omits targets and split direction for overlays", () => { + const args = artifactPaneOpenArgs("/repo/artifacts/overview.artifact.tsx", "/repo/artifacts", { placement: "overlay", focus: false }); expect(args).not.toContain("--direction"); expect(args).not.toContain("--target-pane"); expect(args).not.toContain("--workspace"); expect(args).toContain("--no-focus"); }); -test("canvasPaneOpenArgs targets tab placement by workspace only", () => { - const args = canvasPaneOpenArgs("/repo/canvases/overview.canvas.tsx", "/repo/canvases", { +test("artifactPaneOpenArgs targets tab placement by workspace only", () => { + const args = artifactPaneOpenArgs("/repo/artifacts/overview.artifact.tsx", "/repo/artifacts", { placement: "tab", workspaceId: "w15", targetPaneId: "w15:p1", @@ -39,8 +39,8 @@ test("canvasPaneOpenArgs targets tab placement by workspace only", () => { expect(args).not.toContain("--direction"); }); -test("canvasPaneOpenArgs targets zoomed placement by pane without split direction", () => { - const args = canvasPaneOpenArgs("/repo/canvases/overview.canvas.tsx", "/repo/canvases", { +test("artifactPaneOpenArgs targets zoomed placement by pane without split direction", () => { + const args = artifactPaneOpenArgs("/repo/artifacts/overview.artifact.tsx", "/repo/artifacts", { placement: "zoomed", workspaceId: "w15", targetPaneId: "w15:p1", diff --git a/src/herdr.ts b/src/herdr.ts index cc9068b..7bf5b82 100644 --- a/src/herdr.ts +++ b/src/herdr.ts @@ -32,9 +32,9 @@ export function createHerdrClient(env: NodeJS.ProcessEnv = process.env): HerdrCl export type PanePlacement = "split" | "tab" | "zoomed" | "overlay"; -export function canvasPaneOpenArgs( - canvasPath: string, - canvasesDir: string, +export function artifactPaneOpenArgs( + artifactPath: string, + artifactsDir: string, opts: { placement?: PanePlacement; direction?: "right" | "down"; @@ -52,20 +52,20 @@ export function canvasPaneOpenArgs( "pane", "open", "--plugin", - "herdr.canvas", + "herdr.artifacts", "--entrypoint", - "canvas", + "artifacts", "--placement", placement, "--env", - `HERDR_CANVAS_PATH=${canvasPath}`, + `ARTIFACTS_PATH=${artifactPath}`, "--env", - `HERDR_CANVAS_DIR=${canvasesDir}`, + `ARTIFACTS_DIR=${artifactsDir}`, ]; for (const [key, value] of Object.entries({ - HERDR_CANVAS_HISTORY_DB: opts.historyPath, - HERDR_CANVAS_VERSION: opts.versionId ?? "", - HERDR_CANVAS_EVENT: opts.eventId ?? "", + ARTIFACTS_HISTORY_DB: opts.historyPath, + ARTIFACTS_VERSION: opts.versionId ?? "", + ARTIFACTS_EVENT: opts.eventId ?? "", })) { if (value !== undefined) args.push("--env", `${key}=${value}`); } diff --git a/src/history-serving.test.ts b/src/history-serving.test.ts index 58f7ce1..cd3833c 100644 --- a/src/history-serving.test.ts +++ b/src/history-serving.test.ts @@ -1,31 +1,31 @@ import { expect, spyOn, test } from "bun:test"; import { readFileSync, readdirSync, symlinkSync, unlinkSync, writeFileSync } from "node:fs"; import { join } from "node:path"; -import { compileCanvas } from "./compile"; -import { CanvasHistory } from "./history"; -import { createCanvasServer } from "./serve"; -import { CanvasService } from "./service"; -import { tempDir, VALID_CANVAS, writeCanvas } from "./test/fixtures"; +import { compileArtifact } from "./compile"; +import { ArtifactHistory } from "./history"; +import { createArtifactServer } from "./serve"; +import { ArtifactService } from "./service"; +import { tempDir, VALID_ARTIFACT, writeArtifact } from "./test/fixtures"; function bridge(html: string) { - return JSON.parse(html.match(/window\.__herdrCanvas = (.*);<\/script>/)![1]); + return JSON.parse(html.match(/window\.__artifacts = (.*);<\/script>/)![1]); } function bundlePath(html: string) { return html.match(/src="([^\"]+bundle\.js[^\"]*)"/)![1]; } test("direct edits create revisions; page bundles are pinned even if source changes before the JS request", async () => { const dir = tempDir(); - const path = writeCanvas(dir, "overview", VALID_CANVAS); + const path = writeArtifact(dir, "overview", VALID_ARTIFACT); const dbPath = join(dir, "history.sqlite"); - const server = await createCanvasServer({ canvasesDir: dir, historyPath: dbPath }); - const history = new CanvasHistory(dbPath); + const server = await createArtifactServer({ artifactsDir: dir, historyPath: dbPath }); + const history = new ArtifactHistory(dbPath); try { const first = await (await fetch(`${server.url}/c/overview`)).text(); const firstVersion = bridge(first).versionId; - writeFileSync(path, VALID_CANVAS.replaceAll("Overview", "EditedScene")); + writeFileSync(path, VALID_ARTIFACT.replaceAll("Overview", "EditedScene")); const oldJS = await (await fetch(server.url + bundlePath(first))).text(); expect(oldJS).toContain("Overview"); expect(oldJS).not.toContain("EditedScene"); - expect(history.version(firstVersion)?.source).toBe(VALID_CANVAS); + expect(history.version(firstVersion)?.source).toBe(VALID_ARTIFACT); const second = await (await fetch(`${server.url}/c/overview`)).text(); expect(bridge(second).versionId).not.toBe(firstVersion); expect(await (await fetch(server.url + bundlePath(second))).text()).toContain("EditedScene"); @@ -37,19 +37,19 @@ test("direct edits create revisions; page bundles are pinned even if source chan test("compile uses the exact captured source even when the file changes during compilation", async () => { const dir = tempDir(); - const path = writeCanvas(dir, "overview", VALID_CANVAS); + const path = writeArtifact(dir, "overview", VALID_ARTIFACT); const dbPath = join(dir, "history.sqlite"); - const server = await createCanvasServer({ - canvasesDir: dir, historyPath: dbPath, + const server = await createArtifactServer({ + artifactsDir: dir, historyPath: dbPath, compile: async (file, source) => { - writeFileSync(path, VALID_CANVAS.replaceAll("Overview", "RacingEdit")); - return compileCanvas(file, source); + writeFileSync(path, VALID_ARTIFACT.replaceAll("Overview", "RacingEdit")); + return compileArtifact(file, source); }, }); - const history = new CanvasHistory(dbPath); + const history = new ArtifactHistory(dbPath); try { const html = await (await fetch(`${server.url}/c/overview`)).text(); - expect(history.version(bridge(html).versionId)?.source).toBe(VALID_CANVAS); + expect(history.version(bridge(html).versionId)?.source).toBe(VALID_ARTIFACT); const js = await (await fetch(server.url + bundlePath(html))).text(); expect(js).toContain("Overview"); expect(js).not.toContain("RacingEdit"); @@ -58,11 +58,11 @@ test("compile uses the exact captured source even when the file changes during c test("archived raw source reopens after deletion and server closure with the selected initial state", async () => { const dir = tempDir(); - const path = writeCanvas(dir, "overview", VALID_CANVAS); + const path = writeArtifact(dir, "overview", VALID_ARTIFACT); const dbPath = join(dir, "history.sqlite"); - const opts = { canvasesDir: dir, historyPath: dbPath }; - let server = await createCanvasServer(opts); - const history = new CanvasHistory(dbPath); + const opts = { artifactsDir: dir, historyPath: dbPath }; + let server = await createArtifactServer(opts); + const history = new ArtifactHistory(dbPath); try { const statePath = path.replace(".tsx", ".data.json"); writeFileSync(statePath, JSON.stringify({ label: "", count: 1 })); @@ -71,7 +71,7 @@ test("archived raw source reopens after deletion and server closure with the sel await fetch(`${server.url}/c/overview`); server.stop(); unlinkSync(path); // Disposable test fixture; history must outlive its source file. - server = await createCanvasServer(opts); + server = await createArtifactServer(opts); const response = await fetch(`${server.url}/v/${original.versionId}?event=${original.eventId}`); expect(response.status).toBe(200); const html = await response.text(); @@ -91,11 +91,11 @@ test("archived raw source reopens after deletion and server closure with the sel test("failed compilations are not recorded as served and versions cannot cross workspace boundaries", async () => { const dir = tempDir(); - writeCanvas(dir, "bad", "export default function ( broken"); + writeArtifact(dir, "bad", "export default function ( broken"); const dbPath = join(dir, "history.sqlite"); - const history = new CanvasHistory(dbPath); - const other = history.capture({ workspace: dir + "-other", name: "private", sourcePath: join(dir, "private.canvas.tsx"), source: VALID_CANVAS, runtime: "test" }); - const server = await createCanvasServer({ canvasesDir: dir, historyPath: dbPath }); + const history = new ArtifactHistory(dbPath); + const other = history.capture({ workspace: dir + "-other", name: "private", sourcePath: join(dir, "private.artifact.tsx"), source: VALID_ARTIFACT, runtime: "test" }); + const server = await createArtifactServer({ artifactsDir: dir, historyPath: dbPath }); try { expect((await fetch(`${server.url}/c/bad`)).status).toBe(400); expect(history.list(dir)).toHaveLength(0); @@ -106,11 +106,11 @@ test("failed compilations are not recorded as served and versions cannot cross w test("restore preserves unserved drafts, creates a new revision, retains UI state, and works after deletion", () => { const dir = tempDir(); - const path = writeCanvas(dir, "overview", VALID_CANVAS); + const path = writeArtifact(dir, "overview", VALID_ARTIFACT); const dbPath = join(dir, "history.sqlite"); - const history = new CanvasHistory(dbPath); - const original = history.capture({ workspace: dir, name: "overview", sourcePath: path, source: VALID_CANVAS, runtime: "test" }); - const service = new CanvasService({ canvasesDir: dir, env: { HERDR_CANVAS_HISTORY_DB: dbPath } }); + const history = new ArtifactHistory(dbPath); + const original = history.capture({ workspace: dir, name: "overview", sourcePath: path, source: VALID_ARTIFACT, runtime: "test" }); + const service = new ArtifactService({ artifactsDir: dir, env: { ARTIFACTS_HISTORY_DB: dbPath } }); try { writeFileSync(path, "unfinished draft"); const statePath = path.replace(".tsx", ".data.json"); @@ -118,34 +118,34 @@ test("restore preserves unserved drafts, creates a new revision, retains UI stat const restored = service.restore(original.id); expect(restored.ok).toBe(true); expect(restored.revision).toBe(3); - expect(readFileSync(path, "utf8")).toBe(VALID_CANVAS); + expect(readFileSync(path, "utf8")).toBe(VALID_ARTIFACT); expect(history.version(restored.versionId)?.restored_from).toBe(original.id); const draft = (history.list(dir) as Array<{ version_id: string; reason: string }>).find(row => row.reason === "before-restore")!; expect(history.version(draft.version_id)?.source).toBe("unfinished draft"); expect(readFileSync(statePath, "utf8")).toBe('{"count":9}'); unlinkSync(path); expect(service.restore(original.id).revision).toBe(4); - expect(readFileSync(path, "utf8")).toBe(VALID_CANVAS); + expect(readFileSync(path, "utf8")).toBe(VALID_ARTIFACT); expect(history.list(dir)).toHaveLength(4); } finally { history.close(); } }, { timeout: 30_000 }); test("restore detects an edit made after its preservation snapshot and leaves that edit untouched", () => { const dir = tempDir(); - const path = writeCanvas(dir, "overview", VALID_CANVAS); + const path = writeArtifact(dir, "overview", VALID_ARTIFACT); const dbPath = join(dir, "history.sqlite"); - const archive = new CanvasHistory(dbPath); - const version = archive.capture({ workspace: dir, name: "overview", sourcePath: path, source: VALID_CANVAS, runtime: "test" }); + const archive = new ArtifactHistory(dbPath); + const version = archive.capture({ workspace: dir, name: "overview", sourcePath: path, source: VALID_ARTIFACT, runtime: "test" }); writeFileSync(path, "first unserved draft"); - const capture = CanvasHistory.prototype.capture; - const spy = spyOn(CanvasHistory.prototype, "capture").mockImplementation(function (this: CanvasHistory, input) { + const capture = ArtifactHistory.prototype.capture; + const spy = spyOn(ArtifactHistory.prototype, "capture").mockImplementation(function (this: ArtifactHistory, input) { const result = capture.call(this, input); if (input.reason === "before-restore") writeFileSync(path, "concurrent editor change"); return result; }); try { - const service = new CanvasService({ canvasesDir: dir, env: { HERDR_CANVAS_HISTORY_DB: dbPath } }); - expect(() => service.restore(version.id)).toThrow("canvas changed during restore"); + const service = new ArtifactService({ artifactsDir: dir, env: { ARTIFACTS_HISTORY_DB: dbPath } }); + expect(() => service.restore(version.id)).toThrow("artifact changed during restore"); expect(readFileSync(path, "utf8")).toBe("concurrent editor change"); expect(archive.list(dir)).toHaveLength(2); expect(readdirSync(dir).filter(name => name.endsWith(".tmp"))).toEqual([]); @@ -154,33 +154,33 @@ test("restore detects an edit made after its preservation snapshot and leaves th test("live serving and restore reject symlinked sources without modifying the external target", async () => { const dir = tempDir(); - const outside = writeCanvas(tempDir(), "outside", VALID_CANVAS); - const path = join(dir, "overview.canvas.tsx"); + const outside = writeArtifact(tempDir(), "outside", VALID_ARTIFACT); + const path = join(dir, "overview.artifact.tsx"); symlinkSync(outside, path); const dbPath = join(dir, "history.sqlite"); - const archive = new CanvasHistory(dbPath); + const archive = new ArtifactHistory(dbPath); const target = archive.capture({ workspace: dir, name: "overview", sourcePath: path, source: "different source", runtime: "test" }); - const server = await createCanvasServer({ canvasesDir: dir, historyPath: dbPath }); + const server = await createArtifactServer({ artifactsDir: dir, historyPath: dbPath }); try { expect((await fetch(`${server.url}/c/overview`)).status).toBe(400); expect(archive.events(target.id)).toHaveLength(0); - const service = new CanvasService({ canvasesDir: dir, env: { HERDR_CANVAS_HISTORY_DB: dbPath } }); + const service = new ArtifactService({ artifactsDir: dir, env: { ARTIFACTS_HISTORY_DB: dbPath } }); expect(() => service.restore(target.id)).toThrow("not a symlink"); - expect(readFileSync(outside, "utf8")).toBe(VALID_CANVAS); + expect(readFileSync(outside, "utf8")).toBe(VALID_ARTIFACT); } finally { server.stop(); archive.close(); } }); test("SDK changes invalidate memory bundles and each serve records the actual runtime", async () => { const dir = tempDir(); - writeCanvas(dir, "overview", VALID_CANVAS); + writeArtifact(dir, "overview", VALID_ARTIFACT); const dbPath = join(dir, "history.sqlite"); let runtime = "sdk-a"; let builds = 0; - const server = await createCanvasServer({ - canvasesDir: dir, historyPath: dbPath, runtimeIdentity: () => runtime, + const server = await createArtifactServer({ + artifactsDir: dir, historyPath: dbPath, runtimeIdentity: () => runtime, compile: async () => { builds++; return { ok: true, js: `export default '${runtime}';`, diagnostics: [] }; }, }); - const archive = new CanvasHistory(dbPath); + const archive = new ArtifactHistory(dbPath); try { const first = await (await fetch(`${server.url}/c/overview`)).text(); runtime = "sdk-b"; @@ -195,3 +195,33 @@ test("SDK changes invalidate memory bundles and each serve records the actual ru expect((await fetch(`${server.url}/v/${bridge(first).versionId}/bundle.js?runtime=expired`)).status).toBe(409); } finally { server.stop(); archive.close(); } }); + +test("legacy source, project, state and history stay paired through serving and edits", async () => { + const dir = tempDir(), path = join(dir, "legacy.canvas.tsx"), dbPath = join(dir, "history.sqlite"); + writeFileSync(path, VALID_ARTIFACT); + writeFileSync(`${path}.project.json`, JSON.stringify({ files: { "note.json": "{}" }, dependencies: {}, lock: {} })); + writeFileSync(join(dir, "legacy.canvas.data.json"), JSON.stringify({ count: 7 })); + const service = new ArtifactService({ artifactsDir: dir, env: { HERDR_CANVAS_HISTORY_DB: dbPath } }); + expect(service.list()[0]?.path).toBe(path); + expect(service.readRange("legacy").project.files["note.json"]).toBe("{}"); + const server = await createArtifactServer({ artifactsDir: dir, historyPath: dbPath }); + const history = new ArtifactHistory(dbPath); + try { + const response = await fetch(`${server.url}/c/legacy`); + expect(response.status).toBe(200); + const first = bridge(await response.text()); + expect(first.state.count).toBe(7); + expect(history.version(first.versionId)?.source_path).toBe(path); + await fetch(`${server.url}/c/legacy/state`, { method: "PUT", body: JSON.stringify({ key: "count", value: 8 }) }); + expect(JSON.parse(readFileSync(join(dir, "legacy.canvas.data.json"), "utf8")).count).toBe(8); + service.edit("legacy", [{ old_text: "

Overview

", new_text: "

Legacy Edit

" }]); + expect(readFileSync(path, "utf8")).toContain("Legacy Edit"); + expect(service.restore(first.versionId).path).toBe(path); + unlinkSync(path); + expect(service.restore(first.versionId).path).toBe(path); + expect(readFileSync(path, "utf8")).toBe(VALID_ARTIFACT); + expect(readdirSync(dir)).not.toContain("legacy.artifact.tsx"); + expect(readdirSync(dir)).not.toContain("legacy.artifact.data.json"); + expect(service.readRange("legacy").project.files["note.json"]).toBe("{}"); + } finally { server.stop(); history.close(); } +}, 30_000); diff --git a/src/history.test.ts b/src/history.test.ts index 5d50e3d..9172386 100644 --- a/src/history.test.ts +++ b/src/history.test.ts @@ -1,20 +1,21 @@ +import { mkdirSync } from "node:fs"; import { expect, test } from "bun:test"; import { join } from "node:path"; -import { CanvasHistory, historyPath } from "./history"; +import { ArtifactHistory, historyPath } from "./history"; import { PLUGIN_ROOT } from "./paths"; import { tempDir } from "./test/fixtures"; test("history survives reconnect, scopes artifacts, and records distinct serves without duplicating unchanged source", () => { const dir = tempDir(); const dbPath = join(dir, "history.sqlite"); - let history = new CanvasHistory(dbPath); - const input = { workspace: dir, name: "one", sourcePath: join(dir, "one.canvas.tsx"), source: "raw TSX", runtime: "runtime-a" }; + let history = new ArtifactHistory(dbPath); + const input = { workspace: dir, name: "one", sourcePath: join(dir, "one.artifact.tsx"), source: "raw TSX", runtime: "runtime-a" }; const version = history.capture(input); expect(history.capture({ ...input, runtime: "runtime-b" }).id).toBe(version.id); history.served(version.id, { count: 1 }, "live", { HERDR_PANE_ID: "w1:p2", HERDR_WORKSPACE_ID: "w1" }, "runtime-a"); history.served(version.id, { count: 2 }, "live", {}, "runtime-b"); history.close(); - history = new CanvasHistory(dbPath); + history = new ArtifactHistory(dbPath); try { expect(history.version(version.id)?.source).toBe("raw TSX"); expect(history.list(dir)).toHaveLength(1); @@ -33,11 +34,11 @@ test("history survives reconnect, scopes artifacts, and records distinct serves test("simultaneous processes share one revision for identical source", async () => { const dir = tempDir(); const dbPath = join(dir, "history.sqlite"); - const initial = new CanvasHistory(dbPath); + const initial = new ArtifactHistory(dbPath); initial.close(); - const script = `import { CanvasHistory } from ${JSON.stringify(join(PLUGIN_ROOT, "src/history.ts"))}; - const history = new CanvasHistory(process.env.TEST_HISTORY_DB); - for (let i = 0; i < 15; i++) history.capture({ workspace: process.env.TEST_WORKSPACE, name: 'shared', sourcePath: process.env.TEST_WORKSPACE + '/shared.canvas.tsx', source: 'same source', runtime: 'test' }); + const script = `import { ArtifactHistory } from ${JSON.stringify(join(PLUGIN_ROOT, "src/history.ts"))}; + const history = new ArtifactHistory(process.env.TEST_HISTORY_DB); + for (let i = 0; i < 15; i++) history.capture({ workspace: process.env.TEST_WORKSPACE, name: 'shared', sourcePath: process.env.TEST_WORKSPACE + '/shared.artifact.tsx', source: 'same source', runtime: 'test' }); history.close();`; const children = [0, 1, 2].map(() => Bun.spawn([process.execPath, "-e", script], { env: { ...process.env, TEST_HISTORY_DB: dbPath, TEST_WORKSPACE: dir }, stdout: "pipe", stderr: "pipe", @@ -46,13 +47,32 @@ test("simultaneous processes share one revision for identical source", async () const stderr = await new Response(child.stderr).text(); expect({ code: await child.exited, stderr }).toEqual({ code: 0, stderr: "" }); } - const history = new CanvasHistory(dbPath); + const history = new ArtifactHistory(dbPath); try { expect(history.list(dir)).toHaveLength(1); } finally { history.close(); } }); test("history path override does not depend on a pane's temporary plugin state directory", () => { const dir = tempDir(); - expect(historyPath({ HERDR_CANVAS_HISTORY_DB: join(dir, "archive.sqlite"), HERDR_PLUGIN_STATE_DIR: "/tmp/pane" })).toBe(join(dir, "archive.sqlite")); + expect(historyPath({ ARTIFACTS_HISTORY_DB: join(dir, "archive.sqlite"), HERDR_PLUGIN_STATE_DIR: "/tmp/pane" })).toBe(join(dir, "archive.sqlite")); expect(historyPath({ HERDR_PLUGIN_STATE_DIR: "/tmp/pane" })).not.toContain("/tmp/pane"); }); + +test("legacy history file is reused without moving records or workspace keys", () => { + const home = tempDir(), base = join(home, ".local", "share"); + const legacy = join(base, "herdr-canvas", "history.sqlite"), current = join(base, "artifacts", "history.sqlite"); + const workspace = join(home, "project", "canvases"); + const original = new ArtifactHistory(legacy); + const version = original.capture({ workspace, name: "old", sourcePath: join(workspace, "old.canvas.tsx"), source: "original", runtime: "test" }); + original.close(); + mkdirSync(join(base, "artifacts"), { recursive: true }); + expect(historyPath({}, "linux", home)).toBe(legacy); + const reopened = new ArtifactHistory(historyPath({}, "linux", home)); + expect(reopened.version(version.id, workspace)?.source_path).toBe(join(workspace, "old.canvas.tsx")); + expect(reopened.list(workspace)[0]?.artifact_id).toBe(version.artifact_id); + reopened.close(); + expect(historyPath({ HERDR_CANVAS_HISTORY_DB: legacy }, "linux", home)).toBe(legacy); + expect(historyPath({ ARTIFACTS_HISTORY_DB: current, HERDR_CANVAS_HISTORY_DB: legacy }, "linux", home)).toBe(current); + new ArtifactHistory(current).close(); + expect(historyPath({}, "linux", home)).toBe(current); +}); diff --git a/src/history.ts b/src/history.ts index 37878cc..40e1035 100644 --- a/src/history.ts +++ b/src/history.ts @@ -8,12 +8,14 @@ import { homedir } from "node:os"; import { dirname, join, resolve } from "node:path"; import { PLUGIN_ROOT } from "./paths"; -export function historyPath(env: NodeJS.ProcessEnv = process.env): string { - if (env.HERDR_CANVAS_HISTORY_DB) return resolve(env.HERDR_CANVAS_HISTORY_DB); - const root = process.platform === "darwin" - ? join(homedir(), "Library", "Application Support") - : env.XDG_DATA_HOME || join(homedir(), ".local", "share"); - return join(root, "herdr-canvas", "history.sqlite"); +export function historyPath(env: NodeJS.ProcessEnv = process.env, platform: string = process.platform, home = homedir()): string { + if (env.ARTIFACTS_HISTORY_DB || env.HERDR_CANVAS_HISTORY_DB) return resolve(env.ARTIFACTS_HISTORY_DB || env.HERDR_CANVAS_HISTORY_DB!); + const root = platform === "darwin" + ? join(home, "Library", "Application Support") + : env.XDG_DATA_HOME || join(home, ".local", "share"); + const current = join(root, "artifacts", "history.sqlite"); + const legacy = join(root, "herdr-canvas", "history.sqlite"); + return !existsSync(current) && existsSync(legacy) ? legacy : current; } export function hash(value: string): string { @@ -50,14 +52,14 @@ export function runtimeIdentity(): string { export type { Version, ServeEvent, HistoryEntry } from "./historyTypes"; import type { Version, ServeEvent, HistoryEntry } from "./historyTypes"; -export class CanvasHistory { +export class ArtifactHistory { readonly db: Database; constructor(readonly path = historyPath()) { mkdirSync(dirname(path), { recursive: true }); this.db = new Database(path, { create: true, strict: true }); this.db.exec("pragma busy_timeout = 5000; pragma journal_mode = wal; pragma foreign_keys = on;"); const version = (this.db.query("pragma user_version").get() as { user_version: number }).user_version; - if (version > 1) { this.db.close(); throw new Error("Canvas history database is newer than this application"); } + if (version > 1) { this.db.close(); throw new Error("Artifact history database is newer than this application"); } this.db.transaction(() => { this.db.exec(` create table if not exists artifacts ( diff --git a/src/html.ts b/src/html.ts index c223235..6cb3357 100644 --- a/src/html.ts +++ b/src/html.ts @@ -1,6 +1,6 @@ -export function canvasHtml(opts: { +export function artifactHtml(opts: { title: string; - canvasId: string; + artifactId: string; scriptUrl: string; persistUrl?: string; actionUrl?: string; @@ -12,7 +12,8 @@ export function canvasHtml(opts: { state?: Record; }): string { const bridge = { - canvasId: opts.canvasId, + artifactId: opts.artifactId, + canvasId: opts.artifactId, persistUrl: opts.persistUrl, actionUrl: opts.actionUrl, theme: { kind: opts.themeKind ?? "dark" }, @@ -38,20 +39,20 @@ export function canvasHtml(opts: {
- + `; +
Waiting for artifact…
`; } /** Compile a source snapshot without a loopback server or a Herdr process. */ -export async function canvasAppResult(service: CanvasService, selection: { name?: string; version_id?: string; event_id?: string }) { +export async function artifactAppResult(service: ArtifactService, selection: { name?: string; version_id?: string; event_id?: string }) { if (Boolean(selection.name) === Boolean(selection.version_id)) throw new Error("provide name or version_id, but not both"); if (selection.event_id && !selection.version_id) throw new Error("event_id requires version_id"); const saved = selection.version_id ? service.version(selection.version_id) : undefined; - const path = saved?.source_path ?? service.resolve(ensureCanvasFileName(selection.name!)); - if (!saved) assertRegularCanvas(path); + const path = saved?.source_path ?? service.resolve(ensureArtifactFileName(selection.name!)); + if (!saved) assertRegularArtifact(path); const source = saved?.source ?? readFileSync(path, "utf8"); const project = saved?.project ?? readLocalProject(path); const runtime = runtimeIdentity(); - const compiled = await compileCanvas(path, source, undefined, project); - if (!compiled.ok || !compiled.js) return { ok: false, path, check: formatCanvasCheck(compiled.diagnostics), diagnostics: compiled.diagnostics }; - if (runtimeIdentity() !== runtime) throw new Error("Canvas SDK changed during compilation; retry"); + const compiled = await compileArtifact(path, source, undefined, project); + if (!compiled.ok || !compiled.js) return { ok: false, path, check: formatArtifactCheck(compiled.diagnostics), diagnostics: compiled.diagnostics }; + if (runtimeIdentity() !== runtime) throw new Error("Artifact SDK changed during compilation; retry"); let state: Record = {}; if (saved) { const event = selection.event_id ? saved.events.find(event => event.id === selection.event_id) : saved.events.find(event => event.mode === "live") ?? saved.events[0]; @@ -75,19 +75,19 @@ export async function canvasAppResult(service: CanvasService, selection: { name? if (event) state = JSON.parse(event.initial_state); } else { try { - const value: unknown = JSON.parse(readFileSync(path.replace(/\.canvas\.tsx$/, ".canvas.data.json"), "utf8")); + const value: unknown = JSON.parse(readFileSync(path.replace(/\.artifact\.tsx$/, ".artifact.data.json"), "utf8")); if (value && typeof value === "object" && !Array.isArray(value)) state = value as Record; } catch { /* Match gallery previews: malformed sidecars remain untouched. */ } } - const history = new CanvasHistory(historyPath(service.env)); + const history = new ArtifactHistory(historyPath(service.env)); try { - const version = saved ?? history.capture({ workspace: service.canvasesDir, name: canvasIdFromFile(path), sourcePath: path, source, project, runtime }); + const version = saved ?? history.capture({ workspace: service.artifactsDir, name: artifactIdFromFile(path), sourcePath: path, source, project, runtime }); // Delivery records a preview event, not evidence that a human saw the app. const eventId = history.served(version.id, state, "preview", service.env, runtime); return { - ok: true, path, check: formatCanvasCheck([]), diagnostics: [], - canvas: { name: version.name, versionId: version.id, eventId, sourceHash: version.source_hash }, - _meta: { canvas: { name: version.name, versionId: version.id, eventId, sourceHash: version.source_hash, js: compiled.js, state } satisfies CanvasAppPayload }, + ok: true, path, check: formatArtifactCheck([]), diagnostics: [], + artifact: { name: version.name, versionId: version.id, eventId, sourceHash: version.source_hash }, + _meta: { artifact: { name: version.name, versionId: version.id, eventId, sourceHash: version.source_hash, js: compiled.js, state } satisfies ArtifactAppPayload }, }; } finally { history.close(); } } diff --git a/src/mcp/guide.ts b/src/mcp/guide.ts index fbcd4ab..76655c0 100644 --- a/src/mcp/guide.ts +++ b/src/mcp/guide.ts @@ -1,32 +1,38 @@ // Kept in sync with the SDK by the guide contract test, without importing // browser-only React modules into the Cloudflare server. -export const CANVAS_GUIDE_EXPORTS = [ +export const ARTIFACTS_GUIDE_EXPORTS = [ "BarChart", "Button", "Callout", "Card", "CardBody", "CardHeader", "Checkbox", "Code", "Divider", "Grid", "H1", "H2", "H3", "LineChart", "Link", "PieChart", "Pill", "Row", "Select", "Spacer", "Stack", "Stat", "Table", "Text", "TextArea", - "TextInput", "Toggle", "canvasFetch", "pluginCall", "Routes", "Route", "Outlet", "Navigate", "NavLink", "useNavigate", "useParams", "useLocation", "useSearchParams", "useMatch", "useResolvedPath", "canvasPaletteDark", "canvasPaletteLight", "canvasTypography", - "mergeStyle", "themeFromKind", "tokensFromPalette", "useCanvasAction", "useCanvasState", "useState", "useReducer", "useRef", "useMemo", "useCallback", "useEffect", "useHostTheme", - "canvasFiles", "MAX_CANVAS_FILE_BYTES", + "TextInput", "Toggle", "artifactFetch", "pluginCall", "Routes", "Route", "Outlet", "Navigate", "NavLink", "useNavigate", "useParams", "useLocation", "useSearchParams", "useMatch", "useResolvedPath", "artifactPaletteDark", "artifactPaletteLight", "artifactTypography", + "mergeStyle", "themeFromKind", "tokensFromPalette", "useArtifactAction", "useArtifactState", "useState", "useReducer", "useRef", "useMemo", "useCallback", "useEffect", "useHostTheme", + "artifactFiles", "MAX_ARTIFACTS_FILE_BYTES", +]; +export const LEGACY_GUIDE_EXPORTS = [ + "useCanvasAction", "useCanvasState", "canvasFetch", "canvasFiles", "MAX_CANVAS_FILE_BYTES", + "canvasPaletteDark", "canvasPaletteLight", "canvasTypography", ]; -export function canvasGuide() { - return `# Sidequery Canvas SDK contract +export function artifactGuide() { + return `# Sidequery Artifacts SDK contract -Read this guide before your first canvas_write in the conversation. Reuse it for subsequent creates and edits; read it again when unsure about the SDK. +Read this guide before your first artifact_write in the conversation. Reuse it for subsequent creates and edits; read it again when unsure about the SDK. ## Source and imports -Submit a complete TSX entrypoint with a default-exported React component. Use a kebab-case canvas name without slashes. Import the SDK from "sidequery/canvas" ("herdr/canvas" and "cursor/canvas" are compatibility aliases). Add relative helper modules with canvas_write.project.files and exact package versions with project.dependencies. Omit project to preserve it; when supplied, files and dependencies replace the existing declarations. Dependency source is integrity-verified and archived for offline replay; project.lock is read-only. Use canvas_read/canvas_edit with file to select a helper. Hosted deployments may also expose browser packages listed by plugins_list. Browser React packages share the host runtime. Dynamic imports and require remain unsupported in authored canvas modules. +Submit a complete TSX entrypoint with a default-exported React component. Use a kebab-case artifact name without slashes. Import the SDK from "sidequery/artifacts" ("herdr/canvas" and "cursor/canvas" are compatibility aliases). Add relative helper modules with artifact_write.project.files and exact package versions with project.dependencies. Omit project to preserve it; when supplied, files and dependencies replace the existing declarations. Dependency source is integrity-verified and archived for offline replay; project.lock is read-only. Use artifact_read/artifact_edit with file to select a helper. Hosted deployments may also expose browser packages listed by plugins_list. Browser React packages share the host runtime. Dynamic imports and require remain unsupported in authored artifact modules. These are the installed SDK's runtime exports: -${CANVAS_GUIDE_EXPORTS.join(", ")} +${ARTIFACTS_GUIDE_EXPORTS.join(", ")} + +Historical sources can still use these compatibility aliases: ${LEGACY_GUIDE_EXPORTS.join(", ")}. Use the Artifacts names for new source. ## State and host hooks - useState, useReducer, useRef, useMemo, useCallback, useEffect: standard React hooks for component-local state and behavior. -- useCanvasState(key: string, defaultValue: T): [T, setter] for host-backed state. Keys must be stable and distinct. The setter accepts a value or updater. Live local views persist through the host; MCP and gallery views keep isolated in-memory state. +- useArtifactState(key: string, defaultValue: T): [T, setter] for host-backed state. Keys must be stable and distinct. The setter accepts a value or updater. Live local views persist through the host; MCP and gallery views keep isolated in-memory state. - useHostTheme(): returns the host theme with color and typography tokens. -- useCanvasAction(): returns a dispatcher accepting {type: "openFile", path, selection?}, {type: "promptAgent", prompt}, or {type: "openUrl", url}. In MCP Apps, promptAgent and HTTP(S) openUrl depend on host approval; openFile is unavailable. +- useArtifactAction(): returns a dispatcher accepting {type: "openFile", path, selection?}, {type: "promptAgent", prompt}, or {type: "openUrl", url}. In MCP Apps, promptAgent and HTTP(S) openUrl depend on host approval; openFile is unavailable. ## Deployment plugins @@ -34,31 +40,31 @@ pluginCall(plugin, operation, input, { signal }?) calls an authenticated depl ## Routing -The runtime supplies React Router. Use Routes, Route, Outlet, Navigate, NavLink and routing hooks from sidequery/canvas. Link to="/accounts/123" navigates within a canvas; existing Link href="https://..." keeps ordinary link behavior. Nested layouts, params, search params and navigation state work normally. Standalone /slug/* URLs support deep links, reload and browser back/forward. Gallery and chat views keep navigation in memory. /api and /api/* are reserved for the canvas backend; /_canvas/* is reserved for runtime bridges. +The runtime supplies React Router. Use Routes, Route, Outlet, Navigate, NavLink and routing hooks from sidequery/artifacts. Link to="/accounts/123" navigates within an artifact; existing Link href="https://..." keeps ordinary link behavior. Nested layouts, params, search params and navigation state work normally. Standalone /slug/* URLs support deep links, reload and browser back/forward. Gallery and chat views keep navigation in memory. /api and /api/* are reserved for the artifact backend; /_artifact/* is reserved for runtime bridges. ## Hosted servers and SQLite -canvasFetch(path: string, init?: RequestInit): Promise sends a request to the canvas's native server through the host bridge. Use relative paths and standard methods, headers, and bodies; check response.ok before reading response.json() or response.text(). Cross-origin and protocol-relative URLs are rejected. Request and response bodies are limited to 256 KiB. +artifactFetch(path: string, init?: RequestInit): Promise sends a request to the artifact's native server through the host bridge. Use relative paths and standard methods, headers, and bodies; check response.ok before reading response.json() or response.text(). Cross-origin and protocol-relative URLs are rejected. Request and response bodies are limited to 256 KiB. -Server requests require a hosted runtime with a native canvas server. Local Bun CLI, local stdio MCP and local gallery views do not execute servers and report requests as unavailable. Hosted MCP Apps and the hosted gallery support them. Hosted canvas_write accepts server TypeScript exporting class CanvasServer extends DurableObject from "cloudflare:workers", alongside browser contents. +Server requests require a hosted runtime with a native artifacts server. Local Bun CLI, local stdio MCP and local gallery views do not execute servers and report requests as unavailable. Hosted MCP Apps and the hosted gallery support them. Hosted artifact_write accepts server TypeScript exporting class ArtifactServer extends DurableObject from "cloudflare:workers", alongside browser contents. -Each canvas with a server gets one native SQLite database. Different canvas names get separate databases; multiple tabs and server restarts use the same database. Use this.ctx.storage.sql.exec(sql, ...bindings) with ? placeholders for values; cursors support .toArray() and .one(). Use this.ctx.storage.kv.get/put/delete for key/value data and this.ctx.storage.transactionSync(() => { ... }) for synchronous SQL/KV transactions. Initialize tables with create table if not exists. +Each artifact with a server gets one native SQLite database. Different artifact names get separate databases; multiple tabs and server restarts use the same database. Use this.ctx.storage.sql.exec(sql, ...bindings) with ? placeholders for values; cursors support .toArray() and .one(). Use this.ctx.storage.kv.get/put/delete for key/value data and this.ctx.storage.transactionSync(() => { ... }) for synchronous SQL/KV transactions. Initialize tables with create table if not exists. -Canvas has no application-schema migration runner. For schema changes, keep a schema version in the database and apply pending changes plus the version update in one this.ctx.storage.transactionSync during server initialization, before serving requests. Saving source does not execute migrations; they run when that server is next requested. Prefer additive changes compatible with older source because source restore does not reverse migrations. +Artifacts has no application-schema migration runner. For schema changes, keep a schema version in the database and apply pending changes plus the version update in one this.ctx.storage.transactionSync during server initialization, before serving requests. Saving source does not execute migrations; they run when that server is next requested. Prefer additive changes compatible with older source because source restore does not reverse migrations. -Omitting server preserves existing code; null removes code without deleting the database. Hosted canvas_read/canvas_edit accept part: "server". Browser and server source are versioned together; the database remains live across source edits and restores. Archived code also runs against the current database, not a historical snapshot. +Omitting server preserves existing code; null removes code without deleting the database. Hosted artifact_read/artifact_edit accept part: "server". Browser and server source are versioned together; the database remains live across source edits and restores. Archived code also runs against the current database, not a historical snapshot. -Database identity is library + workspace + canvas name: private libraries isolate users; an authorized team library shares data. Server code has no ordinary D1, R2 or custom Worker bindings and no global outbound access. +Database identity is library + workspace + artifact name: private libraries isolate users; an authorized team library shares data. Server code has no ordinary D1, R2 or custom Worker bindings and no global outbound access. -Direct HTTP requests to /slug/api and /slug/api/* reach the active canvas backend with the /api path and query preserved. Unknown API routes never fall back to page HTML; a canvas without a backend returns JSON 404. Existing body limits and access rules apply. +Direct HTTP requests to /slug/api and /slug/api/* reach the active artifact backend with the /api path and query preserved. Unknown API routes never fall back to page HTML; an artifact without a backend returns JSON 404. Existing body limits and access rules apply. -Implement fetch(request: Request) on CanvasServer and return a Response; browser code calls it with canvasFetch. SQLite stores durable application data; useState and useCanvasState hold UI state. Agents can also invoke the hosted canvas_request tool with name or version_id and a request envelope (path, method, headers, base64 body). +Implement fetch(request: Request) on ArtifactServer and return a Response; browser code calls it with artifactFetch. SQLite stores durable application data; useState and useArtifactState hold UI state. Agents can also invoke the hosted artifact_request tool with name or version_id and a request envelope (path, method, headers, base64 body). ## Persistent files -Hosted canvases and the managed local celld server expose canvasFiles from sidequery/canvas; no server code is required. canvasFiles.upload(file: File | Blob, {name?, signal?}?) returns {id,name,size,type,uploaded}; maximum 25 MiB. canvasFiles.list({cursor?}?) returns {files,cursor?} with at most 100 files. canvasFiles.read(id, {signal?}?) returns a Blob for parsing; canvasFiles.download(id) asks the host to start a download; canvasFiles.delete(id) removes it. Catch errors and show them in the UI. Use an input type="file" to select uploads. Filenames are display metadata; use opaque file IDs for subsequent operations. +Hosted artifacts and the managed local celld server expose artifactFiles from sidequery/artifacts; no server code is required. artifactFiles.upload(file: File | Blob, {name?, signal?}?) returns {id,name,size,type,uploaded}; maximum 25 MiB. artifactFiles.list({cursor?}?) returns {files,cursor?} with at most 100 files. artifactFiles.read(id, {signal?}?) returns a Blob for parsing; artifactFiles.download(id) asks the host to start a download; artifactFiles.delete(id) removes it. Catch errors and show them in the UI. Use an input type="file" to select uploads. Filenames are display metadata; use opaque file IDs for subsequent operations. -Files use the same library + workspace + canvas identity as the database and stay live across source edits, archived previews and restores. Uploads create distinct files even when names match. Public standalone canvases expose their files for reading but cannot upload/delete through the public URL. Plain file-based previews have no file storage. Transfer URLs expire after five minutes; bytes bypass the small JSON bridge. Agents use canvas_files for listing or allocating transfers, then ordinary HTTP PUT/GET for bytes. Never put file bytes or bearer transfer URLs into canvas source. +Files use the same library + workspace + artifact identity as the database and stay live across source edits, archived previews and restores. Uploads create distinct files even when names match. Public standalone artifacts expose their files for reading but cannot upload/delete through the public URL. Plain file-based previews have no file storage. Transfer URLs expire after five minutes; bytes bypass the small JSON bridge. Agents use artifact_files for listing or allocating transfers, then ordinary HTTP PUT/GET for bytes. Never put file bytes or bearer transfer URLs into artifact source. ## Common components and props @@ -71,12 +77,12 @@ Files use the same library + workspace + canvas identity as the database and sta ## Restrictions and validation -For browser TSX, embed data in the source or use canvasFetch when a hosted native server is available. Direct network calls (fetch, XMLHttpRequest, WebSocket), eval, new Function, process/Bun APIs, localStorage and sessionStorage are disallowed. The browser import restrictions above do not apply to native server source, which imports DurableObject from "cloudflare:workers". +For browser TSX, embed data in the source or use artifactFetch when a hosted native server is available. Direct network calls (fetch, XMLHttpRequest, WebSocket), eval, new Function, process/Bun APIs, localStorage and sessionStorage are disallowed. The browser import restrictions above do not apply to native server source, which imports DurableObject from "cloudflare:workers". -canvas_write creates or replaces the full source and then validates it. canvas_edit applies exact replacements and then validates; use canvas_read's source_hash as expected_hash to guard edits. A failed typecheck can leave source applied: inspect applied/ok and diagnostics, fix the source, and validate again. Do not treat an error as a rollback. Successful writes/edits show an inline preview in MCP Apps hosts. canvas_typecheck and canvas_compile check existing canvases; canvas_open shows one. canvas_history lists revisions; canvas_version reads archived source; canvas_restore restores source as a new revision while retaining current state. Hosted revisions include both client and server source. +artifact_write creates or replaces the full source and then validates it. artifact_edit applies exact replacements and then validates; use artifact_read's source_hash as expected_hash to guard edits. A failed typecheck can leave source applied: inspect applied/ok and diagnostics, fix the source, and validate again. Do not treat an error as a rollback. Successful writes/edits show an inline preview in MCP Apps hosts. artifact_typecheck and artifact_compile check existing artifacts; artifact_open shows one. artifact_history lists revisions; artifact_version reads archived source; artifact_restore restores source as a new revision while retaining current state. Hosted revisions include both client and server source. `; } -export function canvasGuideResult() { - return { content: [{ type: "text" as const, text: canvasGuide() }], isError: false }; +export function artifactGuideResult() { + return { content: [{ type: "text" as const, text: artifactGuide() }], isError: false }; } diff --git a/src/mcp/local-tools.test.ts b/src/mcp/local-tools.test.ts index df8a07e..102c6a4 100644 --- a/src/mcp/local-tools.test.ts +++ b/src/mcp/local-tools.test.ts @@ -2,16 +2,16 @@ import { expect, test } from "bun:test"; import { handleMcpRequest } from "./local-tools"; import { encodeMessage, parseMessages } from "./protocol"; -import { CanvasService } from "../service"; -import { VALID_CANVAS, tempDir } from "../test/fixtures"; +import { ArtifactService } from "../service"; +import { VALID_ARTIFACT, tempDir } from "../test/fixtures"; import { join } from "node:path"; import { readFileSync } from "node:fs"; -import { CanvasHistory } from "../history"; -import { CANVAS_GUIDE_EXPORTS } from "./guide"; +import { ArtifactHistory } from "../history"; +import { ARTIFACTS_GUIDE_EXPORTS, LEGACY_GUIDE_EXPORTS } from "./guide"; import * as sdk from "../sdk"; test("guide documents exactly the installed SDK runtime exports", () => { - expect([...CANVAS_GUIDE_EXPORTS].sort()).toEqual(Object.keys(sdk).sort()); + expect([...ARTIFACTS_GUIDE_EXPORTS, ...LEGACY_GUIDE_EXPORTS].sort()).toEqual(Object.keys(sdk).sort()); }); test("encodeMessage writes standard newline-delimited MCP JSON-RPC", () => { @@ -34,66 +34,66 @@ test("parseMessages reads newline-delimited JSON-RPC", () => { }); test("MCP initialize and tools/list", async () => { - const service = new CanvasService({ canvasesDir: tempDir() }); + const service = new ArtifactService({ artifactsDir: tempDir() }); const initialized = await handleMcpRequest({ jsonrpc: "2.0", id: 1, method: "initialize" }, service); expect(initialized?.result).toMatchObject({ - serverInfo: { name: "canvas" }, + serverInfo: { name: "artifacts" }, }); const listed = await handleMcpRequest({ jsonrpc: "2.0", id: 2, method: "tools/list" }, service); const tools = (listed?.result as { tools: Array<{ name: string }> }).tools.map((tool) => tool.name); - expect(tools).toContain("canvas_write"); - expect(tools).toContain("canvas_guide"); - expect(tools).toContain("canvas_read"); - expect(tools).toContain("canvas_edit"); - expect(tools).toContain("canvas_open"); - expect(tools).toContain("canvas_history"); - expect(tools).toContain("canvas_version"); - expect(tools).toContain("canvas_restore"); + expect(tools).toContain("artifact_write"); + expect(tools).toContain("artifact_guide"); + expect(tools).toContain("artifact_read"); + expect(tools).toContain("artifact_edit"); + expect(tools).toContain("artifact_open"); + expect(tools).toContain("artifact_history"); + expect(tools).toContain("artifact_version"); + expect(tools).toContain("artifact_restore"); }); test("MCP guide is read-only", async () => { const dir = tempDir(); - const service = new CanvasService({ canvasesDir: dir, env: { HERDR_CANVAS_HISTORY_DB: join(dir, "history.sqlite") } }); - const response = await handleMcpRequest({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "canvas_guide", arguments: {} } }, service); + const service = new ArtifactService({ artifactsDir: dir, env: { ARTIFACTS_HISTORY_DB: join(dir, "history.sqlite") } }); + const response = await handleMcpRequest({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "artifact_guide", arguments: {} } }, service); const guide = response?.result as { content: Array<{ text: string }>; isError: boolean }; expect(guide.isError).toBe(false); expect(service.list()).toHaveLength(0); - expect(guide.content[0]!.text).toContain("sidequery/canvas"); + expect(guide.content[0]!.text).toContain("sidequery/artifacts"); }, { timeout: 30_000 }); test( - "MCP canvas_write returns typecheck diagnostics", + "MCP artifact_write returns typecheck diagnostics", async () => { const dir = tempDir(); - const service = new CanvasService({ canvasesDir: dir, env: { HERDR_CANVAS_HISTORY_DB: join(dir, "history.sqlite") } }); + const service = new ArtifactService({ artifactsDir: dir, env: { ARTIFACTS_HISTORY_DB: join(dir, "history.sqlite") } }); const response = await handleMcpRequest( { jsonrpc: "2.0", id: 3, method: "tools/call", params: { - name: "canvas_write", - arguments: { name: "overview", contents: VALID_CANVAS }, + name: "artifact_write", + arguments: { name: "overview", contents: VALID_ARTIFACT }, }, }, service, ); const text = (response?.result as { content: Array<{ text: string }> }).content[0]?.text ?? ""; - expect(text).toContain("Canvas TypeScript check: no errors"); + expect(text).toContain("Artifact TypeScript check: no errors"); expect(service.list()).toHaveLength(1); }, { timeout: 30_000 }, ); -test("MCP canvas_write reports sandbox errors", async () => { - const service = new CanvasService({ canvasesDir: tempDir() }); +test("MCP artifact_write reports sandbox errors", async () => { + const service = new ArtifactService({ artifactsDir: tempDir() }); const response = await handleMcpRequest( { jsonrpc: "2.0", id: 4, method: "tools/call", params: { - name: "canvas_write", + name: "artifact_write", arguments: { name: "bad", contents: `import fs from "fs";\nexport default function App() { return null; }\n`, @@ -104,20 +104,20 @@ test("MCP canvas_write reports sandbox errors", async () => { ); const payload = response?.result as { content: Array<{ text: string }>; isError?: boolean }; expect(payload.isError).toBe(true); - expect(payload.content[0]?.text).toContain("Canvas TypeScript check:"); + expect(payload.content[0]?.text).toContain("Artifact TypeScript check:"); }); test("MCP history tools read archived source, reopen a managed pane, and restore forward", async () => { const dir = tempDir(); const dbPath = join(dir, "history.sqlite"); - const path = join(dir, "overview.canvas.tsx"); - const archive = new CanvasHistory(dbPath); - const version = archive.capture({ workspace: dir, name: "overview", sourcePath: path, source: VALID_CANVAS, runtime: "test" }); + const path = join(dir, "overview.artifact.tsx"); + const archive = new ArtifactHistory(dbPath); + const version = archive.capture({ workspace: dir, name: "overview", sourcePath: path, source: VALID_ARTIFACT, runtime: "test" }); const eventId = archive.served(version.id, { count: 4 }, "live"); archive.close(); const calls: string[][] = []; - const service = new CanvasService({ - canvasesDir: dir, env: { HERDR_CANVAS_HISTORY_DB: dbPath }, + const service = new ArtifactService({ + artifactsDir: dir, env: { ARTIFACTS_HISTORY_DB: dbPath }, herdr: { bin: "herdr", run(args) { calls.push(args); return { status: 0, stdout: "{}", stderr: "" }; } }, }); const call = async (name: string, args: Record) => { @@ -128,14 +128,14 @@ test("MCP history tools read archived source, reopen a managed pane, and restore const output = result.content[0]!.text; return JSON.parse(output.slice(output.indexOf("{"))); }; - expect((await call("canvas_history", {})).versions[0].version_id).toBe(version.id); - expect((await call("canvas_version", { version_id: version.id })).source).toBe(VALID_CANVAS); - expect((await call("canvas_open", { version_id: version.id, event_id: eventId, target: "herdr" })).ok).toBe(true); - expect(calls[0]).toContain(`HERDR_CANVAS_VERSION=${version.id}`); - expect(calls[0]).toContain(`HERDR_CANVAS_EVENT=${eventId}`); - expect(calls[0]).toContain(`HERDR_CANVAS_HISTORY_DB=${dbPath}`); - expect((await call("canvas_restore", { version_id: version.id })).revision).toBe(2); - expect(readFileSync(path, "utf8")).toBe(VALID_CANVAS); - const ambiguous = await handleMcpRequest({ jsonrpc: "2.0", id: 2, method: "tools/call", params: { name: "canvas_open", arguments: { name: "overview", version_id: version.id } } }, service); + expect((await call("artifact_history", {})).versions[0].version_id).toBe(version.id); + expect((await call("artifact_version", { version_id: version.id })).source).toBe(VALID_ARTIFACT); + expect((await call("artifact_open", { version_id: version.id, event_id: eventId, target: "herdr" })).ok).toBe(true); + expect(calls[0]).toContain(`ARTIFACTS_VERSION=${version.id}`); + expect(calls[0]).toContain(`ARTIFACTS_EVENT=${eventId}`); + expect(calls[0]).toContain(`ARTIFACTS_HISTORY_DB=${dbPath}`); + expect((await call("artifact_restore", { version_id: version.id })).revision).toBe(2); + expect(readFileSync(path, "utf8")).toBe(VALID_ARTIFACT); + const ambiguous = await handleMcpRequest({ jsonrpc: "2.0", id: 2, method: "tools/call", params: { name: "artifact_open", arguments: { name: "overview", version_id: version.id } } }, service); expect(ambiguous?.error?.message).toContain("but not both"); }, { timeout: 30_000 }); diff --git a/src/mcp/local-tools.ts b/src/mcp/local-tools.ts index fda26fb..713b724 100644 --- a/src/mcp/local-tools.ts +++ b/src/mcp/local-tools.ts @@ -1,8 +1,8 @@ -import { CanvasService, type CanvasEdit, type ReadOptions } from "../service"; -import { canvasIdFromFile } from "../canvasFile"; -import { canvasGuideResult } from "./guide"; +import { ArtifactService, type ArtifactEdit, type ReadOptions } from "../service"; +import { artifactIdFromFile } from "../artifactFile"; +import { artifactGuideResult } from "./guide"; import type { JsonRpcRequest, JsonRpcResponse } from "./protocol"; -import { CANVAS_APP_URI, CANVAS_RESOURCE, canvasAppHtml, canvasAppResult } from "./app"; +import { ARTIFACTS_APP_URI, ARTIFACTS_RESOURCE, artifactAppHtml, artifactAppResult } from "./app"; const PROTOCOL_VERSION = "2025-06-18"; @@ -11,7 +11,7 @@ import { MCP_TOOLS } from "./tools"; export async function handleMcpRequest( request: JsonRpcRequest, - service: CanvasService, + service: ArtifactService, ): Promise { if (request.id === undefined) { return undefined; @@ -22,7 +22,7 @@ export async function handleMcpRequest( return ok(request.id, { protocolVersion: PROTOCOL_VERSION, capabilities: { tools: {}, resources: {} }, - serverInfo: { name: "canvas", version: "0.1.0" }, + serverInfo: { name: "artifacts", version: "0.1.0" }, }); } if (request.method === "ping") { @@ -31,12 +31,12 @@ export async function handleMcpRequest( if (request.method === "tools/list") { return ok(request.id, { tools: MCP_TOOLS }); } - if (request.method === "resources/list") return ok(request.id, { resources: [CANVAS_RESOURCE] }); + if (request.method === "resources/list") return ok(request.id, { resources: [ARTIFACTS_RESOURCE] }); if (request.method === "resources/templates/list") return ok(request.id, { resourceTemplates: [] }); if (request.method === "resources/read") { const params = request.params as { uri?: string } | undefined; - if (params?.uri !== CANVAS_APP_URI) return error(request.id, -32002, "unknown resource"); - return ok(request.id, { contents: [{ ...CANVAS_RESOURCE, text: await canvasAppHtml() }] }); + if (params?.uri !== ARTIFACTS_APP_URI) return error(request.id, -32002, "unknown resource"); + return ok(request.id, { contents: [{ ...ARTIFACTS_RESOURCE, text: await artifactAppHtml() }] }); } if (request.method === "tools/call") { const params = (request.params ?? {}) as { name?: string; arguments?: Record }; @@ -50,33 +50,33 @@ export async function handleMcpRequest( } async function callTool( - service: CanvasService, + service: ArtifactService, name: string, args: Record, ): Promise<{ content: Array<{ type: "text"; text: string }>; isError?: boolean; structuredContent?: Record; _meta?: Record }> { - if (name === "canvas_guide") return canvasGuideResult(); - if (name === "canvas_read" || name === "canvas_edit") { - if (typeof args.name !== "string") throw new Error("canvas name must be a string"); - if (name === "canvas_read") { + if (name === "artifact_guide") return artifactGuideResult(); + if (name === "artifact_read" || name === "artifact_edit") { + if (typeof args.name !== "string") throw new Error("artifact name must be a string"); + if (name === "artifact_read") { return text(JSON.stringify(service.readRange(args.name, { file: args.file, start_line: args.start_line, end_line: args.end_line } as ReadOptions))); } - const result = service.edit(args.name, args.edits as CanvasEdit[], args.expected_hash as string | undefined, args.file as string | undefined); + const result = service.edit(args.name, args.edits as ArtifactEdit[], args.expected_hash as string | undefined, args.file as string | undefined); return withPreview(service, result, { name: args.name }); } - if (name === "canvas_history") return text(JSON.stringify({ versions: service.history(args.name === undefined ? undefined : String(args.name)) }, null, 2)); - if (name === "canvas_version") return text(JSON.stringify(service.version(String(args.version_id)), null, 2)); - if (name === "canvas_remix") { - const result = service.remix(args as Parameters[0]); + if (name === "artifact_history") return text(JSON.stringify({ versions: service.history(args.name === undefined ? undefined : String(args.name)) }, null, 2)); + if (name === "artifact_version") return text(JSON.stringify(service.version(String(args.version_id)), null, 2)); + if (name === "artifact_remix") { + const result = service.remix(args as Parameters[0]); return withPreview(service, result, { name: result.name }); } - if (name === "canvas_restore") { + if (name === "artifact_restore") { const result = service.restore(String(args.version_id)); - return withPreview(service, result, { name: canvasIdFromFile(result.path) }); + return withPreview(service, result, { name: artifactIdFromFile(result.path) }); } - if (name === "canvas_list") { - return text(JSON.stringify({ canvases: service.list() }, null, 2)); + if (name === "artifact_list") { + return text(JSON.stringify({ artifacts: service.list() }, null, 2)); } - if (name === "canvas_write") { + if (name === "artifact_write") { if (args.target !== undefined && args.target !== "inline" && args.target !== "herdr") throw new Error("target must be inline or herdr"); const written = args.project === undefined ? service.write(String(args.name), String(args.contents ?? "")) : await service.writeProject(String(args.name), String(args.contents ?? ""), args.project); let opened: unknown; @@ -86,22 +86,22 @@ async function callTool( const payload = { ...written, opened }; return withPreview(service, payload, { name: String(args.name) }); } - if (name === "canvas_typecheck") { + if (name === "artifact_typecheck") { const result = service.typecheck(String(args.name)); return text(`${result.check}\n\n${JSON.stringify(result, null, 2)}`, result.diagnostics.length > 0); } - if (name === "canvas_compile") { + if (name === "artifact_compile") { const result = await service.compile(String(args.name)); return text( `${result.check}\n\n${JSON.stringify({ ok: result.ok, path: result.path, bytes: result.js?.length ?? 0 }, null, 2)}`, !result.ok, ); } - if (name === "canvas_open") { + if (name === "artifact_open") { if (Boolean(args.name) === Boolean(args.version_id)) throw new Error("provide name or version_id, but not both"); if (args.target !== undefined && args.target !== "inline" && args.target !== "herdr") throw new Error("target must be inline or herdr"); if (args.target !== "herdr") { - const result = await canvasAppResult(service, args as { name?: string; version_id?: string; event_id?: string }); + const result = await artifactAppResult(service, args as { name?: string; version_id?: string; event_id?: string }); return previewResult(result); } const result = await service.open(args.name === undefined ? "" : String(args.name), { @@ -114,16 +114,16 @@ async function callTool( throw new Error(`unknown tool: ${name}`); } -function previewResult(result: Awaited>) { +function previewResult(result: Awaited>) { const { _meta, ...payload } = result; return { ...text(`${result.check}\n\n${JSON.stringify(payload, null, 2)}`, !result.ok), structuredContent: payload, ...(_meta ? { _meta } : {}) }; } -async function withPreview(service: CanvasService, result: { ok: boolean; check: string; [key: string]: unknown }, selection: { name: string }) { +async function withPreview(service: ArtifactService, result: { ok: boolean; check: string; [key: string]: unknown }, selection: { name: string }) { if (!result.ok) return { ...text(JSON.stringify(result, null, 2), true), structuredContent: result }; // Write/edit/restore have already applied. Preview failure must not imply rollback. try { - const preview = await canvasAppResult(service, selection); + const preview = await artifactAppResult(service, selection); const { _meta, ...details } = preview; const payload = { ...result, preview: details }; return { ...text(JSON.stringify(preview.ok ? result : payload, null, 2), !preview.ok), structuredContent: payload, ...(_meta ? { _meta } : {}) }; diff --git a/src/mcp/stdio.ts b/src/mcp/stdio.ts index 4234cf7..22d151e 100644 --- a/src/mcp/stdio.ts +++ b/src/mcp/stdio.ts @@ -1,8 +1,8 @@ import { encodeMessage, parseMessages, type JsonRpcRequest } from "./protocol"; import { handleMcpRequest } from "./local-tools"; -import { CanvasService } from "../service"; +import { ArtifactService } from "../service"; -export async function runMcpServer(service: CanvasService): Promise { +export async function runMcpServer(service: ArtifactService): Promise { let buffer: Buffer = Buffer.alloc(0); const stdin = Bun.stdin.stream(); const reader = stdin.getReader(); diff --git a/src/mcp/tools.ts b/src/mcp/tools.ts index d0b8272..420011e 100644 --- a/src/mcp/tools.ts +++ b/src/mcp/tools.ts @@ -1,26 +1,26 @@ -import { CANVAS_APP_META } from "./app-contract"; +import { ARTIFACTS_APP_META } from "./app-contract"; export const MCP_TOOLS = [ { - name: "canvas_guide", - description: "Read the Sidequery Canvas SDK contract: exports, host APIs, component conventions, restrictions, and validation behavior. Call before creating a canvas if you have not read it in this conversation.", + name: "artifact_guide", + description: "Read the Sidequery Artifacts SDK contract: exports, host APIs, component conventions, restrictions, and validation behavior. Call before creating an artifact if you have not read it in this conversation.", annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, inputSchema: { type: "object", properties: {}, additionalProperties: false }, }, { - name: "canvas_read", + name: "artifact_read", description: "Read working raw TSX by inclusive 1-based line range (default 200 lines). Returns exact source with line endings, total_lines, next_line and a full-file source_hash for guarded edits. Rejects symlinks and names with slashes.", inputSchema: { type: "object", properties: { name: { type: "string" }, file: { type: "string", description: "Optional existing project helper path." }, start_line: { type: "integer", minimum: 1 }, end_line: { type: "integer", minimum: 1 } }, required: ["name"], additionalProperties: false }, }, { - name: "canvas_edit", - _meta: CANVAS_APP_META, - description: "Apply sequential exact-text replacements to a working canvas. Every non-empty old_text must match exactly once; empty new_text deletes. Optional expected_hash guards against changes since canvas_read. Invalid batches write nothing. Atomically replaces source then typechecks once; failed diagnostics leave edits applied. Returns compact summary, hash and diagnostics, not source. Rejects symlinks and names with slashes.", + name: "artifact_edit", + _meta: ARTIFACTS_APP_META, + description: "Apply sequential exact-text replacements to a working artifact. Every non-empty old_text must match exactly once; empty new_text deletes. Optional expected_hash guards against changes since artifact_read. Invalid batches write nothing. Atomically replaces source then typechecks once; failed diagnostics leave edits applied. Returns compact summary, hash and diagnostics, not source. Rejects symlinks and names with slashes.", inputSchema: { type: "object", properties: { name: { type: "string" }, - file: { type: "string", description: "Optional existing project helper path. Omit to edit the canvas entrypoint." }, + file: { type: "string", description: "Optional existing project helper path. Omit to edit the artifact entrypoint." }, edits: { type: "array", minItems: 1, items: { type: "object", properties: { old_text: { type: "string", minLength: 1 }, new_text: { type: "string" } }, required: ["old_text", "new_text"], additionalProperties: false } }, expected_hash: { type: "string", pattern: "^[a-f0-9]{64}$" }, }, @@ -28,45 +28,45 @@ export const MCP_TOOLS = [ }, }, { - name: "canvas_history", - description: "List archived artifact versions in this workspace, including deleted canvases. Optionally filter by name.", + name: "artifact_history", + description: "List archived artifact versions in this workspace, including deleted artifacts. Optionally filter by name.", inputSchema: { type: "object", properties: { name: { type: "string" } }, additionalProperties: false }, }, { - name: "canvas_version", + name: "artifact_version", description: "Read an archived version's raw TSX, metadata, and serve events with initial-state snapshots.", inputSchema: { type: "object", properties: { version_id: { type: "string" } }, required: ["version_id"], additionalProperties: false }, }, { - name: "canvas_remix", - _meta: CANVAS_APP_META, - description: "Copy a working canvas or archived version to a new canvas with source provenance and fresh state. Never overwrites existing canvases or archived identities. Supply exactly one of name and version_id, plus new_name.", + name: "artifact_remix", + _meta: ARTIFACTS_APP_META, + description: "Copy a working artifact or archived version to a new artifact with source provenance and fresh state. Never overwrites existing artifacts or archived identities. Supply exactly one of name and version_id, plus new_name.", inputSchema: { type: "object", properties: { name: { type: "string" }, version_id: { type: "string" }, new_name: { type: "string" } }, required: ["new_name"], oneOf: [{ required: ["name"] }, { required: ["version_id"] }], additionalProperties: false }, }, { - name: "canvas_restore", - _meta: CANVAS_APP_META, - description: "Replace a working canvas with archived TSX. Saves the current working source first, adds a new revision, and preserves later history and current UI state. Returns typecheck diagnostics after restoration.", + name: "artifact_restore", + _meta: ARTIFACTS_APP_META, + description: "Replace a working artifact with archived TSX. Saves the current working source first, adds a new revision, and preserves later history and current UI state. Returns typecheck diagnostics after restoration.", inputSchema: { type: "object", properties: { version_id: { type: "string" } }, required: ["version_id"], additionalProperties: false }, }, { - name: "canvas_list", - description: "List .canvas.tsx files in the canvases directory.", + name: "artifact_list", + description: "List .artifact.tsx files in the artifacts directory.", inputSchema: { type: "object", properties: {}, additionalProperties: false }, }, { - name: "canvas_write", - _meta: CANVAS_APP_META, + name: "artifact_write", + _meta: ARTIFACTS_APP_META, description: - "Create or replace a canvas and display it inline in chat after typechecking. Before creating a canvas, call canvas_guide if you have not read it in this conversation. Import from sidequery/canvas. Pass kebab-case name without slashes. Returns Canvas TypeScript check diagnostics.", + "Create or replace an artifact and display it inline in chat after typechecking. Before creating an artifact, call artifact_guide if you have not read it in this conversation. Import from sidequery/artifacts. Pass kebab-case name without slashes. Returns Artifact TypeScript check diagnostics.", inputSchema: { type: "object", properties: { - name: { type: "string", description: "Canvas name, e.g. billing-review or billing-review.canvas.tsx" }, + name: { type: "string", description: "Artifact name, e.g. billing-review or billing-review.artifact.tsx" }, project: { type: "object", properties: { files: { type: "object", additionalProperties: { type: "string" } }, dependencies: { type: "object", additionalProperties: { type: "string" } } }, additionalProperties: false, description: "Helper modules by relative path and bun packages pinned to exact versions. Omit to preserve existing project. Resolved package snapshot is retained for replay." }, - contents: { type: "string", description: "Full .canvas.tsx source" }, - open: { type: "boolean", description: "Legacy flag; successful writes now always show the canvas inline." }, + contents: { type: "string", description: "Full .artifact.tsx source" }, + open: { type: "boolean", description: "Legacy flag; successful writes now always show the artifact inline." }, target: { type: "string", enum: ["inline", "herdr"], default: "inline", description: "Use herdr only to explicitly open a terminal pane as well." }, }, required: ["name", "contents"], @@ -74,8 +74,8 @@ export const MCP_TOOLS = [ }, }, { - name: "canvas_typecheck", - description: "Typecheck and sandbox-scan a canvas file.", + name: "artifact_typecheck", + description: "Typecheck and sandbox-scan an artifact file.", inputSchema: { type: "object", properties: { name: { type: "string" } }, @@ -84,8 +84,8 @@ export const MCP_TOOLS = [ }, }, { - name: "canvas_compile", - description: "Sandbox-scan and bundle a canvas file with Bun.", + name: "artifact_compile", + description: "Sandbox-scan and bundle an artifact file with Bun.", inputSchema: { type: "object", properties: { name: { type: "string" } }, @@ -94,9 +94,9 @@ export const MCP_TOOLS = [ }, }, { - name: "canvas_open", - _meta: CANVAS_APP_META, - description: "Show the interactive canvas inline in chat. Supply name for a working canvas, or version_id for an archived canvas. Optional event_id selects archived initial state. Use target: herdr only to explicitly open a terminal pane instead.", + name: "artifact_open", + _meta: ARTIFACTS_APP_META, + description: "Show the interactive artifact inline in chat. Supply name for a working artifact, or version_id for an archived artifact. Optional event_id selects archived initial state. Use target: herdr only to explicitly open a terminal pane instead.", inputSchema: { type: "object", properties: { diff --git a/src/open.test.ts b/src/open.test.ts index e16bfed..0e91743 100644 --- a/src/open.test.ts +++ b/src/open.test.ts @@ -1,25 +1,25 @@ import { expect, test } from "bun:test"; -import { openCanvas } from "./open"; -import { FORBIDDEN_IMPORT_CANVAS, VALID_CANVAS, tempDir, writeCanvas } from "./test/fixtures"; +import { openArtifact } from "./open"; +import { FORBIDDEN_IMPORT_ARTIFACT, VALID_ARTIFACT, tempDir, writeArtifact } from "./test/fixtures"; -test("openCanvas refuses a sandboxed-invalid canvas without contacting Herdr", async () => { +test("openArtifact refuses a sandboxed-invalid artifact without contacting Herdr", async () => { const dir = tempDir(); - const path = writeCanvas(dir, "bad", FORBIDDEN_IMPORT_CANVAS); - const result = await openCanvas(path, { canvasesDir: dir }); + const path = writeArtifact(dir, "bad", FORBIDDEN_IMPORT_ARTIFACT); + const result = await openArtifact(path, { artifactsDir: dir }); expect(result.ok).toBe(false); expect(result.opened).toBe("none"); - expect(result.check).toContain("Canvas TypeScript check:"); + expect(result.check).toContain("Artifact TypeScript check:"); }); -test("openCanvas opens herdr.canvas without starting a detached server", async () => { +test("openArtifact opens herdr.artifacts without starting a detached server", async () => { const dir = tempDir(); - const path = writeCanvas(dir, "overview", VALID_CANVAS); + const path = writeArtifact(dir, "overview", VALID_ARTIFACT); const calls: string[][] = []; - const result = await openCanvas(path, { - canvasesDir: dir, + const result = await openArtifact(path, { + artifactsDir: dir, ensureServer: async () => { - throw new Error("normal Canvas opens must not start the detached server"); + throw new Error("normal Artifact opens must not start the detached server"); }, herdr: { bin: "herdr", @@ -27,7 +27,7 @@ test("openCanvas opens herdr.canvas without starting a detached server", async ( calls.push(args); return { status: 0, - stdout: JSON.stringify({ result: { plugin_pane: { plugin_id: "herdr.canvas" } } }), + stdout: JSON.stringify({ result: { plugin_pane: { plugin_id: "herdr.artifacts" } } }), stderr: "", }; }, @@ -38,12 +38,12 @@ test("openCanvas opens herdr.canvas without starting a detached server", async ( }, }); expect(result.ok).toBe(true); - expect(result.opened).toBe("canvas-pane"); + expect(result.opened).toBe("artifact-pane"); expect(result.url).toBeUndefined(); expect(calls).toHaveLength(1); - expect(calls[0]).toContain("herdr.canvas"); - expect(calls[0]).toContain("HERDR_CANVAS_PATH=" + path); - expect(calls[0]).toContain("HERDR_CANVAS_DIR=" + dir); + expect(calls[0]).toContain("herdr.artifacts"); + expect(calls[0]).toContain("ARTIFACTS_PATH=" + path); + expect(calls[0]).toContain("ARTIFACTS_DIR=" + dir); expect(calls[0]).toContain("--target-pane"); expect(calls[0]).toContain("w15:p1"); expect(calls[0]).not.toContain("--workspace"); diff --git a/src/open.ts b/src/open.ts index 1ea36ff..2463ee8 100644 --- a/src/open.ts +++ b/src/open.ts @@ -1,72 +1,72 @@ -import { canvasIdFromFile } from "./canvasFile"; -import { compileCanvas } from "./compile"; -import { ensureCanvasServer } from "./local/preview-daemon"; -import { formatCanvasCheck } from "./diagnostics"; +import { artifactIdFromFile } from "./artifactFile"; +import { compileArtifact } from "./compile"; +import { ensureArtifactServer } from "./local/preview-daemon"; +import { formatArtifactCheck } from "./diagnostics"; import { - canvasPaneOpenArgs, + artifactPaneOpenArgs, createHerdrClient, type CommandResult, type HerdrClient, type PanePlacement, } from "./herdr"; -import { typecheckCanvas } from "./typecheck"; -import { CanvasHistory, historyPath } from "./history"; +import { typecheckArtifact } from "./typecheck"; +import { ArtifactHistory, historyPath } from "./history"; export type OpenResult = { ok: boolean; url?: string; - canvasId?: string; + artifactId?: string; reusedServer?: boolean; - opened?: "canvas-pane" | "none"; + opened?: "artifact-pane" | "none"; check: string; - diagnostics: ReturnType; + diagnostics: ReturnType; herdr?: CommandResult; }; -export type OpenCanvasOptions = { - canvasesDir: string; +export type OpenArtifactOptions = { + artifactsDir: string; herdr?: HerdrClient; placement?: PanePlacement; direction?: "right" | "down"; focus?: boolean; env?: NodeJS.ProcessEnv; - ensureServer?: typeof ensureCanvasServer; - compile?: typeof compileCanvas; + ensureServer?: typeof ensureArtifactServer; + compile?: typeof compileArtifact; skipOpen?: boolean; inProcessServer?: boolean; versionId?: string; eventId?: string; }; -export async function openCanvas(canvasPath: string, opts: OpenCanvasOptions): Promise { +export async function openArtifact(artifactPath: string, opts: OpenArtifactOptions): Promise { let snapshot: string | undefined; if (opts.versionId) { - const archive = new CanvasHistory(historyPath(opts.env)); + const archive = new ArtifactHistory(historyPath(opts.env)); try { - const version = archive.version(opts.versionId, opts.canvasesDir); + const version = archive.version(opts.versionId, opts.artifactsDir); if (!version) throw new Error("version not found in this workspace"); if (opts.eventId && !archive.events(version.id).some(event => event.id === opts.eventId)) throw new Error("serve event not found for version"); snapshot = version.source; } finally { archive.close(); } } - const diagnostics = snapshot === undefined ? typecheckCanvas(canvasPath) : []; - const check = snapshot === undefined ? formatCanvasCheck(diagnostics) : "Archived source: compiling with the installed SDK"; + const diagnostics = snapshot === undefined ? typecheckArtifact(artifactPath) : []; + const check = snapshot === undefined ? formatArtifactCheck(diagnostics) : "Archived source: compiling with the installed SDK"; if (diagnostics.length > 0) { return { ok: false, check, diagnostics, opened: "none" }; } - const compile = opts.compile ?? compileCanvas; - const compiled = await compile(canvasPath, snapshot); + const compile = opts.compile ?? compileArtifact; + const compiled = await compile(artifactPath, snapshot); if (!compiled.ok) { return { ok: false, - check: formatCanvasCheck(compiled.diagnostics), + check: formatArtifactCheck(compiled.diagnostics), diagnostics: compiled.diagnostics, opened: "none", }; } - const canvasId = canvasIdFromFile(canvasPath); + const artifactId = artifactIdFromFile(artifactPath); const paneContext = { workspaceId: opts.env?.HERDR_WORKSPACE_ID, targetPaneId: opts.env?.HERDR_PANE_ID, @@ -76,18 +76,18 @@ export async function openCanvas(canvasPath: string, opts: OpenCanvasOptions): P }; if (opts.skipOpen) { - const ensure = opts.ensureServer ?? ensureCanvasServer; + const ensure = opts.ensureServer ?? ensureArtifactServer; const server = await ensure({ - canvasesDir: opts.canvasesDir, + artifactsDir: opts.artifactsDir, env: opts.env, inProcess: opts.inProcessServer, }); - const route = opts.versionId ? `/v/${opts.versionId}${opts.eventId ? `?event=${encodeURIComponent(opts.eventId)}` : ""}` : `/c/${encodeURIComponent(canvasId)}`; + const route = opts.versionId ? `/v/${opts.versionId}${opts.eventId ? `?event=${encodeURIComponent(opts.eventId)}` : ""}` : `/c/${encodeURIComponent(artifactId)}`; const url = `${server.url.replace(/\/$/, "")}${route}`; return { ok: true, url, - canvasId, + artifactId, reusedServer: server.reused, opened: "none", check, @@ -97,7 +97,7 @@ export async function openCanvas(canvasPath: string, opts: OpenCanvasOptions): P const herdr = opts.herdr ?? createHerdrClient(opts.env); let pane = herdr.run( - canvasPaneOpenArgs(canvasPath, opts.canvasesDir, { + artifactPaneOpenArgs(artifactPath, opts.artifactsDir, { placement: opts.placement, direction: opts.direction, focus: opts.focus, @@ -106,7 +106,7 @@ export async function openCanvas(canvasPath: string, opts: OpenCanvasOptions): P ); if (pane.status !== 0 && `${pane.stdout}${pane.stderr}`.includes("no_active_pane")) { pane = herdr.run( - canvasPaneOpenArgs(canvasPath, opts.canvasesDir, { + artifactPaneOpenArgs(artifactPath, opts.artifactsDir, { placement: "tab", focus: opts.focus, ...paneContext, @@ -115,8 +115,8 @@ export async function openCanvas(canvasPath: string, opts: OpenCanvasOptions): P } return { ok: pane.status === 0, - canvasId, - opened: "canvas-pane", + artifactId, + opened: "artifact-pane", check, diagnostics: [], herdr: pane, diff --git a/src/plugins/browser.test.ts b/src/plugins/browser.test.ts index 960e05a..398a1e2 100644 --- a/src/plugins/browser.test.ts +++ b/src/plugins/browser.test.ts @@ -2,13 +2,13 @@ import { expect, test } from "bun:test"; import { cpSync, mkdirSync } from "node:fs"; import { join } from "node:path"; import { prepareBrowserPlugins } from "../../scripts/prepare-browser-plugins"; -import { compileCanvas } from "../compile"; -import { typecheckCanvas } from "../typecheck"; -import { tempDir, writeCanvas } from "../test/fixtures"; +import { compileArtifact } from "../compile"; +import { typecheckArtifact } from "../typecheck"; +import { tempDir, writeArtifact } from "../test/fixtures"; const source = `import { PluginCounter, type Label } from "@test/counter"; const label: Label = { prefix: "Plugin" }; -export default function Canvas() { return ; }`; +export default function Artifact() { return ; }`; const plugin = { name: "@test/counter", description: "Counter fixture", browser: "./src/test/plugins/browser.tsx", secrets: ["PRIVATE_TOKEN"], operations: { serverOnly: { description: "server only", inputSchema: {}, handler: () => "SERVER_ONLY_SENTINEL" } } }; const registry = await prepareBrowserPlugins([plugin], new URL("../..", import.meta.url).pathname); @@ -23,19 +23,19 @@ test("browser preparation captures transitive code and types without handlers or }); test("local checking uses prepared transitive declarations and rejects deep or uninstalled imports", async () => { - const path = writeCanvas(tempDir(), "plugin", source); - expect(typecheckCanvas(path, registry)).toEqual([]); + const path = writeArtifact(tempDir(), "plugin", source); + expect(typecheckArtifact(path, registry)).toEqual([]); const invalidSdkType = source.replace("type Label", "type Label, type Tone").replace("const label", 'const tone: Tone = "invalid-tone"; const label'); - expect(typecheckCanvas(writeCanvas(tempDir(), "invalid-sdk-type", invalidSdkType), registry).some(item => item.message.includes("invalid-tone"))).toBe(true); - const invalid = writeCanvas(tempDir(), "plugin", source.replace('prefix: "Plugin"', "prefix: 123")); - expect(typecheckCanvas(invalid, registry).some(item => item.message.includes("number"))).toBe(true); + expect(typecheckArtifact(writeArtifact(tempDir(), "invalid-sdk-type", invalidSdkType), registry).some(item => item.message.includes("invalid-tone"))).toBe(true); + const invalid = writeArtifact(tempDir(), "plugin", source.replace('prefix: "Plugin"', "prefix: 123")); + expect(typecheckArtifact(invalid, registry).some(item => item.message.includes("number"))).toBe(true); const hiddenImport = source.replace('from "@test/counter";', 'from "@test/counter"; import "jose";'); - expect((await compileCanvas(path, hiddenImport, registry)).ok).toBe(false); - expect((await compileCanvas(path, 'import{base64url}from"jose";export default()=>
', registry)).ok).toBe(false); + expect((await compileArtifact(path, hiddenImport, registry)).ok).toBe(false); + expect((await compileArtifact(path, 'import{base64url}from"jose";export default()=>
', registry)).ok).toBe(false); for (const specifier of ["@test/counter/deep", "jose", "uninstalled"]) { const denied = source.replace("@test/counter", specifier); - expect(typecheckCanvas(writeCanvas(tempDir(), "denied", denied), registry)[0]?.message).toContain("not allowed"); - expect((await compileCanvas(path, denied, registry)).ok).toBe(false); + expect(typecheckArtifact(writeArtifact(tempDir(), "denied", denied), registry)[0]?.message).toContain("not allowed"); + expect((await compileArtifact(path, denied, registry)).ok).toBe(false); } }, 30000); @@ -45,13 +45,13 @@ test("installed package entries and explicit JavaScript declarations keep their { name: "@test/encoding", description: "Installed library", browser: "jose" }, { name: "@test/untyped", description: "Explicit types", browser: "./src/test/plugins/untyped.js", types: "./src/test/plugins/untyped.d.ts" }, ], new URL("../..", import.meta.url).pathname); - const canvas = `import { base64url } from "@test/encoding"; + const artifact = `import { base64url } from "@test/encoding"; import { labelLength } from "@test/untyped"; -export default function Canvas() { return
{base64url.encode(String(labelLength({prefix: "ok"})))}
; }`; - expect(typecheckCanvas(writeCanvas(tempDir(), "installed", canvas), prepared)).toEqual([]); - expect(typecheckCanvas(writeCanvas(tempDir(), "invalid", canvas.replace('prefix: "ok"', 'prefix: false')), prepared) +export default function Artifact() { return
{base64url.encode(String(labelLength({prefix: "ok"})))}
; }`; + expect(typecheckArtifact(writeArtifact(tempDir(), "installed", artifact), prepared)).toEqual([]); + expect(typecheckArtifact(writeArtifact(tempDir(), "invalid", artifact.replace('prefix: "ok"', 'prefix: false')), prepared) .some(item => item.message.includes("boolean"))).toBe(true); - expect((await compileCanvas(writeCanvas(tempDir(), "installed", canvas), undefined, prepared)).ok).toBe(true); + expect((await compileArtifact(writeArtifact(tempDir(), "installed", artifact), undefined, prepared)).ok).toBe(true); }, 30000); @@ -67,6 +67,6 @@ test("package conditional exports select browser code and declarations from the expect(prepared.modules["@test/conditional"]).not.toContain("node:fs"); const source = `import { environment } from "@test/conditional"; const browser: "browser" = environment; -export default function Canvas() { return
{browser}
; }`; - expect(typecheckCanvas(writeCanvas(directory, "conditional", source), prepared)).toEqual([]); +export default function Artifact() { return
{browser}
; }`; + expect(typecheckArtifact(writeArtifact(directory, "conditional", source), prepared)).toEqual([]); }, 30000); diff --git a/src/plugins/config.ts b/src/plugins/config.ts index 5f94293..079f6d1 100644 --- a/src/plugins/config.ts +++ b/src/plugins/config.ts @@ -16,7 +16,7 @@ export type PluginOperation = { handler: (input: any, context: PluginContext) => unknown | Promise; }; -export type CanvasPlugin = { +export type ArtifactPlugin = { /** Also the public browser import, when a browser entry is supplied. */ name: string; description: string; @@ -30,6 +30,6 @@ export type CanvasPlugin = { }; /** Configuration is trusted operator code, evaluated only during build and on the server. */ -export function definePlugins(plugins: T): T { +export function definePlugins(plugins: T): T { return plugins; } diff --git a/src/remix.test.ts b/src/remix.test.ts index cf9bdd4..a8dc1a8 100644 --- a/src/remix.test.ts +++ b/src/remix.test.ts @@ -1,26 +1,26 @@ import { expect, test } from "bun:test"; import { existsSync, readFileSync, symlinkSync, unlinkSync, writeFileSync } from "node:fs"; import { join } from "node:path"; -import { CanvasService } from "./service"; -import { createCanvasServer } from "./serve"; +import { ArtifactService } from "./service"; +import { createArtifactServer } from "./serve"; import { handleMcpRequest } from "./mcp/local-tools"; -import { VALID_CANVAS, tempDir } from "./test/fixtures"; +import { VALID_ARTIFACT, tempDir } from "./test/fixtures"; function setup() { const dir = tempDir(); const db = join(dir, "history.sqlite"); - const service = new CanvasService({ cwd: dir, canvasesDir: dir, env: { HERDR_CANVAS_HISTORY_DB: db } }); - writeFileSync(join(dir, "source.canvas.tsx"), VALID_CANVAS); + const service = new ArtifactService({ cwd: dir, artifactsDir: dir, env: { ARTIFACTS_HISTORY_DB: db } }); + writeFileSync(join(dir, "source.artifact.tsx"), VALID_ARTIFACT); return { dir, db, service }; } test("remix copies working source with durable provenance and fresh state", () => { const { dir, service } = setup(); - writeFileSync(join(dir, "source.canvas.data.json"), '{"secret":"private"}'); + writeFileSync(join(dir, "source.artifact.data.json"), '{"secret":"private"}'); const result = service.remix({ name: "source", new_name: "copy" }); expect(result.ok).toBe(true); - expect(readFileSync(result.path, "utf8")).toBe(VALID_CANVAS); - expect(existsSync(join(dir, "copy.canvas.data.json"))).toBe(false); + expect(readFileSync(result.path, "utf8")).toBe(VALID_ARTIFACT); + expect(existsSync(join(dir, "copy.artifact.data.json"))).toBe(false); const version = service.version(result.versionId); expect(version.origin).toMatchObject({ source_name: "source", source_version_id: service.history("source")[0]!.version_id }); expect(version.events).toEqual([]); @@ -32,9 +32,9 @@ test("archived remix copies selected revision even after source is deleted", () const { dir, service } = setup(); service.remix({ name: "source", new_name: "first" }); const versionId = service.history("source")[0]!.version_id; - unlinkSync(join(dir, "source.canvas.tsx")); + unlinkSync(join(dir, "source.artifact.tsx")); const result = service.remix({ version_id: versionId, new_name: "second" }); - expect(readFileSync(result.path, "utf8")).toBe(VALID_CANVAS); + expect(readFileSync(result.path, "utf8")).toBe(VALID_ARTIFACT); expect(service.version(result.versionId).origin).toMatchObject({ source_version_id: versionId }); expect(() => service.remix({ version_id: versionId, new_name: "source" })).toThrow("history"); }); @@ -47,11 +47,11 @@ test("remix rejects collisions, links, orphan state, traversal and ambiguous sou expect(() => service.remix({ name: "../source", new_name: "copy" })).toThrow("slashes"); expect(() => service.remix({ name: "source", new_name: "../copy" })).toThrow("slashes"); expect(() => service.remix({ name: "source", new_name: "source" })).toThrow("exists"); - symlinkSync(join(dir, "missing"), join(dir, "copy.canvas.tsx")); + symlinkSync(join(dir, "missing"), join(dir, "copy.artifact.tsx")); expect(() => service.remix({ name: "source", new_name: "copy" })).toThrow("exists"); - symlinkSync(join(dir, "source.canvas.tsx"), join(dir, "link.canvas.tsx")); + symlinkSync(join(dir, "source.artifact.tsx"), join(dir, "link.artifact.tsx")); expect(() => service.remix({ name: "link", new_name: "new" })).toThrow("symlink"); - writeFileSync(join(dir, "orphan.canvas.data.json"), '{}'); + writeFileSync(join(dir, "orphan.artifact.data.json"), '{}'); expect(() => service.remix({ name: "source", new_name: "orphan" })).toThrow("exists"); expect(service.history()).toEqual([]); }); @@ -66,20 +66,20 @@ test("CLI remixes working and archived sources; local MCP validates the same con expect({ code: await child.exited, err }).toEqual({ code: 0, err: "" }); expect(JSON.parse(out).remixed).toBe(true); } - const response = await handleMcpRequest({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "canvas_remix", arguments: { name: "source", version_id: "bad", new_name: "mcp-copy" } } }, service); + const response = await handleMcpRequest({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "artifact_remix", arguments: { name: "source", version_id: "bad", new_name: "mcp-copy" } } }, service); expect(response?.error?.message).toContain("provide name or version_id"); }, 120_000); test("gallery remix requires same-origin JSON and creates a distinct artifact", async () => { const { dir, db } = setup(); - const server = await createCanvasServer({ canvasesDir: dir, historyPath: db, gallery: true }); + const server = await createArtifactServer({ artifactsDir: dir, historyPath: db, gallery: true }); try { - const body = JSON.stringify({ name: "canvas_remix", arguments: { name: "source", new_name: "web-copy" } }); + const body = JSON.stringify({ name: "artifact_remix", arguments: { name: "source", new_name: "web-copy" } }); const rejected = await fetch(server.url + "/api/tools", { method: "POST", headers: { "content-type": "application/json", origin: "https://untrusted.example" }, body }); expect(rejected.status).toBe(403); const rebound = await fetch(server.url + "/api/tools", { method: "POST", headers: { "content-type": "application/json", host: "untrusted.example", origin: "http://untrusted.example" }, body }); expect(rebound.status).toBe(403); - expect(existsSync(join(dir, "web-copy.canvas.tsx"))).toBe(false); + expect(existsSync(join(dir, "web-copy.artifact.tsx"))).toBe(false); const accepted = await fetch(server.url + "/api/tools", { method: "POST", headers: { "content-type": "application/json", origin: server.url }, body }); expect(accepted.status).toBe(200); expect(await accepted.json()).toMatchObject({ ok: true, remixed: true, name: "web-copy" }); diff --git a/src/runtime/artifacts-entry.d.ts b/src/runtime/artifacts-entry.d.ts new file mode 100644 index 0000000..a48f537 --- /dev/null +++ b/src/runtime/artifacts-entry.d.ts @@ -0,0 +1,5 @@ +declare module "artifacts-entry" { + import type { ComponentType } from "react"; + const Artifact: ComponentType; + export default Artifact; +} diff --git a/src/runtime/cloud-runtime.ts b/src/runtime/cloud-runtime.ts index f3f5e44..a64bb8f 100644 --- a/src/runtime/cloud-runtime.ts +++ b/src/runtime/cloud-runtime.ts @@ -6,7 +6,7 @@ import * as reactDomClient from "react-dom/client"; import * as jsxDev from "react/jsx-dev-runtime"; import type { ComponentType } from "react"; import * as jsx from "react/jsx-runtime"; -import { CanvasRouter } from "../sdk/routing"; +import { ArtifactRouter } from "../sdk/routing"; import * as sdk from "../sdk"; const runtime = { @@ -21,11 +21,11 @@ const runtime = { const element = document.getElementById("root"); if (!element) throw new Error("missing #root"); const root = createRoot(element); - (window as Window & { __herdrCanvasUnmount?: () => void }).__herdrCanvasUnmount = () => root.unmount(); - root.render(jsx.jsx(CanvasRouter, { children: jsx.jsx(Component, {}) })); + (window as Window & { __artifactsUnmount?: () => void }).__artifactsUnmount = () => root.unmount(); + root.render(jsx.jsx(ArtifactRouter, { children: jsx.jsx(Component, {}) })); }, }; -// This runs only inside the canvas browser frame. A deployment prebuilds this -// fixed SDK/React runtime; request-time compilation only handles canvas source. -(globalThis as typeof globalThis & { __herdrCanvasRuntime: typeof runtime }).__herdrCanvasRuntime = runtime; +// This runs only inside the artifact browser frame. A deployment prebuilds this +// fixed SDK/React runtime; request-time compilation only handles artifact source. +(globalThis as typeof globalThis & { __artifactsRuntime: typeof runtime }).__artifactsRuntime = runtime; diff --git a/src/runtime/gallery-request.ts b/src/runtime/gallery-request.ts index b691fd0..2126172 100644 --- a/src/runtime/gallery-request.ts +++ b/src/runtime/gallery-request.ts @@ -1,11 +1,11 @@ import type { HostBridge } from "../sdk/hooks"; -import type { CanvasHttpRequest, CanvasHttpResponse } from "../sdk/server"; +import type { ArtifactHttpRequest, ArtifactHttpResponse } from "../sdk/server"; -const bridge = (window as Window & { __herdrCanvas?: HostBridge & { serverVersionId?: string; filesVersionId?: string; plugins?: boolean } }).__herdrCanvas; +const bridge = (window as Window & { __artifacts?: HostBridge & { serverVersionId?: string; filesVersionId?: string; plugins?: boolean } }).__artifacts; if (bridge?.serverVersionId && window.parent !== window) { - const pending = new Map void; reject: (error: Error) => void; timeout: ReturnType }>(); + const pending = new Map void; reject: (error: Error) => void; timeout: ReturnType }>(); window.addEventListener("message", event => { - if (event.source !== window.parent || event.data?.type !== "canvas/http-response") return; + if (event.source !== window.parent || event.data?.type !== "artifact/http-response") return; const item = pending.get(event.data.id); if (!item) return; clearTimeout(item.timeout); @@ -13,22 +13,22 @@ if (bridge?.serverVersionId && window.parent !== window) { if (event.data.error) item.reject(new Error(String(event.data.error))); else item.resolve(event.data.response); }); - bridge.onRequest = (request: CanvasHttpRequest) => new Promise((resolve, reject) => { - if (pending.size >= 16) return reject(new Error("Too many pending canvas requests")); + bridge.onRequest = (request: ArtifactHttpRequest) => new Promise((resolve, reject) => { + if (pending.size >= 16) return reject(new Error("Too many pending artifact requests")); const id = crypto.randomUUID(); const timeout = setTimeout(() => { pending.delete(id); - reject(new Error("Canvas server request timed out")); + reject(new Error("Artifact server request timed out")); }, 30000); pending.set(id, { resolve, reject, timeout }); - window.parent.postMessage({ type: "canvas/http-request", id, versionId: bridge.serverVersionId, request }, "*"); + window.parent.postMessage({ type: "artifact/http-request", id, versionId: bridge.serverVersionId, request }, "*"); }); } if (bridge?.plugins && window.parent !== window) { const pending = new Map void; reject: (error: Error) => void; timeout: ReturnType }>(); window.addEventListener("message", event => { - if (event.source !== window.parent || event.data?.type !== "canvas/plugin-response") return; + if (event.source !== window.parent || event.data?.type !== "artifact/plugin-response") return; const item = pending.get(event.data.id); if (!item) return; clearTimeout(item.timeout); @@ -41,7 +41,7 @@ if (bridge?.plugins && window.parent !== window) { const id = crypto.randomUUID(); const timeout = setTimeout(() => { pending.delete(id); reject(new Error("Plugin call timed out")); }, 30000); pending.set(id, { resolve, reject, timeout }); - window.parent.postMessage({ type: "canvas/plugin-request", id, request }, "*"); + window.parent.postMessage({ type: "artifact/plugin-request", id, request }, "*"); }); } @@ -57,12 +57,12 @@ if (bridge?.filesVersionId && window.parent !== window) { else item.resolve(event.data.result); }); const send = (type: string, responseType: string, value: Record) => new Promise((resolve, reject) => { - if (pending.size >= 16) return reject(new Error("Too many pending canvas file requests")); + if (pending.size >= 16) return reject(new Error("Too many pending artifact file requests")); const id = crypto.randomUUID(); - const timeout = setTimeout(() => { pending.delete(id); reject(new Error("Canvas file request timed out")); }, 30000); + const timeout = setTimeout(() => { pending.delete(id); reject(new Error("Artifact file request timed out")); }, 30000); pending.set(id, { responseType, resolve, reject, timeout }); window.parent.postMessage({ type, id, versionId: bridge.filesVersionId, ...value }, "*"); }); - bridge.onFileRequest = request => send("canvas/files-request", "canvas/files-response", { request }); - bridge.onFileDownload = async url => { await send("canvas/file-download", "canvas/file-download-response", { url }); }; + bridge.onFileRequest = request => send("artifact/files-request", "artifact/files-response", { request }); + bridge.onFileDownload = async url => { await send("artifact/file-download", "artifact/file-download-response", { url }); }; } diff --git a/src/runtime/herdr-canvas-entry.d.ts b/src/runtime/herdr-canvas-entry.d.ts deleted file mode 100644 index 1762473..0000000 --- a/src/runtime/herdr-canvas-entry.d.ts +++ /dev/null @@ -1,5 +0,0 @@ -declare module "herdr-canvas-entry" { - import type { ComponentType } from "react"; - const Canvas: ComponentType; - export default Canvas; -} diff --git a/src/runtime/mcp-app.ts b/src/runtime/mcp-app.ts index 5e3bfbc..e941763 100644 --- a/src/runtime/mcp-app.ts +++ b/src/runtime/mcp-app.ts @@ -1,23 +1,23 @@ import { App, type McpUiHostContext } from "@modelcontextprotocol/ext-apps"; -import type { CanvasAppPayload } from "../mcp/app"; -import type { CanvasAction, HostBridge } from "../sdk/hooks"; -import type { CanvasHttpRequest, CanvasHttpResponse } from "../sdk/server"; -import { canvasFileTransferUrl } from "../sdk/files"; +import type { ArtifactAppPayload } from "../mcp/app"; +import type { ArtifactAction, HostBridge } from "../sdk/hooks"; +import type { ArtifactHttpRequest, ArtifactHttpResponse } from "../sdk/server"; +import { artifactFileTransferUrl } from "../sdk/files"; // Measure intrinsic content rather than the iframe's document height, so views // can grow and shrink even when the host clamps or ignores a resize request. -const app = new App({ name: "Canvas", version: "0.1.0" }, {}, { autoResize: false }); -const hostWindow = window as Window & { __herdrCanvas?: HostBridge; __herdrCanvasUnmount?: () => void }; +const app = new App({ name: "Artifacts", version: "0.1.0" }, {}, { autoResize: false }); +const hostWindow = window as Window & { __artifacts?: HostBridge; __artifactsUnmount?: () => void; __herdrCanvas?: HostBridge; __herdrCanvasUnmount?: () => void }; const status = document.getElementById("status")!; -const shell = document.getElementById("canvas-shell")!; -const viewport = document.getElementById("canvas-viewport")!; +const shell = document.getElementById("artifact-shell")!; +const viewport = document.getElementById("artifact-viewport")!; const root = document.getElementById("root")!; -const toolbar = document.getElementById("canvas-toolbar")!; +const toolbar = document.getElementById("artifact-toolbar")!; const displayButton = document.getElementById("display-mode") as HTMLButtonElement; let script: HTMLScriptElement | undefined; let context: McpUiHostContext = {}; let connected = false; -let hasCanvas = false; +let hasArtifact = false; let changingMode = false; let sizeFrame = 0; let lastHeight: number | undefined; @@ -28,7 +28,7 @@ function reportSize() { if (sizeFrame) return; sizeFrame = requestAnimationFrame(() => { sizeFrame = 0; - // Fullscreen/PiP dimensions belong to the host, not the canvas content. + // Fullscreen/PiP dimensions belong to the host, not the artifact content. if (!connected || (context.displayMode && context.displayMode !== "inline")) return; const contentHeight = Math.max(root.getBoundingClientRect().height, root.scrollHeight) + status.getBoundingClientRect().height @@ -48,9 +48,9 @@ function updateDisplay() { const mode = context.displayMode ?? "inline"; document.documentElement.dataset.displayMode = mode; const target = mode === "fullscreen" ? "inline" : "fullscreen"; - toolbar.hidden = !(hasCanvas || mode === "fullscreen") || !context.availableDisplayModes?.includes(target); - displayButton.title = mode === "fullscreen" ? "Exit fullscreen" : "Expand canvas"; - displayButton.setAttribute("aria-label", mode === "fullscreen" ? "Exit fullscreen" : "Expand canvas"); + toolbar.hidden = !(hasArtifact || mode === "fullscreen") || !context.availableDisplayModes?.includes(target); + displayButton.title = mode === "fullscreen" ? "Exit fullscreen" : "Expand artifact"; + displayButton.setAttribute("aria-label", mode === "fullscreen" ? "Exit fullscreen" : "Expand artifact"); displayButton.disabled = changingMode; const dimensions = context.containerDimensions; const fixed = dimensions && "height" in dimensions ? dimensions.height : undefined; @@ -59,8 +59,8 @@ function updateDisplay() { const limit = isSize(fixed) ? fixed : isSize(maximum) ? Math.min(600, maximum) : 600; inlineLimit = limit; fixedHeight = isSize(fixed) ? fixed : undefined; - shell.style.setProperty("--canvas-inline-limit", `${limit}px`); - shell.style.setProperty("--canvas-fixed-height", isSize(fixed) ? `${fixed}px` : "auto"); + shell.style.setProperty("--artifact-inline-limit", `${limit}px`); + shell.style.setProperty("--artifact-fixed-height", isSize(fixed) ? `${fixed}px` : "auto"); reportSize(); } @@ -92,100 +92,104 @@ displayButton.addEventListener("click", async () => { function theme(kind?: string) { const value = kind === "light" ? "light" : "dark"; - document.documentElement.style.setProperty("--canvas-background", value === "light" ? "#ffffff" : "#181818"); - document.documentElement.style.setProperty("--canvas-foreground", value === "light" ? "#181818" : "#f0f0f0"); - if (hostWindow.__herdrCanvas) hostWindow.__herdrCanvas.theme = { kind: value }; + document.documentElement.style.setProperty("--artifact-background", value === "light" ? "#ffffff" : "#181818"); + document.documentElement.style.setProperty("--artifact-foreground", value === "light" ? "#181818" : "#f0f0f0"); + if (hostWindow.__artifacts) hostWindow.__artifacts.theme = { kind: value }; + window.dispatchEvent(new Event("artifact-theme-change")); window.dispatchEvent(new Event("canvas-theme-change")); } -async function action(value: CanvasAction) { +async function action(value: ArtifactAction) { try { let result: { isError?: boolean }; if (value.type === "openUrl") { - if (!/^https?:\/\//i.test(value.url)) throw new Error("Only HTTP and HTTPS links can be opened from this canvas."); + if (!/^https?:\/\//i.test(value.url)) throw new Error("Only HTTP and HTTPS links can be opened from this artifact."); result = await app.openLink({ url: value.url }); } else if (value.type === "promptAgent") { result = await app.sendMessage({ role: "user", content: [{ type: "text", text: value.prompt }] }); } else { throw new Error(`Opening local files is unavailable in this chat: ${value.path}`); } - if (result.isError) throw new Error("The chat host declined this canvas action."); + if (result.isError) throw new Error("The chat host declined this artifact action."); status.textContent = ""; } catch (error) { status.textContent = error instanceof Error ? error.message : String(error); } } -async function serverRequest(canvas: CanvasAppPayload, request: CanvasHttpRequest): Promise { +async function serverRequest(artifact: ArtifactAppPayload, request: ArtifactHttpRequest): Promise { const result = await app.callServerTool({ - name: "canvas_request", - arguments: { version_id: canvas.versionId, request }, + name: "artifact_request", + arguments: { version_id: artifact.versionId, request }, }); if (result.isError) { const message = result.content?.filter(item => item.type === "text").map(item => item.text).join("\n"); - throw new Error(message || "Canvas server request failed."); + throw new Error(message || "Artifact server request failed."); } - const structured = result.structuredContent as { response?: CanvasHttpResponse } | undefined; - if (!structured?.response) throw new Error("Canvas server response was missing."); + const structured = result.structuredContent as { response?: ArtifactHttpResponse } | undefined; + if (!structured?.response) throw new Error("Artifact server response was missing."); return structured.response; } function clear() { - hostWindow.__herdrCanvasUnmount?.(); + for (const unmount of new Set([hostWindow.__artifactsUnmount, hostWindow.__herdrCanvasUnmount])) unmount?.(); + delete hostWindow.__artifactsUnmount; delete hostWindow.__herdrCanvasUnmount; + delete hostWindow.__artifacts; + delete hostWindow.__herdrCanvas; script?.remove(); root.replaceChildren(); viewport.scrollTo(0, 0); - hasCanvas = false; + hasArtifact = false; updateDisplay(); } app.ontoolresult = result => { clear(); if (result.isError) { - status.textContent = result.content?.filter(item => item.type === "text").map(item => item.text).join("\n") || "Canvas could not be rendered."; + status.textContent = result.content?.filter(item => item.type === "text").map(item => item.text).join("\n") || "Artifact could not be rendered."; return; } - const canvas = result._meta?.canvas as CanvasAppPayload | undefined; - if (!canvas || typeof canvas.js !== "string") { - status.textContent = "No canvas preview was returned."; + const artifact = result._meta?.artifact as ArtifactAppPayload | undefined; + if (!artifact || typeof artifact.js !== "string") { + status.textContent = "No artifact preview was returned."; return; } - const bridge: HostBridge = { canvasId: canvas.name, state: canvas.state, onAction: value => { void action(value); } }; - if (canvas.server === true) bridge.onRequest = request => serverRequest(canvas, request); - if (canvas.files === true) { + const bridge: HostBridge & { canvasId: string } = { canvasId: artifact.name, artifactId: artifact.name, state: artifact.state, onAction: value => { void action(value); } }; + if (artifact.server === true) bridge.onRequest = request => serverRequest(artifact, request); + if (artifact.files === true) { bridge.onFileRequest = async request => { - const result = await app.callServerTool({ name: "canvas_files", arguments: { version_id: canvas.versionId, request } }); - if (result.isError) throw new Error(result.content?.filter(item => item.type === "text").map(item => item.text).join("\n") || "Canvas file request failed"); - if (!result.structuredContent || !Object.hasOwn(result.structuredContent, "result")) throw new Error("Canvas file result was missing"); + const result = await app.callServerTool({ name: "artifact_files", arguments: { version_id: artifact.versionId, request } }); + if (result.isError) throw new Error(result.content?.filter(item => item.type === "text").map(item => item.text).join("\n") || "Artifact file request failed"); + if (!result.structuredContent || !Object.hasOwn(result.structuredContent, "result")) throw new Error("Artifact file result was missing"); return result.structuredContent.result; }; bridge.onFileDownload = async url => { - const result = await app.openLink({ url: canvasFileTransferUrl(url) }); + const result = await app.openLink({ url: artifactFileTransferUrl(url) }); if (result.isError) throw new Error("The chat host declined the file download."); }; } - if (canvas.plugins === true) bridge.onPluginCall = async request => { - const result = await app.callServerTool({ name: "canvas_plugin_call", arguments: { ...request } }); + if (artifact.plugins === true) bridge.onPluginCall = async request => { + const result = await app.callServerTool({ name: "artifact_plugin_call", arguments: { ...request } }); if (result.isError) throw new Error(result.content?.filter(item => item.type === "text").map(item => item.text).join("\n") || "Plugin call failed"); const structured = result.structuredContent; if (!structured || !Object.hasOwn(structured, "result")) throw new Error("Plugin result was missing"); return structured.result; }; - hostWindow.__herdrCanvas = bridge; - hasCanvas = true; + hostWindow.__artifacts = hostWindow.__herdrCanvas = bridge; + hasArtifact = true; theme(context.theme); updateDisplay(); status.textContent = ""; - // The host's MCP Apps sandbox/CSP contains this locally authored canvas. + // The host's MCP Apps sandbox/CSP contains this locally authored artifact. // Inline modules avoid eval, external assets, loopback fetches and blob URLs. script = document.createElement("script"); script.type = "module"; - script.textContent = canvas.js; + script.textContent = artifact.js; document.body.append(script); }; -app.ontoolcancelled = () => { clear(); status.textContent = "Canvas request cancelled."; }; +app.ontoolcancelled = () => { clear(); status.textContent = "Artifact request cancelled."; }; app.onhostcontextchanged = applyContext; -window.addEventListener("error", event => { status.textContent = `Canvas error: ${event.message}`; }); -window.addEventListener("unhandledrejection", event => { status.textContent = `Canvas error: ${String(event.reason)}`; }); +window.addEventListener("error", event => { status.textContent = `Artifact error: ${event.message}`; }); +window.addEventListener("unhandledrejection", event => { status.textContent = `Artifact error: ${String(event.reason)}`; }); app.connect().then(() => { connected = true; applyContext(app.getHostContext() ?? {}); diff --git a/src/runtime/navigation-host.ts b/src/runtime/navigation-host.ts index 5ee0f18..3d1e3bd 100644 --- a/src/runtime/navigation-host.ts +++ b/src/runtime/navigation-host.ts @@ -1,16 +1,21 @@ /** Only the trusted standalone wrapper owns the real browser URL. */ export function installNavigationHost(frame: HTMLIFrameElement, basePath: string) { + let protocol: "artifact" | "canvas" = "artifact"; + const routeState = () => history.state?.__artifactRoute ?? history.state?.__canvasRoute; const currentPath = () => (location.pathname.slice(basePath.length) || "/") + location.search + location.hash; const send = (action = "POP") => frame.contentWindow?.postMessage({ - type: "canvas/location", path: currentPath(), action, - state: history.state?.__canvasRoute?.state ?? null, - key: history.state?.__canvasRoute?.key ?? "initial", + type: `${protocol}/location`, path: currentPath(), action, + state: routeState()?.state ?? null, + key: routeState()?.key ?? "initial", }, "*"); const receive = (event: MessageEvent) => { if (event.source !== frame.contentWindow) return; const value = event.data; - if (value?.type === "canvas/navigation-ready") { send(); return; } - if (value?.type !== "canvas/navigate") return; + if (value?.type === "artifact/navigation-ready" || value?.type === "canvas/navigation-ready") { + protocol = value.type === "canvas/navigation-ready" ? "canvas" : "artifact"; + send(); return; + } + if (value?.type !== `${protocol}/navigate`) return; if (value.action === "POP") { if (Number.isSafeInteger(value.delta) && value.delta !== 0) history.go(value.delta); return; @@ -18,9 +23,10 @@ export function installNavigationHost(frame: HTMLIFrameElement, basePath: string if (!["PUSH", "REPLACE"].includes(value.action) || typeof value.path !== "string" || value.path.length > 8192 || !value.path.startsWith("/") || value.path.startsWith("//") || value.path.includes("\\")) return; const next = new URL(value.path, location.origin); - if (next.origin !== location.origin || /^\/(?:api|_canvas)(?:\/|$)/.test(next.pathname)) return; + if (next.origin !== location.origin || /^\/(?:api|_artifact|_canvas)(?:\/|$)/.test(next.pathname)) return; const path = next.pathname + next.search + next.hash; - const state = { ...history.state, __canvasRoute: { state: value.state ?? null, key: crypto.randomUUID() } }; + const route = { state: value.state ?? null, key: crypto.randomUUID() }; + const state = { ...history.state, __artifactRoute: route, __canvasRoute: route }; try { if (value.action === "REPLACE") history.replaceState(state, "", basePath + path); else history.pushState(state, "", basePath + path); @@ -38,5 +44,7 @@ export function installNavigationHost(frame: HTMLIFrameElement, basePath: string }; } -const config = (window as Window & { __canvasNavigationHost?: { frame: HTMLIFrameElement; basePath: string } }).__canvasNavigationHost; +type NavigationConfig = { frame: HTMLIFrameElement; basePath: string }; +const hostWindow = window as Window & { __artifactNavigationHost?: NavigationConfig; __canvasNavigationHost?: NavigationConfig }; +const config = hostWindow.__artifactNavigationHost ?? hostWindow.__canvasNavigationHost; if (config) installNavigationHost(config.frame, config.basePath); diff --git a/src/runtime/wrapper.tsx b/src/runtime/wrapper.tsx index 2ace7f4..d081632 100644 --- a/src/runtime/wrapper.tsx +++ b/src/runtime/wrapper.tsx @@ -1,14 +1,14 @@ import { createRoot } from "react-dom/client"; import type { ComponentType } from "react"; -import { CanvasRouter } from "../sdk/routing"; -import Canvas from "herdr-canvas-entry"; +import { ArtifactRouter } from "../sdk/routing"; +import Artifact from "artifacts-entry"; const root = document.getElementById("root"); if (!root) { throw new Error("missing #root"); } -const Component = Canvas as ComponentType; +const Component = Artifact as ComponentType; const reactRoot = createRoot(root); -(window as Window & { __herdrCanvasUnmount?: () => void }).__herdrCanvasUnmount = () => reactRoot.unmount(); -reactRoot.render(); +(window as Window & { __artifactsUnmount?: () => void }).__artifactsUnmount = () => reactRoot.unmount(); +reactRoot.render(); diff --git a/src/sandbox.test.ts b/src/sandbox.test.ts index 20cb6d8..fb3a543 100644 --- a/src/sandbox.test.ts +++ b/src/sandbox.test.ts @@ -1,33 +1,33 @@ import { expect, test } from "bun:test"; -import { scanCanvasSource } from "./sandbox"; -import { BAD_TYPE_CANVAS, FETCH_CANVAS, FORBIDDEN_IMPORT_CANVAS, VALID_CANVAS } from "./test/fixtures"; +import { scanArtifactSource } from "./sandbox"; +import { BAD_TYPE_ARTIFACT, FETCH_ARTIFACT, FORBIDDEN_IMPORT_ARTIFACT, VALID_ARTIFACT } from "./test/fixtures"; -test("scanCanvasSource accepts a valid canvas", () => { - expect(scanCanvasSource(VALID_CANVAS)).toEqual([]); +test("scanArtifactSource accepts a valid artifact", () => { + expect(scanArtifactSource(VALID_ARTIFACT)).toEqual([]); }); -test("scanCanvasSource rejects node imports", () => { - const violations = scanCanvasSource(FORBIDDEN_IMPORT_CANVAS); +test("scanArtifactSource rejects node imports", () => { + const violations = scanArtifactSource(FORBIDDEN_IMPORT_ARTIFACT); expect(violations.some((item) => item.message.includes("node:fs"))).toBe(true); }); -test("scanCanvasSource rejects fetch", () => { - const violations = scanCanvasSource(FETCH_CANVAS); +test("scanArtifactSource rejects fetch", () => { + const violations = scanArtifactSource(FETCH_ARTIFACT); expect(violations.some((item) => item.message.includes("fetch()"))).toBe(true); }); -test("scanCanvasSource requires a default export", () => { - const violations = scanCanvasSource(`import { H1 } from "herdr/canvas";\nexport function App() { return

Hi

; }\n`); +test("scanArtifactSource requires a default export", () => { + const violations = scanArtifactSource(`import { H1 } from "herdr/canvas";\nexport function App() { return

Hi

; }\n`); expect(violations.some((item) => item.kind === "export")).toBe(true); }); -test("scanCanvasSource allows type-only SDK imports", () => { +test("scanArtifactSource allows type-only SDK imports", () => { const source = `import type { StatProps } from "herdr/canvas"; import { H1 } from "cursor/canvas"; export default function App(_props: StatProps) { return

Hi

; } `; - expect(scanCanvasSource(source)).toEqual([]); + expect(scanArtifactSource(source)).toEqual([]); }); test("comments do not trigger fetch detection", () => { @@ -35,16 +35,25 @@ test("comments do not trigger fetch detection", () => { // fetch("nope") export default function App() { return

Hi

; } `; - expect(scanCanvasSource(source)).toEqual([]); + expect(scanArtifactSource(source)).toEqual([]); }); test("bad types are not a sandbox violation", () => { - expect(scanCanvasSource(BAD_TYPE_CANVAS)).toEqual([]); + expect(scanArtifactSource(BAD_TYPE_ARTIFACT)).toEqual([]); }); test("repeated scans keep rejecting dynamic imports and require calls", () => { for (let attempt = 0; attempt < 3; attempt++) { - expect(scanCanvasSource('export default function Canvas() { return import("herdr/canvas"); }').some(item => item.message.includes("dynamic import"))).toBe(true); - expect(scanCanvasSource('export default function Canvas() { return require("herdr/canvas"); }').some(item => item.message.includes("require()"))).toBe(true); + expect(scanArtifactSource('export default function Artifact() { return import("herdr/canvas"); }').some(item => item.message.includes("dynamic import"))).toBe(true); + expect(scanArtifactSource('export default function Artifact() { return require("herdr/canvas"); }').some(item => item.message.includes("require()"))).toBe(true); + } +}); + +test("only documented Artifacts and historical Canvas SDK paths are allowed", () => { + for (const specifier of ["sidequery/artifacts", "@sidequery/artifacts", "sidequery/canvas", "@sidequery/canvas", "herdr/canvas", "cursor/canvas"]) { + expect(scanArtifactSource(`import { H1 } from "${specifier}"; export default function App() { return

Hi

; }`)).toEqual([]); + } + for (const specifier of ["herdr/artifact", "cursor/artifact"]) { + expect(scanArtifactSource(`import { H1 } from "${specifier}"; export default function App() { return

Hi

; }`).some(item => item.kind === "import")).toBe(true); } }); diff --git a/src/sandbox.ts b/src/sandbox.ts index addc841..b9d0c39 100644 --- a/src/sandbox.ts +++ b/src/sandbox.ts @@ -4,7 +4,7 @@ export type SandboxViolation = { line?: number; }; -const ALLOWED_MODULES = new Set(["sidequery/canvas", "herdr/canvas", "cursor/canvas"]); +const ALLOWED_MODULES = new Set(["sidequery/artifacts","@sidequery/artifacts","sidequery/canvas","@sidequery/canvas","herdr/canvas","cursor/canvas"]); const IMPORT_FROM_RE = /(?:^|[;\n}])\s*(?:import|export)\s*(?:type\s+)?[\s\S]*?\bfrom\s*["']([^"']+)["']/g; const SIDE_EFFECT_IMPORT_RE = /(?:^|[;\n}])\s*import\s*["']([^"']+)["']/g; @@ -12,15 +12,15 @@ const DYNAMIC_IMPORT_RE = /\bimport\s*\(/; const REQUIRE_RE = /\brequire\s*\(/; const FORBIDDEN_APIS: Array<{ kind: SandboxViolation["kind"]; pattern: RegExp; message: string }> = [ - { kind: "api", pattern: /\bfetch\s*\(/, message: "fetch() is not allowed in canvas files; embed data inline" }, - { kind: "api", pattern: /\bXMLHttpRequest\b/, message: "XMLHttpRequest is not allowed in canvas files" }, - { kind: "api", pattern: /\bWebSocket\b/, message: "WebSocket is not allowed in canvas files" }, - { kind: "api", pattern: /\beval\s*\(/, message: "eval() is not allowed in canvas files" }, - { kind: "api", pattern: /\bnew\s+Function\b/, message: "new Function is not allowed in canvas files" }, - { kind: "api", pattern: /\bprocess\./, message: "Node process is not allowed in canvas files" }, - { kind: "api", pattern: /\bBun\./, message: "Bun APIs are not allowed in canvas files" }, - { kind: "api", pattern: /\blocalStorage\b/, message: "localStorage is not allowed; use useCanvasState" }, - { kind: "api", pattern: /\bsessionStorage\b/, message: "sessionStorage is not allowed; use useCanvasState" }, + { kind: "api", pattern: /\bfetch\s*\(/, message: "fetch() is not allowed in artifact files; embed data inline" }, + { kind: "api", pattern: /\bXMLHttpRequest\b/, message: "XMLHttpRequest is not allowed in artifact files" }, + { kind: "api", pattern: /\bWebSocket\b/, message: "WebSocket is not allowed in artifact files" }, + { kind: "api", pattern: /\beval\s*\(/, message: "eval() is not allowed in artifact files" }, + { kind: "api", pattern: /\bnew\s+Function\b/, message: "new Function is not allowed in artifact files" }, + { kind: "api", pattern: /\bprocess\./, message: "Node process is not allowed in artifact files" }, + { kind: "api", pattern: /\bBun\./, message: "Bun APIs are not allowed in artifact files" }, + { kind: "api", pattern: /\blocalStorage\b/, message: "localStorage is not allowed; use useArtifactState" }, + { kind: "api", pattern: /\bsessionStorage\b/, message: "sessionStorage is not allowed; use useArtifactState" }, ]; export function stripCommentsForScan(source: string): string { @@ -29,7 +29,7 @@ export function stripCommentsForScan(source: string): string { .replace(/(^|[^:\\\n])\/\/.*$/gm, "$1"); } -export function scanCanvasSource(source: string, publicImports: readonly string[] = []): SandboxViolation[] { +export function scanArtifactSource(source: string, publicImports: readonly string[] = []): SandboxViolation[] { const stripped = stripCommentsForScan(source); const violations: SandboxViolation[] = []; @@ -38,7 +38,7 @@ export function scanCanvasSource(source: string, publicImports: readonly string[ if (!ALLOWED_MODULES.has(specifier) && !publicImports.includes(specifier)) { violations.push({ kind: "import", - message: `import from "${specifier}" is not allowed; import only from "sidequery/canvas"`, + message: `import from "${specifier}" is not allowed; import only from "sidequery/artifacts"`, line: lineNumberAt(stripped, match.index ?? 0), }); } @@ -56,10 +56,10 @@ export function scanCanvasSource(source: string, publicImports: readonly string[ } if (DYNAMIC_IMPORT_RE.test(stripped)) { - violations.push({ kind: "import", message: "dynamic import() is not allowed in canvas files" }); + violations.push({ kind: "import", message: "dynamic import() is not allowed in artifact files" }); } if (REQUIRE_RE.test(stripped)) { - violations.push({ kind: "import", message: "require() is not allowed in canvas files" }); + violations.push({ kind: "import", message: "require() is not allowed in artifact files" }); } for (const api of FORBIDDEN_APIS) { @@ -71,7 +71,7 @@ export function scanCanvasSource(source: string, publicImports: readonly string[ if (!/\bexport\s+default\b/.test(stripped)) { violations.push({ kind: "export", - message: "canvas files must default-export a React component", + message: "artifact files must default-export a React component", }); } diff --git a/src/sdk/files.test.ts b/src/sdk/files.test.ts index 54908e6..3395bee 100644 --- a/src/sdk/files.test.ts +++ b/src/sdk/files.test.ts @@ -1,22 +1,22 @@ import { afterEach, expect, test } from "bun:test"; -import { canvasFiles, canvasFileTransferUrl, MAX_CANVAS_FILE_BYTES, type CanvasFileRequest } from "./files"; +import { artifactFiles, artifactFileTransferUrl, MAX_ARTIFACTS_FILE_BYTES, type ArtifactFileRequest } from "./files"; import type { HostBridge } from "./hooks"; -const host = globalThis as typeof globalThis & { __herdrCanvas?: HostBridge }; -const original = host.__herdrCanvas; +const host = globalThis as typeof globalThis & { __artifacts?: HostBridge }; +const original = host.__artifacts; const originalFetch = globalThis.fetch; -const url = `https://canvas.example/api/canvas/files/transfer/${"a".repeat(64)}/12345678-1234-1234-1234-123456789abc`; +const url = `https://artifact.example/api/artifact/files/transfer/${"a".repeat(64)}/12345678-1234-1234-1234-123456789abc`; const file = { id: "one", name: "binary.bin", size: 300_000, type: "application/octet-stream", uploaded: "2026-09-07T00:00:00Z" }; const grant = { file, url, expires: "2026-09-07T00:05:00Z" }; -afterEach(() => { host.__herdrCanvas = original; globalThis.fetch = originalFetch; }); +afterEach(() => { host.__artifacts = original; globalThis.fetch = originalFetch; }); function mockFetch(handler: (input: string | URL | Request, init?: RequestInit) => Promise) { globalThis.fetch = handler as typeof fetch; } test("uploads binary blobs above the server envelope limit directly with no credentials", async () => { - const requests: CanvasFileRequest[] = []; - host.__herdrCanvas = { onFileRequest: async request => { requests.push(request); return grant; } }; + const requests: ArtifactFileRequest[] = []; + host.__artifacts = { onFileRequest: async request => { requests.push(request); return grant; } }; const bytes = Uint8Array.from({ length: 300_000 }, (_, i) => i % 256); const body = new File([bytes], "binary.bin", { type: "application/octet-stream" }); mockFetch(async (input, init) => { @@ -29,14 +29,14 @@ test("uploads binary blobs above the server envelope limit directly with no cred expect(new Headers(init?.headers).has("authorization")).toBe(false); return Response.json({ file: { ...file, uploaded: "2026-09-07T00:00:01Z" } }, { status: 201 }); }); - expect(await canvasFiles.upload(body)).toEqual({ ...file, uploaded: "2026-09-07T00:00:01Z" }); + expect(await artifactFiles.upload(body)).toEqual({ ...file, uploaded: "2026-09-07T00:00:01Z" }); expect(requests).toEqual([{ operation: "upload", name: "binary.bin", size: 300_000, type: "application/octet-stream" }]); }); test("lists pages, reads binary data, delegates downloads and deletes using metadata only", async () => { - const requests: CanvasFileRequest[] = []; + const requests: ArtifactFileRequest[] = []; const downloads: string[] = []; - host.__herdrCanvas = { + host.__artifacts = { onFileRequest: async request => { requests.push(request); return request.operation === "list" ? { files: [file], cursor: "next" } : request.operation === "delete" ? { deleted: true } : grant; @@ -50,53 +50,69 @@ test("lists pages, reads binary data, delegates downloads and deletes using meta expect(init?.redirect).toBe("error"); return new Response(bytes); }); - expect(await canvasFiles.list({ cursor: "page" })).toEqual({ files: [file], cursor: "next" }); - expect(new Uint8Array(await (await canvasFiles.read("one")).arrayBuffer())).toEqual(bytes); - await canvasFiles.download("one"); + expect(await artifactFiles.list({ cursor: "page" })).toEqual({ files: [file], cursor: "next" }); + expect(new Uint8Array(await (await artifactFiles.read("one")).arrayBuffer())).toEqual(bytes); + await artifactFiles.download("one"); expect(downloads).toEqual([url]); - expect(await canvasFiles.delete("one")).toEqual({ deleted: true }); + expect(await artifactFiles.delete("one")).toEqual({ deleted: true }); expect(requests).toEqual([{ operation: "list", cursor: "page" }, { operation: "download", id: "one" }, { operation: "download", id: "one" }, { operation: "delete", id: "one" }]); }); test("rejects unavailable, oversized and failed transfers", async () => { - host.__herdrCanvas = {}; - await expect(canvasFiles.list()).rejects.toThrow("unavailable"); - await expect(canvasFiles.upload(new Blob([new Uint8Array(MAX_CANVAS_FILE_BYTES + 1)]))).rejects.toThrow("exceeds"); - host.__herdrCanvas = { onFileRequest: async () => { throw new Error("denied"); } }; - await expect(canvasFiles.read("one")).rejects.toThrow("denied"); - host.__herdrCanvas = { onFileRequest: async () => grant }; + host.__artifacts = {}; + await expect(artifactFiles.list()).rejects.toThrow("unavailable"); + await expect(artifactFiles.upload(new Blob([new Uint8Array(MAX_ARTIFACTS_FILE_BYTES + 1)]))).rejects.toThrow("exceeds"); + host.__artifacts = { onFileRequest: async () => { throw new Error("denied"); } }; + await expect(artifactFiles.read("one")).rejects.toThrow("denied"); + host.__artifacts = { onFileRequest: async () => grant }; mockFetch(async () => new Response(null, { status: 410 })); - await expect(canvasFiles.upload(new Blob(["x"]))).rejects.toThrow("upload failed (410)"); - await expect(canvasFiles.read("one")).rejects.toThrow("read failed (410)"); - host.__herdrCanvas.onFileDownload = async () => { throw new Error("host declined"); }; - await expect(canvasFiles.download("one")).rejects.toThrow("host declined"); + await expect(artifactFiles.upload(new Blob(["x"]))).rejects.toThrow("upload failed (410)"); + await expect(artifactFiles.read("one")).rejects.toThrow("read failed (410)"); + host.__artifacts.onFileDownload = async () => { throw new Error("host declined"); }; + await expect(artifactFiles.download("one")).rejects.toThrow("host declined"); }); test("validates grant URLs and prevents transfers after cancellation", async () => { for (const invalid of ["javascript:alert(1)", "https://evil.example/", `${url}?redirect=x`, `${url}#fragment`, url.replace("https://", "https://user:pass@")]) { - expect(() => canvasFileTransferUrl(invalid)).toThrow(); + expect(() => artifactFileTransferUrl(invalid)).toThrow(); } - expect(() => canvasFileTransferUrl(url, "https://other.example")).toThrow(); + expect(() => artifactFileTransferUrl(url, "https://other.example")).toThrow(); let calls = 0; let fetches = 0; mockFetch(async () => { fetches++; return new Response(); }); const controller = new AbortController(); - host.__herdrCanvas = { onFileRequest: async () => { calls++; controller.abort(); return grant; } }; - await expect(canvasFiles.upload(new Blob(["x"]), { signal: controller.signal })).rejects.toThrow(); + host.__artifacts = { onFileRequest: async () => { calls++; controller.abort(); return grant; } }; + await expect(artifactFiles.upload(new Blob(["x"]), { signal: controller.signal })).rejects.toThrow(); expect(calls).toBe(1); expect(fetches).toBe(0); - await expect(canvasFiles.read("one", { signal: controller.signal })).rejects.toThrow(); + await expect(artifactFiles.read("one", { signal: controller.signal })).rejects.toThrow(); expect(calls).toBe(1); - host.__herdrCanvas.onFileRequest = async () => ({ ...grant, url: "https://evil.example/" }); - await expect(canvasFiles.read("one")).rejects.toThrow("Invalid canvas file transfer URL"); + host.__artifacts.onFileRequest = async () => ({ ...grant, url: "https://evil.example/" }); + await expect(artifactFiles.read("one")).rejects.toThrow("Invalid artifact file transfer URL"); expect(fetches).toBe(0); }); test("cancellation rejects while a metadata request is still pending", async () => { - host.__herdrCanvas = { onFileRequest: () => new Promise(() => {}) }; + host.__artifacts = { onFileRequest: () => new Promise(() => {}) }; const controller = new AbortController(); - const read = canvasFiles.read("one", { signal: controller.signal }); + const read = artifactFiles.read("one", { signal: controller.signal }); await Promise.resolve(); controller.abort(new Error("stopped")); await expect(read).rejects.toThrow("stopped"); }); + + +test("legacy grant endpoints retain validation and work for binary reads and host downloads", async () => { + const legacyUrl = url.replace("/api/artifact/", "/api/canvas/"); + expect(artifactFileTransferUrl(legacyUrl, "https://artifact.example")).toBe(legacyUrl); + for (const invalid of [legacyUrl + "?redirect=x", legacyUrl + "#fragment", legacyUrl.replace("https://", "https://user@"), legacyUrl.replace("/transfer/", "/other/")]) { + expect(() => artifactFileTransferUrl(invalid)).toThrow(); + } + expect(() => artifactFileTransferUrl(legacyUrl, "https://other.example")).toThrow(); + const downloads: string[] = []; + host.__artifacts = { onFileRequest: async () => ({ ...grant, url: legacyUrl }), onFileDownload: async value => { downloads.push(value); } }; + mockFetch(async (input, init) => { expect(input).toBe(legacyUrl); expect(init?.credentials).toBe("omit"); expect(init?.redirect).toBe("error"); return new Response("legacy bytes"); }); + expect(await (await artifactFiles.read("one")).text()).toBe("legacy bytes"); + await artifactFiles.download("one"); + expect(downloads).toEqual([legacyUrl]); +}); diff --git a/src/sdk/files.ts b/src/sdk/files.ts index b05eb4f..2ecf983 100644 --- a/src/sdk/files.ts +++ b/src/sdk/files.ts @@ -1,36 +1,36 @@ -export type CanvasFile = { id: string; name: string; size: number; type: string; uploaded: string }; -export type CanvasFileRequest = +export type ArtifactFile = { id: string; name: string; size: number; type: string; uploaded: string }; +export type ArtifactFileRequest = | { operation: "list"; cursor?: string } | { operation: "upload"; name: string; size: number; type: string } | { operation: "download"; id: string } | { operation: "delete"; id: string }; -export type CanvasFileList = { files: CanvasFile[]; cursor?: string }; -export type CanvasFileTransfer = { file: CanvasFile; url: string; expires: string }; -export type CanvasFileResult = CanvasFileList | CanvasFileTransfer | { deleted: true }; -export const MAX_CANVAS_FILE_BYTES = 25 * 1024 * 1024; +export type ArtifactFileList = { files: ArtifactFile[]; cursor?: string }; +export type ArtifactFileTransfer = { file: ArtifactFile; url: string; expires: string }; +export type ArtifactFileResult = ArtifactFileList | ArtifactFileTransfer | { deleted: true }; +export const MAX_ARTIFACTS_FILE_BYTES = 25 * 1024 * 1024; type FileBridge = { - onFileRequest?: (request: CanvasFileRequest) => Promise; + onFileRequest?: (request: ArtifactFileRequest) => Promise; onFileDownload?: (url: string) => Promise; }; function bridge(): FileBridge { - return (globalThis as typeof globalThis & { __herdrCanvas?: FileBridge }).__herdrCanvas ?? {}; + return (globalThis as typeof globalThis & { __artifacts?: FileBridge }).__artifacts ?? {}; } /** Accept only the dedicated grant endpoint, never arbitrary navigation or redirects. */ -export function canvasFileTransferUrl(value: string, origin?: string): string { +export function artifactFileTransferUrl(value: string, origin?: string): string { const url = new URL(value); if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.search || url.hash || (origin !== undefined && url.origin !== origin) - || !/^\/api\/canvas\/files\/transfer\/[a-f0-9]{64}\/[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/.test(url.pathname)) { - throw new TypeError("Invalid canvas file transfer URL"); + || !/^\/api\/(?:artifact|canvas)\/files\/transfer\/[a-f0-9]{64}\/[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/.test(url.pathname)) { + throw new TypeError("Invalid artifact file transfer URL"); } return url.href; } -async function request(host: FileBridge, value: CanvasFileRequest, signal?: AbortSignal): Promise { +async function request(host: FileBridge, value: ArtifactFileRequest, signal?: AbortSignal): Promise { signal?.throwIfAborted(); - if (!host.onFileRequest) throw new Error("Canvas files are unavailable in this view."); + if (!host.onFileRequest) throw new Error("Artifact files are unavailable in this view."); if (!signal) return await host.onFileRequest(value) as T; return await new Promise((resolve, reject) => { const aborted = () => reject(signal.reason ?? new DOMException("The operation was aborted.", "AbortError")); @@ -42,39 +42,39 @@ async function request(host: FileBridge, value: CanvasFileRequest, signal?: A }); } -export const canvasFiles = { - async list(options?: { cursor?: string }): Promise { +export const artifactFiles = { + async list(options?: { cursor?: string }): Promise { return request(bridge(), { operation: "list", ...options }); }, - async upload(file: File | Blob, options?: { name?: string; signal?: AbortSignal }): Promise { + async upload(file: File | Blob, options?: { name?: string; signal?: AbortSignal }): Promise { options?.signal?.throwIfAborted(); - if (!(file instanceof Blob)) throw new TypeError("canvasFiles.upload requires a File or Blob"); - if (file.size > MAX_CANVAS_FILE_BYTES) throw new TypeError(`Canvas file exceeds ${MAX_CANVAS_FILE_BYTES} bytes`); + if (!(file instanceof Blob)) throw new TypeError("artifactFiles.upload requires a File or Blob"); + if (file.size > MAX_ARTIFACTS_FILE_BYTES) throw new TypeError(`Artifact file exceeds ${MAX_ARTIFACTS_FILE_BYTES} bytes`); const name = options?.name ?? (typeof File !== "undefined" && file instanceof File ? file.name : "file"); - const grant = await request(bridge(), { operation: "upload", name, size: file.size, type: file.type || "application/octet-stream" }, options?.signal); + const grant = await request(bridge(), { operation: "upload", name, size: file.size, type: file.type || "application/octet-stream" }, options?.signal); options?.signal?.throwIfAborted(); const init: RequestInit & { credentials: "omit" } = { method: "PUT", body: file, credentials: "omit", redirect: "error", signal: options?.signal, headers: { "content-type": file.type || "application/octet-stream" }, }; - const response = await fetch(canvasFileTransferUrl(grant.url), init); - if (!response.ok) throw new Error(`Canvas file upload failed (${response.status})`); - const result = await response.json() as { file: CanvasFile }; + const response = await fetch(artifactFileTransferUrl(grant.url), init); + if (!response.ok) throw new Error(`Artifact file upload failed (${response.status})`); + const result = await response.json() as { file: ArtifactFile }; return result.file; }, async read(id: string, options?: { signal?: AbortSignal }): Promise { - const grant = await request(bridge(), { operation: "download", id }, options?.signal); + const grant = await request(bridge(), { operation: "download", id }, options?.signal); options?.signal?.throwIfAborted(); const init: RequestInit & { credentials: "omit" } = { credentials: "omit", redirect: "error", signal: options?.signal }; - const response = await fetch(canvasFileTransferUrl(grant.url), init); - if (!response.ok) throw new Error(`Canvas file read failed (${response.status})`); + const response = await fetch(artifactFileTransferUrl(grant.url), init); + if (!response.ok) throw new Error(`Artifact file read failed (${response.status})`); return response.blob(); }, async download(id: string): Promise { const host = bridge(); - if (!host.onFileDownload) throw new Error("Canvas file downloads are unavailable in this view."); - const grant = await request(host, { operation: "download", id }); - await host.onFileDownload(canvasFileTransferUrl(grant.url)); + if (!host.onFileDownload) throw new Error("Artifact file downloads are unavailable in this view."); + const grant = await request(host, { operation: "download", id }); + await host.onFileDownload(artifactFileTransferUrl(grant.url)); }, async delete(id: string): Promise<{ deleted: true }> { return request(bridge(), { operation: "delete", id }); diff --git a/src/sdk/hooks.ts b/src/sdk/hooks.ts index 8016ed1..b1dec20 100644 --- a/src/sdk/hooks.ts +++ b/src/sdk/hooks.ts @@ -1,28 +1,28 @@ import { useEffect, useMemo, useState, type Dispatch, type SetStateAction } from "react"; -import { themeFromKind, type CanvasHostTheme } from "./tokens"; -import type { CanvasHttpRequest, CanvasHttpResponse } from "./server"; +import { themeFromKind, type ArtifactHostTheme } from "./tokens"; +import type { ArtifactHttpRequest, ArtifactHttpResponse } from "./server"; import type { PluginRequest } from "../plugins/types"; -import type { CanvasFileRequest } from "./files"; +import type { ArtifactFileRequest } from "./files"; -export type CanvasAction = +export type ArtifactAction = | { type: "openFile"; path: string; selection?: { startLine?: number; endLine?: number } } | { type: "promptAgent"; prompt: string } | { type: "openUrl"; url: string }; -export type SetCanvasState = Dispatch>; +export type SetArtifactState = Dispatch>; export type HostBridge = { - route?: import("./routing").CanvasRoute; - canvasId?: string; + route?: import("./routing").ArtifactRoute; + artifactId?: string; state?: Record; theme?: { kind?: string }; persistUrl?: string; actionUrl?: string; - onAction?: (action: CanvasAction) => void; - onRequest?: (request: CanvasHttpRequest) => Promise; + onAction?: (action: ArtifactAction) => void; + onRequest?: (request: ArtifactHttpRequest) => Promise; onPluginCall?: (request: PluginRequest) => Promise; - onFileRequest?: (request: CanvasFileRequest) => Promise; + onFileRequest?: (request: ArtifactFileRequest) => Promise; onFileDownload?: (url: string) => Promise; }; @@ -30,7 +30,7 @@ function hostBridge(): HostBridge { if (typeof window === "undefined") { return {}; } - return (window as Window & { __herdrCanvas?: HostBridge }).__herdrCanvas ?? {}; + return (window as Window & { __artifacts?: HostBridge }).__artifacts ?? {}; } export function themeKindFromHost(kind: string | undefined, prefersLight: boolean): "dark" | "light" { @@ -40,12 +40,12 @@ export function themeKindFromHost(kind: string | undefined, prefersLight: boolea return prefersLight ? "light" : "dark"; } -export function useHostTheme(): CanvasHostTheme { +export function useHostTheme(): ArtifactHostTheme { const [generation, setGeneration] = useState(0); useEffect(() => { const changed = () => setGeneration(value => value + 1); - window.addEventListener("canvas-theme-change", changed); - return () => window.removeEventListener("canvas-theme-change", changed); + window.addEventListener("artifact-theme-change", changed); + return () => window.removeEventListener("artifact-theme-change", changed); }, []); return useMemo(() => { const prefersLight = typeof window !== "undefined" && Boolean(window.matchMedia?.("(prefers-color-scheme: light)").matches); @@ -53,14 +53,14 @@ export function useHostTheme(): CanvasHostTheme { }, [generation]); } -export function useCanvasState(key: string, defaultValue: T): [T, SetCanvasState] { +export function useArtifactState(key: string, defaultValue: T): [T, SetArtifactState] { const [value, setValue] = useState(() => { const stored = hostBridge().state?.[key]; return stored === undefined ? defaultValue : (stored as T); }); const persistUrl = hostBridge().persistUrl; - const setter: SetCanvasState = (action) => { + const setter: SetArtifactState = (action) => { setValue((prev) => { const next = typeof action === "function" ? (action as (current: T) => T)(prev) : action; if (persistUrl) { @@ -76,7 +76,7 @@ export function useCanvasState(key: string, defaultValue: T): [T, SetCanvasSt return [value, setter]; } -export function useCanvasAction(): (action: CanvasAction) => void { +export function useArtifactAction(): (action: ArtifactAction) => void { const actionUrl = hostBridge().actionUrl; return (action) => { if (hostBridge().onAction) { diff --git a/src/sdk/index.ts b/src/sdk/index.ts index 56f78a2..a6c8328 100644 --- a/src/sdk/index.ts +++ b/src/sdk/index.ts @@ -1,18 +1,18 @@ -export type { CanvasAction, SetCanvasState } from "./hooks"; +export type { ArtifactAction, SetArtifactState } from "./hooks"; export { useState, useReducer, useRef, useMemo, useCallback, useEffect } from "react"; -export { useCanvasAction, useCanvasState, useHostTheme } from "./hooks"; -export { canvasFetch, type CanvasHttpRequest, type CanvasHttpResponse } from "./server"; +export { useArtifactAction, useArtifactState, useHostTheme } from "./hooks"; +export { artifactFetch, type ArtifactHttpRequest, type ArtifactHttpResponse } from "./server"; export { pluginCall, type PluginRequest } from "./plugins"; -export { canvasFiles, MAX_CANVAS_FILE_BYTES, type CanvasFile, type CanvasFileRequest, type CanvasFileResult, type CanvasFileList, type CanvasFileTransfer } from "./files"; +export { artifactFiles, MAX_ARTIFACTS_FILE_BYTES, type ArtifactFile, type ArtifactFileRequest, type ArtifactFileResult, type ArtifactFileList, type ArtifactFileTransfer } from "./files"; export { - canvasPaletteDark, - canvasPaletteLight, - canvasTypography, + artifactPaletteDark, + artifactPaletteLight, + artifactTypography, themeFromKind, tokensFromPalette, - type CanvasHostTheme, - type CanvasPalette, - type CanvasTokens, + type ArtifactHostTheme, + type ArtifactPalette, + type ArtifactTokens, type Tone, } from "./tokens"; export { @@ -66,3 +66,10 @@ export { } from "./forms"; export { Routes, Route, Outlet, Navigate, NavLink, useNavigate, useParams, useLocation, useSearchParams, useMatch, useResolvedPath, type RouteObject, type NavigateOptions, type To } from "./routing"; + +// Historical source files and archived projects retain their original SDK names. +export type { ArtifactAction as CanvasAction, SetArtifactState as SetCanvasState } from "./hooks"; +export { useArtifactAction as useCanvasAction, useArtifactState as useCanvasState } from "./hooks"; +export { artifactFetch as canvasFetch, type ArtifactHttpRequest as CanvasHttpRequest, type ArtifactHttpResponse as CanvasHttpResponse } from "./server"; +export { artifactFiles as canvasFiles, MAX_ARTIFACTS_FILE_BYTES as MAX_CANVAS_FILE_BYTES, type ArtifactFile as CanvasFile, type ArtifactFileRequest as CanvasFileRequest, type ArtifactFileResult as CanvasFileResult, type ArtifactFileList as CanvasFileList, type ArtifactFileTransfer as CanvasFileTransfer } from "./files"; +export { artifactPaletteDark as canvasPaletteDark, artifactPaletteLight as canvasPaletteLight, artifactTypography as canvasTypography, type ArtifactHostTheme as CanvasHostTheme, type ArtifactPalette as CanvasPalette, type ArtifactTokens as CanvasTokens } from "./tokens"; diff --git a/src/sdk/plugins.test.ts b/src/sdk/plugins.test.ts index 399f6dc..b2ff258 100644 --- a/src/sdk/plugins.test.ts +++ b/src/sdk/plugins.test.ts @@ -1,24 +1,24 @@ import { afterEach, expect, test } from "bun:test"; import { pluginCall } from "./plugins"; import type { PluginRequest } from "../plugins/types"; -const host = globalThis as typeof globalThis & { __herdrCanvas?: { onPluginCall?: (request: PluginRequest) => Promise } }; -const original = host.__herdrCanvas; -afterEach(() => { if (original === undefined) delete host.__herdrCanvas; else host.__herdrCanvas = original; }); +const host = globalThis as typeof globalThis & { __artifacts?: { onPluginCall?: (request: PluginRequest) => Promise } }; +const original = host.__artifacts; +afterEach(() => { if (original === undefined) delete host.__artifacts; else host.__artifacts = original; }); test("pluginCall delivers JSON and returns the typed server result", async () => { let received: PluginRequest | undefined; const input = { id: "record-1" }; - host.__herdrCanvas = { onPluginCall: async request => { received = request; return { name: "Record" }; } }; + host.__artifacts = { onPluginCall: async request => { received = request; return { name: "Record" }; } }; expect(await pluginCall<{ name: string }>("directory", "lookup", input)).toEqual({ name: "Record" }); expect(received).toEqual({ plugin: "directory", operation: "lookup", input }); expect(received!.input).not.toBe(input); }); test("pluginCall rejects invalid, oversized and unavailable calls", async () => { - host.__herdrCanvas = {}; + host.__artifacts = {}; await expect(pluginCall("directory", "lookup", {})).rejects.toThrow("unavailable"); let calls = 0; - host.__herdrCanvas = { onPluginCall: async () => { calls++; return null; } }; + host.__artifacts = { onPluginCall: async () => { calls++; return null; } }; await expect(pluginCall("", "lookup", {})).rejects.toThrow(TypeError); await expect(pluginCall("directory", "lookup", undefined)).rejects.toThrow(TypeError); await expect(pluginCall("directory", "lookup", "x".repeat(256 * 1024))).rejects.toThrow(RangeError); @@ -28,9 +28,9 @@ test("pluginCall rejects invalid, oversized and unavailable calls", async () => }); test("pluginCall propagates server errors and client cancellation", async () => { - host.__herdrCanvas = { onPluginCall: async () => { throw new Error("Operation denied"); } }; + host.__artifacts = { onPluginCall: async () => { throw new Error("Operation denied"); } }; await expect(pluginCall("directory", "lookup", {})).rejects.toThrow("Operation denied"); - host.__herdrCanvas = { onPluginCall: () => new Promise(() => {}) }; + host.__artifacts = { onPluginCall: () => new Promise(() => {}) }; const controller = new AbortController(); const pending = pluginCall("directory", "lookup", {}, { signal: controller.signal }); controller.abort(); diff --git a/src/sdk/plugins.ts b/src/sdk/plugins.ts index 0d3c641..69d4572 100644 --- a/src/sdk/plugins.ts +++ b/src/sdk/plugins.ts @@ -9,7 +9,7 @@ export async function pluginCall(plugin: string, operation: string, input: un } const encoded = JSON.stringify({ plugin, operation, input }); if (new TextEncoder().encode(encoded).byteLength > 256 * 1024) throw new RangeError("Plugin input exceeds 256 KiB"); - const bridge = (globalThis as typeof globalThis & { __herdrCanvas?: { onPluginCall?: (request: PluginRequest) => Promise } }).__herdrCanvas; + const bridge = (globalThis as typeof globalThis & { __artifacts?: { onPluginCall?: (request: PluginRequest) => Promise } }).__artifacts; if (!bridge?.onPluginCall) throw new Error("Plugin functions are unavailable in this view"); const request: PluginRequest = JSON.parse(encoded); return new Promise((resolve, reject) => { diff --git a/src/sdk/routing.tsx b/src/sdk/routing.tsx index 8eefb9c..0fb0173 100644 --- a/src/sdk/routing.tsx +++ b/src/sdk/routing.tsx @@ -4,17 +4,17 @@ import { MemoryRouter, Router, NavigationType, createPath, parsePath, type Locat export { Routes, Route, Outlet, Navigate, NavLink, useNavigate, useParams, useLocation, useSearchParams, useMatch, useResolvedPath } from "react-router"; export type { RouteObject, NavigateOptions, To } from "react-router"; -export type CanvasRoute = { path: string; basePath: string; external: true }; +export type ArtifactRoute = { path: string; basePath: string; external: true }; -/** The viewer owns browser history; embedded canvases keep their own history. */ -export function CanvasRouter({ children }: { children: ReactNode }) { - const route = (globalThis as typeof globalThis & { __herdrCanvas?: { route?: CanvasRoute } }).__herdrCanvas?.route; +/** The viewer owns browser history; embedded artifacts keep their own history. */ +export function ArtifactRouter({ children }: { children: ReactNode }) { + const route = (globalThis as typeof globalThis & { __artifacts?: { route?: ArtifactRoute } }).__artifacts?.route; return route?.external && window.parent !== window ? {children} : {children}; } -function HostedRouter({ route, children }: { route: CanvasRoute; children: ReactNode }) { +function HostedRouter({ route, children }: { route: ArtifactRoute; children: ReactNode }) { const [current, setCurrent] = useState<{ location: Location; action: NavigationType }>(() => ({ location: { pathname: "/", search: "", hash: "", ...parsePath(route.path), state: null, key: "initial" }, action: NavigationType.Pop, @@ -22,19 +22,19 @@ function HostedRouter({ route, children }: { route: CanvasRoute; children: React useEffect(() => { const receive = (event: MessageEvent) => { const value = event.data; - if (event.source !== window.parent || value?.type !== "canvas/location" || typeof value.path !== "string" + if (event.source !== window.parent || value?.type !== "artifact/location" || typeof value.path !== "string" || !value.path.startsWith("/") || !["POP", "PUSH", "REPLACE"].includes(value.action)) return; setCurrent({ location: { pathname: "/", search: "", hash: "", ...parsePath(value.path), state: value.state ?? null, key: String(value.key ?? "default") }, action: value.action }); }; window.addEventListener("message", receive); - window.parent.postMessage({ type: "canvas/navigation-ready" }, "*"); + window.parent.postMessage({ type: "artifact/navigation-ready" }, "*"); return () => window.removeEventListener("message", receive); }, []); const navigator = useMemo(() => ({ createHref: to => route.basePath + (typeof to === "string" ? to : createPath(to)), - go: delta => window.parent.postMessage({ type: "canvas/navigate", action: "POP", delta }, "*"), - push: (to, state) => window.parent.postMessage({ type: "canvas/navigate", action: "PUSH", path: typeof to === "string" ? to : createPath(to), state }, "*"), - replace: (to, state) => window.parent.postMessage({ type: "canvas/navigate", action: "REPLACE", path: typeof to === "string" ? to : createPath(to), state }, "*"), + go: delta => window.parent.postMessage({ type: "artifact/navigate", action: "POP", delta }, "*"), + push: (to, state) => window.parent.postMessage({ type: "artifact/navigate", action: "PUSH", path: typeof to === "string" ? to : createPath(to), state }, "*"), + replace: (to, state) => window.parent.postMessage({ type: "artifact/navigate", action: "REPLACE", path: typeof to === "string" ? to : createPath(to), state }, "*"), }), [route.basePath]); return {children}; } diff --git a/src/sdk/server.test.ts b/src/sdk/server.test.ts index 2203223..d196337 100644 --- a/src/sdk/server.test.ts +++ b/src/sdk/server.test.ts @@ -1,35 +1,35 @@ import { afterEach, expect, test } from "bun:test"; -import { canvasFetch, type CanvasHttpRequest, type CanvasHttpResponse } from "./server"; +import { artifactFetch, type ArtifactHttpRequest, type ArtifactHttpResponse } from "./server"; -const testGlobal = globalThis as typeof globalThis & { __herdrCanvas?: { onRequest?: (request: CanvasHttpRequest) => Promise } }; -const originalBridge = testGlobal.__herdrCanvas; +const testGlobal = globalThis as typeof globalThis & { __artifacts?: { onRequest?: (request: ArtifactHttpRequest) => Promise } }; +const originalBridge = testGlobal.__artifacts; afterEach(() => { - if (originalBridge === undefined) delete testGlobal.__herdrCanvas; - else testGlobal.__herdrCanvas = originalBridge; + if (originalBridge === undefined) delete testGlobal.__artifacts; + else testGlobal.__artifacts = originalBridge; }); -function installBridge(handler?: (request: CanvasHttpRequest) => Promise) { - testGlobal.__herdrCanvas = handler ? { onRequest: handler } : {}; +function installBridge(handler?: (request: ArtifactHttpRequest) => Promise) { + testGlobal.__artifacts = handler ? { onRequest: handler } : {}; } function encoded(value: string): string { return btoa(value); } -test("canvasFetch preserves native request and response semantics through the JSON envelope", async () => { - let captured: CanvasHttpRequest | undefined; +test("artifactFetch preserves native request and response semantics through the JSON envelope", async () => { + let captured: ArtifactHttpRequest | undefined; installBridge(async request => { captured = request; return { status: 201, statusText: "Created", - headers: [["content-type", "application/json"], ["x-canvas", "yes"]], + headers: [["content-type", "application/json"], ["x-artifact", "yes"]], body: encoded('{"saved":true}'), }; }); - const response = await canvasFetch("/api/items?limit=2#ignored", { + const response = await artifactFetch("/api/items?limit=2#ignored", { method: "post", headers: { "content-type": "application/json", "x-request": "native" }, body: JSON.stringify({ title: "hello" }), @@ -42,60 +42,60 @@ test("canvasFetch preserves native request and response semantics through the JS expect(atob(captured!.body!)).toBe('{"title":"hello"}'); expect(response.status).toBe(201); expect(response.statusText).toBe("Created"); - expect(response.headers.get("x-canvas")).toBe("yes"); + expect(response.headers.get("x-artifact")).toBe("yes"); expect(await response.json()).toEqual({ saved: true }); }); -test("canvasFetch lets Request serialize FormData and enforces the request body limit", async () => { - const requests: CanvasHttpRequest[] = []; +test("artifactFetch lets Request serialize FormData and enforces the request body limit", async () => { + const requests: ArtifactHttpRequest[] = []; installBridge(async request => { requests.push(request); return { status: 200, statusText: "OK", headers: [], body: encoded("ok") }; }); const form = new FormData(); form.set("name", "native form"); - expect(await (await canvasFetch("/form", { method: "POST", body: form })).text()).toBe("ok"); + expect(await (await artifactFetch("/form", { method: "POST", body: form })).text()).toBe("ok"); expect(new Headers(requests[0]!.headers).get("content-type")?.startsWith("multipart/form-data; boundary=")).toBe(true); expect(atob(requests[0]!.body!)).toContain("native form"); - await canvasFetch("/limit", { method: "POST", body: "x".repeat(256 * 1024) }); + await artifactFetch("/limit", { method: "POST", body: "x".repeat(256 * 1024) }); expect(requests).toHaveLength(2); - await expect(canvasFetch("/too-large", { method: "POST", body: "x".repeat(256 * 1024 + 1) })).rejects.toThrow("exceeds 262144 bytes"); + await expect(artifactFetch("/too-large", { method: "POST", body: "x".repeat(256 * 1024 + 1) })).rejects.toThrow("exceeds 262144 bytes"); expect(requests).toHaveLength(2); }); -test("canvasFetch returns null bodies for HEAD and bodyless response statuses", async () => { +test("artifactFetch returns null bodies for HEAD and bodyless response statuses", async () => { installBridge(async request => ({ status: request.path === "/head" ? 200 : Number(request.path.slice(1)), statusText: "Bodyless", headers: [["x-body", "ignored"]], body: encoded("must not become a response body"), })); - const head = await canvasFetch("/head", { method: "HEAD" }); + const head = await artifactFetch("/head", { method: "HEAD" }); expect(head.body).toBeNull(); expect(await head.text()).toBe(""); for (const status of [204, 205, 304]) { - const response = await canvasFetch(`/${status}`); + const response = await artifactFetch(`/${status}`); expect(response.status).toBe(status); expect(response.body).toBeNull(); expect(await response.text()).toBe(""); } }); -test("canvasFetch rejects unavailable, unsafe, and aborted requests before delivery", async () => { +test("artifactFetch rejects unavailable, unsafe, and aborted requests before delivery", async () => { installBridge(); - await expect(canvasFetch("/missing")).rejects.toThrow("Canvas server requests are unavailable in this view"); + await expect(artifactFetch("/missing")).rejects.toThrow("Artifact server requests are unavailable in this view"); let calls = 0; installBridge(async () => { calls += 1; return { status: 200, statusText: "OK", headers: [] }; }); - for (const url of ["//evil.example/path", "https://evil.example/path", "https://canvas.invalid/path", "file:///tmp/data", "mailto:test@example.com"]) { - await expect(canvasFetch(url)).rejects.toThrow(); + for (const url of ["//evil.example/path", "https://evil.example/path", "https://artifact.invalid/path", "file:///tmp/data", "mailto:test@example.com"]) { + await expect(artifactFetch(url)).rejects.toThrow(); } const controller = new AbortController(); controller.abort(); - await expect(canvasFetch("/aborted", { signal: controller.signal })).rejects.toThrow(); + await expect(artifactFetch("/aborted", { signal: controller.signal })).rejects.toThrow(); expect(calls).toBe(0); }); diff --git a/src/sdk/server.ts b/src/sdk/server.ts index c2c2a4d..b669445 100644 --- a/src/sdk/server.ts +++ b/src/sdk/server.ts @@ -1,19 +1,19 @@ -export type { CanvasHttpRequest, CanvasHttpResponse } from "../httpTypes"; -import type { CanvasHttpRequest, CanvasHttpResponse } from "../httpTypes"; +export type { ArtifactHttpRequest, ArtifactHttpResponse } from "../httpTypes"; +import type { ArtifactHttpRequest, ArtifactHttpResponse } from "../httpTypes"; type RequestBridge = { - onRequest?: (request: CanvasHttpRequest) => Promise; + onRequest?: (request: ArtifactHttpRequest) => Promise; }; const MAX_REQUEST_BODY_BYTES = 256 * 1024; -const FALLBACK_ORIGIN = "https://canvas.invalid"; +const FALLBACK_ORIGIN = "https://artifact.invalid"; function requestBridge(): RequestBridge { - return (globalThis as typeof globalThis & { __herdrCanvas?: RequestBridge }).__herdrCanvas ?? {}; + return (globalThis as typeof globalThis & { __artifacts?: RequestBridge }).__artifacts ?? {}; } function target(path: string): { url: string; path: string } { - if (path.startsWith("//")) throw new TypeError("canvasFetch does not allow protocol-relative URLs"); + if (path.startsWith("//")) throw new TypeError("artifactFetch does not allow protocol-relative URLs"); const currentOrigin = typeof location !== "undefined" && (location.protocol === "http:" || location.protocol === "https:") ? location.origin : undefined; @@ -22,11 +22,11 @@ function target(path: string): { url: string; path: string } { try { url = new URL(path, browserOrigin); } catch { - throw new TypeError("canvasFetch requires a valid HTTP path"); + throw new TypeError("artifactFetch requires a valid HTTP path"); } - if (url.protocol !== "http:" && url.protocol !== "https:") throw new TypeError("canvasFetch only supports HTTP paths"); + if (url.protocol !== "http:" && url.protocol !== "https:") throw new TypeError("artifactFetch only supports HTTP paths"); const absolute = /^[a-z][a-z\d+.-]*:/i.test(path); - if (absolute && (!currentOrigin || url.origin !== currentOrigin)) throw new TypeError("canvasFetch does not allow cross-origin URLs"); + if (absolute && (!currentOrigin || url.origin !== currentOrigin)) throw new TypeError("artifactFetch does not allow cross-origin URLs"); return { url: url.href, path: `${url.pathname}${url.search}` }; } @@ -50,17 +50,17 @@ function abortReason(signal: AbortSignal): unknown { return signal.reason ?? new DOMException("The operation was aborted.", "AbortError"); } -async function bridgeRequest(bridge: RequestBridge, envelope: CanvasHttpRequest, signal: AbortSignal): Promise { - if (!bridge.onRequest) throw new Error("Canvas server requests are unavailable in this view."); +async function bridgeRequest(bridge: RequestBridge, envelope: ArtifactHttpRequest, signal: AbortSignal): Promise { + if (!bridge.onRequest) throw new Error("Artifact server requests are unavailable in this view."); if (signal.aborted) throw abortReason(signal); - return await new Promise((resolve, reject) => { + return await new Promise((resolve, reject) => { const aborted = () => reject(abortReason(signal)); signal.addEventListener("abort", aborted, { once: true }); bridge.onRequest!(envelope).then(resolve, reject).finally(() => signal.removeEventListener("abort", aborted)); }); } -export async function canvasFetch(path: string, init?: RequestInit): Promise { +export async function artifactFetch(path: string, init?: RequestInit): Promise { const resolved = target(path); const request = new Request(resolved.url, init); // Bun currently clears its generated multipart content-type after consuming @@ -68,9 +68,9 @@ export async function canvasFetch(path: string, init?: RequestInit): Promise MAX_REQUEST_BODY_BYTES) { - throw new TypeError(`canvasFetch request body exceeds ${MAX_REQUEST_BODY_BYTES} bytes`); + throw new TypeError(`artifactFetch request body exceeds ${MAX_REQUEST_BODY_BYTES} bytes`); } - const envelope: CanvasHttpRequest = { + const envelope: ArtifactHttpRequest = { path: resolved.path, method: request.method, headers, diff --git a/src/sdk/tokens.ts b/src/sdk/tokens.ts index 70db4f3..8f83cbf 100644 --- a/src/sdk/tokens.ts +++ b/src/sdk/tokens.ts @@ -1,6 +1,6 @@ export type Tone = "success" | "danger" | "warning" | "info" | "neutral"; -export type CanvasPalette = { +export type ArtifactPalette = { foreground: string; foregroundSecondary: string; foregroundTertiary: string; @@ -28,7 +28,7 @@ export type CanvasPalette = { info: string; }; -export const canvasPaletteDark: CanvasPalette = { +export const artifactPaletteDark: ArtifactPalette = { foreground: "#F0F0F0", foregroundSecondary: "#C2C2C2", foregroundTertiary: "#9A9A9A", @@ -56,7 +56,7 @@ export const canvasPaletteDark: CanvasPalette = { info: "#88C0D0", }; -export const canvasPaletteLight: CanvasPalette = { +export const artifactPaletteLight: ArtifactPalette = { foreground: "#141414", foregroundSecondary: "#3A3A3A", foregroundTertiary: "#5C5C5C", @@ -118,7 +118,7 @@ export type AccentTokens = { control: string; }; -export type CanvasTokens = { +export type ArtifactTokens = { text: TextTokens; bg: SurfaceTokens; fill: FillTokens; @@ -126,13 +126,13 @@ export type CanvasTokens = { accent: AccentTokens; }; -export type CanvasHostTheme = CanvasTokens & { +export type ArtifactHostTheme = ArtifactTokens & { kind: "dark" | "light"; - tokens: CanvasTokens; - palette: CanvasPalette; + tokens: ArtifactTokens; + palette: ArtifactPalette; }; -export function tokensFromPalette(palette: CanvasPalette): CanvasTokens { +export function tokensFromPalette(palette: ArtifactPalette): ArtifactTokens { return { text: { primary: palette.foreground, @@ -166,13 +166,13 @@ export function tokensFromPalette(palette: CanvasPalette): CanvasTokens { }; } -export function themeFromKind(kind: "dark" | "light"): CanvasHostTheme { - const palette = kind === "light" ? canvasPaletteLight : canvasPaletteDark; +export function themeFromKind(kind: "dark" | "light"): ArtifactHostTheme { + const palette = kind === "light" ? artifactPaletteLight : artifactPaletteDark; const tokens = tokensFromPalette(palette); return { kind, ...tokens, tokens, palette }; } -export function toneColor(theme: CanvasHostTheme, tone?: Tone | "success" | "danger" | "warning" | "info"): string { +export function toneColor(theme: ArtifactHostTheme, tone?: Tone | "success" | "danger" | "warning" | "info"): string { if (tone === "success") return theme.palette.success; if (tone === "danger") return theme.palette.danger; if (tone === "warning") return theme.palette.warning; @@ -180,7 +180,7 @@ export function toneColor(theme: CanvasHostTheme, tone?: Tone | "success" | "dan return theme.text.primary; } -export const canvasTypography = { +export const artifactTypography = { h1: { fontSize: "24px", lineHeight: "30px", fontWeight: 590 }, h2: { fontSize: "18px", lineHeight: "24px", fontWeight: 590 }, h3: { fontSize: "16px", lineHeight: "22px", fontWeight: 590 }, diff --git a/src/sdk/ui.tsx b/src/sdk/ui.tsx index ecffa9e..95b9c39 100644 --- a/src/sdk/ui.tsx +++ b/src/sdk/ui.tsx @@ -9,7 +9,7 @@ import { import { Link as RouterLink, type LinkProps } from "react-router"; import { useHostTheme } from "./hooks"; -import { canvasTypography, toneColor, type Tone } from "./tokens"; +import { artifactTypography, toneColor, type Tone } from "./tokens"; const NestedText = createContext(false); @@ -130,7 +130,7 @@ export function Text({ margin: Tag === "p" ? 0 : undefined, color: theme.text[tone], fontFamily: "ui-sans-serif, system-ui, sans-serif", - ...(size === "small" ? canvasTypography.small : canvasTypography.body), + ...(size === "small" ? artifactTypography.small : artifactTypography.body), fontWeight: weights[weight], fontStyle: italic ? "italic" : undefined, overflow: truncate ? "hidden" : undefined, @@ -156,7 +156,7 @@ export function H1({ children, style }: { children?: ReactNode; style?: CSSPrope margin: 0, color: theme.text.primary, fontFamily: "ui-sans-serif, system-ui, sans-serif", - ...canvasTypography.h1, + ...artifactTypography.h1, }, style, )} @@ -175,7 +175,7 @@ export function H2({ children, style }: { children?: ReactNode; style?: CSSPrope margin: 0, color: theme.text.primary, fontFamily: "ui-sans-serif, system-ui, sans-serif", - ...canvasTypography.h2, + ...artifactTypography.h2, }, style, )} @@ -194,7 +194,7 @@ export function H3({ children, style }: { children?: ReactNode; style?: CSSPrope margin: 0, color: theme.text.primary, fontFamily: "ui-sans-serif, system-ui, sans-serif", - ...canvasTypography.h3, + ...artifactTypography.h3, }, style, )} diff --git a/src/serve.test.ts b/src/serve.test.ts index 251b8ed..6ec759b 100644 --- a/src/serve.test.ts +++ b/src/serve.test.ts @@ -1,13 +1,13 @@ import { expect, test } from "bun:test"; import { join } from "node:path"; -import { createCanvasServer } from "./serve"; -import { VALID_CANVAS, tempDir, writeCanvas } from "./test/fixtures"; +import { createArtifactServer } from "./serve"; +import { VALID_ARTIFACT, tempDir, writeArtifact } from "./test/fixtures"; -test("createCanvasServer serves compiled canvas HTML and JS", async () => { +test("createArtifactServer serves compiled artifact HTML and JS", async () => { const dir = tempDir(); - writeCanvas(dir, "overview", VALID_CANVAS); - const server = await createCanvasServer({ canvasesDir: dir, historyPath: join(dir, "history.sqlite") }); + writeArtifact(dir, "overview", VALID_ARTIFACT); + const server = await createArtifactServer({ artifactsDir: dir, historyPath: join(dir, "history.sqlite") }); try { const health = await fetch(`${server.url}/health`); expect(health.ok).toBe(true); @@ -16,7 +16,7 @@ test("createCanvasServer serves compiled canvas HTML and JS", async () => { expect(page.status).toBe(200); const html = await page.text(); expect(html).toContain("bundle.js"); - expect(html).toContain("__herdrCanvas"); + expect(html).toContain("__artifacts"); const js = await fetch(`${server.url}/c/overview/bundle.js`); expect(js.status).toBe(200); @@ -30,10 +30,10 @@ test("createCanvasServer serves compiled canvas HTML and JS", async () => { } }); -test("createCanvasServer persists canvas state", async () => { +test("createArtifactServer persists artifact state", async () => { const dir = tempDir(); - writeCanvas(dir, "overview", VALID_CANVAS); - const server = await createCanvasServer({ canvasesDir: dir, historyPath: join(dir, "history.sqlite") }); + writeArtifact(dir, "overview", VALID_ARTIFACT); + const server = await createArtifactServer({ artifactsDir: dir, historyPath: join(dir, "history.sqlite") }); try { const put = await fetch(`${server.url}/c/overview/state`, { method: "PUT", diff --git a/src/serve.ts b/src/serve.ts index 024b5aa..59316e1 100644 --- a/src/serve.ts +++ b/src/serve.ts @@ -1,34 +1,35 @@ +import { resolveArtifactFile } from "./artifactFile"; import { readLocalProject } from "./localProject"; import { projectSourceHash, type ArtifactProject } from "../cloudflare/project"; import { existsSync, readFileSync, writeFileSync } from "node:fs"; import { join, resolve } from "node:path"; -import { assertRegularCanvas, ensureCanvasFileName } from "./canvasFile"; -import { compileCanvas } from "./compile"; -import { CanvasHistory, hash, historyPath, runtimeIdentity, type Version } from "./history"; -import { CanvasService } from "./service"; -import { canvasHtml } from "./html"; +import { assertRegularArtifact, ensureArtifactFileName } from "./artifactFile"; +import { compileArtifact } from "./compile"; +import { ArtifactHistory, hash, historyPath, runtimeIdentity, type Version } from "./history"; +import { ArtifactService } from "./service"; +import { artifactHtml } from "./html"; import { galleryBundle, galleryData, galleryHtml, workingSource } from "./gallery/server"; -export type CanvasServer = { url: string; port: number; stop: () => void }; -export type CanvasActionEvent = { type: string; [key: string]: unknown }; -export type CreateCanvasServerOptions = { - canvasesDir: string; +export type ArtifactServer = { url: string; port: number; stop: () => void }; +export type ArtifactActionEvent = { type: string; [key: string]: unknown }; +export type CreateArtifactServerOptions = { + artifactsDir: string; port?: number; hostname?: string; historyPath?: string; env?: NodeJS.ProcessEnv; - onAction?: (canvasId: string, action: CanvasActionEvent) => void; - compile?: typeof compileCanvas; + onAction?: (artifactId: string, action: ArtifactActionEvent) => void; + compile?: typeof compileArtifact; runtimeIdentity?: typeof runtimeIdentity; gallery?: boolean; }; -export async function createCanvasServer(opts: CreateCanvasServerOptions): Promise { - const compile = opts.compile ?? compileCanvas; - const canvasesDir = resolve(opts.canvasesDir); +export async function createArtifactServer(opts: CreateArtifactServerOptions): Promise { + const compile = opts.compile ?? compileArtifact; + const artifactsDir = resolve(opts.artifactsDir); const env = opts.env ?? process.env; - const history = new CanvasHistory(opts.historyPath ?? historyPath(env)); + const history = new ArtifactHistory(opts.historyPath ?? historyPath(env)); const identifyRuntime = opts.runtimeIdentity ?? runtimeIdentity; let galleryJs: Promise | undefined; const states = new Map>(); @@ -44,7 +45,7 @@ export async function createCanvasServer(opts: CreateCanvasServerOptions): Promi if (requestedRuntime && requestedRuntime !== runtimeHash) return Promise.resolve(undefined); pending = compile(path, source, undefined, project).then(result => { if (!result.ok || !result.js) { builds.delete(key); return undefined; } - if (identifyRuntime() !== runtime) { builds.delete(key); throw new Error("Canvas SDK changed during compilation; reload to retry"); } + if (identifyRuntime() !== runtime) { builds.delete(key); throw new Error("Artifact SDK changed during compilation; reload to retry"); } return { js: result.js, runtime, project }; }).catch(error => { builds.delete(key); throw error; }); builds.set(key, pending); @@ -56,13 +57,13 @@ export async function createCanvasServer(opts: CreateCanvasServerOptions): Promi function versionPage(version: Version, state: Record, replay: boolean, runtime: string, inlineBundle?: string): Response { const eventId = history.served(version.id, state, inlineBundle ? "preview" : replay ? "replay" : "live", env, runtime); const base = "/c/" + encodeURIComponent(version.name); - return new Response(canvasHtml({ + return new Response(artifactHtml({ title: version.name, - canvasId: version.name, + artifactId: version.name, versionId: version.id, eventId, // A self-contained data module lets gallery previews run in an opaque sandbox - // without granting canvas code access to the gallery or exposing bundle CORS. + // without granting artifact code access to the gallery or exposing bundle CORS. scriptUrl: inlineBundle ? `data:text/javascript;base64,${Buffer.from(inlineBundle).toString("base64")}` : "/v/" + version.id + "/bundle.js?runtime=" + hash(runtime), persistUrl: replay ? undefined : base + "/state", actionUrl: replay ? undefined : base + "/action", @@ -83,7 +84,7 @@ export async function createCanvasServer(opts: CreateCanvasServerOptions): Promi if (url.pathname === "/health") return json({ ok: true }); if (opts.gallery && url.pathname === "/api/tools" && request.method === "POST") { - // The gallery is a loopback UI. Untrusted websites and opaque canvas + // The gallery is a loopback UI. Untrusted websites and opaque artifact // previews must not be able to create local files through this endpoint. const allowedHosts = new Set([opts.hostname ?? "127.0.0.1"]); if (["127.0.0.1", "::1", "localhost"].includes(opts.hostname ?? "127.0.0.1")) { @@ -93,11 +94,11 @@ export async function createCanvasServer(opts: CreateCanvasServerOptions): Promi return json({ ok: false, error: "same-origin JSON request required" }, 403); } const workspace = url.searchParams.get("workspace"); - if (workspace && resolve(workspace) !== canvasesDir) return json({ ok: false, error: "open this workspace's gallery to remix its canvas" }, 400); - const body = await request.json() as { name?: string; arguments?: Parameters[0] }; - if (body.name !== "canvas_remix" || !body.arguments) return json({ ok: false, error: "unknown tool" }, 400); + if (workspace && resolve(workspace) !== artifactsDir) return json({ ok: false, error: "open this workspace's gallery to remix its artifact" }, 400); + const body = await request.json() as { name?: string; arguments?: Parameters[0] }; + if (body.name !== "artifact_remix" || !body.arguments) return json({ ok: false, error: "unknown tool" }, 400); try { - const service = new CanvasService({ canvasesDir, env: { ...env, HERDR_CANVAS_HISTORY_DB: history.path } }); + const service = new ArtifactService({ artifactsDir, env: { ...env, ARTIFACTS_HISTORY_DB: history.path } }); return json(service.remix(body.arguments)); } catch (error) { return json({ ok: false, error: error instanceof Error ? error.message : String(error) }, 400); } } @@ -108,7 +109,7 @@ export async function createCanvasServer(opts: CreateCanvasServerOptions): Promi galleryJs ??= galleryBundle().catch(error => { galleryJs = undefined; throw error; }); return javascript(await galleryJs); } - if (url.pathname === "/api/gallery") return json(galleryData(history, canvasesDir, url.searchParams.get("all") === "1")); + if (url.pathname === "/api/gallery") return json(galleryData(history, artifactsDir, url.searchParams.get("all") === "1")); if (url.pathname === "/api/source" || url.pathname === "/gallery/preview") { const versionId = url.searchParams.get("version"); const name = url.searchParams.get("name"); @@ -117,28 +118,28 @@ export async function createCanvasServer(opts: CreateCanvasServerOptions): Promi if (versionId && !version) return new Response("version not found", { status: 404 }); let source: string; let sourcePath: string; - let canvasName: string; + let artifactName: string; try { - const selected = version ? { source: version.source, path: version.source_path, name: version.name } : workingSource(canvasesDir, name!); - source = selected.source; sourcePath = selected.path; canvasName = selected.name; - } catch { return new Response("working canvas not found or not a regular file", { status: 404 }); } + const selected = version ? { source: version.source, path: version.source_path, name: version.name } : workingSource(artifactsDir, name!); + source = selected.source; sourcePath = selected.path; artifactName = selected.name; + } catch { return new Response("working artifact not found or not a regular file", { status: 404 }); } if (url.pathname === "/api/source") { return new Response(source, { headers: { "content-type": "text/plain; charset=utf-8", "cache-control": "no-store", "x-content-type-options": "nosniff", - ...(url.searchParams.get("download") === "1" ? { "content-disposition": `attachment; filename="canvas.canvas.tsx"; filename*=UTF-8''${encodeURIComponent(canvasName + ".canvas.tsx")}` } : {}), + ...(url.searchParams.get("download") === "1" ? { "content-disposition": `attachment; filename="artifact.artifact.tsx"; filename*=UTF-8''${encodeURIComponent(artifactName + ".artifact.tsx")}` } : {}), } }); } const compiled = await build(sourcePath, source, undefined, version?.project); if (!compiled) return new Response("This source does not compile with the installed SDK.", { status: 400 }); - const initialState = version ? (() => { const events = history.events(version.id); const event = events.find(item => item.mode === "live") ?? events[0]; return event ? JSON.parse(event.initial_state) : {}; })() : loadState(sourcePath, states, canvasName); - version ??= history.capture({ workspace: canvasesDir, name: canvasName, sourcePath, source, project: compiled.project, runtime: compiled.runtime }); + const initialState = version ? (() => { const events = history.events(version.id); const event = events.find(item => item.mode === "live") ?? events[0]; return event ? JSON.parse(event.initial_state) : {}; })() : loadState(sourcePath, states, artifactName); + version ??= history.capture({ workspace: artifactsDir, name: artifactName, sourcePath, source, project: compiled.project, runtime: compiled.runtime }); return versionPage(version, initialState, true, compiled.runtime, compiled.js); } } const archived = url.pathname.match(/^\/v\/([a-f0-9-]+)(\/bundle\.js)?$/); if (archived && request.method === "GET") { - const version = history.version(archived[1], canvasesDir); + const version = history.version(archived[1], artifactsDir); if (!version) return new Response("version not found", { status: 404 }); const requestedRuntime = archived[2] ? url.searchParams.get("runtime") ?? undefined : undefined; const compiled = await build(version.source_path, version.source, requestedRuntime, version.project); @@ -153,42 +154,42 @@ export async function createCanvasServer(opts: CreateCanvasServerOptions): Promi const match = url.pathname.match(/^\/c\/([^/]+)(?:\/(.*))?$/); if (!match) return new Response("not found", { status: 404 }); - let canvasId: string; + let artifactId: string; try { - canvasId = decodeURIComponent(match[1]); - ensureCanvasFileName(canvasId); - } catch { return new Response("invalid canvas name", { status: 400 }); } + artifactId = decodeURIComponent(match[1]); + ensureArtifactFileName(artifactId); + } catch { return new Response("invalid artifact name", { status: 400 }); } const rest = match[2] ?? ""; - const filePath = join(canvasesDir, canvasId + ".canvas.tsx"); - if (!existsSync(filePath)) return new Response("canvas not found", { status: 404 }); - try { assertRegularCanvas(filePath); } - catch { return new Response("canvas source must be a regular file", { status: 400 }); } + const filePath = resolveArtifactFile(artifactId, artifactsDir); + if (!existsSync(filePath)) return new Response("artifact not found", { status: 404 }); + try { assertRegularArtifact(filePath); } + catch { return new Response("artifact source must be a regular file", { status: 400 }); } if (rest === "mtime" && request.method === "GET") { // Content identity also detects editors that preserve modification timestamps. return json({ mtime: hash(readFileSync(filePath, "utf8")) }); } - if (rest === "state" && request.method === "GET") return json(loadState(filePath, states, canvasId)); + if (rest === "state" && request.method === "GET") return json(loadState(filePath, states, artifactId)); if (rest === "state" && request.method === "PUT") { const body = (await request.json()) as { key?: string; value?: unknown }; if (typeof body.key !== "string" || !body.key || ["__proto__", "constructor", "prototype"].includes(body.key)) return json({ ok: false, error: "invalid key" }, 400); - const current = { ...loadState(filePath, states, canvasId), [body.key]: body.value }; + const current = { ...loadState(filePath, states, artifactId), [body.key]: body.value }; writeFileSync(stateSidecar(filePath), JSON.stringify(current, null, 2) + "\n"); - states.set(canvasId, current); + states.set(artifactId, current); return json({ ok: true }); } if (rest === "action" && request.method === "POST") { - opts.onAction?.(canvasId, (await request.json()) as CanvasActionEvent); + opts.onAction?.(artifactId, (await request.json()) as ArtifactActionEvent); return json({ ok: true }); } - if (request.method === "GET" && ["", "bundle.js", canvasId + ".js"].includes(rest)) { + if (request.method === "GET" && ["", "bundle.js", artifactId + ".js"].includes(rest)) { const source = readFileSync(filePath, "utf8"); const compiled = await build(filePath, source); if (!compiled) return new Response("compile failed", { status: 400 }); - const version = history.capture({ workspace: canvasesDir, name: canvasId, sourcePath: filePath, source, project: compiled.project, runtime: compiled.runtime }); + const version = history.capture({ workspace: artifactsDir, name: artifactId, sourcePath: filePath, source, project: compiled.project, runtime: compiled.runtime }); if (rest) return javascript(compiled.js); - return versionPage(version, loadState(filePath, states, canvasId), false, compiled.runtime); + return versionPage(version, loadState(filePath, states, artifactId), false, compiled.runtime); } return new Response("not found", { status: 404 }); } catch (error) { @@ -215,22 +216,22 @@ export async function createCanvasServer(opts: CreateCanvasServerOptions): Promi function javascript(js: string): Response { return new Response(js, { headers: { "content-type": "text/javascript; charset=utf-8", "cache-control": "no-store" } }); } -function stateSidecar(canvasPath: string): string { return canvasPath.replace(/\.canvas\.tsx$/, ".canvas.data.json"); } -function loadState(filePath: string, states: Map>, canvasId: string): Record { - const cached = states.get(canvasId); +function stateSidecar(artifactPath: string): string { return artifactPath.replace(/\.tsx$/, ".data.json"); } +function loadState(filePath: string, states: Map>, artifactId: string): Record { + const cached = states.get(artifactId); if (cached) return cached; const sidecar = stateSidecar(filePath); if (existsSync(sidecar)) { try { const parsed: unknown = JSON.parse(readFileSync(sidecar, "utf8")); if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { - states.set(canvasId, parsed as Record); + states.set(artifactId, parsed as Record); return parsed as Record; } } catch { /* Keep malformed sidecars untouched; start with empty UI state. */ } } const empty = {}; - states.set(canvasId, empty); + states.set(artifactId, empty); return empty; } function json(value: unknown, status = 200): Response { diff --git a/src/service.test.ts b/src/service.test.ts index 40c318c..6327029 100644 --- a/src/service.test.ts +++ b/src/service.test.ts @@ -1,21 +1,21 @@ import { expect, test } from "bun:test"; -import { CanvasService } from "./service"; -import { VALID_CANVAS, tempDir } from "./test/fixtures"; +import { ArtifactService } from "./service"; +import { VALID_ARTIFACT, tempDir } from "./test/fixtures"; -test("CanvasService write typechecks and list returns the file", () => { +test("ArtifactService write typechecks and list returns the file", () => { const dir = tempDir(); - const service = new CanvasService({ canvasesDir: dir, cwd: dir }); - const written = service.write("overview", VALID_CANVAS); + const service = new ArtifactService({ artifactsDir: dir, cwd: dir }); + const written = service.write("overview", VALID_ARTIFACT); expect(written.ok).toBe(true); - expect(written.check).toBe("Canvas TypeScript check: no errors"); + expect(written.check).toBe("Artifact TypeScript check: no errors"); expect(service.list().map((item) => item.id)).toEqual(["overview"]); }); -test("CanvasService compile reports bytes for a valid canvas", async () => { +test("ArtifactService compile reports bytes for a valid artifact", async () => { const dir = tempDir(); - const service = new CanvasService({ canvasesDir: dir, cwd: dir }); - service.write("overview", VALID_CANVAS); + const service = new ArtifactService({ artifactsDir: dir, cwd: dir }); + service.write("overview", VALID_ARTIFACT); const compiled = await service.compile("overview"); expect(compiled.ok).toBe(true); expect(compiled.js?.length).toBeGreaterThan(100); diff --git a/src/service.ts b/src/service.ts index f58fd4e..a03258f 100644 --- a/src/service.ts +++ b/src/service.ts @@ -4,27 +4,27 @@ import { PLUGIN_ROOT } from "./paths"; import { readLocalProject, writeLocalProject, localProjectPath } from "./localProject"; import { resolveProject } from "../cloudflare/project"; import { existsSync, lstatSync, mkdirSync, readFileSync, writeFileSync, unlinkSync } from "node:fs"; -import { basename, resolve } from "node:path"; +import { basename, dirname, resolve } from "node:path"; import { createHash } from "node:crypto"; import { - canvasesDirFrom, - canvasIdFromFile, - ensureCanvasesDir, - listCanvasFiles, - resolveCanvasFile, - assertRegularCanvas, - replaceCanvasSource, - ensureCanvasFileName, -} from "./canvasFile"; -import { compileCanvas, type CompileResult } from "./compile"; -import { formatCanvasCheck, type Diagnostic } from "./diagnostics"; + artifactsDirFrom, + artifactIdFromFile, + ensureArtifactsDir, + listArtifactFiles, + resolveArtifactFile, + assertRegularArtifact, + replaceArtifactSource, + ensureArtifactFileName, +} from "./artifactFile"; +import { compileArtifact, type CompileResult } from "./compile"; +import { formatArtifactCheck, type Diagnostic } from "./diagnostics"; import { createHerdrClient, type HerdrClient, type PanePlacement } from "./herdr"; -import { openCanvas, type OpenResult } from "./open"; -import { typecheckCanvas } from "./typecheck"; -import { CanvasHistory, historyPath, runtimeIdentity } from "./history"; +import { openArtifact, type OpenResult } from "./open"; +import { typecheckArtifact } from "./typecheck"; +import { ArtifactHistory, historyPath, runtimeIdentity } from "./history"; -export type CanvasInfo = { +export type ArtifactInfo = { id: string; path: string; name: string; @@ -37,7 +37,7 @@ export type WriteResult = { diagnostics: Diagnostic[]; }; -export type CanvasEdit = { old_text: string; new_text: string }; +export type ArtifactEdit = { old_text: string; new_text: string }; export type ReadOptions = { file?: string; start_line?: number; end_line?: number }; function sourceHash(source: string): string { @@ -45,58 +45,58 @@ function sourceHash(source: string): string { } function projectFile(files: Record, file: string): string { - if (typeof file !== "string" || !Object.hasOwn(files, file)) throw new Error("project file not found; select a path returned by canvas_read.project.files"); + if (typeof file !== "string" || !Object.hasOwn(files, file)) throw new Error("project file not found; select a path returned by artifact_read.project.files"); return files[file]!; } -export type CanvasServiceOptions = { +export type ArtifactServiceOptions = { cwd?: string; env?: NodeJS.ProcessEnv; - canvasesDir?: string; + artifactsDir?: string; herdr?: HerdrClient; inProcessServer?: boolean; }; -export class CanvasService { - readonly canvasesDir: string; +export class ArtifactService { + readonly artifactsDir: string; readonly cwd: string; readonly env: NodeJS.ProcessEnv; readonly herdr?: HerdrClient; readonly inProcessServer: boolean; - constructor(opts: CanvasServiceOptions = {}) { + constructor(opts: ArtifactServiceOptions = {}) { this.cwd = opts.cwd ?? process.cwd(); this.env = opts.env ?? process.env; - this.canvasesDir = opts.canvasesDir ?? canvasesDirFrom(this.cwd, this.env); + this.artifactsDir = opts.artifactsDir ?? artifactsDirFrom(this.cwd, this.env); this.herdr = opts.herdr; this.inProcessServer = opts.inProcessServer ?? false; } - list(): CanvasInfo[] { - return listCanvasFiles(this.canvasesDir).map((path) => ({ - id: canvasIdFromFile(path), + list(): ArtifactInfo[] { + return listArtifactFiles(this.artifactsDir).map((path) => ({ + id: artifactIdFromFile(path), path, name: basename(path), })); } resolve(input: string): string { - return resolveCanvasFile(input, this.canvasesDir, this.cwd); + return resolveArtifactFile(input, this.artifactsDir, this.cwd); } write(input: string, contents: string): WriteResult { - ensureCanvasesDir(this.canvasesDir); + ensureArtifactsDir(this.artifactsDir); const path = this.resolve(input); - mkdirSync(this.canvasesDir, { recursive: true }); + mkdirSync(this.artifactsDir, { recursive: true }); let current: string | undefined; - try { assertRegularCanvas(path); current = readFileSync(path, "utf8"); } + try { assertRegularArtifact(path); current = readFileSync(path, "utf8"); } catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; } - replaceCanvasSource(path, contents.endsWith("\n") ? contents : `${contents}\n`, current, "write"); - const diagnostics = typecheckCanvas(path); + replaceArtifactSource(path, contents.endsWith("\n") ? contents : `${contents}\n`, current, "write"); + const diagnostics = typecheckArtifact(path); return { ok: diagnostics.length === 0, path, - check: formatCanvasCheck(diagnostics), + check: formatArtifactCheck(diagnostics), diagnostics, }; } @@ -105,16 +105,16 @@ export class CanvasService { const path = this.resolve(input); const require = createRequire(join(PLUGIN_ROOT, "package.json")); const hostPackages = Object.fromEntries(["react", "react-dom", "react-router"].map(name => [name, require(`${name}/package.json`).version as string])); - try { assertRegularCanvas(path); } + try { assertRegularArtifact(path); } catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; } const previousSource = existsSync(path) ? readFileSync(path, "utf8") : undefined; const previousProject = readLocalProject(path); const resolved = await resolveProject(project, previousProject, fetch, hostPackages); if ((existsSync(path) ? readFileSync(path, "utf8") : undefined) !== previousSource || JSON.stringify(readLocalProject(path)) !== JSON.stringify(previousProject)) { - throw new Error("canvas changed while resolving dependencies; read it again before writing"); + throw new Error("artifact changed while resolving dependencies; read it again before writing"); } - if (existsSync(path)) assertRegularCanvas(path); - ensureCanvasesDir(this.canvasesDir); + if (existsSync(path)) assertRegularArtifact(path); + ensureArtifactsDir(this.artifactsDir); writeLocalProject(path, resolved); return this.write(input, contents); } @@ -124,8 +124,9 @@ export class CanvasService { } readRange(input: string, opts: ReadOptions = {}) { - const path = this.resolve(ensureCanvasFileName(input)); - assertRegularCanvas(path); + ensureArtifactFileName(input); + const path = this.resolve(input); + assertRegularArtifact(path); const project = readLocalProject(path); const source = opts.file === undefined ? readFileSync(path, "utf8") : projectFile(project.files, opts.file); // Keep line endings intact so returned source can be used in an exact edit. @@ -135,7 +136,7 @@ export class CanvasService { if (!Number.isSafeInteger(start) || start < 1 || !Number.isSafeInteger(end) || end < start) { throw new Error("line range must contain positive integers with end_line >= start_line"); } - if (start > Math.max(1, lines.length)) throw new Error("start_line is past the end of the canvas"); + if (start > Math.max(1, lines.length)) throw new Error("start_line is past the end of the artifact"); const actualEnd = Math.min(end, lines.length); return { project, file: opts.file, @@ -146,18 +147,19 @@ export class CanvasService { }; } - edit(input: string, edits: CanvasEdit[], expectedHash?: string, file?: string) { + edit(input: string, edits: ArtifactEdit[], expectedHash?: string, file?: string) { if (!Array.isArray(edits) || edits.length === 0) throw new Error("edits must be a non-empty array"); if (expectedHash !== undefined && (typeof expectedHash !== "string" || !/^[a-f0-9]{64}$/.test(expectedHash))) { throw new Error("expected_hash must be a SHA-256 hex string"); } - const path = this.resolve(ensureCanvasFileName(input)); - assertRegularCanvas(path); + ensureArtifactFileName(input); + const path = this.resolve(input); + assertRegularArtifact(path); const rawProject = file === undefined ? undefined : readFileSync(localProjectPath(path), "utf8"); const project = readLocalProject(path); const original = file === undefined ? readFileSync(path, "utf8") : projectFile(project.files, file); if (expectedHash !== undefined && sourceHash(original) !== expectedHash) { - throw new Error("canvas changed since read; read it again before editing"); + throw new Error("artifact changed since read; read it again before editing"); } let source = original; for (const [index, edit] of edits.entries()) { @@ -170,29 +172,29 @@ export class CanvasService { source = source.slice(0, at) + edit.new_text + source.slice(at + edit.old_text.length); } const changed = source !== original; - if (changed && file === undefined) replaceCanvasSource(path, source, original, "edit"); + if (changed && file === undefined) replaceArtifactSource(path, source, original, "edit"); if (changed && file !== undefined) { const updated = { ...project, files: { ...project.files, [file]: source } }; - replaceCanvasSource(localProjectPath(path), JSON.stringify(updated, null, 2) + "\n", rawProject, "project edit"); + replaceArtifactSource(localProjectPath(path), JSON.stringify(updated, null, 2) + "\n", rawProject, "project edit"); } - const diagnostics = typecheckCanvas(path); + const diagnostics = typecheckArtifact(path); return { ok: diagnostics.length === 0, applied: true, changed, file, path: file ?? path, edits_applied: edits.length, source_hash: sourceHash(source), - check: formatCanvasCheck(diagnostics), diagnostics, + check: formatArtifactCheck(diagnostics), diagnostics, }; } history(name?: string) { - const archive = new CanvasHistory(historyPath(this.env)); - try { return archive.list(this.canvasesDir, name ? canvasIdFromFile(this.resolve(name)) : undefined); } + const archive = new ArtifactHistory(historyPath(this.env)); + try { return archive.list(this.artifactsDir, name ? artifactIdFromFile(this.resolve(name)) : undefined); } finally { archive.close(); } } version(id: string) { - const archive = new CanvasHistory(historyPath(this.env)); + const archive = new ArtifactHistory(historyPath(this.env)); try { - const version = archive.version(id, this.canvasesDir); + const version = archive.version(id, this.artifactsDir); if (!version) throw new Error("version not found in this workspace"); return { ...version, origin: archive.origin(version.artifact_id), events: archive.events(id) }; } finally { archive.close(); } @@ -203,80 +205,86 @@ export class CanvasService { if ((input.name !== undefined) === (input.version_id !== undefined)) throw new Error("provide name or version_id, but not both"); if (input.name !== undefined && typeof input.name !== "string") throw new Error("name must be a string"); if (input.version_id !== undefined && (typeof input.version_id !== "string" || !input.version_id)) throw new Error("version_id must be a non-empty string"); - const path = this.resolve(ensureCanvasFileName(input.new_name)); - const name = canvasIdFromFile(path); - const archive = new CanvasHistory(historyPath(this.env)); + const path = this.resolve(ensureArtifactFileName(input.new_name)); + const name = artifactIdFromFile(path); + const archive = new ArtifactHistory(historyPath(this.env)); try { const revision = archive.db.transaction(() => { - if (archive.db.query("select id from artifacts where workspace = ? and name = ?").get(resolve(this.canvasesDir), name)) { - throw new Error("destination canvas already exists in history; choose a new name"); + if (archive.db.query("select id from artifacts where workspace = ? and name = ?").get(resolve(this.artifactsDir), name)) { + throw new Error("destination artifact already exists in history; choose a new name"); } // Reject orphaned state and dangling links too: a remix always starts fresh. - for (const candidate of [path, localProjectPath(path), path.replace(/\.canvas\.tsx$/, ".canvas.data.json")]) { + const destinations = [join(this.artifactsDir, `${name}.artifact.tsx`), join(this.artifactsDir, `${name}.canvas.tsx`)]; + for (const candidate of destinations.flatMap(source => [source, localProjectPath(source), source.replace(/\.tsx$/, ".data.json")])) { try { lstatSync(candidate); } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") continue; throw error; } - throw new Error("destination canvas or state already exists; choose a new name"); + throw new Error("destination artifact or state already exists; choose a new name"); } const runtime = runtimeIdentity(); let sourceVersion; if (input.version_id !== undefined) { - sourceVersion = archive.version(input.version_id, this.canvasesDir); + sourceVersion = archive.version(input.version_id, this.artifactsDir); if (!sourceVersion) throw new Error("version not found in this workspace"); } else { - const sourcePath = this.resolve(ensureCanvasFileName(input.name!)); - assertRegularCanvas(sourcePath); - sourceVersion = archive.capture({ workspace: this.canvasesDir, name: canvasIdFromFile(sourcePath), sourcePath, + ensureArtifactFileName(input.name!); + const sourcePath = this.resolve(input.name!); + assertRegularArtifact(sourcePath); + sourceVersion = archive.capture({ workspace: this.artifactsDir, name: artifactIdFromFile(sourcePath), sourcePath, source: readFileSync(sourcePath, "utf8"), runtime, reason: "before-remix" }); } - const version = archive.capture({ workspace: this.canvasesDir, name, sourcePath: path, + const version = archive.capture({ workspace: this.artifactsDir, name, sourcePath: path, source: sourceVersion.source, project: sourceVersion.project, runtime, reason: "remix" }); archive.db.query("insert into artifact_remixes values (?, ?)").run(version.artifact_id, sourceVersion.id); - ensureCanvasesDir(this.canvasesDir); + ensureArtifactsDir(this.artifactsDir); // Exclusive creation never follows or replaces a competing destination link/file. writeLocalProject(path, sourceVersion.project!, true); try { writeFileSync(path, sourceVersion.source, { flag: "wx" }); } catch (error) { unlinkSync(localProjectPath(path)); throw error; } return version; }).immediate(); - const diagnostics = typecheckCanvas(path); + const diagnostics = typecheckArtifact(path); return { ok: diagnostics.length === 0, remixed: true, name, path, versionId: revision.id, - origin: archive.origin(revision.artifact_id), check: formatCanvasCheck(diagnostics), diagnostics }; + origin: archive.origin(revision.artifact_id), check: formatArtifactCheck(diagnostics), diagnostics }; } finally { archive.close(); } } restore(id: string) { - const archive = new CanvasHistory(historyPath(this.env)); + const archive = new ArtifactHistory(historyPath(this.env)); try { - const target = archive.version(id, this.canvasesDir); + const target = archive.version(id, this.artifactsDir); if (!target) throw new Error("version not found in this workspace"); - const path = resolveCanvasFile(target.name, this.canvasesDir, this.cwd); + let path = resolveArtifactFile(target.name, this.artifactsDir, this.cwd); + // A deleted legacy source must be recreated next to its original sidecars. + if (!existsSync(path) && dirname(resolve(target.source_path)) === resolve(this.artifactsDir) && artifactIdFromFile(target.source_path) === target.name) { + path = resolve(target.source_path); + } const runtime = runtimeIdentity(); // Commit the working copy to history before overwriting it, including invalid drafts. let current: string | undefined; if (existsSync(path)) { - assertRegularCanvas(path); + assertRegularArtifact(path); current = readFileSync(path, "utf8"); - archive.capture({ workspace: this.canvasesDir, name: target.name, sourcePath: path, source: current, runtime, reason: "before-restore" }); + archive.capture({ workspace: this.artifactsDir, name: target.name, sourcePath: path, source: current, runtime, reason: "before-restore" }); } - ensureCanvasesDir(this.canvasesDir); - replaceCanvasSource(path, target.source, current); + ensureArtifactsDir(this.artifactsDir); + replaceArtifactSource(path, target.source, current); writeLocalProject(path, target.project!); - const revision = archive.capture({ workspace: this.canvasesDir, name: target.name, sourcePath: path, source: target.source, runtime, reason: "restore", restoredFrom: id, force: true }); - const diagnostics = typecheckCanvas(path); - return { ok: diagnostics.length === 0, restored: true, path, versionId: revision.id, revision: revision.revision, check: formatCanvasCheck(diagnostics), diagnostics }; + const revision = archive.capture({ workspace: this.artifactsDir, name: target.name, sourcePath: path, source: target.source, runtime, reason: "restore", restoredFrom: id, force: true }); + const diagnostics = typecheckArtifact(path); + return { ok: diagnostics.length === 0, restored: true, path, versionId: revision.id, revision: revision.revision, check: formatArtifactCheck(diagnostics), diagnostics }; } finally { archive.close(); } } typecheck(input: string): { check: string; diagnostics: Diagnostic[]; path: string } { const path = this.resolve(input); - const diagnostics = typecheckCanvas(path); - return { path, diagnostics, check: formatCanvasCheck(diagnostics) }; + const diagnostics = typecheckArtifact(path); + return { path, diagnostics, check: formatArtifactCheck(diagnostics) }; } async compile(input: string): Promise { const path = this.resolve(input); - const result = await compileCanvas(path); - return { ...result, path, check: formatCanvasCheck(result.diagnostics) }; + const result = await compileArtifact(path); + return { ...result, path, check: formatArtifactCheck(result.diagnostics) }; } async open( @@ -291,8 +299,8 @@ export class CanvasService { } = {}, ): Promise { const path = opts.versionId ? this.version(opts.versionId).source_path : this.resolve(input); - const result = await openCanvas(path, { - canvasesDir: this.canvasesDir, + const result = await openArtifact(path, { + artifactsDir: this.artifactsDir, herdr: this.herdr ?? createHerdrClient(this.env), env: this.env, inProcessServer: this.inProcessServer, diff --git a/src/test/fixtures.ts b/src/test/fixtures.ts index c36bd5e..c9692ad 100644 --- a/src/test/fixtures.ts +++ b/src/test/fixtures.ts @@ -2,7 +2,7 @@ import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -export const VALID_CANVAS = `import { H1, Stack, Stat, Text } from "sidequery/canvas"; +export const VALID_ARTIFACT = `import { H1, Stack, Stat, Text } from "sidequery/artifacts"; export default function Overview() { return ( @@ -15,15 +15,15 @@ export default function Overview() { } `; -export const FORBIDDEN_IMPORT_CANVAS = `import fs from "node:fs"; -import { H1 } from "sidequery/canvas"; +export const FORBIDDEN_IMPORT_ARTIFACT = `import fs from "node:fs"; +import { H1 } from "sidequery/artifacts"; export default function Bad() { return

{fs.readFileSync("secrets")}

; } `; -export const FETCH_CANVAS = `import { H1 } from "sidequery/canvas"; +export const FETCH_ARTIFACT = `import { H1 } from "sidequery/artifacts"; export default function Bad() { fetch("https://example.com"); @@ -31,27 +31,27 @@ export default function Bad() { } `; -export const BAD_TYPE_CANVAS = `import { Stack } from "sidequery/canvas"; +export const BAD_TYPE_ARTIFACT = `import { Stack } from "sidequery/artifacts"; export default function Bad() { return nope; } `; -export function tempDir(prefix = "herdr-canvas-"): string { +export function tempDir(prefix = "artifacts-"): string { return mkdtempSync(join(tmpdir(), prefix)); } -export function writeCanvas(dir: string, name: string, source: string): string { +export function writeArtifact(dir: string, name: string, source: string): string { mkdirSync(dir, { recursive: true }); - const fileName = name.endsWith(".canvas.tsx") ? name : `${name}.canvas.tsx`; + const fileName = name.endsWith(".artifact.tsx") ? name : `${name}.artifact.tsx`; const path = join(dir, fileName); writeFileSync(path, source); return path; } // Exercised through both compilers and real browser runtimes. -export const HOOKS_CANVAS = `import { useState, useReducer, useRef, useMemo, useCallback, useEffect } from "sidequery/canvas"; +export const HOOKS_ARTIFACT = `import { useState, useReducer, useRef, useMemo, useCallback, useEffect } from "sidequery/artifacts"; export default function Hooks() { const [count, setCount] = useState(0); const [total, add] = useReducer((value: number, amount: number) => value + amount, 0); diff --git a/src/test/plugins/browser.tsx b/src/test/plugins/browser.tsx index 8ca7335..9a19220 100644 --- a/src/test/plugins/browser.tsx +++ b/src/test/plugins/browser.tsx @@ -1,7 +1,7 @@ import { useState } from "react"; -import { Button } from "@sidequery/canvas"; +import { Button } from "@sidequery/artifacts"; import { label, type Label } from "./label"; -export type { Tone } from "@sidequery/canvas"; +export type { Tone } from "@sidequery/artifacts"; export type { Label } from "./label"; export function PluginCounter({ prefix }: Label) { const [count, setCount] = useState(0); diff --git a/src/test/routing.ts b/src/test/routing.ts index fa8d7cd..09cae95 100644 --- a/src/test/routing.ts +++ b/src/test/routing.ts @@ -1,9 +1,9 @@ -export const ROUTING_CANVAS = `import { Routes, Route, Outlet, Link, Button, H1, useParams, useLocation, useSearchParams, useNavigate } from "sidequery/canvas"; +export const ROUTING_ARTIFACT = `import { Routes, Route, Outlet, Link, Button, H1, useParams, useLocation, useSearchParams, useNavigate } from "sidequery/artifacts"; function Account() { const {id} = useParams(); const location = useLocation(); const [query] = useSearchParams(); return <>

Account {id}

Tab: {query.get('tab') ?? 'none'}

Hash: {location.hash}

State: {JSON.stringify(location.state)}

Next account; } -export default function Canvas() { +export default function Artifact() { const navigate = useNavigate(); return <> Home page}/>

Account layout

}>}/>Page not found}/>
; diff --git a/src/test/setup.ts b/src/test/setup.ts index 6920b07..4450cda 100644 --- a/src/test/setup.ts +++ b/src/test/setup.ts @@ -1,4 +1,4 @@ import { setDefaultTimeout } from "bun:test"; -// Canvas checks invoke the real TypeScript compiler and can exceed Bun's 5s default. +// Artifact checks invoke the real TypeScript compiler and can exceed Bun's 5s default. setDefaultTimeout(30_000); diff --git a/src/typecheck.test.ts b/src/typecheck.test.ts index d39e987..cd32691 100644 --- a/src/typecheck.test.ts +++ b/src/typecheck.test.ts @@ -1,31 +1,31 @@ import { expect, test } from "bun:test"; -import { formatCanvasCheck } from "./diagnostics"; -import { BAD_TYPE_CANVAS, FORBIDDEN_IMPORT_CANVAS, VALID_CANVAS, tempDir, writeCanvas } from "./test/fixtures"; -import { typecheckCanvas } from "./typecheck"; +import { formatArtifactCheck } from "./diagnostics"; +import { BAD_TYPE_ARTIFACT, FORBIDDEN_IMPORT_ARTIFACT, VALID_ARTIFACT, tempDir, writeArtifact } from "./test/fixtures"; +import { typecheckArtifact } from "./typecheck"; test( - "typecheckCanvas accepts a valid canvas", + "typecheckArtifact accepts a valid artifact", () => { - const path = writeCanvas(tempDir(), "overview", VALID_CANVAS); - expect(typecheckCanvas(path)).toEqual([]); + const path = writeArtifact(tempDir(), "overview", VALID_ARTIFACT); + expect(typecheckArtifact(path)).toEqual([]); }, { timeout: 30_000 }, ); -test("typecheckCanvas reports sandbox violations first", () => { - const path = writeCanvas(tempDir(), "bad", FORBIDDEN_IMPORT_CANVAS); - const diagnostics = typecheckCanvas(path); +test("typecheckArtifact reports sandbox violations first", () => { + const path = writeArtifact(tempDir(), "bad", FORBIDDEN_IMPORT_ARTIFACT); + const diagnostics = typecheckArtifact(path); expect(diagnostics.length).toBeGreaterThan(0); - expect(formatCanvasCheck(diagnostics)).toContain("Canvas TypeScript check:"); + expect(formatArtifactCheck(diagnostics)).toContain("Artifact TypeScript check:"); expect(diagnostics.some((item) => item.message.includes("node:fs"))).toBe(true); }); test( - "typecheckCanvas flags incorrect SDK prop types", + "typecheckArtifact flags incorrect SDK prop types", () => { - const path = writeCanvas(tempDir(), "types", BAD_TYPE_CANVAS); - const diagnostics = typecheckCanvas(path); + const path = writeArtifact(tempDir(), "types", BAD_TYPE_ARTIFACT); + const diagnostics = typecheckArtifact(path); expect(diagnostics.length).toBeGreaterThan(0); expect(diagnostics.some((item) => /string/i.test(item.message) || /gap/i.test(item.message))).toBe(true); }, diff --git a/src/typecheck.ts b/src/typecheck.ts index 20bba6c..aaae590 100644 --- a/src/typecheck.ts +++ b/src/typecheck.ts @@ -8,19 +8,19 @@ import { relative, resolve } from "node:path"; import { loadBrowserPlugins } from "./plugins/browser"; import type { BrowserPlugins } from "./plugins/types"; -export function typecheckCanvas(canvasPath: string, plugins: BrowserPlugins = loadBrowserPlugins()): Diagnostic[] { - const source = readFileSync(canvasPath, "utf8"); - const project = readLocalProject(canvasPath); - const violations = projectDiagnostics(canvasPath, source, project, Object.keys(plugins.paths)); +export function typecheckArtifact(artifactPath: string, plugins: BrowserPlugins = loadBrowserPlugins()): Diagnostic[] { + const source = readFileSync(artifactPath, "utf8"); + const project = readLocalProject(artifactPath); + const violations = projectDiagnostics(artifactPath, source, project, Object.keys(plugins.paths)); if (violations.length > 0) { return violations; } const snapshot = materializeProject(source, project); - try { return checkSnapshot(snapshot.path, plugins).map(item => ({ ...item, file: item.file === snapshot.path ? canvasPath : item.file?.replace(snapshot.directory + "/", "") })); } finally { snapshot.dispose(); } + try { return checkSnapshot(snapshot.path, plugins).map(item => ({ ...item, file: item.file === snapshot.path ? artifactPath : item.file?.replace(snapshot.directory + "/", "") })); } finally { snapshot.dispose(); } } -function checkSnapshot(canvasPath: string, plugins: BrowserPlugins): Diagnostic[] { +function checkSnapshot(artifactPath: string, plugins: BrowserPlugins): Diagnostic[] { const compilerOptions: ts.CompilerOptions = { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.ESNext, @@ -34,6 +34,8 @@ function checkSnapshot(canvasPath: string, plugins: BrowserPlugins): Diagnostic[ baseUrl: PLUGIN_ROOT, paths: { ...plugins.paths, + "@sidequery/artifacts": [relative(PLUGIN_ROOT, SDK_ENTRY)], + "sidequery/artifacts": [relative(PLUGIN_ROOT, SDK_ENTRY)], "@sidequery/canvas": [relative(PLUGIN_ROOT, SDK_ENTRY)], "sidequery/canvas": [relative(PLUGIN_ROOT, SDK_ENTRY)], "herdr/canvas": [relative(PLUGIN_ROOT, SDK_ENTRY)], @@ -57,14 +59,14 @@ function checkSnapshot(canvasPath: string, plugins: BrowserPlugins): Diagnostic[ })); const program = ts.createProgram({ - rootNames: [canvasPath], + rootNames: [artifactPath], options: compilerOptions, host, }); return ts .getPreEmitDiagnostics(program) .filter((diagnostic) => diagnostic.category === ts.DiagnosticCategory.Error) - .map((diagnostic) => toDiagnostic(diagnostic, canvasPath)); + .map((diagnostic) => toDiagnostic(diagnostic, artifactPath)); } function resolveSpecifier( @@ -73,7 +75,7 @@ function resolveSpecifier( options: ts.CompilerOptions, host: ts.CompilerHost, ): ts.ResolvedModuleFull | undefined { - if (specifier === "sidequery/canvas" || specifier === "herdr/canvas" || specifier === "cursor/canvas") { + if (["sidequery/artifacts","@sidequery/artifacts","sidequery/canvas","@sidequery/canvas","herdr/canvas","cursor/canvas"].includes(specifier)) { return { resolvedFileName: SDK_ENTRY, extension: ts.Extension.Ts, @@ -92,7 +94,7 @@ function resolveSpecifier( return fromRoot.resolvedModule; } -function toDiagnostic(diagnostic: ts.Diagnostic, canvasPath: string): Diagnostic { +function toDiagnostic(diagnostic: ts.Diagnostic, artifactPath: string): Diagnostic { const message = ts.flattenDiagnosticMessageText(diagnostic.messageText, "\n"); if (diagnostic.file && diagnostic.start !== undefined) { const { line, character } = diagnostic.file.getLineAndCharacterOfPosition(diagnostic.start); @@ -104,5 +106,5 @@ function toDiagnostic(diagnostic: ts.Diagnostic, canvasPath: string): Diagnostic column: character + 1, }; } - return { severity: "error", message, file: canvasPath }; + return { severity: "error", message, file: artifactPath }; } diff --git a/src/viewer.test.ts b/src/viewer.test.ts index 4589e68..ebab289 100644 --- a/src/viewer.test.ts +++ b/src/viewer.test.ts @@ -2,29 +2,29 @@ import { expect, test } from "bun:test"; import { EventEmitter } from "node:events"; import { join } from "node:path"; -import { tempDir, VALID_CANVAS, writeCanvas } from "./test/fixtures"; +import { tempDir, VALID_ARTIFACT, writeArtifact } from "./test/fixtures"; import { - canvasPaneConfig, + artifactPaneConfig, paneInputCommand, - runManagedCanvasPane, + runManagedArtifactPane, terminalBrowserCommand, type TerminalBrowserChild, } from "./viewer"; -test("canvasPaneConfig requires a canvas inside its managed directory", () => { +test("artifactPaneConfig requires an artifact inside its managed directory", () => { const dir = tempDir(); - const path = writeCanvas(dir, "overview", VALID_CANVAS); - expect(canvasPaneConfig({ HERDR_CANVAS_PATH: path, HERDR_CANVAS_DIR: dir })).toEqual({ - canvasPath: path, - canvasesDir: dir, - canvasId: "overview", + const path = writeArtifact(dir, "overview", VALID_ARTIFACT); + expect(artifactPaneConfig({ ARTIFACTS_PATH: path, ARTIFACTS_DIR: dir })).toEqual({ + artifactPath: path, + artifactsDir: dir, + artifactId: "overview", }); - expect(() => canvasPaneConfig({ HERDR_CANVAS_PATH: path, HERDR_CANVAS_DIR: join(dir, "other") })).toThrow( - "canvas path must be inside", + expect(() => artifactPaneConfig({ ARTIFACTS_PATH: path, ARTIFACTS_DIR: join(dir, "other") })).toThrow( + "artifact path must be inside", ); }); -test("terminalBrowserCommand opens the Canvas without browser chrome", () => { +test("terminalBrowserCommand opens the Artifact without browser chrome", () => { expect(terminalBrowserCommand("http://127.0.0.1:4567/c/overview", "/bin/terminal-browser")).toEqual([ "/bin/terminal-browser", "open", @@ -33,7 +33,16 @@ test("terminalBrowserCommand opens the Canvas without browser chrome", () => { ]); }); -test("paneInputCommand gives the Canvas pane application mouse routing", () => { +test("existing pane environment remains readable and Artifacts values take priority", () => { + const dir = tempDir(); + const path = writeArtifact(dir, "overview", VALID_ARTIFACT); + expect(artifactPaneConfig({ HERDR_CANVAS_PATH: path, HERDR_CANVAS_DIR: dir })).toEqual({ + artifactPath: path, artifactsDir: dir, artifactId: "overview", + }); + expect(artifactPaneConfig({ ARTIFACTS_PATH: path, ARTIFACTS_DIR: dir, HERDR_CANVAS_PATH: "/missing", HERDR_CANVAS_DIR: "/missing" }).artifactPath).toBe(path); +}); + +test("paneInputCommand gives the Artifact pane application mouse routing", () => { expect(paneInputCommand({ HERDR_BIN_PATH: "/bin/herdr", HERDR_PANE_ID: "w1:p2" })).toEqual([ "/bin/herdr", "pane", @@ -46,9 +55,9 @@ test("paneInputCommand gives the Canvas pane application mouse routing", () => { expect(() => paneInputCommand({})).toThrow("HERDR_PANE_ID is required"); }); -test("managed Canvas pane owns and stops its server around Terminal Browser", async () => { +test("managed Artifact pane owns and stops its server around Terminal Browser", async () => { const dir = tempDir(); - const canvasPath = writeCanvas(dir, "overview", VALID_CANVAS); + const artifactPath = writeArtifact(dir, "overview", VALID_ARTIFACT); let stopped = false; let launched: string[] = []; @@ -60,8 +69,8 @@ test("managed Canvas pane owns and stops its server around Terminal Browser", as return true; }; - const running = runManagedCanvasPane({ - config: { canvasPath, canvasesDir: dir, canvasId: "overview" }, + const running = runManagedArtifactPane({ + config: { artifactPath, artifactsDir: dir, artifactId: "overview" }, env: { HERDR_PANE_ID: "w1:p2" }, terminalBrowserBin: "/bin/terminal-browser", createServer: async () => ({ diff --git a/src/viewer.ts b/src/viewer.ts index b0c779c..7d04877 100644 --- a/src/viewer.ts +++ b/src/viewer.ts @@ -4,19 +4,19 @@ import { existsSync } from "node:fs"; import { spawn, spawnSync } from "node:child_process"; import { dirname, isAbsolute, relative, resolve } from "node:path"; -import { canvasIdFromFile } from "./canvasFile"; -import { compileCanvas } from "./compile"; -import { formatCanvasCheck } from "./diagnostics"; -import { createCanvasServer, type CanvasServer } from "./serve"; -import { typecheckCanvas } from "./typecheck"; -import { CanvasHistory, historyPath } from "./history"; +import { artifactIdFromFile } from "./artifactFile"; +import { compileArtifact } from "./compile"; +import { formatArtifactCheck } from "./diagnostics"; +import { createArtifactServer, type ArtifactServer } from "./serve"; +import { typecheckArtifact } from "./typecheck"; +import { ArtifactHistory, historyPath } from "./history"; type ViewerSignal = "SIGHUP" | "SIGINT" | "SIGTERM"; -export type CanvasPaneConfig = { - canvasPath: string; - canvasesDir: string; - canvasId: string; +export type ArtifactPaneConfig = { + artifactPath: string; + artifactsDir: string; + artifactId: string; versionId?: string; eventId?: string; }; @@ -31,26 +31,26 @@ export type TerminalBrowserChild = { ): TerminalBrowserChild; }; -export function canvasPaneConfig(env: NodeJS.ProcessEnv = process.env): CanvasPaneConfig { - const rawPath = env.HERDR_CANVAS_PATH?.trim(); +export function artifactPaneConfig(env: NodeJS.ProcessEnv = process.env): ArtifactPaneConfig { + const rawPath = (env.ARTIFACTS_PATH ?? env.HERDR_CANVAS_PATH)?.trim(); if (!rawPath) { - throw new Error("HERDR_CANVAS_PATH is required for a Canvas pane"); + throw new Error("ARTIFACTS_PATH is required for an Artifact pane"); } - const canvasPath = resolve(rawPath); - const canvasesDir = resolve(env.HERDR_CANVAS_DIR?.trim() || dirname(canvasPath)); - const withinDir = relative(canvasesDir, canvasPath); + const artifactPath = resolve(rawPath); + const artifactsDir = resolve((env.ARTIFACTS_DIR ?? env.HERDR_CANVAS_DIR)?.trim() || dirname(artifactPath)); + const withinDir = relative(artifactsDir, artifactPath); if (!withinDir || withinDir.startsWith("..") || isAbsolute(withinDir)) { - throw new Error(`canvas path must be inside the canvases directory: ${canvasPath}`); + throw new Error(`artifact path must be inside the artifacts directory: ${artifactPath}`); } - const versionId = env.HERDR_CANVAS_VERSION?.trim(); - if (!versionId && !existsSync(canvasPath)) { - throw new Error(`canvas file does not exist: ${canvasPath}`); + const versionId = (env.ARTIFACTS_VERSION ?? env.HERDR_CANVAS_VERSION)?.trim(); + if (!versionId && !existsSync(artifactPath)) { + throw new Error(`artifact file does not exist: ${artifactPath}`); } return { - canvasPath, - canvasesDir, - canvasId: canvasIdFromFile(canvasPath), - ...(versionId ? { versionId, eventId: env.HERDR_CANVAS_EVENT?.trim() || undefined } : {}), + artifactPath, + artifactsDir, + artifactId: artifactIdFromFile(artifactPath), + ...(versionId ? { versionId, eventId: (env.ARTIFACTS_EVENT ?? env.HERDR_CANVAS_EVENT)?.trim() || undefined } : {}), }; } @@ -61,36 +61,36 @@ export function terminalBrowserCommand(url: string, bin = "terminal-browser"): s export function paneInputCommand(env: NodeJS.ProcessEnv): string[] { const paneId = env.HERDR_PANE_ID?.trim(); if (!paneId) { - throw new Error("HERDR_PANE_ID is required for an interactive Canvas pane"); + throw new Error("HERDR_PANE_ID is required for an interactive Artifact pane"); } return [env.HERDR_BIN_PATH?.trim() || "herdr", "pane", "input", "--pane", paneId, "--right-click", "pane"]; } -export async function runManagedCanvasPane(opts: { - config: CanvasPaneConfig; +export async function runManagedArtifactPane(opts: { + config: ArtifactPaneConfig; env?: NodeJS.ProcessEnv; terminalBrowserBin?: string; - createServer?: typeof createCanvasServer; + createServer?: typeof createArtifactServer; spawnBrowser?: (command: string[], env: NodeJS.ProcessEnv) => TerminalBrowserChild; configurePaneInput?: (command: string[], env: NodeJS.ProcessEnv) => void; }): Promise { const childEnv = opts.env ?? process.env; let snapshot: string | undefined; if (opts.config.versionId) { - const archive = new CanvasHistory(historyPath(childEnv)); + const archive = new ArtifactHistory(historyPath(childEnv)); try { - const version = archive.version(opts.config.versionId, opts.config.canvasesDir); + const version = archive.version(opts.config.versionId, opts.config.artifactsDir); if (!version) throw new Error("version not found in this workspace"); snapshot = version.source; } finally { archive.close(); } } - const diagnostics = snapshot === undefined ? typecheckCanvas(opts.config.canvasPath) : []; + const diagnostics = snapshot === undefined ? typecheckArtifact(opts.config.artifactPath) : []; if (diagnostics.length > 0) { - throw new Error(formatCanvasCheck(diagnostics)); + throw new Error(formatArtifactCheck(diagnostics)); } - const compiled = await compileCanvas(opts.config.canvasPath, snapshot); + const compiled = await compileArtifact(opts.config.artifactPath, snapshot); if (!compiled.ok) { - throw new Error(formatCanvasCheck(compiled.diagnostics)); + throw new Error(formatArtifactCheck(compiled.diagnostics)); } const inputCommand = paneInputCommand(childEnv); @@ -103,21 +103,21 @@ export async function runManagedCanvasPane(opts: { stdio: ["ignore", "pipe", "pipe"], }); if (input.status !== 0) { - throw new Error(`failed to enable Canvas pane mouse input: ${(input.stderr || input.stdout).trim()}`); + throw new Error(`failed to enable Artifact pane mouse input: ${(input.stderr || input.stdout).trim()}`); } } - const createServer = opts.createServer ?? createCanvasServer; - let server: CanvasServer | undefined; + const createServer = opts.createServer ?? createArtifactServer; + let server: ArtifactServer | undefined; let child: TerminalBrowserChild | undefined; const listeners = new Map void>(); let requestedExitCode: number | undefined; try { - server = await createServer({ canvasesDir: opts.config.canvasesDir, env: childEnv }); + server = await createServer({ artifactsDir: opts.config.artifactsDir, env: childEnv }); const route = opts.config.versionId ? `/v/${opts.config.versionId}${opts.config.eventId ? `?event=${encodeURIComponent(opts.config.eventId)}` : ""}` - : `/c/${encodeURIComponent(opts.config.canvasId)}`; + : `/c/${encodeURIComponent(opts.config.artifactId)}`; const url = `${server.url}${route}`; const terminalBrowserBin = opts.terminalBrowserBin ?? ( childEnv.HERDR_TERMINAL_BROWSER_BIN?.trim() || "terminal-browser" @@ -172,8 +172,8 @@ function waitForChild(child: TerminalBrowserChild): Promise { } async function main(): Promise { - const config = canvasPaneConfig(); - const exitCode = await runManagedCanvasPane({ config }); + const config = artifactPaneConfig(); + const exitCode = await runManagedArtifactPane({ config }); process.exitCode = exitCode; } diff --git a/tsconfig.json b/tsconfig.json index 6208484..4a3b44a 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -7,12 +7,33 @@ "strict": true, "noEmit": true, "skipLibCheck": true, - "types": ["bun"], + "types": [ + "bun" + ], "paths": { - "sidequery/canvas": ["./src/sdk/index.ts"], - "herdr/canvas": ["./src/sdk/index.ts"], - "cursor/canvas": ["./src/sdk/index.ts"] + "sidequery/artifacts": [ + "./src/sdk/index.ts" + ], + "@sidequery/artifacts": [ + "./src/sdk/index.ts" + ], + "sidequery/canvas": [ + "./src/sdk/index.ts" + ], + "@sidequery/canvas": [ + "./src/sdk/index.ts" + ], + "herdr/canvas": [ + "./src/sdk/index.ts" + ], + "cursor/canvas": [ + "./src/sdk/index.ts" + ] } }, - "include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.d.ts"] + "include": [ + "src/**/*.ts", + "src/**/*.tsx", + "src/**/*.d.ts" + ] } From 2cccb15d7866c9590ec68709a27fc9b24f9dc677 Mon Sep 17 00:00:00 2001 From: Nico Ritschel Date: Mon, 7 Sep 2026 20:15:43 -0700 Subject: [PATCH 2/3] Preserve native Worker exports and prepare bun release --- README.md | 3 +- cloudflare/backend-test-worker.ts | 1 + cloudflare/backend.test.ts | 58 +++++++++++++++++++++++++++---- cloudflare/backend.ts | 5 +-- docs/releasing.md | 10 ++++-- package.json | 2 +- 6 files changed, 65 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 31318cb..0473b38 100644 --- a/README.md +++ b/README.md @@ -21,8 +21,7 @@ The gallery, source operations, history, typechecking and compilation work witho ## Install -After the first registry release, install the CLI with Bun; a source checkout is -not required. The package is currently being prepared and is not published yet. +Install the CLI with Bun; a source checkout is not required. ```bash bun add --global @sidequery/artifacts diff --git a/cloudflare/backend-test-worker.ts b/cloudflare/backend-test-worker.ts index 09fe094..4373b3c 100644 --- a/cloudflare/backend-test-worker.ts +++ b/cloudflare/backend-test-worker.ts @@ -4,6 +4,7 @@ import type { ArtifactBackend } from "./backend"; type Env = { BACKENDS: DurableObjectNamespace }; export default { async fetch(request: Request, env: Env): Promise { + if (new URL(request.url).pathname === "/health") return new Response("ready"); try { const input = await request.json() as Parameters[0]; return Response.json(await env.BACKENDS.getByName("existing").request(input)); diff --git a/cloudflare/backend.test.ts b/cloudflare/backend.test.ts index bd70ae3..5b5d454 100644 --- a/cloudflare/backend.test.ts +++ b/cloudflare/backend.test.ts @@ -1,11 +1,52 @@ import { afterAll, beforeAll, expect, test } from "bun:test"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { ensureCelldRuntime } from "../src/local/celld-runtime"; import { Miniflare } from "miniflare"; import type { ArtifactHttpResponse } from "../src/httpTypes"; let runtime: Miniflare; +const native = process.env.CELLD_BACKEND_INTEGRATION === "1"; +let directory = "", binary = "", url = "", logs = ""; +let child: ReturnType | undefined; +async function startNative() { + child = Bun.spawn([binary, "dev", directory, "--host", "127.0.0.1", "--port", new URL(url).port, "--no-watch"], { + cwd: directory, env: { ...process.env, CELLD_WORKER_LOADER: "LOADER", CELLD_ESBUILD: join(import.meta.dir, "../node_modules/.bin/esbuild") }, stdout: "pipe", stderr: "pipe", + }); + for (const stream of [child.stdout, child.stderr]) if (typeof stream !== "number") void (async () => { + for await (const chunk of stream) logs += new TextDecoder().decode(chunk); + })(); + const deadline = Date.now() + 45_000; + while (Date.now() < deadline && child.exitCode === null) { + try { if ((await fetch(url + "/health")).ok) return; } catch {} + await Bun.sleep(100); + } + throw new Error("celld did not become ready: " + logs); +} +async function stopNative() { + if (!child || child.exitCode !== null) return; + child.kill("SIGINT"); + const stopped = await Promise.race([child.exited.then(() => true), Bun.sleep(10_000).then(() => false)]); + if (!stopped) { child.kill("SIGKILL"); await child.exited; } + child = undefined; +} beforeAll(async () => { const build = await Bun.build({ entrypoints: [new URL("./backend-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")); + if (native) { + directory = await mkdtemp(join(tmpdir(), "artifact-backend-celld-")); + binary = process.env.CELLD_BIN ?? await ensureCelldRuntime({ dataRoot: join(directory, "runtime") }); + const listener = Bun.listen({ hostname: "127.0.0.1", port: 0, socket: { data() {} } }); + url = `http://127.0.0.1:${listener.port}`; + listener.stop(true); + await Bun.write(join(directory, "worker.js"), await build.outputs[0]!.text()); + await Bun.write(join(directory, "wrangler.jsonc"), JSON.stringify({ name: "backend-test", main: "worker.js", compatibility_date: "2026-09-06", compatibility_flags: ["nodejs_compat"], + durable_objects: { bindings: [{ name: "BACKENDS", class_name: "ArtifactBackend" }] }, migrations: [{ tag: "v1", new_sqlite_classes: ["ArtifactBackend"] }], + })); + await startNative(); + return; + } runtime = new Miniflare({ cf: false, port: 0, unsafeInspectDurableObjects: true, workers: [{ config: { name: "backend-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() } } }, @@ -13,8 +54,8 @@ beforeAll(async () => { exports: { ArtifactBackend: { type: "durable-object", storage: "sqlite" } }, }, dev: {} }] }); await runtime.ready; -}, 30_000); -afterAll(async () => { await runtime?.dispose(); }); +}, 180_000); +afterAll(async () => { await runtime?.dispose(); await stopNative(); if (directory) await rm(directory, { recursive: true, force: true }); }); // Already compiled JavaScript, bypassing the source compiler and its export normalization. function compiledServer(className: "CanvasServer" | "ArtifactServer") { @@ -29,15 +70,17 @@ export class ${className} extends DurableObject { } return Response.json({ exporter: "${className}", count: sql.exec("select sum(value) as total from counter").one().total, retained: await this.ctx.storage.get("retained") }); } -}`; +} +export default { fetch() { return new Response("Not found", { status: 404 }); } };`; } async function invoke(className: "CanvasServer" | "ArtifactServer", method = "GET") { - const response = await runtime.dispatchFetch("http://localhost/", { method: "POST", body: JSON.stringify({ + const dispatch = native ? (input: string, init: RequestInit) => fetch(url + new URL(input).pathname, init) : (input: string, init: RequestInit) => runtime.dispatchFetch(input, init); + const response = await dispatch("http://localhost/", { method: "POST", body: JSON.stringify({ code: compiledServer(className), hash: className, version_id: className, request: { path: "/", method, headers: [] }, }) }); const result = await response.json() as ArtifactHttpResponse & { error?: string }; - if (!response.ok) throw new Error(result.error); + if (!response.ok) throw new Error(result.error + (native ? "\n" + logs : "")); expect(result.status).toBe(200); return JSON.parse(atob(result.body!)); } @@ -45,7 +88,8 @@ async function invoke(className: "CanvasServer" | "ArtifactServer", method = "GE test("cached CanvasServer revisions and new ArtifactServer revisions share native SQLite and KV state", async () => { expect(await invoke("CanvasServer", "POST")).toEqual({ exporter: "CanvasServer", count: 7, retained: "original value" }); expect(await invoke("ArtifactServer")).toEqual({ exporter: "ArtifactServer", count: 7, retained: "original value" }); - await runtime.unsafeEvictDurableObject("backend-test", "ArtifactBackend", { name: "existing" }); + if (native) { await stopNative(); await startNative(); } + else await runtime.unsafeEvictDurableObject("backend-test", "ArtifactBackend", { name: "existing" }); expect(await invoke("ArtifactServer")).toEqual({ exporter: "ArtifactServer", count: 7, retained: "original value" }); expect(await invoke("CanvasServer")).toEqual({ exporter: "CanvasServer", count: 7, retained: "original value" }); -}, 30_000); +}, native ? 180_000 : 30_000); diff --git a/cloudflare/backend.ts b/cloudflare/backend.ts index d81fcec..b57b9fa 100644 --- a/cloudflare/backend.ts +++ b/cloudflare/backend.ts @@ -102,9 +102,10 @@ export class ArtifactBackend extends DurableObject { const worker = this.env.LOADER.get(input.hash, async () => ({ compatibilityDate: "2026-09-06", mainModule: "artifact-server.js", - // Old immutable compiled revisions export CanvasServer. + // Old immutable compiled revisions export CanvasServer. Preserve the + // default Worker object too: celld requires it on the main module. modules: { - "artifact-server.js": 'import * as source from "./source.js"; export const ArtifactServer = source.ArtifactServer ?? source.CanvasServer;', + "artifact-server.js": 'import * as source from "./source.js"; export const ArtifactServer = source.ArtifactServer ?? source.CanvasServer; export default source.default;', "source.js": input.code, }, globalOutbound: null, diff --git a/docs/releasing.md b/docs/releasing.md index 622bc43..9d643f7 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -21,7 +21,13 @@ is stored in GitHub or 1Password for routine releases. The package must first exist on npm before its trusted publisher can be configured. For the first approved release, use the local validation and publication steps below with interactive npm authentication (or a temporary bootstrap credential). -Then open `@sidequery/artifacts` package settings on npm and add this trusted publisher: +Then configure the trusted publisher with the npm CLI (11.17 or newer): + +```bash +npm trust github @sidequery/artifacts --repo sidequery/artifacts --file publish.yml --allow-publish +``` + +The equivalent settings on the npm package page are: | Field | Value | | --- | --- | @@ -51,7 +57,7 @@ publication verifies the connection. Subsequent releases need a new package vers archive with `ARTIFACTS_PACKAGE_ARCHIVE=/tmp/sidequery-artifacts-VERSION.tgz CELLD_PACKAGE_INTEGRATION=1 bun test scripts/package.integration.test.ts --timeout 360000`. 6. After release approval and authentication for the `@sidequery` scope, - publish that tarball with `bun publish /tmp/sidequery-artifacts-VERSION.tgz --access public`. + publish that tarball with `npm publish /tmp/sidequery-artifacts-VERSION.tgz --access public --ignore-scripts`. A package build is not a registry publication. CI validates packaging without publishing. Never enable a login daemon as an install or publish lifecycle script. diff --git a/package.json b/package.json index bb42c3d..a214467 100644 --- a/package.json +++ b/package.json @@ -114,7 +114,7 @@ "build:cloudflare-compiler": "wrangler deploy --config cloudflare/wrangler.compiler.jsonc --dry-run --outdir ../dist/worker", "typecheck:cloudflare": "tsc -p cloudflare/tsconfig.json --noEmit && tsc -p examples/runner-status/tsconfig.worker.json --noEmit", "test:cloudflare": "bun run build:cloudflare-compiler && bun run build:cloudflare && bun test cloudflare --timeout 60000", - "test:celld": "bun run build:cloudflare && bun run prepare:celld && CELLD_INTEGRATION=1 CELLD_AUTH_INTEGRATION=1 bun test cloudflare/celld.test.ts cloudflare/auth-flow.test.ts --timeout 180000", + "test:celld": "bun run build:cloudflare && bun run prepare:celld && CELLD_INTEGRATION=1 CELLD_AUTH_INTEGRATION=1 CELLD_BACKEND_INTEGRATION=1 bun test cloudflare/backend.test.ts cloudflare/celld.test.ts cloudflare/auth-flow.test.ts --timeout 180000", "deploy:cloudflare": "wrangler deploy", "seed:cloudflare": "bun run scripts/seed-cloudflare.ts", "example:runner-status": "bun run examples/runner-status/local.ts", From ee3672c59403943adbdf05e1e11516897fc25d19 Mon Sep 17 00:00:00 2001 From: Nico Ritschel Date: Mon, 7 Sep 2026 20:24:06 -0700 Subject: [PATCH 3/3] Use fresh connections in tenant isolation tests --- cloudflare/worker.test.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/cloudflare/worker.test.ts b/cloudflare/worker.test.ts index 6cdba40..3f2aac8 100644 --- a/cloudflare/worker.test.ts +++ b/cloudflare/worker.test.ts @@ -246,6 +246,13 @@ test("all hosted surfaces fail closed off loopback, and mutations enforce origin }); test("verified users have isolated private libraries and can collaborate in the team library", async () => { + // Compilation pauses can outlive the local server's idle connections. Avoid + // reusing those sockets in both direct requests and the MCP transport. + const fetch = (input: string | URL | Request, init: RequestInit = {}) => { + const headers = new Headers(init.headers ?? (input instanceof Request ? input.headers : undefined)); + headers.set("Connection", "close"); + return globalThis.fetch(input, { ...init, headers }); + }; const { generateKeyPair, exportJWK, SignJWT } = await import("jose"); const { Response: RuntimeResponse } = await import("miniflare"); const keys = await generateKeyPair("RS256", { extractable: true }); @@ -276,6 +283,7 @@ test("verified users have isolated private libraries and can collaborate in the const remote = new Client({ name: "tenancy-test", version: "1" }); clients.push(remote); await remote.connect(new StreamableHTTPClientTransport(new URL(`${address}/mcp?workspace=test&library=${library}`), { + fetch, requestInit: { headers: { "Cf-Access-Jwt-Assertion": token } }, })); return remote;