Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/png-screenshots.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 5 additions & 0 deletions viewer/src/theme.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
51 changes: 49 additions & 2 deletions workers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { SqlStore } from "./sqlStore.ts";

interface Env {
BOARD: DurableObjectNamespace<SideshowBoard>;
BROWSER: BrowserRun;
SIDESHOW_TOKEN?: string;
SIDESHOW_PUBLIC_READ?: string;
}
Expand Down Expand Up @@ -42,14 +43,60 @@ export class SideshowBoard extends DurableObject<Env> {
}

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",
{ status: 503 },
);
}
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<Env>;
1 change: 1 addition & 0 deletions wrangler.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,6 @@
"bindings": [{ "name": "BOARD", "class_name": "SideshowBoard" }],
},
"migrations": [{ "tag": "v1", "new_sqlite_classes": ["SideshowBoard"] }],
"browser": { "binding": "BROWSER" },
"observability": { "enabled": true },
}
Loading