UNPKG

@pulzar/core

Version:

Next-generation Node.js framework for ultra-fast web applications with zero-reflection DI, GraphQL, WebSockets, events, and edge runtime support

324 lines 11.4 kB
import glob from "fast-glob"; import { watch } from "chokidar"; import { join, relative, parse } from "path"; import { logger } from "../utils/logger"; export class FileBasedRouter { app; options; routes = new Map(); watcher; registeredRoutes = new Set(); constructor(app, options) { this.app = app; this.options = { baseUrl: "", watchMode: process.env.NODE_ENV === "development", ignorePaths: ["**/*.test.ts", "**/*.spec.ts", "**/index.ts"], enableHotReload: true, ...options, }; } /** * Initialize the file-based router */ async initialize() { logger.info("Initializing file-based router", { routesDir: this.options.routesDir, watchMode: this.options.watchMode, }); await this.scanRoutes(); await this.registerRoutes(); if (this.options.watchMode) { this.startWatching(); } logger.info(`File-based router initialized with ${this.routes.size} routes`); } /** * Scan for route files */ async scanRoutes() { const pattern = join(this.options.routesDir, "**/*.{get,post,put,delete,patch,head,options}.{ts,js}"); const files = await glob(pattern, { ignore: this.options.ignorePaths, absolute: true, }); logger.debug(`Found ${files.length} route files`); for (const file of files) { await this.loadRoute(file); } } /** * Load a single route file */ async loadRoute(filePath) { try { // Clear module cache for hot reload if (this.options.enableHotReload && require.cache[filePath]) { delete require.cache[filePath]; } const routeModule = await import(filePath); const routeDefinition = this.parseRouteFromFile(filePath, routeModule); if (routeDefinition) { const routeKey = `${routeDefinition.method}:${routeDefinition.path}`; this.routes.set(routeKey, routeDefinition); logger.debug("Loaded route", { method: routeDefinition.method, path: routeDefinition.path, file: relative(process.cwd(), filePath), }); } } catch (error) { logger.error("Failed to load route", { file: relative(process.cwd(), filePath), error: error instanceof Error ? error.message : String(error), }); } } /** * Parse route definition from file path and module */ parseRouteFromFile(filePath, module) { const relativePath = relative(this.options.routesDir, filePath); const { dir, name } = parse(relativePath); // Extract method from filename (e.g., users.get.ts -> GET) const parts = name.split("."); if (parts.length < 2) return null; const methodPart = parts[parts.length - 1]; if (!methodPart) return null; const method = methodPart.toUpperCase(); const routeName = parts.slice(0, -1).join("."); // Build URL path from directory structure and filename let urlPath = dir ? `/${dir}` : ""; if (routeName !== "index") { urlPath += `/${routeName}`; } // Handle dynamic parameters [id] -> :id urlPath = urlPath.replace(/\[([^\]]+)\]/g, ":$1"); // Handle optional parameters [id]? -> :id? urlPath = urlPath.replace(/:([^?/]+)\?/g, ":$1?"); // Combine with base URL const fullPath = join(this.options.baseUrl || "", urlPath).replace(/\\/g, "/"); // Get handler function const handler = module.default || module.handler || module[method.toLowerCase()]; if (!handler || typeof handler !== "function") { logger.warn("No valid handler found for route", { filePath, method, path: fullPath, }); return null; } // Build route definition const routeDefinition = { method, path: fullPath || "/", handler, }; // Extract metadata if available if (module.schema) routeDefinition.schema = module.schema; if (module.preHandler) routeDefinition.preHandler = Array.isArray(module.preHandler) ? module.preHandler : [module.preHandler]; if (module.config) routeDefinition.config = module.config; if (module.summary) routeDefinition.summary = module.summary; if (module.description) routeDefinition.description = module.description; if (module.tags) routeDefinition.tags = module.tags; return routeDefinition; } /** * Register routes with Fastify */ async registerRoutes() { for (const [routeKey, route] of this.routes) { if (this.registeredRoutes.has(routeKey)) { continue; // Skip already registered routes } try { const routeOptions = { method: route.method, url: route.path, handler: route.handler, }; // Add schema if available if (route.schema) { routeOptions.schema = this.convertZodToFastifySchema(route.schema); } // Add preHandler hooks if (route.preHandler) { routeOptions.preHandler = route.preHandler; } // Add route config if (route.config) { routeOptions.config = route.config; } this.app.route(routeOptions); this.registeredRoutes.add(routeKey); logger.debug("Registered route", { method: route.method, path: route.path, tags: route.tags, }); } catch (error) { logger.error("Failed to register route", { method: route.method, path: route.path, error: error instanceof Error ? error.message : String(error), }); } } } /** * Convert Zod schemas to Fastify JSON schemas */ convertZodToFastifySchema(schema) { const fastifySchema = {}; if (schema?.body) { fastifySchema.body = this.zodToJsonSchema(schema.body); } if (schema?.querystring) { fastifySchema.querystring = this.zodToJsonSchema(schema.querystring); } if (schema?.params) { fastifySchema.params = this.zodToJsonSchema(schema.params); } if (schema?.headers) { fastifySchema.headers = this.zodToJsonSchema(schema.headers); } if (schema?.response) { fastifySchema.response = {}; for (const [statusCode, responseSchema] of Object.entries(schema.response)) { fastifySchema.response[statusCode] = this.zodToJsonSchema(responseSchema); } } return fastifySchema; } /** * Convert Zod schema to JSON schema (simplified) */ zodToJsonSchema(schema) { // This is a simplified conversion // In a real implementation, you'd use a library like zod-to-json-schema try { // For now, just return a basic object schema // TODO: Implement proper Zod to JSON Schema conversion return { type: "object", additionalProperties: true, }; } catch (error) { logger.warn("Failed to convert Zod schema to JSON schema", { error }); return { type: "object" }; } } /** * Start watching for file changes */ startWatching() { if (this.watcher) { this.watcher.close(); } const pattern = join(this.options.routesDir, "**/*.{get,post,put,delete,patch,head,options}.{ts,js}"); this.watcher = watch(pattern, { ignored: this.options.ignorePaths, persistent: true, ignoreInitial: true, }); this.watcher.on("add", (filePath) => { logger.debug("Route file added", { file: relative(process.cwd(), filePath), }); this.handleFileChange(filePath); }); this.watcher.on("change", (filePath) => { logger.debug("Route file changed", { file: relative(process.cwd(), filePath), }); this.handleFileChange(filePath); }); this.watcher.on("unlink", (filePath) => { logger.debug("Route file removed", { file: relative(process.cwd(), filePath), }); this.handleFileRemoval(filePath); }); logger.info("File watcher started for routes", { pattern }); } /** * Handle file changes (hot reload) */ async handleFileChange(filePath) { if (!this.options.enableHotReload) return; try { // Remove old route await this.handleFileRemoval(filePath); // Load new route await this.loadRoute(filePath); // Re-register routes (Fastify doesn't support dynamic route removal) // In a real implementation, you might need to restart the server // or use a more sophisticated hot reload mechanism logger.info("Route hot reloaded", { file: relative(process.cwd(), filePath), }); } catch (error) { logger.error("Failed to hot reload route", { file: relative(process.cwd(), filePath), error: error instanceof Error ? error.message : String(error), }); } } /** * Handle file removal */ async handleFileRemoval(filePath) { // Find and remove route from internal maps const routesToRemove = []; for (const [routeKey, route] of this.routes) { // Match by file path (simplified) // In a real implementation, you'd store file path mapping routesToRemove.push(routeKey); } for (const routeKey of routesToRemove) { this.routes.delete(routeKey); this.registeredRoutes.delete(routeKey); } } /** * Get all registered routes */ getRoutes() { return Array.from(this.routes.values()); } /** * Get route by method and path */ getRoute(method, path) { return this.routes.get(`${method.toUpperCase()}:${path}`); } /** * Stop watching and cleanup */ async stop() { if (this.watcher) { await this.watcher.close(); this.watcher = undefined; } this.routes.clear(); this.registeredRoutes.clear(); logger.info("File-based router stopped"); } } //# sourceMappingURL=file-router.js.map