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>(); 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 { 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 { 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 { 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 };