muddle/adapters/server-bun.ts
2026-10-23 08:53:07 -04:00

123 lines
4.1 KiB
TypeScript

import { existsSync } from "node:fs";
import { readFile } from "node:fs/promises";
import { extname, isAbsolute, join, relative, resolve } from "node:path";
import type { App } from "../core/app.ts";
import { toFetchResponse } from "../core/app.ts";
import { contentTypeOf } from "../core/http.ts";
import { predictLayout } from "./prediction.ts";
import { inlinePredictionStyles } from "./static.ts";
export interface BunServerOptions {
/** Port the server listens on, `0` picks a free port. */
port?: number;
/** Hostname the server binds to. */
hostname?: string;
/** Directory with the static assets. */
publicDir?: string;
/** Called for every request, useful for logging. */
onRequest?: (request: Request) => void;
/** Predict and cache rendered HTML before its first response. Defaults to true. */
predictViews?: boolean;
}
interface CachedResponse {
body: ArrayBuffer;
status: number;
statusText: string;
headers: [string, string][];
}
/** Serves the application. */
export function serve(app: App, options: BunServerOptions = {}) {
const publicDir = options.publicDir ? resolve(options.publicDir) : undefined;
const rendered = new Map<string, Promise<CachedResponse>>();
return Bun.serve({
port: options.port ?? 3000,
hostname: options.hostname ?? "0.0.0.0",
async fetch(request) {
options.onRequest?.(request);
if (publicDir) {
const asset = await serveAsset(request, publicDir);
if (asset) return asset;
}
if (options.predictViews === false || request.method !== "GET") return app.fetch(request);
const key = request.url;
let predicted = rendered.get(key);
if (!predicted) {
predicted = renderPredictedView(app, request).then(cacheResponse);
rendered.set(key, predicted);
}
try {
return await cloneResponse(await predicted);
} catch (error) {
rendered.delete(key);
throw error;
}
},
});
}
async function renderPredictedView(app: App, request: Request): Promise<Response> {
const response = await app.fetch(request);
const contentType = response.headers.get("content-type") ?? "";
if (!response.ok || !contentType.includes("text/html") || response.headers.has("set-cookie") || /no-store/i.test(response.headers.get("cache-control") ?? "")) return response;
const html = await response.text();
const hasRelations = /data-muddle-anchor|muddle-predict\s*:|--muddle-relate-/.test(html);
if (!hasRelations) return responseFromText(html, response);
const assetHtml = await inlinePredictionStyles(app, html, new URL(request.url).origin);
const predicted = await predictLayout(assetHtml, { strict: true });
return responseFromText(predicted.html, response);
}
function responseFromText(html: string, source: Response): Response {
const headers = new Headers(source.headers);
headers.delete("content-length");
return new Response(html, { status: source.status, statusText: source.statusText, headers });
}
function cloneResponse(source: CachedResponse): Response {
return new Response(source.body.slice(0), {
status: source.status,
statusText: source.statusText,
headers: source.headers,
});
}
async function cacheResponse(source: Response): Promise<CachedResponse> {
return {
body: await source.arrayBuffer(),
status: source.status,
statusText: source.statusText,
headers: [...source.headers],
};
}
/** Serves a static asset of the public directory. */
export async function serveAsset(
request: Request,
publicDir: string,
): Promise<Response | undefined> {
const url = new URL(request.url);
if (request.method !== "GET" && request.method !== "HEAD") return undefined;
const requested = resolve(publicDir, decodeURIComponent(url.pathname).replace(/^[/\\]+/, ""));
const relativePath = relative(publicDir, requested);
if (relativePath.startsWith("..") || isAbsolute(relativePath)) return undefined;
const path = existsSync(join(requested, "index.html")) ? join(requested, "index.html") : requested;
if (!existsSync(path)) return undefined;
let file: Uint8Array;
try {
file = await readFile(path);
} catch {
return undefined;
}
return new Response(file, {
headers: {
"content-type": contentTypeOf(extname(path)),
},
});
}
export { toFetchResponse };