Skip to content
File

Blob: src/worker/sites/router.ts

typescript428 lines
1import { Hono, type Context } from "hono";
2import { setMetric, timing, wrapTime } from "hono/timing";
3 
4import { createSessionDb, type Db } from "@/worker/db/d1/client";
5import { matchSiteHost, type SiteHostMatch } from "@/worker/lib/host-match";
6import {
7 isSitesFeatureEnabled,
8 resolvePublishedSitePage,
9 type ResolvedPublishedPage,
10 type ResolvedPublishedSitePage,
11 type ResolvedSite,
12} from "@/worker/lib/published-pages";
13import { buildSitePagePath } from "@/worker/lib/site-public-url";
14import { applySitesSecurityHeaders } from "@/worker/lib/security-headers";
15import {
16 withSitesPageDocumentPreloadHeaders,
17 withSitesStaticDocumentPreloadHeaders,
18} from "@/worker/sites/preload-headers";
19import { serveSiteAsset } from "@/worker/sites/assets";
20import { coverNotFound, serveSiteCover } from "@/worker/sites/cover";
21import {
22 buildSiteCacheKey,
23 buildSiteCacheTags,
24 buildSiteHtmlEtag,
25 createSiteHtmlRevision,
26 getSitesCache,
27 getSitesRendererVersion,
28 siteHtmlEtagMatches,
29} from "@/worker/sites/cache";
30import { loadPagePmJson } from "@/worker/sites/load-page-pm-json";
31import { prepareSitePageRender } from "@/worker/sites/prepare-page-render";
32import { resolveSitesDocumentAssets } from "@/worker/sites/manifest";
33import {
34 renderApexDocumentHtml,
35 renderRobotsTxt,
36 renderSiteBuildingDocumentHtml,
37 renderSiteNotFoundDocumentHtml,
38} from "@/sites/document";
39import type { SiteDocumentAssets } from "@/sites/types";
40 
41const PAGE_ID_LENGTH = 26;
42const PAGE_SEGMENT_ROUTE =
43 "/:pageSegment{[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?-[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}}";
44const COVER_ASSET_ROUTE = "/_assets/:pageId{[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}}/cover";
45const ASSET_ROUTE = "/_assets/:pageId{[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}}/:uploadId{[A-Za-z0-9_-]+}";
46 
47const HTML_HEADERS = {
48 "Content-Type": "text/html; charset=utf-8",
49 "Cache-Control": "public, max-age=0, must-revalidate",
50};
51const INTERNAL_HTML_CACHE_CONTROL = "public, max-age=300, must-revalidate";
52 
53type MatchedSiteHost = Extract<SiteHostMatch, { kind: "apex" | "subdomain" }>;
54 
55interface HtmlCacheState {
56 cache: Promise<Cache>;
57 key: Request;
58 checked: boolean;
59}
60 
61type SitesVariables = {
62 db: Db;
63 siteHost: MatchedSiteHost;
64 htmlCache?: HtmlCacheState;
65};
66 
67type SitesContext = { Bindings: Env; Variables: SitesVariables };
68 
69export const sitesApp = new Hono<SitesContext>();
70 
71sitesApp.use("*", timing());
72 
73sitesApp.use("*", async (c, next) => {
74 await next();
75 c.res = applySitesSecurityHeaders(c.res, c.req.url);
76});
77 
78sitesApp.use("*", async (c, next) => {
79 if (c.req.method !== "GET" && c.req.method !== "HEAD") {
80 return methodNotAllowed(c);
81 }
82 await next();
83});
84 
85sitesApp.use("*", async (c, next) => {
86 const match = matchSiteHost(new URL(c.req.url), c.env);
87 if (!isSitesFeatureEnabled(c.env) || match.kind === "none") {
88 return siteNotFound(c);
89 }
90 
91 c.set("siteHost", match);
92 
93 if (c.req.path === "/robots.txt" || match.kind === "apex") {
94 await next();
95 return;
96 }
97 
98 const { db } = createSessionDb(c.env.DB, "first-unconstrained");
99 c.set("db", db);
100 
101 await next();
102});
103 
104sitesApp.get("/robots.txt", (c) => {
105 return c.text(renderRobotsTxt(), 200, { "Content-Type": "text/plain; charset=utf-8" });
106});
107 
108sitesApp.get("/", async (c) => {
109 const match = c.get("siteHost");
110 if (match.kind === "apex") {
111 const assets = await resolveRequiredSitesDocumentAssets(c);
112 if (!assets) return sitesAssetsUnavailable(c);
113 return c.html(renderApexDocumentHtml({ assets }), 200, withSitesStaticDocumentPreloadHeaders(HTML_HEADERS, assets));
114 }
115 
116 const cached = await serveHtmlCacheHit(c);
117 if (cached) return cached;
118 
119 const resolved = await resolveCurrentSitePage(c, null);
120 if (!resolved) return siteNotFound(c);
121 if (!resolved.page) return siteNotFound(c, resolved.site);
122 return servePage(c, resolved.site, resolved.page, { isHome: true });
123});
124 
125sitesApp.get(COVER_ASSET_ROUTE, async (c) => {
126 const match = c.get("siteHost");
127 if (match.kind === "apex") return coverNotFound();
128 
129 const resolved = await resolveCurrentSitePage(c, c.req.param("pageId").toUpperCase());
130 if (!resolved?.page) return coverNotFound();
131 
132 return serveSiteCover({
133 env: c.env,
134 db: c.get("db"),
135 page: resolved.page,
136 versionHash: c.req.query("v") ?? null,
137 });
138});
139 
140sitesApp.get(ASSET_ROUTE, async (c) => {
141 const match = c.get("siteHost");
142 if (match.kind === "apex") return siteNotFound(c);
143 
144 const resolved = await resolveCurrentSitePage(c, c.req.param("pageId").toUpperCase());
145 if (!resolved) return siteNotFound(c);
146 if (!resolved.page) return siteNotFound(c, resolved.site);
147 
148 return serveSiteAsset({
149 env: c.env,
150 db: c.get("db"),
151 site: resolved.site,
152 page: resolved.page,
153 uploadId: c.req.param("uploadId"),
154 });
155});
156 
157sitesApp.get(PAGE_SEGMENT_ROUTE, async (c) => {
158 const match = c.get("siteHost");
159 if (match.kind === "apex") return siteNotFound(c);
160 
161 const cached = await serveHtmlCacheHit(c);
162 if (cached) return cached;
163 
164 const pageSegment = c.req.param("pageSegment");
165 const pageId = pageSegment.slice(-PAGE_ID_LENGTH).toUpperCase();
166 const resolved = await resolveCurrentSitePage(c, pageId);
167 if (!resolved) return siteNotFound(c);
168 if (!resolved.page) return siteNotFound(c, resolved.site);
169 return servePage(c, resolved.site, resolved.page, { isHome: false, requestedPath: c.req.path });
170});
171 
172sitesApp.get("*", async (c) => {
173 const match = c.get("siteHost");
174 if (match.kind !== "subdomain") return siteNotFound(c);
175 
176 const resolved = await resolveCurrentSitePage(c, null);
177 return siteNotFound(c, resolved?.site ?? null);
178});
179 
180sitesApp.notFound((c) => siteNotFound(c));
181 
182interface ServePageOptions {
183 isHome: boolean;
184 requestedPath?: string;
185}
186 
187async function resolveCurrentSitePage(
188 c: Context<SitesContext>,
189 requestedPageId: string | null,
190): Promise<ResolvedPublishedSitePage | null> {
191 const match = c.get("siteHost");
192 if (match.kind !== "subdomain") return null;
193 return timeSite(c, "site_page_lookup", () =>
194 resolvePublishedSitePage(c.get("db"), match.slug, c.req.path, requestedPageId),
195 );
196}
197 
198async function servePage(
199 c: Context<SitesContext>,
200 site: ResolvedSite,
201 page: ResolvedPublishedPage,
202 opts: ServePageOptions,
203): Promise<Response> {
204 if (!opts.isHome) {
205 if (site.home_page_id === page.id) {
206 return redirect(c, "/");
207 }
208 const canonicalPath = buildSitePagePath(page.id, page.title);
209 if (opts.requestedPath && opts.requestedPath !== canonicalPath) {
210 return redirect(c, canonicalPath);
211 }
212 }
213 
214 return serveCachedOrRender(c, site, page);
215}
216 
217async function serveCachedOrRender(
218 c: Context<SitesContext>,
219 site: ResolvedSite,
220 page: ResolvedPublishedPage,
221): Promise<Response> {
222 const request = c.req.raw;
223 const db = c.get("db");
224 const rendererVersion = getSitesRendererVersion(c.env);
225 const currentIsHome = site.home_page_id === page.id;
226 const canonicalPath = currentIsHome ? "/" : buildSitePagePath(page.id, page.title);
227 const url = new URL(c.req.url);
228 const canonicalUrl = `${url.protocol}//${url.host}${canonicalPath}`;
229 
230 const pmJson = await loadPagePmJson({ env: c.env, page, timings: (name, operation) => timeSite(c, name, operation) });
231 if (!pmJson) {
232 c.executionCtx.waitUntil(enqueuePageProjection(c.env, page.id));
233 return siteBuilding(c, site);
234 }
235 
236 const revision = await createSiteHtmlRevision({
237 rendererVersion,
238 artifactEtag: pmJson.artifactEtag,
239 site,
240 page,
241 currentIsHome,
242 canonicalPath,
243 });
244 const baseHeaders = buildHtmlHeaders(site, revision, pmJson.artifactUpdatedAt);
245 
246 if (siteHtmlEtagMatches(request.headers.get("If-None-Match"), baseHeaders.ETag)) {
247 markSite(c, "cache_read", "skipped_304");
248 return c.body(null, 304, baseHeaders);
249 }
250 
251 if (pmJson.stale) {
252 c.executionCtx.waitUntil(enqueuePageProjection(c.env, page.id));
253 }
254 
255 const prepared = await prepareSitePageRender({
256 env: c.env,
257 db,
258 timings: (name, operation) => timeSite(c, name, operation),
259 page,
260 pmJson,
261 currentIsHome,
262 canonicalPath,
263 canonicalUrl,
264 });
265 if (!prepared) return sitesAssetsUnavailable(c);
266 const headers = withSitesPageDocumentPreloadHeaders(baseHeaders, prepared.assets);
267 
268 const stream = await timeSite(c, "render_stream", async () => {
269 // ADR: keep Tiptap/static-renderer out of Worker startup; load it only on Sites HTML cache misses.
270 const { renderSitePageDocumentStream } = await import("@/worker/sites/render-page-stream");
271 return renderSitePageDocumentStream({ db, site, page, prepared });
272 });
273 const [responseStream, cacheStream] = stream.tee();
274 
275 const cacheState = htmlCacheState(c);
276 const cacheResponse = new Response(cacheStream, {
277 status: 200,
278 headers: internalHtmlHeaders(headers, site, page),
279 });
280 markSite(c, "cache_write", "scheduled");
281 const cache = await cacheState.cache;
282 c.executionCtx.waitUntil(cache.put(cacheState.key, cacheResponse));
283 
284 return c.body(responseStream, 200, headers);
285}
286 
287async function serveHtmlCacheHit(c: Context<SitesContext>): Promise<Response | null> {
288 const cacheState = htmlCacheState(c);
289 const cache = await cacheState.cache;
290 const cached = await timeSite(c, "cache_read", () => cache.match(cacheState.key));
291 cacheState.checked = true;
292 if (!cached) return null;
293 if (!cached.body) return null;
294 
295 const headers = cachedHtmlHeaders(cached);
296 if (siteHtmlEtagMatches(c.req.header("If-None-Match") ?? null, headers.etag ?? "")) {
297 markSite(c, "cache_read", "skipped_304");
298 return c.body(null, 304, headers);
299 }
300 
301 markSite(c, "cache_write", "skipped_hit");
302 if (c.req.method === "HEAD") return c.body(null, 200, headers);
303 return c.body(cached.body, 200, headers);
304}
305 
306function htmlCacheState(c: Context<SitesContext>): HtmlCacheState {
307 const existing = c.get("htmlCache");
308 if (existing) return existing;
309 
310 const state: HtmlCacheState = {
311 cache: getSitesCache(),
312 key: buildSiteCacheKey(c.req.raw),
313 checked: false,
314 };
315 c.set("htmlCache", state);
316 return state;
317}
318 
319function buildHtmlHeaders(site: ResolvedSite, revision: string, artifactUpdatedAt: string) {
320 return {
321 ...HTML_HEADERS,
322 ETag: buildSiteHtmlEtag(revision),
323 "Last-Modified": newestHttpDate(site.updated_at, artifactUpdatedAt),
324 };
325}
326 
327function internalHtmlHeaders(headers: HeadersInit, site: ResolvedSite, page: ResolvedPublishedPage): HeadersInit {
328 const next = new Headers(headers);
329 next.set("Cache-Control", INTERNAL_HTML_CACHE_CONTROL);
330 next.set("Cache-Tag", buildSiteCacheTags(site, page));
331 return next;
332}
333 
334function cachedHtmlHeaders(cached: Response): Record<string, string> {
335 const headers = new Headers(cached.headers);
336 headers.set("Cache-Control", HTML_HEADERS["Cache-Control"]);
337 headers.delete("Cache-Tag");
338 return Object.fromEntries(headers.entries());
339}
340 
341async function resolveRequiredSitesDocumentAssets(c: Context<SitesContext>): Promise<SiteDocumentAssets | null> {
342 return timeSite(c, "asset_manifest", () => resolveSitesDocumentAssets(c.env));
343}
344 
345function sitesAssetsUnavailable(c: Context<SitesContext>): Response {
346 return c.text("Sites assets unavailable", 500, {
347 "Content-Type": "text/plain; charset=utf-8",
348 "Cache-Control": "no-store",
349 });
350}
351 
352function redirect(c: Context<SitesContext>, path: string): Response {
353 const originalUrl = new URL(c.req.url);
354 const target = new URL(path, originalUrl);
355 target.search = originalUrl.search;
356 return c.redirect(target.toString(), 302);
357}
358 
359async function siteNotFound(c: Context<SitesContext>, site: ResolvedSite | null = null): Promise<Response> {
360 const assets = await resolveRequiredSitesDocumentAssets(c);
361 if (!assets) return sitesAssetsUnavailable(c);
362 
363 return c.html(
364 renderSiteNotFoundDocumentHtml({
365 site: site ? { workspaceName: site.workspace_name, workspaceIcon: site.workspace_icon, homeHref: "/" } : null,
366 assets,
367 }),
368 404,
369 withSitesStaticDocumentPreloadHeaders(
370 {
371 "Content-Type": "text/html; charset=utf-8",
372 "Cache-Control": "no-store",
373 },
374 assets,
375 ),
376 );
377}
378 
379async function siteBuilding(c: Context<SitesContext>, site: ResolvedSite): Promise<Response> {
380 const assets = await resolveRequiredSitesDocumentAssets(c);
381 if (!assets) return sitesAssetsUnavailable(c);
382 
383 return c.html(
384 renderSiteBuildingDocumentHtml({
385 site: { workspaceName: site.workspace_name, workspaceIcon: site.workspace_icon, homeHref: "/" },
386 assets,
387 }),
388 503,
389 withSitesStaticDocumentPreloadHeaders(
390 {
391 "Content-Type": "text/html; charset=utf-8",
392 "Cache-Control": "no-store",
393 "Retry-After": "5",
394 },
395 assets,
396 ),
397 );
398}
399 
400async function enqueuePageProjection(env: Pick<Env, "TASKS_QUEUE">, pageId: string): Promise<void> {
401 try {
402 await env.TASKS_QUEUE.send({ type: "page-projection", pageId });
403 } catch {
404 // Derived Sites JSON is repaired by the next save or request-path self-heal.
405 }
406}
407 
408function methodNotAllowed(c: Context<SitesContext>): Response {
409 return c.text("Method not allowed", 405, {
410 "Content-Type": "text/plain; charset=utf-8",
411 Allow: "GET, HEAD",
412 });
413}
414 
415function timeSite<T>(c: Context<SitesContext>, name: string, operation: () => Promise<T>): Promise<T> {
416 return wrapTime(c, name, Promise.resolve().then(operation));
417}
418 
419function markSite(c: Context<SitesContext>, name: string, description: string): void {
420 setMetric(c, name, description);
421}
422 
423function newestHttpDate(...values: string[]): string {
424 const timestamps = values.map((value) => Date.parse(value)).filter((value) => Number.isFinite(value));
425 if (timestamps.length === 0) return new Date().toUTCString();
426 return new Date(Math.max(...timestamps)).toUTCString();
427}