2026-03-10 00:00:00 -07:00
|
|
|
/**
|
|
|
|
|
* App loader utility for CLI commands.
|
|
|
|
|
* Handles various app export patterns and module systems.
|
2026-05-21 20:39:36 -07:00
|
|
|
*
|
|
|
|
|
* 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
|
2026-03-10 00:00:00 -07:00
|
|
|
*/
|
2026-05-21 20:39:36 -07:00
|
|
|
import { existsSync } from 'node:fs'
|
|
|
|
|
import { resolve, extname } from 'node:path'
|
2026-03-10 00:00:00 -07:00
|
|
|
import { pathToFileURL } from 'node:url'
|
|
|
|
|
|
2026-05-21 20:39:36 -07:00
|
|
|
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',
|
|
|
|
|
]
|
|
|
|
|
|
2026-03-10 00:00:00 -07:00
|
|
|
export interface LoadedApp {
|
|
|
|
|
fastify: unknown
|
|
|
|
|
source: 'default' | 'named' | 'commonjs'
|
2026-05-21 20:39:36 -07:00
|
|
|
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
|
2026-03-10 00:00:00 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
2026-05-21 20:39:36 -07:00
|
|
|
* Load a Fastify app from the given directory.
|
|
|
|
|
*
|
|
|
|
|
* Auto-detects the entrypoint by searching APP_CANDIDATES for the first
|
|
|
|
|
* existing file. Supports:
|
2026-03-10 00:00:00 -07:00
|
|
|
* - ESM default export: export default fastifyInstance
|
|
|
|
|
* - ESM named export: export const createApp = () => fastifyInstance
|
|
|
|
|
* - CommonJS: module.exports = fastifyInstance
|
|
|
|
|
* - CommonJS named: exports.createApp = () => fastifyInstance
|
2026-05-21 20:39:36 -07:00
|
|
|
*
|
|
|
|
|
* 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.
|
2026-03-10 00:00:00 -07:00
|
|
|
*/
|
2026-05-21 20:39:36 -07:00
|
|
|
export async function loadApp(cwd: string, entrypoint?: string): Promise<LoadedApp> {
|
|
|
|
|
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()
|
2026-03-10 00:00:00 -07:00
|
|
|
|
|
|
|
|
let appModule: Record<string, unknown>
|
|
|
|
|
try {
|
|
|
|
|
appModule = await import(appUrl) as Record<string, unknown>
|
|
|
|
|
} catch (err) {
|
2026-05-21 20:39:36 -07:00
|
|
|
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',
|
|
|
|
|
)
|
|
|
|
|
}
|
2026-03-10 00:00:00 -07:00
|
|
|
throw new AppLoadError(
|
2026-05-21 20:39:36 -07:00
|
|
|
`Cannot load ${resolvedEntrypoint}: ${errMessage}`,
|
2026-03-10 00:00:00 -07:00
|
|
|
'import_failed',
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-21 20:39:36 -07:00
|
|
|
let fastify: unknown
|
|
|
|
|
let source: 'default' | 'named' | 'commonjs' = 'default'
|
2026-03-10 00:00:00 -07:00
|
|
|
|
2026-05-21 20:39:36 -07:00
|
|
|
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
|
2026-03-10 00:00:00 -07:00
|
|
|
|
2026-05-21 20:39:36 -07:00
|
|
|
if (isFastifyInstance(value)) {
|
|
|
|
|
fastify = value
|
|
|
|
|
source = 'named'
|
|
|
|
|
found = true
|
|
|
|
|
break
|
|
|
|
|
}
|
2026-03-10 00:00:00 -07:00
|
|
|
|
2026-05-21 20:39:36 -07:00
|
|
|
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
|
2026-03-10 00:00:00 -07:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-05-21 20:39:36 -07:00
|
|
|
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',
|
|
|
|
|
)
|
|
|
|
|
}
|
2026-03-10 00:00:00 -07:00
|
|
|
}
|
|
|
|
|
|
2026-05-21 20:39:36 -07:00
|
|
|
await ensureRouteDiscovery(fastify as Record<string, unknown>)
|
2026-03-10 00:00:00 -07:00
|
|
|
|
2026-05-21 20:39:36 -07:00
|
|
|
return { fastify, source, entrypoint: resolvedEntrypoint }
|
2026-03-10 00:00:00 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
2026-05-21 20:39:36 -07:00
|
|
|
* 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.
|
2026-03-10 00:00:00 -07:00
|
|
|
*/
|
2026-05-21 20:39:36 -07:00
|
|
|
async function ensureRouteDiscovery(f: Record<string, unknown>): Promise<void> {
|
|
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-10 00:00:00 -07:00
|
|
|
function isFastifyInstance(value: unknown): boolean {
|
2026-05-21 20:39:36 -07:00
|
|
|
if (value === null || typeof value !== 'object') return false
|
|
|
|
|
const obj = value as Record<string, unknown>
|
|
|
|
|
return typeof obj.ready === 'function'
|
2026-03-10 00:00:00 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function isClass(fn: unknown): boolean {
|
|
|
|
|
return typeof fn === 'function' &&
|
|
|
|
|
fn.toString().startsWith('class ')
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export class AppLoadError extends Error {
|
|
|
|
|
constructor(
|
|
|
|
|
message: string,
|
2026-05-21 20:39:36 -07:00
|
|
|
public readonly code: 'import_failed' | 'no_fastify' | 'not_found',
|
2026-03-10 00:00:00 -07:00
|
|
|
) {
|
|
|
|
|
super(message)
|
|
|
|
|
this.name = 'AppLoadError'
|
|
|
|
|
}
|
|
|
|
|
}
|