182 lines
4.7 KiB
TypeScript
182 lines
4.7 KiB
TypeScript
import * as zstd from "@bokuweb/zstd-wasm";
|
|
import { Glob, gzipSync } from "bun";
|
|
import { BunSqliteKeyValue } from "bun-sqlite-key-value";
|
|
import { exists, existsAsync } from "fs-jetpack";
|
|
import mime from "mime";
|
|
import { readFileSync } from "node:fs";
|
|
import { join } from "path";
|
|
import { addRoute, createRouter, findRoute } from "rou3";
|
|
import type { ServerCtx } from "./server-ctx";
|
|
import { g } from "./global";
|
|
import { waitUntil } from "./wait-until";
|
|
|
|
await zstd.init();
|
|
|
|
export type StaticFile = Awaited<ReturnType<typeof staticFile>>;
|
|
export const staticFile = async (
|
|
path: string,
|
|
opt?: { index?: string; debug?: boolean }
|
|
) => {
|
|
if (g.mode !== "site") return;
|
|
|
|
if (!g.static_cache) {
|
|
g.static_cache = {} as any;
|
|
|
|
if (!g.static_cache.gz) {
|
|
g.static_cache.gz = new BunSqliteKeyValue(":memory:");
|
|
}
|
|
|
|
if (!g.static_cache.zstd) {
|
|
g.static_cache.zstd = new BunSqliteKeyValue(":memory:");
|
|
}
|
|
}
|
|
|
|
const store = g.static_cache;
|
|
|
|
const glob = new Glob("**");
|
|
|
|
const internal = {
|
|
indexPath: "",
|
|
rescan_timeout: null as any,
|
|
router: createRouter<{
|
|
mime: string | null;
|
|
fullpath: string;
|
|
path: string;
|
|
}>(),
|
|
};
|
|
const static_file = {
|
|
scanning: false,
|
|
paths: new Set<string>(),
|
|
// rescan will be overwritten below.
|
|
async rescan(arg?: { immediatly?: boolean }) {},
|
|
exists(rpath: string, arg?: { prefix?: string; debug?: boolean }) {
|
|
let pathname = rpath;
|
|
if (arg?.prefix && pathname) {
|
|
pathname = pathname.substring(arg.prefix.length);
|
|
}
|
|
const found = findRoute(internal.router, undefined, path + pathname);
|
|
return !!found;
|
|
},
|
|
serve: (ctx: ServerCtx, arg?: { prefix?: string; debug?: boolean }) => {
|
|
let pathname = ctx.url.pathname || "";
|
|
if (arg?.prefix && pathname) {
|
|
pathname = pathname.substring(arg.prefix.length);
|
|
}
|
|
const found = findRoute(internal.router, undefined, pathname);
|
|
|
|
if (found) {
|
|
const { fullpath, mime } = found.data;
|
|
if (exists(fullpath)) {
|
|
const { headers, content } = cachedResponse(
|
|
ctx,
|
|
fullpath,
|
|
mime,
|
|
store
|
|
);
|
|
headers["cache-control"] = "public, max-age=604800, immutable";
|
|
return new Response(content, {
|
|
headers,
|
|
});
|
|
} else {
|
|
store.gz.delete(fullpath);
|
|
store.zstd.delete(fullpath);
|
|
}
|
|
}
|
|
|
|
if (opt?.index) {
|
|
const { headers, content } = cachedResponse(
|
|
ctx,
|
|
internal.indexPath,
|
|
"text/html",
|
|
store
|
|
);
|
|
return new Response(content, { headers });
|
|
}
|
|
},
|
|
};
|
|
|
|
const scan = async () => {
|
|
if (static_file.scanning) {
|
|
await waitUntil(() => !static_file.scanning);
|
|
return;
|
|
}
|
|
static_file.scanning = true;
|
|
if (await existsAsync(path)) {
|
|
if (static_file.paths.size > 0) {
|
|
store.gz.delete([...static_file.paths]);
|
|
store.zstd.delete([...static_file.paths]);
|
|
}
|
|
|
|
for await (const file of glob.scan(path)) {
|
|
if (file === opt?.index) internal.indexPath = join(path, file);
|
|
|
|
static_file.paths.add(join(path, file));
|
|
|
|
let type = mime.getType(file);
|
|
if (file.endsWith(".ts")) {
|
|
type = "application/javascript";
|
|
}
|
|
|
|
addRoute(internal.router, undefined, `/${file}`, {
|
|
mime: type,
|
|
path: file,
|
|
fullpath: join(path, file),
|
|
});
|
|
}
|
|
}
|
|
static_file.scanning = false;
|
|
};
|
|
await scan();
|
|
|
|
static_file.rescan = (arg?: { immediatly?: boolean }) => {
|
|
return new Promise<void>((resolve) => {
|
|
clearTimeout(internal.rescan_timeout);
|
|
internal.rescan_timeout = setTimeout(
|
|
async () => {
|
|
await scan();
|
|
resolve();
|
|
},
|
|
arg?.immediatly ? 0 : 300
|
|
);
|
|
});
|
|
};
|
|
|
|
return static_file;
|
|
};
|
|
|
|
const cachedResponse = (
|
|
ctx: ServerCtx,
|
|
file_path: string,
|
|
mime: string | null,
|
|
store: any
|
|
) => {
|
|
const accept = ctx.req.headers.get("accept-encoding") || "";
|
|
const headers: any = {
|
|
"content-type": mime || "",
|
|
};
|
|
let content = null as any;
|
|
|
|
if (accept.includes("zstd")) {
|
|
content = store.zstd.get(file_path);
|
|
if (!content) {
|
|
content = zstd.compress(
|
|
new Uint8Array(readFileSync(file_path)) as any,
|
|
10
|
|
);
|
|
store.zstd.set(file_path, content);
|
|
}
|
|
headers["content-encoding"] = "zstd";
|
|
}
|
|
|
|
if (!content && accept.includes("gz")) {
|
|
content = store.gz.get(file_path);
|
|
if (!content) {
|
|
content = gzipSync(new Uint8Array(readFileSync(file_path)));
|
|
store.gz.set(file_path, content);
|
|
}
|
|
headers["content-encoding"] = "gzip";
|
|
}
|
|
|
|
return { content, headers };
|
|
};
|