/// import fs from "node:fs"; import path from "node:path"; import { getInputTypeFromFile, getSyncClientDataType, API_VERSION_TOKEN_REGEX, SYNC_VERSION_TOKEN_REGEX, assertNoDuplicateNormalizedRouteKeys, assertValidRouteNaming, collectFunctionModules, renderFunctionsMap, } from '@luckystack/devkit'; import { ROOT_DIR, getSrcDir, getServerDir } from '@luckystack/core'; import { assertRouteServicesDeclared, extractServiceFromPath, type RouteServicesConfig } from './routeServices'; const normalizePath = (p: string) => p.split(path.sep).join("/"); const mapApiPagePath = (pagePath?: string): string => { return pagePath && pagePath.length > 0 ? pagePath : 'system'; }; // Recursively walk dirs to collect _api and _sync files const walkSrcFiles = (dir: string, results: string[] = []) => { const list = fs.readdirSync(dir); for (const file of list) { const fullPath = path.join(dir, file); const stat = fs.statSync(fullPath); if (stat.isDirectory()) { //? `src/playground/**` is a DEV-ONLY fixture tree (unauthenticated demo + //? resource-amplifying stream endpoints). Never bake it into the PRODUCTION //? route maps — dev still serves it via the live devkit loader. Keeping it //? out here is the structural gate so a forgotten manual deletion can't ship //? the playground to prod. if (file === 'playground') continue; walkSrcFiles(fullPath, results); } else if (file.endsWith(".ts") && !file.endsWith(".tests.ts") && (fullPath.includes("_api") || fullPath.includes("_sync"))) { results.push(fullPath); } } return results; }; // -------------------- // Preset plan // -------------------- //? WITH the router topology — `services.config.ts` + `deploy.config.ts` + //? `server/config/presetLoader.ts`, all added by `npx luckystack add router` — we //? emit one bundle per preset, each filtered to that preset's services. //? //? WITHOUT the router (a base install) none of those files exist. We then emit a //? SINGLE bundle named `default` containing every service — matching the runtime //? fallback in `@luckystack/server` (`resolvePresets()` falls back to `['default']` //? when no preset is requested). So a bare `npm run server` in prod loads //? `generatedApis.default.ts`. interface PresetPlan { /** Preset names to emit a bundle for. */ presets: string[]; /** Services allowed in a preset, or `null` to include EVERY service (base install). */ servicesForPreset: (preset: string) => string[] | null; /** Declared services + presets, or `null` in a base install (nothing is filtered out). */ servicesConfig: RouteServicesConfig | null; } const DEFAULT_PRESET_NAME = 'default'; const loadPresetPlan = async (): Promise => { const servicesConfigPath = path.join(process.cwd(), 'services.config.ts'); const presetLoaderPath = path.join(process.cwd(), 'server', 'config', 'presetLoader.ts'); const hasRouterConfig = fs.existsSync(servicesConfigPath) && fs.existsSync(presetLoaderPath); if (!hasRouterConfig) { return { presets: [DEFAULT_PRESET_NAME], servicesForPreset: () => null, servicesConfig: null }; } //? Non-literal specifier so `tsc` doesn't try to resolve `presetLoader` (which //? is absent in a base, no-router install). At runtime tsx resolves it fine //? because the file exists in this branch. const loaderSpecifier = ['..', 'server', 'config', 'presetLoader'].join('/'); const loader = (await import(loaderSpecifier)) as { loadBuildConfig: () => { services: RouteServicesConfig }; validatePresetsAndServices: (config: unknown) => void; resolveRequestedPresets: (requested: string[], config: unknown) => string[]; getServicesForPreset: (presetName: string, config: unknown) => string[]; }; const buildConfig = loader.loadBuildConfig(); loader.validatePresetsAndServices(buildConfig); const requestedArgs = process.argv.slice(2); const presets = loader.resolveRequestedPresets(requestedArgs, buildConfig); return { presets, servicesForPreset: (presetName: string) => loader.getServicesForPreset(presetName, buildConfig), servicesConfig: buildConfig.services, }; }; // -------------------- // Build // -------------------- const run = async () => { const plan = await loadPresetPlan(); const srcDir = getSrcDir(); assertValidRouteNaming({ srcDir, context: 'generating server request maps for build', }); assertNoDuplicateNormalizedRouteKeys({ srcDir, context: 'generating server request maps for build', }); const rawSrcFiles = walkSrcFiles(srcDir).map(normalizePath).sort(); //? Before filtering per preset: a route whose service no preset takes would //? otherwise be skipped silently and exist only in dev. if (plan.servicesConfig) { assertRouteServicesDeclared( rawSrcFiles.map((file) => normalizePath(path.relative(ROOT_DIR, file))), plan.servicesConfig, ); } // Collect functions from every configured `paths.serverFunctionDirs` root // (default `['functions', 'shared']`), nested by directory — the SAME discovery // the dev loader and the type-map generator use. Phase 1: the full registry is // emitted into every preset, no pruning. Cross-root and module/namespace key // collisions throw here, failing the build instead of shipping a map with a // silently missing entry (ADR 0046). const functionModules = collectFunctionModules(); // -------------------- // Iterate over each preset and build maps // -------------------- for (const presetName of plan.presets) { //? `null` = base install → include EVERY service (no preset filtering). const allowedServices = plan.servicesForPreset(presetName); const apiImports: string[] = []; const syncImports: string[] = []; //? Generated route maps use `unknown` because each consumer's API/sync //? handler signature is unique — the framework dispatcher narrows at the //? call site after auth + input validation. `auth` is loosely typed because //? consumers can extend `AuthProps` via module augmentation. let apiMap = "export const apis: Record, main: (...args: unknown[]) => unknown, rateLimit?: number | false, httpMethod?: 'GET' | 'POST' | 'PUT' | 'DELETE', inputType?: string, inputTypeFilePath?: string, validation?: 'strict' | 'relaxed' | { input: 'skip' | 'strict' }, errorFormatter?: (...args: unknown[]) => unknown }> = {\n"; let syncMap = "export const syncs: Record unknown, auth: Record, inputType?: string, inputTypeFilePath?: string, validation?: 'strict' | 'relaxed' | { input: 'skip' | 'strict' }, errorFormatter?: (...args: unknown[]) => unknown }> = {\n"; let apiCount = 0; let syncCount = 0; // Process API + Sync rawSrcFiles.forEach((normalized) => { const workspaceRelativePath = normalizePath(path.relative(ROOT_DIR, normalized)); const serviceName = extractServiceFromPath(workspaceRelativePath); if (allowedServices !== null && !allowedServices.includes(serviceName)) return; const importPath = "../../" + workspaceRelativePath.replace(/\.ts$/, ""); // API if (normalized.includes("_api/")) { const varName = `api${apiCount++}`; apiImports.push(`import * as ${varName} from '${importPath}';`); const match = normalized.match(/src\/(?:(.+?)\/)?_api\/(.+)\.ts$/i); if (!match) return; const [_, pagePath, apiNameWithVersion] = match; const versionMatch = apiNameWithVersion.match(API_VERSION_TOKEN_REGEX); if (!versionMatch) return; const version = `v${versionMatch[1]}`; const apiName = apiNameWithVersion.replace(API_VERSION_TOKEN_REGEX, ''); const routeKey = `api/${mapApiPagePath(pagePath)}/${apiName}/${version}`; apiMap += ` "${routeKey}": (() => {\n const mod = ${varName} as Record;\n return {\n auth: ("auth" in mod ? mod.auth : {}) as Record,\n main: mod.main as (...args: unknown[]) => unknown,\n rateLimit: mod.rateLimit as number | false | undefined,\n httpMethod: mod.httpMethod as 'GET' | 'POST' | 'PUT' | 'DELETE' | undefined,\n inputType: ${JSON.stringify(getInputTypeFromFile(normalized))},\n inputTypeFilePath: ${JSON.stringify(workspaceRelativePath)},\n validation: mod.validation as 'strict' | 'relaxed' | { input: 'skip' | 'strict' } | undefined,\n errorFormatter: mod.errorFormatter as ((...args: unknown[]) => unknown) | undefined,\n };\n })(),\n`; } // Sync if (normalized.includes("_sync/")) { const match = normalized.match(/src\/(?:(.+?)\/)?_sync\/(.+)\.ts$/i); if (!match) return; const [_, pagePath, syncNameWithVersion] = match; const syncMatch = syncNameWithVersion.match(SYNC_VERSION_TOKEN_REGEX); if (!syncMatch) return; const kind = syncMatch[1]; const version = `v${syncMatch[2]}`; const syncName = syncNameWithVersion.replace(SYNC_VERSION_TOKEN_REGEX, ''); const routeKey = pagePath ? `sync/${pagePath}/${syncName}/${version}` : `sync/${syncName}/${version}`; if (kind === 'client') { const varName = `syncClient${syncCount++}`; syncImports.push(`import * as ${varName} from '${importPath}';`); syncMap += ` "${routeKey}_client": ${varName}.main,\n`; } if (kind === 'server') { const varName = `syncServer${syncCount++}`; syncImports.push(`import * as ${varName} from '${importPath}';`); const inputType = getSyncClientDataType(normalized); syncMap += ` "${routeKey}_server": (() => {\n const mod = ${varName} as Record;\n return {\n auth: (("auth" in mod ? mod.auth : {}) as Record),\n main: mod.main as (...args: unknown[]) => unknown,\n inputType: ${JSON.stringify(inputType)},\n inputTypeFilePath: ${JSON.stringify(workspaceRelativePath)},\n validation: mod.validation as 'strict' | 'relaxed' | { input: 'skip' | 'strict' } | undefined,\n errorFormatter: mod.errorFormatter as ((...args: unknown[]) => unknown) | undefined,\n };\n })(),\n`; } } }); // Process Functions (Phase 1: all functions are included regardless of service) const { imports: functionImports, source: functionsMap } = renderFunctionsMap({ modules: functionModules, importPrefix: '../../', }); apiMap += "};\n"; syncMap += "};\n"; const importStatements = [ ...apiImports, "", ...syncImports, "", ...functionImports, ].join("\n"); const output = `${importStatements}\n\n${apiMap}\n${syncMap}\n${functionsMap}`; const outFileName = `generatedApis.${presetName}.ts`; const outDir = path.join(getServerDir(), 'prod'); fs.mkdirSync(outDir, { recursive: true }); fs.writeFileSync(path.join(outDir, outFileName), output); console.log(`✅ server/prod/${outFileName} created for preset '${presetName}'`); } }; run() .then(() => { // Explicit exit: loading `@luckystack/{core,devkit}` transitively connects to // Redis on import. Without an explicit exit the dangling ioredis handle keeps // the event loop alive and the script hangs. process.exit(0); }) .catch((err) => { console.error(err); process.exit(1); });