Application Lifecycle

Init, active, and shutdown phases of a Miia app - graceful destroy on SIGTERM/SIGINT/SIGHUP by default.

A Miia app moves through three phases: init, active, and shutdown. Knowing what happens in each lets you wire onInit / onDestroy lifecycle hooks correctly and reason about graceful shutdown under signals like SIGTERM.

Init phase

Triggered by app.listen(port, ...) (or app.init() directly for embedded scenarios like app.fetch in serverless). Sequence:

  1. container.initAll() - runs every singleton provider's onInit() (if defined). Async, awaited in registration order.
  2. compilePipelines() - bakes global middleware + per-route guard/middleware chains into final Middleware functions. Cached until invalidated by a new use() / register().
  3. container.bootstrapAll() - runs every singleton's onReady() hook. By this point all providers are fully initialised, so handlers like MessageBus.onReady can safely call DiscoveryService and wire @On subscriptions.

Once init() returns, the app is ready to accept requests via app.fetch.

@Injectable()
class DbConnection {
  async onInit() {
    await this.connect()
  }
}

Active phase

While the app is running:

  • HTTP requests routed through app.fetchcompose() middleware pipeline → controller handler.
  • @On handlers receive messages via the configured MessageTransport (when using @miiajs/messaging).
  • Request-scoped DI providers are created on first inject, cleared after the response (per request).
  • request scope vs singleton vs transient - see Providers.

Shutdown phase

By default, when app.listen() is called, Miia registers handlers for SIGTERM, SIGINT, and SIGHUP. On any of those signals, the app:

  1. Logs [App] Received <signal>, shutting down gracefully...
  2. Awaits app.destroy() which:
    • Calls closeServer() (stops accepting new requests)
    • Calls container.destroyAll() - runs every singleton's onDestroy() (e.g. database disconnect, broker cleanup, in-flight handler drain)
    • Logs [App] Shutdown complete
  3. process.exit(0) (or 1 if destroy() throws)
@Injectable()
class DbConnection {
  async onDestroy() {
    await this.disconnect()
  }
}

Concurrent signals are guarded: only the first one initiates shutdown. Pressing Ctrl+C twice means the second SIGINT goes to the OS default action (kill the process) - this is conventional Unix behaviour, not a bug.

Triggers in real deployments

  • K8s / Docker stop sends SIGTERM, then SIGKILL after terminationGracePeriodSeconds (30s default). Fits Miia's flow - destroy() runs within the grace period.
  • systemd stop also SIGTERM, similar timing.
  • Ctrl+C in a terminal sends SIGINT.
  • Closing the terminal sends SIGHUP (Unix-only).

Configuring shutdown hooks

The shutdownHooks option in MiiaOptions controls registration:

new Miia()                                     // default - SIGTERM + SIGINT + SIGHUP
new Miia({ shutdownHooks: false })             // opt out completely
new Miia({ shutdownHooks: ['SIGTERM'] })       // custom signal subset

When to opt out:

  • Serverless - if you only use app.fetch for AWS Lambda / Vercel / Cloudflare Workers, skip app.listen() entirely. Handlers are not registered (registration happens in listen(), not constructor).
  • Multi-Miia processes - rare, but possible (e.g. public API + admin worker in one process). See below.
  • Custom bootstrap - you have your own process.on('SIGTERM') orchestration and want full control over destroy() timing.

Behavioural details

  • process.exit(0) cancels non-awaited timers and any remaining open handles after destroy() returns. Acceptable trade-off for deterministic exit. If you have long-running background jobs, await them in your own onDestroy().
  • User signal handlers co-exist with Miia's. If you have your own process.on('SIGTERM', ...) listener, it still fires alongside Miia's. Pass shutdownHooks: false if you want exclusive control.
  • Windows: SIGHUP is not supported. Miia silently filters it out from the default array on process.platform === 'win32'. SIGTERM and SIGINT remain active.
  • TestApp does not call listen() - it goes through init() directly. So no signal handlers are registered in tests, by construction.

Multi-Miia in one process

Node delivers each signal to every registered listener. If two Miia instances both have shutdownHooks: true, both start their own destroy() concurrently on SIGTERM. The first instance to reach process.exit(0) terminates the process; the other's cleanup may be interrupted mid-flight.

Pattern for two-app processes:

const primary = new Miia().register(PublicAppModule)
const admin = new Miia({ shutdownHooks: false }).register(AdminAppModule)

await Promise.all([primary.listen(8080), admin.listen(9090)])

// Primary owns shutdown signals. When SIGTERM fires, Miia destroys primary
// automatically. We register an extra hook to tear down admin first.
process.on('SIGTERM', async () => {
  await admin.destroy()
  // primary.destroy() runs from Miia's own handler immediately after.
})

Alternative: both opt out, register your own coordinator.

Embedded app.fetch (serverless)

For environments where the runtime owns the request loop (Lambda, Cloudflare Workers, Vercel Edge), do not call app.listen():

const app = new Miia({ shutdownHooks: false })
  .register(AppModule)

await app.init()  // explicit - normally called by listen()

export default { fetch: app.fetch }  // platform handler

shutdownHooks: false is recommended here - the platform manages signals itself, and serverless handlers are short-lived. If you skip the option, no harm done either: signal handlers are only registered in listen(), not in init().