UNPKG

fastify-file-router

Version:

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

174 lines (173 loc) 7.41 kB
import path from 'node:path'; import fp from 'fastify-plugin'; import fs from 'fs/promises'; const validMethods = ['delete', 'get', 'head', 'patch', 'post', 'put']; const methodRegex = new RegExp(`^(${validMethods.join('|')})(\\..+)?$`); const segmentRegex = /^[$[]?.*[\]]?$/; const toHttpMethod = (method, fullPath) => { // Validate method if (method && !methodRegex.test(method)) { throw new Error(`Invalid method "${method}" in file ${fullPath}`); } return method; }; const toRouteRemixStyle = (segments, fullPath) => { // ensure that only valid characters or a remix parameter are present const remixSegment = /^\$?[^[\]$&]*$/; // regex that matches a leading $ and captures the rest as the named capture group "param", regardless of what character it is const remixParamRegex = /^\$(?<param>.*)$/; return segments .map((segment) => { // Validate remaining segments if (!remixSegment.test(segment)) { throw new Error(`Invalid segment "${segment}" in file ${fullPath}`); } const matchResult = segment.match(remixParamRegex); if (matchResult && matchResult.groups) { const param = matchResult.groups.param; if (param && param.length > 0) { return `:${param}`; } return '*'; } return segment; }) .join('/'); }; const toRouteNextStyle = (segments, fullPath) => { // ensures that only valid characters or a next.js parameter are present const nextSegment = /^[[]?(?:\.\.\.|[^[\]&]*)[\]]?$/; // regex that matches both a leading [ and trailing ], capturing the rest as the named capture group "param", regardless of what character it is const nextParamRegex = /^\[(?<param>.*)\]$/; return segments .map((segment) => { // Validate remaining segments if (!nextSegment.test(segment)) { throw new Error(`Invalid segment "${segment}" in file ${fullPath}`); } const matchResult = segment.match(nextParamRegex); if (matchResult && matchResult.groups) { const param = matchResult.groups.param; if (param && param.substring(0, 2) === '...') { return `*`; } if (param && param.length > 0) { return `:${param}`; } throw new Error(`Invalid segment "${segment}" in convention "next"`); } return segment; }) .join('/'); }; async function registerRoutes(fastify, mount, extensions, convention, logLevel, exclude, dir, baseRootDir) { const fileNames = await fs.readdir(dir); const baseSegments = dir.replace(baseRootDir, '').split('/').filter(Boolean); await Promise.all(fileNames.map(async (fileName) => { // compare against each excludePattern let matchingExcludePattern = undefined; for (const pattern of exclude) { if (pattern.test(fileName)) { matchingExcludePattern = pattern; break; } } if (matchingExcludePattern) { fastify.log[logLevel](`Ignoring ${fileName} as it matches the exclude pattern ${matchingExcludePattern.source}`); return; } const fullPath = path.join(dir, fileName); const stat = await fs.stat(fullPath); if (stat.isDirectory()) { await registerRoutes(fastify, mount, extensions, convention, logLevel, exclude, fullPath, baseRootDir); return; } const segments = fileName.split('.'); const extensionSegment = `.${segments[segments.length - 1]}`; const methodSegment = segments[segments.length - 2]; const routeSegments = segments.slice(0, -2); if (!extensions.includes(extensionSegment)) { fastify.log[logLevel](`Ignoring file ${fullPath} as its extension, ${extensionSegment}, isn't in the list of extensions.`); return; } if (segments.length < 2) { throw new Error(`Invalid file name "${fileName}" in file ${fullPath}, must have at least 2 segments separated by a dot`); } // get next to last segment as method const typedMethod = toHttpMethod(methodSegment, fullPath); let routePath; switch (convention) { case 'remix': routePath = toRouteRemixStyle([...baseSegments, ...routeSegments], fullPath); break; case 'next': routePath = toRouteNextStyle([...baseSegments, ...routeSegments], fullPath); break; default: throw new Error(`Invalid convention "${convention}"`); } const handlerModule = (await import(fullPath)); let url = routePath; // add mount if present if (mount !== '/') { url = `${mount}/${url}`; } // add preceding '/' if missing if (!url.startsWith('/')) { url = `/${url}`; } // Validate handler exports 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`); } fastify.log[logLevel](`Registering route ${typedMethod.toUpperCase()} ${url} ${handlerModule.schema ? '(with schema)' : ''}`); fastify.route({ method: typedMethod, url, schema: handlerModule.schema, handler: handlerModule.default }); })); } export const fastifyFileRouter = fp(async (fastify, { mount = '/', routesDirs = ['./routes', './src/routes'], buildRoot = '.', extensions = ['.js', '.ts', '.jsx', '.tsx'], convention = 'remix', logLevel = 'info', // ignore files that start with a dot, or underscore. Also ignore files that end in .test.js or .test.ts or include __tests__ in the path or include .d.ts // also ignore files that end in .spec.js or .spec.ts exclude: excludePatterns = [ /^[\.|_].*/, /\.(test|spec)\.[jt]s$/, /__(test|spec)__/, /\.d\.ts$/ ] }) => { const cwd = process.env.REMIX_ROOT ?? process.cwd(); extensions.map((extension) => { if (!extension.startsWith('.')) { throw new Error(`Invalid extension "${extension}", must start with a dot`); } }); let numberOfValidRouteDirs = 0; await Promise.all(routesDirs.map(async (routesDir) => { const sourceRouteDir = path.join(buildRoot ?? '', routesDir); const absoluteSourceRoutesDir = path.resolve(cwd, sourceRouteDir); let stats; try { stats = await fs.lstat(absoluteSourceRoutesDir); } catch (_e) { return; } if (stats.isDirectory()) { numberOfValidRouteDirs++; await registerRoutes(fastify, mount, extensions, convention, logLevel, excludePatterns, absoluteSourceRoutesDir, absoluteSourceRoutesDir); } })); if (numberOfValidRouteDirs === 0) { throw new Error(`None of routesDirs, [${routesDirs.join(', ')}], were valid directories.`); } }, { // replaced with the package name during build name: 'fastify-file-router', fastify: '5.x' });