UNPKG

@spfn/core

Version:

SPFN Framework Core - File-based routing, transactions, repository pattern

1,461 lines (1,444 loc) 44.8 kB
import pino from 'pino'; import { readFileSync, existsSync, mkdirSync, accessSync, constants, writeFileSync, unlinkSync, createWriteStream, statSync, readdirSync, renameSync } from 'fs'; import { join, dirname, relative } from 'path'; import { readdir, stat } from 'fs/promises'; import { Value } from '@sinclair/typebox/value'; import { Hono } from 'hono'; import { Type } from '@sinclair/typebox'; var __defProp = Object.defineProperty; var __getOwnPropNames = Object.getOwnPropertyNames; var __esm = (fn, res) => function __init() { return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res; }; var __export = (target, all) => { for (var name in all) __defProp(target, name, { get: all[name], enumerable: true }); }; var PinoAdapter; var init_pino = __esm({ "src/logger/adapters/pino.ts"() { PinoAdapter = class _PinoAdapter { logger; constructor(config) { this.logger = pino({ level: config.level, // 기본 필드 base: config.module ? { module: config.module } : void 0 }); } child(module) { const childLogger = new _PinoAdapter({ level: this.logger.level, module }); childLogger.logger = this.logger.child({ module }); return childLogger; } debug(message, context) { this.logger.debug(context || {}, message); } info(message, context) { this.logger.info(context || {}, message); } warn(message, errorOrContext, context) { if (errorOrContext instanceof Error) { this.logger.warn({ err: errorOrContext, ...context }, message); } else { this.logger.warn(errorOrContext || {}, message); } } error(message, errorOrContext, context) { if (errorOrContext instanceof Error) { this.logger.error({ err: errorOrContext, ...context }, message); } else { this.logger.error(errorOrContext || {}, message); } } fatal(message, errorOrContext, context) { if (errorOrContext instanceof Error) { this.logger.fatal({ err: errorOrContext, ...context }, message); } else { this.logger.fatal(errorOrContext || {}, message); } } async close() { } }; } }); // src/logger/types.ts var LOG_LEVEL_PRIORITY; var init_types = __esm({ "src/logger/types.ts"() { LOG_LEVEL_PRIORITY = { debug: 0, info: 1, warn: 2, error: 3, fatal: 4 }; } }); // src/logger/formatters.ts function isSensitiveKey(key) { const lowerKey = key.toLowerCase(); return SENSITIVE_KEYS.some((sensitive) => lowerKey.includes(sensitive)); } function maskSensitiveData(data) { if (data === null || data === void 0) { return data; } if (Array.isArray(data)) { return data.map((item) => maskSensitiveData(item)); } if (typeof data === "object") { const masked = {}; for (const [key, value] of Object.entries(data)) { if (isSensitiveKey(key)) { masked[key] = MASKED_VALUE; } else if (typeof value === "object" && value !== null) { masked[key] = maskSensitiveData(value); } else { masked[key] = value; } } return masked; } return data; } function formatTimestamp(date) { return date.toISOString(); } function formatTimestampHuman(date) { const year = date.getFullYear(); const month = String(date.getMonth() + 1).padStart(2, "0"); const day = String(date.getDate()).padStart(2, "0"); const hours = String(date.getHours()).padStart(2, "0"); const minutes = String(date.getMinutes()).padStart(2, "0"); const seconds = String(date.getSeconds()).padStart(2, "0"); const ms = String(date.getMilliseconds()).padStart(3, "0"); return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}.${ms}`; } function formatError(error) { const lines = []; lines.push(`${error.name}: ${error.message}`); if (error.stack) { const stackLines = error.stack.split("\n").slice(1); lines.push(...stackLines); } return lines.join("\n"); } function formatConsole(metadata, colorize = true) { const parts = []; const timestamp = formatTimestampHuman(metadata.timestamp); if (colorize) { parts.push(`${COLORS.gray}[${timestamp}]${COLORS.reset}`); } else { parts.push(`[${timestamp}]`); } if (metadata.module) { if (colorize) { parts.push(`${COLORS.dim}[module=${metadata.module}]${COLORS.reset}`); } else { parts.push(`[module=${metadata.module}]`); } } if (metadata.context && Object.keys(metadata.context).length > 0) { Object.entries(metadata.context).forEach(([key, value]) => { const valueStr = typeof value === "string" ? value : String(value); if (colorize) { parts.push(`${COLORS.dim}[${key}=${valueStr}]${COLORS.reset}`); } else { parts.push(`[${key}=${valueStr}]`); } }); } const levelStr = metadata.level.toUpperCase(); if (colorize) { const color = COLORS[metadata.level]; parts.push(`${color}(${levelStr})${COLORS.reset}:`); } else { parts.push(`(${levelStr}):`); } if (colorize) { parts.push(`${COLORS.bright}${metadata.message}${COLORS.reset}`); } else { parts.push(metadata.message); } let output = parts.join(" "); if (metadata.error) { output += "\n" + formatError(metadata.error); } return output; } function formatJSON(metadata) { const obj = { timestamp: formatTimestamp(metadata.timestamp), level: metadata.level, message: metadata.message }; if (metadata.module) { obj.module = metadata.module; } if (metadata.context) { obj.context = metadata.context; } if (metadata.error) { obj.error = { name: metadata.error.name, message: metadata.error.message, stack: metadata.error.stack }; } return JSON.stringify(obj); } var SENSITIVE_KEYS, MASKED_VALUE, COLORS; var init_formatters = __esm({ "src/logger/formatters.ts"() { SENSITIVE_KEYS = [ "password", "passwd", "pwd", "secret", "token", "apikey", "api_key", "accesstoken", "access_token", "refreshtoken", "refresh_token", "authorization", "auth", "cookie", "session", "sessionid", "session_id", "privatekey", "private_key", "creditcard", "credit_card", "cardnumber", "card_number", "cvv", "ssn", "pin" ]; MASKED_VALUE = "***MASKED***"; COLORS = { reset: "\x1B[0m", bright: "\x1B[1m", dim: "\x1B[2m", // 로그 레벨 컬러 debug: "\x1B[36m", // cyan info: "\x1B[32m", // green warn: "\x1B[33m", // yellow error: "\x1B[31m", // red fatal: "\x1B[35m", // magenta // 추가 컬러 gray: "\x1B[90m" }; } }); // src/logger/logger.ts var Logger; var init_logger = __esm({ "src/logger/logger.ts"() { init_types(); init_formatters(); Logger = class _Logger { config; module; constructor(config) { this.config = config; this.module = config.module; } /** * Get current log level */ get level() { return this.config.level; } /** * Create child logger (per module) */ child(module) { return new _Logger({ ...this.config, module }); } /** * Debug log */ debug(message, context) { this.log("debug", message, void 0, context); } /** * Info log */ info(message, context) { this.log("info", message, void 0, context); } warn(message, errorOrContext, context) { if (errorOrContext instanceof Error) { this.log("warn", message, errorOrContext, context); } else { this.log("warn", message, void 0, errorOrContext); } } error(message, errorOrContext, context) { if (errorOrContext instanceof Error) { this.log("error", message, errorOrContext, context); } else { this.log("error", message, void 0, errorOrContext); } } fatal(message, errorOrContext, context) { if (errorOrContext instanceof Error) { this.log("fatal", message, errorOrContext, context); } else { this.log("fatal", message, void 0, errorOrContext); } } /** * Log processing (internal) */ log(level, message, error, context) { if (LOG_LEVEL_PRIORITY[level] < LOG_LEVEL_PRIORITY[this.config.level]) { return; } const metadata = { timestamp: /* @__PURE__ */ new Date(), level, message, module: this.module, error, // Mask sensitive information in context to prevent credential leaks context: context ? maskSensitiveData(context) : void 0 }; this.processTransports(metadata); } /** * Process Transports */ processTransports(metadata) { const promises = this.config.transports.filter((transport) => transport.enabled).map((transport) => this.safeTransportLog(transport, metadata)); Promise.all(promises).catch((error) => { const errorMessage = error instanceof Error ? error.message : String(error); process.stderr.write(`[Logger] Transport error: ${errorMessage} `); }); } /** * Transport log (error-safe) */ async safeTransportLog(transport, metadata) { try { await transport.log(metadata); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); process.stderr.write(`[Logger] Transport "${transport.name}" failed: ${errorMessage} `); } } /** * Close all Transports */ async close() { const closePromises = this.config.transports.filter((transport) => transport.close).map((transport) => transport.close()); await Promise.all(closePromises); } }; } }); // src/logger/transports/console.ts var ConsoleTransport; var init_console = __esm({ "src/logger/transports/console.ts"() { init_types(); init_formatters(); ConsoleTransport = class { name = "console"; level; enabled; colorize; constructor(config) { this.level = config.level; this.enabled = config.enabled; this.colorize = config.colorize ?? true; } async log(metadata) { if (!this.enabled) { return; } if (LOG_LEVEL_PRIORITY[metadata.level] < LOG_LEVEL_PRIORITY[this.level]) { return; } const message = formatConsole(metadata, this.colorize); if (metadata.level === "warn" || metadata.level === "error" || metadata.level === "fatal") { console.error(message); } else { console.log(message); } } }; } }); var FileTransport; var init_file = __esm({ "src/logger/transports/file.ts"() { init_types(); init_formatters(); FileTransport = class { name = "file"; level; enabled; logDir; maxFileSize; maxFiles; currentStream = null; currentFilename = null; constructor(config) { this.level = config.level; this.enabled = config.enabled; this.logDir = config.logDir; this.maxFileSize = config.maxFileSize ?? 10 * 1024 * 1024; this.maxFiles = config.maxFiles ?? 10; if (!existsSync(this.logDir)) { mkdirSync(this.logDir, { recursive: true }); } } async log(metadata) { if (!this.enabled) { return; } if (LOG_LEVEL_PRIORITY[metadata.level] < LOG_LEVEL_PRIORITY[this.level]) { return; } const message = formatJSON(metadata); const filename = this.getLogFilename(metadata.timestamp); if (this.currentFilename !== filename) { await this.rotateStream(filename); await this.cleanOldFiles(); } else if (this.currentFilename) { await this.checkAndRotateBySize(); } if (this.currentStream) { return new Promise((resolve, reject) => { this.currentStream.write(message + "\n", "utf-8", (error) => { if (error) { process.stderr.write(`[FileTransport] Failed to write log: ${error.message} `); reject(error); } else { resolve(); } }); }); } } /** * 스트림 교체 (날짜 변경 시) */ async rotateStream(filename) { if (this.currentStream) { await this.closeStream(); } const filepath = join(this.logDir, filename); this.currentStream = createWriteStream(filepath, { flags: "a", // append mode encoding: "utf-8" }); this.currentFilename = filename; this.currentStream.on("error", (error) => { process.stderr.write(`[FileTransport] Stream error: ${error.message} `); this.currentStream = null; this.currentFilename = null; }); } /** * 현재 스트림 닫기 */ async closeStream() { if (!this.currentStream) { return; } return new Promise((resolve, reject) => { this.currentStream.end((error) => { if (error) { reject(error); } else { this.currentStream = null; this.currentFilename = null; resolve(); } }); }); } /** * 파일 크기 체크 및 크기 기반 로테이션 */ async checkAndRotateBySize() { if (!this.currentFilename) { return; } const filepath = join(this.logDir, this.currentFilename); if (!existsSync(filepath)) { return; } try { const stats = statSync(filepath); if (stats.size >= this.maxFileSize) { await this.rotateBySize(); } } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); process.stderr.write(`[FileTransport] Failed to check file size: ${errorMessage} `); } } /** * 크기 기반 로테이션 수행 * 예: 2025-01-01.log -> 2025-01-01.1.log, 2025-01-01.1.log -> 2025-01-01.2.log */ async rotateBySize() { if (!this.currentFilename) { return; } await this.closeStream(); const baseName = this.currentFilename.replace(/\.log$/, ""); const files = readdirSync(this.logDir); const relatedFiles = files.filter((file) => file.startsWith(baseName) && file.endsWith(".log")).sort().reverse(); for (const file of relatedFiles) { const match = file.match(/\.(\d+)\.log$/); if (match) { const oldNum = parseInt(match[1], 10); const newNum = oldNum + 1; const oldPath = join(this.logDir, file); const newPath2 = join(this.logDir, `${baseName}.${newNum}.log`); try { renameSync(oldPath, newPath2); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); process.stderr.write(`[FileTransport] Failed to rotate file: ${errorMessage} `); } } } const currentPath = join(this.logDir, this.currentFilename); const newPath = join(this.logDir, `${baseName}.1.log`); try { if (existsSync(currentPath)) { renameSync(currentPath, newPath); } } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); process.stderr.write(`[FileTransport] Failed to rotate current file: ${errorMessage} `); } await this.rotateStream(this.currentFilename); } /** * 오래된 로그 파일 정리 * maxFiles 개수를 초과하는 로그 파일 삭제 */ async cleanOldFiles() { try { if (!existsSync(this.logDir)) { return; } const files = readdirSync(this.logDir); const logFiles = files.filter((file) => file.endsWith(".log")).map((file) => { const filepath = join(this.logDir, file); const stats = statSync(filepath); return { file, mtime: stats.mtime }; }).sort((a, b) => b.mtime.getTime() - a.mtime.getTime()); if (logFiles.length > this.maxFiles) { const filesToDelete = logFiles.slice(this.maxFiles); for (const { file } of filesToDelete) { const filepath = join(this.logDir, file); try { unlinkSync(filepath); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); process.stderr.write(`[FileTransport] Failed to delete old file "${file}": ${errorMessage} `); } } } } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); process.stderr.write(`[FileTransport] Failed to clean old files: ${errorMessage} `); } } /** * 날짜별 로그 파일명 생성 */ getLogFilename(date) { const year = date.getFullYear(); const month = String(date.getMonth() + 1).padStart(2, "0"); const day = String(date.getDate()).padStart(2, "0"); return `${year}-${month}-${day}.log`; } async close() { await this.closeStream(); } }; } }); function isFileLoggingEnabled() { return process.env.LOGGER_FILE_ENABLED === "true"; } function getDefaultLogLevel() { const isProduction = process.env.NODE_ENV === "production"; const isDevelopment = process.env.NODE_ENV === "development"; if (isDevelopment) { return "debug"; } if (isProduction) { return "info"; } return "warn"; } function getConsoleConfig() { const isProduction = process.env.NODE_ENV === "production"; return { level: "debug", enabled: true, colorize: !isProduction // Dev: colored output, Production: plain text }; } function getFileConfig() { const isProduction = process.env.NODE_ENV === "production"; return { level: "info", enabled: isProduction, // File logging in production only logDir: process.env.LOG_DIR || "./logs", maxFileSize: 10 * 1024 * 1024, // 10MB maxFiles: 10 }; } function validateDirectoryWritable(dirPath) { if (!existsSync(dirPath)) { try { mkdirSync(dirPath, { recursive: true }); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); throw new Error(`Failed to create log directory "${dirPath}": ${errorMessage}`); } } try { accessSync(dirPath, constants.W_OK); } catch { throw new Error(`Log directory "${dirPath}" is not writable. Please check permissions.`); } const testFile = join(dirPath, ".logger-write-test"); try { writeFileSync(testFile, "test", "utf-8"); unlinkSync(testFile); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); throw new Error(`Cannot write to log directory "${dirPath}": ${errorMessage}`); } } function validateFileConfig() { if (!isFileLoggingEnabled()) { return; } const logDir = process.env.LOG_DIR; if (!logDir) { throw new Error( "LOG_DIR environment variable is required when LOGGER_FILE_ENABLED=true. Example: LOG_DIR=/var/log/myapp" ); } validateDirectoryWritable(logDir); } function validateSlackConfig() { const webhookUrl = process.env.SLACK_WEBHOOK_URL; if (!webhookUrl) { return; } if (!webhookUrl.startsWith("https://hooks.slack.com/")) { throw new Error( `Invalid SLACK_WEBHOOK_URL: "${webhookUrl}". Slack webhook URLs must start with "https://hooks.slack.com/"` ); } } function validateEmailConfig() { const smtpHost = process.env.SMTP_HOST; const smtpPort = process.env.SMTP_PORT; const emailFrom = process.env.EMAIL_FROM; const emailTo = process.env.EMAIL_TO; const hasAnyEmailConfig = smtpHost || smtpPort || emailFrom || emailTo; if (!hasAnyEmailConfig) { return; } const missingFields = []; if (!smtpHost) missingFields.push("SMTP_HOST"); if (!smtpPort) missingFields.push("SMTP_PORT"); if (!emailFrom) missingFields.push("EMAIL_FROM"); if (!emailTo) missingFields.push("EMAIL_TO"); if (missingFields.length > 0) { throw new Error( `Email transport configuration incomplete. Missing: ${missingFields.join(", ")}. Either set all required fields or remove all email configuration.` ); } const port = parseInt(smtpPort, 10); if (isNaN(port) || port < 1 || port > 65535) { throw new Error( `Invalid SMTP_PORT: "${smtpPort}". Must be a number between 1 and 65535.` ); } const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; if (!emailRegex.test(emailFrom)) { throw new Error(`Invalid EMAIL_FROM format: "${emailFrom}"`); } const recipients = emailTo.split(",").map((e) => e.trim()); for (const email of recipients) { if (!emailRegex.test(email)) { throw new Error(`Invalid email address in EMAIL_TO: "${email}"`); } } } function validateEnvironment() { const nodeEnv = process.env.NODE_ENV; if (!nodeEnv) { process.stderr.write( "[Logger] Warning: NODE_ENV is not set. Defaulting to test environment.\n" ); } } function validateConfig() { try { validateEnvironment(); validateFileConfig(); validateSlackConfig(); validateEmailConfig(); } catch (error) { if (error instanceof Error) { throw new Error(`[Logger] Configuration validation failed: ${error.message}`); } throw error; } } var init_config = __esm({ "src/logger/config.ts"() { } }); // src/logger/adapters/custom.ts function initializeTransports() { const transports = []; const consoleConfig = getConsoleConfig(); transports.push(new ConsoleTransport(consoleConfig)); const fileConfig = getFileConfig(); if (fileConfig.enabled) { transports.push(new FileTransport(fileConfig)); } return transports; } var CustomAdapter; var init_custom = __esm({ "src/logger/adapters/custom.ts"() { init_logger(); init_console(); init_file(); init_config(); CustomAdapter = class _CustomAdapter { logger; constructor(config) { this.logger = new Logger({ level: config.level, module: config.module, transports: initializeTransports() }); } child(module) { const adapter = new _CustomAdapter({ level: this.logger.level, module }); adapter.logger = this.logger.child(module); return adapter; } debug(message, context) { this.logger.debug(message, context); } info(message, context) { this.logger.info(message, context); } warn(message, errorOrContext, context) { if (errorOrContext instanceof Error) { this.logger.warn(message, errorOrContext, context); } else { this.logger.warn(message, errorOrContext); } } error(message, errorOrContext, context) { if (errorOrContext instanceof Error) { this.logger.error(message, errorOrContext, context); } else { this.logger.error(message, errorOrContext); } } fatal(message, errorOrContext, context) { if (errorOrContext instanceof Error) { this.logger.fatal(message, errorOrContext, context); } else { this.logger.fatal(message, errorOrContext); } } async close() { await this.logger.close(); } }; } }); // src/logger/adapter-factory.ts function createAdapter(type) { const level = getDefaultLogLevel(); switch (type) { case "pino": return new PinoAdapter({ level }); case "custom": return new CustomAdapter({ level }); default: return new PinoAdapter({ level }); } } function getAdapterType() { const adapterEnv = process.env.LOGGER_ADAPTER; if (adapterEnv === "custom" || adapterEnv === "pino") { return adapterEnv; } return "pino"; } function initializeLogger() { validateConfig(); return createAdapter(getAdapterType()); } var logger; var init_adapter_factory = __esm({ "src/logger/adapter-factory.ts"() { init_pino(); init_custom(); init_config(); logger = initializeLogger(); } }); // src/logger/index.ts var init_logger2 = __esm({ "src/logger/index.ts"() { init_adapter_factory(); } }); // src/route/function-routes.ts var function_routes_exports = {}; __export(function_routes_exports, { discoverFunctionRoutes: () => discoverFunctionRoutes }); function discoverFunctionRoutes(cwd = process.cwd()) { const functions = []; const nodeModulesPath = join(cwd, "node_modules"); try { const projectPkgPath = join(cwd, "package.json"); const projectPkg = JSON.parse(readFileSync(projectPkgPath, "utf-8")); const dependencies = { ...projectPkg.dependencies, ...projectPkg.devDependencies }; for (const [packageName] of Object.entries(dependencies)) { if (!packageName.startsWith("@spfn/") && !packageName.startsWith("spfn-")) { continue; } try { const pkgPath = join(nodeModulesPath, ...packageName.split("/"), "package.json"); const pkg = JSON.parse(readFileSync(pkgPath, "utf-8")); if (pkg.spfn?.routes?.dir) { const { dir } = pkg.spfn.routes; const prefix = pkg.spfn.prefix; const packagePath = dirname(pkgPath); const routesDir = join(packagePath, dir); functions.push({ packageName, routesDir, packagePath, prefix // Include prefix in function info }); routeLogger.debug("Discovered function routes", { package: packageName, dir, prefix: prefix || "(none)" }); } } catch (error) { } } } catch (error) { routeLogger.warn("Failed to discover function routes", { error: error instanceof Error ? error.message : "Unknown error" }); } return functions; } var routeLogger; var init_function_routes = __esm({ "src/route/function-routes.ts"() { init_logger2(); routeLogger = logger.child("function-routes"); } }); // src/route/auto-loader.ts init_logger2(); var routeLogger2 = logger.child("route"); var AutoRouteLoader = class { constructor(routesDir, debug = false, middlewares = []) { this.routesDir = routesDir; this.debug = debug; this.middlewares = middlewares; } routes = []; debug; middlewares; async load(app) { const startTime = Date.now(); const files = await this.scanFiles(this.routesDir); if (files.length === 0) { routeLogger2.warn("No route files found"); return this.getStats(); } let failureCount = 0; for (const file of files) { const success = await this.loadRoute(app, file); if (success) ; else { failureCount++; } } const elapsed = Date.now() - startTime; const stats = this.getStats(); if (this.debug) { this.logStats(stats, elapsed); } if (failureCount > 0) { routeLogger2.warn("Some routes failed to load", { failureCount }); } return stats; } /** * Load routes from an external directory (e.g., from SPFN function packages) * Reads package.json spfn.prefix and mounts routes under that prefix * * @param app - Hono app instance * @param routesDir - Directory containing route handlers * @param packageName - Name of the package (for logging) * @param prefix - Optional prefix to mount routes under (from package.json spfn.prefix) * @returns Route statistics */ async loadExternalRoutes(app, routesDir, packageName, prefix) { const startTime = Date.now(); const tempRoutesDir = this.routesDir; this.routesDir = routesDir; const files = await this.scanFiles(routesDir); if (files.length === 0) { routeLogger2.warn("No route files found", { dir: routesDir, package: packageName }); this.routesDir = tempRoutesDir; return this.getStats(); } let successCount = 0; let failureCount = 0; for (const file of files) { const success = await this.loadRoute(app, file, prefix); if (success) { successCount++; } else { failureCount++; } } const elapsed = Date.now() - startTime; if (this.debug) { routeLogger2.info("External routes loaded", { package: packageName, prefix: prefix || "/", total: successCount, failed: failureCount, elapsed: `${elapsed}ms` }); } this.routesDir = tempRoutesDir; return this.getStats(); } getStats() { const stats = { total: this.routes.length, byPriority: { static: 0, dynamic: 0, catchAll: 0 }, byTag: {}, routes: this.routes }; for (const route of this.routes) { if (route.priority === 1) stats.byPriority.static++; else if (route.priority === 2) stats.byPriority.dynamic++; else if (route.priority === 3) stats.byPriority.catchAll++; if (route.meta?.tags) { for (const tag of route.meta.tags) { stats.byTag[tag] = (stats.byTag[tag] || 0) + 1; } } } return stats; } async scanFiles(dir, files = []) { const entries = await readdir(dir); for (const entry of entries) { const fullPath = join(dir, entry); const fileStat = await stat(fullPath); if (fileStat.isDirectory()) { await this.scanFiles(fullPath, files); } else if (this.isValidRouteFile(entry)) { files.push(fullPath); } } return files; } isValidRouteFile(fileName) { return fileName === "index.ts" || fileName === "index.js" || fileName === "index.mjs"; } async loadRoute(app, absolutePath, prefix) { const relativePath = relative(this.routesDir, absolutePath); try { const module = await import(absolutePath); if (!this.validateModule(module, relativePath)) { return false; } const hasContractMetas = module.default._contractMetas && module.default._contractMetas.size > 0; if (!hasContractMetas) { routeLogger2.error("Route must use contract-based routing", { file: relativePath, hint: "Export contracts using satisfies RouteContract and use app.bind()" }); return false; } const contractPaths = this.extractContractPaths(module); if (prefix) { const invalidPaths = contractPaths.filter((path) => !path.startsWith(prefix)); if (invalidPaths.length > 0) { routeLogger2.error("Contract paths must include the package prefix", { file: relativePath, prefix, invalidPaths, hint: `Contract paths should start with "${prefix}". Example: path: "${prefix}/labels"` }); return false; } } this.registerContractBasedMiddlewares(app, contractPaths, module); app.route("/", module.default); contractPaths.forEach((path) => { this.routes.push({ path, // Use contract path as-is (already includes prefix) file: relativePath, meta: module.meta, priority: this.calculateContractPriority(path) }); if (this.debug) { const icon = path.includes("*") ? "\u2B50" : path.includes(":") ? "\u{1F538}" : "\u{1F539}"; routeLogger2.debug(`Registered route: ${path}`, { icon, file: relativePath }); } }); return true; } catch (error) { this.categorizeAndLogError(error, relativePath); return false; } } extractContractPaths(module) { const paths = /* @__PURE__ */ new Set(); if (module.default._contractMetas) { for (const key of module.default._contractMetas.keys()) { const path = key.split(" ")[1]; if (path) { paths.add(path); } } } return Array.from(paths); } calculateContractPriority(path) { if (path.includes("*")) return 3; if (path.includes(":")) return 2; return 1; } validateModule(module, relativePath) { if (!module.default) { routeLogger2.error("Route must export Hono instance as default", { file: relativePath }); return false; } if (typeof module.default.route !== "function") { routeLogger2.error("Default export is not a Hono instance", { file: relativePath }); return false; } return true; } registerContractBasedMiddlewares(app, contractPaths, module) { app.use("*", (c, next) => { const method = c.req.method; const requestPath = new URL(c.req.url).pathname; const key = `${method} ${requestPath}`; const meta = module.default._contractMetas?.get(key); if (meta?.skipMiddlewares) { c.set("_skipMiddlewares", meta.skipMiddlewares); } return next(); }); for (const contractPath of contractPaths) { const middlewarePath = contractPath === "/" ? "/*" : `${contractPath}/*`; for (const middleware of this.middlewares) { app.use(middlewarePath, async (c, next) => { const skipList = c.get("_skipMiddlewares") || []; if (skipList.includes(middleware.name)) { return next(); } return middleware.handler(c, next); }); } } } categorizeAndLogError(error, relativePath) { const message = error.message; const stack = error.stack; if (message.includes("Cannot find module") || message.includes("MODULE_NOT_FOUND")) { routeLogger2.error("Missing dependency", { file: relativePath, error: message, hint: "Run: npm install" }); } else if (message.includes("SyntaxError") || stack?.includes("SyntaxError")) { routeLogger2.error("Syntax error", { file: relativePath, error: message, ...this.debug && stack && { stack: stack.split("\n").slice(0, 5).join("\n") } }); } else if (message.includes("Unexpected token")) { routeLogger2.error("Parse error", { file: relativePath, error: message, hint: "Check for syntax errors or invalid TypeScript" }); } else { routeLogger2.error("Route loading failed", { file: relativePath, error: message, ...this.debug && stack && { stack } }); } } logStats(stats, elapsed) { const tagCounts = Object.entries(stats.byTag).map(([tag, count]) => `${tag}(${count})`).join(", "); routeLogger2.info("Routes loaded successfully", { total: stats.total, priority: { static: stats.byPriority.static, dynamic: stats.byPriority.dynamic, catchAll: stats.byPriority.catchAll }, ...tagCounts && { tags: tagCounts }, elapsed: `${elapsed}ms` }); } }; async function loadRoutes(app, options) { const routesDir = options?.routesDir ?? join(process.cwd(), "src", "server", "routes"); const debug = options?.debug ?? false; const middlewares = options?.middlewares ?? []; const includeFunctionRoutes = options?.includeFunctionRoutes ?? true; const loader = new AutoRouteLoader(routesDir, debug, middlewares); const stats = await loader.load(app); if (includeFunctionRoutes) { const { discoverFunctionRoutes: discoverFunctionRoutes2 } = await Promise.resolve().then(() => (init_function_routes(), function_routes_exports)); const functionRoutes = discoverFunctionRoutes2(); if (functionRoutes.length > 0) { routeLogger2.info("Loading function routes", { count: functionRoutes.length }); for (const func of functionRoutes) { try { await loader.loadExternalRoutes(app, func.routesDir, func.packageName, func.prefix); routeLogger2.info("Function routes loaded", { package: func.packageName, routesDir: func.routesDir, prefix: func.prefix || "/" }); } catch (error) { routeLogger2.error("Failed to load function routes", { package: func.packageName, error: error instanceof Error ? error.message : "Unknown error" }); } } } } return stats; } // src/errors/http-errors.ts var HttpError = class extends Error { statusCode; details; timestamp; constructor(message, statusCode, details) { super(message); this.name = "HttpError"; this.statusCode = statusCode; this.details = details; this.timestamp = /* @__PURE__ */ new Date(); Error.captureStackTrace(this, this.constructor); } /** * Serialize error for API response */ toJSON() { return { name: this.name, message: this.message, statusCode: this.statusCode, details: this.details, timestamp: this.timestamp.toISOString() }; } }; var ValidationError = class extends HttpError { constructor(message, details) { super(message, 400, details); this.name = "ValidationError"; } }; // src/route/bind.ts function bind(contract, handler) { return async (rawContext) => { let params = rawContext.req.param(); if (contract.params) { params = Value.Convert(contract.params, params); const errors = [...Value.Errors(contract.params, params)]; if (errors.length > 0) { throw new ValidationError( "Invalid path parameters", { fields: errors.map((e) => ({ path: e.path, message: e.message, value: e.value })) } ); } } const url = new URL(rawContext.req.url); let query = {}; url.searchParams.forEach((v, k) => { const existing = query[k]; if (existing) { query[k] = Array.isArray(existing) ? [...existing, v] : [existing, v]; } else { query[k] = v; } }); if (contract.query) { query = Value.Convert(contract.query, query); const errors = [...Value.Errors(contract.query, query)]; if (errors.length > 0) { throw new ValidationError( "Invalid query parameters", { fields: errors.map((e) => ({ path: e.path, message: e.message, value: e.value })) } ); } } const routeContext = { params, query, data: async () => { let body = await rawContext.req.json(); if (contract.body) { body = Value.Convert(contract.body, body); const errors = [...Value.Errors(contract.body, body)]; if (errors.length > 0) { throw new ValidationError( "Invalid request body", { fields: errors.map((e) => ({ path: e.path, message: e.message, value: e.value })) } ); } } return body; }, json: (data, status, headers) => { const errorHandlerEnabled = rawContext.get("errorHandlerEnabled"); if (errorHandlerEnabled && process.env.NODE_ENV !== "production") { const hasSuccessField = data && typeof data === "object" && "success" in data; if (!hasSuccessField) { console.warn( "[SPFN] Warning: ErrorHandler is enabled but c.json() is being used with non-standard response format.\nConsider using c.success() for consistent API responses, or disable ErrorHandler if you prefer custom formats." ); } } return rawContext.json(data, status, headers); }, success: (data, meta, status = 200) => { const response = { success: true, data }; if (meta) { response.meta = meta; } return rawContext.json(response, status); }, paginated: (data, page, limit, total) => { const response = { success: true, data, meta: { pagination: { page, limit, total, totalPages: Math.ceil(total / limit) } } }; return rawContext.json(response, 200); }, raw: rawContext }; return handler(routeContext); }; } // src/middleware/error-handler.ts init_logger2(); var errorLogger = logger.child("error-handler"); function ErrorHandler(options = {}) { const { includeStack = process.env.NODE_ENV !== "production", enableLogging = true } = options; return (err, c) => { const errorWithCode = err; const statusCode = errorWithCode.statusCode || 500; const errorType = err.name || "Error"; if (enableLogging) { const logLevel = statusCode >= 500 ? "error" : "warn"; const logData = { type: errorType, message: err.message, statusCode, path: c.req.path, method: c.req.method }; if (errorWithCode.details) { logData.details = errorWithCode.details; } if (statusCode >= 500 && includeStack) { logData.stack = err.stack; } errorLogger[logLevel]("Error occurred", logData); } const response = { success: false, error: { message: err.message || "Internal Server Error", type: errorType, statusCode } }; if (errorWithCode.details) { response.error.details = errorWithCode.details; } if (includeStack) { response.error.stack = err.stack; } return c.json(response, statusCode); }; } // src/middleware/request-logger.ts init_logger2(); // src/route/create-app.ts function createApp() { const hono = new Hono(); const app = hono; app._contractMetas = /* @__PURE__ */ new Map(); app.onError(ErrorHandler()); const methodMap = /* @__PURE__ */ new Map([ ["get", (path, handlers) => hono.get(path, ...handlers)], ["post", (path, handlers) => hono.post(path, ...handlers)], ["put", (path, handlers) => hono.put(path, ...handlers)], ["patch", (path, handlers) => hono.patch(path, ...handlers)], ["delete", (path, handlers) => hono.delete(path, ...handlers)] ]); app.bind = function(contract, ...args) { const method = contract.method.toLowerCase(); const path = contract.path; const [middlewares, handler] = args.length === 1 ? [[], args[0]] : [args[0], args[1]]; const key = `${contract.method} ${path}`; app._contractMetas.set(key, contract.meta || {}); const boundHandler = bind(contract, handler); const handlers = middlewares.length > 0 ? [...middlewares, boundHandler] : [boundHandler]; const registerMethod = methodMap.get(method); if (!registerMethod) { throw new Error(`Unsupported HTTP method: ${contract.method}`); } registerMethod(path, handlers); }; return app; } function ApiSuccessSchema(dataSchema) { return Type.Object({ success: Type.Literal(true), data: dataSchema, meta: Type.Optional(Type.Object({ timestamp: Type.Optional(Type.String()), requestId: Type.Optional(Type.String()), pagination: Type.Optional(Type.Object({ page: Type.Number(), limit: Type.Number(), total: Type.Number(), totalPages: Type.Number() })) })) }); } function ApiErrorSchema() { return Type.Object({ success: Type.Literal(false), error: Type.Object({ message: Type.String(), type: Type.String(), statusCode: Type.Number(), stack: Type.Optional(Type.String()), details: Type.Optional(Type.Any()) }) }); } function ApiResponseSchema(dataSchema) { return ApiSuccessSchema(dataSchema); } // src/route/types.ts function isHttpMethod(value) { return typeof value === "string" && ["GET", "POST", "PUT", "PATCH", "DELETE"].includes(value); } export { ApiErrorSchema, ApiResponseSchema, ApiSuccessSchema, AutoRouteLoader, bind, createApp, isHttpMethod, loadRoutes }; //# sourceMappingURL=index.js.map //# sourceMappingURL=index.js.map