/** * Router Discovery * * Core discovery logic: imports the user's entry file via the RSC * environment's module runner, generates manifests for each discovered * router, and builds route tries for O(path_length) matching. */ import { buildCombinedRouteMapForRouterFile, formatNestedRouterConflictError, findNestedRouterConflict, } from "../../build/generate-route-types.js"; import { flattenLeafEntries, buildRouteToStaticPrefix, } from "../utils/manifest-utils.js"; import type { DiscoveryState, PrecomputedEntry } from "./state.js"; import { expandPrerenderRoutes, renderStaticHandlers, } from "./prerender-collection.js"; import { resolveHostRouterHandlers, DiscoveryError, type CaughtDiscoveryError, } from "./discovery-errors.js"; import { createRangoDebugger, timed, NS } from "../debug.js"; const debug = createRangoDebugger(NS.discovery); /** * Import the user's entry via RSC runner, generate manifests for each * discovered router, build route tries, and optionally run prerender * expansion and static handler rendering (build mode only). * * Returns the imported `@rangojs/router/server` module so the caller * can access the RouterRegistry and manifest setters. */ export async function discoverRouters( state: DiscoveryState, rscEnv: any, ): Promise { if (!state.resolvedEntryPath) return; // Import the entry file via RSC environment. // For node preset: this is the router file (createRouter() registers in RouterRegistry). // For cloudflare preset: this is the worker entry (which imports the router). await timed(debug, "inner: import entry", () => rscEnv.runner.import(state.resolvedEntryPath), ); // Import the router package to access the registry const serverMod = await timed( debug, "inner: import @rangojs/router/server", () => rscEnv.runner.import("@rangojs/router/server"), ); let registry: Map = serverMod.RouterRegistry; if (!registry || registry.size === 0) { // No RSC routers found directly. Check for host routers with lazy handlers // that need to be resolved to trigger sub-app createRouter() calls. // // Handler failures are collected rather than swallowed: when the registry // is still empty afterwards, these errors (typically a sub-app whose router // module failed to import) are the most likely cause and are surfaced in // the terminal "No routers found" error below. const discoveryErrors: CaughtDiscoveryError[] = []; try { const hostRegistry: Map | undefined = serverMod.HostRouterRegistry; if (hostRegistry && hostRegistry.size > 0) { console.log( `[rango] Found ${hostRegistry.size} host router(s), resolving lazy handlers...`, ); const handlerErrors = await resolveHostRouterHandlers(hostRegistry); discoveryErrors.push(...handlerErrors); for (const { context, error } of handlerErrors) { debug?.("caught error while resolving %s: %O", context, error); } // Re-read RouterRegistry - sub-app createRouter() calls should have populated it const freshServerMod = await rscEnv.runner.import( "@rangojs/router/server", ); const freshRegistry: Map = freshServerMod.RouterRegistry; if (freshRegistry && freshRegistry.size > 0) { // Update references so the manifest generation below uses the fresh data Object.assign(serverMod, freshServerMod); registry = freshRegistry; } } } catch (error) { // Host-router discovery is best-effort; record the failure so it can be // surfaced if no routers are found. discoveryErrors.push({ context: "host-router discovery", error }); } // If still no routers after host router resolution, fail if (!registry || registry.size === 0) { throw new DiscoveryError(state.resolvedEntryPath, discoveryErrors); } } // Import build utilities for manifest generation const buildMod = await timed( debug, "inner: import @rangojs/router/build", () => rscEnv.runner.import("@rangojs/router/build"), ); const generateManifestFull = buildMod.generateManifestFull; debug?.("inner: found %d router(s) in registry", registry.size); const nestedRouterConflict = findNestedRouterConflict( [...registry.values()] .map((router) => router.__sourceFile) .filter( (sourceFile): sourceFile is string => typeof sourceFile === "string", ), ); if (nestedRouterConflict) { throw new Error(formatNestedRouterConflictError(nestedRouterConflict)); } // Build into local variables first. Only commit to state after the // full pass succeeds, so a failed re-discovery preserves the last // known-good state instead of leaving it partially wiped. const newMergedRouteManifest: Record = {}; const newMergedPrecomputedEntries: PrecomputedEntry[] = []; const newPerRouterManifests: typeof state.perRouterManifests = []; const newPerRouterManifestDataMap = new Map(); const newPerRouterPrecomputedMap = new Map(); const newPerRouterTrieMap = new Map(); let mergedRouteAncestry: Record = {}; let mergedRouteTrailingSlash: Record = {}; let routerMountIndex = 0; // Collect all manifests for trie building (avoid re-running generateManifest) const allManifests: Array<{ id: string; manifest: any }> = []; const manifestGenStart = debug ? performance.now() : 0; for (const [id, router] of registry) { if (!router.urlpatterns || !generateManifestFull) { continue; } const manifest = generateManifestFull( router.urlpatterns, routerMountIndex, router.__basename ? { urlPrefix: router.__basename } : undefined, ); routerMountIndex++; allManifests.push({ id, manifest }); const routeCount = Object.keys(manifest.routeManifest).length; const staticRoutes = Object.values(manifest.routeManifest).filter( (p: any) => !p.includes(":") && !p.includes("*"), ).length; const dynamicRoutes = routeCount - staticRoutes; // Merge into the combined manifest Object.assign(newMergedRouteManifest, manifest.routeManifest); // Compute factory-only prefixes: dot-prefixed groups in the runtime // manifest that the static parser cannot see. These are routes created // by factory functions (e.g. createDocsPatterns()) and should always be // supplemented on file change since HMR won't re-discover them. let factoryOnlyPrefixes: Set | undefined; if (router.__sourceFile) { const staticParsed = buildCombinedRouteMapForRouterFile( router.__sourceFile, ); const staticNames = new Set(Object.keys(staticParsed.routes)); factoryOnlyPrefixes = new Set(); for (const name of Object.keys(manifest.routeManifest)) { if (staticNames.has(name)) continue; const dotIdx = name.indexOf("."); if (dotIdx <= 0) continue; const prefix = name.substring(0, dotIdx + 1); if ([...staticNames].some((n) => n.startsWith(prefix))) continue; factoryOnlyPrefixes.add(prefix); } if (factoryOnlyPrefixes.size === 0) factoryOnlyPrefixes = undefined; } newPerRouterManifests.push({ id, routeManifest: manifest.routeManifest, routeSearchSchemas: manifest.routeSearchSchemas, sourceFile: router.__sourceFile, factoryOnlyPrefixes, }); // Merge ancestry (internal field, used only for trie building) if (manifest._routeAncestry) { Object.assign(mergedRouteAncestry, manifest._routeAncestry); } // Merge trailing slash config if (manifest.routeTrailingSlash) { Object.assign(mergedRouteTrailingSlash, manifest.routeTrailingSlash); } // Flatten prefix tree leaf nodes into precomputed entries. // Leaf nodes (no children) can have their routes used directly by // evaluateLazyEntry() without running the handler at runtime. flattenLeafEntries( manifest.prefixTree, manifest.routeManifest, newMergedPrecomputedEntries, ); // Store per-router manifest and precomputed entries for isolated virtual modules. newPerRouterManifestDataMap.set(id, manifest.routeManifest); const routerPrecomputed: PrecomputedEntry[] = []; flattenLeafEntries( manifest.prefixTree, manifest.routeManifest, routerPrecomputed, ); newPerRouterPrecomputedMap.set(id, routerPrecomputed); console.log( `[rango] Router "${id}" -> ${routeCount} routes ` + `(${staticRoutes} static, ${dynamicRoutes} dynamic)`, ); } // Warn if multiple routers use auto-generated IDs (router_0, router_1, ...). // Auto-IDs are assigned by counter and depend on module evaluation order, // which can differ between build time and runtime (especially with dynamic // imports in host routers). This causes per-router data to be loaded into // the wrong router at runtime. if (registry.size > 1) { const autoIds = [...registry.keys()].filter((id) => /^router_\d+$/.test(id), ); if (autoIds.length > 1) { console.warn( `[rango] WARNING: ${autoIds.length} routers use auto-generated IDs (${autoIds.join(", ")}). ` + `In multi-router setups, each createRouter() must have an explicit \`id\` option ` + `to ensure per-router manifest data is matched correctly at runtime. ` + `Example: createRouter({ id: "site", ... })`, ); } } debug?.( "inner: generated manifests for %d router(s) (%sms)", allManifests.length, (performance.now() - manifestGenStart).toFixed(1), ); // Build route trie from merged manifest + ancestry let newMergedRouteTrie: any = null; const trieStart = debug ? performance.now() : 0; if (Object.keys(newMergedRouteManifest).length > 0) { const buildRouteTrie = buildMod.buildRouteTrie; if (buildRouteTrie && mergedRouteAncestry) { // Build routeToStaticPrefix from saved manifests const routeToStaticPrefix: Record = {}; for (const { manifest } of allManifests) { // Root-level routes have empty static prefix for (const name of Object.keys(manifest.routeManifest)) { if (!(name in routeToStaticPrefix)) { routeToStaticPrefix[name] = ""; } } buildRouteToStaticPrefix(manifest.prefixTree, routeToStaticPrefix); } // Collect prerender route names and response type routes from all manifests const prerenderRouteNames = new Set(); const passthroughRouteNames = new Set(); const mergedResponseTypeRoutes: Record = {}; for (const { manifest } of allManifests) { if (manifest.prerenderRoutes) { for (const name of manifest.prerenderRoutes) { prerenderRouteNames.add(name); } } if (manifest.passthroughRoutes) { for (const name of manifest.passthroughRoutes) { passthroughRouteNames.add(name); } } if (manifest.responseTypeRoutes) { Object.assign(mergedResponseTypeRoutes, manifest.responseTypeRoutes); } } // buildRouteTrie reads these via ?.has / ?.[] — empty is observationally // identical to undefined, so no empty->undefined coercion is needed. newMergedRouteTrie = buildRouteTrie( newMergedRouteManifest, mergedRouteAncestry, routeToStaticPrefix, mergedRouteTrailingSlash, prerenderRouteNames, passthroughRouteNames, mergedResponseTypeRoutes, ); // Build per-router tries for multi-router isolation. for (const { id, manifest } of allManifests) { if ( !manifest._routeAncestry || Object.keys(manifest._routeAncestry).length === 0 ) continue; const perRouterStaticPrefix: Record = {}; for (const name of Object.keys(manifest.routeManifest)) { perRouterStaticPrefix[name] = ""; } buildRouteToStaticPrefix(manifest.prefixTree, perRouterStaticPrefix); const perRouterPrerenderNames = manifest.prerenderRoutes ? new Set(manifest.prerenderRoutes) : undefined; const perRouterPassthroughNames = manifest.passthroughRoutes ? new Set(manifest.passthroughRoutes) : undefined; const perRouterTrie = buildRouteTrie( manifest.routeManifest, manifest._routeAncestry, perRouterStaticPrefix, manifest.routeTrailingSlash, perRouterPrerenderNames, perRouterPassthroughNames, manifest.responseTypeRoutes, ); newPerRouterTrieMap.set(id, perRouterTrie); } } } debug?.( "inner: trie build done (%sms)", (performance.now() - trieStart).toFixed(1), ); // Commit all local state to the shared discovery state atomically. // This ensures a failed re-discovery (e.g. from a transient module // evaluation error) preserves the last known-good state. state.mergedRouteManifest = newMergedRouteManifest; state.mergedPrecomputedEntries = newMergedPrecomputedEntries; state.perRouterManifests = newPerRouterManifests; state.perRouterManifestDataMap = newPerRouterManifestDataMap; state.perRouterPrecomputedMap = newPerRouterPrecomputedMap; state.perRouterTrieMap = newPerRouterTrieMap; state.mergedRouteTrie = newMergedRouteTrie; // Expand prerender routes and render static handlers (build mode only) await expandPrerenderRoutes(state, rscEnv, registry, allManifests); await renderStaticHandlers(state, rscEnv, registry); return serverMod; }