The module system
The kernel in technical/ is what every project gets. Everything beyond it — search engines, GraphQL, WebSockets, mail, storage, exports, imports, an outbound HTTP client, webhooks, an admin panel — arrives through hery install and lands in src/modules/, where it can be removed again.
A module is an installer, not a dependency. It runs once, writes real files into your project, and disappears. There is no module resident at runtime, no plugin lifecycle, no hook system: the same “generate once, own your code” bargain as hery generate, applied to infrastructure instead of resources.
The files it writes are held to the same conventions as the kernel, because they were written by the same people: a guard and a policy on every route, tenant scoping, and a paged collection route wherever it exposes one — lint:pagination and lint:capabilities cover module code exactly as they cover technical/. Installing a module is meant to be the whole job: fill in whatever it added to .env, run the migration it printed, and it works. Nothing is left as an exercise.
Listing and installing
Section titled “Listing and installing”pnpm hery module:list # what is availablepnpm hery install storage # onepnpm hery install storage mail graphql # severalpnpm hery install --all # the full packageinstall takes a variadic list of module ids. --all installs every registered module in registry order — note that it ignores any names given alongside it, and that it will install both search drivers, which is rarely what you want.
Running pnpm hery install with no arguments prints nothing to install and exits 0. An unknown id reports itself and sets a failing exit code, but the other names in the same invocation are still installed.
What is available
Section titled “What is available”| Id | What it adds |
|---|---|
search-elasticsearch |
Swaps free-text search onto Elasticsearch — docker service, driver, DI wiring. |
search-meilisearch |
The same, onto Meilisearch. |
graphql |
A GraphQL endpoint (Apollo driver) with a session guard mirroring the REST auth flow. |
mcp |
An authenticated MCP gateway over Streamable HTTP, exposing resources as tools. |
live |
Bidirectional WebSocket support (Socket.IO). |
stream |
One-to-many audio/video over LiveKit. |
mail |
Outgoing mail: a MailLog model, a driver registry, and a BullMQ job that sends. |
mail-resend |
A MailDriver sending over Resend, landing inside mail. |
mail-smtp |
A MailDriver sending over SMTP, landing inside mail. |
storage |
File storage behind a driver registry — local disk with signed local URLs by default. |
export |
Turn records into a downloadable file: an Exportable, a CSV driver, and a queued path. |
export-xlsx |
An ExportDriver writing real spreadsheets through exceljs, landing inside export. |
export-pdf |
An ExportDriver writing paginated PDF tables through pdfkit, landing inside export. |
import |
Turn an uploaded file back into records, with a per-row report of what was rejected. |
http-client |
Call other people’s APIs behind one contract, with a fake driver that cannot reach the network. |
admin-astro |
An admin panel built with Astro, discovering its sections from GET /introspect. |
impersonation |
Let an admin act as another user, tenant-bounded and audit-logged, via a bearer token for the target. |
webhooks |
Receive inbound webhooks with HMAC-SHA256 signature verification and run each one through Event, Job, Notification, Audit and Signal. |
Eight of them are modules of their own under src/modules/: live, stream, mail, storage, export, import, http-client, webhooks. Four are drivers rather than modules, and land inside the module they extend — mail-resend and mail-smtp into src/modules/mail, export-xlsx and export-pdf into src/modules/export — because a module may not import another module, so a driver living in a folder of its own could never reach the contract it implements. No module lands in src/technical or edits a file there (lint:module-destinations refuses it): the search drivers, graphql, mcp and impersonation land in src/modules/<name> and reach the kernel through @heryjs/core — the search drivers register under searchDriverToken, the contract the kernel already owns.
That split is one convention, not a per-module accident. Modules and drivers is the whole shape, and every module above follows it.
hery module:monitoring looks like a module but is a separate command: it scaffolds Prometheus, Grafana and Loki as a local compose stack. It is not in the registry, so it does not appear in module:list and --all does not cover it.
Two channels, discovered rather than hardcoded
Section titled “Two channels, discovered rather than hardcoded”Every module above is official — it lives in this repository’s own packages/ directory. module:list labels each entry with its channel:
admin-astro [official] - Add an admin panel built with Astro...Discovery walks packages/* at startup and requires whatever src/module.ts it finds there — there is no barrel file listing packages by hand, so a new official module needs nothing beyond its own folder to show up.
Because this repository installs the modules it exercises into itself, those exist twice: authored under packages/<name>/src/runtime, installed where that module’s own module.ts copies it to. pnpm lint:module-drift compares the two and fails when they diverge — a fix applied to the copy that runs here and not to the copy a project would install is a fix nobody else gets, and the whole gate stayed green through it until this check existed. The runtime file is written against the kernel through a #kernel/ specifier, rewritten to the app’s own #technical/ on the way in, which is the only difference between the two copies.
The community channel is the same mechanism turned outward: any npm package a project installs is a module once it adds a heryjs.module: true marker to its own package.json and default-exports a definition. hery module:list and hery install scan the project’s declared dependencies for that marker and read the export of whichever ones carry it — there is no separate registry to submit to and nothing HeryJs curates on that side; the convention itself is the whole channel.
A package that carries the marker but exports the wrong shape is reported, not skipped:
✖ bad-module is not a usable module: it declares no description it declares no install function it declares no metaThat is the one failure worth being loud about, because the alternative symptom is an install command that says nothing at all.
A module is these fields
Section titled “A module is these fields”export interface ModuleDefinition { name: string; // the id you type description: string; // the line module:list prints meta: { compatibility: string }; // the HeryJs range it was written against dest?: string; // only when src/runtime/ lands somewhere unusual dependencies?: string[]; install(context: InstallContext): void | Promise<void>;}name is kebab-case, and that is checked rather than recommended: the name becomes a directory, a package name, an npm id and the word you type, and those four have exactly one spelling in common.
dependencies are npm specifiers handed to pnpm add -w before install() runs.
dest is where src/runtime/ lands, from the project root — and most modules do not declare it. Left out it is src/modules/<name>, which is where a module that adds something of its own goes; declaring that value by hand is refused, not tolerated, so one destination has one spelling. What remains worth declaring is a module that lands somewhere else: admin-astro lands on admin because it is not application code at all. It stays a declared field rather than a local constant so lint:module-drift can find the installed half of every file without parsing anyone’s source.
meta.compatibility is a semver range. It matters more here than in a framework whose modules stay resident: a HeryJs module runs once and leaves code behind, so installing one written against another kernel is not a runtime error anyone can undo — it is files on disk written against a contract that has moved. So it is checked while nothing has been written yet, before the first dependency is even added:
$ pnpm hery install some-module✖ some-module was written for HeryJs >=2.0.0, and this project is on 1.4.2 install it with --force if you mean to take that on--force installs it anyway and prints the same line as a warning. The refusal is a default, not a wall: the files land, and you own them.
The version it is compared against is not your project’s — that one is yours to number as you like — but the kernel yours was generated from, declared in @heryjs/cli (packages/cli/src/lib/kernel-version.ts). A range nobody can parse is reported as the malformed field it is rather than as a version mismatch.
There is no channel field. The loader stamps it from where it found the module, so nothing can claim to be official.
Starting one: hery module:new
Section titled “Starting one: hery module:new”pnpm hery module:new audit-trailIt writes a module that already satisfies the contract, so your first run is never spent on the shape of the definition:
packages/audit-trail/package.json name, the heryjs.module marker, pinned dependenciespackages/audit-trail/tsconfig.json #kernel/* pointed at the kernelpackages/audit-trail/src/module.ts the definition, compatibility filled inpackages/audit-trail/src/runtime/… a service, a Nest module, and a specThe name has to be kebab-case, and it is refused if a directory or an installable module already answers to it. The shape is the same rule the loader applies, from the same function: a module that arrives without ever having been scaffolded is held to it too.
Three things in there are the constraints rather than the convenience. meta.compatibility is written from the kernel you scaffolded against, so it is never the field nobody filled in. The spec is under src/runtime, which means copyRuntime copies it into the project alongside the code, and the installing project’s own suite runs it — a module with no spec is a module whose installer has nothing to run. And the tsconfig.json includes test before that directory exists, because the day you add integration tests is the day your typed lint rules would otherwise report every file in it as outside the project.
Moving that directory into its own repository is all it takes to make it a community module — see Publishing a module for what the package has to declare, and examples/hery-module-maintenance for a complete one.
That spec runs where it is written, without being installed first: pnpm run test:module-specs runs every src/runtime/**/*.spec.ts under packages/, against the kernel’s own sources. Installing the module then runs the copy too, under the project’s own suite.
Checking one: hery module:validate
Section titled “Checking one: hery module:validate”pnpm hery module:validate audit-trailCalled with no name it checks every module it can discover, official and community alike:
✔ mail✖ audit-trail its src/module.ts imports node:fs — every write goes through the install context, which is what makes idempotence and the record of what was touched hold for every module✖ audit-trail src/runtime/audit.service.ts imports #technical/auth/session.guard — a module reaches the kernel through #kernel/, which is rewritten to the app's own #technical/ on the way inEverything it asks for is checked from the package alone, so a third-party author runs it on their own package with none of this project present. It reads the definition the same way install does, then looks at what is around it:
src/module.ts never imports node:fs |
every write goes through the install context |
src/runtime/ exists, and holds more than specs |
a module ships real files, never runtime code in string constants |
something under src/runtime/ is a spec |
copyRuntime copies it in with the code, so the installer has something to run |
the only subpath import is #kernel/ |
it is the one specifier rewritten on the way in; any other arrives pointing at nothing |
a kernel import names a public entry, @heryjs/core/<entry> |
everything else under src/technical is internal and may move; a file the module installs into a kernel folder itself stays reachable |
| no relative import climbs out of the package | nothing above src/runtime is copied |
a tsconfig.json that is present maps #kernel/* |
otherwise the author’s own tsc typechecks against no kernel at all — and a published package ships none, so this one applies where the module is authored |
a test/ directory that is present is in the tsconfig’s include |
left out, it is outside the project: every typed lint rule reports every file in it as “not found by the project service”, which names neither the tsconfig nor the missing entry |
Typechecking the runtime is deliberately not in that list: that is the author’s own tsc, against their own tsconfig.
The same checks run over the official modules in this repository as pnpm run lint:module-validity, part of lint:conventions and so of CI — what is demanded of a community module is demanded of the official ones first.
A module is its default export
Section titled “A module is its default export”import type { ModuleDefinition } from 'heryjs';
export default { name: 'storage', description: 'Add file storage behind a swappable provider.', meta: { compatibility: '>=0.0.1' }, install(context) { context.copyPackageFile('docker-compose.storage.yml'); context.copyRuntime(); context.nextSteps(['Import StorageModule into src/app.module.ts']); },} satisfies ModuleDefinition;That is a plain object literal, and import type is erased by the compiler, so a published module has no runtime dependency on HeryJs at all — the contract is checked when the module is built and gone by the time it is installed.
It is deliberate. The contract used to be a registerModule() call into a module-level Map, which only ever worked for the modules living in this repository: a third-party package had nothing to import that function from, and a bundled copy of it would have been a second Map the CLI never reads — so a community module loaded fine and registered nothing, silently.
The official modules are packages too
Section titled “The official modules are packages too”The official modules in this repository are published as @heryjs/<name>, versioned with the kernel:
pnpm add @heryjs/storagepnpm hery install storageThe npm name is not what you install. hery install takes the name in the definition, so @heryjs/storage and a fork of it published under another id are both storage — which is also how a fork can replace the official one without the project’s scripts changing.
That does make one collision easy to reach. A project scaffolded by hery new already carries every official module as a workspace, so adding one from npm gives you two modules answering to one name, and the loader says so rather than picking quietly:
! two modules are named "storage" — the official one wins, and the other is unreachable by that nameRemove the workspace copy you are not using. Keeping both means the name resolves to whichever channel the loader read first, which is not a thing to build on.
install() cannot touch the filesystem itself
Section titled “install() cannot touch the filesystem itself”A module never imports node:fs. Every write goes through the context it is handed, which is what makes idempotence, the skip logging and the record of what was touched hold for every module rather than for the ones whose author remembered them:
copyRuntime() |
copies src/runtime/ to dest, rewriting #kernel/ on the way, skipping any file already there |
copyPackageFile(name) |
copies a file from the package root into the project, skipping one already there |
addPrismaModels(models) |
writes the models it owns to prisma/schema/<module>.prisma |
addModelFields(model, fields) |
adds columns to a Prisma model the module does not own |
chainScript(script, command) |
appends a command to one of the root package.json scripts |
addWorkspace(directory) |
declares a directory in pnpm-workspace.yaml |
patchExactStrings(file, pairs, guard) |
exact-match replacements in a kernel file the module extends |
nextSteps(steps) |
the closing numbered list |
touched |
every path this install wrote, in order |
A file that does not exist is skipped rather than created: every one of these callers extends something the project already owns, so a missing file means the project is not shaped the way the module expected, and inventing it would be worse than saying so.
Every write is one named operation, and each intent has exactly one of them. The four that target a file the project always has in the same place — the Prisma schema, the root manifest, the workspace file — take no path at all: the operation says which file it means, so two modules cannot spell that path differently, and the patch check below knows what to look for without being told. And none of them takes a callback receiving the file’s source and returning a new one: what a module writes has to be readable without running it. patchExactStrings is the one that stays general, for the kernel files a module extends, and it is declarative in the same way — pairs of exact strings, plus the guard that makes a second install a no-op.
A patch that stops applying is a silent failure
Section titled “A patch that stops applying is a silent failure”patchExactStrings throws when it cannot find its search text — but only on a project that has not been patched yet. Once the guard is in the file, the whole patch is skipped as already applied, so a kernel refactor that moves the anchored text turns the patch into a no-op that reports success. In this repository that is exactly how one module went on installing a value the kernel had stopped reading.
pnpm run lint:module-patches closes that: it runs each installed module’s install() against a context that writes nothing and only records what it would patch, then checks the project still holds every mark — each replacement of an exact patch, the name of every model appended, the chained command, the workspace entry, the field name of a Prisma patch. Either the module patches what the kernel says now, or the kernel keeps what the module extends; a skipped patch is not a third option.
It is part of lint:conventions, and so of CI, alongside lint:module-drift — which compares the two copies of a module’s runtime and cannot see a patched kernel file, since that file is a copy of nothing.
Installing is idempotent, per file
Section titled “Installing is idempotent, per file”Every module guards each file it writes:
src/modules/storage/storage.module.ts already exists, skipping.Nothing is overwritten and nothing is re-templated, so a file you have edited by hand survives a re-install untouched. install --force does not change this: it overrides the compatibility refusal above and nothing else, so there is no way to make install overwrite a file, unlike generate.
The modules that patch an existing file guard on content rather than existence: mail skips if prisma/schema/ already declares model MailLog, webhooks skips the same file if it already has model WebhookEndpoint, admin-astro skips if pnpm-workspace.yaml already lists admin, and impersonation skips each of its six kernel-file patches independently, once its own marker is already there.
Be aware of what this does not give you. There is no record anywhere of which modules are installed — no manifest, no marker in package.json. “Installed” is inferred one file at a time, at write time.
hery uninstall removes what is safe to automate, and nothing else
Section titled “hery uninstall removes what is safe to automate, and nothing else”pnpm hery uninstall storageinstall() copies runtime files and patches a handful of kernel files, both of which become code the project owns from that point on — possibly edited since. Reversing either automatically would mean guessing whether what is on disk still matches what was generated, the same silent-rewrite risk “own your code” rules out everywhere else. So uninstall only automates the one part that is unambiguous: removing the module’s own npm dependencies. Everything else — the import in src/app.module.ts, the copied or patched files, a Prisma migration to drop whatever it added — comes back as an explicit numbered list, the same shape install() itself prints for its next steps. The layering rules are what make finishing that list a bounded job rather than an archaeology exercise.
One consequence of the unconditional dependency step: pnpm add -w runs on every install of a module that declares dependencies, even when every file is then skipped.
install never edits your app module
Section titled “install never edits your app module”This is deliberate and consistent across every official module. Every module that needs wiring ends its output with a numbered list telling you what to add, and where:
Next steps: 1. Import StorageModule into src/app.module.ts 2. Inject StorageService anywhere and call .upload()/.signedUrl()/.remove() 3. Declare the driver in hery.config.ts under storage.driverssrc/app.module.ts is the single composition point between the kernel and the optional layer, and it is yours. A tool that rewrote it would be a tool you could no longer freely edit — and the whole premise here is that you can.
A driver package needs two imports there, not one: the module it extends and the driver’s own @Global() module, because the driver binds its token from outside the registry’s import graph. Installing mail-resend and forgetting ResendMailModule is a boot that stops with a missing-driver message naming the install command.
Two modules wire into a resource module rather than the app module, because they are per-resource by nature: live (a gateway) and stream (a controller) both go into src/functional/<name>/<name>.module.ts.
Schema and environment
Section titled “Schema and environment”mail, webhooks and impersonation are the three that touch prisma/schema/ — mail and webhooks each append their own new models (MailLog, and WebhookEndpoint/WebhookEvent), impersonation adds fields to the existing User and Session models instead, since it needs columns on a model it does not own. All three leave you one migration behind, which is why their next steps start with pnpm hery migrate --name add_mail_log, add_webhooks or add_impersonation. hery install never runs Prisma itself.
No module writes to .env. Every variable a module needs has a working development default, so a fresh install runs with no configuration at all. It is still validated: a module is opt-in, so technical/config/env.ts cannot declare variables for a module that may not be installed, and each module declares a schema of its own instead — storage.env.ts, stream.env.ts, one per module that has configuration — parsed through technical/config/module-env.ts when the module is imported. A misspelled STORAGE_DRIVR is refused at boot with the module’s name on it, rather than silently becoming local.
The one command that does write env vars is pnpm hery up --start: for each search module whose compose file is present, it starts the service and resolves the Docker-assigned port into .env as ELASTICSEARCH_URL or MEILISEARCH_URL. So installing a search module and running hery up --start are two halves of the same step.
The compose files for stream, storage and monitoring are not covered by hery up — start those by hand:
docker compose -f docker-compose.storage.yml up -dModule configuration at a glance
Section titled “Module configuration at a glance”Every variable below has a working development default, and none of them are written to .env by the installer.
| Module | Variables |
|---|---|
search-elasticsearch |
ELASTICSEARCH_URL |
search-meilisearch |
MEILISEARCH_URL, MEILISEARCH_API_KEY |
stream |
LIVEKIT_URL, LIVEKIT_API_KEY, LIVEKIT_API_SECRET |
storage |
STORAGE_URL_SECRET, STORAGE_DRIVER, STORAGE_MAX_UPLOAD_BYTES, STORAGE_ALLOWED_CONTENT_TYPES, STORAGE_S3_* |
mail-resend |
RESEND_API_KEY, RESEND_FROM — no defaults, and read lazily on first send |
mail-smtp |
MAIL_SMTP_HOST, MAIL_SMTP_FROM; MAIL_SMTP_PORT, MAIL_SMTP_TLS, MAIL_SMTP_USER, MAIL_SMTP_PASSWORD optional — read lazily on first send |
admin-astro |
PUBLIC_API_URL |
webhooks |
WEBHOOK_SIGNATURE_TOLERANCE_SECONDS |
graphql, mcp, live, mail, export, export-xlsx, export-pdf, import, http-client, impersonation |
none |
Which driver a module runs is not in that table, because it is not environment at all — it is hery.config.ts, typed and committed. The env vars a module-and-drivers slice reads are only the secrets the chosen driver needs, plus the one name-selecting variable a default may read (MAIL_DRIVER, HTTP_CLIENT_DRIVER). Those two axes are separate on purpose, and mixing them is the most common way to get the convention wrong — see Modules and drivers.
The development defaults are development defaults in the literal sense — the LiveKit dev keys are devkey/secret and the MinIO credentials are heryjs/heryjs-dev-secret. You do not have to remember to replace them: under NODE_ENV=production every one of those defaults is refused at boot, naming the variable that is still set to it, because a credential printed in a public repository is not a credential and a URL pointing at localhost is not a service. The S3 credentials are checked when the S3 provider is constructed rather than when the module is imported, so a production app on the local driver is never refused a boot over credentials it does not use.
The two secrets the framework itself signs with, SIGNAL_TOKEN_SECRET and STORAGE_URL_SECRET, answer to the same contract from the kernel’s own schema. They are separate on purpose — the local storage driver signs its URLs the same way the signal SSE token is signed, and one shared value would mean a leak on either side forges both.