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 = "\nSnapshot"; + 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; `; } -/** Limits the size of the exported file streamed back to the client. */ -export function limitStream( - source: ReadableStream, - maxBytes: number, -): ReadableStream { - let total = 0; - let limiter = new TransformStream({ - transform(chunk, controller) { - total += chunk.byteLength; - if (total > maxBytes) { - controller.error(new Error(`Gadget exports may not exceed ${maxBytes} bytes.`)); - return; - } - controller.enqueue(chunk); - }, - }); - void source.pipeTo(limiter.writable).catch(() => {}); - return limiter.readable; -} - -/** Releases the browser session once the export stream completes, fails, or is cancelled. */ -function releaseWhenSettled( - source: ReadableStream, - release: () => Promise, -): ReadableStream { - let reader = source.getReader(); - return new ReadableStream({ - async pull(controller) { - let chunk; - try { - chunk = await reader.read(); - } catch (error) { - await release(); - throw error; - } - if (chunk.done) { - await release(); - controller.close(); - } else { - controller.enqueue(chunk.value); - } - }, - async cancel(reason) { - await reader.cancel(reason).catch(() => {}); - await release(); - }, - }); -} - -async function waitForDomSettled(page: Page): Promise { - await page.evaluate(async (quietMs: number) => { - const browser = globalThis as unknown as { - __workshopExportModulePromise: Promise>; - document: { documentElement: unknown }; - MutationObserver: new(callback: () => void) => { - observe(target: unknown, options: Record): void; - disconnect(): void; - }; - }; - // Make sure that client module has been loaded before watching DOM. - await browser.__workshopExportModulePromise; - await new Promise(resolve => { - let timer: ReturnType; - let observer = new browser.MutationObserver(() => { - clearTimeout(timer); - timer = setTimeout(finish, quietMs); - }); - function finish() { - observer.disconnect(); - resolve(); - } - observer.observe(browser.document.documentElement, { - subtree: true, - childList: true, - attributes: true, - characterData: true, - }); - timer = setTimeout(finish, quietMs); - }); - }, DOM_SETTLE_MS); -} - /** - * Renders a Gadget's UI as PDF in a remote browser and streams the bytes back. + * Renders a Gadget's browser-mode export and streams the bytes back. * * Takes ownership of `gadget` and disposes it once the export settles. The * returned stream must be consumed or cancelled: the browser session stays open * until it settles or times out. */ -export async function renderGadgetPdf( +export async function renderGadgetInBrowser( browserBinding: BrowserRun, clientCode: string, documentTitle: string, gadget: RpcStub, + format: GadgetExportFormat, ): Promise> { - let deadline = createDeadline(MAX_EXPORT_DURATION_MS, "Browser export timed out."); + const deadline = createExportDeadline("Browser export timed out."); let launchPromise = launch(browserBinding); let browser: Awaited>; @@ -284,23 +199,27 @@ export async function renderGadgetPdf( } return releasePromise; }; - deadline.onExpire(release); - try { let source = await deadline.race((async () => { let page = await browser.newPage(); + await page.emulateMediaType( + format.contentType === "application/pdf" ? "print" : "screen", + ); await page.setRequestInterception(true); page.on("request", (request) => { let url = request.url(); - if (url === EXPORT_DOCUMENT_URL && request.isNavigationRequest() && - request.frame() === page.mainFrame()) { - void request.respond({ - status: 200, - contentType: "text/html", - headers: {"Content-Security-Policy": EXPORT_DOCUMENT_CSP}, - body: makeExportHtml(clientCode), - }); - } else if (url === "about:blank" || url.startsWith("data:") || url.startsWith("blob:")) { + if (request.isNavigationRequest()) { + if (url === EXPORT_DOCUMENT_URL && request.frame() === page.mainFrame()) { + void request.respond({ + status: 200, + contentType: "text/html", + headers: {"Content-Security-Policy": EXPORT_DOCUMENT_CSP}, + body: makeExportHtml(clientCode, format.id), + }); + } else { + void request.abort(); + } + } else if (url.startsWith("data:") || url.startsWith("blob:")) { void request.continue(); } else { void request.abort(); @@ -311,24 +230,71 @@ export async function renderGadgetPdf( page.on("close", () => transport.abort(new Error("Browser page closed."))); let rpcSession = new RpcSession(transport, gadget); sessionCloser = rpcSession.getRemoteMain(); - await waitForDomSettled(page); - await page.emulateMediaType("print"); - await page.evaluate(title => { - let browser = globalThis as unknown as { document: { title: string } }; - browser.document.title = title; - }, documentTitle); - return page.createPDFStream({ - preferCSSPageSize: true, - printBackground: true, - waitForFonts: true, - }); + await page.evaluate(waitForClientModule); + const frame = page.mainFrame() as FrameWithIsolatedRealm; + const isolatedRealm = frame.isolatedRealm(); + await isolatedRealm.evaluate(setDocumentTitle, documentTitle); + switch (format.contentType) { + case "application/pdf": + return page.createPDFStream({ + preferCSSPageSize: true, + printBackground: true, + waitForFonts: true, + }); + case "text/html": { + await isolatedRealm.evaluate(HTML_SANITIZER_RUNTIME); + const html = await isolatedRealm.evaluate( + createStaticHtmlSnapshot, + STATIC_HTML_CSP, + MAX_EXPORT_BYTES, + ); + return streamBytes(new TextEncoder().encode(html)); + } + case "image/png": { + const clip = await isolatedRealm.evaluate( + getValidatedScreenshotClip, + MAX_SCREENSHOT_PIXELS, + ); + return streamBytes(await page.screenshot({ + type: "png", + clip, + captureBeyondViewport: true, + })); + } + case "image/jpeg": { + const clip = await isolatedRealm.evaluate( + getValidatedScreenshotClip, + MAX_SCREENSHOT_PIXELS, + ); + return streamBytes(await page.screenshot({ + type: "jpeg", + clip, + captureBeyondViewport: true, + })); + } + default: + throw new Error(`Unsupported browser export content type: ${format.contentType}`); + } })()); - return releaseWhenSettled(limitStream(source, MAX_EXPORT_BYTES), release); + return limitExportStream(source, deadline, release); } catch (error) { // Deliberately omits the caught value: failures here can carry Gadget-authored exception text, // which must not reach logs or the external issue Reporter. logger.warn("failed to render gadget export", { event: "gadget.export.render.failed" }); - await release(); + if (error === deadline.error) { + void release(); + } else { + await release(); + } throw error; } } + +function streamBytes(bytes: Uint8Array): ReadableStream { + return new ReadableStream({ + start(controller) { + controller.enqueue(bytes); + controller.close(); + }, + }); +} diff --git a/packages/workshop-backend/src/export-limits.ts b/packages/workshop-backend/src/export-limits.ts new file mode 100644 index 000000000..918cde7d6 --- /dev/null +++ b/packages/workshop-backend/src/export-limits.ts @@ -0,0 +1,80 @@ +/** Wall-clock budget covering generation and delivery of an export. */ +export const MAX_EXPORT_DURATION_MS = 30_000; + +/** Largest export the Workshop will stream. */ +export const MAX_EXPORT_BYTES = 100 * 1024 * 1024; + +/** A deadline shared by export setup and stream delivery. */ +export type ExportDeadline = ReturnType; + +/** Creates a rejectable wall-clock deadline for an export operation. */ +export function createExportDeadline(message: string, durationMs = MAX_EXPORT_DURATION_MS) { + const expired = Promise.withResolvers(); + const error = new Error(message); + const timer = setTimeout(() => expired.reject(error), durationMs); + expired.promise.catch(() => {}); + + return { + error, + race(work: Promise): Promise { + return Promise.race([work, expired.promise]); + }, + clear(): void { + clearTimeout(timer); + }, + onExpire(callback: (reason: Error) => Promise): void { + void expired.promise.catch(callback).catch(() => {}); + }, + }; +} + +/** Enforces export size and duration while releasing owned resources on settlement. */ +export function limitExportStream( + source: ReadableStream, + deadline: ExportDeadline, + release: () => Promise = async () => {}, + maxBytes = MAX_EXPORT_BYTES, +): ReadableStream { + const reader = source.getReader(); + let total = 0; + let settlePromise: Promise | undefined; + const settle = (cancelSource: boolean, reason?: unknown) => { + if (!settlePromise) { + deadline.clear(); + settlePromise = (async () => { + await release(); + // Cancellation is advisory. A hostile Gadget stream must not keep the + // export or its owned browser alive by returning a promise that never settles. + if (cancelSource) void reader.cancel(reason).catch(() => {}); + })(); + } + return settlePromise; + }; + + deadline.onExpire(reason => settle(true, reason)); + + return new ReadableStream({ + async pull(controller) { + try { + const chunk = await deadline.race(reader.read()); + if (chunk.done) { + await settle(false); + controller.close(); + return; + } + total += chunk.value.byteLength; + if (total > maxBytes) { + throw new Error(`Gadget exports may not exceed ${maxBytes} bytes.`); + } + controller.enqueue(chunk.value); + } catch (error) { + const cleanup = settle(true, error); + if (error !== deadline.error) await cleanup; + throw error; + } + }, + async cancel(reason) { + await settle(true, reason); + }, + }); +} diff --git a/packages/workshop-backend/src/gadget-export.ts b/packages/workshop-backend/src/gadget-export.ts new file mode 100644 index 000000000..c2c4b75f9 --- /dev/null +++ b/packages/workshop-backend/src/gadget-export.ts @@ -0,0 +1,142 @@ +import type { GadgetExportFormat } from "@gadgets/workshop-shared/api"; +import type { DurableObject, RpcStub, RpcTarget, WorkerEntrypoint } from "cloudflare:workers"; +import { z } from "zod"; +import { createExportDeadline, limitExportStream } from "./export-limits"; + +/** Name of the optional Gadget export handler entrypoint. */ +export const GADGET_EXPORT_ENTRYPOINT = "ExportHandler"; + +type GadgetExportCapability = + RpcStub>; + +/** Optional Worker entrypoint exported by a Gadget to customize file exports. */ +export interface GadgetExportEntrypoint + extends WorkerEntrypoint { + /** Lists all export formats supported by the Gadget. */ + getExportFormats(gadget: GadgetExportCapability): Promise; + + /** Produces a server-mode export without retaining `gadget` after this call returns. */ + export(gadget: GadgetExportCapability, id: string): Promise>; +} + +const MAX_EXPORT_FORMATS = 32; +const MAX_EXPORT_ID_LENGTH = 128; +const MAX_EXPORT_LABEL_LENGTH = 128; +const MAX_CONTENT_TYPE_LENGTH = 255; +const MAX_FILE_EXTENSION_LENGTH = 16; + +const BROWSER_CONTENT_TYPES = new Set([ + "text/html", + "application/pdf", + "image/png", + "image/jpeg", +]); + +function boundedString(name: string, maxLength: number) { + return z.string() + .min(1, `Gadget export format ${name} must be between 1 and ${maxLength} characters.`) + .max(maxLength, + `Gadget export format ${name} must be between 1 and ${maxLength} characters.`); +} + +const EXPORT_FORMAT_SCHEMA: z.ZodType = z.object({ + id: boundedString("id", MAX_EXPORT_ID_LENGTH), + label: boundedString("label", MAX_EXPORT_LABEL_LENGTH), + mode: z.enum(["browser", "server"]), + contentType: boundedString("contentType", MAX_CONTENT_TYPE_LENGTH) + .regex(/^[-!#$%&'*+.^_`|~0-9A-Za-z]+\/[-!#$%&'*+.^_`|~0-9A-Za-z]+$/, + "Gadget export format has an invalid content type."), + fileExtension: boundedString("fileExtension", MAX_FILE_EXTENSION_LENGTH) + .regex(/^\.[A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?$/, + "Gadget export format has an invalid file extension."), +}).superRefine((format, context) => { + if (format.mode === "browser" && !BROWSER_CONTENT_TYPES.has(format.contentType)) { + context.addIssue({ + code: "custom", + path: ["contentType"], + message: `Browser export format ${format.id} has an unsupported content type.`, + }); + } +}); + +const EXPORT_FORMATS_SCHEMA = z.array(EXPORT_FORMAT_SCHEMA) + .max(MAX_EXPORT_FORMATS, `A Gadget may define at most ${MAX_EXPORT_FORMATS} export formats.`) + .superRefine((formats, context) => { + const ids = new Set(); + formats.forEach((format, index) => { + if (ids.has(format.id)) { + context.addIssue({ + code: "custom", + path: [index, "id"], + message: `Gadget export format id is not unique: ${format.id}`, + }); + } + ids.add(format.id); + }); + }); + +const DEFAULT_EXPORT_FORMATS: GadgetExportFormat[] = [ + { + id: "html", + label: "HTML", + mode: "browser", + contentType: "text/html", + fileExtension: ".html", + }, + { + id: "pdf", + label: "PDF", + mode: "browser", + contentType: "application/pdf", + fileExtension: ".pdf", + }, +]; + +/** Returns fresh copies of the default HTML and PDF export formats. */ +export function defaultExportFormats(): GadgetExportFormat[] { + return DEFAULT_EXPORT_FORMATS.map(format => ({...format})); +} + +/** Validates and normalizes export format metadata returned by Gadget code. */ +export function validateExportFormats(value: unknown): GadgetExportFormat[] { + const result = EXPORT_FORMATS_SCHEMA.safeParse(value); + if (!result.success) { + throw new Error(result.error.issues[0]?.message ?? "Invalid Gadget export formats."); + } + return result.data; +} + +/** Reads custom formats, returning null only when the named entrypoint is absent. */ +export async function readCustomExportFormats( + handler: {getExportFormats(gadget: Gadget): Promise}, + gadget: Gadget, +): Promise { + const deadline = createExportDeadline("Listing Gadget export formats timed out."); + try { + return validateExportFormats(await deadline.race(handler.getExportFormats(gadget))); + } catch (error) { + if (error instanceof Error && + error.message === `Worker has no such entrypoint: ${GADGET_EXPORT_ENTRYPOINT}`) { + return null; + } + throw error; + } finally { + deadline.clear(); + } +} + +/** Applies the platform export limits to a Gadget-generated server stream. */ +export async function exportServerFormat( + createStream: () => Promise>, +): Promise> { + const deadline = createExportDeadline("Gadget export timed out."); + const streamPromise = createStream(); + try { + const source = await deadline.race(streamPromise); + return limitExportStream(source, deadline); + } catch (error) { + deadline.clear(); + void streamPromise.then(stream => stream.cancel(error), () => {}).catch(() => {}); + throw error; + } +} diff --git a/packages/workshop-backend/src/overseer.ts b/packages/workshop-backend/src/overseer.ts index 43361797e..98c5f34bc 100644 --- a/packages/workshop-backend/src/overseer.ts +++ b/packages/workshop-backend/src/overseer.ts @@ -40,12 +40,20 @@ import { collectSlashCommands, invokeSlashCommand } from "./slash-commands"; import { createWorkshopLogger, obsContext, traced } from "./observability"; import { wrapDoStubForTelemetry } from "./do-telemetry"; import type { ChatGatewayRpcTarget, SubmitExternalMessageResult } from "@gadgets/workshop-shared/external-message-gateway"; +import type { GadgetExportFormat } from "@gadgets/workshop-shared/api"; import { assertChatAttachmentSupportedByProvider, isAllowedChatAttachmentImageMimeType, validateChatAttachmentUpload, } from "./chat-attachment-validation"; -import { renderGadgetPdf } from "./browser-export"; +import { renderGadgetInBrowser } from "./browser-export"; +import { + defaultExportFormats, + exportServerFormat, + GADGET_EXPORT_ENTRYPOINT, + type GadgetExportEntrypoint, + readCustomExportFormats, +} from "./gadget-export"; const logger = createWorkshopLogger("workshop.overseer"); export const AGENT_RUNNING_ERROR_MESSAGE = "Agent is running, wait for it to finish."; @@ -2521,13 +2529,85 @@ class OverseerImpl implements AgentHooks { }, }); - // Explicitly construct at RpcStub around the proxy to work around a workerd bug where + // Explicitly construct an RpcStub around the proxy to work around a workerd bug where // returning an RpcTarget proxy as the top-level return value from an RPC isn't detected // correctly. // @ts-expect-error NativeRpcStub still has infinite recursion problems, fixed in Cap'n Web. return new NativeRpcStub(proxy) as RpcStub; } + getGadgetUiBundle(gadgetId: WorkpieceId, chatId?: number): UiBundle | null { + this.checkChatExistsAndMaterializeDrafts(chatId); + + let {ydoc} = this.buildYDoc("current"); + if (chatId !== undefined) { + this.getProposedChanges(chatId).forEach(({update}) => { + if (update !== undefined) Y.applyUpdateV2(ydoc, update); + }); + } + + let file = ydoc.getMap(this.gadgetRootName(gadgetId)).get("client.js"); + return file ? {jsCode: file.toString()} : null; + } + + async getGadgetExportFormats(gadgetId: WorkpieceId, chatId?: number) + : Promise { + this.checkChatExistsAndMaterializeDrafts(chatId); + let resolved = await this.#resolveGadgetExportFormats(gadgetId, chatId); + resolved.gadget[Symbol.dispose](); + return resolved.formats; + } + + async exportGadget(gadgetId: WorkpieceId, formatId: string, chatId?: number) + : Promise> { + this.checkChatExistsAndMaterializeDrafts(chatId); + let {formats, handler, gadget} = await this.#resolveGadgetExportFormats(gadgetId, chatId); + using exportGadget = gadget; + let format = formats.find(candidate => candidate.id === formatId); + if (!format) throw new Error(`This Gadget does not support export format: ${formatId}`); + + if (format.mode === "server") { + if (!handler) throw new Error("The Gadget export handler is unavailable."); + return await exportServerFormat(() => + handler.export(exportGadget, format.id)); + } else { + let browser = this.env.BROWSER; + if (!browser) throw new Error("Gadget export is not configured for this deployment."); + let bundle = this.getGadgetUiBundle(gadgetId, chatId); + if (!bundle) throw new Error("This Gadget does not have a UI to export."); + let title = this.getGadgetRecord(gadgetId).title; + return renderGadgetInBrowser(browser, bundle.jsCode, title, exportGadget.move(), format); + } + } + + checkChatExistsAndMaterializeDrafts(chatId?: number): void { + if (chatId !== undefined) { + let meta = this.getChatMetaOrThrow(chatId); + if (!meta.activeAgent) this.materializeChatDraft(chatId, meta); + } + } + + async #resolveGadgetExportFormats(gadgetId: WorkpieceId, chatId?: number): Promise<{ + formats: GadgetExportFormat[]; + handler: Fetcher | null; + gadget: NativeRpcStub; + }> { + let handler = this.loadGadgetWorker(gadgetId, chatId) + .getEntrypoint(GADGET_EXPORT_ENTRYPOINT); + // getGadgetFacet() wraps this native stub for Cap'n Web's type system, but this path invokes + // native Worker RPC and needs its actual runtime type. + let gadget = await this.getGadgetFacet(gadgetId, chatId) as unknown as NativeRpcStub; + try { + let formats = await readCustomExportFormats(handler, gadget); + return formats === null + ? {formats: defaultExportFormats(), handler: null, gadget} + : {formats, handler, gadget}; + } catch (error) { + gadget[Symbol.dispose](); + throw error; + } + } + // Load a WorkerEntrypoint exported by the gadget, used to implement a hook. // // TODO: There should be a way to simulate hooks within the context of a particular chat thread, @@ -9230,30 +9310,7 @@ class GadgetClientImpl extends RpcTarget implements GadgetClient { } async getUiBundle(chatId?: number): Promise { - // TODO: Bundle the UI? For now we just return client.js. - if (chatId !== undefined) { - let meta = this.impl.getChatMetaOrThrow(chatId); - if (!meta.activeAgent) { - this.impl.materializeChatDraft(chatId, meta); - } - } - - let {ydoc} = this.impl.buildYDoc("current"); - - if (chatId !== undefined) { - this.impl.getProposedChanges(chatId).forEach(({update}) => { - if (update !== undefined) { - Y.applyUpdateV2(ydoc, update); - } - }); - } - - let file = ydoc.getMap(this.impl.gadgetRootName(this.id)).get("client.js"); - if (file) { - return { jsCode: file.toString() }; - } else { - return null; - } + return this.impl.getGadgetUiBundle(this.id, chatId); } async connectToGadget(chatId?: number): Promise> { @@ -9266,15 +9323,12 @@ class GadgetClientImpl extends RpcTarget implements GadgetClient { return this.impl.getGadgetFacet(this.id, chatId); } - async exportPdf(chatId?: number): Promise> { - // Read as possibly-undefined: self-hosted deployments may omit the binding (see env.d.ts). - let browser: BrowserRun | undefined = this.impl.env.BROWSER; - if (!browser) throw new Error("Gadget export is not configured for this deployment."); - let bundle = await this.getUiBundle(chatId); - if (!bundle) throw new Error("This Gadget does not have a UI to export."); - let gadget = await this.impl.getGadgetFacet(this.id, chatId); - let title = this.impl.getGadgetRecord(this.id).title; - return renderGadgetPdf(browser, bundle.jsCode, title, gadget); + async getExportFormats(chatId?: number): Promise { + return this.impl.getGadgetExportFormats(this.id, chatId); + } + + async export(formatId: string, chatId?: number): Promise> { + return this.impl.exportGadget(this.id, formatId, chatId); } async listBindings(chatId?: number): Promise { @@ -9502,10 +9556,7 @@ class UseGadgetClientInterface extends RpcTarget implements GadgetClient { if (chatId !== undefined) { this.#deny(); } - - let {ydoc} = this.impl.buildYDoc("current"); - let file = ydoc.getMap(this.impl.gadgetRootName(this.id)).get("client.js"); - return file ? { jsCode: file.toString() } : null; + return this.impl.getGadgetUiBundle(this.id); } async connectToGadget(chatId?: number): Promise> { @@ -9521,16 +9572,14 @@ class UseGadgetClientInterface extends RpcTarget implements GadgetClient { return this.impl.getGadgetFacet(this.id, undefined); } - async exportPdf(chatId?: number): Promise> { + async getExportFormats(chatId?: number): Promise { + if (chatId !== undefined) this.#deny(); + return this.impl.getGadgetExportFormats(this.id); + } + + async export(id: string, chatId?: number): Promise> { if (chatId !== undefined) this.#deny(); - // Read as possibly-undefined: self-hosted deployments may omit the binding (see env.d.ts). - let browser: BrowserRun | undefined = this.impl.env.BROWSER; - if (!browser) throw new Error("Gadget export is not configured for this deployment."); - let bundle = await this.getUiBundle(); - if (!bundle) throw new Error("This Gadget does not have a UI to export."); - let gadget = await this.impl.getGadgetFacet(this.id); - let title = this.impl.getGadgetRecord(this.id).title; - return renderGadgetPdf(browser, bundle.jsCode, title, gadget); + return this.impl.exportGadget(this.id, id); } // --- Denied methods (build-only) --- diff --git a/packages/workshop-backend/tsconfig.browser.json b/packages/workshop-backend/tsconfig.browser.json new file mode 100644 index 000000000..0504f918c --- /dev/null +++ b/packages/workshop-backend/tsconfig.browser.json @@ -0,0 +1,12 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "target": "ES2022", + "lib": ["ESNext", "DOM", "DOM.Iterable"], + "module": "ESNext", + "moduleResolution": "bundler", + "types": [], + "noEmit": true + }, + "include": ["browser"] +} diff --git a/packages/workshop-backend/vite.config.ts b/packages/workshop-backend/vite.config.ts index 0c6d5451f..5f4d4a328 100644 --- a/packages/workshop-backend/vite.config.ts +++ b/packages/workshop-backend/vite.config.ts @@ -28,7 +28,7 @@ export default { * writes into the same package tracking hashes as its input, which vp declines to cache. */ build: { - command: ['node build-browser-runtime.mjs', 'tsc'], + command: ['node build-browser-runtime.mjs', 'tsc --project tsconfig.browser.json', 'tsc'], dependsOn: ['build:format-blueprints'], cache: false, }, diff --git a/packages/workshop-frontend/src/GadgetEditor.tsx b/packages/workshop-frontend/src/GadgetEditor.tsx index 4eec39a21..f3619af3d 100644 --- a/packages/workshop-frontend/src/GadgetEditor.tsx +++ b/packages/workshop-frontend/src/GadgetEditor.tsx @@ -1601,7 +1601,6 @@ export default function GadgetEditor() { gadget={selectedGadgetStub} gadgetTitle={selectedGadgetSummary?.title ?? 'Gadget'} chatId={previewChatId} - disabled={activeTab !== 'app' || previewMode} /> )} diff --git a/packages/workshop-frontend/src/GadgetExportMenu.test.tsx b/packages/workshop-frontend/src/GadgetExportMenu.test.tsx new file mode 100644 index 000000000..b33663121 --- /dev/null +++ b/packages/workshop-frontend/src/GadgetExportMenu.test.tsx @@ -0,0 +1,233 @@ +// @vitest-environment jsdom +/* eslint-disable react/react-in-jsx-scope */ + +import { act, type ComponentProps, type ReactElement, type ReactNode } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { RpcStub } from 'capnweb' +import type { GadgetClient } from '@gadgets/workshop-shared/api' +import type { GadgetExportFormat } from '@gadgets/workshop-shared/api' + +const testGlobal = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean } +const previousActEnvironment = testGlobal.IS_REACT_ACT_ENVIRONMENT +testGlobal.IS_REACT_ACT_ENVIRONMENT = true +afterAll(() => { + if (previousActEnvironment === undefined) delete testGlobal.IS_REACT_ACT_ENVIRONMENT + else testGlobal.IS_REACT_ACT_ENVIRONMENT = previousActEnvironment +}) + +const mocks = vi.hoisted(() => ({ + saveStreamToFile: vi.fn<( + createStream: () => Promise>, + filename: string, + fileType: {description: string; contentType: string; extension: string}, + ) => Promise>(), + toast: vi.fn<(toast: unknown) => void>(), +})) + +vi.mock('@cloudflare/kumo', () => { + const DropdownMenu = Object.assign( + ({ children, onOpenChange }: { + children: ReactNode + onOpenChange?: (open: boolean) => void + }) => ( +
+ + + {children} +
+ ), + { + Trigger: ({ render }: { render: ReactElement }) => render, + Content: ({ children }: { children: ReactNode }) =>
{children}
, + Item: ({ children, onClick }: { children: ReactNode; onClick?: () => void }) => ( + + ), + }, + ) + return { + DropdownMenu, + Tooltip: ({ children }: { children: ReactNode }) => <>{children}, + useKumoToastManager: () => ({ add: mocks.toast }), + } +}) + +vi.mock('@phosphor-icons/react', () => ({ + DownloadSimple: () => download, +})) + +vi.mock('./components/WorkshopControls', () => ({ + WorkshopIconButton: ({ children, ...props }: ComponentProps<'button'>) => ( + + ), +})) + +vi.mock('./fileTransfers', () => ({ + makeExportFilename: (title: string, extension: string) => `${title}${extension}`, + saveStreamToFile: mocks.saveStreamToFile, +})) + +import GadgetExportMenu from './GadgetExportMenu' + +let container: HTMLDivElement +let root: Root + +beforeEach(() => { + mocks.saveStreamToFile.mockReset() + mocks.toast.mockReset() + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) +}) + +afterEach(async () => { + await act(async () => root.unmount()) + container.remove() +}) + +function gadget(overrides: Partial): RpcStub { + return overrides as RpcStub +} + +function button(label: string): HTMLButtonElement | undefined { + return Array.from(container.querySelectorAll('button')) + .find(candidate => candidate.textContent === label) +} + +describe('GadgetExportMenu', () => { + it('loads formats on open and exports the selected one with its metadata', async () => { + const exportFormat = vi.fn<( + id: string, + chatId?: number, + ) => Promise>>( + async () => new ReadableStream(), + ) + const formats: GadgetExportFormat[] = [{ + id: 'csv', + label: 'CSV', + mode: 'server', + contentType: 'text/csv', + fileExtension: '.csv', + }] + const client = gadget({ + getExportFormats: vi.fn<(chatId?: number) => Promise>( + async () => formats, + ), + export: exportFormat, + }) + mocks.saveStreamToFile.mockImplementation(async (createStream) => { + await createStream() + }) + + await act(async () => { + root.render() + }) + expect(client.getExportFormats).not.toHaveBeenCalled() + + await act(async () => { button('open export menu')?.click() }) + await act(async () => { button('CSV')?.click() }) + + expect(client.getExportFormats).toHaveBeenCalledWith(7) + expect(exportFormat).toHaveBeenCalledWith('csv', 7) + expect(mocks.saveStreamToFile).toHaveBeenCalledWith( + expect.any(Function), + 'Report.csv', + { description: 'CSV', contentType: 'text/csv', extension: '.csv' }, + ) + }) + + it('shows an empty state without hiding or disabling the export button', async () => { + const client = gadget({ + getExportFormats: vi.fn<(chatId?: number) => Promise>( + async () => [], + ), + }) + + await act(async () => { + root.render() + }) + const trigger = container.querySelector('[aria-label="Export Gadget"]') + expect(trigger).not.toBeNull() + expect(trigger?.disabled).toBe(false) + + await act(async () => { button('open export menu')?.click() }) + + expect(container.textContent).toContain('This Gadget does not support exports.') + expect(container.querySelector('[aria-label="Export Gadget"]')).not.toBeNull() + }) + + it('does not render the control without a selected Gadget', async () => { + await act(async () => { + root.render() + }) + + expect(container.querySelector('[aria-label="Export Gadget"]')).toBeNull() + }) + + it('loads fresh formats on every open and ignores a response after close', async () => { + let resolveFirst!: (formats: GadgetExportFormat[]) => void + const first = new Promise(resolve => { resolveFirst = resolve }) + const second: GadgetExportFormat[] = [{ + id: 'csv:second', label: 'Second sheet', mode: 'server', + contentType: 'text/csv', fileExtension: '.csv', + }] + const getExportFormats = vi.fn<(chatId?: number) => Promise>() + .mockReturnValueOnce(first) + .mockResolvedValueOnce(second) + const client = gadget({ getExportFormats }) + + await act(async () => { + root.render() + }) + await act(async () => { button('open export menu')?.click() }) + + expect(container.querySelector('[role="status"][aria-label="Loading export formats"]')).not.toBeNull() + await act(async () => { button('close export menu')?.click() }) + expect(container.querySelector('[role="status"][aria-label="Loading export formats"]')).toBeNull() + + await act(async () => { button('open export menu')?.click() }) + expect(container.textContent).toContain('Second sheet') + + await act(async () => { + resolveFirst([{ + id: 'csv:first', label: 'First sheet', mode: 'server', + contentType: 'text/csv', fileExtension: '.csv', + }]) + await first + }) + + expect(container.textContent).not.toContain('First sheet') + expect(container.textContent).toContain('Second sheet') + expect(getExportFormats).toHaveBeenCalledTimes(2) + }) + + it('shows an inline error and retries format discovery', async () => { + const format: GadgetExportFormat = { + id: 'html', label: 'HTML', mode: 'browser', + contentType: 'text/html', fileExtension: '.html', + } + const getExportFormats = vi.fn<(chatId?: number) => Promise>() + .mockRejectedValueOnce(new Error('temporary failure')) + .mockResolvedValueOnce([format]) + const client = gadget({ getExportFormats }) + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}) + + try { + await act(async () => { + root.render() + }) + await act(async () => { button('open export menu')?.click() }) + + expect(container.textContent).toContain('Export formats could not be loaded.') + expect(button('Try again')).toBeDefined() + + await act(async () => { button('Try again')?.click() }) + + expect(button('HTML')).toBeDefined() + expect(getExportFormats).toHaveBeenCalledTimes(2) + expect(mocks.toast).not.toHaveBeenCalled() + } finally { + consoleError.mockRestore() + } + }) +}) diff --git a/packages/workshop-frontend/src/GadgetExportMenu.tsx b/packages/workshop-frontend/src/GadgetExportMenu.tsx index 0b16dcd7e..a1d97e73a 100644 --- a/packages/workshop-frontend/src/GadgetExportMenu.tsx +++ b/packages/workshop-frontend/src/GadgetExportMenu.tsx @@ -1,8 +1,9 @@ -import { useState } from 'react' -import { Tooltip, useKumoToastManager } from '@cloudflare/kumo' +import { useEffect, useRef, useState } from 'react' +import { DropdownMenu, Tooltip, useKumoToastManager } from '@cloudflare/kumo' import { DownloadSimple } from '@phosphor-icons/react' import type { RpcStub } from 'capnweb' import type { GadgetClient } from '@gadgets/workshop-shared/api' +import type { GadgetExportFormat } from '@gadgets/workshop-shared/api' import { WorkshopIconButton } from './components/WorkshopControls' import { makeExportFilename, saveStreamToFile } from './fileTransfers' @@ -10,46 +11,130 @@ type Props = { gadget: RpcStub | null gadgetTitle: string chatId?: number - disabled?: boolean } -export default function GadgetExportMenu({ gadget, gadgetTitle, chatId, disabled }: Props) { - const [exporting, setExporting] = useState(false) +export default function GadgetExportMenu({ gadget, gadgetTitle, chatId }: Props) { + const [formats, setFormats] = useState(null) + const [loading, setLoading] = useState(false) + const [loadFailed, setLoadFailed] = useState(false) + const [exportingId, setExportingId] = useState(null) + const formatRequest = useRef(0) const toasts = useKumoToastManager() - const download = async () => { - if (!gadget || exporting) return + useEffect(() => { + ++formatRequest.current + setFormats(null) + setLoading(false) + setLoadFailed(false) + }, [gadget, chatId]) - setExporting(true) + const loadFormats = () => { + if (!gadget) return + const request = ++formatRequest.current + setFormats(null) + setLoading(true) + setLoadFailed(false) + void gadget.getExportFormats(chatId).then(result => { + if (formatRequest.current !== request) return + setFormats(result) + setLoading(false) + }, error => { + if (formatRequest.current !== request) return + console.error('Failed to list Gadget export formats:', error) + setLoading(false) + setLoadFailed(true) + }) + } + + const handleOpenChange = (open: boolean) => { + if (open) { + loadFormats() + } else { + ++formatRequest.current + setFormats(null) + setLoading(false) + setLoadFailed(false) + } + } + + const download = async (format: GadgetExportFormat) => { + if (!gadget || exportingId !== null) return + + setExportingId(format.id) try { await saveStreamToFile( - () => gadget.exportPdf(chatId), - makeExportFilename(gadgetTitle, '.pdf'), + () => gadget.export(format.id, chatId), + makeExportFilename(gadgetTitle, format.fileExtension), { - description: 'PDF document', - contentType: 'application/pdf', - extension: '.pdf', + description: format.label, + contentType: format.contentType, + extension: format.fileExtension, }, ) } catch (error) { - console.error('Failed to export Gadget as PDF:', error) - toasts.add({ title: 'Failed to export PDF', variant: 'error' }) + console.error(`Failed to export Gadget as ${format.label}:`, error) + toasts.add({ title: `Failed to export ${format.label}`, variant: 'error' }) } finally { - setExporting(false) + setExportingId(null) } } + if (!gadget) return null + + const exportingFormat = formats?.find(format => format.id === exportingId) + const tooltip = exportingFormat ? `Exporting to ${exportingFormat.label}` : 'Export Gadget' + return ( - + - { void download() }} - > - - - {exporting && ( + + + + + )} + /> + + {loading ? ( +
+ {['w-20', 'w-14'].map(width => ( +
+ + +
+ ))} +
+ ) : loadFailed ? ( +
+

Export formats could not be loaded.

+ +
+ ) : formats?.length === 0 ? ( +

+ This Gadget does not support exports. +

+ ) : formats?.map(format => ( + } + onClick={() => { void download(format) }} + className="!h-auto rounded-md !px-2.5 !py-1.5 text-[12px] leading-4 tracking-[-0.2px] text-kumo-default transition-colors data-highlighted:bg-kumo-tint" + > + {format.label} + + ))} +
+
+ {exportingId !== null && ( diff --git a/packages/workshop-frontend/src/GadgetUseView.tsx b/packages/workshop-frontend/src/GadgetUseView.tsx index aafd7745e..8d3015eea 100644 --- a/packages/workshop-frontend/src/GadgetUseView.tsx +++ b/packages/workshop-frontend/src/GadgetUseView.tsx @@ -23,7 +23,7 @@ import GadgetExportMenu from './GadgetExportMenu' // Gadget/Code/Connections controls, workspace activity, and every editor-only control. The // overseer and gadget passed in here are the restricted capabilities returned by openGadget() for // "use" sessions; calling anything outside getMetadata()/subscribeToMetadata()/subscribeToPresence()/ -// subscribeToWorkpieces()/getGadget() (and, on the gadget, getUiBundle()/connectToGadget()/exportPdf()) +// subscribeToWorkpieces()/getGadget() (and, on the gadget, UI connection and export methods) // would throw. // // When the workspace has more than one gadget, a simple picker in the top bar switches between diff --git a/packages/workshop-frontend/src/fileTransfers.test.ts b/packages/workshop-frontend/src/fileTransfers.test.ts index 6b883fae8..c9964b3f1 100644 --- a/packages/workshop-frontend/src/fileTransfers.test.ts +++ b/packages/workshop-frontend/src/fileTransfers.test.ts @@ -87,4 +87,31 @@ describe('export file transfers', () => { expect(source).not.toHaveBeenCalled() }) + it('preserves the advertised content type in the Blob fallback', async () => { + const createObjectURL = vi.fn<(blob: Blob) => string>(() => 'blob:test') + const oldCreateObjectURL = URL.createObjectURL + const oldRevokeObjectURL = URL.revokeObjectURL + const click = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {}) + URL.createObjectURL = createObjectURL + URL.revokeObjectURL = vi.fn<(url: string) => void>() + try { + await saveStreamToFile( + async () => new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode('a,b')) + controller.close() + }, + }), + 'report.csv', + { description: 'CSV', contentType: 'text/csv', extension: '.csv' }, + ) + + expect(createObjectURL.mock.calls[0]?.[0].type).toBe('text/csv') + } finally { + URL.createObjectURL = oldCreateObjectURL + URL.revokeObjectURL = oldRevokeObjectURL + click.mockRestore() + } + }) + }) diff --git a/packages/workshop-frontend/src/fileTransfers.ts b/packages/workshop-frontend/src/fileTransfers.ts index 7a24349a3..3556a0982 100644 --- a/packages/workshop-frontend/src/fileTransfers.ts +++ b/packages/workshop-frontend/src/fileTransfers.ts @@ -89,7 +89,9 @@ export async function saveStreamToFile( } const stream = await createStream() - triggerBlobDownload(await new Response(stream).blob(), filename) + triggerBlobDownload(await new Response(stream, { + headers: { 'Content-Type': fileType.contentType }, + }).blob(), filename) } export function saveTextToFile(filename: string, content: string): void { diff --git a/packages/workshop-shared/src/api.ts b/packages/workshop-shared/src/api.ts index 67eb4b019..d858dd2d5 100644 --- a/packages/workshop-shared/src/api.ts +++ b/packages/workshop-shared/src/api.ts @@ -3314,6 +3314,24 @@ export interface WorkpieceClient extends RpcTarget { remove(): Promise; } +/** Describes a file export format supported by a Gadget. */ +export type GadgetExportFormat = { + /** Unique, non-empty identifier for this format. */ + id: string; + + /** User-facing label for the format. */ + label: string; + + /** Whether the Workshop captures a browser or invokes a server-side handler. */ + mode: "browser" | "server"; + + /** Media type of the exported file. */ + contentType: string; + + /** File extension, including the leading dot. */ + fileExtension: string; +}; + /** * Capability representing one gadget workpiece within a workspace. Obtained from * Overseer.createGadget() or Overseer.getGadget(). Workspace-level concerns (code sync, chats, @@ -3339,10 +3357,16 @@ export interface GadgetClient extends WorkpieceClient { connectToGadget(chatId?: number): Promise>; /** - * Renders the Gadget's UI as a PDF. If `chatId` is specified, the PDF includes changes currently - * proposed in that chat. + * Lists the Gadget's supported file export formats. If `chatId` is specified, + * the formats are read from the code currently proposed in that chat. + */ + getExportFormats(chatId?: number): Promise; + + /** + * Exports the format with the given ID. If `chatId` is specified, the export + * uses changes currently proposed in that chat. */ - exportPdf(chatId?: number): Promise>; + export(id: string, chatId?: number): Promise>; // --- Binding management --- // @@ -3441,7 +3465,7 @@ export interface GatekeeperClient> extend * - "build": full access -- edit code, use and participate in chats, manage bindings, etc. (the * same access the owner has, modulo the owner-only exceptions documented in sharing.md). * - "use": may only render, interact with, and export the gadget's deployed UI (getUiBundle(), - * connectToGadget(), and exportPdf()), plus read basic metadata. + * connectToGadget(), getExportFormats(), and export()), plus read basic metadata. * * Roles are ordered build > use. A collaborator's effective role is the maximum role reachable * from the owner through their valid permission edges, where each edge grants diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0228129f2..794f196a3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -803,10 +803,16 @@ importers: yjs: specifier: ^13.6.31 version: 13.6.31 + zod: + specifier: ^4.4.3 + version: 4.4.3 devDependencies: '@cloudflare/vitest-pool-workers': specifier: 'catalog:' version: 0.20.3(@cloudflare/workers-types@5.20260808.1)(@vitest/runner@4.1.10)(@vitest/snapshot@4.1.10)(vitest@4.1.10) + dompurify: + specifier: ^3.4.13 + version: 3.4.13 esbuild: specifier: ^0.28.1 version: 0.28.1 @@ -3297,6 +3303,9 @@ packages: resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==} engines: {node: '>= 4'} + dompurify@3.4.13: + resolution: {integrity: sha512-2vmYIoqjze2d+kakP8S/nS5shfsl587kzwEjcGlTdiksUVgFHnFCsLYDVj/JNqJVOQZGSYBTmuycv0PodwmnMQ==} + dompurify@3.4.8: resolution: {integrity: sha512-yb1cEmaOum7wFvOCSQxyfgVlv5D47Rc30iZWoMpbDIWTnJ6grDDQyu2KFJzB2k7u0pMuJcQ1zphH//fFnw2tjQ==} @@ -7111,6 +7120,10 @@ snapshots: dependencies: domelementtype: 2.3.0 + dompurify@3.4.13: + optionalDependencies: + '@types/trusted-types': 2.0.7 + dompurify@3.4.8: optionalDependencies: '@types/trusted-types': 2.0.7