import type { Context } from "hono"; /** `Vary` for a page that serves one body per URL: the cookie decides which * viewer's render it is. */ const VARY_PAGE = "Cookie"; /** `Vary` for a route that also branches on the `X-Fragment` request header, * returning a bare gallery fragment for the island's swap and the whole page * for a normal navigation. Two different bodies under one URL: a cache that * keys only on the URL can store the fragment and later hand it to a * navigation (or the reverse), which renders as a broken page. Naming the * header here keeps the two apart. */ export const VARY_FRAGMENT_PAGE = `${VARY_PAGE}, X-Fragment`; /** Set the cache headers for a page rendered through `AppShell`. * * Every such page is viewer-dependent: the header carries the signed-in * handle and avatar, and the footer's first site link reads "My profile" * rather than "Sign in". Both headers are needed and neither is enough on its * own. `private, no-store` keeps the signed-in render out of shared caches, * which is what stops one viewer's identity being handed to another; `Vary: * Cookie` stops a stored anonymous copy being served back to a viewer who * does have a session. Routes supply only the anonymous directive, so a * caller cannot express half of the pair. * * Pass `fragment: true` from a route that branches on `X-Fragment`, so the * two bodies it can return for one URL are not interchangeable to a cache. * * A page that is never shared (an owner-only dashboard) does not need this: * it can set `private, no-store` outright. */ export function setPageCache( c: Context, viewer: unknown, anonymous: string, { fragment = false }: { fragment?: boolean } = {}, ): void { c.header("Vary", fragment ? VARY_FRAGMENT_PAGE : VARY_PAGE); c.header("Cache-Control", viewer ? "private, no-store" : anonymous); } /** Trims and length-caps a raw request query-param value. Returns "" when the * param is absent, so callers can `|| undefined` when they want to omit it. */ export function trimQueryParam(value: string | undefined, max = 128): string { return (value ?? "").trim().slice(0, max); } /** Read at most `maxBytes` of a response body, then cancel the rest. * `res.text()` buffers whatever the peer sends before any truncation can * apply, so a caller that only wants a line of diagnostics still pays for the * whole payload. Pair with `Accept-Encoding: identity` when the size bound * matters: a small gzip body can decode to far more than it transferred. * Never throws - a body that errors mid-read yields what was read so far. */ export async function readCapped(res: Response, maxBytes: number): Promise { if (!res.body) return ""; const reader = res.body.getReader(); const decoder = new TextDecoder("utf-8", { fatal: false }); let received = 0; let out = ""; try { while (received < maxBytes) { const { done, value } = await reader.read(); if (done) break; received += value.byteLength; out += decoder.decode(value, { stream: true }); } out += decoder.decode(); } catch { // Partial output is still useful to the caller; the read is best-effort. } finally { await reader.cancel().catch(() => {}); } return out; }