import { watch, existsSync } from "node:fs"; import { dirname, join } from "node:path"; import { createRequire } from "node:module"; import { DOCS_DIR, PAGES_DIR, loadDocuConfig } from "./paths"; import { loadPlugins } from "./plugin-loader"; import { BuildPluginBuilder } from "./plugin-builder"; import { buildClientBundle, computeInlineThemeCss } from "./hydrate"; import { generateSearchIndex } from "./search-indexer"; import { logger } from "./logger"; import { initSentry, captureException } from "./sentry"; import { serveStatic, handleDocsIndex, handleDocsRoute, handleIndex, handleNotFound, serverErrorResponse, type ServerState, } from "./server-routes"; import { wrapPluginResponse } from "./security"; import { stripDocsHtmlSuffix } from "./utils"; const docuConfig = loadDocuConfig(); const parsedPort = parseInt(process.env.PORT ?? "3000", 10); const PORT = Number.isInteger(parsedPort) && parsedPort > 0 && parsedPort <= 65535 ? parsedPort : 3000; logger.buildStart(); await initSentry(); logger.bundleStart(); let t = performance.now(); const assetManifest = await buildClientBundle(); logger.bundleDone(Math.round(performance.now() - t)); const inlineThemeCss = computeInlineThemeCss(); logger.indexStart(); t = performance.now(); const records = await generateSearchIndex(); logger.indexDone(records, Math.round(performance.now() - t)); logger.routes(); // Plugin setup — all hooks active (onLoad, remark/rehype, frontmatter, head/body, html transform, handleRequest) const pluginsConfig = docuConfig.plugins ?? []; const builder = pluginsConfig.length > 0 ? new BuildPluginBuilder(docuConfig) : null; if (builder) { const plugins = await loadPlugins(pluginsConfig); for (const plugin of plugins) { await plugin.setup(builder); } await builder.runOnStart(); } const state: ServerState = { docuConfig, assetManifest, inlineThemeCss, builder, }; let router: InstanceType | null = null; try { router = new Bun.FileSystemRouter({ style: "nextjs", dir: PAGES_DIR, }); } catch (e) { logger.warn(`FileSystemRouter failed: ${e instanceof Error ? e.message : String(e)}`); } const hmrClients = new Set(); const require = createRequire(import.meta.url); /** Locate the installed @docubook/markdown source dir (workspace link), if any. */ function resolveMdxContentSrc(): string | null { try { const pkgDir = dirname(require.resolve("@docubook/markdown/package.json")); const src = join(pkgDir, "src"); return existsSync(src) ? src : null; } catch { return null; } } let hmrTimeout: ReturnType | null = null; const watcher = watch(DOCS_DIR, { recursive: true }, (_event, filename) => { if (!filename || (!filename.endsWith(".mdx") && !filename.endsWith(".md"))) return; if (hmrTimeout) clearTimeout(hmrTimeout); hmrTimeout = setTimeout(() => { for (const client of [...hmrClients]) { try { client.enqueue(new TextEncoder().encode("data: reload\n\n")); } catch { hmrClients.delete(client); } } }, 300); }); // Rebuild the client bundle when component sources change (e.g. the // @docubook/markdown workspace package) so interactive islands pick up // edits, then reload connected clients. MDX edits are handled by the watcher // above — no rebuild needed, content recompiles per request. let srcHmrTimeout: ReturnType | null = null; const mdxContentSrc = resolveMdxContentSrc(); const srcWatcher = mdxContentSrc ? watch(mdxContentSrc, { recursive: true }, (_event, filename) => { if (!filename || !/\.(ts|tsx|js|jsx|css)$/.test(filename)) return; if (srcHmrTimeout) clearTimeout(srcHmrTimeout); srcHmrTimeout = setTimeout(async () => { try { state.assetManifest = await buildClientBundle(); logger.warn("[hmr] client bundle rebuilt (component change)"); } catch (e) { logger.warn(`[hmr] bundle rebuild failed: ${e instanceof Error ? e.message : String(e)}`); } for (const client of [...hmrClients]) { try { client.enqueue(new TextEncoder().encode("data: reload\n\n")); } catch { hmrClients.delete(client); } } }, 300); }) : null; process.on("SIGINT", () => { watcher.close(); srcWatcher?.close(); process.exit(0); }); process.on("SIGTERM", () => { watcher.close(); srcWatcher?.close(); process.exit(0); }); const server = Bun.serve({ port: PORT, development: true, idleTimeout: 255, async fetch(req) { const url = new URL(req.url); // Generated links carry `.html` (matching the static build output); // route them to the same handler as their extensionless form. const pathname = stripDocsHtmlSuffix(url.pathname); const startTime = performance.now(); if (builder) { const serverPort = server.port ?? PORT; const serverHostname = server.hostname ?? "localhost"; const pluginResponse = await builder.runHandleRequest(req, { port: serverPort, hostname: serverHostname, assetManifest: state.assetManifest, }); if (pluginResponse) { const securedResponse = wrapPluginResponse(pluginResponse, true); logger.request( req.method, pathname, securedResponse.status, Math.round(performance.now() - startTime) ); return securedResponse; } } try { if (pathname === "/__hmr") { const stream = new ReadableStream({ start(controller) { hmrClients.add(controller); controller.enqueue(new TextEncoder().encode("data: connected\n\n")); }, cancel(controller) { hmrClients.delete(controller); }, }); return new Response(stream, { headers: { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", Connection: "keep-alive", }, }); } if (pathname.startsWith("/assets/") || /\.\w+$/.test(pathname)) { const staticRes = serveStatic(pathname); if (staticRes) return staticRes; } const match = router?.match(pathname); let response: Response; const routeName = match?.name; if (routeName === "/docs/[[...slug]]") { const slugParam = match?.params?.slug; const slug = slugParam ? slugParam.split("/") : []; if (slug.length === 0) { response = await handleDocsIndex(state); } else { response = await handleDocsRoute(slug, state); } } else if (routeName === "/") { response = await handleIndex(state); } else { response = await handleNotFound(state); } logger.request( req.method, pathname, response.status, Math.round(performance.now() - startTime) ); return response; } catch (err) { captureException(err, { method: req.method, pathname }); logger.request(req.method, pathname, 500, Math.round(performance.now() - startTime)); return serverErrorResponse(err); } }, error(error) { console.error(error); captureException(error); return serverErrorResponse(error); }, }); logger.ready(server.port ?? PORT, true);