From 3195a9efaaa93855de193fe3655da3ddd2d0d36f Mon Sep 17 00:00:00 2001 From: Nico Ritschel Date: Mon, 7 Sep 2026 07:41:54 -0700 Subject: [PATCH 1/3] Build package before Cloudflare typecheck --- .github/workflows/publish.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 572f163..71b9ebf 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -34,9 +34,9 @@ jobs: 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");' - run: bun install --frozen-lockfile - run: bun run typecheck - - run: bun run typecheck:cloudflare - 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" - name: Test release archive including native server persistence From 25ab3325e8ea926f815f32d685fcf0ecfc6e0b93 Mon Sep 17 00:00:00 2001 From: Nico Ritschel Date: Mon, 7 Sep 2026 09:19:48 -0700 Subject: [PATCH 2/3] Expose team preview failures in authentication tests --- cloudflare/auth-flow.test.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/cloudflare/auth-flow.test.ts b/cloudflare/auth-flow.test.ts index 6ab558b..d07c8e0 100644 --- a/cloudflare/auth-flow.test.ts +++ b/cloudflare/auth-flow.test.ts @@ -231,7 +231,12 @@ test("real OIDC and MCP OAuth share per-user gallery, source and database bounda // counter button, so wait for the team document before interacting with it. await bobPage.waitForURL(url => url.searchParams.get("library") === "team"); await bobPage.getByRole("button", { name: "counter", exact: true }).click(); - await bobPage.frameLocator("iframe").getByText("Count: 2", { exact: true }).waitFor(); + try { + await bobPage.frameLocator("iframe").getByText("Count: 2", { exact: true }).waitFor(); + } catch (error) { + const frame = bobPage.frameLocator("iframe"); + throw new Error(`${String(error)}\nGallery: ${bobPage.url()}\nPreview: ${await bobPage.locator("iframe").getAttribute("src")}\n${await frame.locator("body").innerText()}`); + } expect((await aliceContext.request.post(`${origin}/api/canvas/request`, { headers: { Origin: "https://evil.example" }, data: {} })).status()).toBe(403); const token = aliceProvider.savedTokens!.access_token; expect((await fetch(`${origin}/mcp`, { headers: { Authorization: `Bearer ${token.slice(0, -10)}tampered00` } })).status).toBe(401); From e39ca139a7b5d09591e18fbb280b03c1e23a2764 Mon Sep 17 00:00:00 2001 From: Nico Ritschel Date: Mon, 7 Sep 2026 09:39:40 -0700 Subject: [PATCH 3/3] Keep native compilation alive across preview disconnects --- cloudflare/auth-flow.test.ts | 19 ++++++++++++++++++- cloudflare/celld.test.ts | 27 +++++++++++++++++++++++++++ cloudflare/compiler.ts | 29 ++++++++++++++--------------- cloudflare/dev.ts | 9 ++++++--- cloudflare/worker.ts | 6 +++++- 5 files changed, 70 insertions(+), 20 deletions(-) diff --git a/cloudflare/auth-flow.test.ts b/cloudflare/auth-flow.test.ts index d07c8e0..743f9b4 100644 --- a/cloudflare/auth-flow.test.ts +++ b/cloudflare/auth-flow.test.ts @@ -173,6 +173,22 @@ test("real OIDC and MCP OAuth share per-user gallery, source and database bounda try { const aliceProvider = await authorize(aliceContext, "Alice"); const bobPage = await bobContext.newPage(); + const previewRequests: string[] = []; + bobPage.on("request", request => { + if (["/gallery/preview", "/api/canvas/request"].includes(new URL(request.url()).pathname)) { + previewRequests.push(`Started ${request.url()}`); + } + }); + bobPage.on("requestfailed", request => { + if (["/gallery/preview", "/api/canvas/request"].includes(new URL(request.url()).pathname)) { + previewRequests.push(`${request.url()}: ${request.failure()?.errorText}`); + } + }); + bobPage.on("response", response => { + if (["/gallery/preview", "/api/canvas/request"].includes(new URL(response.url()).pathname)) { + previewRequests.push(`${response.url()}: ${response.status()}`); + } + }); await bobPage.goto(`${origin}/?workspace=test`); await login(bobPage, "Bob"); await bobPage.waitForURL(`${origin}/?workspace=test`); @@ -235,7 +251,8 @@ test("real OIDC and MCP OAuth share per-user gallery, source and database bounda await bobPage.frameLocator("iframe").getByText("Count: 2", { exact: true }).waitFor(); } catch (error) { const frame = bobPage.frameLocator("iframe"); - throw new Error(`${String(error)}\nGallery: ${bobPage.url()}\nPreview: ${await bobPage.locator("iframe").getAttribute("src")}\n${await frame.locator("body").innerText()}`); + const body = await frame.locator("body").innerText({ timeout: 500 }).catch(() => "Preview body unavailable"); + throw new Error(`${String(error)}\nGallery: ${bobPage.url()}\nPreview: ${await bobPage.locator("iframe").getAttribute("src")}\n${body}\n${previewRequests.join("\n")}\n${celldRuntime?.logs().split("\n").filter(line => /ERROR|WARN| at | at /.test(line)).join("\n") ?? ""}`); } expect((await aliceContext.request.post(`${origin}/api/canvas/request`, { headers: { Origin: "https://evil.example" }, data: {} })).status()).toBe(403); const token = aliceProvider.savedTokens!.access_token; diff --git a/cloudflare/celld.test.ts b/cloudflare/celld.test.ts index 254dd0e..0b8b35f 100644 --- a/cloudflare/celld.test.ts +++ b/cloudflare/celld.test.ts @@ -105,6 +105,7 @@ async function withClient(run: (client: Client) => Promise) { async function write(client: Client, server: string) { const result = await client.callTool({ name: "canvas_write", arguments: { name: "native-counter", contents: clientSource, server } }); expect(result.isError, JSON.stringify(result.content)).toBe(false); + return (result._meta as { canvas: { versionId: string } }).canvas.versionId; } async function request(client: Client, method: string) { @@ -174,3 +175,29 @@ celldTest("runs native canvas SQLite and KV across code update and celld restart expect(await request(client, "GET")).toEqual({ code: 2, value: 3, kv: 3 }); }); }); + +celldTest("aborted previews do not block subsequent compilation", async () => { + const version = await withClient(client => write(client, serverV1)); + await withClient(client => write(client, serverV2)); + const url = `${baseUrl}/gallery/preview?version=${version}`; + // Exercise disconnects at different points in the real preview pipeline, + // including the asynchronous compiler used by subsequent requests. + for (const delay of [10, 25, 50]) { + // Select the current server first so the archived preview must compile its + // different server source after compiling the client, even after disconnect. + await withClient(client => request(client, "GET")); + const controller = new AbortController(); + const interrupted = fetch(url, { signal: controller.signal }) + .then(response => response.text()).catch(error => { + if (!controller.signal.aborted) throw error; + }); + await Bun.sleep(delay); + controller.abort(); + await interrupted; + const response = await fetch(url, { signal: AbortSignal.timeout(10_000) }).catch(error => { + throw new Error(`Preview after ${delay}ms disconnect: ${String(error)}\n${processLogs}`); + }); + expect(response.status).toBe(200); + expect(await response.text()).toContain("serverVersionId"); + } +}, 45_000); diff --git a/cloudflare/compiler.ts b/cloudflare/compiler.ts index 92c345f..b3f99cc 100644 --- a/cloudflare/compiler.ts +++ b/cloudflare/compiler.ts @@ -119,26 +119,21 @@ function serverClassDiagnostic(node?: ts.Node): Diagnostic { }; } -let compilationTail: Promise = Promise.resolve(); let pendingCompilations = 0; +let nextCompilation = 0; +let activeCompilation = 0; export async function compileCanvasSource(source: string) { return queueCompile(source, false); } -let lastServer: { source: string; result: ReturnType } | undefined; +let lastServer: { source: string; result: Awaited> } | undefined; export async function compileCanvasServerSource(source: string) { if (lastServer?.source === source) return lastServer.result; - const result = queueCompile(source, true); - lastServer = { source, result }; - try { - const compiled = await result; - if (!compiled.ok && lastServer?.result === result) lastServer = undefined; - return compiled; - } catch (error) { - if (lastServer?.result === result) lastServer = undefined; - throw error; - } + const result = await queueCompile(source, true); + // Share completed code, never another request's in-flight native operations. + if (result.ok) lastServer = { source, result }; + return result; } async function queueCompile(source: string, server: boolean) { @@ -146,9 +141,13 @@ async function queueCompile(source: string, server: boolean) { // Serialize builds and bound retained request sources under load. if (pendingCompilations >= 8) return { ok: false, diagnostics: [{ severity: "error" as const, message: "Compiler is busy; retry shortly", file: server ? serverPath : sourcePath }] }; pendingCompilations++; - const result = compilationTail.then(() => server ? compileServer(source) : compileSource(source)); - compilationTail = result.catch(() => undefined); - try { return await result; } finally { pendingCompilations--; } + const ticket = nextCompilation++; + // celld 0.4.1 attributes native timers to the request driving a continuation. + // Wait on this request's own timer instead of another request's promise, so + // disconnecting a preview cannot cancel the next build's esbuild operations. + while (ticket !== activeCompilation) await new Promise(resolve => setTimeout(resolve, 5)); + try { return await (server ? compileServer(source) : compileSource(source)); } + finally { activeCompilation++; pendingCompilations--; } } async function compileServer(source: string) { diff --git a/cloudflare/dev.ts b/cloudflare/dev.ts index fbcb02c..9cf1706 100644 --- a/cloudflare/dev.ts +++ b/cloudflare/dev.ts @@ -1,10 +1,11 @@ import { compileCanvasSource, compileCanvasServerSource, typecheckCanvasSource, typecheckCanvasServerSource } from "./compiler"; import { readRequestText } from "./http"; +import type { ExecutionContext } from "@cloudflare/workers-types"; // Local compiler qualification entrypoint. The hosted API supplies its own // authentication and storage boundaries before exposing these operations. export default { - async fetch(request: Request): Promise { + async fetch(request: Request, _env: unknown, ctx: ExecutionContext): Promise { const url = new URL(request.url); if (request.method === "GET" && url.pathname === "/health") return Response.json({ ok: true, runtime: navigator.userAgent }); if (request.method !== "POST" || !["/compile", "/compile-server", "/typecheck", "/typecheck-server"].includes(url.pathname)) return new Response("Not found", { status: 404 }); @@ -14,8 +15,10 @@ export default { if (error instanceof RangeError) return new Response("Canvas source exceeds 256 KiB", { status: 413 }); throw error; } - return Response.json(url.pathname === "/compile-server" ? await compileCanvasServerSource(source) - : url.pathname === "/compile" ? await compileCanvasSource(source) + const compilation = url.pathname === "/compile-server" ? compileCanvasServerSource(source) + : url.pathname === "/compile" ? compileCanvasSource(source) : undefined; + if (compilation) ctx.waitUntil(compilation); + return Response.json(compilation ? await compilation : { diagnostics: url.pathname === "/typecheck-server" ? typecheckCanvasServerSource(source) : typecheckCanvasSource(source) }); }, }; diff --git a/cloudflare/worker.ts b/cloudflare/worker.ts index 2bb4313..b863db8 100644 --- a/cloudflare/worker.ts +++ b/cloudflare/worker.ts @@ -69,7 +69,11 @@ app.use("*", async (c, next) => { : 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)); - await next(); + // 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(); + c.executionCtx.waitUntil(operation); + await operation; }); app.get("/api/session", c => { const user = c.get("user");