UNPKG

fastify-file-router

Version:

A fastify plugin that automatically registers routes from files in a directory.

680 lines 33.3 kB
import fs from 'node:fs/promises'; import path from 'node:path'; import { performance } from 'node:perf_hooks'; import pLimit from 'p-limit'; import { formatJsonSchemaError, formatZodError, isZodSchema } from './defineRouteZod.js'; import { toHttpMethod, toRouteNextStyle, toRouteRemixStyle } from './routeConverter.js'; export function createRouteRegistrationRuntimeContext(options) { const maxConcurrentTasks = options?.maxConcurrentTasks ?? 64; let ajvPromise; return { maxConcurrentTasks, taskLimit: pLimit(maxConcurrentTasks), profiling: { enabled: options?.profiling?.enabled ?? false, routes: [], directories: [], }, getAjv: async () => { if (ajvPromise) { return ajvPromise; } ajvPromise = (async () => { try { const ajvModule = await import('ajv'); const AjvCtor = ajvModule.default; return new AjvCtor({ allErrors: true, coerceTypes: true, useDefaults: true }); } catch { return; } })(); return ajvPromise; }, }; } async function executeTaskQueue(seedTasks, limit) { const queue = [...seedTasks]; const inFlight = new Set(); const launch = (task) => { const runPromise = limit(async () => { const followUpTasks = await task(); if (followUpTasks.length > 0) { queue.push(...followUpTasks); } }).finally(() => { inFlight.delete(runPromise); }); inFlight.add(runPromise); }; while (queue.length > 0 || inFlight.size > 0) { while (queue.length > 0) { const task = queue.shift(); if (task) { launch(task); } } if (inFlight.size > 0) { await Promise.race(inFlight); } } } /** * Parses a filename into its components: route segments, method, and extension. * @param fileName - The filename to parse * @param extensions - Valid file extensions * @param fullPath - The full path to the file (for error messages) * @returns Object with routeSegments, method, and extension, or null if invalid */ export function parseFileName(fileName, extensions, fullPath) { // Replace [.] with a placeholder that doesn't contain dots // Since [.] represents a literal dot in the route, we replace it before splitting const placeholder = '__LITERAL_DOT__'; // Replace [.] with placeholder (no dots in placeholder to avoid splitting issues) const fileNameWithPlaceholder = fileName.replace(/\[\.\]/g, placeholder); // Split by dots - the placeholder will be part of a segment const segments = fileNameWithPlaceholder.split('.'); // Check segment count first if (segments.length < 2) { throw new Error(`Invalid file name "${fileName}" in file ${fullPath}, must have at least 2 segments separated by a dot`); } // Process segments: split segments that contain the placeholder and restore [.] const processedSegments = []; for (const seg of segments) { if (seg === placeholder) { // Standalone placeholder segment processedSegments.push('[.]'); } else if (seg.includes(placeholder)) { // Segment contains placeholder - split it const parts = seg.split(placeholder); for (let i = 0; i < parts.length; i++) { const part = parts[i]; if (part) { processedSegments.push(part); } // Add [.] between parts (but not after the last part) if (i < parts.length - 1) { processedSegments.push('[.]'); } } } else if (seg) { // Regular segment (filter out empty strings) processedSegments.push(seg); } } const extensionSegment = `.${processedSegments[processedSegments.length - 1]}`; const methodSegment = processedSegments[processedSegments.length - 2]; const routeSegments = processedSegments.slice(0, -2); if (!extensions.includes(extensionSegment)) { return null; } // get next to last segment as method if (!methodSegment) { throw new Error(`Invalid file name "${fileName}" in file ${fullPath}, method segment is missing`); } return { routeSegments, method: methodSegment, extension: extensionSegment, }; } /** * Converts route segments to a route path based on the convention. * @param segments - Array of route segments * @param convention - The route convention to use ('remix' or 'next') * @param fullPath - The full path to the file (for error messages) * @returns The converted route path */ export function convertRoutePath(segments, convention, fullPath) { if (convention === 'remix') { return toRouteRemixStyle(segments, fullPath); } if (convention === 'next') { return toRouteNextStyle(segments, fullPath); } throw new Error(`Invalid convention "${convention}"`); } /** * Builds the full URL path from route path and mount point. * @param routePath - The route path * @param mount - The mount point * @returns The full URL path */ export function buildUrl(routePath, mount) { let url = routePath; // Remove leading slash from routePath to avoid double slashes if (url.startsWith('/')) { url = url.substring(1); } // add mount if present if (mount !== '/') { url = `${mount}/${url}`; } // add preceding '/' if missing if (!url.startsWith('/')) { url = `/${url}`; } return url; } /** * Returns true if a directory segment is a route group (parenthesized). * Route groups like (auth) or (marketing) are used for organization only and do not appear in the URL. * @param segment - A single path segment (directory name) */ export function isRouteGroupSegment(segment) { return segment.length >= 2 && segment.startsWith('(') && segment.endsWith(')'); } /** * Checks if a file should be excluded based on exclude patterns. * @param fileName - The filename to check * @param excludePatterns - Array of regex patterns to match against * @returns The matching exclude pattern, or undefined if not excluded */ export function shouldExcludeFile(fileName, excludePatterns) { for (const pattern of excludePatterns) { if (pattern.test(fileName)) { return pattern; } } return; } /** * Extracts parameter names from a route path. * @param routePath - The route path (e.g., '/files/:oid' or '/users/:id/posts/:postId') * @returns Array of parameter names, or empty array if route contains wildcard */ export function extractRouteParams(routePath) { // Skip validation for wildcard routes if (routePath.includes('*')) { return []; } // Extract parameter names using regex to match :paramName patterns // Stop at dots (.) since [.] ends a parameter and starts a literal segment // e.g., /:assetName.glb should extract 'assetName', not 'assetName.glb' const paramRegex = /:([^/.]+)/g; const params = []; const matches = routePath.matchAll(paramRegex); for (const match of matches) { if (match[1]) { params.push(match[1]); } } return params; } /** * Extracts property names from a JSON Schema params object. * @param schema - The JSON Schema params object * @returns Array of property names */ export function extractJsonSchemaParams(schema) { if (!schema || typeof schema !== 'object') { return []; } const schemaObj = schema; const properties = schemaObj.properties; if (!properties || typeof properties !== 'object') { return []; } return Object.keys(properties); } /** * Extracts property names from a Zod object schema. * @param zodSchema - The Zod schema * @returns Array of property names */ export function extractZodSchemaParams(zodSchema) { // Check if it's a ZodObject by checking for shape property // ZodObject has a shape property in its _def const def = zodSchema._def; if (!def || !('shape' in def)) { return []; } const shape = def.shape; if (!shape || typeof shape !== 'object') { return []; } return Object.keys(shape); } /** * Validates that param schema properties match route path parameters. * @param routePath - The route path * @param paramsSchema - The params schema (JSON Schema or Zod) * @param schemaType - The type of schema ('zod' | 'json' | undefined) * @param fullPath - The full path to the route file (for error messages) * @throws Error if schema properties don't match route parameters */ export function validateParamsSchema(routePath, paramsSchema, schemaType, fullPath) { // Skip validation if no params schema if (!paramsSchema) { return; } // Extract route parameters const routeParams = extractRouteParams(routePath); // Skip validation for wildcard routes (already handled in extractRouteParams) if (routePath.includes('*')) { return; } // Extract schema properties based on schema type let schemaParams = []; if (schemaType === 'zod' && isZodSchema(paramsSchema)) { schemaParams = extractZodSchemaParams(paramsSchema); } else if (schemaType === 'json') { schemaParams = extractJsonSchemaParams(paramsSchema); } else if (schemaType === undefined) { // Try to detect schema type if (isZodSchema(paramsSchema)) { schemaParams = extractZodSchemaParams(paramsSchema); } else { schemaParams = extractJsonSchemaParams(paramsSchema); } } // If schema has no properties, skip validation if (schemaParams.length === 0) { return; } // Check that all schema properties exist in route parameters const missingInRoute = schemaParams.filter((param) => !routeParams.includes(param)); if (missingInRoute.length > 0) { throw new Error(`Parameter schema mismatch in file ${fullPath}\n` + ` Route path: ${routePath}\n` + ` Schema defines parameter(s): ${schemaParams.join(', ')}\n` + ` But route path only has parameter(s): ${routeParams.length > 0 ? routeParams.join(', ') : 'none'}\n` + ` Missing in route path: ${missingInRoute.join(', ')}`); } } /** * Registers routes from a directory recursively. * @param fastify - The Fastify instance * @param mount - The mount point for routes * @param extensions - Valid file extensions * @param convention - The route convention to use * @param logLevel - The log level for messages * @param excludePatterns - Patterns for files to exclude * @param dir - The directory to scan * @param baseRootDir - The base root directory for calculating route paths */ export async function registerRoutes(fastify, mount, extensions, convention, logLevel, excludePatterns, dir, baseRootDir, logRoutes = false, zodResponseValidation = false, runtimeContext) { const context = runtimeContext ?? createRouteRegistrationRuntimeContext({ maxConcurrentTasks: 64, profiling: { enabled: false }, }); const createDirectoryScanTask = (directoryPath) => { return async () => { const readStart = performance.now(); const dirents = await fs.readdir(directoryPath, { withFileTypes: true }); const readdirMs = performance.now() - readStart; if (context.profiling.enabled) { context.profiling.directories.push({ dirPath: directoryPath, readdirMs, entryCount: dirents.length, }); } const baseSegments = path .relative(baseRootDir, directoryPath) .split(path.sep) .filter(Boolean) .filter((seg) => !isRouteGroupSegment(seg)); const nextTasks = []; for (const dirent of dirents) { const fileName = dirent.name; const matchingExcludePattern = shouldExcludeFile(fileName, excludePatterns); if (matchingExcludePattern) { fastify.log[logLevel](`Ignoring ${fileName} as it matches the exclude pattern ${matchingExcludePattern.source}`); continue; } const fullPath = path.join(directoryPath, fileName); if (dirent.isDirectory()) { nextTasks.push(createDirectoryScanTask(fullPath)); continue; } nextTasks.push(createRouteRegistrationTask(fullPath, fileName, baseSegments)); } return nextTasks; }; }; const createRouteRegistrationTask = (fullPath, fileName, baseSegments) => { return async () => { const routeStart = performance.now(); // Parse filename const parsed = parseFileName(fileName, extensions, fullPath); if (!parsed) { fastify.log[logLevel](`Ignoring file ${fullPath} as its extension, ${`.${fileName.split('.').pop()}`}, isn't in the list of extensions.`); return []; } const { routeSegments, method } = parsed; const typedMethod = toHttpMethod(method, fullPath); // Convert route segments to route path const routePath = convertRoutePath([...baseSegments, ...routeSegments], convention, fullPath); // Import and register the route const importStart = performance.now(); const handlerModule = (await import(fullPath)); const importMs = performance.now() - importStart; const url = buildUrl(routePath, mount); // Check if this is a defineRoute pattern (route export) let handler; let schema; if (handlerModule.route) { // New pattern: route defined using defineRoute() if (typeof handlerModule.route.handler !== 'function') { throw new Error(`Route handler in file ${fullPath} is not a function`); } if (handlerModule.route.schema && typeof handlerModule.route.schema !== 'object') { throw new Error(`Route schema in file ${fullPath} is not an object`); } handler = handlerModule.route.handler; schema = handlerModule.route.schema; } else { // Legacy pattern: default export + optional schema export if (typeof handlerModule.default !== 'function') { throw new Error(`Default export in file ${fullPath} is not a function`); } if (handlerModule.schema && typeof handlerModule.schema !== 'object') { throw new Error(`Schema export in file ${fullPath} is not an object`); } handler = handlerModule.default; schema = handlerModule.schema; } // Check if logLevel is verbose (debug or trace) if (logRoutes) { fastify.log[logLevel](`Registering route ${typedMethod.toUpperCase()} ${url} ${schema ? '(with schema)' : ''} from ${fullPath}`); } const prepareStart = performance.now(); // Check if this is a mixed schema route (has __zodSchemas or __schemaTypes property) const zodSchemas = schema && '__zodSchemas' in schema ? schema.__zodSchemas : undefined; const schemaTypes = schema && '__schemaTypes' in schema ? schema.__schemaTypes : undefined; // Validate that param schema properties match route path parameters if (schema?.params) { const paramsSchemaType = schemaTypes && typeof schemaTypes === 'object' && 'params' in schemaTypes ? schemaTypes.params : undefined; // Get the actual params schema (could be Zod or JSON Schema) // For Zod schemas, use the original Zod schema from zodSchemas if available // Otherwise, use the JSON Schema from schema.params let paramsSchema = schema.params; if (paramsSchemaType === 'zod' && zodSchemas && typeof zodSchemas === 'object' && 'params' in zodSchemas) { const zodParamsSchema = zodSchemas.params; if (zodParamsSchema) { paramsSchema = zodParamsSchema; } } validateParamsSchema(routePath, paramsSchema, paramsSchemaType, fullPath); } // Check if we have any Zod schemas (if so, we need custom validation) const hasZodSchemas = zodSchemas && Object.keys(zodSchemas).length > 0; const hasSchemaTypes = schemaTypes && Object.keys(schemaTypes).length > 0; // Check if we have Zod response schemas specifically const zodResponseSchemas = zodSchemas && typeof zodSchemas === 'object' && 'response' in zodSchemas ? zodSchemas.response : undefined; const hasZodResponseSchemas = zodResponseValidation && zodResponseSchemas && Object.keys(zodResponseSchemas).length > 0; // If we have schema types defined, we need to validate in preValidation for consistent error handling const needsCustomValidation = hasZodSchemas || hasSchemaTypes; // Always pass schema to Fastify for Swagger/OpenAPI documentation generation. // Custom validation happens in preValidation hooks, so passing the schema doesn't interfere. // The schema contains JSON Schema (converted from Zod schemas) which Swagger needs for documentation. // Create a clean schema object without our internal metadata for Fastify // We need to explicitly construct the schema to ensure all properties are present const fastifySchema = schema ? (() => { const cleanSchema = {}; if (schema.params) cleanSchema.params = schema.params; if (schema.querystring) cleanSchema.querystring = schema.querystring; if (schema.body) cleanSchema.body = schema.body; if (schema.headers) cleanSchema.headers = schema.headers; if (schema.response) cleanSchema.response = schema.response; // Copy other properties (description, tags, etc.) but exclude internal metadata for (const [key, value] of Object.entries(schema)) { if (!['params', 'querystring', 'body', 'headers', 'response', '__zodSchemas', '__schemaTypes'].includes(key)) { cleanSchema[key] = value; } } return cleanSchema; })() : undefined; // Set up route options - if we have custom validation, disable Fastify's validators // BEFORE setting the schema, so Fastify uses our no-op validators const routeOptions = { method: typedMethod, url, schema: fastifySchema, // Always pass schema for Swagger/OpenAPI documentation handler, // Set validatorCompiler and serializerCompiler BEFORE adding preValidation hook // to ensure Fastify uses our no-op validators instead of default ones ...(needsCustomValidation ? { // Disable Fastify's validator when we have custom request validation to avoid // double validation and conflicts between Zod validation and JSON Schema validation. // Swagger will still process the schema for documentation even with no-op validators. // The validatorCompiler signature: ({ schema, method, url, httpPart }) => (data) => result // Returns a function that always returns true (validation passes) // Match the exact signature from Fastify tests to ensure compatibility validatorCompiler: // biome-ignore lint/correctness/noUnusedFunctionParameters lint/nursery/noShadow: Parameters must match Fastify's signature ({ schema: _schema, method: _method, url: _url, httpPart: _httpPart }) => { return () => true; // Always pass validation - we handle it in preValidation hook }, // Only disable serializerCompiler if we have Zod response schemas and validation is enabled // Otherwise, let Fastify handle JSON Schema response validation ...(hasZodResponseSchemas ? { // Disable serialization validation for Zod response schemas - we handle it in preSerialization hook // We use JSON.stringify to bypass fast-json-stringify's schema filtering serializerCompiler: // biome-ignore lint/correctness/noUnusedFunctionParameters lint/nursery/noShadow: Parameters must match Fastify's signature ({ schema: _schema, method: _method, url: _url, httpStatus: _httpStatus, contentType: _contentType, }) => (data) => JSON.stringify(data), } : {}), } : {}), }; // Add preValidation hook for routes with Zod schemas or mixed schemas if (needsCustomValidation) { const ajvInstance = await context.getAjv(); const types = (schemaTypes || {}); const jsonValidators = {}; if (ajvInstance) { if (types.params === 'json' && schema?.params) { jsonValidators.params = ajvInstance.compile(schema.params); } if (types.querystring === 'json' && schema?.querystring) { jsonValidators.querystring = ajvInstance.compile(schema.querystring); } if (types.body === 'json' && schema?.body) { jsonValidators.body = ajvInstance.compile(schema.body); } if (types.headers === 'json' && schema?.headers) { jsonValidators.headers = ajvInstance.compile(schema.headers); } } routeOptions.preValidation = async (request, reply) => { const zodSchema = (zodSchemas || {}); // Validate params if (types.params === 'zod' && zodSchema.params) { const paramsResult = zodSchema.params.safeParse(request.params); if (!paramsResult.success) { return reply.status(400).send({ error: formatZodError(paramsResult.error, 'params'), }); } // Replace request.params with validated data request.params = paramsResult.data; } else if (types.params === 'json' && jsonValidators.params) { if (!jsonValidators.params(request.params)) { return reply.status(400).send({ error: formatJsonSchemaError(jsonValidators.params.errors || [], 'params'), }); } } // Validate querystring (Fastify uses request.query, not request.querystring) if (types.querystring === 'zod' && zodSchema.querystring) { const querystringResult = zodSchema.querystring.safeParse(request.query); if (!querystringResult.success) { return reply.status(400).send({ error: formatZodError(querystringResult.error, 'querystring'), }); } // Replace request.query with validated data request.query = querystringResult.data; } else if (types.querystring === 'json' && jsonValidators.querystring) { // Coerce querystring values (HTTP querystring params are always strings) // Convert 'true'/'false' strings to booleans, numbers to numbers, etc. const queryObj = request.query; const coercedQuery = {}; for (const [key, value] of Object.entries(queryObj || {})) { if (typeof value === 'string') { // Try to coerce boolean strings if (value === 'true') { coercedQuery[key] = true; } else if (value === 'false') { coercedQuery[key] = false; } else { // Try to coerce to number const numValue = Number(value); if (!Number.isNaN(numValue) && value.trim() !== '' && !value.includes('.')) { coercedQuery[key] = numValue; } else { coercedQuery[key] = value; } } } else { coercedQuery[key] = value; } } // Validate the coerced query object if (!jsonValidators.querystring(coercedQuery)) { return reply.status(400).send({ error: formatJsonSchemaError(jsonValidators.querystring.errors || [], 'querystring'), }); } // Update request.query with coerced values (Ajv may have further modified them) request.query = coercedQuery; } // Validate body if (types.body === 'zod' && zodSchema.body) { const bodyResult = zodSchema.body.safeParse(request.body); if (!bodyResult.success) { return reply.status(400).send({ error: formatZodError(bodyResult.error, 'body'), }); } // Replace request.body with validated data request.body = bodyResult.data; } else if (types.body === 'json' && jsonValidators.body) { if (!jsonValidators.body(request.body)) { return reply.status(400).send({ error: formatJsonSchemaError(jsonValidators.body.errors || [], 'body'), }); } } // Validate headers if (types.headers === 'zod' && zodSchema.headers) { const headersResult = zodSchema.headers.safeParse(request.headers); if (!headersResult.success) { return reply.status(400).send({ error: formatZodError(headersResult.error, 'headers'), }); } // Replace request.headers with validated data request.headers = headersResult.data; } else if (types.headers === 'json' && jsonValidators.headers) { if (!jsonValidators.headers(request.headers)) { return reply.status(400).send({ error: formatJsonSchemaError(jsonValidators.headers.errors || [], 'headers'), }); } } }; } // Add preSerialization hook for Zod response schemas when validation is enabled if (hasZodResponseSchemas && zodResponseSchemas) { const zodValidationHook = async ( // biome-ignore lint/correctness/noUnusedFunctionParameters: Parameters must match Fastify's signature _request, reply, payload) => { const statusCode = reply.statusCode; const zodResponseSchema = zodResponseSchemas[statusCode]; // If we have a Zod schema for this status code, validate the payload if (zodResponseSchema) { const result = zodResponseSchema.safeParse(payload); if (!result.success) { // Response validation failure is a server error (500) // We need to change the status code and payload before serialization fastify.log.error({ statusCode, validationError: result.error, payload, }, 'Response validation failed'); // Set status code and replace payload with error reply.code(500); return { error: 'Internal Server Error', message: 'Response validation failed', details: formatZodError(result.error, 'response'), }; } // Return validated data (Zod may have transformed it) return result.data; } // No Zod schema for this status code, return payload as-is // (JSON Schema validation will be handled by Fastify's serializerCompiler if enabled) return payload; }; // Handle case where preSerialization might already be set (e.g., by @fastify/response-validation) // Always use array format to be compatible with plugins that expect arrays // Note: @fastify/response-validation registers hooks via onRoute, which runs after route registration // So we set up our hook here, and it will be merged with plugin hooks if (Array.isArray(routeOptions.preSerialization)) { // Append our hook (plugin hooks will be added via onRoute) routeOptions.preSerialization.push(zodValidationHook); } else if (routeOptions.preSerialization) { // If it's already a function, convert to array routeOptions.preSerialization = [routeOptions.preSerialization, zodValidationHook]; } else { // Set as array to be compatible with @fastify/response-validation plugin routeOptions.preSerialization = [zodValidationHook]; } } const prepareMs = performance.now() - prepareStart; const registerStart = performance.now(); fastify.route(routeOptions); const registerMs = performance.now() - registerStart; const totalMs = performance.now() - routeStart; if (context.profiling.enabled) { context.profiling.routes.push({ filePath: fullPath, method: typedMethod, url, importMs, prepareMs, registerMs, totalMs, }); } return []; }; }; await executeTaskQueue([createDirectoryScanTask(dir)], context.taskLimit); } //# sourceMappingURL=routeRegistration.js.map