Lifecycle

A config value falls into one of two kinds, and everything on this page follows from which one it is:

A value that is CONSULTED can change under a running app. A value that was CONSUMED to build something cannot, without rebuilding what it built.

logLevel is consulted at every emission. db opened a connection, ports: registered a class, the entities built façades — those were consumed.

The way up

dispose() had a shape from the start: reverse order, only what it built. The way UP had none — it lived in four places under the name afterBoot, meaning two different things, and a host that wanted its own seeding had to claim EVERYTHING after the boot to get it.

So the pair is one value. An extension states what it does to an app and what it undoes:

const app = await createApp({
  root,
  createContainer,
  extensions: [observability()],
});

up runs before createApp returns, in declaration order; down runs inside dispose(), in reverse. It is also the one point in the boot that may await, which is where a provider that has to open something belongs — a constructor cannot.

Two members are the framework's own, and they are ordinary members:

namewhat it does
migratebrings the schema up to date — tables before rows. The slot is declared even when nothing migrates, so a host filling it REPLACES this member rather than landing after the seeds
seedsplants what seeds/ declares, in dependency order

A name already declared is replaced, not refused. That is how a host says the seeding, but mine:

// what @fougere/nuxt generates: a bundler needs its seed modules as static imports
extensions: [
  migrating(storage.migrate),
  { name: 'seeds', up: (app) => runSeeds(app, [/* imported above */]) },
]

The ascent and the descent refuse in opposite ways, on purpose:

  • up stops at the first refusal. A seed that assumes a migration ran must not run when it did not, and a half-started app must not be handed out.
  • down releases every member even when one throws, and the refusals leave together in an AggregateError — a release that gives up on the first failure leaks everything after it.

An extension is not a Frond and cannot become one: a Frond has entities and may move behind remotes:, while an extension belongs to the process that hosts them. observability() is the case that proves it — moved, it would report the observer instead of the observed.

Re-reading the config

applyConfig is the one place that answers what a re-read changes in a running app. It applies what is consulted and reports the rest instead of ignoring it:

// the host owns the process, so the host owns the signal — Fougere catches none
process.on('SIGHUP', async () => {
  const next = await loadConfig(root, { fresh: true });
  const { applied, pending } = applyConfig(next, inForce);
  inForce = next;
  // applied: ['logLevel: warn → debug']
  // pending: ['db']  — changed in the file, and it opened a connection
});

{ fresh: true } is not decoration: a module is cached by its specifier, so a second read of an edited file hands back the first one.

The list of consulted keys is declared nowhere — a key is consulted when applyConfig does something with it, which is the only definition that cannot go stale. Today that is logLevel, and a logger built before the change obeys it, children included: the level is held once for the process rather than copied into each instance.

Turning the ring

For everything else, the value cannot move under what it built — so the thing is built again. One app is instantiated, the previous one let go:

await reloadFougere();     // instantiate, drain the old, release it

It works because every door reaches the app through useFougereApp() inside the request it serves, and none holds it across two. The new app is up before the old one is released, so a boot that fails leaves the previous one serving.

Draining

Releasing an app closes its storage connection and every Frond scope — which is what the calls still running are standing on. So they are waited for:

await app.drain();      // stop taking calls, wait for the running ones
await app.dispose();    // then close the scopes and the connection

app.inFlight() is the count, taken at the single path all three doors and the wire share, so one number covers them. A nested call counts twice — the app is not idle while an inner op is running.

Two refusals, both deliberate:

  • drain(timeoutMs) rejects on its deadline, naming how many calls are left, rather than resolving as if it had worked. Its caller is about to close a connection under whatever remains, so the decision is theirs.
  • A call arriving after the door closed is refused with SERVICE_UNAVAILABLE. The handle already points at the new app, so that caller kept a reference across the turn.

Waiting and releasing stay two gestures: a script releases at once, a host turning the ring drains first.

Shutting down

The same two gestures answer SIGTERM, and nothing else is needed:

process.on('SIGTERM', async () => {
  const app = await useFougereApp();
  await app.drain(10_000);
  await app.dispose();
  process.exit(0);
});

dispose() releases in reverse of construction, at three levels: each extension's down (built last), then the container — each scope disposing the scopes it opened, deepest first, then what it built itself — then the storage connection that was handed in and opened before any of it existed. A provider that holds something says so by having a dispose() method — nothing declares it, the container looks for the method.

Failures travel together in an AggregateError rather than the first one silencing the rest: everything gets told to close.

What is not here

Fougere catches no signal of its own. A process belongs to its host, and the logger it ships runs on Cloudflare Workers where process.on does not exist.

Back to Sources, or the Ports a ports: line chooses between.

Built with Fougere — this site runs on the framework it documents.