/** * App loader utility for CLI commands. * Handles various app export patterns and module systems. * * Supports auto-detection of entrypoint files: * app.js, app.ts, server.js, server.ts, index.js, index.ts, * src/app.js, src/app.ts, src/server.js, src/server.ts, * src/index.js, src/index.ts */ import { existsSync } from 'node:fs' import { resolve, extname } from 'node:path' import { pathToFileURL } from 'node:url' function isTsxAvailable(): boolean { return process.execArgv.some(arg => arg.includes('tsx') || arg.includes('ts-node')) || process.argv[0]?.includes('tsx') || !!process.env.TSX_TSCONFIG_PATH } export const APP_CANDIDATES = [ 'app.js', 'app.ts', 'server.js', 'server.ts', 'index.js', 'index.ts', 'src/app.js', 'src/app.ts', 'src/server.js', 'src/server.ts', 'src/index.js', 'src/index.ts', ] export interface LoadedApp { fastify: unknown source: 'default' | 'named' | 'commonjs' entrypoint: string } /** * Find the first existing app entrypoint file in the given directory. * Returns the resolved absolute path, or null if no candidate exists. */ export function findAppFile(cwd: string): string | null { for (const candidate of APP_CANDIDATES) { const fullPath = resolve(cwd, candidate) if (existsSync(fullPath)) { return fullPath } } return null } /** * Load a Fastify app from the given directory. * * Auto-detects the entrypoint by searching APP_CANDIDATES for the first * existing file. Supports: * - ESM default export: export default fastifyInstance * - ESM named export: export const createApp = () => fastifyInstance * - CommonJS: module.exports = fastifyInstance * - CommonJS named: exports.createApp = () => fastifyInstance * * If an explicit entrypoint is provided, it takes precedence over auto-detection. * For .ts entrypoints, a clear error message suggests installing tsx. * * After loading the app, this function ensures route discovery works even if the * APOPHIS plugin is not registered or was registered after routes. */ export async function loadApp(cwd: string, entrypoint?: string): Promise { const resolvedEntrypoint = entrypoint ? resolve(cwd, entrypoint) : findAppFile(cwd) if (!resolvedEntrypoint) { throw new AppLoadError( `No Fastify app entrypoint found. Searched for: ${APP_CANDIDATES.join(', ')}.\n` + 'Create an app.js, server.js, or similar that exports a Fastify instance.', 'not_found', ) } const ext = extname(resolvedEntrypoint) const isTypeScript = ext === '.ts' if (isTypeScript && !isTsxAvailable()) { throw new AppLoadError( `TypeScript entrypoint ${resolvedEntrypoint} requires a TS loader.\n` + 'Install tsx and run:\n' + ' npm install -D tsx\n' + ' npx tsx ./node_modules/.bin/apophis verify\n\n' + 'Or convert your entrypoint to JavaScript.', 'import_failed', ) } const appUrl = pathToFileURL(resolvedEntrypoint).href + '?t=' + Date.now() let appModule: Record try { appModule = await import(appUrl) as Record } catch (err) { const errMessage = err instanceof Error ? err.message : String(err) if (isTypeScript) { throw new AppLoadError( `Cannot load TypeScript entrypoint ${resolvedEntrypoint}: ${errMessage}\n\n` + 'TypeScript entrypoints require tsx. Install tsx and run:\n' + ' npm install -D tsx\n' + ' npx tsx ./node_modules/.bin/apophis verify\n\n' + 'Or convert your entrypoint to JavaScript.', 'import_failed', ) } throw new AppLoadError( `Cannot load ${resolvedEntrypoint}: ${errMessage}`, 'import_failed', ) } let fastify: unknown let source: 'default' | 'named' | 'commonjs' = 'default' if (appModule.default && isFastifyInstance(appModule.default)) { fastify = appModule.default source = 'default' } else { let found = false for (const [key, value] of Object.entries(appModule)) { if (key === 'default') continue if (isFastifyInstance(value)) { fastify = value source = 'named' found = true break } if (typeof value === 'function' && !isClass(value)) { try { const result = await value() if (isFastifyInstance(result)) { fastify = result source = 'named' found = true break } } catch { // Factory function failed, try next } } } if (!found && isFastifyInstance(appModule)) { fastify = appModule source = 'commonjs' found = true } if (!found) { throw new AppLoadError( `No Fastify instance found in ${resolvedEntrypoint}. ` + 'Ensure the file exports a Fastify instance or a factory function.\n\n' + 'Supported patterns:\n' + ' export default app\n' + ' export { app }\n' + ' export const createApp = () => app\n' + ' module.exports = app', 'no_fastify', ) } } await ensureRouteDiscovery(fastify as Record) return { fastify, source, entrypoint: resolvedEntrypoint } } /** * Install a lightweight onRoute hook for route discovery. * This runs before avvio's boot phase, so routes registered * inside deferred plugins will be captured even if apophis * is not registered first. */ async function ensureRouteDiscovery(f: Record): Promise { if (typeof f.addHook !== 'function') return try { const { installRouteDiscovery } = await import('../../infrastructure/discovery-hook.js') installRouteDiscovery(f as { addHook: Function }) } catch { // discovery module not importable, skip auto-capture } } function isFastifyInstance(value: unknown): boolean { if (value === null || typeof value !== 'object') return false const obj = value as Record return typeof obj.ready === 'function' } function isClass(fn: unknown): boolean { return typeof fn === 'function' && fn.toString().startsWith('class ') } export class AppLoadError extends Error { constructor( message: string, public readonly code: 'import_failed' | 'no_fastify' | 'not_found', ) { super(message) this.name = 'AppLoadError' } }