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. 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 9308e29..c5051b8 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,52 @@ 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. 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 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); + checkUrl.pathname = `/s/${pngMatch[1]}`; + checkUrl.search = ""; // clear .png query params + checkUrl.searchParams.set("part", "0"); + if (theme) checkUrl.searchParams.set("theme", theme); + 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. + await checkRes.arrayBuffer(); + + const target = checkUrl.toString(); + const screenshot = await env.BROWSER.quickAction("screenshot", { + url: target, + 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(), { + headers: { + "Content-Type": "image/png", + "Cache-Control": noCache ? "no-store" : "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 }, }