Skip to content
File

Blob: src/worker/routes/d1Bookmark.ts

typescript67 lines
1import { getCookie, setCookie } from "hono/cookie";
2import type { CookieOptions } from "hono/utils/cookie";
3import type { AppContext } from "./hono";
4 
5// D1 Sessions API transport contract. Hono middleware opens exactly one
6// `D1DatabaseSession` per request and uses these helpers to carry the
7// advanced bookmark into the next request. Hono's `host` prefix option
8// serializes this as a `__Host-` cookie so browsers enforce `Secure` +
9// `Path=/` + no `Domain`; `HttpOnly` because the bookmark is consistency
10// metadata, not something the SSR UI needs to read from JS.
11export const D1_BOOKMARK_HEADER = "x-goc-d1-bookmark";
12export const D1_BOOKMARK_COOKIE_NAME = "goc-d1-bm";
13export const D1_BOOKMARK_COOKIE_MAX_AGE_SECONDS = 300;
14 
15// D1 bookmarks are short Lamport-style logical-clock tokens. The current
16// format is four hyphen-separated hex groups (`xxxxxxxx-xxxxxxxx-xxxxxxxx-`
17// `xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx`, 59 chars total) and they are
18// lexicographically sortable; see
19// https://developers.cloudflare.com/d1/reference/time-travel/#bookmarks.
20// We still treat the value as opaque per the API contract, but cap the
21// accepted length well below the cookie/header size envelope so a
22// malformed inbound value cannot inflate downstream serialization. The
23// control-character check keeps stray bytes from poisoning `Set-Cookie`
24// or response-header emission.
25const D1_BOOKMARK_MAX_LENGTH = 256;
26const CONTROL_CHARACTER_PATTERN = /[\x00-\x1F\x7F]/;
27const D1_BOOKMARK_COOKIE_PREFIX = "host";
28const D1_BOOKMARK_COOKIE_OPTIONS = {
29 httpOnly: true,
30 sameSite: "Lax",
31 maxAge: D1_BOOKMARK_COOKIE_MAX_AGE_SECONDS,
32 prefix: D1_BOOKMARK_COOKIE_PREFIX,
33} as const satisfies CookieOptions;
34 
35function sanitizeBookmark(raw: string | null): string | null {
36 if (raw === null) return null;
37 const trimmed = raw.trim();
38 if (trimmed.length === 0) return null;
39 if (trimmed.length > D1_BOOKMARK_MAX_LENGTH) return null;
40 if (CONTROL_CHARACTER_PATTERN.test(trimmed)) return null;
41 return trimmed;
42}
43 
44export function readInboundD1Bookmark(c: AppContext): string | null {
45 // Header takes precedence over cookie so a programmatic client can
46 // override whatever the browser happens to be carrying.
47 const headerValue = sanitizeBookmark(c.req.raw.headers.get(D1_BOOKMARK_HEADER));
48 if (headerValue) return headerValue;
49 return sanitizeBookmark(getCookie(c, D1_BOOKMARK_COOKIE_NAME, D1_BOOKMARK_COOKIE_PREFIX) ?? null);
50}
51 
52export function emitD1Bookmark(
53 c: AppContext,
54 session: D1DatabaseSession,
55 inboundBookmark: string | null
56): void {
57 const bookmark = session.getBookmark();
58 // No queries ran on the session (or none advanced state).
59 if (bookmark === null) return;
60 // Avoid header/cookie churn when the bookmark hasn't moved past the
61 // value we already observed on the request. This also covers
62 // primary-anchored requests that didn't actually perform a write.
63 if (bookmark === inboundBookmark) return;
64 c.header(D1_BOOKMARK_HEADER, bookmark);
65 setCookie(c, D1_BOOKMARK_COOKIE_NAME, bookmark, D1_BOOKMARK_COOKIE_OPTIONS);
66}