File
Blob: src/worker/sites/router.ts
| 1 | import { Hono, type Context } from "hono"; |
| 2 | import { setMetric, timing, wrapTime } from "hono/timing"; |
| 3 | |
| 4 | import { createSessionDb, type Db } from "@/worker/db/d1/client"; |
| 5 | import { matchSiteHost, type SiteHostMatch } from "@/worker/lib/host-match"; |
| 6 | import { |
| 7 | isSitesFeatureEnabled, |
| 8 | resolvePublishedSitePage, |
| 9 | type ResolvedPublishedPage, |
| 10 | type ResolvedPublishedSitePage, |
| 11 | type ResolvedSite, |
| 12 | } from "@/worker/lib/published-pages"; |
| 13 | import { buildSitePagePath } from "@/worker/lib/site-public-url"; |
| 14 | import { applySitesSecurityHeaders } from "@/worker/lib/security-headers"; |
| 15 | import { |
| 16 | withSitesPageDocumentPreloadHeaders, |
| 17 | withSitesStaticDocumentPreloadHeaders, |
| 18 | } from "@/worker/sites/preload-headers"; |
| 19 | import { serveSiteAsset } from "@/worker/sites/assets"; |
| 20 | import { coverNotFound, serveSiteCover } from "@/worker/sites/cover"; |
| 21 | import { |
| 22 | buildSiteCacheKey, |
| 23 | buildSiteCacheTags, |
| 24 | buildSiteHtmlEtag, |
| 25 | createSiteHtmlRevision, |
| 26 | getSitesCache, |
| 27 | getSitesRendererVersion, |
| 28 | siteHtmlEtagMatches, |
| 29 | } from "@/worker/sites/cache"; |
| 30 | import { loadPagePmJson } from "@/worker/sites/load-page-pm-json"; |
| 31 | import { prepareSitePageRender } from "@/worker/sites/prepare-page-render"; |
| 32 | import { resolveSitesDocumentAssets } from "@/worker/sites/manifest"; |
| 33 | import { |
| 34 | renderApexDocumentHtml, |
| 35 | renderRobotsTxt, |
| 36 | renderSiteBuildingDocumentHtml, |
| 37 | renderSiteNotFoundDocumentHtml, |
| 38 | } from "@/sites/document"; |
| 39 | import type { SiteDocumentAssets } from "@/sites/types"; |
| 40 | |
| 41 | const PAGE_ID_LENGTH = 26; |
| 42 | const 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}}"; |
| 44 | const COVER_ASSET_ROUTE = "/_assets/:pageId{[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}}/cover"; |
| 45 | const ASSET_ROUTE = "/_assets/:pageId{[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}}/:uploadId{[A-Za-z0-9_-]+}"; |
| 46 | |
| 47 | const HTML_HEADERS = { |
| 48 | "Content-Type": "text/html; charset=utf-8", |
| 49 | "Cache-Control": "public, max-age=0, must-revalidate", |
| 50 | }; |
| 51 | const INTERNAL_HTML_CACHE_CONTROL = "public, max-age=300, must-revalidate"; |
| 52 | |
| 53 | type MatchedSiteHost = Extract<SiteHostMatch, { kind: "apex" | "subdomain" }>; |
| 54 | |
| 55 | interface HtmlCacheState { |
| 56 | cache: Promise<Cache>; |
| 57 | key: Request; |
| 58 | checked: boolean; |
| 59 | } |
| 60 | |
| 61 | type SitesVariables = { |
| 62 | db: Db; |
| 63 | siteHost: MatchedSiteHost; |
| 64 | htmlCache?: HtmlCacheState; |
| 65 | }; |
| 66 | |
| 67 | type SitesContext = { Bindings: Env; Variables: SitesVariables }; |
| 68 | |
| 69 | export const sitesApp = new Hono<SitesContext>(); |
| 70 | |
| 71 | sitesApp.use("*", timing()); |
| 72 | |
| 73 | sitesApp.use("*", async (c, next) => { |
| 74 | await next(); |
| 75 | c.res = applySitesSecurityHeaders(c.res, c.req.url); |
| 76 | }); |
| 77 | |
| 78 | sitesApp.use("*", async (c, next) => { |
| 79 | if (c.req.method !== "GET" && c.req.method !== "HEAD") { |
| 80 | return methodNotAllowed(c); |
| 81 | } |
| 82 | await next(); |
| 83 | }); |
| 84 | |
| 85 | sitesApp.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 | |
| 104 | sitesApp.get("/robots.txt", (c) => { |
| 105 | return c.text(renderRobotsTxt(), 200, { "Content-Type": "text/plain; charset=utf-8" }); |
| 106 | }); |
| 107 | |
| 108 | sitesApp.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 | |
| 125 | sitesApp.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 | |
| 140 | sitesApp.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 | |
| 157 | sitesApp.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 | |
| 172 | sitesApp.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 | |
| 180 | sitesApp.notFound((c) => siteNotFound(c)); |
| 181 | |
| 182 | interface ServePageOptions { |
| 183 | isHome: boolean; |
| 184 | requestedPath?: string; |
| 185 | } |
| 186 | |
| 187 | async 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 | |
| 198 | async 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 | |
| 217 | async 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 | |
| 287 | async 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 | |
| 306 | function 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 | |
| 319 | function 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 | |
| 327 | function 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 | |
| 334 | function 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 | |
| 341 | async function resolveRequiredSitesDocumentAssets(c: Context<SitesContext>): Promise<SiteDocumentAssets | null> { |
| 342 | return timeSite(c, "asset_manifest", () => resolveSitesDocumentAssets(c.env)); |
| 343 | } |
| 344 | |
| 345 | function 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 | |
| 352 | function 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 | |
| 359 | async 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 | |
| 379 | async 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 | |
| 400 | async 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 | |
| 408 | function 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 | |
| 415 | function timeSite<T>(c: Context<SitesContext>, name: string, operation: () => Promise<T>): Promise<T> { |
| 416 | return wrapTime(c, name, Promise.resolve().then(operation)); |
| 417 | } |
| 418 | |
| 419 | function markSite(c: Context<SitesContext>, name: string, description: string): void { |
| 420 | setMetric(c, name, description); |
| 421 | } |
| 422 | |
| 423 | function 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 | } |