///
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);
});