Skip to content

Module lifecycle

The five states are on Managing modules, from the operator’s side. This is the same machine from inside the module — what core calls, when, and what it does with a throw.

The loader reads modules/*/module.json from the filesystem synchronously, at require time. The database is not consulted: what is on the volume determines what mounts.

MODULES_DIR defaults to <repo>/modules, and Compose sets it to /app/modules. A missing modules directory is not an error — “no modules installed” is the normal state of bare core, and the loader must not make the mount mandatory to boot.

Modules load alphabetically by id, deterministically. There is no dependency resolution between modules, and alphabetical order is the honest way of saying so: any other order would imply a precedence nobody is computing. Do not build a module that needs to load before or after another one.

Each step runs against your module. A failure at any step is your module’s failure and nobody else’s.

  1. module.json parses, has no unknown keys, and its id matches the directory name.
  2. coreApi is satisfied by core’s MODULE_API_VERSION.
  3. Declared mounts prefixes are well-formed and collide with nothing.
  4. Declared extensions slots all exist.
  5. schema and purge files exist and are readable, and their table names are namespaced or allowlisted.
  6. require() of your server entry succeeds and exports a function.
  7. register(ctx, api) returns without throwing, and registers exactly what module.json declared.

Step 7 is worth reading twice. The manifest is not documentation of what you register — it is a claim core holds you to. Registering something you did not declare fails, and so does declaring something you do not register.

For each module that passed:

  1. Schema replay — your schema.sql fragment is applied. It must be idempotent; it runs on every boot.
  2. Routes and registrations mount.
  3. onBoot(ctx) is called, if you export one. This is where long-lived work belongs: opening a stream, starting a poller, connecting to something.

On shutdown, onShutdown() is called. Disabling a module from the panel dispatches it too, so the module actually stops — releases its sockets, closes its streams — rather than merely becoming unreachable.

Enabling is deliberately not the mirror image: there is no onBoot re-dispatch, so the panel offers a restart instead. If your onBoot is expensive or stateful, that asymmetry is in your favour.

A module that fails to load never takes the site down.

The loader try/catches the module’s entire lifecycle — require, validation, registration, schema replay, onBoot — not merely failures that surface after a router object was returned. Any failure at any point marks that module startup_failed, records the reason, and the site comes up with that module’s routes and navigation absent.

Two consequences to design around:

  • A failed module is retried on every restart. There is no backoff and no quarantine. A deterministically broken module re-records its failure each boot, which is the honest thing for it to do.
  • disabled is the only state a boot leaves alone. Every other non-disabled module is reset to enabled at boot and then recorded as started or startup_failed. Disabling is an operator’s decision rather than an outcome, so it survives restarts untouched.

MODULE_API.md Part 4 is the normative account of everything on this page, including the exact position of the load() call in app.js and why it is load-bearing in both directions.