Skip to content
File

Blob: src/worker/sites/cache.ts

typescript171 lines
1import {
2 parseSitePmJsonEnvelope as parseEnvelopeText,
3 type SitePmJsonEnvelope,
4 SitePmJsonEnvelopeSchema,
5} from "@/shared/sites/pm-json-schemas";
6import type { ResolvedPublishedPage, ResolvedSite } from "@/worker/lib/published-pages";
7 
8const SITES_CACHE_NAME = "sites:v1";
9const SITES_HTML_CACHE_TAG = "sites-html";
10 
11export type { SitePmJsonEnvelope };
12 
13export async function getSitesCache(): Promise<Cache> {
14 return caches.open(SITES_CACHE_NAME);
15}
16 
17/**
18 * Cache API key. Carries the visitor's host and path, drops visitor query
19 * params, and normalizes the method. Visitors never see this URL.
20 */
21export function buildSiteCacheKey(request: Request): Request {
22 const original = new URL(request.url);
23 const normalized = new URL(`${original.protocol}//${original.host}${original.pathname}`);
24 return new Request(normalized.toString(), { method: "GET" });
25}
26 
27export function buildSiteCacheTags(
28 site: Pick<ResolvedSite, "workspace_id">,
29 page: Pick<ResolvedPublishedPage, "id" | "published_root_id">,
30): string {
31 return [SITES_HTML_CACHE_TAG, `site:${site.workspace_id}`, `page:${page.id}`, `root:${page.published_root_id}`].join(
32 ",",
33 );
34}
35 
36export function getSitesRendererVersion(env: Pick<Env, "CF_VERSION_METADATA">): string {
37 return env.CF_VERSION_METADATA.id;
38}
39 
40export interface SiteHtmlRevisionInput {
41 rendererVersion: string;
42 artifactEtag: string;
43 site: Pick<
44 ResolvedSite,
45 "workspace_id" | "slug" | "home_page_id" | "updated_at" | "workspace_name" | "workspace_icon"
46 >;
47 page: Pick<ResolvedPublishedPage, "id" | "title" | "icon" | "cover_url" | "updated_at" | "published_root_id">;
48 canonicalPath: string;
49 currentIsHome: boolean;
50}
51 
52export async function createSiteHtmlRevision(input: SiteHtmlRevisionInput): Promise<string> {
53 return createRenderDependencyHash({
54 rendererVersion: input.rendererVersion,
55 artifactEtag: input.artifactEtag,
56 site: {
57 workspaceId: input.site.workspace_id,
58 slug: input.site.slug,
59 homePageId: input.site.home_page_id,
60 updatedAt: input.site.updated_at,
61 workspaceName: input.site.workspace_name,
62 workspaceIcon: input.site.workspace_icon,
63 },
64 page: {
65 id: input.page.id,
66 title: input.page.title,
67 icon: input.page.icon,
68 coverUrl: input.page.cover_url,
69 updatedAt: input.page.updated_at,
70 publishedRootId: input.page.published_root_id,
71 },
72 routing: {
73 canonicalPath: input.canonicalPath,
74 currentIsHome: input.currentIsHome,
75 },
76 });
77}
78 
79export function buildSiteHtmlEtag(revision: string): string {
80 return `"sites-html:${revision.replace(/"/g, "")}"`;
81}
82
83export function siteHtmlEtagMatches(ifNoneMatch: string | null, etag: string): boolean {
84 if (!ifNoneMatch) return false;
85 const normalizedEtag = normalizeEtag(etag);
86 for (const candidate of ifNoneMatch.split(",")) {
87 const trimmed = candidate.trim();
88 if (trimmed === "*") return true;
89 if (normalizeEtag(trimmed) === normalizedEtag) return true;
90 }
91 return false;
92}
93
94export interface SiteR2Read {
95 envelope: SitePmJsonEnvelope | null;
96 fresh: boolean;
97 etag: string;
98}
99
100export async function readSiteR2(
101 env: Pick<Env, "SITES">,
102 workspaceId: string,
103 pageId: string,
104 expectedUpdatedAt: string,
105): Promise<SiteR2Read | null> {
106 const object = await env.SITES.get(buildSiteR2ObjectKey(workspaceId, pageId));
107 if (!object) return null;
108 const envelope = parseEnvelopeText(await object.text());
109 return {
110 envelope,
111 fresh: envelope !== null && object.customMetadata?.updated_at === expectedUpdatedAt,
112 etag: object.httpEtag,
113 };
114}
115
116export async function writeSiteR2(
117 env: Pick<Env, "SITES">,
118 workspaceId: string,
119 pageId: string,
120 envelope: SitePmJsonEnvelope,
121): Promise<void> {
122 await env.SITES.put(buildSiteR2ObjectKey(workspaceId, pageId), JSON.stringify(envelope), {
123 httpMetadata: { contentType: "application/json; charset=utf-8" },
124 customMetadata: { updated_at: envelope.updatedAt },
125 });
126}
127
128export function buildSiteR2ObjectKey(workspaceId: string, pageId: string): string {
129 return `${workspaceId}/${pageId}.json`;
130}
131
132export function parseSitePmJsonEnvelope(text: string): SitePmJsonEnvelope | null {
133 return parseEnvelopeText(text);
134}
135
136export { SitePmJsonEnvelopeSchema };
137
138/**
139 * Stable SHA-256 fingerprint over render-affecting inputs. Object keys are
140 * recursively sorted before JSON.stringify so semantically equal inputs produce
141 * identical digests.
142 */
143export async function createRenderDependencyHash(input: unknown): Promise<string> {
144 const canonical = JSON.stringify(canonicalize(input));
145 const bytes = new TextEncoder().encode(canonical);
146 const digest = await crypto.subtle.digest("SHA-256", bytes);
147 return base64url(new Uint8Array(digest));
148}
149
150function canonicalize(value: unknown): unknown {
151 if (value === null || typeof value !== "object") return value;
152 if (Array.isArray(value)) return value.map((entry) => canonicalize(entry));
153 const entries = Object.entries(value as Record<string, unknown>)
154 .filter(([, v]) => v !== undefined)
155 .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
156 const result: Record<string, unknown> = {};
157 for (const [k, v] of entries) result[k] = canonicalize(v);
158 return result;
159}
160
161function base64url(bytes: Uint8Array): string {
162 let binary = "";
163 for (let i = 0; i < bytes.length; i += 1) binary += String.fromCharCode(bytes[i]);
164 return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
165}
166
167function normalizeEtag(etag: string): string {
168 const trimmed = etag.trim();
169 return trimmed.startsWith("W/") ? trimmed.slice(2).trim() : trimmed;
170}
171