123 lines
4.1 KiB
TypeScript
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 };
|