From 49d2428d0d07e7575d0e7d5ed5a00763367dcd8c Mon Sep 17 00:00:00 2001 From: Max Peterson Date: Sun, 9 Aug 2026 20:22:45 -0700 Subject: [PATCH 1/6] add entrypoint-based custom export api --- packages/workshop-shared/package.json | 4 + packages/workshop-shared/src/export.ts | 116 +++++++++++++++++++++++++ 2 files changed, 120 insertions(+) create mode 100644 packages/workshop-shared/src/export.ts diff --git a/packages/workshop-shared/package.json b/packages/workshop-shared/package.json index 0af3c76fb..7653ca027 100644 --- a/packages/workshop-shared/package.json +++ b/packages/workshop-shared/package.json @@ -17,6 +17,10 @@ "types": "./src/gatekeeper.ts", "import": "./src/gatekeeper.ts" }, + "./export": { + "types": "./src/export.ts", + "import": "./src/export.ts" + }, "./cloudflare-gatekeeper": { "types": "./src/cloudflare-gatekeeper.ts", "import": "./src/cloudflare-gatekeeper.ts" diff --git a/packages/workshop-shared/src/export.ts b/packages/workshop-shared/src/export.ts new file mode 100644 index 000000000..30a52f896 --- /dev/null +++ b/packages/workshop-shared/src/export.ts @@ -0,0 +1,116 @@ +import type { DurableObject, WorkerEntrypoint } from "cloudflare:workers"; + +// Gadget-facing export API: + +/** Name under which a Gadget may export its optional export handler entrypoint. */ +export const GADGET_EXPORT_ENTRYPOINT = "ExportHandler"; + +/** + * Describes an export format that a Gadget supports. Export formats are split + * into two modes: + * + * Browser mode: The Gadget UI is loaded in a remote browser and the rendered + * output is captured, either by printing the page to PDF or returning the + * underlying HTML. Browser mode exports are limited to a small set of formats + * that can be easily captured using the Browser Run binding. + * + * Custom mode: The server-side export handler entrypoint returns the file + * content directly. Export formats are unrestricted, and a Gadget may (on rare + * occasion) reimplement a browser-mode export format server-side if it wants + * total control over the exported file content. +*/ +export type GadgetExportFormat = + | { + // A Gadget can support multiple HTML or PDF export variants by returning + // multiple browser-mode export entries with different ids. When the Gadget + // UI is loaded in a remote browser, globalThis.gadgetExportFormatId will be + // set to the selected format id so the Gadget UI knows which variant to + // render. + id: string; + // User-facing label for the export format. Usually "HTML" unless multiple + // HTML variants are supported + label: string; + mode: "browser"; + contentType: "text/html"; + } + | { + id: string; + // User-facing label for the export format. Usually "PDF" unless multiple + // PDF variants are supported + label: string; + mode: "browser"; + contentType: "application/pdf"; + } + | { + id: string; + label: string; + mode: "custom"; + contentType: string; + fileExtension: string; + }; + +/** + * Optional Worker entrypoint exported by a Gadget as `ExportHandler` to + * customize file export behavior. + */ +export interface GadgetExportEntrypoint + extends WorkerEntrypoint { + /** + * Lists the export formats supported by this Gadget. When this entrypoint or + * method is absent, the Workshop defaults to: + * - { id: "html", label: "HTML", mode: "browser", contentType: "text/html"} + * - { id: "pdf", label: "PDF", mode: "browser", contentType: "application/pdf"} + * + * When implementing this method, the Gadget should return these browser-mode + * export formats in addition to any custom formats, unless it specifically + * does not want to support browser-mode HTML or PDF exports. Export format + * ids must be unique across both modes. + */ + getExportFormats(gadget: Fetcher): Promise; + + /** + * Produces the custom format identified by an id returned from + * `getExportFormats()`. Only called for custom-mode export formats. + * Browser-mode export formats are handled by the workshop backend. + */ + export(gadget: Fetcher, id: string): Promise>; +} + +// Export API exposed to the workshop UI: + +/** + * Describes an export format that a Gadget supports. Hides browser- vs. + * custom-mode and default format implementation details. + */ +export type GadgetClientExportFormat = { + /** Identifier to pass to `GadgetClientExportApi.export()`. */ + id: string; + + /** Media type of the exported file. */ + contentType: string; + + /** User-facing name for the format. */ + label: string; + + /** + * File extension, including the leading dot. Inferred by workshop for + * browser-mode exports. + */ + fileExtension: string; +}; + + +/** + * Gadget export API used by the workshop UI, and potentially the workshop agent + * in the future. + */ +export interface GadgetClientExportApi { + /** + * Lists supported export formats, including default formats if the Gadget + * does not implement ExportHandler. + */ + getExportFormats(chatId?: number): Promise; + + /** Exports the format with the given ID. */ + export(id: string, chatId?: number): Promise>; +} From 447dbb09611ad9943166b2a393495e27d299d4ba Mon Sep 17 00:00:00 2001 From: Max Peterson Date: Mon, 10 Aug 2026 13:01:01 -0700 Subject: [PATCH 2/6] comment refinements based on bonk feedback --- packages/workshop-shared/src/export.ts | 72 ++++++++++++++++++-------- 1 file changed, 51 insertions(+), 21 deletions(-) diff --git a/packages/workshop-shared/src/export.ts b/packages/workshop-shared/src/export.ts index 30a52f896..5a7d48c14 100644 --- a/packages/workshop-shared/src/export.ts +++ b/packages/workshop-shared/src/export.ts @@ -21,69 +21,99 @@ export const GADGET_EXPORT_ENTRYPOINT = "ExportHandler"; */ export type GadgetExportFormat = | { - // A Gadget can support multiple HTML or PDF export variants by returning - // multiple browser-mode export entries with different ids. When the Gadget - // UI is loaded in a remote browser, globalThis.gadgetExportFormatId will be - // set to the selected format id so the Gadget UI knows which variant to - // render. + /** + * Unique, non-empty identifier for this format. A Gadget can support + * multiple HTML variants with different ids. During browser rendering, the + * global variable `gadgetExportFormatId` identifies the selected variant. + */ id: string; - // User-facing label for the export format. Usually "HTML" unless multiple - // HTML variants are supported + + /** User-facing label, usually "HTML" unless multiple variants are supported. */ label: string; + + /** Selects browser-based rendering. */ mode: "browser"; + + /** Media type produced by this browser-based format. */ contentType: "text/html"; } | { + /** + * Unique, non-empty identifier for this format. A Gadget can support + * multiple PDF variants with different ids. During browser rendering, the + * global variable `gadgetExportFormatId` identifies the selected variant. + */ id: string; - // User-facing label for the export format. Usually "PDF" unless multiple - // PDF variants are supported + + /** User-facing label, usually "PDF" unless multiple variants are supported. */ label: string; + + /** Selects browser-based rendering. */ mode: "browser"; + + /** Media type produced by this browser-based format. */ contentType: "application/pdf"; } | { + /** Unique, non-empty identifier for this format. */ id: string; + + /** User-facing label for the export format. */ label: string; + + /** Selects server-side generation by the Gadget's export handler. */ mode: "custom"; + + /** Media type produced by this custom format. */ contentType: string; + + /** File extension, including the leading dot. */ fileExtension: string; }; /** * Optional Worker entrypoint exported by a Gadget as `ExportHandler` to * customize file export behavior. + * + * @typeParam Gadget The Gadget Durable Object type accepted by the handler. */ export interface GadgetExportEntrypoint extends WorkerEntrypoint { /** - * Lists the export formats supported by this Gadget. When this entrypoint or - * method is absent, the Workshop defaults to: + * Lists the export formats supported by this Gadget. When this entrypoint is + * absent, the Workshop defaults to: * - { id: "html", label: "HTML", mode: "browser", contentType: "text/html"} * - { id: "pdf", label: "PDF", mode: "browser", contentType: "application/pdf"} + * Every `ExportHandler` entrypoint must implement this method. The Workshop rejects + * a missing `getExportFormats` method instead of applying defaults. * * When implementing this method, the Gadget should return these browser-mode * export formats in addition to any custom formats, unless it specifically - * does not want to support browser-mode HTML or PDF exports. Export format - * ids must be unique across both modes. + * does not want to support browser-mode HTML or PDF exports. */ getExportFormats(gadget: Fetcher): Promise; /** * Produces the custom format identified by an id returned from * `getExportFormats()`. Only called for custom-mode export formats. - * Browser-mode export formats are handled by the workshop backend. + * Browser-mode export formats are handled by the Workshop backend. + * + * The Workshop enforces 30s export duration and 100MB file size limits while + * consuming the returned stream. It derives the filename from the Gadget + * title and the format's `fileExtension`; the handler does not control the + * base filename. */ export(gadget: Fetcher, id: string): Promise>; } -// Export API exposed to the workshop UI: +// Export API exposed to the Workshop UI: /** * Describes an export format that a Gadget supports. Hides browser- vs. * custom-mode and default format implementation details. */ export type GadgetClientExportFormat = { - /** Identifier to pass to `GadgetClientExportApi.export()`. */ + /** Identifier to pass to `GadgetClientExportMethods.export()`. */ id: string; /** Media type of the exported file. */ @@ -93,18 +123,18 @@ export type GadgetClientExportFormat = { label: string; /** - * File extension, including the leading dot. Inferred by workshop for + * File extension, including the leading dot. Inferred by the Workshop for * browser-mode exports. */ fileExtension: string; }; - /** - * Gadget export API used by the workshop UI, and potentially the workshop agent - * in the future. + * Export methods that will be added directly to `GadgetClient`, rather than + * exposed as a separate RPC capability. These methods replace the existing + * `GadgetClient.exportPdf()` method when implemented. */ -export interface GadgetClientExportApi { +export interface GadgetClientExportMethods { /** * Lists supported export formats, including default formats if the Gadget * does not implement ExportHandler. From 050976bd69cb65208dcc490fff594f9f1d4cd3ae Mon Sep 17 00:00:00 2001 From: Max Peterson Date: Mon, 10 Aug 2026 13:24:44 -0700 Subject: [PATCH 3/6] switch to "export target" terminology --- packages/workshop-shared/src/export.ts | 67 ++++++++++++++------------ 1 file changed, 35 insertions(+), 32 deletions(-) diff --git a/packages/workshop-shared/src/export.ts b/packages/workshop-shared/src/export.ts index 5a7d48c14..d79ee1d9e 100644 --- a/packages/workshop-shared/src/export.ts +++ b/packages/workshop-shared/src/export.ts @@ -6,25 +6,26 @@ import type { DurableObject, WorkerEntrypoint } from "cloudflare:workers"; export const GADGET_EXPORT_ENTRYPOINT = "ExportHandler"; /** - * Describes an export format that a Gadget supports. Export formats are split + * Describes an export target that a Gadget supports. Export targets are split * into two modes: * * Browser mode: The Gadget UI is loaded in a remote browser and the rendered * output is captured, either by printing the page to PDF or returning the - * underlying HTML. Browser mode exports are limited to a small set of formats - * that can be easily captured using the Browser Run binding. + * underlying HTML. Browser mode exports are limited to a small set of file + * formats that can be easily captured using the Browser Run binding. * * Custom mode: The server-side export handler entrypoint returns the file - * content directly. Export formats are unrestricted, and a Gadget may (on rare - * occasion) reimplement a browser-mode export format server-side if it wants + * content directly. Export targets are unrestricted, and a Gadget may (on rare + * occasion) reimplement a browser-mode export target server-side if it wants * total control over the exported file content. */ -export type GadgetExportFormat = +export type GadgetExportTarget = | { /** - * Unique, non-empty identifier for this format. A Gadget can support - * multiple HTML variants with different ids. During browser rendering, the - * global variable `gadgetExportFormatId` identifies the selected variant. + * Unique, non-empty identifier for this target. A Gadget can support + * multiple HTML variants with different ids. The Workshop injects the + * global variable `gadgetExportTargetId` before the Gadget UI module + * evaluates; it identifies the selected variant during browser rendering. */ id: string; @@ -34,14 +35,15 @@ export type GadgetExportFormat = /** Selects browser-based rendering. */ mode: "browser"; - /** Media type produced by this browser-based format. */ + /** Media type produced by this browser-based target. */ contentType: "text/html"; } | { /** - * Unique, non-empty identifier for this format. A Gadget can support - * multiple PDF variants with different ids. During browser rendering, the - * global variable `gadgetExportFormatId` identifies the selected variant. + * Unique, non-empty identifier for this target. A Gadget can support + * multiple PDF variants with different ids. The Workshop injects the global + * variable `gadgetExportTargetId` before the Gadget UI module evaluates; it + * identifies the selected variant during browser rendering. */ id: string; @@ -51,20 +53,20 @@ export type GadgetExportFormat = /** Selects browser-based rendering. */ mode: "browser"; - /** Media type produced by this browser-based format. */ + /** Media type produced by this browser-based target. */ contentType: "application/pdf"; } | { - /** Unique, non-empty identifier for this format. */ + /** Unique, non-empty identifier for this target. */ id: string; - /** User-facing label for the export format. */ + /** User-facing label for the export target. */ label: string; /** Selects server-side generation by the Gadget's export handler. */ mode: "custom"; - /** Media type produced by this custom format. */ + /** Media type produced by this custom target. */ contentType: string; /** File extension, including the leading dot. */ @@ -80,27 +82,28 @@ export type GadgetExportFormat = export interface GadgetExportEntrypoint extends WorkerEntrypoint { /** - * Lists the export formats supported by this Gadget. When this entrypoint is + * Lists the export targets supported by this Gadget. When this entrypoint is * absent, the Workshop defaults to: * - { id: "html", label: "HTML", mode: "browser", contentType: "text/html"} * - { id: "pdf", label: "PDF", mode: "browser", contentType: "application/pdf"} * Every `ExportHandler` entrypoint must implement this method. The Workshop rejects - * a missing `getExportFormats` method instead of applying defaults. + * a missing `getExportFormats` method instead of applying defaults. Returning an + * empty list disables file exports. * * When implementing this method, the Gadget should return these browser-mode - * export formats in addition to any custom formats, unless it specifically + * export targets in addition to any custom targets, unless it specifically * does not want to support browser-mode HTML or PDF exports. */ - getExportFormats(gadget: Fetcher): Promise; + getExportTargets(gadget: Fetcher): Promise; /** - * Produces the custom format identified by an id returned from - * `getExportFormats()`. Only called for custom-mode export formats. - * Browser-mode export formats are handled by the Workshop backend. + * Produces the custom target identified by an id returned from + * `getExportTargets()`. Only called for custom-mode export targets. + * Browser-mode export targets are handled by the Workshop backend. * * The Workshop enforces 30s export duration and 100MB file size limits while * consuming the returned stream. It derives the filename from the Gadget - * title and the format's `fileExtension`; the handler does not control the + * title and the target's `fileExtension`; the handler does not control the * base filename. */ export(gadget: Fetcher, id: string): Promise>; @@ -109,17 +112,17 @@ export interface GadgetExportEntrypoint; + getExportTargets(chatId?: number): Promise; - /** Exports the format with the given ID. */ + /** Exports the target with the given ID. */ export(id: string, chatId?: number): Promise>; } From 371ff3746ddc04a7aeeb56f0a23f29545b76ec18 Mon Sep 17 00:00:00 2001 From: Max Peterson Date: Mon, 10 Aug 2026 13:29:38 -0700 Subject: [PATCH 4/6] fix confusing target wording --- packages/workshop-shared/src/export.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/workshop-shared/src/export.ts b/packages/workshop-shared/src/export.ts index d79ee1d9e..721de77b9 100644 --- a/packages/workshop-shared/src/export.ts +++ b/packages/workshop-shared/src/export.ts @@ -15,8 +15,8 @@ export const GADGET_EXPORT_ENTRYPOINT = "ExportHandler"; * formats that can be easily captured using the Browser Run binding. * * Custom mode: The server-side export handler entrypoint returns the file - * content directly. Export targets are unrestricted, and a Gadget may (on rare - * occasion) reimplement a browser-mode export target server-side if it wants + * content directly. Export file formats are unrestricted, and a Gadget may (on + * rare occasion) reimplement a browser-mode file format server-side if it wants * total control over the exported file content. */ export type GadgetExportTarget = From 83beace9da27814a6b4f50e6865c3dec723da70f Mon Sep 17 00:00:00 2001 From: Max Peterson Date: Mon, 10 Aug 2026 13:38:12 -0700 Subject: [PATCH 5/6] fix missing format => target rename --- packages/workshop-shared/src/export.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/workshop-shared/src/export.ts b/packages/workshop-shared/src/export.ts index 721de77b9..8ef318b6a 100644 --- a/packages/workshop-shared/src/export.ts +++ b/packages/workshop-shared/src/export.ts @@ -87,7 +87,7 @@ export interface GadgetExportEntrypoint Date: Tue, 11 Aug 2026 17:18:20 -0700 Subject: [PATCH 6/6] simplify API interface types - rename "custom" mode to "server" mode - rename GadgetExportTarget to GadgetExportFormat - convert GadgetExportFormat from union to record type - drop GadgetClientExportTarget type --- packages/workshop-shared/src/export.ts | 136 ++++++++----------------- 1 file changed, 43 insertions(+), 93 deletions(-) diff --git a/packages/workshop-shared/src/export.ts b/packages/workshop-shared/src/export.ts index 8ef318b6a..d9585ae96 100644 --- a/packages/workshop-shared/src/export.ts +++ b/packages/workshop-shared/src/export.ts @@ -6,72 +6,40 @@ import type { DurableObject, WorkerEntrypoint } from "cloudflare:workers"; export const GADGET_EXPORT_ENTRYPOINT = "ExportHandler"; /** - * Describes an export target that a Gadget supports. Export targets are split + * Describes an export format that a Gadget supports. Export formats are split * into two modes: * * Browser mode: The Gadget UI is loaded in a remote browser and the rendered - * output is captured, either by printing the page to PDF or returning the - * underlying HTML. Browser mode exports are limited to a small set of file - * formats that can be easily captured using the Browser Run binding. + * output is captured as HTML, PDF, PNG, or JPEG using the Browser Run binding. * - * Custom mode: The server-side export handler entrypoint returns the file - * content directly. Export file formats are unrestricted, and a Gadget may (on - * rare occasion) reimplement a browser-mode file format server-side if it wants - * total control over the exported file content. + * Server mode: The server-side export handler entrypoint returns the file + * content directly. File formats are unrestricted, and a Gadget may reimplement + * a browser-mode format server-side if it wants total control over the exported + * file content. */ -export type GadgetExportTarget = - | { - /** - * Unique, non-empty identifier for this target. A Gadget can support - * multiple HTML variants with different ids. The Workshop injects the - * global variable `gadgetExportTargetId` before the Gadget UI module - * evaluates; it identifies the selected variant during browser rendering. - */ - id: string; - - /** User-facing label, usually "HTML" unless multiple variants are supported. */ - label: string; - - /** Selects browser-based rendering. */ - mode: "browser"; - - /** Media type produced by this browser-based target. */ - contentType: "text/html"; - } - | { - /** - * Unique, non-empty identifier for this target. A Gadget can support - * multiple PDF variants with different ids. The Workshop injects the global - * variable `gadgetExportTargetId` before the Gadget UI module evaluates; it - * identifies the selected variant during browser rendering. - */ - id: string; - - /** User-facing label, usually "PDF" unless multiple variants are supported. */ - label: string; - - /** Selects browser-based rendering. */ - mode: "browser"; - - /** Media type produced by this browser-based target. */ - contentType: "application/pdf"; - } - | { - /** Unique, non-empty identifier for this target. */ - id: string; +export type GadgetExportFormat = { + /** + * Unique, non-empty identifier for this format. During browser rendering, the + * Workshop injects this value as `gadgetExportFormatId` before the Gadget UI + * module evaluates, allowing multiple variants of the same media type. + */ + id: string; - /** User-facing label for the export target. */ - label: string; + /** User-facing label for the format. */ + label: string; - /** Selects server-side generation by the Gadget's export handler. */ - mode: "custom"; + /** Whether the Workshop captures a browser or invokes the server-side handler. */ + mode: "browser" | "server"; - /** Media type produced by this custom target. */ - contentType: string; + /** + * Media type of the exported file. Browser mode supports `text/html`, + * `application/pdf`, `image/png`, and `image/jpeg`; server mode is unrestricted. + */ + contentType: string; - /** File extension, including the leading dot. */ - fileExtension: string; - }; + /** File extension, including the leading dot. */ + fileExtension: string; +}; /** * Optional Worker entrypoint exported by a Gadget as `ExportHandler` to @@ -82,28 +50,31 @@ export type GadgetExportTarget = export interface GadgetExportEntrypoint extends WorkerEntrypoint { /** - * Lists the export targets supported by this Gadget. When this entrypoint is + * Lists the export formats supported by this Gadget. When this entrypoint is * absent, the Workshop defaults to: - * - { id: "html", label: "HTML", mode: "browser", contentType: "text/html"} - * - { id: "pdf", label: "PDF", mode: "browser", contentType: "application/pdf"} - * Every `ExportHandler` entrypoint must implement this method. The Workshop rejects - * a missing `getExportTargets` method instead of applying defaults. Returning an - * empty list disables file exports. + * - { id: "html", label: "HTML", mode: "browser", contentType: "text/html", + * fileExtension: ".html" } + * - { id: "pdf", label: "PDF", mode: "browser", contentType: "application/pdf", + * fileExtension: ".pdf" } + * + * Every `ExportHandler` entrypoint must implement this method. The Workshop + * rejects a missing `getExportFormats` method instead of applying defaults. + * Returning an empty list disables file exports. * * When implementing this method, the Gadget should return these browser-mode - * export targets in addition to any custom targets, unless it specifically + * export formats in addition to any server-mode formats, unless it specifically * does not want to support browser-mode HTML or PDF exports. */ - getExportTargets(gadget: Fetcher): Promise; + getExportFormats(gadget: Fetcher): Promise; /** - * Produces the custom target identified by an id returned from - * `getExportTargets()`. Only called for custom-mode export targets. - * Browser-mode export targets are handled by the Workshop backend. + * Produces the server-side format identified by an id returned from + * `getExportFormats()`. Only called for server-mode export formats. + * Browser-mode export formats are handled by the Workshop backend. * * The Workshop enforces 30s export duration and 100MB file size limits while * consuming the returned stream. It derives the filename from the Gadget - * title and the target's `fileExtension`; the handler does not control the + * title and the format's `fileExtension`; the handler does not control the * base filename. */ export(gadget: Fetcher, id: string): Promise>; @@ -111,27 +82,6 @@ export interface GadgetExportEntrypoint; + getExportFormats(chatId?: number): Promise; - /** Exports the target with the given ID. */ + /** Exports the format with the given ID. */ export(id: string, chatId?: number): Promise>; }