diff --git a/src/app.js b/src/app.js index e84a2319..e82b4081 100644 --- a/src/app.js +++ b/src/app.js @@ -14,7 +14,7 @@ import { handlers } from './handlers' import { findOwnHome } from './lib/find-own-home' import { getServerSettings } from './lib/get-server-settings' import { loadPlugins, registerHandlers } from './lib' -import { commonPathResolvers } from './lib/path-resolvers' +import { clearRegistry, registerPathVar } from './lib/path-var-registry' import { Reporter } from './lib/reporter' const pkgRoot = findRoot(__dirname) @@ -55,12 +55,24 @@ const appInit = async(initArgs) => { version } = initArgs + // Clear and initialize path var registry + clearRegistry() + // Find the server package root (where the running server's package.json is) // This is where core plugins are loaded from const serverPackageRoot = await findOwnHome(process.argv[1]) app = app || express() + // Register core path variables + // Note: optionsFetcher still accesses app.ext.handlerPlugins for now + registerPathVar('serverPluginName', { + validationRe : '((?:@|%40)[a-z0-9-~][a-z0-9-._~]*(?:[/]|%2f|%2F))?([a-z0-9-~][a-z0-9-._~]*)', + optionsFetcher : ({ app }) => { + return app.ext.handlerPlugins.map(({ npmName }) => npmName) + } + }) + app.use(express.json()) app.use(express.urlencoded({ extended : true })) // handle POST body params app.use(fileUpload({ parseNested : true })) @@ -75,7 +87,6 @@ const appInit = async(initArgs) => { handlerPlugins : [], localSettings : {}, name, - pathResolvers : commonPathResolvers, pendingHandlers : [], dynamicPluginInstallDir : dynamicPluginInstallDir || fsPath.join(serverConfigRoot, 'dynamic-plugins'), serverConfigRoot, @@ -113,19 +124,19 @@ const appInit = async(initArgs) => { if (skipCorePlugins !== true) { reporter.log(`Loading core plugins from '${serverPackageRoot}'...`) - await loadPlugins(app, { cache, reporter, searchPath : serverPackageRoot, explicitPlugins, loadedPluginNames }) + await loadPlugins(app, { cache, reporter, searchPath : serverPackageRoot, explicitPlugins, loadedPluginNames, registerPathVar }) } // Also load plugins from dynamicPluginInstallDir if it's different from serverPackageRoot if (app.ext.dynamicPluginInstallDir !== serverPackageRoot) { reporter.log(`Loading dynamic plugins from '${app.ext.dynamicPluginInstallDir}'...`) - await loadPlugins(app, { cache, reporter, searchPath : app.ext.dynamicPluginInstallDir, explicitPlugins, loadedPluginNames }) + await loadPlugins(app, { cache, reporter, searchPath : app.ext.dynamicPluginInstallDir, explicitPlugins, loadedPluginNames, registerPathVar }) } if (pluginPaths?.length > 0) { for (const pluginDir of pluginPaths) { reporter.log(`Loading additional plugins from '${pluginDir}'...`) - await loadPlugins(app, { cache, reporter, searchPath : pluginDir, explicitPlugins, loadedPluginNames }) + await loadPlugins(app, { cache, reporter, searchPath : pluginDir, explicitPlugins, loadedPluginNames, registerPathVar }) } } diff --git a/src/handlers/server/errors/detail.mjs b/src/handlers/server/errors/detail.mjs index 6f0c05fb..70e9895f 100644 --- a/src/handlers/server/errors/detail.mjs +++ b/src/handlers/server/errors/detail.mjs @@ -64,12 +64,12 @@ const httpOut = ({ data, req, res }) => { } } -const func = ({ app, model, reporter }) => { - app.ext.pathResolvers.errorKey = { +const func = ({ app, model, reporter, registerPathVar }) => { + registerPathVar('errorKey', { optionsFetcher : () => app.ext.errorsRetained.map((e) => e.id) .concat(app.ext.errorsEphemeral.map((e) => e.id)), - bitReString : '[a-z0-9]{5}' - } + validationRe : '[a-z0-9]{5}' + }) return (req, res) => { const { errorKey } = req.vars diff --git a/src/handlers/server/next-commands.mjs b/src/handlers/server/next-commands.mjs index fa5e63e2..79522ab4 100644 --- a/src/handlers/server/next-commands.mjs +++ b/src/handlers/server/next-commands.mjs @@ -1,3 +1,4 @@ +import { getVarDef } from '../../lib/path-var-registry' import { nextOptions } from './_lib/next-options' const help = { @@ -69,14 +70,14 @@ const func = ({ app, cache }) => async(req, res) => { foundVariablePathElement = fKey const typeKey = fKey.slice(1) prevElements[typeKey] = commandBit // save the value of the variable - const elementConfig = app.ext.pathResolvers[typeKey] - const { bitReString, optionsFetcher } = elementConfig + const elementConfig = getVarDef(typeKey) + const { validationRe, optionsFetcher } = elementConfig let myOptions = optionsFetcher({ app, cache, currToken : commandBit, ...prevElements }) if (myOptions?.then) myOptions = await myOptions if (myOptions?.length > 0) finalOptions.push(...myOptions) - if (bitReString - && commandBit.match(new RegExp('^' + bitReString + '$')) + if (validationRe + && commandBit.match(new RegExp('^' + validationRe + '$')) && finalOptions.includes(commandBit)) { frontier = frontier[fKey] break // there may be other possibilities, but once we have a match, we move on. @@ -114,7 +115,7 @@ const func = ({ app, cache }) => async(req, res) => { .reduce(async(acc, k) => { acc = await acc if (k.startsWith(':')) { - const elementConfig = app.ext.pathResolvers[k.slice(1)] // this should already be validated + const elementConfig = getVarDef(k.slice(1)) // this should already be validated const { optionsFetcher } = elementConfig let fOpts = optionsFetcher({ app, cache, currToken : '', req, ...prevElements }) if (fOpts?.then) fOpts = await fOpts diff --git a/src/lib/load-plugins.js b/src/lib/load-plugins.js index 0cb55f07..9a527835 100644 --- a/src/lib/load-plugins.js +++ b/src/lib/load-plugins.js @@ -7,7 +7,7 @@ import { registerHandlers } from './register-handlers' /** * Loads a single plugin. */ -const loadPlugin = async({ app, cache, reporter, dir, pkg }) => { +const loadPlugin = async({ app, cache, reporter, registerPathVar, dir, pkg }) => { const { main, name: npmName, description, version } = pkg // Since we pull the 'summary' from the package.json description, there may be unecessary context which is clear when // asking 'describe this plugin'. So, we look for this specific phrase and remove it. @@ -18,7 +18,7 @@ const loadPlugin = async({ app, cache, reporter, dir, pkg }) => { } if (setup !== undefined) reporter.log(`Running setup for ${npmName}@${version} plugin...`) - let setupData = setup?.({ app, cache, reporter, serverConfigRoot : app.ext.serverConfigRoot }) + let setupData = setup?.({ app, cache, reporter, registerPathVar, serverConfigRoot : app.ext.serverConfigRoot }) if (setupData?.then !== undefined) { setupData = await setupData } @@ -98,12 +98,13 @@ const discoverExplicitPlugins = async(searchPath, explicitPlugins, reporter) => * @param {Object} options - Loading options * @param {Object} options.cache - Cache instance * @param {Object} options.reporter - Reporter for logging + * @param {Function} options.registerPathVar - Function to register path variables * @param {string} options.searchPath - Directory to search for plugins * @param {Array} options.explicitPlugins - Optional array of package names to explicitly load * @param {Set} options.loadedPluginNames - Optional Set to track loaded plugins across multiple calls * @returns {Promise} */ -const loadPlugins = async(app, { cache, reporter, searchPath, explicitPlugins, loadedPluginNames }) => { +const loadPlugins = async(app, { cache, reporter, registerPathVar, searchPath, explicitPlugins, loadedPluginNames }) => { // Use provided Set or create a new one for this call const pluginNames = loadedPluginNames || new Set() @@ -125,7 +126,7 @@ const loadPlugins = async(app, { cache, reporter, searchPath, explicitPlugins, l continue } - await loadPlugin({ app, cache, reporter, ...plugin }) + await loadPlugin({ app, cache, reporter, registerPathVar, ...plugin }) pluginNames.add(pluginName) } } @@ -154,7 +155,7 @@ const loadPlugins = async(app, { cache, reporter, searchPath, explicitPlugins, l : `Found ${newPlugins.length} keyword-based plugins.`) for (const plugin of newPlugins) { - await loadPlugin({ app, cache, reporter, ...plugin }) + await loadPlugin({ app, cache, reporter, registerPathVar, ...plugin }) pluginNames.add(plugin.pkg.name) } } diff --git a/src/lib/path-to-re.mjs b/src/lib/path-to-re.mjs new file mode 100644 index 00000000..7480a6cd --- /dev/null +++ b/src/lib/path-to-re.mjs @@ -0,0 +1,37 @@ +/** + * Converts a path array to a RegExp for route matching. + * + * @param {Array} pathArr - Array of path segments, may include :vars and optional? segments + * @param {Function} getVarDef - Function to look up variable definitions by name + * @returns {RegExp} Regular expression for matching the path + */ +const pathToRe = (pathArr, getVarDef) => { + let reString = '^' + + for (const pathBit of pathArr) { + if (pathBit.startsWith(':')) { + const pathVar = pathBit.slice(1) + const varDef = getVarDef(pathVar) + + if (varDef === undefined) { + throw new Error(`Unknown variable path element type '${pathVar}' while processing path ${pathArr.join('/')}.`) + } + + const { validationRe } = varDef + reString += `/(?<${pathVar}>${validationRe})` + } + else if (pathBit.endsWith('?')) { + const cleanBit = pathBit.slice(0, -1) + reString += `(?:/${cleanBit})?` + } + else { + reString += '/' + pathBit + } + } + + reString += '[/#?]?$' + + return new RegExp(reString) +} + +export { pathToRe } diff --git a/src/lib/path-var-registry.mjs b/src/lib/path-var-registry.mjs new file mode 100644 index 00000000..f60fdda8 --- /dev/null +++ b/src/lib/path-var-registry.mjs @@ -0,0 +1,45 @@ +/** + * Registry for path variable definitions. + * Path vars are used in handler paths (e.g., :serverPluginName) and need validation patterns. + * + * varDef structure: + * { + * validationRe: string - Regular expression pattern to match the variable value + * optionsFetcher: function - Optional function that returns available options for this var + * } + */ + +// Module-level registry +let registry = {} + +/** + * Clears the entire registry. Called at the start of appInit. + */ +const clearRegistry = () => { + registry = {} +} + +/** + * Registers a path variable definition. + * @param {string} varName - Name of the variable (without leading colon) + * @param {Object} varDef - Definition object with { validationRe, optionsFetcher } + * @throws {Error} If varName is already registered + */ +const registerPathVar = (varName, varDef) => { + if (registry[varName] !== undefined) { + throw new Error(`Path variable '${varName}' is already registered.`) + } + + registry[varName] = varDef +} + +/** + * Retrieves a path variable definition. + * @param {string} varName - Name of the variable (without leading colon) + * @returns {Object|undefined} The varDef object, or undefined if not found + */ +const getVarDef = (varName) => { + return registry[varName] +} + +export { clearRegistry, registerPathVar, getVarDef } diff --git a/src/lib/register-handlers.js b/src/lib/register-handlers.js index 586f17dc..3f2e9e67 100644 --- a/src/lib/register-handlers.js +++ b/src/lib/register-handlers.js @@ -4,6 +4,8 @@ import { pathToRegexp } from 'path-to-regexp' import { commonOutputParams } from '@liquid-labs/liq-handlers-lib' import { sendHelp } from '../handlers/help/lib/send-help' +import { getVarDef, registerPathVar } from './path-var-registry' +import { pathToRe } from './path-to-re' const helpParameters = commonOutputParams() @@ -97,30 +99,25 @@ const processParams = ({ parameters = [], path }) => (req, res, next) => { const processCommandPath = ({ app, model, pathArr, parameters }) => { const commandPath = [] - let reString = '^' + + // Build command path array for commandPaths tree for (const pathBit of pathArr) { if (pathBit.startsWith(':')) { - const pathVar = pathBit.slice(1) - const pathUtils = app.ext.pathResolvers[pathVar] - if (pathUtils === undefined) { - throw new Error(`Unknown variable path element type '${pathVar}' while processing path ${pathArr.join('/')}.`) - } - const { bitReString } = pathUtils commandPath.push(pathBit) // with leading ':' - reString += `/(?<${pathVar}>${bitReString})` } else if (pathBit.endsWith('?')) { const cleanBit = pathBit.slice(0, -1) commandPath.push(cleanBit) - reString += `(?:/${cleanBit})?` } else { commandPath.push(pathBit) - reString += '/' + pathBit } } - reString += '[/#?]?$' + // Build the regex using the extracted pathToRe function + const pathRe = pathToRe(pathArr, getVarDef) + + // Update commandPaths tree let frontier = app.ext.commandPaths for (const pathBit of commandPath) { if (!(pathBit in frontier)) { @@ -137,7 +134,7 @@ const processCommandPath = ({ app, model, pathArr, parameters }) => { // unfreeze and then maybe make copies here to prevent clients from changing the shared parameters data. frontier._parameters = () => parameters - return new RegExp(reString) + return pathRe } // express barfs if there are named capture groups in the path RE. However, we really want to use named capture groups @@ -166,7 +163,7 @@ const registerHandlers = (app, { npmName, handlers, model, reporter, setupData, const methodUpper = method.toUpperCase() // this must come before processCommandPath to give the function the option of registering variable name parameters - const handlerFunc = func({ parameters, app, cache, model, reporter, setupData }) + const handlerFunc = func({ parameters, app, cache, model, reporter, registerPathVar, setupData }) for (const path of paths) { if (!Array.isArray(path)) {