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.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613import { 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";
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;}
const ROUTE_PARAM = /^\[(.+)]$/;const ROUTE_WILDCARD = /^\[...\]$/;function routePathFromName(name: string, forServer = false) { if (!name) return "/"; return name .replace(ROUTE_WILDCARD, forServer ? "(.*)" : "*") .replace(ROUTE_PARAM, ":$1");}function routerPathFromName(name: string) { return routePathFromName(name, true);}
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: "${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) }";`, `const $${routeId} = withAppErrorBoundary($${ routeId - 1 }.default, { FallbackComponent: ($${ routeId - 1 } as RouteFile).ErrorFallback ?? DefaultErrorFallback });`, ); } 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 if (relativePath === "") { childRouteTexts.push(`{ path: "*", element: <NotFound /> }`); }
if (childRouteTexts.length) { routeText += `, children: [${childRouteTexts.join(", ")}]`; } 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( parentRouteId: number, routeId: number, relativePath: string, route: Route,) { const importLines: string[] = []; const routerLines: string[] = [];
const { name, file, main, index, react, children, parent } = route; if (file) { if (file.react) { importLines.push( ...routeImportLines( routeId, path.posix.join(relativePath, routeId > 0 ? "../" : "", file.react), ), ); routerLines.push( `if (($${routeId} as RouteFile).ErrorFallback) {`, ` $${parentRouteId}.use("/${ routerPathFromName(name) }", errorBoundary("/${relativePath}"));`, `}`, ); routeId++; }
if (file.oak) { importLines.push( routerImportLine( routeId, path.posix.join(relativePath, routeId > 0 ? "../" : "", file.oak), ), ); if (relativePath !== "") { routerLines.push( `$${parentRouteId}.use("/${ routerPathFromName(name) }", $${routeId}.routes(), $${routeId}.allowedMethods());`, ); } routeId++; } else if (file.react) { routerLines.push( `$${parentRouteId}.use("/${ routerPathFromName(name) }", defaultRouter.routes(), defaultRouter.allowedMethods())`, ); } } else { const mainRouteId = routeId++; if (main) { if (main.oak) { importLines.push( routerImportLine( mainRouteId, path.posix.join(relativePath, main.oak), ), ); } else { routerLines.push(`const $${mainRouteId} = new Router();`); }
if (main.react) { importLines.push( ...routeImportLines( routeId, path.posix.join(relativePath, main.react), ), ); routerLines.push( `if (($${routeId} as RouteFile).ErrorFallback) {`, ` $${mainRouteId}.use(errorBoundary(${ relativePath ? `"/${relativePath}"` : "" }));`, `}`, ); routeId++; } } else { routerLines.push(`const $${mainRouteId} = new Router();`); if (!relativePath && react) { routerLines.push(`$${mainRouteId}.use(errorBoundary());`); } }
if (index) { if (index.react) { importLines.push( ...routeImportLines( routeId, path.posix.join(relativePath, index.react), ), ); routerLines.push( `if (($${routeId} as RouteFile).ErrorFallback) {`, ` $${mainRouteId}.use("/", errorBoundary("/${ path.join(relativePath, "index") }"));`, `}`, ); routeId++; }
if (index.oak) { importLines.push( routerImportLine( routeId, path.posix.join(relativePath, index.oak), ), );
routerLines.push( `$${mainRouteId}.use("/", $${routeId}.routes(), $${routeId}.allowedMethods());`, ); routeId++; } else if (react) { routerLines.push( `$${mainRouteId}.use("/", defaultRouter.routes(), defaultRouter.allowedMethods())`, ); } }
let notFoundRoute: Route | undefined = undefined; for (const childRoute of Object.values(children ?? {})) { if (childRoute.name === "[...]") { notFoundRoute = childRoute; continue; } const { importLines: childImportLines, routerLines: childRouterLines, nextRouteId, } = routerFileData( mainRouteId, routeId, path.posix.join(relativePath, childRoute.name), childRoute, ); importLines.push(...childImportLines); routerLines.push(...childRouterLines); routeId = nextRouteId; } notFoundRoute;
if (notFoundRoute) { const { importLines: childImportLines, routerLines: childRouterLines, nextRouteId, } = routerFileData( mainRouteId, routeId, path.posix.join(relativePath, notFoundRoute.name), notFoundRoute, ); importLines.push(...childImportLines); routerLines.push(...childRouterLines); routeId = nextRouteId; }
if (relativePath === "") { routerLines.push("", `export default $0;`); } else { routerLines.push( `const $${mainRouteId}Main = ${ parent?.react && !react ? `createApiRouter($${mainRouteId})` : `$${mainRouteId}` };`, ); routerLines.push( `$${parentRouteId}.use("/${ routerPathFromName(name) }", $${mainRouteId}Main.routes(), $${mainRouteId}Main.allowedMethods());`, ); } }
return { importLines, routerLines, nextRouteId: routeId, };}
async function writeRoutes(path: string, text: string) { const fmt = Deno.run({ cmd: ["deno", "fmt", "-"], stdin: "piped", stdout: "piped", }); const encoder = new TextEncoder(); await fmt.stdin.write(encoder.encode(text)); fmt.stdin.close(); const [status, rawOutput] = await Promise.all([fmt.status(), fmt.output()]); if (status.success) { Deno.writeFile(path, rawOutput); } else { console.log("fmt routes failed", { path, ...status }); }}
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 { Router } from "x/oak/mod.ts";`, `import { defaultRouter, createApiRouter, errorBoundary } from "x/udibo_react_app/server.tsx";`, `import { RouteFile } from "x/udibo_react_app/mod.tsx";`, "", ]; const { importLines, routerLines } = routerFileData(-1, 0, "", rootRoute); lines.push(...importLines, "", ...routerLines);
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);}