import { createProxyMiddleware, responseInterceptor, } from "http-proxy-middleware"; import { writeFileSync, existsSync, mkdirSync, readFileSync } from "fs"; import { networkInterfaces } from "os"; import browserSync, { BrowserSyncInstance } from "browser-sync"; import prismaPhpConfigJson from "../prisma-php.json"; import { generateFileListJson } from "./files-list.js"; import { join, dirname, relative } from "path"; import { getFileMeta, PUBLIC_DIR, SRC_DIR } from "./utils.js"; import { updateComponentMap } from "./component-map"; import { DebouncedWorker, createSrcWatcher, DEFAULT_AWF } from "./utils.js"; import { compactBrowserLog, devLogMiddleware, endBrowserLogSession, injectDevLogScript, startBrowserLogSession, } from "./dev-log-bridge.js"; import chalk from "chalk"; const { __dirname } = getFileMeta(); const bs: BrowserSyncInstance = browserSync.create(); const PUBLIC_IGNORE_DIRS = ["uploads"]; function getExternalIP(): string | null { const nets = networkInterfaces(); for (const name of Object.keys(nets)) { for (const net of nets[name]!) { if (net.family === "IPv4" && !net.internal) { return net.address; } } } return null; } const pipeline = new DebouncedWorker( async () => { // Source edits invalidate the browser log: everything in it was produced by // code that just changed. Compact rather than truncate, so an interaction // error nobody has re-tested survives an unrelated save. Only `src/` edits // land here; public-asset churn goes through `publicPipeline` untouched. compactBrowserLog("source file change(s)"); await generateFileListJson(); await updateComponentMap(); if (bs.active) { bs.reload(); } }, 350, "bs-pipeline", ); const publicPipeline = new DebouncedWorker( async () => { console.log(chalk.cyan("→ Public directory changed, reloading browser...")); if (bs.active) { bs.reload(); } }, 350, "bs-public-pipeline", ); createSrcWatcher(join(SRC_DIR, "**", "*"), { onEvent: (_ev, _abs, rel) => pipeline.schedule(rel), awaitWriteFinish: DEFAULT_AWF, logPrefix: "watch-src", usePolling: true, interval: 1000, }); createSrcWatcher(join(PUBLIC_DIR, "**", "*"), { onEvent: (_ev, abs, _) => { const relFromPublic = relative(PUBLIC_DIR, abs); const normalized = relFromPublic.replace(/\\/g, "/"); const segments = normalized.split("/").filter(Boolean); const firstSegment = segments[0] || ""; if (PUBLIC_IGNORE_DIRS.includes(firstSegment)) { return; } publicPipeline.schedule(relFromPublic); }, awaitWriteFinish: DEFAULT_AWF, logPrefix: "watch-public", usePolling: true, interval: 1000, }); const viteFlagFile = join(__dirname, "..", ".pp", ".vite-build-complete"); mkdirSync(dirname(viteFlagFile), { recursive: true }); if (!existsSync(viteFlagFile)) { writeFileSync(viteFlagFile, "0"); } else { writeFileSync(viteFlagFile, ""); } createSrcWatcher(viteFlagFile, { onEvent: (ev) => { if (ev === "change" && bs.active) { console.log(chalk.green("→ Vite build complete, reloading browser...")); bs.reload(); } }, awaitWriteFinish: { stabilityThreshold: 100, pollInterval: 50 }, logPrefix: "watch-vite", usePolling: true, interval: 500, }); // ── PulsePoint named-socket proxy ──────────────────────────────────────────── // `pp.socket("name", ...)` connects to `/__pulsepoint/ws` on the page origin; // in development that origin is this BrowserSync server, so the upgrade is // proxied to the Ratchet websocket server (WS_HOST/WS_PORT from .env). function getWebsocketTarget(): string { let host = "127.0.0.1"; let port = "9001"; try { const envPath = join(__dirname, "..", ".env"); if (existsSync(envPath)) { const env = readFileSync(envPath, "utf8"); const portMatch = env.match(/^\s*WS_PORT\s*=\s*["']?(\d+)["']?\s*$/m); if (portMatch) port = portMatch[1]; const hostMatch = env.match(/^\s*WS_HOST\s*=\s*["']?([^"'\s#]+)["']?\s*$/m); if (hostMatch && hostMatch[1] !== "0.0.0.0") host = hostMatch[1]; } } catch { // Fall back to the defaults; the websocket feature may be unused. } return `ws://${host}:${port}`; } const pulsePointSocketProxy = createProxyMiddleware({ pathFilter: "/__pulsepoint/ws", target: getWebsocketTarget(), ws: true, changeOrigin: true, }); bs.init( { proxy: "http://localhost:3000", online: true, middleware: [ // Serves the browser console hook and receives its reports, so browser-side // PulsePoint errors show up in this terminal (and `.pp/browser-log.jsonl`) // instead of only in DevTools. First so its POSTs skip the proxy logger. devLogMiddleware, pulsePointSocketProxy, (_req, res, next) => { res.setHeader("Cache-Control", "no-cache, no-store, must-revalidate"); res.setHeader("Pragma", "no-cache"); res.setHeader("Expires", "0"); next(); }, (req, _, next) => { const time = new Date().toLocaleTimeString(); console.log( `${chalk.gray(time)} ${chalk.cyan("[Proxy]")} ${chalk.bold(req.method)} ${req.url}`, ); next(); }, createProxyMiddleware({ target: prismaPhpConfigJson.bsTarget, changeOrigin: true, pathRewrite: {}, selfHandleResponse: true, on: { proxyReq: (proxyReq, req, _res) => { proxyReq.setHeader("Accept-Encoding", ""); const sendsJson = req.headers["content-type"]?.includes("application/json"); const asksJson = req.headers["accept"]?.includes("application/json"); if (!sendsJson && !asksJson) return; const originalWrite = proxyReq.write; proxyReq.write = function (data, ...args) { if (data) { try { const body = data.toString(); const json = JSON.parse(body); console.log( chalk.blue("→ API Request:"), JSON.stringify(json, null, 2), ); } catch { if (data.toString().trim() !== "") { console.log(chalk.blue("→ API Request:"), data.toString()); } } } // @ts-ignore return originalWrite.call(proxyReq, data, ...args); }; }, proxyRes: responseInterceptor( async (responseBuffer, proxyRes, _req, _res) => { const contentType = proxyRes.headers["content-type"] || ""; // Dev-only: inject the browser console bridge into full HTML // documents so PulsePoint errors reach this terminal and the // session log. Fragments without pass through untouched, // and nothing here runs outside `npm run dev`. if (contentType.includes("text/html")) { return injectDevLogScript(responseBuffer.toString("utf8")); } if (!contentType.includes("application/json")) { return responseBuffer; } try { const body = responseBuffer.toString("utf8"); console.log( chalk.green("← API Response:"), JSON.stringify(JSON.parse(body), null, 2), ); console.log( chalk.gray("----------------------------------------"), ); } catch (e) { console.log( chalk.red("← API Response (Parse Error):"), responseBuffer.toString(), ); } return responseBuffer; }, ), error: (err) => { console.error(chalk.red("Proxy Error:"), err); }, }, }), ], notify: false, open: false, ghostMode: false, codeSync: true, logLevel: "silent", }, (err, bsInstance) => { if (err) { console.error(chalk.red("BrowserSync failed to start:"), err); return; } // WebSocket upgrades bypass connect middleware, so the named-socket // proxy has to hook the raw HTTP server's upgrade event itself. const httpServer = (bsInstance as any).server; if (httpServer && pulsePointSocketProxy.upgrade) { httpServer.on("upgrade", (req: any, socket: any, head: any) => { if (req.url?.startsWith("/__pulsepoint/ws")) { pulsePointSocketProxy.upgrade!(req, socket, head); } }); } const bsPort = bsInstance.getOption("port"); // Open the session browser log once the port is settled: the header's port // is how `npm run logs` tells a live session from a stale leftover file. startBrowserLogSession(Number(bsPort) || 0); const urls = bsInstance.getOption("urls"); const localUrl = urls.get("local") || `http://localhost:${bsPort}`; const externalIP = getExternalIP(); const externalUrl = urls.get("external") || (externalIP ? `http://${externalIP}:${bsPort}` : null); const uiUrl = urls.get("ui"); const uiExtUrl = urls.get("ui-external"); console.log(""); console.log(chalk.green.bold("✔ Ports Configured:")); console.log( ` ${chalk.blue.bold("Frontend (BrowserSync):")} ${chalk.magenta(localUrl)}`, ); console.log( ` ${chalk.yellow.bold("Backend (PHP Target):")} ${chalk.magenta( prismaPhpConfigJson.bsTarget || "http://localhost:80", )}`, ); console.log(chalk.gray(" ------------------------------------")); if (externalUrl) { console.log( ` ${chalk.bold("External:")} ${chalk.magenta(externalUrl)}`, ); } if (uiUrl) { console.log(` ${chalk.bold("UI:")} ${chalk.magenta(uiUrl)}`); } const out = { local: localUrl, external: externalUrl, ui: uiUrl, uiExternal: uiExtUrl, }; writeFileSync( join(__dirname, "bs-config.json"), JSON.stringify(out, null, 2), ); console.log(`\n${chalk.gray("Press Ctrl+C to stop.")}\n`); }, ); // Close the browser log on the way out: a reader that finds no end marker // treats the session as abandoned, so a clean exit should say so. function shutdown(exitCode: number): void { endBrowserLogSession(); if (bs.active) { bs.exit(); } process.exit(exitCode); } process.once("SIGINT", () => shutdown(0)); process.once("SIGTERM", () => shutdown(0));