Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 16 additions & 5 deletions src/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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 }))
Expand All @@ -75,7 +87,6 @@ const appInit = async(initArgs) => {
handlerPlugins : [],
localSettings : {},
name,
pathResolvers : commonPathResolvers,
pendingHandlers : [],
dynamicPluginInstallDir : dynamicPluginInstallDir || fsPath.join(serverConfigRoot, 'dynamic-plugins'),
serverConfigRoot,
Expand Down Expand Up @@ -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 })
}
}

Expand Down
8 changes: 4 additions & 4 deletions src/handlers/server/errors/detail.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 6 additions & 5 deletions src/handlers/server/next-commands.mjs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { getVarDef } from '../../lib/path-var-registry'
import { nextOptions } from './_lib/next-options'

const help = {
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
11 changes: 6 additions & 5 deletions src/lib/load-plugins.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
}
Expand Down Expand Up @@ -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<string>} options.explicitPlugins - Optional array of package names to explicitly load
* @param {Set<string>} options.loadedPluginNames - Optional Set to track loaded plugins across multiple calls
* @returns {Promise<void>}
*/
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()

Expand All @@ -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)
}
}
Expand Down Expand Up @@ -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)
}
}
Expand Down
37 changes: 37 additions & 0 deletions src/lib/path-to-re.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
/**
* Converts a path array to a RegExp for route matching.
*
* @param {Array<string>} 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 }
45 changes: 45 additions & 0 deletions src/lib/path-var-registry.mjs
Original file line number Diff line number Diff line change
@@ -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 }
23 changes: 10 additions & 13 deletions src/lib/register-handlers.js
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down Expand Up @@ -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)) {
Expand All @@ -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
Expand Down Expand Up @@ -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)) {
Expand Down