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.
4const console = log.scoped("assets");
5
6export type { Key };
7type CacheMode = "auto" | "long-term" | "temporary" | "no-cache";
8
9interface Encodings {
10 raw: Buffer;
11 gzip: Buffer;
12 zstd: Buffer;
13}
14
15let 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 */
22export 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
37export 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
43export 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
56export 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 */
66export 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
71function loaded() {
72 return UNWRAP(current, "assets are served once a build has loaded them");
73}
74
75function 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
122import type { Output } from "#src/paperclover.net.ts";
123import { UNWRAP } from "@clo/lib/assert";
124import * as log from "@clo/lib/log";
125import type { Context, Next } from "hono";
126import type { StatusCode } from "hono/utils/http-status";
127import type { Buffer } from "node:buffer";
128import * as fs from "node:fs/promises";
129import * as path from "node:path";
130import type { AssetKey as Key } from "../../.clover/ts/asset.d.ts";