/** * App Router — Next.js-style file-based routing * * Discovers routes from the app directory, renders pages with SSR, * builds client mount scripts, and handles API routes. */ import path from "path"; import { existsSync } from "fs"; import { dedent } from "ts-dedent"; import { discoverRoutes, matchRoute } from "./router"; import { createElement } from "../client/render"; import { renderToString } from "./ssr"; import { resetHead, getHeadElements, Head } from "./head"; import { imports } from "./imports"; import { buildScript, buildStyle, buildClientScript, clientScriptsUsingReact, builtAssets, getContentType, buildScopedStyle, buildAsset, } from "./build"; import { getPrerendered, prerender as ssgPrerender } from "./ssg"; import { serve as serveHttp } from "./serve"; import { httpMeasure } from "./measure"; import type { Handler, FrontendAppOptions, RenderPageOptions, AppRouterOptions, } from "./types"; const isDev = process.env.NODE_ENV !== "production"; const STREAM_SLOT_MARKUP = ""; const VIEW_TRANSITION_STYLE = ""; function injectViewTransitions(html: string, enabled: boolean): string { if (!enabled || html.includes("data-tradjs-view-transitions")) return html; if (html.includes("")) { return html.replace("", `${VIEW_TRANSITION_STYLE}`); } if (html.startsWith("")) { return html.replace( "", `${VIEW_TRANSITION_STYLE}`, ); } return `${VIEW_TRANSITION_STYLE}${html}`; } function applyRouteBodyAttributes(html: string, routePattern: string): string { if (!html.includes("= 0; i--) { const layoutPath = layoutPaths[i]; const layoutModule = await import(layoutPath); const LayoutComponent = layoutModule.default; if (LayoutComponent) { wrappedTree = createElement(LayoutComponent, { children: wrappedTree }); } } return wrappedTree; } // ─── SPA / Legacy Frontend ────────────────────────────────────────────────────── export async function spa(options: FrontendAppOptions): Promise { return frontendApp(options); } /** @deprecated Use the createAppRouter() pattern instead. */ export async function frontendApp( options: FrontendAppOptions, ): Promise { const { entrypoint, stylePath, title = "Frontend App", viewport = "width=device-width, initial-scale=1", viewTransitions = true, rebuild = true, serverData = {}, additionalAssets = [], meta = [], head = "", headerScripts = [], } = options; let stylesVirtualPath = ""; if (stylePath) { try { stylesVirtualPath = await buildStyle(stylePath); } catch (error) { console.warn(`Style not found: ${stylePath}`); } } const assetPaths: string[] = []; for (const asset of additionalAssets) { const file = Bun.file(asset.path); const virtualPath = await buildAsset(file); if (virtualPath) { assetPaths.push(virtualPath); } } const scriptPath = entrypoint.startsWith("/") ? entrypoint : path.join(process.cwd(), entrypoint); const subpathImports = [ "react-dom/client", "react/jsx-dev-runtime", "wouter/use-browser-location", ]; const packagePath = path.resolve(process.cwd(), "package.json"); const packageJson = (await import(packagePath, { assert: { type: "json" } })) .default; const importMaps = ` `; let scriptVirtualPath = (await buildScript(scriptPath)) ?? ""; if (!scriptVirtualPath) throw `failed to build script`; const metaTags = meta .map((m) => ``) .join("\n"); const additionalHead = additionalAssets .map((asset) => { if (asset.type === "icon") { return ``; } return ""; }) .join("\n"); const headerScriptsHtml = headerScripts .map((script) => ``) .join("\n"); return injectViewTransitions( dedent` ${importMaps} ${metaTags} ${title} ${additionalHead} ${headerScriptsHtml} ${head} ${stylesVirtualPath ? `` : ""}
`, viewTransitions, ); } // ─── Page Renderer ────────────────────────────────────────────────────────────── /** * Render a page component to HTML with SSR. * Used by app router to render route components. */ export async function renderPage(options: RenderPageOptions): Promise { const { component: Component, clientComponent, stylePath, title = "TradJS App", params = {}, props = {}, viewport = "width=device-width, initial-scale=1", viewTransitions = true, meta = [], } = options; let stylesVirtualPath = ""; if (stylePath) { try { stylesVirtualPath = await buildStyle(stylePath); } catch (error) { console.warn(`Style not found: ${stylePath}`); } } const subpathImports = ["react-dom/client", "react/jsx-dev-runtime"]; let importMaps = ""; try { const packagePath = path.resolve(process.cwd(), "package.json"); const packageJson = ( await import(packagePath, { assert: { type: "json" } }) ).default; importMaps = ` `; } catch (e) { console.warn("Could not generate import map:", e); } let serverHtml = ""; try { serverHtml = renderToString(createElement(Component, { ...props, params })); } catch (error) { console.warn("SSR failed, will use client-side rendering only:", error); } let scriptVirtualPath = ""; if (clientComponent) { try { scriptVirtualPath = await buildScript(clientComponent); } catch (error) { console.warn(`Client component build failed: ${clientComponent}`, error); } } const metaTags = meta .map((m) => ``) .join("\n"); return injectViewTransitions( dedent` ${importMaps} ${metaTags} ${title} ${stylesVirtualPath ? `` : ""}
${serverHtml}
${scriptVirtualPath ? `` : ""} `, viewTransitions, ); } // ─── App Router ───────────────────────────────────────────────────────────────── /** * Create a request handler with file-based routing. * Automatically discovers routes from app directory. * * @example * ```ts * import { serve, createAppRouter } from 'tradjs/web'; * * serve(createAppRouter({ * appDir: './app', * globalCss: './app/globals.css', * })); * ``` */ export function createAppRouter(options: AppRouterOptions = {}): Handler { const { appDir = path.join(process.cwd(), "app"), defaultTitle = "TradJS App", viewTransitions = true, } = options; const routes = discoverRoutes(appDir); console.log(`📁 Discovered ${routes.length} routes:`); routes.forEach((route) => { const typeIcon = route.type === "api" ? "⚡" : "📄"; const layoutInfo = route.layouts.length > 0 ? ` (${route.layouts.length} layouts)` : ""; const mwInfo = route.middlewares.length > 0 ? ` [${route.middlewares.length} middleware]` : ""; const errorInfo = route.errorPath ? " 🛡" : ""; const loadingInfo = route.loadingPath ? " ⏳" : ""; console.log( ` ${typeIcon} ${route.pattern} -> ${path.relative(process.cwd(), route.filePath)}${layoutInfo}${mwInfo}${errorInfo}${loadingInfo}`, ); }); let globalCss = options.globalCss; if (!globalCss) { const possiblePaths = [ path.join(appDir, "globals.css"), path.join(appDir, "global.css"), path.join(appDir, "app.css"), ]; for (const cssPath of possiblePaths) { if (existsSync(cssPath)) { globalCss = cssPath; console.log( `📄 Found global CSS: ${path.relative(process.cwd(), cssPath)}`, ); break; } } } // ── SSG: Pre-render eligible pages at startup ────────────────────────── if (!isDev) { (async () => { try { const count = await ssgPrerender(routes, async (route) => { const pageModule = await import(route.filePath); const PageComponent = pageModule.default || pageModule.Page; if (!PageComponent) throw new Error(`No default export in ${route.filePath}`); let tree = createElement(PageComponent, { params: {} }); for (let i = route.layouts.length - 1; i >= 0; i--) { const layoutModule = await import(route.layouts[i]); const LayoutComponent = layoutModule.default; if (LayoutComponent) tree = createElement(LayoutComponent, { children: tree }); } resetHead(); const html = renderToString(tree); const headElements = getHeadElements(); let fullHtml = `${html}`; if (globalCss) { try { const stylesPath = await buildStyle(globalCss); fullHtml = fullHtml.replace( "", ``, ); } catch (_) { /* ignore */ } } if (headElements.length > 0) { fullHtml = fullHtml.replace( "", `${headElements.join("\n")}`, ); } fullHtml = applyRouteBodyAttributes(fullHtml, route.pattern); return injectViewTransitions(fullHtml, viewTransitions); }); if (count > 0) console.log(`⚡ SSG: Pre-rendered ${count} pages`); } catch (e: any) { console.warn("SSG prerender failed:", e.message); } })(); } return async (req: Request) => { const url = new URL(req.url); const pathname = url.pathname; const match = matchRoute(pathname, routes); if (!match) { const notFoundHtml = injectViewTransitions( `404 - Not Found

404 - Not Found

`, viewTransitions, ); return new Response(notFoundHtml, { status: 404, headers: { "Content-Type": "text/html" }, }); } // ── SSG: serve pre-rendered page from memory ────────────────── if (!isDev && match.route.type === "page") { const cached = getPrerendered(pathname); if (cached) { return new Response(cached, { headers: { "Content-Type": "text/html", "Cache-Control": "public, max-age=3600", "X-TradJS-SSG": "1", }, }); } } try { // ── Middleware Chain ───────────────────────────────────────────── // Execute middleware.ts files from root→page (outermost first). // Each middleware can short-circuit by returning a Response. if (match.route.middlewares.length > 0) { for (const mwPath of match.route.middlewares) { const mwResult = await httpMeasure.measure( { start: () => `Middleware: ${path.basename(path.dirname(mwPath))}`, end: (result) => result instanceof Response ? { status: result.status } : { result: result === undefined ? "continue" : "value" }, }, async () => { const mwModule = await import(mwPath); const mwFn = mwModule.default || mwModule.middleware; if (typeof mwFn === "function") { return await mwFn(req, { params: match.params, route: match.route, }); } }, ); // If middleware returns a Response, short-circuit if (mwResult instanceof Response) return mwResult; } } // Handle API routes if (match.route.type === "api") { return await httpMeasure.measure( { start: () => `API: ${match.route.pattern}`, end: (response: Response) => ({ status: response.status }), }, async () => { const apiModule = await import(match.route.filePath); const method = req.method.toUpperCase(); const handler = apiModule[method] || apiModule.default; if (!handler) { return new Response("Method Not Allowed", { status: 405 }); } const response = await handler(req, { params: match.params }); return response instanceof Response ? response : new Response(JSON.stringify(response), { headers: { "Content-Type": "application/json" }, }); }, ); } // Handle Page routes const pageModule = await httpMeasure.measure( { start: () => "Import page", end: () => ({ file: path.basename(match.route.filePath) }), }, () => import(match.route.filePath), ); const PageComponent = pageModule.default || pageModule.Page; if (!PageComponent) { throw new Error(`No default export found in ${match.route.filePath}`); } const pageTree = createElement(PageComponent, { params: match.params }); let stylesVirtualPath = ""; if (globalCss) { try { stylesVirtualPath = await buildStyle(globalCss); } catch (e) { console.warn("Failed to build global CSS:", e); } } // Build page-scoped CSS if page.css exists alongside page.tsx let scopedStylePath = ""; const pageCssPath = match.route.filePath.replace( /\.(tsx?|jsx?)$/, ".css", ); if (existsSync(pageCssPath)) { try { scopedStylePath = await buildScopedStyle( pageCssPath, match.route.pattern, ); } catch (e) { console.warn("Failed to build scoped CSS:", e); } } const clientScriptUrls: { url: string; type: "layout" | "page" }[] = []; for (const layoutPath of match.route.layouts) { const layoutClientPath = layoutPath.replace(/\.tsx?$/, ".client.tsx"); if (existsSync(layoutClientPath)) { const scriptPath = await buildClientScript(layoutClientPath); clientScriptUrls.push({ url: scriptPath, type: "layout" }); } } const pageClientPath = match.route.filePath.replace( /\.tsx?$/, ".client.tsx", ); if (existsSync(pageClientPath)) { try { const scriptPath = await buildClientScript(pageClientPath); if (scriptPath) { clientScriptUrls.push({ url: scriptPath, type: "page" }); } } catch (e: any) { console.error( `[tradjs] Failed to build page client script: ${e.message}`, ); } } const clientScriptTags: string[] = []; if (clientScriptUrls.length > 0) { const paramsJson = JSON.stringify(match.params); const bootstrapLines = clientScriptUrls.map(({ url, type }) => { return `import('${url}').then(m => { if (typeof m.default === 'function') m.default({ params }); }).catch(e => console.error('[tradjs] Failed to mount ${type} script:', e));`; }); clientScriptTags.push( ``, ); } const allClientPaths = [ ...match.route.layouts .map((l) => l.replace(/\.tsx?$/, ".client.tsx")) .filter(existsSync), ...(existsSync(pageClientPath) ? [pageClientPath] : []), ]; const needsReactImportMap = allClientPaths.some((p) => clientScriptsUsingReact.has(p), ); let importMapTag = ""; if (needsReactImportMap) { try { const importMapJson = JSON.stringify( await imports(["react-dom/client", "react/jsx-dev-runtime"]), ); importMapTag = ``; } catch (e) { console.warn("Failed to generate React import maps:", e); } } const responseHeaders = { "Content-Type": "text/html", "Cache-Control": isDev ? "no-cache" : "public, max-age=3600", }; resetHead(); const pageHtml = await httpMeasure.measure("SSR renderPage", () => renderToString(pageTree), ); const pageHeadElements = [...getHeadElements()]; const slotTree = await wrapWithLayouts( createElement("tradjs-stream-slot", {}), match.route.layouts, ); resetHead(); const shellHtmlOnly = await httpMeasure.measure("SSR renderShell", () => renderToString(slotTree), ); const layoutHeadElements = [...getHeadElements()]; const headElements = [...layoutHeadElements, ...pageHeadElements]; let shellHtml = `${shellHtmlOnly}`; if (stylesVirtualPath) { shellHtml = shellHtml.replace( "", ``, ); } if (scopedStylePath) { shellHtml = shellHtml.replace( "", ``, ); } shellHtml = applyRouteBodyAttributes(shellHtml, match.route.pattern); if (headElements.length > 0) { shellHtml = shellHtml.replace( "", `${headElements.join("\n")}`, ); } if (importMapTag) { shellHtml = shellHtml.replace("", `${importMapTag}`); } shellHtml = injectViewTransitions(shellHtml, viewTransitions); const slotIndex = shellHtml.indexOf(STREAM_SLOT_MARKUP); if (slotIndex === -1) { let fullHtml = shellHtml + pageHtml; if (clientScriptTags.length > 0) { fullHtml = fullHtml.replace( "", `${clientScriptTags.join("\n")}`, ); } return new Response(fullHtml, { headers: responseHeaders }); } const prefix = shellHtml.slice(0, slotIndex); let suffix = shellHtml.slice(slotIndex + STREAM_SLOT_MARKUP.length); if (clientScriptTags.length > 0) { suffix = suffix.replace( "", `${clientScriptTags.join("\n")}`, ); } const encoder = new TextEncoder(); const stream = new ReadableStream({ start(controller) { controller.enqueue(encoder.encode(prefix)); controller.enqueue(encoder.encode(pageHtml)); controller.enqueue(encoder.encode(suffix)); controller.close(); }, }); return new Response(stream, { headers: responseHeaders }); } catch (error: any) { console.error("Error rendering page:", error); const errorMessage = error?.message || String(error); const errorStack = error?.stack || "No stack trace available"; // ── Error Boundary: render error.tsx if available ──────────── if (match.route.errorPath) { try { const errorModule = await import(match.route.errorPath); const ErrorComponent = errorModule.default; if (ErrorComponent) { const errorProps = { error: { message: errorMessage, stack: isDev ? errorStack : undefined, }, pathname: url.pathname, }; let errorTree = createElement(ErrorComponent, errorProps); // Wrap in layouts (error page should still have app chrome) for (let i = match.route.layouts.length - 1; i >= 0; i--) { const layoutPath = match.route.layouts[i]; const layoutModule = await import(layoutPath); const LayoutComponent = layoutModule.default; if (LayoutComponent) { errorTree = createElement(LayoutComponent, { children: errorTree, }); } } const errorHtml = renderToString(errorTree); let fullErrorHtml = `${errorHtml}`; if (globalCss) { try { const stylesPath = await buildStyle(globalCss); fullErrorHtml = fullErrorHtml.replace( "", ``, ); } catch (_) { /* ignore CSS errors during error rendering */ } } fullErrorHtml = injectViewTransitions( fullErrorHtml, viewTransitions, ); return new Response(fullErrorHtml, { status: 500, headers: { "Content-Type": "text/html" }, }); } } catch (errorBoundaryError: any) { console.error("Error boundary itself failed:", errorBoundaryError); // Fall through to generic error page } } // Generic fallback error page return new Response( injectViewTransitions( ` 500 - Internal Server Error

500 - Internal Server Error

${isDev ? errorStack : "An error occurred"}

Error: ${errorMessage}

`, viewTransitions, ), { status: 500, headers: { "Content-Type": "text/html" }, }, ); } }; } // ─── Quick Start ──────────────────────────────────────────────────────────────── const DEFAULT_APP_DIR_NAME = "app"; function hasConventionRoutes(dir: string): boolean { const routeSignals = [ "page.tsx", "page.ts", "page.jsx", "page.js", "layout.tsx", "layout.ts", "layout.jsx", "layout.js", "middleware.ts", "middleware.tsx", "middleware.js", "error.tsx", "error.ts", "loading.tsx", "loading.ts", ]; if (existsSync(path.join(dir, "api"))) { return true; } return routeSignals.some((file) => existsSync(path.join(dir, file))); } export function resolveAppDir(appDir?: string): string { if (appDir) { return path.isAbsolute(appDir) ? appDir : path.resolve(process.cwd(), appDir); } const defaultAppDir = path.resolve(process.cwd(), DEFAULT_APP_DIR_NAME); if (existsSync(defaultAppDir)) { return defaultAppDir; } const cwd = process.cwd(); if (hasConventionRoutes(cwd)) { return cwd; } throw new Error( `Could not find a TradJS app. Looked for ./${DEFAULT_APP_DIR_NAME}/ first, then route files in ${cwd}.`, ); } export interface ServeAppOptions extends AppRouterOptions { port?: number; unix?: string; } export async function serve(options?: ServeAppOptions): Promise; export async function serve( handler: Handler, options?: { port?: number; unix?: string; websocket?: any }, ): Promise; export async function serve( handlerOrOptions?: Handler | ServeAppOptions, serverOptions?: { port?: number; unix?: string; websocket?: any }, ) { if (typeof handlerOrOptions === "function") { return serveHttp(handlerOrOptions, serverOptions); } const options = handlerOrOptions ?? {}; const { port, unix, ...routerOptions } = options; const appDir = resolveAppDir(routerOptions.appDir); const router = createAppRouter({ ...routerOptions, appDir }); return serveHttp(router, { port, unix }); } /** * Start a TradJS server with file-based routing in one call. * Combines createAppRouter() + serve() for convenience. * * @example * ```ts * import { start } from 'tradjs'; * await start({ appDir: './app', port: 3000 }); * ``` */ export async function start(options: ServeAppOptions = {}) { return serve(options); }