fastify-file-router
Version:
A fastify plugin that automatically registers routes from files in a directory.
680 lines • 33.3 kB
JavaScript
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