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, renderToStringAsync } from "./ssr";
import { resetHead, getHeadElements } from "./head";
import { imports } from "./imports";
import {
buildScript,
buildStyle,
buildClientScript,
clientScriptsUsingReact,
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 escapeHtml(value: string): string {
return value.replace(
/[&<>"']/g,
(ch) =>
({
"&": "&",
"<": "<",
">": ">",
'"': """,
"'": "'",
})[ch]!,
);
}
function safeJson(value: unknown): string {
const json = JSON.stringify(value) ?? "null";
return json.replace(/")) {
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("
{
if (asyncMode) return renderToStringAsync(tree);
try {
return renderToString(tree);
} catch (error: any) {
if (error?.code === "TRADJS_ASYNC_COMPONENT_IN_SYNC_RENDER") {
const nested = error.componentName ? ` "${error.componentName}"` : "";
const wrapped = new Error(
`[tradjs] ${owner} uses synchronous SSR but rendered async component${nested}. Declare the page or participating layout async to opt this route into async SSR.`,
);
(wrapped as any).cause = error;
throw wrapped;
}
throw error;
}
}
async function wrapWithLayouts(tree: any, layoutPaths: string[]) {
let wrappedTree = tree;
let hasAsyncLayout = false;
for (let i = layoutPaths.length - 1; i >= 0; i--) {
const layoutModule = await import(layoutPaths[i]);
const LayoutComponent = layoutModule.default;
if (LayoutComponent) {
hasAsyncLayout ||= isDeclaredAsyncComponent(LayoutComponent);
wrappedTree = createElement(LayoutComponent, { children: wrappedTree });
}
}
return { tree: wrappedTree, hasAsyncLayout };
}
function createClientBootstrap(
scripts: { url: string; type: "layout" | "page" }[],
params: Record,
): string {
if (scripts.length === 0) return "";
const scriptEntries = safeJson(scripts);
const paramsJson = safeJson(params);
const devJson = JSON.stringify(isDev);
return ``;
}
function renderGenericErrorPage(
error: unknown,
viewTransitions: boolean,
): string {
const value = error as any;
const message = value?.message || String(error);
const stack = value?.stack || message;
const detail = isDev ? stack : "Internal Server Error";
return injectViewTransitions(
`500 - Internal Server Error500 - Internal Server Error
${escapeHtml(isDev ? message : "The request could not be rendered.")}
${escapeHtml(detail)}`,
viewTransitions,
);
}
export async function spa(options: FrontendAppOptions): Promise {
return frontendApp(options);
}
export async function frontendApp(
options: FrontendAppOptions,
): Promise {
const {
entrypoint,
stylePath,
title = "Frontend App",
viewport = "width=device-width, initial-scale=1",
viewTransitions = true,
serverData = {},
additionalAssets = [],
meta = [],
head = "",
headerScripts = [],
} = options;
let stylesVirtualPath = "";
if (stylePath) {
try {
stylesVirtualPath = await buildStyle(stylePath);
} catch {
console.warn(`Style not found: ${stylePath}`);
}
}
const assetPaths: string[] = [];
for (const asset of additionalAssets) {
const virtualPath = await buildAsset(Bun.file(asset.path));
if (virtualPath) assetPaths.push(virtualPath);
}
const scriptPath = entrypoint.startsWith("/")
? entrypoint
: path.join(process.cwd(), entrypoint);
const packagePath = path.resolve(process.cwd(), "package.json");
const packageJson = (await import(packagePath, { assert: { type: "json" } }))
.default;
const importMaps = ``;
const scriptVirtualPath = (await buildScript(scriptPath)) ?? "";
if (!scriptVirtualPath)
throw new Error(`Failed to build script: ${scriptPath}`);
const metaTags = meta
.map((item) => ``)
.join("\n");
const additionalHead = additionalAssets
.map((asset) =>
asset.type === "icon"
? ``
: "",
)
.join("\n");
const headerScriptsHtml = headerScripts
.map((script) => ``)
.join("\n");
return injectViewTransitions(
dedent`
${importMaps}
${metaTags}
${title}
${additionalHead}
${headerScriptsHtml}
${head}
${stylesVirtualPath ? `` : ""}
`,
viewTransitions,
);
}
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 {
console.warn(`Style not found: ${stylePath}`);
}
}
let importMaps = "";
try {
const packagePath = path.resolve(process.cwd(), "package.json");
const packageJson = (
await import(packagePath, { assert: { type: "json" } })
).default;
importMaps = ``;
} catch (error) {
console.warn("Could not generate import map:", error);
}
const serverHtml = await renderServerTree(
createElement(Component, { ...props, params }),
isDeclaredAsyncComponent(Component),
"renderPage() component",
);
let scriptVirtualPath = "";
if (clientComponent) {
scriptVirtualPath = (await buildScript(clientComponent)) ?? "";
if (!scriptVirtualPath) {
throw new Error(`Client component build failed: ${clientComponent}`);
}
}
const metaTags = meta
.map((item) => ``)
.join("\n");
return injectViewTransitions(
dedent`
${importMaps}
${metaTags}
${title}
${stylesVirtualPath ? `` : ""}
${serverHtml}
${scriptVirtualPath ? `` : ""}
`,
viewTransitions,
);
}
export function createAppRouter(options: AppRouterOptions = {}): Handler {
const { appDir = path.join(process.cwd(), "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) {
for (const cssPath of [
path.join(appDir, "globals.css"),
path.join(appDir, "global.css"),
path.join(appDir, "app.css"),
]) {
if (existsSync(cssPath)) {
globalCss = cssPath;
console.log(
`📄 Found global CSS: ${path.relative(process.cwd(), cssPath)}`,
);
break;
}
}
}
if (!isDev) {
void (async () => {
try {
const count = await ssgPrerender(routes, async (route: any) => {
const pageModule = await import(route.filePath);
const PageComponent = pageModule.default || pageModule.Page;
if (!PageComponent) {
throw new Error(`No default export in ${route.filePath}`);
}
const pageIsAsync = isDeclaredAsyncComponent(PageComponent);
const wrapped = await wrapWithLayouts(
createElement(PageComponent, { params: {} }),
route.layouts,
);
resetHead();
const html = await renderServerTree(
wrapped.tree,
pageIsAsync || wrapped.hasAsyncLayout,
`SSG route ${route.pattern}`,
);
const headElements = getHeadElements();
let fullHtml = `${html}`;
if (globalCss) {
try {
const stylesPath = await buildStyle(globalCss);
fullHtml = fullHtml.replace(
"",
``,
);
} catch {}
}
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 (error: any) {
console.warn("SSG prerender failed:", error?.message || error);
}
})();
}
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" },
});
}
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 {
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: any) =>
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 (mwResult instanceof Response) return mwResult;
}
}
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" },
});
},
);
}
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 (error) {
console.warn("Failed to build global CSS:", error);
}
}
let scopedStylePath = "";
const pageCssPath = match.route.filePath.replace(
/\.(tsx?|jsx?)$/,
".css",
);
if (existsSync(pageCssPath)) {
try {
scopedStylePath = await buildScopedStyle(
pageCssPath,
match.route.pattern,
);
} catch (error) {
console.warn("Failed to build scoped CSS:", error);
}
}
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);
if (!scriptPath) {
throw new Error(
`[tradjs] Layout client bundle produced no output: ${layoutClientPath}`,
);
}
clientScriptUrls.push({ url: scriptPath, type: "layout" });
}
}
const pageClientPath = match.route.filePath.replace(
/\.tsx?$/,
".client.tsx",
);
if (existsSync(pageClientPath)) {
const scriptPath = await buildClientScript(pageClientPath);
if (!scriptPath) {
throw new Error(
`[tradjs] Page client bundle produced no output: ${pageClientPath}`,
);
}
clientScriptUrls.push({ url: scriptPath, type: "page" });
}
const clientBootstrap = createClientBootstrap(
clientScriptUrls,
match.params,
);
const allClientPaths = [
...match.route.layouts
.map((layoutPath: string) =>
layoutPath.replace(/\.tsx?$/, ".client.tsx"),
)
.filter(existsSync),
...(existsSync(pageClientPath) ? [pageClientPath] : []),
];
const needsReactImportMap = allClientPaths.some((clientPath) =>
clientScriptsUsingReact.has(clientPath),
);
let importMapTag = "";
if (needsReactImportMap) {
try {
const importMapJson = JSON.stringify(
await imports(["react-dom/client", "react/jsx-dev-runtime"]),
);
importMapTag = ``;
} catch (error) {
console.warn("Failed to generate React import maps:", error);
}
}
const responseHeaders = {
"Content-Type": "text/html",
"Cache-Control": isDev ? "no-cache" : "public, max-age=3600",
};
const pageIsAsync = isDeclaredAsyncComponent(PageComponent);
resetHead();
const pageHtml = await httpMeasure.measure("SSR renderPage", () =>
renderServerTree(pageTree, pageIsAsync, `page ${match.route.pattern}`),
);
const pageHeadElements = [...getHeadElements()];
const wrappedShell = await wrapWithLayouts(
createElement("tradjs-stream-slot", {}),
match.route.layouts,
);
resetHead();
const shellHtmlOnly = await httpMeasure.measure("SSR renderShell", () =>
renderServerTree(
wrappedShell.tree,
wrappedShell.hasAsyncLayout,
`layout shell for ${match.route.pattern}`,
),
);
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 (clientBootstrap) {
fullHtml = fullHtml.replace("