2025-08-11 22:43:27 -07:00
|
|
|
// 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;
|
|
|
|
}
|
2025-06-06 23:38:02 -07:00
|
|
|
interface Loaded {
|
2025-08-11 22:43:27 -07:00
|
|
|
map: Manifest;
|
|
|
|
static: Buffer;
|
|
|
|
dynamic: Map<DynamicId, DynamicEntry>;
|
2025-06-06 23:38:02 -07:00
|
|
|
}
|
2025-08-11 22:43:27 -07:00
|
|
|
export interface DynamicEntry extends AssetBase {
|
|
|
|
buffer: Buffer;
|
2025-06-06 23:38:02 -07:00
|
|
|
}
|
|
|
|
|
2025-08-11 22:43:27 -07:00
|
|
|
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
|
|
|
|
),
|
|
|
|
),
|
2025-06-13 00:13:22 -07:00
|
|
|
);
|
2025-08-11 22:43:27 -07:00
|
|
|
return (current = { map, static: statics, dynamic });
|
2025-06-06 23:38:02 -07:00
|
|
|
}
|
|
|
|
|
2025-06-08 15:12:04 -07:00
|
|
|
export async function middleware(c: Context, next: Next) {
|
2025-08-11 22:43:27 -07:00
|
|
|
if (!current) current = await reload();
|
|
|
|
const asset = current.map[c.req.path];
|
2025-08-02 19:22:07 -07:00
|
|
|
if (asset) return assetInner(c, asset, 200);
|
2025-06-06 23:38:02 -07:00
|
|
|
return next();
|
|
|
|
}
|
|
|
|
|
2025-06-08 15:12:04 -07:00
|
|
|
export async function notFound(c: Context) {
|
2025-08-11 22:43:27 -07:00
|
|
|
if (!current) current = await reload();
|
2025-06-08 15:12:04 -07:00
|
|
|
let pathname = c.req.path;
|
|
|
|
do {
|
2025-08-11 22:43:27 -07:00
|
|
|
const asset = current.map[pathname + "/404"];
|
2025-06-08 15:12:04 -07:00
|
|
|
if (asset) return assetInner(c, asset, 404);
|
|
|
|
pathname = pathname.slice(0, pathname.lastIndexOf("/"));
|
|
|
|
} while (pathname);
|
2025-08-11 22:43:27 -07:00
|
|
|
const asset = current.map["/404"];
|
2025-06-08 15:12:04 -07:00
|
|
|
if (asset) return assetInner(c, asset, 404);
|
|
|
|
return c.text("the 'Not Found' page was not found", 404);
|
|
|
|
}
|
|
|
|
|
2025-08-11 22:43:27 -07:00
|
|
|
export async function serveAsset(c: Context, id: Key, status: StatusCode) {
|
|
|
|
return assetInner(c, (current ?? (await reload())).map[id], status);
|
2025-06-06 23:38:02 -07:00
|
|
|
}
|
|
|
|
|
2025-08-11 22:43:27 -07:00
|
|
|
/** @deprecated */
|
2025-06-06 23:38:02 -07:00
|
|
|
export function hasAsset(id: string) {
|
2025-08-11 22:43:27 -07:00
|
|
|
return UNWRAP(current).map[id] !== undefined;
|
2025-06-06 23:38:02 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
export function etagMatches(etag: string, ifNoneMatch: string) {
|
|
|
|
return ifNoneMatch === etag || ifNoneMatch.split(/,\s*/).indexOf(etag) > -1;
|
|
|
|
}
|
|
|
|
|
2025-08-11 22:43:27 -07:00
|
|
|
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);
|
|
|
|
}
|
2025-06-06 23:38:02 -07:00
|
|
|
}
|
|
|
|
|
2025-08-11 22:43:27 -07:00
|
|
|
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)) {
|
2025-08-02 19:22:07 -07:00
|
|
|
return (c.res = new Response(null, {
|
2025-06-06 23:38:02 -07:00
|
|
|
status: 304,
|
|
|
|
statusText: "Not Modified",
|
|
|
|
headers: {
|
|
|
|
ETag: etag,
|
|
|
|
},
|
2025-08-02 19:22:07 -07:00
|
|
|
}));
|
2025-06-06 23:38:02 -07:00
|
|
|
}
|
|
|
|
}
|
|
|
|
const acceptEncoding = c.req.header("Accept-Encoding") ?? "";
|
|
|
|
let body;
|
|
|
|
let headers = asset.headers;
|
2025-08-11 22:43:27 -07:00
|
|
|
if (acceptEncoding.includes("zstd")) {
|
|
|
|
body = buffer.subarray(...asset.zstd);
|
2025-06-06 23:38:02 -07:00
|
|
|
headers = {
|
|
|
|
...asset.headers,
|
|
|
|
"Content-Encoding": "zstd",
|
|
|
|
};
|
2025-08-11 22:43:27 -07:00
|
|
|
} else if (acceptEncoding.includes("gzip")) {
|
|
|
|
body = buffer.subarray(...asset.gzip);
|
2025-06-06 23:38:02 -07:00
|
|
|
headers = {
|
|
|
|
...asset.headers,
|
|
|
|
"Content-Encoding": "gzip",
|
|
|
|
};
|
|
|
|
} else {
|
2025-08-11 22:43:27 -07:00
|
|
|
body = buffer.subarray(...asset.raw);
|
2025-06-06 23:38:02 -07:00
|
|
|
}
|
2025-08-02 19:22:07 -07:00
|
|
|
return (c.res = new Response(body, { headers, status }));
|
2025-06-06 23:38:02 -07:00
|
|
|
}
|
|
|
|
|
2025-08-11 22:43:27 -07:00
|
|
|
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);
|
|
|
|
}
|
|
|
|
|
2025-06-13 00:13:22 -07:00
|
|
|
process.on("message", (msg: any) => {
|
|
|
|
if (msg?.type === "clover.assets.reload") reload();
|
|
|
|
});
|
|
|
|
|
2025-06-08 17:31:03 -07:00
|
|
|
import * as fs from "#sitegen/fs";
|
2025-06-06 23:38:02 -07:00
|
|
|
import type { Context, Next } from "hono";
|
|
|
|
import type { StatusCode } from "hono/utils/http-status";
|
2025-08-11 22:43:27 -07:00
|
|
|
import type { BufferView } from "../incremental.ts";
|
2025-06-08 12:38:25 -07:00
|
|
|
import { Buffer } from "node:buffer";
|
2025-06-13 00:13:22 -07:00
|
|
|
import * as path from "node:path";
|
2025-08-11 22:43:27 -07:00
|
|
|
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";
|