diff --git a/.changeset/cache-social-previews.md b/.changeset/cache-social-previews.md
new file mode 100644
index 0000000..ffe26b9
--- /dev/null
+++ b/.changeset/cache-social-previews.md
@@ -0,0 +1,5 @@
+---
+"sideshow": patch
+---
+
+Cache fully pinned social-preview screenshots at the Cloudflare edge so repeated link unfurls avoid redundant Browser Rendering calls. Access, post existence, and revision are revalidated on every edge request, and token-protected boards keep private client cache headers.
diff --git a/server/app.ts b/server/app.ts
index 0067a83..46196f2 100644
--- a/server/app.ts
+++ b/server/app.ts
@@ -918,11 +918,25 @@ export function createApp({
return injectHead(text, ``);
};
- const postPreviewHead = (post: Post, request: Request) => {
+ const postPreviewHead = (
+ post: Post,
+ request: Request,
+ themeId: string,
+ rendererGeneration: string,
+ ) => {
const origin = new URL(request.url).origin;
const publicBasePath = requestBasePath(request);
const canonical = `${origin}${publicBasePath}/p/${post.id}`;
- const image = `${origin}${publicBasePath}/p/${post.id}.png?card=1`;
+ // Pin every pixel-affecting input in the advertised URL: post revision,
+ // workspace theme, deterministic color mode, and app/renderer generation.
+ // The Worker validates these before admitting the image to edge cache.
+ const imageUrl = new URL(`${origin}${publicBasePath}/p/${post.id}.png`);
+ imageUrl.searchParams.set("card", "1");
+ imageUrl.searchParams.set("theme", themeId);
+ imageUrl.searchParams.set("mode", "dark");
+ imageUrl.searchParams.set("v", String(post.version));
+ imageUrl.searchParams.set("g", rendererGeneration);
+ const image = imageUrl.toString();
const title = escapeHtml(post.title);
const description = "A https://sideshow.sh surface";
return [
@@ -941,7 +955,10 @@ export function createApp({
].join("\n");
};
- const configuredViewerHtml = (c: Context, opts: { post?: Post; title?: string | null } = {}) => {
+ const configuredViewerHtml = async (
+ c: Context,
+ opts: { post?: Post; title?: string | null } = {},
+ ) => {
// The viewer HTML is the trusted app origin — it shares that origin with the
// authenticated API and the comment→agent channel, so a cross-origin page
// that frames it could clickjack actions or the prompt-injection channel.
@@ -960,16 +977,20 @@ export function createApp({
),
pageTitle,
);
- return opts.post ? injectHead(html, postPreviewHead(opts.post, c.req.raw)) : html;
+ if (!opts.post) return html;
+ const themeId = (await store.getSetting("theme")) ?? DEFAULT_THEME_ID;
+ return injectHead(html, postPreviewHead(opts.post, c.req.raw, themeId, version ?? "dev"));
};
- app.get("/", (c) => c.html(configuredViewerHtml(c)));
- app.get("/connect", (c) => c.html(configuredViewerHtml(c, { title: "Connect an agent" })));
+ app.get("/", async (c) => c.html(await configuredViewerHtml(c)));
+ app.get("/connect", async (c) =>
+ c.html(await configuredViewerHtml(c, { title: "Connect an agent" })),
+ );
app.get("/session/:id", async (c) => {
const session = await store.getSession(c.req.param("id"));
if (isUnauthenticatedSessionRead(c) && !session) {
return c.text("Session not found", 404);
}
- return c.html(configuredViewerHtml(c, { title: sessionDocumentTitle(session) }));
+ return c.html(await configuredViewerHtml(c, { title: sessionDocumentTitle(session) }));
});
const sessionPostPage = async (c: any) => {
const session = await store.getSession(c.req.param("id"));
@@ -980,7 +1001,7 @@ export function createApp({
return c.text("Session or post not found", 404);
}
}
- return c.html(configuredViewerHtml(c, { title: sessionDocumentTitle(session) }));
+ return c.html(await configuredViewerHtml(c, { title: sessionDocumentTitle(session) }));
};
app.get("/session/:id/s/:surfaceId", sessionPostPage); // legacy alias
app.get("/session/:id/p/:postId", sessionPostPage);
@@ -1526,7 +1547,7 @@ export function createApp({
if (!post) return c.text("Post not found", 404);
// `part` is the legacy query key; `surface` is canonical.
const surfaceParam = c.req.query("surface") ?? c.req.query("part");
- if (surfaceParam == null) return c.html(configuredViewerHtml(c, { post }));
+ if (surfaceParam == null) return c.html(await configuredViewerHtml(c, { post }));
const ver = c.req.query("ver");
let title = post.title;
diff --git a/test/api.test.ts b/test/api.test.ts
index 7df49d9..26aa3bd 100644
--- a/test/api.test.ts
+++ b/test/api.test.ts
@@ -14,6 +14,7 @@ function makeApp(
basePath?: string;
viewerHtml?: string;
screenshots?: boolean;
+ version?: string;
maxHoldConnections?: number;
onEvent?: Parameters[0]["onEvent"];
store?: Store;
@@ -359,8 +360,8 @@ test("GET /session/:id serves the viewer shell with the session title", async ()
assert.match(body, /Auth refactor · sideshow<\/title>/);
});
-test("GET /s/:id emits absolute token-free canonical and preview image URLs", async () => {
- const app = makeApp("secret");
+test("GET /s/:id emits fully pinned, absolute, token-free preview image URLs", async () => {
+ const app = makeApp("secret", { version: "1.2.3" });
const res = await app.request(
"https://board.test/api/snippets",
authedJson({ title: "Preview", html: "x
" }),
@@ -369,7 +370,7 @@ test("GET /s/:id emits absolute token-free canonical and preview image URLs", as
const body = await (await app.request(`https://board.test/s/${surface.id}?key=secret`)).text();
const canonical = `https://board.test/p/${surface.id}`;
- const image = `https://board.test/p/${surface.id}.png?card=1`;
+ const image = `https://board.test/p/${surface.id}.png?card=1&theme=github&mode=dark&v=${surface.version}&g=1.2.3`;
assert.match(body, new RegExp(``));
assert.match(body, new RegExp(``));
assert.match(
@@ -430,12 +431,26 @@ test("GET /s/:id preview metadata respects configured base path", async () => {
assert.match(
body,
new RegExp(
- ``,
+ ``,
),
);
assert.match(body, /window\.__SIDESHOW_BASE_PATH__="\/u\/alice"/);
});
+test("post preview image URL changes with the workspace theme", async () => {
+ const app = makeApp(undefined, { version: "1.2.3" });
+ const res = await app.request("/api/snippets", json({ title: "Themed", html: "x
" }));
+ const post = (await res.json()) as any;
+
+ const before = await (await app.request(`/p/${post.id}`)).text();
+ assert.match(before, /theme=github/);
+ const update = await app.request("/api/theme", { ...json({ id: "gruvbox" }), method: "PUT" });
+ assert.equal(update.status, 200);
+ const after = await (await app.request(`/p/${post.id}`)).text();
+ assert.match(after, /theme=gruvbox/);
+ assert.doesNotMatch(after, /theme=github/);
+});
+
test("/s served versioned + themed is cacheable; an unpinned load is not", async () => {
const app = makeApp();
const res = await app.request(
diff --git a/test/workerScreenshot.test.ts b/test/workerScreenshot.test.ts
index 69c6848..5e9f511 100644
--- a/test/workerScreenshot.test.ts
+++ b/test/workerScreenshot.test.ts
@@ -14,15 +14,17 @@ test("post screenshot route matches GET and HEAD requests without baking in an i
test("card screenshots use stable social-card dimensions without fullPage", () => {
const plan = planPostScreenshot(
- new URL("https://workspace.test/p/abc123.png?card=1&w=640&theme=gruvbox&mode=dark&key=secret"),
+ new URL(
+ "https://workspace.test/p/abc123.png?card=1&w=640&theme=gruvbox&mode=dark&v=7&key=secret",
+ ),
"abc123",
"sideshow_mode=light",
);
assert.deepEqual(plan.viewport, { width: 1200, height: 630 });
assert.deepEqual(plan.screenshotOptions, { fullPage: false });
- assert.equal(plan.target, "https://workspace.test/p/abc123?part=0&theme=gruvbox&mode=dark");
- assert.doesNotMatch(plan.target, /key=secret|card=1|w=640/);
+ assert.equal(plan.target, "https://workspace.test/p/abc123?part=0&ver=7&theme=gruvbox&mode=dark");
+ assert.doesNotMatch(plan.target, /key=secret|card=1|w=640|(?:^|[?&])v=/);
});
test("non-card screenshots preserve full-page behavior and configurable width", () => {
diff --git a/test/workerScreenshotCache.test.ts b/test/workerScreenshotCache.test.ts
new file mode 100644
index 0000000..aaa19bd
--- /dev/null
+++ b/test/workerScreenshotCache.test.ts
@@ -0,0 +1,284 @@
+import assert from "node:assert/strict";
+import { test } from "node:test";
+import { planPostScreenshot } from "../workers/screenshot.ts";
+import {
+ postScreenshotCacheKey,
+ postScreenshotClientCacheControl,
+ type EdgeCache,
+ servePostScreenshot,
+ withPostScreenshotCache,
+} from "../workers/screenshotCache.ts";
+
+const GENERATION = "1.2.3";
+const cardUrl = (version = "7", extra = "") =>
+ `https://board.test/p/post_1.png?card=1&theme=github&mode=dark&v=${version}&g=${GENERATION}${extra}`;
+
+function plan(href: string) {
+ const url = new URL(href);
+ return { url, plan: planPostScreenshot(url, "post_1", null) };
+}
+
+function memoryCache() {
+ const entries = new Map();
+ const cache: EdgeCache = {
+ async match(request) {
+ return entries.get(request.url)?.clone();
+ },
+ async put(request, response) {
+ entries.set(request.url, response.clone());
+ },
+ };
+ return { cache, entries };
+}
+
+function png(cacheControl = "public, max-age=300", extraHeaders: Record = {}) {
+ return new Response(new Uint8Array([137, 80, 78, 71]), {
+ headers: {
+ "content-type": "image/png",
+ "cache-control": cacheControl,
+ ...extraHeaders,
+ },
+ });
+}
+
+test("protected screenshots stay private downstream; public-read cards remain shareable", () => {
+ assert.equal(postScreenshotClientCacheControl(false, undefined), "private, max-age=300");
+ assert.equal(postScreenshotClientCacheControl(false, "unexpected"), "private, max-age=300");
+ assert.equal(postScreenshotClientCacheControl(false, "session"), "public, max-age=300");
+ assert.equal(postScreenshotClientCacheControl(false, "full"), "public, max-age=300");
+ assert.equal(postScreenshotClientCacheControl(true, "full"), "no-store");
+});
+
+test("social-card key pins version, theme, mode, and renderer generation", () => {
+ const { url, plan: screenshot } = plan(cardUrl("7", "&utm_source=preview"));
+ const key = postScreenshotCacheKey("GET", url, "post_1", screenshot, GENERATION);
+ assert.equal(
+ key?.url,
+ "https://board.test/__cache/post-screenshot/post_1.png?part=0&v=7&theme=github&mode=dark&g=1.2.3",
+ );
+ assert.equal(screenshot.checkUrl.searchParams.get("ver"), "7");
+ assert.equal(screenshot.checkUrl.searchParams.get("theme"), "github");
+ assert.equal(screenshot.checkUrl.searchParams.get("mode"), "dark");
+ assert.doesNotMatch(key!.url, /utm/);
+});
+
+test("only canonical, fully pinned metadata cards are cacheable", () => {
+ for (const [label, href, method, generation] of [
+ ["unversioned", cardUrl("").replace("&v=&", "&"), "GET", GENERATION],
+ ["non-card", cardUrl().replace("card=1&", ""), "GET", GENERATION],
+ ["HEAD", cardUrl(), "HEAD", GENERATION],
+ ["nocache", cardUrl("1", "&nocache"), "GET", GENERATION],
+ ["missing theme", cardUrl().replace("theme=github&", ""), "GET", GENERATION],
+ ["unknown theme", cardUrl().replace("theme=github", "theme=made-up"), "GET", GENERATION],
+ ["missing mode", cardUrl().replace("mode=dark&", ""), "GET", GENERATION],
+ ["OS mode", cardUrl().replace("mode=dark", "mode=os"), "GET", GENERATION],
+ ["missing generation", cardUrl().replace(`&g=${GENERATION}`, ""), "GET", GENERATION],
+ ["stale generation", cardUrl(), "GET", "2.0.0"],
+ ["leading zero", cardUrl("01"), "GET", GENERATION],
+ ["unsafe integer", cardUrl(String(Number.MAX_SAFE_INTEGER + 1)), "GET", GENERATION],
+ ] as const) {
+ const { url, plan: screenshot } = plan(href);
+ assert.equal(
+ postScreenshotCacheKey(method, url, "post_1", screenshot, generation),
+ null,
+ label,
+ );
+ }
+
+ const { url, plan: screenshot } = plan(cardUrl("2"));
+ screenshot.checkUrl.searchParams.set("ver", "1");
+ assert.equal(postScreenshotCacheKey("GET", url, "post_1", screenshot, GENERATION), null);
+});
+
+test("a miss stores a one-hour internal copy but restores a private client policy", async () => {
+ const { url, plan: screenshot } = plan(cardUrl());
+ const key = postScreenshotCacheKey("GET", url, "post_1", screenshot, GENERATION)!;
+ const { cache, entries } = memoryCache();
+ const deferred: Promise[] = [];
+ let captures = 0;
+
+ const response = await withPostScreenshotCache(
+ key,
+ (promise) => deferred.push(promise),
+ async () => {
+ captures++;
+ return png("private, max-age=300");
+ },
+ cache,
+ );
+ await Promise.all(deferred);
+
+ assert.equal(captures, 1);
+ assert.equal(response.headers.get("x-sideshow-screenshot-cache"), "miss");
+ assert.equal(response.headers.get("cache-control"), "private, max-age=300");
+ const stored = entries.get(key.url)!;
+ assert.equal(stored.headers.get("cache-control"), "public, max-age=3600");
+ assert.equal(stored.headers.get("x-sideshow-origin-cache-control"), "private, max-age=300");
+});
+
+test("a valid hit skips capture and restores its client cache policy", async () => {
+ const { url, plan: screenshot } = plan(cardUrl());
+ const key = postScreenshotCacheKey("GET", url, "post_1", screenshot, GENERATION)!;
+ const { cache } = memoryCache();
+ await cache.put(
+ key,
+ png("public, max-age=3600", {
+ "x-sideshow-origin-cache-control": "public, max-age=300",
+ }),
+ );
+ let captures = 0;
+
+ const response = await withPostScreenshotCache(
+ key,
+ () => {},
+ async () => {
+ captures++;
+ return png();
+ },
+ cache,
+ );
+
+ assert.equal(captures, 0);
+ assert.equal(response.headers.get("x-sideshow-screenshot-cache"), "hit");
+ assert.equal(response.headers.get("cache-control"), "public, max-age=300");
+ assert.equal(response.headers.has("x-sideshow-origin-cache-control"), false);
+});
+
+test("malformed hits and unsafe capture responses never escape into shared cache", async () => {
+ const { url, plan: screenshot } = plan(cardUrl());
+ const key = postScreenshotCacheKey("GET", url, "post_1", screenshot, GENERATION)!;
+ const { cache } = memoryCache();
+ await cache.put(
+ key,
+ png("public, max-age=3600", {
+ "x-sideshow-origin-cache-control": "public, private, max-age=300",
+ }),
+ );
+ let captures = 0;
+ const deferred: Promise[] = [];
+ const repaired = await withPostScreenshotCache(
+ key,
+ (promise) => deferred.push(promise),
+ async () => {
+ captures++;
+ return png();
+ },
+ cache,
+ );
+ await Promise.all(deferred);
+ assert.equal(captures, 1);
+ assert.equal(repaired.headers.get("x-sideshow-screenshot-cache"), "miss");
+
+ for (const [label, response] of [
+ ["mixed-case no-store", png("public, No-Store")],
+ ["contradictory", png("private, public")],
+ ["cookie", png(undefined, { "set-cookie": "x=1" })],
+ [
+ "not PNG",
+ new Response("text", {
+ headers: { "content-type": "text/plain", "cache-control": "public, max-age=300" },
+ }),
+ ],
+ ] as const) {
+ let puts = 0;
+ const rejectingCache: EdgeCache = {
+ async match() {
+ return undefined;
+ },
+ async put() {
+ puts++;
+ },
+ };
+ await withPostScreenshotCache(
+ key,
+ () => {},
+ async () => response,
+ rejectingCache,
+ );
+ assert.equal(puts, 0, label);
+ }
+});
+
+test("orchestration revalidates before hits and applies the current access policy", async () => {
+ const request = () => new Request(cardUrl());
+ const { url, plan: screenshot } = plan(cardUrl());
+ const { cache } = memoryCache();
+ const deferred: Promise[] = [];
+ let authorized = true;
+ let clientCacheControl = "public, max-age=300";
+ let authorizationChecks = 0;
+ let captures = 0;
+
+ const serve = () =>
+ servePostScreenshot({
+ request: request(),
+ requestUrl: url,
+ postId: "post_1",
+ plan: screenshot,
+ rendererGeneration: GENERATION,
+ clientCacheControl,
+ defer: (promise) => deferred.push(promise),
+ authorize: async () => {
+ authorizationChecks++;
+ return authorized ? new Response("renderable") : new Response("not found", { status: 404 });
+ },
+ capture: async () => {
+ captures++;
+ return png();
+ },
+ cache,
+ });
+
+ const miss = await serve();
+ await Promise.all(deferred.splice(0));
+ // Simulate disabling public-read after the public image was cached. The same
+ // pixels may be reused after authorization, but the OLD public directive must not.
+ clientCacheControl = "private, max-age=300";
+ const hit = await serve();
+ assert.equal(miss.headers.get("x-sideshow-screenshot-cache"), "miss");
+ assert.equal(miss.headers.get("cache-control"), "public, max-age=300");
+ assert.equal(hit.headers.get("x-sideshow-screenshot-cache"), "hit");
+ assert.equal(hit.headers.get("cache-control"), "private, max-age=300");
+ assert.equal(authorizationChecks, 2);
+ assert.equal(captures, 1);
+
+ authorized = false;
+ const deletedOrDenied = await serve();
+ assert.equal(deletedOrDenied.status, 404);
+ assert.equal(authorizationChecks, 3);
+ assert.equal(captures, 1);
+});
+
+test("HEAD authorizes but never reads cache or invokes Browser Rendering", async () => {
+ const request = new Request(cardUrl(), { method: "HEAD" });
+ const { url, plan: screenshot } = plan(cardUrl());
+ let cacheReads = 0;
+ let captures = 0;
+ const cache: EdgeCache = {
+ async match() {
+ cacheReads++;
+ return undefined;
+ },
+ async put() {},
+ };
+ const response = await servePostScreenshot({
+ request,
+ requestUrl: url,
+ postId: "post_1",
+ plan: screenshot,
+ rendererGeneration: GENERATION,
+ clientCacheControl: "public, max-age=300",
+ defer: () => {},
+ authorize: async () => new Response("renderable"),
+ capture: async () => {
+ captures++;
+ return png();
+ },
+ cache,
+ });
+
+ assert.equal(response.status, 200);
+ assert.equal(response.headers.get("cache-control"), "public, max-age=300");
+ assert.equal(cacheReads, 0);
+ assert.equal(captures, 0);
+});
diff --git a/workers/index.ts b/workers/index.ts
index 1db111e..03aab60 100644
--- a/workers/index.ts
+++ b/workers/index.ts
@@ -7,6 +7,7 @@ import { createApp } from "../server/app.ts";
import { SqlStore } from "../server/sqlStore.ts";
import viewerHtml from "../viewer/dist/index.html";
import { matchPostScreenshot, planPostScreenshot } from "./screenshot.ts";
+import { postScreenshotClientCacheControl, servePostScreenshot } from "./screenshotCache.ts";
interface Env {
BOARD: DurableObjectNamespace;
@@ -47,7 +48,7 @@ export class SideshowBoard extends DurableObject {
}
export default {
- async fetch(request: Request, env: Env) {
+ async fetch(request: Request, env: Env, ctx: ExecutionContext) {
if (!env.SIDESHOW_TOKEN) {
return new Response(
"sideshow is not configured: set a token first —\n\n wrangler secret put SIDESHOW_TOKEN\n",
@@ -68,35 +69,29 @@ export default {
// page matches what the viewer shows; the width is configurable via ?w=
// (default 800). Social card mode is fixed at 1200x630.
const plan = planPostScreenshot(url, postId, request.headers.get("cookie"));
- const checkRes = await workspace.fetch(
- new Request(plan.checkUrl, { headers: request.headers }),
+ const clientCacheControl = postScreenshotClientCacheControl(
+ plan.noCache,
+ env.SIDESHOW_PUBLIC_READ,
);
- if (!checkRes.ok) return checkRes;
- // Auth passed and post exists — discard the HTML. For HEAD, return the
- // same public image headers Slack checks without paying for Browser Rendering.
- await checkRes.arrayBuffer();
- if (request.method === "HEAD") {
- return new Response(null, {
- headers: {
- "Content-Type": "image/png",
- "Cache-Control": plan.noCache ? "no-store" : "public, max-age=300",
- },
- });
- }
- const screenshot = await env.BROWSER.quickAction("screenshot", {
- url: plan.target,
- viewport: plan.viewport,
- screenshotOptions: plan.screenshotOptions,
- 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": plan.noCache ? "no-store" : "public, max-age=300",
- },
+ return servePostScreenshot({
+ request,
+ requestUrl: url,
+ postId,
+ plan,
+ rendererGeneration: pkg.version,
+ clientCacheControl,
+ defer: (promise) => ctx.waitUntil(promise),
+ authorize: () => workspace.fetch(new Request(plan.checkUrl, { headers: request.headers })),
+ capture: () =>
+ env.BROWSER.quickAction("screenshot", {
+ url: plan.target,
+ viewport: plan.viewport,
+ screenshotOptions: plan.screenshotOptions,
+ gotoOptions: { waitUntil: "networkidle0", timeout: 15000 },
+ cacheTTL: 0,
+ cookies: [{ name: "sideshow_key", value: env.SIDESHOW_TOKEN!, domain: url.hostname }],
+ }),
});
},
} satisfies ExportedHandler;
diff --git a/workers/screenshot.ts b/workers/screenshot.ts
index 3490ab1..3f480ef 100644
--- a/workers/screenshot.ts
+++ b/workers/screenshot.ts
@@ -40,6 +40,10 @@ export function planPostScreenshot(
checkUrl.pathname = `/p/${postId}`;
checkUrl.search = ""; // clear .png query params, including tokens
checkUrl.searchParams.set("part", "0");
+ // Social metadata pins the post version as public `v`; the renderer calls it
+ // `ver` and validates it against current/history before any cache lookup.
+ const version = requestUrl.searchParams.get("v");
+ if (version) checkUrl.searchParams.set("ver", version);
if (theme) checkUrl.searchParams.set("theme", theme);
if (mode) checkUrl.searchParams.set("mode", mode);
diff --git a/workers/screenshotCache.ts b/workers/screenshotCache.ts
new file mode 100644
index 0000000..5886e11
--- /dev/null
+++ b/workers/screenshotCache.ts
@@ -0,0 +1,219 @@
+// Short-lived edge cache for the versioned social-card screenshot advertised by
+// post permalink metadata.
+//
+// Authorization and pixel-identity validation happen before cache lookup: the
+// board DO must approve `/p/:id?part=0&ver=N&theme=T&mode=M`. A forged or
+// unavailable version therefore gets a 404 before it can read or populate cache;
+// a deleted post and changed auth policy are likewise enforced on every edge
+// request. A hit avoids only the expensive Browser Rendering call.
+
+import { themeOptions } from "../server/themes.ts";
+import type { PostScreenshotPlan } from "./screenshot.ts";
+
+const EDGE_MAX_AGE_SECONDS = 3600;
+const ORIGIN_CACHE_CONTROL = "x-sideshow-origin-cache-control";
+const CACHE_STATUS_HEADER = "x-sideshow-screenshot-cache";
+
+// Only the methods used here, so Node tests can provide a Map-backed stand-in.
+export interface EdgeCache {
+ match(request: Request): Promise;
+ put(request: Request, response: Response): Promise;
+}
+
+// `caches.default` is absent under Node tests and unavailable on workers.dev.
+// Both cases safely degrade to an ordinary uncached screenshot.
+export function defaultEdgeCache(): EdgeCache | null {
+ const store = (globalThis as { caches?: { default?: EdgeCache } }).caches;
+ return store?.default ?? null;
+}
+
+// A token-protected board must never advertise its bearer-gated image as shared
+// cacheable downstream. The internal Cache API copy is separate and remains safe
+// because servePostScreenshot reauthorizes before every lookup.
+export function postScreenshotClientCacheControl(
+ noCache: boolean,
+ publicRead: string | undefined,
+): string {
+ if (noCache) return "no-store";
+ return `${publicRead === "session" || publicRead === "full" ? "public" : "private"}, max-age=300`;
+}
+
+// Cache only the exact fixed-size, fully pinned shape emitted by postPreviewHead.
+// Unrelated tracking parameters are ignored rather than fragmenting the cache.
+export function postScreenshotCacheKey(
+ method: string,
+ requestUrl: URL,
+ postId: string,
+ plan: PostScreenshotPlan,
+ rendererGeneration: string,
+): Request | null {
+ if (method !== "GET" || plan.noCache || requestUrl.searchParams.get("card") !== "1") {
+ return null;
+ }
+
+ const version = requestUrl.searchParams.get("v");
+ if (!version || !/^(0|[1-9]\d*)$/.test(version)) return null;
+ const numericVersion = Number(version);
+ if (!Number.isSafeInteger(numericVersion) || numericVersion < 0) return null;
+
+ const theme = requestUrl.searchParams.get("theme");
+ if (!theme || !themeOptions().some((option) => option.id === theme)) return null;
+ const mode = requestUrl.searchParams.get("mode");
+ if (mode !== "light" && mode !== "dark") return null;
+ const generation = requestUrl.searchParams.get("g");
+ if (!generation || generation !== rendererGeneration) return null;
+
+ // planPostScreenshot maps public markers to renderer query names. Equality
+ // ties the cache key to what the DO just validated and Browser Rendering will
+ // actually capture.
+ if (
+ plan.checkUrl.searchParams.get("ver") !== version ||
+ plan.checkUrl.searchParams.get("theme") !== theme ||
+ plan.checkUrl.searchParams.get("mode") !== mode
+ ) {
+ return null;
+ }
+
+ const params = new URLSearchParams({
+ part: plan.checkUrl.searchParams.get("part") ?? "0",
+ v: version,
+ theme,
+ mode,
+ g: generation,
+ });
+
+ return new Request(
+ `${requestUrl.origin}/__cache/post-screenshot/${encodeURIComponent(postId)}.png?${params}`,
+ { method: "GET" },
+ );
+}
+
+function isSafeClientControl(control: string): boolean {
+ const normalized = control.toLowerCase();
+ if (/\b(?:no-cache|no-store)\b/.test(normalized)) return false;
+ const isPublic = /\bpublic\b/.test(normalized);
+ const isPrivate = /\bprivate\b/.test(normalized);
+ // Exactly one audience directive must be present. The internal Cache API copy
+ // is public either way; this original policy is restored only to the client.
+ return isPublic !== isPrivate;
+}
+
+function isPng(res: Response): boolean {
+ return res.headers.get("content-type")?.split(";", 1)[0].trim().toLowerCase() === "image/png";
+}
+
+function isStorable(res: Response): boolean {
+ return (
+ res.status === 200 &&
+ isPng(res) &&
+ !res.headers.has("set-cookie") &&
+ isSafeClientControl(res.headers.get("cache-control") ?? "")
+ );
+}
+
+function storableCopy(res: Response): Response {
+ const copy = res.clone();
+ const stored = new Response(copy.body, copy);
+ stored.headers.set(ORIGIN_CACHE_CONTROL, res.headers.get("cache-control") ?? "no-store");
+ stored.headers.set("cache-control", `public, max-age=${EDGE_MAX_AGE_SECONDS}`);
+ return stored;
+}
+
+function isUsableHit(res: Response): boolean {
+ return (
+ res.status === 200 &&
+ isPng(res) &&
+ isSafeClientControl(res.headers.get(ORIGIN_CACHE_CONTROL) ?? "")
+ );
+}
+
+function tagged(res: Response, state: "hit" | "miss"): Response {
+ const out = new Response(res.body, res);
+ const originControl =
+ out.headers.get(ORIGIN_CACHE_CONTROL) ??
+ (state === "miss" ? (out.headers.get("cache-control") ?? "") : "");
+ out.headers.set("cache-control", isSafeClientControl(originControl) ? originControl : "no-store");
+ out.headers.delete(ORIGIN_CACHE_CONTROL);
+ out.headers.set(CACHE_STATUS_HEADER, state);
+ return out;
+}
+
+export async function withPostScreenshotCache(
+ key: Request | null,
+ defer: (promise: Promise) => void,
+ capture: () => Promise,
+ cache: EdgeCache | null = defaultEdgeCache(),
+): Promise {
+ if (!key || !cache) return capture();
+
+ const hit = await cache.match(key).catch(() => undefined);
+ if (hit && isUsableHit(hit)) return tagged(hit, "hit");
+
+ const res = await capture();
+ if (!isStorable(res)) return res;
+ defer(cache.put(key, storableCopy(res)).catch(() => {}));
+ return tagged(res, "miss");
+}
+
+export interface ServePostScreenshotOptions {
+ request: Request;
+ requestUrl: URL;
+ postId: string;
+ plan: PostScreenshotPlan;
+ rendererGeneration: string;
+ clientCacheControl: string;
+ defer: (promise: Promise) => void;
+ // Must perform the real board-app read with the caller's credentials. Keeping
+ // this callback inside the orchestration function makes auth-before-cache a
+ // testable invariant rather than merely call-site ordering.
+ authorize: () => Promise;
+ capture: () => Promise;
+ cache?: EdgeCache | null;
+}
+
+// Authorize and validate at the DO first, then serve/fill the edge cache. The
+// capture callback returns Browser Rendering's response; this function owns the
+// final PNG and client cache headers so private deployments cannot accidentally
+// emit a public response.
+export async function servePostScreenshot({
+ request,
+ requestUrl,
+ postId,
+ plan,
+ rendererGeneration,
+ clientCacheControl,
+ defer,
+ authorize,
+ capture,
+ cache = defaultEdgeCache(),
+}: ServePostScreenshotOptions): Promise {
+ const checkRes = await authorize();
+ if (!checkRes.ok) return checkRes;
+ await checkRes.arrayBuffer();
+
+ if (request.method === "HEAD") {
+ return new Response(null, {
+ headers: { "content-type": "image/png", "cache-control": clientCacheControl },
+ });
+ }
+
+ const key = postScreenshotCacheKey(request.method, requestUrl, postId, plan, rendererGeneration);
+ const response = await withPostScreenshotCache(
+ key,
+ defer,
+ async () => {
+ const screenshot = await capture();
+ return new Response(await screenshot.arrayBuffer(), {
+ headers: { "content-type": "image/png", "cache-control": clientCacheControl },
+ });
+ },
+ cache,
+ );
+ // Access policy may change while the pixel identity stays the same (for
+ // example, public-read is disabled after a card was cached). Authorization
+ // above uses the CURRENT policy, so its client directive must also win over
+ // the policy recorded with an older internal entry.
+ const out = new Response(response.body, response);
+ out.headers.set("cache-control", clientCacheControl);
+ return out;
+}