// Static and dynamic assets are built alongside the server binary. // This module implements decoding and serving of the asset blobs, // but also implements patching of dynamic assets. The `Manifest` // is generated by `incremental.ts` const debug = console.scoped("assets"); const root = import.meta.dirname; let current: Loaded | null = null; // TODO: rename all these types type DynamicId = string; export type { Key }; export type Manifest = & { [K in Key]: StaticAsset | DynamicAsset; } & { [string: string]: StaticAsset | DynamicAsset; }; export interface StaticAsset extends AssetBase { type: 0; } interface AssetBase { headers: Record & { etag: string }; raw: BufferView; gzip: BufferView; zstd: BufferView; } export interface DynamicAsset { type: 1; id: DynamicId; } interface Loaded { map: Manifest; static: Buffer; dynamic: Map; } export interface DynamicEntry extends AssetBase { buffer: Buffer; } type CacheMode = "auto" | "long-term" | "temporary" | "no-cache"; export async function reload() { const map = await fs.readJson(path.join(root, "asset.json")); const statics = await fs.readFile(path.join(root, "asset.blob")); const dynamic = new Map( await Promise.all( Object.entries(map) .filter((entry): entry is [string, DynamicAsset] => entry[1].type === 1) .map(async ([k, v]) => [ v.id, await fs.readFile(path.join(root, "dynamic", v.id)) .then(loadRegenerative), ] as const ), ), ); return (current = { map, static: statics, dynamic }); } export async function middleware(c: Context, next: Next) { if (!current) current = await reload(); const asset = current.map[c.req.path]; if (asset) return assetInner(c, c.req.path, asset, 200); return next(); } export async function notFound(c: Context) { if (!current) current = await reload(); let pathname = c.req.path; do { const asset = current.map[pathname + "/404"]; if (asset) return assetInner(c, pathname + "/404", asset, 404); pathname = pathname.slice(0, pathname.lastIndexOf("/")); } while (pathname); const asset = current.map["/404"]; if (asset) return assetInner(c, "/404", asset, 404); return c.text("the 'Not Found' page was not found", 404); } export async function serveAsset( c: Context, id: Key, status: StatusCode = 200, cache: CacheMode = 'auto', ) { return assetInner( c, id, (current ?? (await reload())).map[id], status, cache, ); } /** @deprecated */ export function hasAsset(id: string) { return UNWRAP(current).map[id] !== undefined; } export function etagMatches(etag: string, ifNoneMatch: string) { return ifNoneMatch === etag || ifNoneMatch.split(/,\s*/).indexOf(etag) > -1; } function assetInner( c: Context, key: string, asset: Manifest[Key], status: StatusCode, cache: CacheMode = "auto", ) { ASSERT(current); if (asset.type === 0) { return respondWithBufferAndViews( c, key, current.static, asset, status, cache, ); } else { const entry = UNWRAP(current.dynamic.get(asset.id)); return respondWithBufferAndViews( c, key, entry.buffer, entry, status, cache, ); } } function respondWithBufferAndViews( c: Context, key: string, buffer: Buffer, asset: AssetBase, status: StatusCode, cache: CacheMode, ) { const ifNoneMatch = c.req.header("If-None-Match"); if (ifNoneMatch) { const etag = asset.headers.etag; if (etagMatches(etag, ifNoneMatch)) { return (c.res = new Response(null, { status: 304, statusText: "Not Modified", headers: { ETag: etag, }, })); } } const acceptEncoding = c.req.header("Accept-Encoding") ?? ""; let body; let headers = asset.headers; if (acceptEncoding.includes("zstd")) { body = buffer.subarray(...asset.zstd); headers = { ...asset.headers, "Content-Encoding": "zstd", }; } else if (acceptEncoding.includes("gzip")) { body = buffer.subarray(...asset.gzip); headers = { ...asset.headers, "Content-Encoding": "gzip", }; } else { body = buffer.subarray(...asset.raw); } debug( `${key} encoding=${headers["Content-Encoding"] ?? "raw"} status=${status}`, ); if (!Object.keys(headers).some((x) => x.toLowerCase() === "cache-control")) { if (cache === "auto") { if (status < 200 || status >= 300) { cache = 'no-cache'; } else if (headers['content-type']?.includes('text/html')) { cache = 'temporary'; } else { cache = 'long-term'; } } if (cache === "no-cache") { headers["Cache-Control"] = "no-store"; } else if (cache === "long-term") { headers["Cache-Control"] = "public, max-age=7200, stale-while-revalidate=7200, immutable"; } else if (cache === "temporary") { headers["Cache-Control"] = "public, max-age=7200, stale-while-revalidate=7200"; } } return (c.res = new Response(body, { headers, status })); } export function packDynamicBuffer( raw: Buffer, gzip: Buffer, zstd: Buffer, headers: Record, ) { const headersBuffer = Buffer.from( Object.entries(headers) .map((entry) => entry.join(":")) .join("\n"), "utf-8", ); const header = new Uint32Array(3); header[0] = headersBuffer.byteLength + header.byteLength; header[1] = header[0] + raw.byteLength; header[2] = header[1] + gzip.byteLength; return Buffer.concat([ Buffer.from(header.buffer), headersBuffer, raw, gzip, zstd, ]); } function loadRegenerative(buffer: Buffer): DynamicEntry { const headersEnd = buffer.readUInt32LE(0); const headers = Object.fromEntries( buffer .subarray(3 * 4, headersEnd) .toString("utf-8") .split("\n") .map((line) => { const i = line.indexOf(":"); return [line.slice(0, i), line.slice(i + 1)]; }), ); const raw = buffer.readUInt32LE(4); const gzip = buffer.readUInt32LE(8); const hasEtag = (v: object): v is typeof v & { etag: string } => "etag" in v && typeof v.etag === "string"; ASSERT(hasEtag(headers)); return { headers, buffer, raw: [headersEnd, raw], gzip: [raw, gzip], zstd: [gzip, buffer.byteLength], }; } const gzip = util.promisify(zlib.gzip); const zstdCompress = util.promisify(zlib.zstdCompress); export async function overwriteDynamic( key: Key, value: string | Buffer, headers: Record, ) { if (!current) current = await reload(); const asset = UNWRAP(current.map[key]); ASSERT(asset.type === 1); UNWRAP(current.dynamic.has(asset.id)); const buffer = Buffer.from(value); const etag = JSON.stringify( crypto.createHash("sha1").update(buffer).digest("hex"), ); const [gzipBuffer, zstdBuffer] = await Promise.all([ gzip(buffer), zstdCompress(buffer), ]); const packed = packDynamicBuffer(buffer, gzipBuffer, zstdBuffer, { ...headers, etag, }); current.dynamic.set(asset.id, loadRegenerative(packed)); await fs.writeFile(path.join(root, "dynamic", asset.id), packed); } process.on("message", (msg: any) => { if (msg?.type === "clover.assets.reload") reload(); }); import * as fs from "#sitegen/fs"; import type { Context, Next } from "hono"; import type { StatusCode } from "hono/utils/http-status"; import type { BufferView } from "../incremental.ts"; import { Buffer } from "node:buffer"; import * as path from "node:path"; import type { AssetKey as Key } from "../../.clover/ts/asset.d.ts"; import * as crypto from "node:crypto"; import * as zlib from "node:zlib"; import * as util from "node:util"; import * as console from "@paperclover/console";