| 1 | // Pages, fonts, scripts, and static files are built ahead of time, each in |
| 2 | // every encoding a client may accept. A build hands them to `load`, and the |
| 3 | // rest of this module serves them. |
| 4 | const console = log.scoped("assets"); |
| 5 | |
| 6 | export type { Key }; |
| 7 | type CacheMode = "auto" | "long-term" | "temporary" | "no-cache"; |
| 8 | |
| 9 | interface Encodings { |
| 10 | raw: Buffer; |
| 11 | gzip: Buffer; |
| 12 | zstd: Buffer; |
| 13 | } |
| 14 | |
| 15 | let current: { assets: Output["assets"]; bytes: Map<string, Encodings> } | null = null; |
| 16 | |
| 17 | /** |
| 18 | * serve the assets of a build, from where it wrote their bytes by hash. the |
| 19 | * bytes stay in memory, since the next build deletes the files it stops |
| 20 | * writing while this one is still being served. |
| 21 | */ |
| 22 | export async function load(assets: Output["assets"], dir: string) { |
| 23 | const known = current?.bytes; |
| 24 | const hashes = new Set(Object.values(assets).map((asset) => asset.hash)); |
| 25 | const read = async (hash: string): Promise<Encodings> => { |
| 26 | const [raw, gzip, zstd] = await Promise.all( |
| 27 | ["", ".gz", ".zst"].map((encoding) => fs.readFile(path.join(dir, hash + encoding))), |
| 28 | ); |
| 29 | return { raw: raw!, gzip: gzip!, zstd: zstd! }; |
| 30 | }; |
| 31 | const bytes = await Promise.all( |
| 32 | Array.from(hashes, async (hash) => [hash, known?.get(hash) ?? await read(hash)] as const), |
| 33 | ); |
| 34 | current = { assets, bytes: new Map(bytes) }; |
| 35 | } |
| 36 | |
| 37 | export async function middleware(c: Context, next: Next) { |
| 38 | const asset = loaded().assets[c.req.path]; |
| 39 | if (asset) return respond(c, c.req.path, asset, 200); |
| 40 | return next(); |
| 41 | } |
| 42 | |
| 43 | export async function notFound(c: Context) { |
| 44 | const { assets } = loaded(); |
| 45 | let pathname = c.req.path; |
| 46 | do { |
| 47 | const asset = assets[pathname + "/404"]; |
| 48 | if (asset) return respond(c, pathname + "/404", asset, 404); |
| 49 | pathname = pathname.slice(0, pathname.lastIndexOf("/")); |
| 50 | } while (pathname); |
| 51 | const asset = assets["/404"]; |
| 52 | if (asset) return respond(c, "/404", asset, 404); |
| 53 | return c.text("the 'Not Found' page was not found", 404); |
| 54 | } |
| 55 | |
| 56 | export async function serveAsset( |
| 57 | c: Context, |
| 58 | id: Key, |
| 59 | status: StatusCode = 200, |
| 60 | cache: CacheMode = "auto", |
| 61 | ) { |
| 62 | return respond(c, id, UNWRAP(loaded().assets[id], `Missing asset ${id}`), status, cache); |
| 63 | } |
| 64 | |
| 65 | /** whether `If-None-Match` names `etag`, which it compares weakly, as http says to */ |
| 66 | export function etagMatches(etag: string, ifNoneMatch: string) { |
| 67 | const opaque = (tag: string) => tag.trim().replace(/^W\//, ""); |
| 68 | return ifNoneMatch.trim() === "*" || ifNoneMatch.split(",").some((tag) => opaque(tag) === opaque(etag)); |
| 69 | } |
| 70 | |
| 71 | function loaded() { |
| 72 | return UNWRAP(current, "assets are served once a build has loaded them"); |
| 73 | } |
| 74 | |
| 75 | function respond( |
| 76 | c: Context, |
| 77 | key: string, |
| 78 | asset: Output["assets"][string], |
| 79 | status: StatusCode, |
| 80 | cache: CacheMode = "auto", |
| 81 | ) { |
| 82 | const etag = UNWRAP(asset.headers.etag); |
| 83 | const ifNoneMatch = c.req.header("If-None-Match"); |
| 84 | if (ifNoneMatch && etagMatches(etag, ifNoneMatch)) { |
| 85 | return (c.res = new Response(null, { |
| 86 | status: 304, |
| 87 | statusText: "Not Modified", |
| 88 | headers: { |
| 89 | ETag: etag, |
| 90 | }, |
| 91 | })); |
| 92 | } |
| 93 | |
| 94 | const encodings = UNWRAP(loaded().bytes.get(asset.hash)); |
| 95 | const acceptEncoding = c.req.header("Accept-Encoding") ?? ""; |
| 96 | const encoding = acceptEncoding.includes("zstd") ? "zstd" : acceptEncoding.includes("gzip") ? "gzip" : "raw"; |
| 97 | const headers = { ...asset.headers }; |
| 98 | if (encoding !== "raw") headers["Content-Encoding"] = encoding; |
| 99 | console.info(`${key} encoding=${encoding} status=${status}`); |
| 100 | |
| 101 | if (!Object.keys(headers).some((x) => x.toLowerCase() === "cache-control")) { |
| 102 | if (cache === "auto") { |
| 103 | if (status < 200 || status >= 300) { |
| 104 | cache = "no-cache"; |
| 105 | } else if (headers["content-type"]?.includes("text/html")) { |
| 106 | cache = "temporary"; |
| 107 | } else { |
| 108 | cache = "long-term"; |
| 109 | } |
| 110 | } |
| 111 | if (cache === "no-cache") { |
| 112 | headers["Cache-Control"] = "no-store"; |
| 113 | } else if (cache === "long-term") { |
| 114 | headers["Cache-Control"] = "public, max-age=7200, stale-while-revalidate=7200, immutable"; |
| 115 | } else if (cache === "temporary") { |
| 116 | headers["Cache-Control"] = "public, max-age=7200, stale-while-revalidate=7200"; |
| 117 | } |
| 118 | } |
| 119 | return (c.res = new Response(new Uint8Array(encodings[encoding]), { headers, status })); |
| 120 | } |
| 121 | |
| 122 | import type { Output } from "#src/paperclover.net.ts"; |
| 123 | import { UNWRAP } from "@clo/lib/assert"; |
| 124 | import * as log from "@clo/lib/log"; |
| 125 | import type { Context, Next } from "hono"; |
| 126 | import type { StatusCode } from "hono/utils/http-status"; |
| 127 | import type { Buffer } from "node:buffer"; |
| 128 | import * as fs from "node:fs/promises"; |
| 129 | import * as path from "node:path"; |
| 130 | import type { AssetKey as Key } from "../../.clover/ts/asset.d.ts"; |