Module
A React Framework for Deno that makes it easy to create highly interactive apps that have server side rendering with file based routing for both your UI and API.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551import { walk } from "std/fs/walk.ts";import { ensureDir } from "std/fs/ensure_dir.ts";import * as path from "std/path/mod.ts";import * as esbuild from "x/esbuild/mod.js";import { denoPlugin } from "x/esbuild_deno_loader/mod.ts";
import { isProduction, isTest } from "./env.ts";import { routePathFromName } from "./server.tsx";
interface Route { name: string; parent?: Route; react?: boolean; file?: { react?: string; oak?: string; }; main?: { react?: string; oak?: string; }; index?: { react?: string; oak?: string; }; children?: Record<string, Route>;}
const TEST_PATH = /(\.|_)test(\.(?:js|jsx|ts|tsx))$/;const IGNORE_PATH = /(\/|\\)_[^\/\\]*(\.(?:js|jsx|ts|tsx))$/;const ROUTE_PATH = /(\.(?:js|jsx|ts|tsx))$/;const REACT_EXT = /(\.(?:jsx|tsx))$/;
function addFileToDir(route: Route, name: string, ext: string) { const isReactFile = REACT_EXT.test(ext); if (name === "main" || name === "index") { if (!route[name]) { route[name] = {}; } if (isReactFile) { route[name]!.react = `${name}${ext}`; } else { route[name]!.oak = `${name}${ext}`; } } else { if (!route.children) route.children = {}; if (!route.children[name]) { route.children[name] = { name, parent: route, file: {} }; } const childRoute = route.children[name];
if (!childRoute.file) childRoute.file = {}; if (isReactFile) { childRoute.react = true; childRoute.file.react = `${name}${ext}`; } else { childRoute.file.oak = `${name}${ext}`; } }
if (isReactFile) { let currentRoute: Route | undefined = route; while (currentRoute && !currentRoute.react) { currentRoute.react = true; currentRoute = route.parent as Route; } }}
async function generateRoutes(routesUrl: string): Promise<Route> { const rootRoute = { name: "", children: {} } as Route;
for await ( const entry of walk(routesUrl, { includeDirs: false, match: [ROUTE_PATH], skip: [TEST_PATH, IGNORE_PATH], }) ) { const parsedPath = path.parse(entry.path); const { name, ext, dir } = parsedPath; const relativePath = path.relative(routesUrl, dir.replaceAll("\\", "/")); const layers = relativePath.length ? relativePath.split("/") : [];
let parentRoute = rootRoute; for (const layer of layers) { if (!parentRoute.children) parentRoute.children = {}; if (!parentRoute.children[layer]) { parentRoute.children[layer] = { name: layer, children: {}, parent: parentRoute, }; } parentRoute = parentRoute.children[layer]; }
addFileToDir(parentRoute, name, ext); }
return rootRoute;}
function lazyImportLine(routeId: number, routePath: string, filePath: string) { return `const $${routeId} = lazy(${ routePath ? `"/${routePath}", ` : "" }() => import("./${filePath}"));`;}
function routeFileData(routeId: number, relativePath: string, route: Route) { const importLines: string[] = []; const name = routePathFromName(route.name); let routeText = `{ path: ${JSON.stringify(name)}`;
const { file, main, index, children } = route; if (file?.react) { importLines.push( lazyImportLine( routeId, relativePath, path.posix.join(relativePath, routeId === 0 ? "" : "../", file.react), ), ); routeText += `, element: <$${routeId} /> }`; routeId++; } else { if (main?.react) { if (relativePath) { importLines.push( lazyImportLine( routeId, relativePath, path.posix.join(relativePath, main.react), ), ); } else { importLines.push( `import * as $${routeId++} from "./${ path.posix.join(relativePath, main.react) }";`, `let $${routeId};`, `if (($${routeId - 1} as RouteFile).ErrorFallback) {`, ` $${routeId} = withAppErrorBoundary($${routeId - 1}.default, {`, ` FallbackComponent: ($${ routeId - 1 } as RouteFile).ErrorFallback!,`, ` });`, `} else {`, ` $${routeId} = $${routeId - 1}.default;`, `}`, ); } routeText += `, element: <$${routeId} />`; routeId++; } else if (!relativePath) { importLines.push( `import { Outlet } from "npm/react-router-dom";`, `const $${routeId} = withAppErrorBoundary(() => <Outlet />, { FallbackComponent: DefaultErrorFallback });`, ); routeText += `, element: <$${routeId} />`; routeId++; }
const childRouteTexts: string[] = []; if (index?.react) { importLines.push( lazyImportLine( routeId, path.posix.join(relativePath, "index"), path.posix.join(relativePath, index.react), ), ); childRouteTexts.push(`{ index: true, element: <$${routeId} /> }`); routeId++; }
let notFoundRoute: Route | undefined = undefined; for (const childRoute of Object.values(children ?? {})) { if (!childRoute.react) continue; if (childRoute.name === "[...]") { notFoundRoute = childRoute; continue; }
const { importLines: childImportLines, routeText: childRouteText, nextRouteId, } = routeFileData( routeId, path.posix.join(relativePath, childRoute.name), childRoute, ); importLines.push(...childImportLines); childRouteTexts.push(childRouteText); routeId = nextRouteId; }
if (notFoundRoute) { const { importLines: childImportLines, routeText: childRouteText, nextRouteId, } = routeFileData( routeId, path.posix.join(relativePath, notFoundRoute.name), notFoundRoute, ); importLines.push(...childImportLines); childRouteTexts.push(childRouteText); routeId = nextRouteId; } else { childRouteTexts.push(`{ path: "*", element: <NotFound /> }`); }
if (childRouteTexts.length) { routeText += `, children: [\n${childRouteTexts.join(",\n")}\n]`; } routeText += "}"; }
return { importLines, routeText, nextRouteId: routeId, };}
function routeImportLines(routeId: number, relativePath: string) { return [ `import "./${relativePath}";`, `import * as $${routeId} from "./${relativePath}";`, ];}
function routerImportLine(routeId: number, relativePath: string) { return `import $${routeId} from "./${relativePath}";`;}
function routerFileData( routeId: number, relativePath: string, route: Route,) { const { name, file, main, index, react, children } = route; const importLines: string[] = [];
let routerText = `{ name: ${JSON.stringify(name)}`; if (react) routerText += `, react: true`;
if (file) { routerText += ", file: {"; const fileText: string[] = [];
if (file.react) { importLines.push( ...routeImportLines( routeId, path.posix.join(relativePath, routeId > 0 ? "../" : "", file.react), ), ); fileText.push(`react:$${routeId}`); routeId++; }
if (file.oak) { importLines.push( routerImportLine( routeId, path.posix.join(relativePath, routeId > 0 ? "../" : "", file.oak), ), ); fileText.push(`oak:$${routeId}`); routeId++; }
routerText += fileText.join(", ") + `}`; } else { if (main) { routerText += ", main: {"; const fileText: string[] = [];
if (main.react) { importLines.push( ...routeImportLines( routeId, path.posix.join(relativePath, main.react), ), ); fileText.push(`react:$${routeId}`); routeId++; }
if (main.oak) { importLines.push( routerImportLine( routeId, path.posix.join(relativePath, main.oak), ), ); fileText.push(`oak:$${routeId}`); routeId++; }
routerText += fileText.join(", ") + `}`; }
if (index) { routerText += ", index: {"; const fileText: string[] = [];
if (index.react) { importLines.push( ...routeImportLines( routeId, path.posix.join(relativePath, index.react), ), ); fileText.push(`react:$${routeId}`); routeId++; }
if (index.oak) { importLines.push( routerImportLine( routeId, path.posix.join(relativePath, index.oak), ), ); fileText.push(`oak:$${routeId}`); routeId++; }
routerText += fileText.join(", ") + `}`; }
if (children) { routerText += ", children: {"; const childText: string[] = []; for (const [name, childRoute] of Object.entries(children)) { const { importLines: childImportLines, routerText: childRouterText, nextRouteId, } = routerFileData( routeId, path.posix.join(relativePath, name), childRoute, ); importLines.push(...childImportLines); childText.push(`${JSON.stringify(name)}: ${childRouterText}`); routeId = nextRouteId; } routerText += childText.join(", ") + "}"; } } routerText += "}";
return { importLines, routerText, nextRouteId: routeId, };}
const fmtCommand = new Deno.Command(Deno.execPath(), { args: ["fmt", "-"], stdin: "piped", stdout: "piped",});async function writeRoutes(path: string, text: string) { const fmt = fmtCommand.spawn(); const fmtWriter = fmt.stdin.getWriter(); const encoder = new TextEncoder(); await fmtWriter.write(encoder.encode(text)); await fmtWriter.close(); const { success, code } = await fmt.status; if (success) { Deno.writeFile(path, fmt.stdout); } else { console.log("fmt routes failed", { path, code }); }}
async function updateRoutes(routesUrl: string, rootRoute: Route) { if (rootRoute.react) { const lines = [ `import { lazy, withAppErrorBoundary, DefaultErrorFallback, NotFound, RouteFile } from "x/udibo_react_app/mod.tsx";`, `import { RouteObject } from "npm/react-router-dom";`, "", ]; const { importLines, routeText } = routeFileData(0, "", rootRoute); lines.push(...importLines, ""); lines.push(`export default ${routeText} as RouteObject;`, "");
await writeRoutes(path.join(routesUrl, "_main.tsx"), lines.join("\n")); }
const lines = [ `import { generateRouter } from "x/udibo_react_app/server.tsx";`, "", ]; const { importLines, routerText } = routerFileData(0, "", rootRoute); lines.push( ...importLines, "", `export default generateRouter(${routerText});`, );
await writeRoutes(path.join(routesUrl, "_main.ts"), lines.join("\n"));}
async function buildRoutes(routesUrl: string) { const appRoute = await generateRoutes(routesUrl); await updateRoutes(routesUrl, appRoute);}
export interface BuildOptions { /** The absolute path to the working directory for your application. */ workingDirectory: string; /** The client entry point for your application relative to the workingDirectory. */ entryPoint: string; /** * Urls for all routes directories that your app uses. * Each routes directory will have 2 files generated in them. * - `_main.ts`: Contains the oak router for the routes. * - `_main.tsx`: Contains the react router routes for controlling navigation in the app. * Your server entrypoint should import and use both of the generated files for each routes directory. * Your client entry point should only import the react router routes. * In most cases, you will only need a single routesUrl. */ routesUrls: string[]; /** * The application will serve all files from this directory. * The build for your client entry point will be stored in public/build. * Test builds will be stored in public/test-build. */ publicUrl: string; /** File url for your import map. */ importMapUrl: string; /** * ESBuild plugins to use when building your application. * These plugins will be added after the deno plugin. */ esbuildPlugins?: esbuild.Plugin[]; /** * Called before building the application. * This can be used to add additional steps before the build starts. */ preBuild?: (() => Promise<void>) | (() => void); /** * Called after building the application. * This can be used to add additional steps after the build is completed. */ postBuild?: (() => Promise<void>) | (() => void);}
/** Builds the application and all of it's routes. */export async function build(options: BuildOptions) { const { preBuild, postBuild } = options;
if (preBuild) await preBuild();
console.log("Building app"); performance.mark("buildStart"); let success = false; try { const { entryPoint, routesUrls, publicUrl, importMapUrl, workingDirectory, } = options; const outdir = path.join( publicUrl, `${isTest() ? "test-" : ""}build`, ); await ensureDir(outdir);
const importMapURL = path.toFileUrl(importMapUrl);
const buildOptions: esbuild.BuildOptions = isProduction() ? { minify: true } : { minifyIdentifiers: false, minifySyntax: true, minifyWhitespace: true, jsxDev: true, sourcemap: "linked", };
for (const routesUrl of routesUrls) { await buildRoutes(routesUrl); }
const esbuildPlugins = options.esbuildPlugins ?? []; await esbuild.build({ plugins: [ denoPlugin({ importMapURL }), ...esbuildPlugins, ], absWorkingDir: workingDirectory, entryPoints: [entryPoint], outdir, bundle: true, splitting: true, treeShaking: true, platform: "neutral", format: "esm", jsx: "automatic", jsxImportSource: "npm/react", ...buildOptions, }); esbuild.stop(); success = true; } catch { // Ignore error, esbuild already logs it } finally { performance.mark("buildEnd"); const measure = performance.measure("build", "buildStart", "buildEnd"); console.log( `Build ${success ? "completed" : "failed"} in ${ Math.round(measure.duration) } ms`, ); if (!success) Deno.exit(1); }
if (postBuild) await postBuild();
return success;}
if (import.meta.main) { const cwd = Deno.cwd(); const entryPoint = "app.tsx"; const publicUrl = path.join(cwd, "public"); const routesUrl = path.join(cwd, "routes"); const importMapUrl = path.join(cwd, "import_map.json");
const success = await build({ workingDirectory: cwd, entryPoint, publicUrl, routesUrls: [routesUrl], importMapUrl, }); if (!success) Deno.exit(1);}