File
Blob: src/worker/routes/d1Bookmark.ts
| 1 | import { getCookie, setCookie } from "hono/cookie"; |
| 2 | import type { CookieOptions } from "hono/utils/cookie"; |
| 3 | import 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. |
| 11 | export const D1_BOOKMARK_HEADER = "x-goc-d1-bookmark"; |
| 12 | export const D1_BOOKMARK_COOKIE_NAME = "goc-d1-bm"; |
| 13 | export 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. |
| 25 | const D1_BOOKMARK_MAX_LENGTH = 256; |
| 26 | const CONTROL_CHARACTER_PATTERN = /[\x00-\x1F\x7F]/; |
| 27 | const D1_BOOKMARK_COOKIE_PREFIX = "host"; |
| 28 | const 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 | |
| 35 | function 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 | |
| 44 | export 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 | |
| 52 | export 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 | } |