the asset system is reworked to support "dynamic" entries, where each entry is a separate file on disk containing the latest generation's headers+raw+gzip+zstd. when calling view.regenerate, it will look for pages that had "export const regenerate" during generation, and render those pages using the view system, but then store the results as assets instead of sending as a response. pages configured as regenerable are also bundled as views, using the non-aliasing key "page:${page.id}". this cannot alias because file paths may not contain a colon.
234 lines
6.4 KiB
TypeScript
234 lines
6.4 KiB
TypeScript
// 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 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<string, string> & { etag: string };
|
|
raw: BufferView;
|
|
gzip: BufferView;
|
|
zstd: BufferView;
|
|
}
|
|
export interface DynamicAsset {
|
|
type: 1;
|
|
id: DynamicId;
|
|
}
|
|
interface Loaded {
|
|
map: Manifest;
|
|
static: Buffer;
|
|
dynamic: Map<DynamicId, DynamicEntry>;
|
|
}
|
|
export interface DynamicEntry extends AssetBase {
|
|
buffer: Buffer;
|
|
}
|
|
|
|
export async function reload() {
|
|
const map = await fs.readJson<Manifest>(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, 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, asset, 404);
|
|
pathname = pathname.slice(0, pathname.lastIndexOf("/"));
|
|
} while (pathname);
|
|
const asset = current.map["/404"];
|
|
if (asset) return assetInner(c, asset, 404);
|
|
return c.text("the 'Not Found' page was not found", 404);
|
|
}
|
|
|
|
export async function serveAsset(c: Context, id: Key, status: StatusCode) {
|
|
return assetInner(c, (current ?? (await reload())).map[id], status);
|
|
}
|
|
|
|
/** @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, asset: Manifest[Key], status: StatusCode) {
|
|
ASSERT(current);
|
|
if (asset.type === 0) {
|
|
return respondWithBufferAndViews(c, current.static, asset, status);
|
|
} else {
|
|
const entry = UNWRAP(current.dynamic.get(asset.id));
|
|
return respondWithBufferAndViews(c, entry.buffer, entry, status);
|
|
}
|
|
}
|
|
|
|
function respondWithBufferAndViews(
|
|
c: Context,
|
|
buffer: Buffer,
|
|
asset: AssetBase,
|
|
status: StatusCode,
|
|
) {
|
|
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);
|
|
}
|
|
return (c.res = new Response(body, { headers, status }));
|
|
}
|
|
|
|
export function packDynamicBuffer(
|
|
raw: Buffer,
|
|
gzip: Buffer,
|
|
zstd: Buffer,
|
|
headers: Record<string, string>,
|
|
) {
|
|
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<string, string>,
|
|
) {
|
|
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";
|