@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
JavaScript
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