Plugin Specification
Background
FaasJS supports plugins in two complementary layers:
- code registers plugin instances on
Func faas.yamlprovides staged, directory-aware plugin configuration
The runtime behavior is already stable across @faasjs/core and @faasjs/node-utils, but the contract is currently spread across source code, tests, and published docs.
This specification defines the baseline for plugin identity, lifecycle execution, config layering, and config-driven loading.
Related references:
packages/core/src/func/index.tspackages/core/src/index.tspackages/node-utils/src/load_config.tspackages/core/src/plugins/http/index.tsdocs/zh/guide/excel/plugin.md
Goals
- Keep plugin authoring and loading behavior predictable.
- Define how plugin identity, ordering, config precedence, and deduplication work.
- Make code registration and
faas.yamlconfig play together without ambiguous ownership. - Align config-driven loading with current
defineApi()behavior.
Non-goals
- Defining each plugin package's private
configschema. - Standardizing npm publishing, versioning, or marketplace metadata for plugins.
Normative Rules
1. Runtime Plugin Contract
- A plugin instance MUST expose string
typeand stringnamefields. - Plugin
nameMUST identify the runtime plugin id. - Plugin
typeMUST identify the plugin source, family, or module specifier rather than the runtime instance id. - Plugin
nameSHOULD stay stable within the same function because ordering, deduplication, logs, and config lookup rely on it. - A plugin MAY implement
onMount,onInvoke, or both. - Plugins auto-loaded by
defineApi()MUST be created from a constructor whose prototype implements at least one lifecycle method:onMountoronInvoke. - Plugins registered in code MAY implement
applyConfig(resolvedConfig)to receive the final merged config for their plugin id before first mount.
2. Lifecycle Execution Model
- User plugins MUST execute before the built-in run-handler plugin.
onMounthooks MUST run in plugin order and MUST run at most once perFuncinstance.onInvokehooks MUST run in plugin order for every invocation.- Plugins MUST use
await next()to continue the lifecycle chain. - Calling
next()multiple times from the same lifecycle hook MUST reject withnext() called multiple times. - Plugins MAY mutate mount or invoke data to inject fields, prepare context, or control the final response.
- Errors thrown by a plugin or downstream handler MUST stop the current chain and propagate to the caller.
3. Configuration Layering And Precedence
- Plugin configuration MAY be authored in code, in
faas.yaml, or both. faas.yamlplugin config MUST support directory-level layering from project root toward the target API directory.- When multiple
faas.yamlfiles contribute config for the same plugin id, the deeper directory MUST override the shallower directory while preserving unspecified fields through deep merge. - Code-authored plugin config MUST override merged
faas.yamlconfig for the same plugin id. - Plugin config merging MUST use plugin
nameas the identity key. - Resolved plugin config exposed on
func.config.pluginsMUST reflect the final merged view after directory layering and code overrides.
4. Manual Registration
new Func({ plugins: [...] })MUST preserve the provided plugin order.- Manual plugin arrays MUST NOT perform implicit deduplication; callers are responsible for avoiding duplicate plugin names when that matters.
- When code registers a plugin instance, that instance remains the source of runtime behavior; config resolution MAY augment its settings but MUST NOT silently replace it with another instance from YAML.
- When a pre-registered plugin instance implements
applyConfig, the loader SHOULD call it with the final merged config for that plugin id.
5. Config-Driven Loading In defineApi()
defineApi()MUST resolve stagedfaas.yamlconfig andfunc.config.pluginsbefore the first mount or invoke.- The loader MUST inspect only own enumerable keys on
config.plugins. - Plugin config entries in
func.config.pluginsMUST be keyed by plugin id. - For config-driven loading, resolved plugin
nameMUST default to the entry key and therefore represent the plugin id. - For an object config entry, resolved plugin
typeMUST come fromtype, then fall back to the entry key only for built-in plugin ids explicitly supported by the runtime. - The loader MUST instantiate plugins with the resolved config object plus resolved
nameandtype. - If a plugin with the same resolved
namealready exists on the function, config-driven loading MUST NOT create a duplicate runtime instance. - When a plugin instance already exists in code and config exists for the same id, the resolved config MUST still be attached to
func.config.plugins[name]with code values taking precedence.
6. Module And Constructor Resolution
- Plugin type
httpMUST resolve to module@faasjs/core. - Unscoped bare plugin types such as
mysqlMUST resolve to@faasjs/<type>. - Scoped package names, relative paths, absolute paths, and
file://local file URLs MUST resolve as authored after stripping an optionalnpm:prefix. - When resolving a class export from a module, the loader MUST use normalized PascalCase class names derived from the plugin type or trailing path segments.
- If no matching named export is a valid lifecycle plugin constructor, the loader MUST throw an error.
- If constructor execution throws or returns a non-object plugin instance, the loader MUST throw an error.
7. defineApi() Requirements
- A
defineApi()function MUST have anhttpplugin available after plugin resolution. - If the
httpplugin is missing, invocation MUST fail with an error that indicates the requiredhttpplugin is missing. - Additional plugins MAY inject fields into the handler data by mutating invoke data before the business handler runs.
- Plugin packages that inject extra handler fields SHOULD provide TypeScript module augmentation for
DefineApiInject.
Examples
Manual lifecycle plugin
import { Func, type InvokeData, type Next, type Plugin } from '@faasjs/core'
class TracePlugin implements Plugin {
public readonly name = 'trace'
public readonly type = '@/plugins/trace'
public async onInvoke(data: InvokeData, next: Next) {
data.context.trace = ['before']
await next()
data.context.trace.push('after')
}
}
export default new Func({
plugins: [new TracePlugin()],
async handler({ context }) {
context.trace.push('handler')
return context.trace
},
})
Config layering with code precedence
# src/faas.yaml
defaults:
plugins:
auth:
type: file://./plugins/auth-plugin.ts
config:
provider: jwt
secret: from-root
# src/admin/faas.yaml
defaults:
plugins:
auth:
config:
secret: from-admin
import { defineApi } from '@faasjs/core'
const api = defineApi({
async handler({ config, current_user }) {
return {
current_user,
auth: config.plugins?.auth,
}
},
})
api.config = {
plugins: {
auth: {
config: {
secret: 'from-code',
},
},
http: {
config: {},
},
},
}
export default api
In the resolved config:
- plugin id is
auth, so runtimename === 'auth' - plugin source comes from the configured
type secretresolves to'from-code'providerremains'jwt'