/** * File Server * * Lightweight HTTP server that serves a project directory to headless Chrome. * Optionally injects scripts into index.html on-the-fly (e.g. runtime, bridge). * Framework-agnostic — the caller decides what scripts to inject. */ import { Hono } from "hono"; import { serve } from "@hono/node-server"; import { readFileSync, existsSync, statSync } from "node:fs"; import { join, extname } from "node:path"; const MIME_TYPES: Record = { ".html": "text/html; charset=utf-8", ".css": "text/css; charset=utf-8", ".js": "application/javascript; charset=utf-8", ".json": "application/json; charset=utf-8", ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".gif": "image/gif", ".svg": "image/svg+xml", ".webp": "image/webp", ".mp4": "video/mp4", ".webm": "video/webm", ".mp3": "audio/mpeg", ".wav": "audio/wav", ".ogg": "audio/ogg", ".aac": "audio/aac", ".woff": "font/woff", ".woff2": "font/woff2", ".ttf": "font/ttf", ".otf": "font/otf", }; function stripEmbeddedRuntimeScripts(html: string): string { if (!html) return html; const scriptRe = /]*>[\s\S]*?<\/script>/gi; const runtimeSrcMarkers = [ "hyperframe.runtime.iife.js", "hyperframes-runtime.modular.inline.js", "data-hyperframes-preview-runtime", ]; const runtimeInlineMarkers = [ "__hyperframeRuntimeBootstrapped", "__hyperframeRuntime", "__hyperframeRuntimeTeardown", "window.__player =", "window.__playerReady", "window.__renderReady", ]; const shouldStrip = (block: string): boolean => { const lowered = block.toLowerCase(); for (const marker of runtimeSrcMarkers) { if (lowered.includes(marker.toLowerCase())) { return true; } } for (const marker of runtimeInlineMarkers) { if (block.includes(marker)) { return true; } } return false; }; return html.replace(scriptRe, (block) => (shouldStrip(block) ? "" : block)); } function injectScriptsIntoHtml( html: string, headScripts: string[], bodyScripts: string[], stripEmbedded: boolean, ): string { if (stripEmbedded) { html = stripEmbeddedRuntimeScripts(html); } if (headScripts.length > 0) { const headTags = headScripts.map((src) => ``).join("\n"); if (html.includes("")) { html = html.replace("", () => `${headTags}\n`); } else if (html.includes(" `${headTags}\n 0) { const bodyTags = bodyScripts.map((src) => ``).join("\n"); if (html.includes("")) { html = html.replace("", () => `${bodyTags}\n`); } else { html = html + "\n" + bodyTags; } } return html; } export interface FileServerOptions { projectDir: string; compiledDir?: string; port?: number; /** Scripts injected into of index.html. Default: none. */ headScripts?: string[]; /** Scripts injected before of index.html. Default: none. */ bodyScripts?: string[]; /** Strip embedded runtime scripts from HTML before injection. Default: true. */ stripEmbeddedRuntime?: boolean; } export interface FileServerHandle { url: string; port: number; close: () => void; } export function createFileServer(options: FileServerOptions): Promise { const { projectDir, compiledDir, port = 0, stripEmbeddedRuntime = true } = options; const headScripts = options.headScripts ?? []; const bodyScripts = options.bodyScripts ?? []; const app = new Hono(); app.get("/*", (c) => { let requestPath = c.req.path; if (requestPath === "/") requestPath = "/index.html"; // Remove leading slash const relativePath = requestPath.replace(/^\//, ""); const compiledPath = compiledDir ? join(compiledDir, relativePath) : null; const hasCompiledFile = Boolean(compiledPath && existsSync(compiledPath) && statSync(compiledPath).isFile()); const filePath = hasCompiledFile ? (compiledPath as string) : join(projectDir, relativePath); if (!existsSync(filePath) || !statSync(filePath).isFile()) { return c.text("Not found", 404); } const ext = extname(filePath).toLowerCase(); const contentType = MIME_TYPES[ext] || "application/octet-stream"; if (ext === ".html") { const rawHtml = readFileSync(filePath, "utf-8"); const html = relativePath === "index.html" ? injectScriptsIntoHtml(rawHtml, headScripts, bodyScripts, stripEmbeddedRuntime) : rawHtml; return c.text(html, 200, { "Content-Type": contentType }); } const content = readFileSync(filePath); return new Response(content, { status: 200, headers: { "Content-Type": contentType }, }); }); return new Promise((resolve) => { const server = serve({ fetch: app.fetch, port }, (info) => { const actualPort = info.port; const url = `http://localhost:${actualPort}`; resolve({ url, port: actualPort, close: () => server.close(), }); }); }); }