Extending JSails
Extending JSails
JSails is extended through a single seam: the extension. An extension is a plain object — not a class, not a global registration — that provides services, registers HTTP hooks, and returns a cleanup. Everything above the substrate is plugin-shaped, and a plugin is just an extension that can be packaged, discovered, and installed.
Import from jsails/extensions for an ORM-free entry (Hono is referenced
through import type only). The root jsails entry re-exports the same symbols
but pulls in reflect-metadata and TypeORM.
The extension seam
An extension is { name, requires?, provides?, priority?, setup(context) }.
Names must be non-empty and unique — a duplicate errors before any setup runs.
setup({ services, configureHttp, configureMiddleware, onServe }) may return a
cleanup function (or a promise of one).
createServiceToken<T>(name)makes an opaque typed token. Identity is the token object itself, not its name, so define each token once and share it between providers and consumers.nameis a diagnostic label only.- Services are per application:
runExtensions([...])(orcreateApplication) builds a fresh registry. There is no global registry and no cross-app sharing. requireslists service tokens that must already be registered by an earlier declared extension; a missing requirement fails beforesetup.priorityorders the setup phase: within a topological layer, lower priorities run first, and ties keep declaration order. It defaults to0.- Cleanups run in reverse declaration order on
close(). If asetupthrows, already-completed extensions are cleaned up and the registry cleared. An extension that throws before returning its teardown owns the cleanup of whatever it partially created — return the teardown as soon as resources exist so a later failure can dispose of them.
// jsails.app.js (app-owned, compiled ESM; plain default export)
import { createServiceToken } from 'jsails/extensions';
// Token defined once, shared by the provider and the consumer.
export const clock = createServiceToken('clock');
const clockExtension = {
name: 'clock',
setup({ services }) {
services.provide(clock, { now: () => new Date().toISOString() });
},
};
const healthExtension = {
name: 'health',
requires: [clock], // provided by the earlier "clock" extension
setup({ services, configureHttp }) {
const { now } = services.get(clock);
configureHttp((app) => {
// Native Hono route; this hook owns its own authorization.
app.get('/healthz', (c) => c.json({ ok: true, at: now() }));
});
},
};
export default {
rootDir: '.',
out: 'out',
extensions: [clockExtension, healthExtension],
};
HTTP and middleware hooks
configureHttp(hook) collects native Hono hooks, once, in declaration order.
Hooks are trusted application code: routes they add own their own security and
are not covered by the filesystem default-deny API pipeline.
configureMiddleware(handler) registers a global middleware handler that runs
before every filesystem page and API route. Handlers are collected in setup
registration order and frozen after setup; the server layer composes them ahead
of per-route middleware. Both configureHttp and configureMiddleware are only
valid while setup is running — calling either afterwards throws.
Substrate vs. plugin boundary
Not everything can be an extension. The substrate is everything upstream of
runExtensions — the code that must run before any extension can exist: the
CLI entry and command dispatch (src/cli.ts), the app config loader
(loadAppConfig / validateAppConfig), the extension runner itself
(runExtensions) and the service registry, route discovery (discoverRoutes),
and pure plugin enablement (resolvePluginEnablement / loadPluginEnablement).
An extension cannot run itself, so these stay in core. Everything above that line — HTTP hooks, services, commands, pages, admin surfaces, first-party features like auth/blog/jobs — is plugin-shaped and can be declared as an extension.
The read-only introspection commands (inspect, describe, explain) are
deliberately substrate, not an introspect plugin: they are early-routed
before config loading and own their own --config flag, so they work from any
cwd with no app config. Their names are reserved
(DEFAULT_RESERVED_COMMAND_NAMES) so a user command cannot be silently shadowed
by the early routing.
Contract tokens
A contract token is a typed capability declaration with replacement
semantics — distinct from a service token, not a flag on it.
createContractToken<T>(name, { version? }) produces a token whose version
defaults to 1 and must be a positive integer; each call is a distinct identity
even when name repeats.
An extension declares the contracts it fulfills in provides and the ones it
reads in requires. When one extension provides a contract and another requires
it, the provider is guaranteed to run before the consumer regardless of
declaration order — the runner topologically sorts over these edges, then
priority lowest-first, then declaration order. Service tokens in requires
are still checked inline; they do not affect ordering.
At most one non-override provider plus at most one override is permitted per
contract. A provides entry with override: true replaces an earlier default
provider — the override's implementation wins in the registry. Zero providers
for a required contract, or two non-override providers, fails value-free at load
time.
Plugins
A plugin is a packageable extension: JsailsPlugin extends JsailsExtension
with declarative passthroughs (deployments, renderer) carried by identity for
the config loader, and a PluginContext whose setup can additionally register
interceptors and observers. priority, disabled, requires, provides, and
commands are inherited, so a plugin participates in the same contract-ordered
setup phase as every extension. definePlugin(plugin) is an identity helper that
keeps the setup context typed as PluginContext even when the literal is
nested inside a JsailsExtension[].
A plugin may carry an optional describe?: () => PluginDescription — a pure,
side-effect-free declarative descriptor readable without running setup. It
never invokes callbacks, signs snapshots, or performs I/O. PluginDescription
currently carries components and commands; both jsails describe and the
introspect components section read component metadata through it without
materializing a runtime.
Declaring plugins
plugins.use in jsails.app.js lists plugins to load, each a specifier or a
[specifier, options] tuple. The loader imports the module's default export and
calls it with the options; a module whose default export is already a
JsailsPlugin object is accepted as-is. Every plugin a specifier names
automatically runs — developer plugins are never gated by enabled.
export default {
plugins: {
use: [
'jsails/plugin-tools',
['jsails/auth', { publicOrigin }],
['jsails/filesystem', { disks: { local: createLocalDisk({ root: 'storage/files' }) } }],
],
enabled: ['plugin-tools', 'auth', 'filesystem'],
managed: true,
},
};
Two-source enablement
A plugin id is enabled by either of two independent sources, merged
deterministically by resolvePluginEnablement({ codeEnabled, state }):
- Code list.
plugins.enablednames the npm plugins enabled out of the box. Installing an npm plugin that self-identifies through itspackage.jsonjsailsfield is activating it, so the code list is the source of truth for dependency plugins. - Managed state. A
PluginStateSource— either the<pluginsDir>/state.jsonPluginStateStoredocument ({ version, plugins: { [id]: { active, enabled } } }, written by the admin) or the database-backed store — records what the admin installed and toggled. Every id whoseenabledistrueis enabled.
plugins.enabled also gates the built-in config.extensions at runtime by name:
an extension whose name is not listed is skipped before its setup runs.
loadPluginEnablement({ codeEnabled, managed, stateSource }) is the
managed/non-managed switch: a non-managed deployment (managed !== true)
performs no state I/O and resolves code-only enablement, while a managed one
requires a state source, loads it, then merges it with the code list. The static
export is always non-managed.
The result is frozen and deterministic: enabled is the sorted union of the code
list and every state id with enabled === true; conflicts lists ids present in
both sources, which are excluded from enabled and must be reported by
the caller (fail closed — a conflict is a configuration error, never silently
resolved). disabledManaged lists state ids turned off that are not
code-enabled; those ids — and any id that is not installed — are skipped, not
enabled.
The manifest
A plugin package or bundle declares a manifest.json so the framework can
discover, check, and activate it without importing any plugin code. The manifest
is validated with Zod and then checked for semver soundness; parsePluginManifest
throws a value-free PluginManifestError whose messages never echo input. The
schema is strict — unknown fields are rejected rather than ignored.
{
"id": "acme-blog",
"version": "1.2.0",
"jsailsCompat": ">=0.1.0 <1.0.0",
"permissions": ["blog.read", "blog.write"],
"plugins": [{ "id": "acme-core", "range": "^1.0.0" }],
"settingsSchema": { "apiKey": "string" },
"entry": "./dist/plugin.js",
"checksums": { "acme-blog-1.2.0.tgz": "sha256-..." },
"signature": "..."
}
id must match PLUGIN_ID_PATTERN (a lowercase/digit start followed by
lowercase letters, digits, ., _, or -); version and jsailsCompat must
be valid semver; permissions and plugins default to []; settingsSchema,
checksums, and signature are optional. entry is a bare module specifier
(imported directly) or a relative path (resolved against the package directory).
Discovery, installer, and activation
The plugin system lives in src/plugins/ (exported as jsails/plugins): the
manifest contract, two-source discovery, the framework-version check, the .tgz
extractor, the persisted state store, the enablement merge, download-capability
resolution, the installer, and activation.
- Discovery (
discoverPlugins) walks the two origins without importing any plugin code: the direct npmdependenciesof the app package (each dependency that self-identifies through its ownpackage.jsonjsailsfield) and the storage-backed plugins folder (eachmanifest.jsonbundle). It is synchronous and filesystem-only, follows no symlinks, and never evaluates a module. - Installer (
createPluginInstaller) downloads a.tgzbundle, verifies an optional SHA-256 checksum and detached signature, extracts it into a private staging directory, validates the embedded manifest against the requested id/version, then atomically renames it into<pluginsDir>/<id>/<version>/and records the active version. It never evaluates plugin code, and every failure raises a value-freePluginInstallerError. - Activation (
activatePlugins) is the one slice that imports plugin code. It resolves the enabled ids, discovers the matching plugins, and dynamically imports each entry module — executing trusted plugin top-level code at import time. It structurally validates the exported plugin object(s) but never callssetup; the extension runner does that later.
activatePlugins is an exported, opt-in capability — it is not wired into
createApplication. The runtime's boot path does not discover or dynamically
import plugins: createApplication runs the extensions the app config declares
directly (config.extensions), gated by name through plugins.enabled. A
deployment that wants discovery-driven activation calls activatePlugins itself
and feeds the returned plugins into its extension list. This keeps the default
boot path free of dynamic imports and managed-state I/O.
Managed state
A managed deployment (plugins.managed: true) can persist plugin state in the
application's database instead of the <pluginsDir>/state.json document. The
table is framework-owned: the JsailsPluginState entity maps to
jsails_plugin_state with a generated integer primary key and four scalar
columns — pluginId varchar(190), activeVersion varchar(64), enabled
boolean, updatedAt datetime. An app opts in by adding pluginStateEntities to
its JsailsDataSource entities and running the normal history:
// jsails.config.js (app-owned; plain ESM)
import { JsailsDataSource, pluginStateEntities } from 'jsails';
import { User } from './entities/user.js';
export default new JsailsDataSource({
type: 'mariadb',
/* ... */
entities: [...pluginStateEntities, User],
});
jsails makemigrations --name add_plugin_state --config jsails.config.js
jsails migrate --config jsails.config.js
createDatabasePluginStateStore({ dataSource }) reads and writes that table
through the injected data source's repository — no raw SQL and no runtime
DDL. The table must already exist: a missing table fails with a clear,
value-free error telling the caller to run makemigrations/migrate; the store
never creates it. The migration history is the only path that creates the
table, so its schema is recorded and diffed like every other entity.
Next steps
- Jobs — the provider-neutral job runtime, metrics, and the failed-job store.