/**
* 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 Found404 - 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(
"
500 - Internal Server Error
${isDev ? errorStack : "An error occurred"}
Error: ${errorMessage}
",
`${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