From 3777d57636727258d860baa63675f3e0c77879c6 Mon Sep 17 00:00:00 2001 From: Justin Giancola Date: Wed, 24 Jun 2026 09:56:55 -0400 Subject: [PATCH 1/7] feat(workers): screenshot surfaces as PNG via /s/:id.png MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add Browser Rendering support to the CF entrypoint. GET /s/:id.png screenshots the rendered surface page and returns a PNG image. Auth is fully delegated to the app — the user's credentials are forwarded to the DO, and the screenshot only proceeds if the app returns 200. The browser rendering call authenticates with the server's own token. - Add browser binding to wrangler.jsonc - Intercept .png requests in the entrypoint before DO dispatch - 1200×630 viewport (OG-image friendly) - Cache-Control: public, max-age=300 for edge caching --- workers/index.ts | 32 ++++++++++++++++++++++++++++++-- wrangler.jsonc | 1 + 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/workers/index.ts b/workers/index.ts index 9308e29..4057e0f 100644 --- a/workers/index.ts +++ b/workers/index.ts @@ -9,6 +9,7 @@ import { SqlStore } from "./sqlStore.ts"; interface Env { BOARD: DurableObjectNamespace; + BROWSER: BrowserRun; SIDESHOW_TOKEN?: string; SIDESHOW_PUBLIC_READ?: string; } @@ -42,7 +43,7 @@ export class SideshowBoard extends DurableObject { } export default { - fetch(request: Request, env: Env) { + async fetch(request: Request, env: Env) { if (!env.SIDESHOW_TOKEN) { return new Response( "sideshow is not configured: set a token first —\n\n wrangler secret put SIDESHOW_TOKEN\n", @@ -50,6 +51,33 @@ export default { ); } const board = env.BOARD.get(env.BOARD.idFromName("default")); - return board.fetch(request); + + // Screenshot: GET /s/:id.png → PNG of the rendered surface page. + // Auth is decided by the app — we forward the user's credentials to the DO + // and only proceed if it returns 200. + const url = new URL(request.url); + const pngMatch = request.method === "GET" && url.pathname.match(/^\/s\/([a-z0-9]+)\.png$/); + if (!pngMatch) return board.fetch(request); + + // Let the app decide auth: forward the request (with user cookies/headers) + // to the real /s/:id route. + const checkUrl = new URL(url); + checkUrl.pathname = `/s/${pngMatch[1]}`; + checkUrl.searchParams.set("part", "0"); + const checkRes = await board.fetch(new Request(checkUrl, { headers: request.headers })); + if (!checkRes.ok) return checkRes; + // Auth passed and surface exists — discard the HTML, take a screenshot. + await checkRes.arrayBuffer(); + + const target = checkUrl.toString(); + const screenshot = await env.BROWSER.quickAction("screenshot", { + url: target, + viewport: { width: 1200, height: 630 }, + gotoOptions: { waitUntil: "networkidle0", timeout: 15000 }, + cookies: [{ name: "sideshow_key", value: env.SIDESHOW_TOKEN, domain: url.hostname }], + }); + return new Response(await screenshot.arrayBuffer(), { + headers: { "Content-Type": "image/png", "Cache-Control": "public, max-age=300" }, + }); }, } satisfies ExportedHandler; diff --git a/wrangler.jsonc b/wrangler.jsonc index f1f60bd..4d80c9b 100644 --- a/wrangler.jsonc +++ b/wrangler.jsonc @@ -8,5 +8,6 @@ "bindings": [{ "name": "BOARD", "class_name": "SideshowBoard" }], }, "migrations": [{ "tag": "v1", "new_sqlite_classes": ["SideshowBoard"] }], + "browser": { "binding": "BROWSER" }, "observability": { "enabled": true }, } From 0a23ebb0570ceebb8d8af1d35e6a8103c5931359 Mon Sep 17 00:00:00 2001 From: Justin Giancola Date: Wed, 24 Jun 2026 10:06:42 -0400 Subject: [PATCH 2/7] fix(workers): pass theme/mode to screenshots, default 800px width MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Forward ?theme= and ?mode= to the /s/:id page so screenshots match the viewer's rendering. Without mode pinning, headless Chrome's OS default produced mismatched colors. - Default width 800px (was 1200), configurable via ?w= (clamped 320–1920) - Mode defaults to light (headless Chrome's environment), ?mode=dark supported - Theme falls back to board setting; ?theme= overrides - Viewport height maintains 1200:630 aspect ratio --- workers/index.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/workers/index.ts b/workers/index.ts index 4057e0f..726a7c2 100644 --- a/workers/index.ts +++ b/workers/index.ts @@ -60,10 +60,18 @@ export default { if (!pngMatch) return board.fetch(request); // Let the app decide auth: forward the request (with user cookies/headers) - // to the real /s/:id route. + // to the real /s/:id route. We pass theme/mode so the rendered page matches + // what the viewer shows; the width is configurable via ?w= (default 800). + const width = Math.min(Math.max(Number(url.searchParams.get("w")) || 800, 320), 1920); + const theme = url.searchParams.get("theme"); + const mode = url.searchParams.get("mode") === "dark" ? "dark" : "light"; + const checkUrl = new URL(url); checkUrl.pathname = `/s/${pngMatch[1]}`; + checkUrl.search = ""; // clear .png query params checkUrl.searchParams.set("part", "0"); + if (theme) checkUrl.searchParams.set("theme", theme); + checkUrl.searchParams.set("mode", mode); const checkRes = await board.fetch(new Request(checkUrl, { headers: request.headers })); if (!checkRes.ok) return checkRes; // Auth passed and surface exists — discard the HTML, take a screenshot. @@ -72,7 +80,7 @@ export default { const target = checkUrl.toString(); const screenshot = await env.BROWSER.quickAction("screenshot", { url: target, - viewport: { width: 1200, height: 630 }, + viewport: { width, height: Math.round((width * 630) / 1200) }, gotoOptions: { waitUntil: "networkidle0", timeout: 15000 }, cookies: [{ name: "sideshow_key", value: env.SIDESHOW_TOKEN, domain: url.hostname }], }); From 0d3870253e4e19d63d3153caa5ebdf716a6550a8 Mon Sep 17 00:00:00 2001 From: Justin Giancola Date: Wed, 24 Jun 2026 10:10:35 -0400 Subject: [PATCH 3/7] fix(workers): full-page screenshots instead of fixed aspect ratio Use fullPage: true so the screenshot captures the entire surface content instead of clipping to a 1200:630 viewport. Tall surfaces were being cut off. The viewport height (800) is now just the initial window size. --- workers/index.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/workers/index.ts b/workers/index.ts index 726a7c2..3e644db 100644 --- a/workers/index.ts +++ b/workers/index.ts @@ -80,7 +80,8 @@ export default { const target = checkUrl.toString(); const screenshot = await env.BROWSER.quickAction("screenshot", { url: target, - viewport: { width, height: Math.round((width * 630) / 1200) }, + viewport: { width, height: 800 }, + screenshotOptions: { fullPage: true }, gotoOptions: { waitUntil: "networkidle0", timeout: 15000 }, cookies: [{ name: "sideshow_key", value: env.SIDESHOW_TOKEN, domain: url.hostname }], }); From e2afd4f4e6cdb7ee1778446a7136e0ee7b50d9ae Mon Sep 17 00:00:00 2001 From: Justin Giancola Date: Wed, 24 Jun 2026 10:13:12 -0400 Subject: [PATCH 4/7] feat(workers): ?nocache param to force screenshot re-render Appending ?nocache to a .png URL bypasses the edge cache and returns Cache-Control: no-store, ensuring a fresh browser render. --- workers/index.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/workers/index.ts b/workers/index.ts index 3e644db..b43873e 100644 --- a/workers/index.ts +++ b/workers/index.ts @@ -65,6 +65,7 @@ export default { const width = Math.min(Math.max(Number(url.searchParams.get("w")) || 800, 320), 1920); const theme = url.searchParams.get("theme"); const mode = url.searchParams.get("mode") === "dark" ? "dark" : "light"; + const noCache = url.searchParams.has("nocache"); const checkUrl = new URL(url); checkUrl.pathname = `/s/${pngMatch[1]}`; @@ -86,7 +87,10 @@ export default { cookies: [{ name: "sideshow_key", value: env.SIDESHOW_TOKEN, domain: url.hostname }], }); return new Response(await screenshot.arrayBuffer(), { - headers: { "Content-Type": "image/png", "Cache-Control": "public, max-age=300" }, + headers: { + "Content-Type": "image/png", + "Cache-Control": noCache ? "no-store" : "public, max-age=300", + }, }); }, } satisfies ExportedHandler; From d4fed8d59f03454f6dba6ec1bde2d494172a455a Mon Sep 17 00:00:00 2001 From: Justin Giancola Date: Wed, 24 Jun 2026 10:23:22 -0400 Subject: [PATCH 5/7] fix(workers): disable Browser Rendering internal cache MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Browser Rendering's quickAction cache (default 5s) keys on the URL path and ignores query params, so ?theme= overrides were returning stale screenshots. Set cacheTTL: 0 to disable it — we handle caching ourselves via Cache-Control headers. --- workers/index.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/workers/index.ts b/workers/index.ts index b43873e..707e64e 100644 --- a/workers/index.ts +++ b/workers/index.ts @@ -84,6 +84,7 @@ export default { viewport: { width, height: 800 }, screenshotOptions: { fullPage: true }, gotoOptions: { waitUntil: "networkidle0", timeout: 15000 }, + cacheTTL: 0, cookies: [{ name: "sideshow_key", value: env.SIDESHOW_TOKEN, domain: url.hostname }], }); return new Response(await screenshot.arrayBuffer(), { From d05c5dd7c662ae3fda7f89924ff7fd2750baa233 Mon Sep 17 00:00:00 2001 From: Justin Giancola Date: Wed, 24 Jun 2026 10:26:43 -0400 Subject: [PATCH 6/7] feat: screenshots match the user's light/dark mode The viewer now persists the resolved OS color-scheme in a sideshow_mode cookie. The .png screenshot handler reads it as the default mode, so screenshots match what the user sees without needing an explicit ?mode= param. Explicit ?mode= still overrides. --- viewer/src/theme.ts | 5 +++++ workers/index.ts | 9 +++++++-- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/viewer/src/theme.ts b/viewer/src/theme.ts index 74ca0c6..b6d9b8e 100644 --- a/viewer/src/theme.ts +++ b/viewer/src/theme.ts @@ -35,8 +35,13 @@ const darkQuery = const [prefersDark, setPrefersDark] = createSignal(!!darkQuery?.matches); // On an OS light/dark flip the resolved palette changes without a theme change, // so re-push it to the host (below) after updating the mode signal. +function syncModeCookie() { + document.cookie = `sideshow_mode=${prefersDark() ? "dark" : "light"};path=/;max-age=31536000;SameSite=Lax`; +} +syncModeCookie(); darkQuery?.addEventListener("change", (e) => { setPrefersDark(e.matches); + syncModeCookie(); emitThemeTokens(); }); export const resolvedMode = (): Mode => (prefersDark() ? "dark" : "light"); diff --git a/workers/index.ts b/workers/index.ts index 707e64e..c5051b8 100644 --- a/workers/index.ts +++ b/workers/index.ts @@ -64,7 +64,12 @@ export default { // what the viewer shows; the width is configurable via ?w= (default 800). const width = Math.min(Math.max(Number(url.searchParams.get("w")) || 800, 320), 1920); const theme = url.searchParams.get("theme"); - const mode = url.searchParams.get("mode") === "dark" ? "dark" : "light"; + const modeParam = url.searchParams.get("mode"); + const modeCookie = request.headers.get("cookie")?.match(/sideshow_mode=(light|dark)/)?.[1]; + const mode = + modeParam === "dark" || modeParam === "light" + ? modeParam + : (modeCookie as "light" | "dark" | undefined); const noCache = url.searchParams.has("nocache"); const checkUrl = new URL(url); @@ -72,7 +77,7 @@ export default { checkUrl.search = ""; // clear .png query params checkUrl.searchParams.set("part", "0"); if (theme) checkUrl.searchParams.set("theme", theme); - checkUrl.searchParams.set("mode", mode); + if (mode) checkUrl.searchParams.set("mode", mode); const checkRes = await board.fetch(new Request(checkUrl, { headers: request.headers })); if (!checkRes.ok) return checkRes; // Auth passed and surface exists — discard the HTML, take a screenshot. From bcb9e9dfe83dec8e9dffac90417cb27de1261885 Mon Sep 17 00:00:00 2001 From: Justin Giancola Date: Wed, 24 Jun 2026 10:32:14 -0400 Subject: [PATCH 7/7] chore: add changeset for PNG screenshot feature --- .changeset/png-screenshots.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/png-screenshots.md diff --git a/.changeset/png-screenshots.md b/.changeset/png-screenshots.md new file mode 100644 index 0000000..5379894 --- /dev/null +++ b/.changeset/png-screenshots.md @@ -0,0 +1,5 @@ +--- +"sideshow": minor +--- + +Screenshot surfaces as PNG by appending `.png` to any surface URL (e.g. `/s/:id.png`). Uses Cloudflare Browser Rendering to capture the rendered page. Supports `?mode=dark|light`, `?theme=`, `?w=` (width), and `?nocache` params. The viewer persists the user's OS color-scheme in a cookie so screenshots automatically match their light/dark preference.