diff --git a/index.js b/index.js index 4bf9347..3285f5a 100644 --- a/index.js +++ b/index.js @@ -1,12 +1,10 @@ 'use strict' -const { promises: { readdir, readFile } } = require('node:fs') -const { join, relative, sep } = require('node:path') +const { readFile } = require('node:fs/promises') +const { join, sep } = require('node:path') +const findPlugins = require('./lib/find-plugins') +const runtime = require('./lib/runtime') const { pathToFileURL } = require('node:url') -const runtime = require('./runtime') - -const routeParamPattern = /\/_/gu -const routeMixedParamPattern = /__/gu const defaults = { scriptPattern: /(?:(?:^.?|\.[^d]|[^.]d|[^.][^d])\.ts|\.js|\.cjs|\.mjs|\.cts|\.mts)$/iu, @@ -19,236 +17,46 @@ const defaults = { const fastifyAutoload = async function autoload (fastify, options) { const packageType = await getPackageType(options.dir) const opts = { ...defaults, packageType, ...options } - const pluginTree = await findPlugins(opts.dir, opts) - const pluginsMeta = {} - const hooksMeta = {} - - const pluginArray = [].concat.apply([], Object.values(pluginTree).map(o => o.plugins)) - const hookArray = [].concat.apply([], Object.values(pluginTree).map(o => o.hooks)) - - await Promise.all(pluginArray.map(({ file, type, prefix }) => { - return loadPlugin({ file, type, directoryPrefix: prefix, options: opts, log: fastify.log }) - .then((plugin) => { - if (plugin) { - // create route parameters from prefixed folders - if (options.routeParams) { - plugin.options.prefix = plugin.options.prefix - ? replaceRouteParamPattern(plugin.options.prefix) - : plugin.options.prefix - } - pluginsMeta[plugin.name] = plugin - } - }) - .catch((err) => { - throw enrichError(err) - }) - })) - - function replaceRouteParamPattern (pattern) { - const isRegularRouteParam = pattern.match(routeParamPattern) - const isMixedRouteParam = pattern.match(routeMixedParamPattern) - - if (isMixedRouteParam) { - return pattern.replace(routeMixedParamPattern, ':') - } else if (isRegularRouteParam) { - return pattern.replace(routeParamPattern, '/:') - } else { - return pattern - } - } - - await Promise.all(hookArray.map((h) => { - return loadHook(h, opts) - .then((hookPlugin) => { - hooksMeta[h.file] = hookPlugin - }) - .catch((err) => { - throw enrichError(err) - }) - })) - - const metas = Object.values(pluginsMeta) - for (const prefix in pluginTree) { - const hookFiles = pluginTree[prefix].hooks - const pluginFiles = pluginTree[prefix].plugins - if (hookFiles.length === 0) { - registerAllPlugins(fastify, pluginFiles) - } else { - const composedPlugin = async function (app) { - // find hook functions for this prefix - for (const hookFile of hookFiles) { - const hookPlugin = hooksMeta[hookFile.file] - // encapsulate hooks at plugin level - app.register(hookPlugin) - } - registerAllPlugins(app, pluginFiles) - } - fastify.register(composedPlugin) - } - } - - function registerAllPlugins (app, pluginFiles) { - for (const pluginFile of pluginFiles) { - // find plugins for this prefix, based on filename stored in registerPlugins() - const plugin = metas.find((i) => i.filename === pluginFile.file) - // register plugins at fastify level - if (plugin) registerPlugin(app, plugin, pluginsMeta) - } - } -} - -async function getPackageType (cwd) { - const directories = cwd.split(sep) - - /* c8 ignore start */ - // required for paths that begin with the sep, such as linux root - // ignore because OS specific evaluation - directories[0] = directories[0] !== '' ? directories[0] : sep - /* c8 ignore stop */ - while (directories.length > 0) { - const filePath = join(...directories, 'package.json') - - const fileContents = await readFile(filePath, 'utf-8') - .catch(() => null) - - if (fileContents) { - return JSON.parse(fileContents).type - } - - directories.pop() - } -} + const pluginTree = await findPlugins(opts.dir, { opts }) -const typescriptPattern = /\.(ts|mts|cts)$/iu -const modulePattern = /\.(mjs|mts)$/iu -const commonjsPattern = /\.(cjs|cts)$/iu -function getScriptType (fname, packageType) { - return { - language: typescriptPattern.test(fname) ? 'typescript' : 'javascript', - type: (modulePattern.test(fname) ? 'module' : commonjsPattern.test(fname) ? 'commonjs' : packageType) || 'commonjs' - } + await loadPlugins({ pluginTree, options, opts, fastify }) } -// eslint-disable-next-line default-param-last -async function findPlugins (dir, options, hookedAccumulator = {}, prefix, depth = 0, hooks = []) { - const { indexPattern, ignorePattern, ignoreFilter, matchFilter, scriptPattern, dirNameRoutePrefix, maxDepth, autoHooksPattern } = options - const list = await readdir(dir, { withFileTypes: true }) - let currentHooks = [] - - // check to see if hooks or plugins have been added to this prefix, initialize if not - if (!hookedAccumulator[prefix || '/']) hookedAccumulator[prefix || '/'] = { hooks: [], plugins: [] } - - if (options.autoHooks) { - // Hooks were passed in, create new array specific to this plugin item - if (hooks && hooks.length > 0) { - for (const hook of hooks) { - currentHooks.push(hook) - } +async function loadPlugins ({ pluginTree, options, opts, fastify }) { + for (const key in pluginTree) { + const node = { + ...pluginTree[key], + pluginsMeta: {}, + hooksMeta: {} } - // Contains autohooks file? - const autoHooks = list.find((dirent) => autoHooksPattern.test(dirent.name)) - if (autoHooks) { - const autoHooksFile = join(dir, autoHooks.name) - const { type: autoHooksType } = getScriptType(autoHooksFile, options.packageType) - - // Overwrite current hooks? - if (options.overwriteHooks && currentHooks.length > 0) { - currentHooks = [] - } - - // Add hook to current chain - currentHooks.push({ file: autoHooksFile, type: autoHooksType }) - } - - hookedAccumulator[prefix || '/'].hooks = currentHooks - } - - // Contains index file? - const indexDirent = list.find((dirent) => indexPattern.test(dirent.name)) - if (indexDirent) { - const file = join(dir, indexDirent.name) - const { language, type } = getScriptType(file, options.packageType) - if (language === 'typescript' && !runtime.supportTypeScript) { - throw new Error(`@fastify/autoload cannot import hooks plugin at '${file}'. To fix this error compile TypeScript to JavaScript or use 'ts-node' to run your app.`) - } - - accumulatePlugin({ file, type }) - const hasDirectory = list.find((dirent) => dirent.isDirectory()) - - if (!hasDirectory) { - return hookedAccumulator - } - } - - // Contains package.json but no index.js file? - const packageDirent = list.find((dirent) => dirent.name === 'package.json') - if (packageDirent && !indexDirent) { - throw new Error(`@fastify/autoload cannot import plugin at '${dir}'. To fix this error rename the main entry file to 'index.js' (or .cjs, .mjs, .ts).`) - } - - // Otherwise treat each script file as a plugin - const directoryPromises = [] - for (const dirent of list) { - if (ignorePattern && dirent.name.match(ignorePattern)) { - continue - } - - const atMaxDepth = Number.isFinite(maxDepth) && maxDepth <= depth - const file = join(dir, dirent.name) - if (dirent.isDirectory() && !atMaxDepth) { - let prefixBreadCrumb = (prefix ? `${prefix}/` : '/') - if (dirNameRoutePrefix === true) { - prefixBreadCrumb += dirent.name - } else if (typeof dirNameRoutePrefix === 'function') { - const prefixReplacer = dirNameRoutePrefix(dir, dirent.name) - if (prefixReplacer) { - prefixBreadCrumb += prefixReplacer - } - } - - // Pass hooks forward to next level - if (options.autoHooks && options.cascadeHooks) { - directoryPromises.push(findPlugins(file, options, hookedAccumulator, prefixBreadCrumb, depth + 1, currentHooks)) - } else { - directoryPromises.push(findPlugins(file, options, hookedAccumulator, prefixBreadCrumb, depth + 1)) - } - - continue - } else if (indexDirent) { - // An index.js file is present in the directory so we ignore the others modules (but not the subdirectories) - continue - } - - if (dirent.isFile() && scriptPattern.test(dirent.name)) { - const { language, type } = getScriptType(file, options.packageType) - if (language === 'typescript' && !runtime.supportTypeScript) { - throw new Error(`@fastify/autoload cannot import plugin at '${file}'. To fix this error compile TypeScript to JavaScript or use 'ts-node' to run your app.`) - } - - // Don't place hook in plugin queue - if (!autoHooksPattern.test(dirent.name)) { - accumulatePlugin({ file, type }) - } - } - } - await Promise.all(directoryPromises) - - return hookedAccumulator - - function accumulatePlugin ({ file, type }) { - // Replace backward slash to forward slash for consistent behavior between windows and posix. - const filePath = '/' + relative(options.dir, file).replace(/\\/gu, '/') - - if (matchFilter && !filterPath(filePath, matchFilter)) { - return - } - - if (ignoreFilter && filterPath(filePath, ignoreFilter)) { - return - } - - hookedAccumulator[prefix || '/'].plugins.push({ file, type, prefix }) + await Promise.all(node.plugins.map(({ file, type, prefix }) => { + return loadPlugin({ file, type, directoryPrefix: prefix, options: opts, log: fastify.log }) + .then((plugin) => { + if (plugin) { + // create route parameters from prefixed folders + if (options.routeParams && plugin.options.prefix) { + plugin.options.prefix = replaceRouteParamPattern(plugin.options.prefix) + } + node.pluginsMeta[plugin.name] = plugin + } + }) + .catch((err) => { + throw enrichError(err) + }) + })) + + await Promise.all(node.hooks.map((h) => { + return loadHook(h, opts) + .then((hookPlugin) => { + node.hooksMeta[h.file] = hookPlugin + }) + .catch((err) => { + throw enrichError(err) + }) + })) + + registerNode(node, fastify) } } @@ -270,18 +78,7 @@ async function loadPlugin ({ file, type, directoryPrefix, options, log }) { } const plugin = wrapRoutes(content.default || content) - const pluginConfig = (content.default && content.default.autoConfig) || content.autoConfig || {} - let pluginOptions - if (typeof pluginConfig === 'function') { - pluginOptions = function (fastify) { - return { ...pluginConfig(fastify), ...overrideConfig } - } - - pluginOptions.prefix = overrideConfig.prefix ?? pluginConfig.prefix - } else { - pluginOptions = { ...pluginConfig, ...overrideConfig } - } - + const pluginOptions = loadPluginOptions(content, overrideConfig) const pluginMeta = plugin[Symbol.for('plugin-meta')] || {} if (!encapsulate) { @@ -298,14 +95,7 @@ async function loadPlugin ({ file, type, directoryPrefix, options, log }) { plugin.autoConfig = undefined } - pluginOptions.prefix = (pluginOptions.prefix && pluginOptions.prefix.endsWith('/')) ? pluginOptions.prefix.slice(0, -1) : pluginOptions.prefix - const prefixOverride = plugin.prefixOverride !== undefined ? plugin.prefixOverride : content.prefixOverride !== undefined ? content.prefixOverride : undefined - const prefix = (plugin.autoPrefix !== undefined ? plugin.autoPrefix : content.autoPrefix !== undefined ? content.autoPrefix : undefined) || directoryPrefix - if (prefixOverride !== undefined) { - pluginOptions.prefix = prefixOverride - } else if (prefix) { - pluginOptions.prefix = (pluginOptions.prefix || '') + prefix.replace(/\/+/gu, '/') - } + handlePrefixConfig({ plugin, pluginOptions, content, directoryPrefix }) return { plugin, @@ -317,6 +107,52 @@ async function loadPlugin ({ file, type, directoryPrefix, options, log }) { } } +async function loadHook (hook, options) { + let hookContent + if (options.forceESM || hook.type === 'module' || runtime.forceESM) { + hookContent = await import(pathToFileURL(hook.file).href) + } else { + hookContent = require(hook.file) + } + + hookContent = hookContent.default || hookContent + + const type = Object.prototype.toString.call(hookContent) + if (type === '[object AsyncFunction]' || type === '[object Function]') { + hookContent[Symbol.for('skip-override')] = true + } + + return hookContent +} + +function registerNode (node, fastify) { + if (node.hooks.length === 0) { + registerAllPlugins(fastify, node) + } else { + const composedPlugin = async function (app) { + // find hook functions for this prefix + for (const hookFile of node.hooks) { + const hookPlugin = node.hooksMeta[hookFile.file] + // encapsulate hooks at plugin level + app.register(hookPlugin) + } + + registerAllPlugins(app, node) + } + fastify.register(composedPlugin) + } +} + +function registerAllPlugins (app, node) { + const metas = Object.values(node.pluginsMeta) + for (const pluginFile of node.plugins) { + // find plugins for this prefix, based on filename stored in registerPlugins() + const plugin = metas.find((i) => i.filename === pluginFile.file) + // register plugins at fastify level + if (plugin) registerPlugin(app, plugin, node.pluginsMeta) + } +} + function registerPlugin (fastify, meta, allPlugins, parentPlugins = {}) { const { plugin, name, options, dependencies = [] } = meta @@ -342,16 +178,50 @@ function registerPlugin (fastify, meta, allPlugins, parentPlugins = {}) { meta.registered = true } -function filterPath (path, filter) { - if (typeof filter === 'string') { - return path.includes(filter) +function loadPluginOptions (content, overrideConfig) { + const pluginConfig = (content.default?.autoConfig) || content.autoConfig || {} + if (typeof pluginConfig === 'function') { + const pluginOptions = (fastify) => ({ ...pluginConfig(fastify), ...overrideConfig }) + pluginOptions.prefix = overrideConfig.prefix ?? pluginConfig.prefix + + return pluginOptions + } + + return { ...pluginConfig, ...overrideConfig } +} + +function handlePrefixConfig ({ plugin, pluginOptions, content, directoryPrefix }) { + if (pluginOptions.prefix?.endsWith('/')) { + pluginOptions.prefix = pluginOptions.prefix.slice(0, -1) } - if (filter instanceof RegExp) { - return filter.test(path) + let prefix + if (plugin.autoPrefix !== undefined) { + prefix = plugin.autoPrefix + } else if (content.autoPrefix !== undefined) { + prefix = content.autoPrefix + } else { + prefix = directoryPrefix } - return filter(path) + const prefixOverride = plugin.prefixOverride ?? content.prefixOverride + if (prefixOverride !== undefined) { + pluginOptions.prefix = prefixOverride + } else if (prefix) { + pluginOptions.prefix = (pluginOptions.prefix || '') + prefix.replace(/\/+/gu, '/') + } +} + +const routeParamPattern = /\/_/gu +const routeMixedParamPattern = /__/gu +function replaceRouteParamPattern (pattern) { + if (pattern.match(routeMixedParamPattern)) { + return pattern.replace(routeMixedParamPattern, ':') + } else if (pattern.match(routeParamPattern)) { + return pattern.replace(routeParamPattern, '/:') + } + + return pattern } /** @@ -364,12 +234,9 @@ function filterPath (path, filter) { * False otherwise. */ function isRouteObject (input) { - if (input && - Object.prototype.toString.call(input) === '[object Object]' && - Object.prototype.hasOwnProperty.call(input, 'method')) { - return true - } - return false + return !!(input && + Object.prototype.toString.call(input) === '[object Object]' && + Object.hasOwn(input, 'method')) } const pluginOrModulePattern = /\[object (?:AsyncFunction|Function|Module)\]/u @@ -389,7 +256,7 @@ function isPluginOrModule (input) { const inputType = Object.prototype.toString.call(input) if (pluginOrModulePattern.test(inputType) === true) { result = true - } else if (Object.prototype.hasOwnProperty.call(input, 'default')) { + } else if (Object.hasOwn(input, 'default')) { result = isPluginOrModule(input.default) } else { result = isRouteObject(input) @@ -404,27 +271,8 @@ function wrapRoutes (content) { fastify.route(content) } } - return content -} - -async function loadHook (hook, options) { - let hookContent - if (options.forceESM || hook.type === 'module' || runtime.forceESM) { - hookContent = await import(pathToFileURL(hook.file).href) - } else { - hookContent = require(hook.file) - } - hookContent = hookContent.default || hookContent - - if ( - Object.prototype.toString.call(hookContent) === '[object AsyncFunction]' || - Object.prototype.toString.call(hookContent) === '[object Function]' - ) { - hookContent[Symbol.for('skip-override')] = true - } - - return hookContent + return content } function enrichError (err) { @@ -438,6 +286,28 @@ function enrichError (err) { return err } +async function getPackageType (cwd) { + const directories = cwd.split(sep) + + /* c8 ignore start */ + // required for paths that begin with the sep, such as linux root + // ignore because OS specific evaluation + directories[0] = directories[0] !== '' ? directories[0] : sep + /* c8 ignore stop */ + while (directories.length > 0) { + const filePath = join(...directories, 'package.json') + + const fileContents = await readFile(filePath, 'utf-8') + .catch(() => null) + + if (fileContents) { + return JSON.parse(fileContents).type + } + + directories.pop() + } +} + // do not create a new context, do not encapsulate // same as fastify-plugin fastifyAutoload[Symbol.for('skip-override')] = true diff --git a/lib/find-plugins.js b/lib/find-plugins.js new file mode 100644 index 0000000..631d870 --- /dev/null +++ b/lib/find-plugins.js @@ -0,0 +1,186 @@ +'use strict' + +const { readdir } = require('node:fs/promises') +const { relative, join } = require('path') +const runtime = require('./runtime') + +async function findPlugins (dir, options) { + const { opts, prefix } = options + + const pluginTree = { + [prefix || '/']: { hooks: [], plugins: [] } + } + + await buildTree(pluginTree, dir, { prefix, opts, depth: 0, hooks: [] }) + + return pluginTree +} + +async function buildTree (pluginTree, dir, { prefix, opts, depth, hooks }) { + // check to see if hooks or plugins have been added to this prefix, initialize if not + if (!pluginTree[prefix]) { + pluginTree[prefix] = { hooks: [], plugins: [] } + } + + const dirEntries = await readdir(dir, { withFileTypes: true }) + + const currentDirHooks = findCurrentDirHooks(pluginTree, { dir, dirEntries, hooks, opts, prefix }) + + const { indexDirEntry, hasNoDirectory } = processIndexDirEntryIfExists(pluginTree, { dirEntries, opts, dir, prefix }) + if (hasNoDirectory) { + return + } + + // Contains package.json but no index.js file? + const packageDirEntry = dirEntries.find((dirEntry) => dirEntry.name === 'package.json') + if (packageDirEntry && !indexDirEntry) { + throw new Error(`@fastify/autoload cannot import plugin at '${dir}'. To fix this error rename the main entry file to 'index.js' (or .cjs, .mjs, .ts).`) + } + + // Otherwise treat each script file as a plugin + await processDirContents(pluginTree, { dirEntries, opts, indexDirEntry, prefix, dir, depth, currentDirHooks }) +} + +function findCurrentDirHooks (pluginTree, { dir, dirEntries, hooks, opts, prefix }) { + if (!opts.autoHooks) return [] + + let currentDirHooks = [] + // Hooks were passed in, create new array specific to this plugin item + for (const hook of hooks) { + currentDirHooks.push(hook) + } + + // Contains autohooks file? + const autoHooks = dirEntries.find((dirEntry) => opts.autoHooksPattern.test(dirEntry.name)) + if (autoHooks) { + const file = join(dir, autoHooks.name) + const { type } = getScriptType(file, opts.packageType) + + // Overwrite current hooks? + if (opts.overwriteHooks && currentDirHooks.length > 0) { + currentDirHooks = [] + } + + // Add hook to current chain + currentDirHooks.push({ file, type }) + } + + pluginTree[prefix || '/'].hooks = currentDirHooks + + return currentDirHooks +} + +function processIndexDirEntryIfExists (pluginTree, { opts, dirEntries, dir, prefix }) { + // Contains index file? + const indexDirEntry = dirEntries.find((dirEntry) => opts.indexPattern.test(dirEntry.name)) + if (!indexDirEntry) return { indexDirEntry } + + const file = join(dir, indexDirEntry.name) + const { language, type } = getScriptType(file, opts.packageType) + handleTypeScriptSupport(file, language, true) + accumulatePlugin({ file, type, opts, pluginTree, prefix }) + + const hasNoDirectory = dirEntries.every((dirEntry) => !dirEntry.isDirectory()) + + return { indexDirEntry, hasNoDirectory } +} + +async function processDirContents (pluginTree, { dirEntries, opts, indexDirEntry, prefix, dir, depth, currentDirHooks }) { + for (const dirEntry of dirEntries) { + if (opts.ignorePattern && RegExp(opts.ignorePattern).exec(dirEntry.name)) { + continue + } + + const atMaxDepth = Number.isFinite(opts.maxDepth) && opts.maxDepth <= depth + const file = join(dir, dirEntry.name) + if (dirEntry.isDirectory() && !atMaxDepth) { + await processDirectory(pluginTree, { prefix, opts, dirEntry, dir, file, depth, currentDirHooks }) + } else if (indexDirEntry) { + // An index.js file is present in the directory so we ignore the others modules (but not the subdirectories) + } else if (dirEntry.isFile() && opts.scriptPattern.test(dirEntry.name)) { + processFile(pluginTree, { file, opts, dirEntry, pluginTree, prefix }) + } + } +} + +async function processDirectory (pluginTree, { prefix, opts, dirEntry, dir, file, depth, currentDirHooks }) { + let prefixBreadCrumb = (prefix ? `${prefix}/` : '/') + if (opts.dirNameRoutePrefix === true) { + prefixBreadCrumb += dirEntry.name + } else if (typeof opts.dirNameRoutePrefix === 'function') { + const prefixReplacer = opts.dirNameRoutePrefix(dir, dirEntry.name) + if (prefixReplacer) { + prefixBreadCrumb += prefixReplacer + } + } + + // Pass hooks forward to next level + const hooks = opts.autoHooks && opts.cascadeHooks ? currentDirHooks : [] + await buildTree(pluginTree, file, { opts, prefix: prefixBreadCrumb, depth: depth + 1, hooks }) +} + +function processFile (pluginTree, { file, opts, dirEntry, prefix }) { + const { language, type } = getScriptType(file, opts.packageType) + handleTypeScriptSupport(file, language) + + // Don't place hook in plugin queue + if (!opts.autoHooksPattern.test(dirEntry.name)) { + accumulatePlugin({ file, type, opts, pluginTree, prefix }) + } +} + +function accumulatePlugin ({ file, type, opts, pluginTree, prefix }) { + // Replace backward slash to forward slash for consistent behavior between windows and posix. + const filePath = '/' + relative(opts.dir, file).replace(/\\/gu, '/') + if (opts.matchFilter && !filterPath(filePath, opts.matchFilter)) { + return + } + + if (opts.ignoreFilter && filterPath(filePath, opts.ignoreFilter)) { + return + } + + pluginTree[prefix || '/'].plugins.push({ file, type, prefix }) +} + +function handleTypeScriptSupport (file, language, isHook = false) { + if (language === 'typescript' && !runtime.supportTypeScript) { + throw new Error(`@fastify/autoload cannot import ${isHook ? 'hooks ' : ''}plugin at '${file}'. To fix this error compile TypeScript to JavaScript or use 'ts-node' to run your app.`) + } +} + +function filterPath (path, filter) { + if (typeof filter === 'string') { + return path.includes(filter) + } + + if (filter instanceof RegExp) { + return filter.test(path) + } + + return filter(path) +} + +const typescriptPattern = /\.(ts|mts|cts)$/iu +function getScriptType (fname, packageType) { + return { + language: typescriptPattern.test(fname) ? 'typescript' : 'javascript', + type: determineModuleType(fname, packageType) + } +} + +const modulePattern = /\.(mjs|mts)$/iu +const commonjsPattern = /\.(cjs|cts)$/iu +function determineModuleType (fname, defaultType) { + if (modulePattern.test(fname)) { + return 'module' + } + + if (commonjsPattern.test(fname)) { + return 'commonjs' + } + + return defaultType || 'commonjs' +} + +module.exports = findPlugins diff --git a/runtime.js b/lib/runtime.js similarity index 100% rename from runtime.js rename to lib/runtime.js