UNPKG

burger-api

Version:

<div align="center"> <a href="https://burger-api.com"> <img src="https://github.com/user-attachments/assets/0d9b376e-1d89-479a-aa7f-e7ee3c6b2342" alt="BurgerAPI"/> </a> </div>

265 lines (264 loc) 12.5 kB
// Import stuff from node import { readdir } from 'fs/promises'; import * as path from 'path'; // Import utils import { normalizePath, ROUTE_CONSTANTS, HTTP_METHODS } from '../utils/index'; import { filePathToApiRoutePath } from '../utils/pathConversion'; /** * ApiRouter class for handling file-based routing. * Loads routes from a directory structure and matches requests to the appropriate route handlers. * Supports dynamic segments (e.g., [id] ,(group),[...]) and HTTP method handlers. * Routes are sorted to prioritize static routes over dynamic and wildcard ones to prevent overlapping route issues. */ export class ApiRouter { routesDir; prefix; /** The root of the trie where all routes will begin*/ root = { children: new Map() }; /** * Constructor for the ApiRouter class. * @param routesDir The directory path where route modules are located. * @param prefix Optional prefix to prepend to all routes (e.g., "api" becomes "/api/..."). * @throws {Error} If the routes directory path is not provided. */ constructor(routesDir, prefix = '') { this.routesDir = routesDir; this.prefix = prefix; if (!routesDir) { throw new Error('Routes directory path is required'); } } /** * Getter for the routes trie. * @returns The root of the trie. */ get routes() { return this.root; } /** * Loads all routes from the routes directory into the trie structure. * @returns {Promise<void>} A promise that resolves when all routes are loaded. * @throws {Error} If the routes directory is not found or if an error occurs during route loading. */ async loadRoutes() { try { await this.scanDirectory(this.routesDir); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); throw new Error(`Failed to load routes: ${errorMessage}`); } } /** * Inserts a route definition into the trie structure. * @param routeDef The route definition to insert, containing path, handlers, middleware, schema, and OpenAPI metadata. * @throws {Error} If the route definition is invalid or causes a conflict in the trie. */ insertRoute(routeDef) { let node = this.root; const segments = routeDef.path.split('/').filter(Boolean); for (const segment of segments) { if (segment.startsWith(':')) { // Handle dynamic segments (:param) if (!node.paramChild) { node.paramChild = { children: new Map() }; } node = node.paramChild; node.paramName = segment.slice(1); } else if (segment.startsWith('*')) { // Handle wildcard segments (*param) if (!node.wildcardChild) { node.wildcardChild = { children: new Map() }; } node = node.wildcardChild; node.isWildcard = true; // Mark as wildcard route } else { // Handle static segments if (!node.children.has(segment)) { node.children.set(segment, { children: new Map() }); } node = node.children.get(segment); } } node.route = routeDef; } /** * Recursively scans a directory for route modules (route.ts files) and loads them into the trie. * @param dir The current directory path to scan. * @param basePath The base path used to construct route paths from the directory structure. * @returns {Promise<void>} A promise that resolves when the directory scan is complete. * @throws {Error} If multiple dynamic folders are found at the same level or if directory access fails. */ async scanDirectory(dir, basePath = '') { // Track route types at this directory level to prevent conflicts let dynamicFolderFound = false; let wildcardFolderFound = false; try { const entries = await readdir(dir, { withFileTypes: true }); for (const entry of entries) { const entryPath = path.join(dir, entry.name); const relativePath = path.join(basePath, entry.name); if (entry.isDirectory()) { // Skip named wildcard folders if (entry.name.startsWith(ROUTE_CONSTANTS.WILDCARD_START) && entry.name !== ROUTE_CONSTANTS.WILDCARD_SIMPLE) { continue; } // Check if this is a dynamic or wildcard directory const isDynamic = entry.name.startsWith(ROUTE_CONSTANTS.DYNAMIC_FOLDER_START) && entry.name.endsWith(ROUTE_CONSTANTS.DYNAMIC_FOLDER_END) && !entry.name.startsWith(ROUTE_CONSTANTS.WILDCARD_START); const isWildcard = entry.name === ROUTE_CONSTANTS.WILDCARD_SIMPLE; // Prevent conflicts between dynamic and wildcard routes if (isDynamic && wildcardFolderFound) { throw new Error(`Cannot mix dynamic and wildcard route folders in the same directory. ` + `Found dynamic folder '${entry.name}' but wildcard folder already exists in '${dir}'.`); } if (isWildcard && dynamicFolderFound) { throw new Error(`Cannot mix wildcard and dynamic route folders in the same directory. ` + `Found wildcard folder '${entry.name}' but dynamic folder already exists in '${dir}'.`); } if (isDynamic && dynamicFolderFound) { throw new Error(`Multiple dynamic route folders found in the same directory: ` + `'${entry.name}' conflicts with another dynamic folder in '${dir}'.`); } if (isWildcard && wildcardFolderFound) { throw new Error(`Multiple wildcard route folders found in the same directory: ` + `'${entry.name}' conflicts with another wildcard folder in '${dir}'.`); } // Update flags if (isDynamic) dynamicFolderFound = true; if (isWildcard) wildcardFolderFound = true; await this.scanDirectory(entryPath, relativePath); } else if (entry.isFile() && entry.name === 'route.ts') { await this.loadRouteModule(entryPath, relativePath); } } } catch (error) { if (error instanceof Error) { throw error; // Re-throw specific errors we created } throw new Error(`Failed to scan directory '${dir}': ${String(error)}`); } } /** * Loads a route module from a file and inserts it into the trie. * @param entryPath The full file system path to the route module (route.ts). * @param relativePath The relative path used to construct the route path. * @returns {Promise<void>} A promise that resolves when the module is loaded and inserted. * @throws {Error} If the route module fails to load or is invalid. */ async loadRouteModule(entryPath, relativePath) { try { // Convert the file path to a route path const routePath = filePathToApiRoutePath(relativePath, this.prefix); console.info('Loading route:', routePath); // Get the module path like "D:\\Projects\\src\\api\\users\\route.ts" const modulePath = path.resolve(entryPath); // Import the module const routeModule = await import(modulePath); // Variable to store the handlers for each method const handlers = {}; // Loop through all the HTTP methods and add the handler to the handlers object for (const method of HTTP_METHODS) { if (typeof routeModule[method] === 'function') { handlers[method] = routeModule[method]; } } // Auto-inject minimal OPTIONS handler for CORS preflight when needed // Only if route defines any preflight-triggering methods and lacks an OPTIONS handler const PREFLIGHT_METHODS = [ 'POST', 'PUT', 'DELETE', 'PATCH', ]; let hasPreflightMethods = false; // Manual loop is slightly faster than Array.prototype.some in tight paths for (let i = 0; i < PREFLIGHT_METHODS.length; i++) { if (handlers[PREFLIGHT_METHODS[i]]) { hasPreflightMethods = true; break; } } // If the route defines any preflight-triggering methods and lacks an OPTIONS handler, auto-add an OPTIONS handler if (hasPreflightMethods && typeof handlers.OPTIONS !== 'function') { // if (process.env.NODE_ENV !== 'production') { // console.debug('Auto-added OPTIONS handler for route:', routePath); // } handlers.OPTIONS = () => new Response(null, { status: 204 }); } const routeDef = { path: routePath, handlers, middleware: routeModule.middleware, schema: routeModule.schema, openapi: routeModule.openapi, isWildcard: routePath.includes(ROUTE_CONSTANTS.WILDCARD_SEGMENT_PREFIX), }; this.insertRoute(routeDef); } catch (error) { throw new Error(`Failed to load route module '${entryPath}': ${String(error)}`); } } /** * Resolves a request to a route definition and its parameters. * @param pathname The normalized request pathname. * @param method The uppercase HTTP method. * @returns {{ route?: RouteDefinition; params: Record<string, string>; wildcardParams?: string[] }} An object containing the matched route (if any), extracted parameters, and wildcard segments (if applicable). * @throws {Error} If the request URL is malformed. */ resolve(pathname, method) { try { const segments = pathname.split('/').filter(Boolean); let node = this.root; const params = {}; let wildcardSegments = []; // Traverse the trie with proper prioritization for (let i = 0; i < segments.length; i++) { const segment = segments[i]; // Priority 1: Exact static match (highest priority) if (node.children.has(segment)) { node = node.children.get(segment); } // Priority 2: Dynamic parameter match (medium priority) else if (node.paramChild) { node = node.paramChild; params[node.paramName] = segment; } // Priority 3: Collect remaining segments for wildcard (lowest priority) else { wildcardSegments = segments.slice(i); break; } } // Check if we have a route at the current node if (node.route?.handlers[method]) { return { route: node.route, params }; } // If no route found but we have wildcard segments, check for wildcard route if (wildcardSegments.length > 0 && node.wildcardChild?.route?.handlers[method]) { // Return wildcard segments so they can be added to req.wildcardParams return { route: node.wildcardChild.route, params, wildcardParams: wildcardSegments, }; } return { params: {} }; } catch (error) { // Log the error for better debugging console.error(`Error resolving API route for path "${pathname}":`, error); return { params: {} }; } } }