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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
24 changes: 23 additions & 1 deletion cloudflare/auth-flow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`);
Expand Down Expand Up @@ -231,7 +247,13 @@ 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");
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;
expect((await fetch(`${origin}/mcp`, { headers: { Authorization: `Bearer ${token.slice(0, -10)}tampered00` } })).status).toBe(401);
Expand Down
27 changes: 27 additions & 0 deletions cloudflare/celld.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@ async function withClient<T>(run: (client: Client) => Promise<T>) {
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) {
Expand Down Expand Up @@ -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);
29 changes: 14 additions & 15 deletions cloudflare/compiler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,36 +119,35 @@ function serverClassDiagnostic(node?: ts.Node): Diagnostic {
};
}

let compilationTail: Promise<unknown> = 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<typeof queueCompile> } | undefined;
let lastServer: { source: string; result: Awaited<ReturnType<typeof queueCompile>> } | 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) {
// esbuild WASM and semantic checking share the isolate's 128 MiB budget.
// Serialize builds and bound retained request sources under load.
if (pendingCompilations >= 8) return { ok: false, diagnostics: [{ severity: "error" as const, message: "Compiler is busy; retry shortly", file: server ? serverPath : sourcePath }] };
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) {
Expand Down
9 changes: 6 additions & 3 deletions cloudflare/dev.ts
Original file line number Diff line number Diff line change
@@ -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<Response> {
async fetch(request: Request, _env: unknown, ctx: ExecutionContext): Promise<Response> {
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 });
Expand All @@ -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) });
},
};
6 changes: 5 additions & 1 deletion cloudflare/worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
Loading