diff --git a/.gitignore b/.gitignore
index 170008326..7e3717056 100644
--- a/.gitignore
+++ b/.gitignore
@@ -10,6 +10,8 @@ packages/gatekeeper-*/src/generated/app.txt
# Bundled format blueprints (regenerated at build time from FORMAT_BLUEPRINTS_DIR).
packages/workshop-backend/src/generated/format-blueprints.ts
packages/workshop-backend/src/generated/browser-export-runtime.txt
+packages/workshop-backend/src/generated/html-sanitizer-runtime.txt
+packages/workshop-backend/src/generated/browser-export-page.js
# TypeScript build info
*.tsbuildinfo
diff --git a/packages/workshop-backend/__tests__/browser-export.test.ts b/packages/workshop-backend/__tests__/browser-export.test.ts
index 04ca6302b..ea660b534 100644
--- a/packages/workshop-backend/__tests__/browser-export.test.ts
+++ b/packages/workshop-backend/__tests__/browser-export.test.ts
@@ -3,15 +3,28 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
const launch = vi.hoisted(() => vi.fn());
vi.mock("@cloudflare/puppeteer", () => ({ launch }));
-const { BrowserRpcTransport, limitStream, renderGadgetPdf } =
+const { BrowserRpcTransport, renderGadgetInBrowser } =
await import("../src/browser-export.js");
+const { createExportDeadline, limitExportStream } =
+ await import("../src/export-limits.js");
type Harness = {
browserClosed: () => boolean;
clientInitialized: () => boolean;
gadgetDisposed: () => boolean;
pdfRequested: () => boolean;
- renderSettled: () => boolean;
+ exportDocument: () => string;
+ exportDocumentCsp: () => string | undefined;
+ blobRequestContinued: () => boolean;
+ htmlSanitized: () => boolean;
+ sanitizerInstalled: () => boolean;
+ sanitizedInIsolatedRealm: () => boolean;
+ mediaType: () => string | undefined;
+ screenshotType: () => string | undefined;
+ screenshotClip: () => {x: number; y: number; width: number; height: number} | undefined;
+ screenshotCaptureBeyondViewport: () => boolean | undefined;
+ setDocumentDimensions: (width: number, height: number) => void;
+ setSnapshot: (value: string) => void;
};
function makeHarness(pdfChunks = ["%PDF-1.4"], closePdf = true) {
@@ -20,30 +33,109 @@ function makeHarness(pdfChunks = ["%PDF-1.4"], closePdf = true) {
let documentTitle: string | undefined;
let gadgetDisposed = false;
let pdfRequested = false;
- let renderSettled = false;
+ let exportDocument = "";
+ let exportDocumentCsp: string | undefined;
+ let blobRequestContinued = false;
+ let htmlSanitized = false;
+ let sanitizerInstalled = false;
+ let sanitizedInIsolatedRealm = false;
+ let mediaType: string | undefined;
+ let screenshotType: string | undefined;
+ let screenshotClip: {x: number; y: number; width: number; height: number} | undefined;
+ let screenshotCaptureBeyondViewport: boolean | undefined;
+ let documentDimensions = {width: 1000, height: 1000};
+ let snapshot = "\n
Snapshot";
+ let navigated = false;
+ let requestHandler: ((request: unknown) => void) | undefined;
+ const evaluate = (
+ isolated: boolean,
+ fn: ((...args: never[]) => unknown) | string,
+ ...args: unknown[]
+ ) => {
+ if (typeof fn === "string") {
+ if (!isolated) throw new Error("HTML sanitizer was installed in the main world.");
+ sanitizerInstalled = true;
+ return Promise.resolve();
+ }
+ if (fn.toString().includes("__workshopExportModulePromise")) {
+ if (isolated) throw new Error("Client module was awaited outside the main world.");
+ clientInitialized = true;
+ return Promise.resolve();
+ }
+ if (fn.toString().includes("document.title")) {
+ if (!isolated) throw new Error("Document title was assigned in the main world.");
+ expect(clientInitialized).toBe(true);
+ documentTitle = typeof args[0] === "string" ? args[0] : undefined;
+ return Promise.resolve();
+ }
+ if (fn.toString().includes("__workshopExportSanitizeHtml")) {
+ if (!isolated) throw new Error("Main-world sanitizer was invoked.");
+ expect(sanitizerInstalled).toBe(true);
+ sanitizedInIsolatedRealm = true;
+ htmlSanitized = typeof args[0] === "string" &&
+ args[0].includes("script-src 'none'") &&
+ fn.toString().includes("ownerDocument") &&
+ !fn.toString().includes("DOMParser") &&
+ fn.toString().includes("charset");
+ if (new TextEncoder().encode(snapshot).byteLength > Number(args[1])) {
+ return Promise.reject(new Error(`Gadget exports may not exceed ${args[1]} bytes.`));
+ }
+ return Promise.resolve(snapshot);
+ }
+ if (fn.toString().includes("scrollWidth")) {
+ if (!isolated) throw new Error("Document dimensions were measured in the main world.");
+ const maxPixels = Number(args[0]);
+ const {width, height} = documentDimensions;
+ if (!Number.isSafeInteger(width) || !Number.isSafeInteger(height) ||
+ width <= 0 || height <= 0 || width > Math.floor(maxPixels / height)) {
+ return Promise.reject(new Error(
+ `Gadget screenshots may not exceed ${maxPixels} pixels.`,
+ ));
+ }
+ return Promise.resolve({x: 0, y: 0, width, height});
+ }
+ // The RPC transport polls this; the fake page never has a message to deliver.
+ return new Promise(() => {});
+ };
+ const mainFrame = {
+ isolatedRealm: () => ({
+ evaluate: (fn: (...args: never[]) => unknown, ...args: unknown[]) =>
+ evaluate(true, fn, ...args),
+ }),
+ };
let page = {
setRequestInterception: async () => {},
- on: () => {},
- goto: async () => {},
- mainFrame: () => ({}),
- emulateMediaType: async () => {},
- evaluate: (fn: (...args: never[]) => unknown, ...args: unknown[]) => {
- if (fn.toString().includes("__workshopExportModulePromise")) {
- clientInitialized = true;
- renderSettled = fn.toString().includes("MutationObserver");
- return Promise.resolve();
- }
- if (fn.toString().includes("document.title")) {
- documentTitle = typeof args[0] === "string" ? args[0] : undefined;
- return Promise.resolve();
- }
- // The RPC transport polls this; the fake page never has a message to deliver.
- return new Promise(() => {});
+ on: (event: string, handler: (request: unknown) => void) => {
+ if (event === "request") requestHandler = handler;
+ },
+ goto: async () => {
+ navigated = true;
+ requestHandler?.({
+ url: () => "https://gadget-export.invalid/",
+ isNavigationRequest: () => true,
+ frame: () => mainFrame,
+ respond: async (response: {body: string, headers?: Record}) => {
+ exportDocument = response.body;
+ exportDocumentCsp = response.headers?.["Content-Security-Policy"];
+ },
+ });
+ requestHandler?.({
+ url: () => "blob:https://gadget-export.invalid/test",
+ isNavigationRequest: () => false,
+ frame: () => mainFrame,
+ continue: async () => { blobRequestContinued = true; },
+ });
},
+ mainFrame: () => mainFrame,
+ emulateMediaType: async (value: string) => {
+ expect(navigated).toBe(false);
+ mediaType = value;
+ },
+ evaluate: (fn: (...args: never[]) => unknown, ...args: unknown[]) =>
+ evaluate(false, fn, ...args),
createPDFStream: async () => {
expect(clientInitialized).toBe(true);
- expect(renderSettled).toBe(true);
expect(documentTitle).toBe("Test Gadget");
pdfRequested = true;
return new ReadableStream({
@@ -53,6 +145,16 @@ function makeHarness(pdfChunks = ["%PDF-1.4"], closePdf = true) {
},
});
},
+ screenshot: async ({type, clip, captureBeyondViewport}: {
+ type: string;
+ clip?: {x: number; y: number; width: number; height: number};
+ captureBeyondViewport?: boolean;
+ }) => {
+ screenshotType = type;
+ screenshotClip = clip;
+ screenshotCaptureBeyondViewport = captureBeyondViewport;
+ return new TextEncoder().encode(type);
+ },
};
launch.mockResolvedValue({
@@ -73,18 +175,40 @@ function makeHarness(pdfChunks = ["%PDF-1.4"], closePdf = true) {
clientInitialized: () => clientInitialized,
gadgetDisposed: () => gadgetDisposed,
pdfRequested: () => pdfRequested,
- renderSettled: () => renderSettled,
+ exportDocument: () => exportDocument,
+ exportDocumentCsp: () => exportDocumentCsp,
+ blobRequestContinued: () => blobRequestContinued,
+ htmlSanitized: () => htmlSanitized,
+ sanitizerInstalled: () => sanitizerInstalled,
+ sanitizedInIsolatedRealm: () => sanitizedInIsolatedRealm,
+ mediaType: () => mediaType,
+ screenshotType: () => screenshotType,
+ screenshotClip: () => screenshotClip,
+ screenshotCaptureBeyondViewport: () => screenshotCaptureBeyondViewport,
+ setDocumentDimensions: (width, height) => { documentDimensions = {width, height}; },
+ setSnapshot: value => { snapshot = value; },
};
return { gadget, harness };
}
-function render(pdfChunks?: string[], closePdf = true) {
+function render(
+ pdfChunks?: string[],
+ closePdf = true,
+ contentType = "application/pdf",
+) {
let { gadget, harness } = makeHarness(pdfChunks, closePdf);
- let stream = renderGadgetPdf(
+ let stream = renderGadgetInBrowser(
{} as BrowserRun,
"export default {}",
"Test Gadget",
gadget as never,
+ {
+ id: "test-format",
+ label: "Test",
+ mode: "browser",
+ contentType,
+ fileExtension: ".test",
+ },
);
return { stream, harness };
}
@@ -129,24 +253,134 @@ describe("BrowserRpcTransport", () => {
describe("limitStream", () => {
it("passes through output that stays within the cap", async () => {
- expect(await collect(limitStream(streamOf(["abc", "de"]), 5))).toBe("abcde");
+ expect(await collect(limitExportStream(
+ streamOf(["abc", "de"]),
+ createExportDeadline("timed out"),
+ undefined,
+ 5,
+ ))).toBe("abcde");
});
it("fails as soon as the cap is exceeded rather than buffering the whole export", async () => {
- let reader = limitStream(streamOf(["abcd", "efgh"]), 6).getReader();
+ let reader = limitExportStream(
+ streamOf(["abcd", "efgh"]),
+ createExportDeadline("timed out"),
+ undefined,
+ 6,
+ ).getReader();
await expect(reader.read()).resolves.toMatchObject({ done: false });
await expect(reader.read()).rejects.toThrow("may not exceed 6 bytes");
});
+
+ it("releases resources when a hostile source never settles cancellation", async () => {
+ vi.useFakeTimers();
+ try {
+ const release = vi.fn(async () => {});
+ const source = new ReadableStream({
+ pull() { return new Promise(() => {}); },
+ cancel() { return new Promise(() => {}); },
+ });
+ const reader = limitExportStream(
+ source,
+ createExportDeadline("timed out", 10),
+ release,
+ ).getReader();
+ const rejection = expect(reader.read()).rejects.toThrow("timed out");
+
+ await vi.advanceTimersByTimeAsync(10);
+
+ await rejection;
+ expect(release).toHaveBeenCalledOnce();
+ } finally {
+ vi.useRealTimers();
+ }
+ });
});
-describe("renderGadgetPdf", () => {
- it("settles the client render, streams a PDF, and releases the browser", async () => {
+describe("renderGadgetInBrowser", () => {
+ it("waits for the client module, streams a PDF, and releases the browser", async () => {
let { stream, harness } = render();
expect(await collect(await stream)).toBe("%PDF-1.4");
expect(harness.clientInitialized()).toBe(true);
- expect(harness.renderSettled()).toBe(true);
expect(harness.pdfRequested()).toBe(true);
+ expect(harness.mediaType()).toBe("print");
+ expect(harness.browserClosed()).toBe(true);
+ expect(harness.exportDocument()).toContain(
+ 'globalThis.gadgetExportFormatId%20%3D%20%22test-format%22',
+ );
+ expect(harness.exportDocumentCsp()).toContain("img-src data: blob:");
+ expect(harness.exportDocumentCsp()).toContain("media-src data: blob:");
+ expect(harness.blobRequestContinued()).toBe(true);
+ });
+
+ it("exports an inert snapshot with locally bundled DOMPurify", async () => {
+ let { stream, harness } = render(undefined, true, "text/html");
+
+ expect(await collect(await stream)).toContain("Snapshot");
+ expect(harness.htmlSanitized()).toBe(true);
+ expect(harness.sanitizerInstalled()).toBe(true);
+ expect(harness.sanitizedInIsolatedRealm()).toBe(true);
+ expect(harness.mediaType()).toBe("screen");
+ expect(harness.browserClosed()).toBe(true);
+ });
+
+ it("rejects oversized HTML before transferring it from the browser", async () => {
+ let { gadget, harness } = makeHarness();
+ harness.setSnapshot("x".repeat(100 * 1024 * 1024 + 1));
+
+ let stream = renderGadgetInBrowser(
+ {} as BrowserRun,
+ "export default {}",
+ "Test Gadget",
+ gadget as never,
+ {
+ id: "html",
+ label: "HTML",
+ mode: "browser",
+ contentType: "text/html",
+ fileExtension: ".html",
+ },
+ );
+
+ await expect(stream).rejects.toThrow("may not exceed 104857600 bytes");
+ expect(harness.browserClosed()).toBe(true);
+ });
+
+ it.each([
+ ["image/png", "png"],
+ ["image/jpeg", "jpeg"],
+ ])("captures bounded full-page %s screenshots", async (contentType, screenshotType) => {
+ let { stream, harness } = render(undefined, true, contentType);
+
+ expect(await collect(await stream)).toBe(screenshotType);
+ expect(harness.screenshotType()).toBe(screenshotType);
+ expect(harness.screenshotClip()).toEqual({x: 0, y: 0, width: 1000, height: 1000});
+ expect(harness.screenshotCaptureBeyondViewport()).toBe(true);
+ expect(harness.mediaType()).toBe("screen");
+ expect(harness.browserClosed()).toBe(true);
+ });
+
+ it("rejects oversized screenshots before capture", async () => {
+ let { gadget, harness } = makeHarness();
+ harness.setDocumentDimensions(5001, 5000);
+
+ let stream = renderGadgetInBrowser(
+ {} as BrowserRun,
+ "export default {}",
+ "Test Gadget",
+ gadget as never,
+ {
+ id: "png",
+ label: "PNG",
+ mode: "browser",
+ contentType: "image/png",
+ fileExtension: ".png",
+ },
+ );
+
+ await expect(stream).rejects.toThrow("may not exceed 25000000 pixels");
+ expect(harness.screenshotType()).toBeUndefined();
expect(harness.browserClosed()).toBe(true);
});
@@ -166,11 +400,12 @@ describe("renderGadgetPdf", () => {
let { stream, harness } = render(["first"], false);
let reader = (await stream).getReader();
await expect(reader.read()).resolves.toMatchObject({ done: false });
+ let timedOut = expect(reader.read()).rejects.toThrow("Browser export timed out.");
await vi.advanceTimersByTimeAsync(30_000);
+ await timedOut;
expect(harness.browserClosed()).toBe(true);
- await reader.cancel();
} finally {
vi.useRealTimers();
}
@@ -183,11 +418,18 @@ describe("renderGadgetPdf", () => {
let browserClosed = false;
let gadgetDisposed = false;
launch.mockReturnValue(pendingLaunch.promise);
- let result = renderGadgetPdf(
+ let result = renderGadgetInBrowser(
{} as BrowserRun,
"export default {}",
"Test Gadget",
{ [Symbol.dispose]: () => { gadgetDisposed = true; } } as never,
+ {
+ id: "pdf",
+ label: "PDF",
+ mode: "browser",
+ contentType: "application/pdf",
+ fileExtension: ".pdf",
+ },
);
let rejection = expect(result).rejects.toThrow("Browser export timed out.");
@@ -209,11 +451,18 @@ describe("renderGadgetPdf", () => {
let gadgetDisposed = false;
launch.mockRejectedValue(new Error("no browser available"));
- await expect(renderGadgetPdf(
+ await expect(renderGadgetInBrowser(
{} as BrowserRun,
"export default {}",
"Test Gadget",
{ [Symbol.dispose]: () => { gadgetDisposed = true; } } as never,
+ {
+ id: "pdf",
+ label: "PDF",
+ mode: "browser",
+ contentType: "application/pdf",
+ fileExtension: ".pdf",
+ },
)).rejects.toThrow("no browser available");
expect(gadgetDisposed).toBe(true);
});
diff --git a/packages/workshop-backend/__tests__/format-blueprints.test.ts b/packages/workshop-backend/__tests__/format-blueprints.test.ts
index ed6b0a8a0..6ff332554 100644
--- a/packages/workshop-backend/__tests__/format-blueprints.test.ts
+++ b/packages/workshop-backend/__tests__/format-blueprints.test.ts
@@ -4,14 +4,17 @@ import { parseBlueprintArchive, parseBlueprintKvRecord, sanitizeBlueprintOutput
import { formatBlueprintsManifestVersion, installFormatBlueprints } from "../src/format-blueprints.js";
import { FORMAT_BLUEPRINTS } from "../src/generated/format-blueprints.js";
-async function readClientCode(entry: (typeof FORMAT_BLUEPRINTS)[number]): Promise {
+async function readBlueprintFile(
+ entry: (typeof FORMAT_BLUEPRINTS)[number],
+ filename: string,
+): Promise {
let archive = new Response(Uint8Array.fromBase64(entry.archive) as BufferSource).body!;
let {content} = await parseBlueprintArchive(archive);
let decompressed = content.pipeThrough(new DecompressionStream("gzip"));
let update = new Uint8Array(await new Response(decompressed).arrayBuffer());
let doc = new Y.Doc();
Y.applyUpdateV2(doc, update);
- return doc.getMap().get("client.js")?.toString() ?? "";
+ return doc.getMap().get(filename)?.toString() ?? "";
}
// Minimal in-memory stand-ins for the two bindings the installer writes to. They record what was
@@ -79,7 +82,44 @@ describe("bundled format blueprints", () => {
it("ships print layouts for every standard output format", async () => {
for (let entry of FORMAT_BLUEPRINTS) {
- expect(await readClientCode(entry), entry.blueprintId).toContain("@media print");
+ expect(await readBlueprintFile(entry, "client.js"), entry.blueprintId)
+ .toContain("@media print");
+ }
+ });
+
+ it("renders document HTML and PDF exports without the editor chrome", async () => {
+ let entry = FORMAT_BLUEPRINTS.find(blueprint => blueprint.blueprintId === "format.document")!;
+ let client = await readBlueprintFile(entry, "client.js");
+
+ expect(client).toContain('["html", "pdf"].includes(globalThis.gadgetExportFormatId)');
+ expect(client).toContain('document.documentElement.classList.add("document-export")');
+ expect(client).toContain("app.replaceChildren(canvas)");
+ });
+
+ it("declares the intended export formats for every standard output format", async () => {
+ let expectedFormats: Record = {
+ "format.document": [
+ 'id: "markdown", label: "Markdown", mode: "server", contentType: "text/markdown"',
+ 'id: "html", label: "HTML", mode: "browser", contentType: "text/html"',
+ 'id: "pdf", label: "PDF", mode: "browser", contentType: "application/pdf"',
+ ],
+ "format.slides": [
+ 'id: "html", label: "HTML", mode: "browser", contentType: "text/html"',
+ 'id: "pdf", label: "PDF", mode: "browser", contentType: "application/pdf"',
+ ],
+ "format.spreadsheet": [
+ 'const CSV_FORMAT_PREFIX = "csv:"',
+ 'mode: "server"',
+ 'contentType: "text/csv"',
+ ],
+ };
+
+ for (let entry of FORMAT_BLUEPRINTS) {
+ let serverCode = await readBlueprintFile(entry, "server.js");
+ expect(serverCode, entry.blueprintId).toContain("export class ExportHandler");
+ for (let declaration of expectedFormats[entry.blueprintId] ?? []) {
+ expect(serverCode, `${entry.blueprintId}: ${declaration}`).toContain(declaration);
+ }
}
});
diff --git a/packages/workshop-backend/__tests__/gadget-export.test.ts b/packages/workshop-backend/__tests__/gadget-export.test.ts
new file mode 100644
index 000000000..a6af832c4
--- /dev/null
+++ b/packages/workshop-backend/__tests__/gadget-export.test.ts
@@ -0,0 +1,161 @@
+import { describe, expect, it, vi } from "vitest";
+import {
+ defaultExportFormats,
+ exportServerFormat,
+ readCustomExportFormats,
+ validateExportFormats,
+} from "../src/gadget-export";
+
+function streamOf(chunks: string[]): ReadableStream {
+ return new ReadableStream({
+ start(controller) {
+ for (const chunk of chunks) controller.enqueue(new TextEncoder().encode(chunk));
+ controller.close();
+ },
+ });
+}
+
+describe("Gadget export formats", () => {
+ it("provides fresh HTML and PDF defaults", () => {
+ const first = defaultExportFormats();
+ const second = defaultExportFormats();
+
+ expect(first.map(format => format.id)).toEqual(["html", "pdf"]);
+ expect(first).toEqual(second);
+ expect(first).not.toBe(second);
+ expect(first[0]).not.toBe(second[0]);
+ });
+
+ it("accepts browser and server formats", () => {
+ const formats = validateExportFormats([
+ {
+ id: "png",
+ label: "Image",
+ mode: "browser",
+ contentType: "image/png",
+ fileExtension: ".png",
+ },
+ {
+ id: "csv",
+ label: "CSV",
+ mode: "server",
+ contentType: "text/csv",
+ fileExtension: ".csv",
+ ignored: "value",
+ },
+ ]);
+
+ expect(formats.map(format => format.id)).toEqual(["png", "csv"]);
+ expect(formats[1]).not.toHaveProperty("ignored");
+ });
+
+ it("rejects duplicate ids and unsupported browser content types", () => {
+ const format = {
+ id: "data",
+ label: "Data",
+ mode: "server",
+ contentType: "text/csv",
+ fileExtension: ".csv",
+ };
+ expect(() => validateExportFormats([format, format])).toThrow("id is not unique");
+ expect(() => validateExportFormats([{
+ ...format,
+ mode: "browser",
+ }])).toThrow("unsupported content type");
+ });
+
+ it("rejects unsafe file extensions and invalid media types", () => {
+ const format = {
+ id: "data",
+ label: "Data",
+ mode: "server",
+ contentType: "text/csv",
+ fileExtension: ".csv",
+ };
+ expect(() => validateExportFormats([{
+ ...format,
+ fileExtension: "/report.csv",
+ }])).toThrow("invalid file extension");
+ expect(() => validateExportFormats([{
+ ...format,
+ fileExtension: ".1234567890123456",
+ }])).toThrow("between 1 and 16 characters");
+ expect(() => validateExportFormats([{
+ ...format,
+ fileExtension: ".csv.",
+ }])).toThrow("invalid file extension");
+ expect(() => validateExportFormats([{
+ ...format,
+ contentType: "not a media type",
+ }])).toThrow("invalid content type");
+ });
+
+ it("uses defaults only for an absent entrypoint and propagates handler failures", async () => {
+ const missing = {
+ async getExportFormats() {
+ throw new Error("Worker has no such entrypoint: ExportHandler");
+ },
+ };
+ await expect(readCustomExportFormats(missing, {})).resolves.toBeNull();
+
+ const broken = {
+ async getExportFormats() {
+ throw new Error("handler failed");
+ },
+ };
+ await expect(readCustomExportFormats(broken, {})).rejects.toThrow("handler failed");
+ });
+
+ it("times out format discovery", async () => {
+ vi.useFakeTimers();
+ try {
+ const result = readCustomExportFormats({
+ getExportFormats: () => new Promise(() => {}),
+ }, {});
+ const rejection = expect(result).rejects.toThrow(
+ "Listing Gadget export formats timed out.",
+ );
+ await vi.advanceTimersByTimeAsync(30_000);
+ await rejection;
+ } finally {
+ vi.useRealTimers();
+ }
+ });
+});
+
+describe("exportServerFormat", () => {
+ it("streams server-generated content", async () => {
+ const stream = await exportServerFormat(async () => streamOf(["a", "bc"]));
+ expect(await new Response(stream).text()).toBe("abc");
+ });
+
+ it("times out while waiting for the handler", async () => {
+ vi.useFakeTimers();
+ try {
+ const result = exportServerFormat(() => new Promise(() => {}));
+ const rejection = expect(result).rejects.toThrow("Gadget export timed out.");
+ await vi.advanceTimersByTimeAsync(30_000);
+ await rejection;
+ } finally {
+ vi.useRealTimers();
+ }
+ });
+
+ it("cancels a stream returned after the handler deadline", async () => {
+ vi.useFakeTimers();
+ try {
+ const pending = Promise.withResolvers>();
+ const result = exportServerFormat(() => pending.promise);
+ const rejection = expect(result).rejects.toThrow("Gadget export timed out.");
+ await vi.advanceTimersByTimeAsync(30_000);
+ await rejection;
+
+ const cancel = vi.fn();
+ pending.resolve(new ReadableStream({cancel}));
+ await vi.advanceTimersByTimeAsync(0);
+ expect(cancel).toHaveBeenCalled();
+ } finally {
+ vi.useRealTimers();
+ }
+ });
+});
diff --git a/packages/workshop-backend/browser/browser-export-page.ts b/packages/workshop-backend/browser/browser-export-page.ts
new file mode 100644
index 000000000..1b57d44c3
--- /dev/null
+++ b/packages/workshop-backend/browser/browser-export-page.ts
@@ -0,0 +1,76 @@
+// These functions run in the remote browser via Puppeteer's page.evaluate().
+// While they are imported by the Worker, they never run in the Worker, and are
+// typed for the browser environment. Functions that need to interact directly
+// with client.js run in the main world. Everything else runs in an isolated
+// realm.
+
+declare global {
+ // Globals set in the main world:
+
+ /** Sends a Cap'n Web RPC message from the Worker to the browser-side session. */
+ var __workshopExportSendToBrowser: (message: string) => void;
+ /** Receives the next Cap'n Web RPC message from the browser-side session. */
+ var __workshopExportReceiveFromBrowser: () => Promise;
+ /** Settles when the Gadget client module has finished loading. */
+ var __workshopExportModulePromise: Promise>;
+
+ // Globals set in the isolated realm:
+
+ /** Sanitizes a complete HTML document inside Puppeteer's isolated realm. */
+ var __workshopExportSanitizeHtml: (html: string) => HTMLHtmlElement;
+}
+
+// Functions that run in the main world:
+
+/** Delivers one Cap'n Web RPC message to the browser-side session. */
+export function sendToBrowser(message: string): void {
+ globalThis.__workshopExportSendToBrowser(message);
+}
+
+/** Receives one Cap'n Web RPC message from the browser-side session. */
+export function receiveFromBrowser(): Promise {
+ return globalThis.__workshopExportReceiveFromBrowser();
+}
+
+/** Waits for the Gadget client module to finish evaluating in the main world. */
+export async function waitForClientModule(): Promise {
+ await globalThis.__workshopExportModulePromise;
+}
+
+// Functions that run in the isolated realm:
+
+/** Assigns the title used by PDF viewers and the static HTML snapshot. */
+export function setDocumentTitle(title: string): void {
+ document.title = title;
+}
+
+/** Creates an inert, self-contained HTML snapshot unless it exceeds the byte limit. */
+export function createStaticHtmlSnapshot(csp: string, maxBytes: number): string {
+ const sanitized = globalThis.__workshopExportSanitizeHtml(
+ `\n${document.documentElement.outerHTML}`,
+ );
+ const ownerDocument = sanitized.ownerDocument;
+ const policy = ownerDocument.createElement("meta");
+ policy.httpEquiv = "Content-Security-Policy";
+ policy.content = csp;
+ const charset = ownerDocument.createElement("meta");
+ charset.setAttribute("charset", "utf-8");
+ ownerDocument.head!.prepend(charset, policy);
+ const html = `\n${sanitized.outerHTML}`;
+ if (new TextEncoder().encode(html).byteLength > maxBytes) {
+ throw new Error(`Gadget exports may not exceed ${maxBytes} bytes.`);
+ }
+ return html;
+}
+
+/** Returns fixed full-document dimensions after validating their pixel area. */
+export function getValidatedScreenshotClip(maxPixels: number) {
+ const root = document.documentElement;
+ const width = root.scrollWidth;
+ const height = root.scrollHeight;
+ if (!Number.isSafeInteger(width) || !Number.isSafeInteger(height) ||
+ width <= 0 || height <= 0 || width > Math.floor(maxPixels / height)) {
+ throw new Error(`Gadget screenshots may not exceed ${maxPixels} pixels.`);
+ }
+ return {x: 0, y: 0, width, height};
+}
diff --git a/packages/workshop-backend/src/browser-export-runtime.ts b/packages/workshop-backend/browser/browser-export-runtime.ts
similarity index 90%
rename from packages/workshop-backend/src/browser-export-runtime.ts
rename to packages/workshop-backend/browser/browser-export-runtime.ts
index e3696e60b..c9ed4aede 100644
--- a/packages/workshop-backend/src/browser-export-runtime.ts
+++ b/packages/workshop-backend/browser/browser-export-runtime.ts
@@ -1,8 +1,8 @@
import { RpcSession, RpcStub, RpcTarget, type RpcTransport } from "capnweb";
-// This code runs in the remote browser used for rendering the Gadget UI for export. It
-// runs before the Gadget client module is loaded and is mainly responsible for setting
-// up the RPC session to the Gadget server.
+// This code runs in the main world of the remote browser used for rendering the
+// Gadget UI for export. It runs before the Gadget client module is loaded and
+// is mainly responsible for setting up the RPC session to the Gadget server.
declare global {
/** Data URL containing the Gadget client module. */
diff --git a/packages/workshop-backend/browser/html-sanitizer-runtime.ts b/packages/workshop-backend/browser/html-sanitizer-runtime.ts
new file mode 100644
index 000000000..28b37c25f
--- /dev/null
+++ b/packages/workshop-backend/browser/html-sanitizer-runtime.ts
@@ -0,0 +1,21 @@
+import createDOMPurify from "dompurify";
+
+// This code runs in the isolated realm prior to HTML export. It provides an
+// HTML sanitizer API to functions called by page.evaluate(). Ideally these
+// functions could use the [HTML Sanitizer
+// API](https://developer.mozilla.org/en-US/docs/Web/API/HTML_Sanitizer_API) and
+// we wouldn't need this file or dompurify as a dependency at all, but Browser
+// Run uses an older version of Chromium that doesn't support the HTML Sanitizer
+// API. When Browser Run eventually updates Chromium to a supported version, we
+// can get rid of this file and dompurify.
+
+declare global {
+ /** Sanitizes a complete HTML document inside Puppeteer's isolated realm. */
+ var __workshopExportSanitizeHtml: (html: string) => HTMLHtmlElement;
+}
+
+const purifier = createDOMPurify(window);
+globalThis.__workshopExportSanitizeHtml = html => purifier.sanitize(html, {
+ WHOLE_DOCUMENT: true,
+ RETURN_DOM: true,
+}) as HTMLHtmlElement;
diff --git a/packages/workshop-backend/build-browser-runtime.mjs b/packages/workshop-backend/build-browser-runtime.mjs
index 972a8bbb6..14a25586a 100644
--- a/packages/workshop-backend/build-browser-runtime.mjs
+++ b/packages/workshop-backend/build-browser-runtime.mjs
@@ -4,9 +4,29 @@ import { fileURLToPath } from "node:url";
import { build } from "esbuild";
const packageDir = dirname(fileURLToPath(import.meta.url));
-const outputFile = resolve(packageDir, "src/generated/browser-export-runtime.txt");
-const result = await build({
- entryPoints: [resolve(packageDir, "src/browser-export-runtime.ts")],
+const runtimeOutputFile = resolve(packageDir, "src/generated/browser-export-runtime.txt");
+const sanitizerOutputFile = resolve(packageDir, "src/generated/html-sanitizer-runtime.txt");
+const pageOutputFile = resolve(packageDir, "src/generated/browser-export-page.js");
+
+const runtimeResult = await build({
+ entryPoints: [resolve(packageDir, "browser/browser-export-runtime.ts")],
+ bundle: true,
+ format: "iife",
+ platform: "browser",
+ target: "es2025",
+ minify: true,
+ write: false,
+});
+const pageResult = await build({
+ entryPoints: [resolve(packageDir, "browser/browser-export-page.ts")],
+ bundle: true,
+ format: "esm",
+ platform: "browser",
+ target: "es2025",
+ write: false,
+});
+const sanitizerResult = await build({
+ entryPoints: [resolve(packageDir, "browser/html-sanitizer-runtime.ts")],
bundle: true,
format: "iife",
platform: "browser",
@@ -14,9 +34,15 @@ const result = await build({
minify: true,
write: false,
});
-const contents = new TextDecoder().decode(result.outputFiles[0].contents);
-if (!existsSync(outputFile) || readFileSync(outputFile, "utf8") !== contents) {
- mkdirSync(dirname(outputFile), { recursive: true });
- writeFileSync(outputFile, contents);
+writeIfChanged(runtimeOutputFile, runtimeResult.outputFiles[0].contents);
+writeIfChanged(sanitizerOutputFile, sanitizerResult.outputFiles[0].contents);
+writeIfChanged(pageOutputFile, pageResult.outputFiles[0].contents);
+
+function writeIfChanged(outputFile, bytes) {
+ const contents = new TextDecoder().decode(bytes);
+ if (!existsSync(outputFile) || readFileSync(outputFile, "utf8") !== contents) {
+ mkdirSync(dirname(outputFile), { recursive: true });
+ writeFileSync(outputFile, contents);
+ }
}
diff --git a/packages/workshop-backend/format-blueprints/workspace-docs.gadget b/packages/workshop-backend/format-blueprints/workspace-docs.gadget
index e8d08bfa0..1e202760a 100644
Binary files a/packages/workshop-backend/format-blueprints/workspace-docs.gadget and b/packages/workshop-backend/format-blueprints/workspace-docs.gadget differ
diff --git a/packages/workshop-backend/format-blueprints/workspace-docs.json b/packages/workshop-backend/format-blueprints/workspace-docs.json
index 2fb902338..6200bc96b 100644
--- a/packages/workshop-backend/format-blueprints/workspace-docs.json
+++ b/packages/workshop-backend/format-blueprints/workspace-docs.json
@@ -10,5 +10,5 @@
"icon": "fileText"
},
"author": { "type": "user", "name": "Cloudflare", "id": "agent@cloudflare.com" },
- "revision": 4
+ "revision": 8
}
diff --git a/packages/workshop-backend/format-blueprints/workspace-sheets.gadget b/packages/workshop-backend/format-blueprints/workspace-sheets.gadget
index 84102c1a1..afac0efd9 100644
Binary files a/packages/workshop-backend/format-blueprints/workspace-sheets.gadget and b/packages/workshop-backend/format-blueprints/workspace-sheets.gadget differ
diff --git a/packages/workshop-backend/format-blueprints/workspace-sheets.json b/packages/workshop-backend/format-blueprints/workspace-sheets.json
index b1e079c89..c2a25129a 100644
--- a/packages/workshop-backend/format-blueprints/workspace-sheets.json
+++ b/packages/workshop-backend/format-blueprints/workspace-sheets.json
@@ -9,5 +9,5 @@
"icon": "table"
},
"author": { "type": "user", "name": "Cloudflare", "id": "agent@cloudflare.com" },
- "revision": 5
+ "revision": 8
}
diff --git a/packages/workshop-backend/format-blueprints/workspace-slides.gadget b/packages/workshop-backend/format-blueprints/workspace-slides.gadget
index 2af2e2bb1..37083e2f3 100644
Binary files a/packages/workshop-backend/format-blueprints/workspace-slides.gadget and b/packages/workshop-backend/format-blueprints/workspace-slides.gadget differ
diff --git a/packages/workshop-backend/format-blueprints/workspace-slides.json b/packages/workshop-backend/format-blueprints/workspace-slides.json
index 6e3800431..55c4f5dc0 100644
--- a/packages/workshop-backend/format-blueprints/workspace-slides.json
+++ b/packages/workshop-backend/format-blueprints/workspace-slides.json
@@ -9,5 +9,5 @@
"icon": "presentation"
},
"author": { "type": "user", "name": "Cloudflare", "id": "agent@cloudflare.com" },
- "revision": 3
+ "revision": 7
}
diff --git a/packages/workshop-backend/package.json b/packages/workshop-backend/package.json
index 758e642d8..9954b6f20 100644
--- a/packages/workshop-backend/package.json
+++ b/packages/workshop-backend/package.json
@@ -25,10 +25,12 @@
"capnweb-validate": "catalog:",
"diff": "^8.0.4",
"jose": "^6.2.8",
- "yjs": "^13.6.31"
+ "yjs": "^13.6.31",
+ "zod": "^4.4.3"
},
"devDependencies": {
"@cloudflare/vitest-pool-workers": "catalog:",
+ "dompurify": "^3.4.13",
"esbuild": "^0.28.1",
"typescript": "catalog:",
"vitest": "catalog:",
diff --git a/packages/workshop-backend/src/agent.ts b/packages/workshop-backend/src/agent.ts
index 23a79d9ed..5a102438e 100644
--- a/packages/workshop-backend/src/agent.ts
+++ b/packages/workshop-backend/src/agent.ts
@@ -490,8 +490,6 @@ Note that there is no index.html. Instead, client.js must build the entire UI us
Make Gadget UIs responsive and usable on both desktop and phones by default.
-Every Gadget UI can be exported to PDF using platform-owned controls outside the Gadget. Never add print or export UI to a Gadget and never call \`window.print()\`. When asked to support or improve PDF export, only add standard print CSS such as \`@media print\`, \`@page\`, and CSS fragmentation properties so the PDF remains readable.
-
Both the client and server run inside a strictly isolated sandbox. They cannot make requests to the Internet, e.g. by calling \`fetch()\`. Instead, a Gadget communicates with the outside world strictly through its "bindings", that is, the Cloudflare Workers \`env\` API, which code in the Durable Object class can access as \`this.env\`.
Note that the iframe sandbox on the client side prohibits modal popup boxes like alert() and confirm(), so do not use those.
@@ -544,6 +542,61 @@ If you need \`RpcTarget\` in server.js, you can import it from "cloudflare:worke
* Clients may frequently reload, and there is no client-side storage, so there is no way to track long-lived "sessions". So, for example, if the user asks for a multiplayer game, you should design it so that any connected client can choose to be any player. If it's turn-based, you can just let any client make any move. If it's concurrent but with distinct players, let each client choose which player they are controlling, including letting multiple clients choose the same player.
* If a Gadget contains a README.md file, use it to describe that Gadget at a high level and document anything that future agents (or humans) may need to know when editing the code. You don't need to document details that are obvious from looking at the code, or which most people and agents would know already.
+## Exporting files from Gadgets
+
+Every Gadget UI can be exported to HTML or PDF using platform-owned controls outside the Gadget. Never add print or export UI to a Gadget and never call \`window.print()\`. Browser-mode PDF exports render using print media; HTML, PNG, and JPEG exports render using screen media. When asked to support or improve PDF export, use standard print CSS such as \`@media print\`, \`@page\`, and CSS fragmentation properties so the output remains readable.
+
+During a browser-mode export, client.js is initialized with another special global variable named \`gadgetExportFormatId\`. This variable is only defined during export; during normal interactive rendering, referencing it directly throws a \`ReferenceError\`. Guard access with \`typeof gadgetExportFormatId !== "undefined"\` or read \`globalThis.gadgetExportFormatId\`. Use \`gadgetExportFormatId\` when the Gadget supports multiple HTML, PDF, PNG, or JPEG export variants. Do not declare or import \`gadgetExportFormatId\` in client.js.
+
+The Workshop waits for client.js, including any top-level \`await\`, to finish before capturing a browser-mode export. Use top-level \`await\` when the initial UI must load data or otherwise complete asynchronous rendering before capture. For example:
+
+\`\`\`
+let report = await gadget.getReport();
+let exportFormat = globalThis.gadgetExportFormatId;
+document.body.className = exportFormat === "compact-pdf" ? "compact" : "interactive";
+document.body.append(renderReport(report));
+\`\`\`
+
+To add, replace, or disable export formats, server.js may export a class named \`ExportHandler\`, which must extend \`WorkerEntrypoint\`. Its \`getExportFormats(gadget)\` method returns the complete list of formats, and its \`export(gadget, id)\` method returns a \`ReadableStream\` for formats whose mode is \`"server"\`. Read any needed Gadget state before \`export()\` returns; do not capture the borrowed \`gadget\` parameter in the returned stream. If \`getExportFormats(gadget)\` returns only browser-mode formats, do not implement \`export(gadget, id)\`. \`export\` is valid as a JavaScript class method name; write it directly as \`async export(gadget, id)\`, without quoting it or using a computed property. Browser mode supports \`text/html\`, \`application/pdf\`, \`image/png\`, and \`image/jpeg\`; server mode supports any media type. Each format must contain a unique non-empty \`id\`, a \`label\`, a \`mode\`, a \`contentType\`, and a \`fileExtension\` beginning with a dot. Returning an empty list disables export. The Workshop supplies default HTML and PDF formats only when server.js does not export \`ExportHandler\` at all.
+
+For example, this replaces the defaults with one browser-mode PDF variant and one server-generated CSV format:
+
+\`\`\`
+import { WorkerEntrypoint } from "cloudflare:workers";
+
+export class ExportHandler extends WorkerEntrypoint {
+ async getExportFormats(gadget) {
+ return [
+ {
+ id: "pdf",
+ label: "PDF",
+ mode: "browser",
+ contentType: "application/pdf",
+ fileExtension: ".pdf",
+ },
+ {
+ id: "csv",
+ label: "CSV",
+ mode: "server",
+ contentType: "text/csv",
+ fileExtension: ".csv",
+ },
+ ];
+ }
+
+ async export(gadget, id) {
+ if (id !== "csv") throw new Error(\`Unknown export format: \${id}\`);
+ let csv = await gadget.getCsv();
+ return new ReadableStream({
+ start(controller) {
+ controller.enqueue(new TextEncoder().encode(csv));
+ controller.close();
+ },
+ });
+ }
+}
+\`\`\`
+
# Persistent Stubs and \`ctx.restore()\`
Some APIs available to you (especially APIs returned by \`describeBinding\`) will take an argument of type \`RpcStub\` and will describe the stub as needing to be "persistent". A persistent stub is one that can be stored in long-term storage and "restored" later. Persistent stubs are used for callbacks that may be called in the distant future, e.g. to implement "hooks" that start the Gadget when certain events occur.
diff --git a/packages/workshop-backend/src/browser-export.ts b/packages/workshop-backend/src/browser-export.ts
index b792b2b1e..4297d5ae8 100644
--- a/packages/workshop-backend/src/browser-export.ts
+++ b/packages/workshop-backend/src/browser-export.ts
@@ -1,7 +1,18 @@
import { launch, type Page } from "@cloudflare/puppeteer";
import { RpcSession, type RpcStub, type RpcTransport } from "capnweb";
import { createLogger } from "@gadgets/backend-utils/logger";
+import type { GadgetExportFormat } from "@gadgets/workshop-shared/api";
import BROWSER_EXPORT_RUNTIME from "./generated/browser-export-runtime.txt";
+import HTML_SANITIZER_RUNTIME from "./generated/html-sanitizer-runtime.txt";
+import {
+ createStaticHtmlSnapshot,
+ getValidatedScreenshotClip,
+ receiveFromBrowser,
+ sendToBrowser,
+ setDocumentTitle,
+ waitForClientModule,
+} from "./generated/browser-export-page.js";
+import { createExportDeadline, limitExportStream, MAX_EXPORT_BYTES } from "./export-limits";
type BrowserExportLogFields = {
event?: string;
@@ -10,18 +21,14 @@ type BrowserExportLogFields = {
const logger = createLogger({ component: "workshop.browser-export" });
-/** Wall-clock budget covering launch, rendering, and delivery of the entire export. */
-const MAX_EXPORT_DURATION_MS = 30_000;
-/** Largest export the Workshop will stream. Enforced while streaming, never buffered in full. */
-const MAX_EXPORT_BYTES = 100 * 1024 * 1024;
-/** Quiet period indicating that the client has finished its initial DOM updates. */
-const DOM_SETTLE_MS = 250;
/** Budget for releasing the browser session once an export has settled. */
const BROWSER_CLOSE_TIMEOUT_MS = 10_000;
/** Maximum number of pending Worker-to-browser RPC messages. */
const MAX_PENDING_RPC_SENDS = 1024;
/** Maximum total string length across all pending Worker-to-browser RPC messages. */
const MAX_PENDING_RPC_SEND_CHARS = 32 * 1024 * 1024;
+/** Largest full-page screenshot capture, before image compression. */
+const MAX_SCREENSHOT_PIXELS = 25_000_000;
/** CSP ignores `sandbox` in a meta tag, so serve the document through interception with a header. */
const EXPORT_DOCUMENT_URL = "https://gadget-export.invalid/";
// TODO: CSP and request interception do not cover WebRTC/STUN. The same gap exists for Gadgets
@@ -31,24 +38,15 @@ const EXPORT_DOCUMENT_CSP = "default-src 'none'; frame-src 'none'; script-src da
"style-src data: 'unsafe-inline'; img-src data: blob:; media-src data: blob:; " +
"font-src data:; object-src 'none'; base-uri 'none'; form-action 'none'; " +
"connect-src 'none'; sandbox allow-scripts;";
+const STATIC_HTML_CSP = "default-src 'none'; frame-src 'none'; script-src 'none'; " +
+ "style-src data: 'unsafe-inline'; img-src data:; media-src data:; font-src data:; " +
+ "object-src 'none'; base-uri 'none'; form-action 'none'; connect-src 'none';";
-function createDeadline(ms: number, message: string) {
- let expired = Promise.withResolvers();
- let timer = setTimeout(() => expired.reject(new Error(message)), ms);
- expired.promise.catch(() => {});
-
- return {
- race(work: Promise): Promise {
- return Promise.race([work, expired.promise]);
- },
- clear(): void {
- clearTimeout(timer);
- },
- onExpire(callback: () => Promise): void {
- void expired.promise.catch(callback).catch(() => {});
- },
- };
-}
+// Puppeteer's isolated realm is intentionally absent from its public bundled types, though its
+// own security-sensitive DOM helpers use it. Keep this narrow until the method is public.
+type FrameWithIsolatedRealm = ReturnType & {
+ isolatedRealm(): Pick;
+};
async function closeBrowser(browser: Awaited>): Promise {
let timer: ReturnType;
@@ -90,10 +88,7 @@ export class BrowserRpcTransport implements RpcTransport {
++this.#pendingSendCount;
this.#pendingSendChars += message.length;
let delivered = this.#sendChain.then(() =>
- this.#untilAborted(this.page.evaluate(
- text => globalThis.__workshopExportSendToBrowser(text),
- message,
- )));
+ this.#untilAborted(this.page.evaluate(sendToBrowser, message)));
let settled = delivered.finally(() => {
--this.#pendingSendCount;
this.#pendingSendChars -= message.length;
@@ -104,7 +99,7 @@ export class BrowserRpcTransport implements RpcTransport {
async receive(): Promise {
let message = await this.#untilAborted(
- this.page.evaluate(() => globalThis.__workshopExportReceiveFromBrowser()),
+ this.page.evaluate(receiveFromBrowser),
);
if (typeof message !== "string") {
throw new Error("The Gadget export RPC message from the browser was not a string.");
@@ -132,13 +127,14 @@ function scriptUrl(source: string): string {
return `data:text/javascript;charset=utf-8,${encodeURIComponent(source)}`;
}
-function makeExportHtml(clientCode: string): string {
+function makeExportHtml(clientCode: string, formatId: string): string {
let clientPrefix = String.raw`//# sourceURL=client.js
const { gadget, RpcStub, RpcTarget } = globalThis.__workshopExportRuntime;
delete globalThis.__workshopExportRuntime;
`;
let clientUrl = scriptUrl(clientPrefix + clientCode);
let runtimeUrl = scriptUrl(
+ `globalThis.gadgetExportFormatId = ${JSON.stringify(formatId)};\n` +
`globalThis.__workshopExportClientUrl = ${JSON.stringify(clientUrl)};\n` +
BROWSER_EXPORT_RUNTIME);
@@ -153,102 +149,21 @@ delete globalThis.__workshopExportRuntime;