From a4e092c5db6c60608c918de5b589543ea8fe3e99 Mon Sep 17 00:00:00 2001 From: Nico Ritschel Date: Mon, 7 Sep 2026 12:56:41 -0700 Subject: [PATCH 1/2] Add canvas page routing and direct APIs --- bun.lock | 13 +++-- cloudflare/artifact-routes.ts | 27 ++++++++-- cloudflare/canvas-api.test.ts | 51 ++++++++++++++++++ cloudflare/canvas-api.ts | 42 +++++++++++++++ cloudflare/worker.test.ts | 87 ++++++++++++++++++++++++++++++ docs/routing.md | 39 ++++++++++++++ e2e/mcp-app.test.ts | 18 +++++++ e2e/routing.test.ts | 79 +++++++++++++++++++++++++++ package.json | 6 ++- scripts/prepare-browser-plugins.ts | 5 +- scripts/prepare-cloudflare.ts | 8 ++- scripts/prepare-plugins.ts | 2 +- src/compile.ts | 4 +- src/history.ts | 2 +- src/mcp/guide.ts | 8 ++- src/runtime/cloud-runtime.ts | 5 +- src/runtime/navigation-host.ts | 42 +++++++++++++++ src/runtime/wrapper.tsx | 3 +- src/sdk/hooks.ts | 1 + src/sdk/index.ts | 2 + src/sdk/routing.tsx | 40 ++++++++++++++ src/sdk/ui.tsx | 19 ++----- src/test/routing.ts | 10 ++++ 23 files changed, 479 insertions(+), 34 deletions(-) create mode 100644 cloudflare/canvas-api.test.ts create mode 100644 cloudflare/canvas-api.ts create mode 100644 docs/routing.md create mode 100644 e2e/routing.test.ts create mode 100644 src/runtime/navigation-host.ts create mode 100644 src/sdk/routing.tsx create mode 100644 src/test/routing.ts diff --git a/bun.lock b/bun.lock index 55dd4c5..7854263 100644 --- a/bun.lock +++ b/bun.lock @@ -12,6 +12,7 @@ "esbuild": "0.28.1", "react": "^19.2.8", "react-dom": "^19.2.8", + "react-router": "^7", "typescript": "5.9.3", }, "devDependencies": { @@ -255,7 +256,7 @@ "content-type": ["content-type@1.0.5", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="], - "cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="], + "cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="], "cookie-signature": ["cookie-signature@1.2.2", "", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="], @@ -419,6 +420,8 @@ "react-dom": ["react-dom@19.2.8", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.8" } }, "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ=="], + "react-router": ["react-router@7.18.3", "", { "dependencies": { "cookie": "^1.0.1", "set-cookie-parser": "^2.6.0" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" }, "optionalPeers": ["react-dom"] }, "sha512-gyXgtdr5uACJ5b1Q4udzjVV+tb/rlHIMJKuJ0e89R4Kzgz47z/rgP0dIKxktqIEUhDHluGTPJJH/wRha7CyqsA=="], + "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], "resolve.exports": ["resolve.exports@2.0.3", "", {}, "sha512-OcXjMsGdhL4XnbShKpAcSqPMzQoYkYyhbEaeSko47MjRP9NfEQMhZkXL1DoFlt9LWQn4YttrdnV6X2OiyzBi+A=="], @@ -437,7 +440,7 @@ "serve-static": ["serve-static@2.2.1", "", { "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "parseurl": "^1.3.3", "send": "^1.2.0" } }, "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw=="], - "set-cookie-parser": ["set-cookie-parser@3.1.2", "", {}, "sha512-5/r/lTwbJ3zQ+qwdUFZYeRNqda7P5HD8zQKqlSjdGt1/S0cjLAphHusj4Y58ahDtWn/g32xrIS58/ikOvwl0Lw=="], + "set-cookie-parser": ["set-cookie-parser@2.7.2", "", {}, "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw=="], "setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="], @@ -513,14 +516,16 @@ "better-call/@better-auth/utils": ["@better-auth/utils@0.5.0", "", { "dependencies": { "@noble/hashes": "^2.0.1" } }, "sha512-BL8W4EfIZFwlu0r54m3v1ztjDhu6dDe/amLTm0xybmbZaNgYUqhD3SjpAsnq0q8YD6/ki4iwIgxJNLP/N3TxiA=="], + "better-call/set-cookie-parser": ["set-cookie-parser@3.1.2", "", {}, "sha512-5/r/lTwbJ3zQ+qwdUFZYeRNqda7P5HD8zQKqlSjdGt1/S0cjLAphHusj4Y58ahDtWn/g32xrIS58/ikOvwl0Lw=="], + "body-parser/content-type": ["content-type@2.1.0", "", {}, "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag=="], + "express/cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="], + "negotiator/content-type": ["content-type@2.1.0", "", {}, "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag=="], "router/path-to-regexp": ["path-to-regexp@8.4.2", "", {}, "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA=="], "type-is/content-type": ["content-type@2.1.0", "", {}, "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag=="], - - "youch/cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="], } } diff --git a/cloudflare/artifact-routes.ts b/cloudflare/artifact-routes.ts index 28c1808..774992b 100644 --- a/cloudflare/artifact-routes.ts +++ b/cloudflare/artifact-routes.ts @@ -8,6 +8,8 @@ import type { CanvasHttpRequest } from "../src/httpTypes"; import { PluginError, PLUGIN_JSON_LIMIT, type PluginInvocationContext } from "./plugins"; 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"; 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 }); @@ -20,8 +22,8 @@ export async function identify(request: Request, env: Env): Promise<{ privateKey return identity instanceof Response ? identity : { privateKey: JSON.stringify(["private", env.ACCESS_TEAM_DOMAIN ?? "local", identity.subject]), user: identity }; } const scriptJson = (value: unknown) => JSON.stringify(value).replaceAll("<", "\\u003c"); -function html(body: string, policy: string): Response { - return new Response(body, { headers: { "content-type": "text/html; charset=utf-8", "content-security-policy": policy, "cache-control": "private, no-store", "x-content-type-options": "nosniff" } }); +function html(body: string, policy: string, head: boolean): Response { + return new Response(head ? null : body, { headers: { "content-type": "text/html; charset=utf-8", "content-security-policy": policy, "cache-control": "private, no-store", "x-content-type-options": "nosniff" } }); } /** Root slug dispatch is separate from management authentication. */ @@ -66,6 +68,19 @@ export async function artifactRoute(request: Request, env: Env): Promise
`; + const frame = `
`; return html(`Canvas`, "default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; frame-src 'self' about:; connect-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors 'self'"); +});`, "default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; frame-src 'self' about:; connect-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors 'self'", request.method === "HEAD"); } diff --git a/cloudflare/canvas-api.test.ts b/cloudflare/canvas-api.test.ts new file mode 100644 index 0000000..e38160b --- /dev/null +++ b/cloudflare/canvas-api.test.ts @@ -0,0 +1,51 @@ +import { expect, test } from "bun:test"; +import { canvasApiRequest, canvasApiResponse } from "./canvas-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", { + 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); + expect(input.path).toBe("/api/items?a=1&a=%FF"); + expect(input.method).toBe("PATCH"); + expect(Uint8Array.from(atob(input.body!), char => char.charCodeAt(0))).toEqual(bytes); + const headers = new Headers(input.headers); + 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"); + expect(response.status).toBe(201); + expect(response.statusText).toBe("Created"); + expect(new Uint8Array(await response.arrayBuffer())).toEqual(bytes); + expect(response.headers.get("x-output")).toBe("kept"); + expect(response.headers.has("set-cookie")).toBe(false); + expect(response.headers.get("content-security-policy")).toContain("sandbox allow-scripts allow-forms"); +}); + +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); + expect(input.path).toBe("/api"); + expect(input.body).toBeUndefined(); + expect(new Headers(input.headers).has("authorization")).toBe(false); + } +}); + +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); + 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"); +}); + +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"); + 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(() => canvasApiResponse({ 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/canvas-api.ts new file mode 100644 index 0000000..b370970 --- /dev/null +++ b/cloudflare/canvas-api.ts @@ -0,0 +1,42 @@ +import type { CanvasHttpRequest, CanvasHttpResponse } 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 { + 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"); + const url = new URL(request.url); + const reader = request.body?.getReader(); + let binary = ""; + try { + if (reader) while (true) { + const chunk = await reader.read(); + if (chunk.done) break; + if (binary.length + chunk.value.byteLength > MAX_BODY) { + await reader.cancel(); + throw new RangeError("Canvas request body exceeds 256 KiB"); + } + for (let i = 0; i < chunk.value.length; i += 8192) binary += String.fromCharCode(...chunk.value.subarray(i, i + 8192)); + } + } finally { reader?.releaseLock(); } + 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 { + // 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"); + headers.append("content-security-policy", "sandbox allow-scripts allow-forms"); + headers.set("x-content-type-options", "nosniff"); + 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"); + const binary = atob(input.body); + if (binary.length > MAX_BODY) throw new RangeError("Canvas 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/worker.test.ts b/cloudflare/worker.test.ts index 551be71..93c0033 100644 --- a/cloudflare/worker.test.ts +++ b/cloudflare/worker.test.ts @@ -5,6 +5,7 @@ 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"; let runtime: Miniflare; let runtimeOptions: ConstructorParameters[0]; @@ -13,6 +14,16 @@ 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 apiServer = `import { DurableObject } from "cloudflare:workers"; +export class CanvasServer 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)); + if (url.pathname === "/api/html") return new Response("

API page

", {headers:{"content-type":"text/html","set-cookie":"session=forged"}}); + if (url.pathname !== "/api" && url.pathname !== "/api/echo") return Response.json({error:"API route not found"}, {status:404}); + return new Response(request.method === "HEAD" ? null : await request.arrayBuffer(), {status:201, headers:{"content-type":"application/octet-stream","x-path":url.pathname + url.search,"x-method":request.method,"x-result":"api"}}); + } +}`; let version: string; beforeAll(async () => { const modulesRoot = join(import.meta.dir, "../dist/worker-app"); @@ -260,6 +271,15 @@ 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 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); + const privateApi = await fetch(`${address}/private-api/api/headers`, { headers: { "Cf-Access-Jwt-Assertion": aliceToken, Authorization: "Bearer private-management", Cookie: "session=private", "Cf-Access-Client-Id": "id", "Cf-Access-Client-Secret": "secret", "x-kept": "yes" } }); + expect(privateApi.status).toBe(200); + 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 } }); expect(privateResult.isError).not.toBe(true); const privateVersion = (privateResult._meta as { canvas: { versionId: string } }).canvas.versionId; @@ -466,3 +486,70 @@ test("correcting initial invalid drafts retains the user-chosen slug and access" expect(payload(corrected).url).toBe(`${origin}/chosen-canvas`); expect((await fetch(`${origin}/chosen-canvas`)).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 } }); + 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"]) { + const response = await fetch(`${origin}/direct-api${path}`, { method: "PATCH", body: bytes }); + expect(response.status).toBe(201); + expect(response.headers.get("x-path")).toBe(path); + expect(response.headers.get("x-method")).toBe("PATCH"); + expect(response.headers.get("x-result")).toBe("api"); + expect(new Uint8Array(await response.arrayBuffer())).toEqual(bytes); + } + const headers = await (await fetch(`${origin}/direct-api/api/headers`, { headers: { Cookie: "session=ambient", "Cf-Access-Jwt-Assertion": "ambient", "Cf-Access-Client-Id": "id", "Cf-Access-Client-Secret": "secret", Authorization: "Bearer application" } })).json() as Record; + expect(headers.authorization).toBe("Bearer application"); + for (const name of ["cookie", "cf-access-jwt-assertion", "cf-access-client-id", "cf-access-client-secret"]) expect(headers[name]).toBeUndefined(); + for (const method of ["GET", "POST"]) expect((await fetch(`${origin}/direct-api/api`, { method, headers: { Origin: "https://evil.example", Cookie: "session=ambient" } })).status).toBe(403); + expect((await fetch(`${origin}/direct-api/api`, { method: "POST", body: new Uint8Array(256 * 1024 + 1) })).status).toBe(413); + const missing = await fetch(`${origin}/direct-api/api/missing`); + expect(missing.status).toBe(404); + expect(await missing.json()).toEqual({ error: "API route not found" }); + const head = await fetch(`${origin}/direct-api/api`, { method: "HEAD" }); + expect(head.status).toBe(201); + expect(head.headers.get("x-method")).toBe("HEAD"); + expect(await head.text()).toBe(""); + const html = await fetch(`${origin}/direct-api/api/html`); + 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 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); + const absent = await fetch(`${origin}/pages-only/api/missing`); + expect(absent.status).toBe(404); + expect(await absent.json()).toEqual({ error: "Canvas 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"); + expect(await deep.text()).toContain("/projects/42?tab=detail"); + const deepHead = await fetch(`${origin}/pages-only/projects/42`, { method: "HEAD" }); + expect(deepHead.status).toBe(200); + 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); +}, 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); + const browser = await chromium.launch({ headless: true }); + try { + const page = await browser.newPage(); + await page.goto(`${origin}/linked-routes/accounts/123?tab=activity#latest`); + const frame = page.frameLocator("iframe"); + await frame.getByRole("heading", { name: "Account 123" }).waitFor(); + await frame.getByText("Tab: activity", { exact: true }).waitFor(); + await frame.getByText("Hash: #latest", { exact: true }).waitFor(); + await frame.getByRole("link", { name: "Next account" }).click(); + await page.waitForURL(`${origin}/linked-routes/accounts/456`); + await page.reload(); + await frame.getByRole("heading", { name: "Account 456" }).waitFor(); + await page.goBack(); + await frame.getByRole("heading", { name: "Account 123" }).waitFor(); + await frame.getByText("Hash: #latest", { exact: true }).waitFor(); + } finally { await browser.close(); } +}, 60000); diff --git a/docs/routing.md b/docs/routing.md new file mode 100644 index 0000000..9807098 --- /dev/null +++ b/docs/routing.md @@ -0,0 +1,39 @@ +# Canvas 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. + +```tsx +import { Routes, Route, Outlet, Link, useParams } from "sidequery/canvas"; + +function Account() { + const { id } = useParams(); + return

Account {id}

; +} + +export default function App() { + return <> + Open account + + Overview

} /> +

Accounts

}> + } /> + + Page not found

} /> +
+ ; +} +``` + +`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. + +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. + +## 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. + +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. + +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. + +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. diff --git a/e2e/mcp-app.test.ts b/e2e/mcp-app.test.ts index 68944c6..49207fe 100644 --- a/e2e/mcp-app.test.ts +++ b/e2e/mcp-app.test.ts @@ -504,3 +504,21 @@ export default function Canvas() { expect(errors).toEqual([]); await page.close(); }, 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"); + const { page, app, errors } = await openHost(); + const outerUrl = page.url(); + await page.evaluate(meta => window.mcpHost!.sendResult({ content: [], _meta: meta }), meta); + await app.getByRole("heading", { name: "Home page" }).waitFor(); + await app.getByRole("link", { name: "Account", exact: true }).click(); + await app.getByRole("heading", { name: "Account 123" }).waitFor(); + await app.getByText("Tab: activity", { exact: true }).waitFor(); + expect(page.url()).toBe(outerUrl); + await page.evaluate(meta => window.mcpHost!.sendResult({ content: [], _meta: meta }), meta); + await app.getByRole("heading", { name: "Home page" }).waitFor(); + expect(errors).toEqual([]); + await page.close(); +}, 30000); diff --git a/e2e/routing.test.ts b/e2e/routing.test.ts new file mode 100644 index 0000000..121f932 --- /dev/null +++ b/e2e/routing.test.ts @@ -0,0 +1,79 @@ +import { afterAll, beforeAll, expect, test } from "bun:test"; +import { chromium, type Browser } from "playwright"; +import { rmSync } from "node:fs"; +import { compileCanvas } from "../src/compile"; +import { typecheckCanvas } from "../src/typecheck"; +import { tempDir, writeCanvas } from "../src/test/fixtures"; +import { ROUTING_CANVAS } from "../src/test/routing"; +let browser: Browser; +let server: ReturnType; +let dir: string; +beforeAll(async () => { + dir = tempDir("canvas-routing-"); + const source = writeCanvas(dir, "routes", ROUTING_CANVAS); + expect(typecheckCanvas(source)).toEqual([]); + const compiled = await compileCanvas(source); + expect(compiled.ok).toBe(true); + const host = await Bun.build({ entrypoints: [new URL("../src/runtime/navigation-host.ts", import.meta.url).pathname], target: "browser", format: "iife" }); + expect(host.success).toBe(true); + const hostJs = await host.outputs[0]!.text(); + server = Bun.serve({ hostname: "127.0.0.1", port: 0, fetch(request) { + const url = new URL(request.url); + const external = url.pathname.startsWith("/sales"); + const context = external ? { route: { path: (url.pathname.slice(6) || "/") + url.search, basePath: "/sales", external: true } } : {}; + const frame = `