Application Lifecycle
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:
container.initAll()- runs every singleton provider'sonInit()(if defined). Async, awaited in registration order.compilePipelines()- bakes global middleware + per-route guard/middleware chains into finalMiddlewarefunctions. Cached until invalidated by a newuse()/register().container.bootstrapAll()- runs every singleton'sonReady()hook. By this point all providers are fully initialised, so handlers likeMessageBus.onReadycan safely callDiscoveryServiceand wire@Onsubscriptions.
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.fetch→compose()middleware pipeline → controller handler. @Onhandlers receive messages via the configuredMessageTransport(when using@miiajs/messaging).- Request-scoped DI providers are created on first inject, cleared after the response (per request).
requestscope vssingletonvstransient- 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:
- Logs
[App] Received <signal>, shutting down gracefully... - Awaits
app.destroy()which:- Calls
closeServer()(stops accepting new requests) - Calls
container.destroyAll()- runs every singleton'sonDestroy()(e.g. database disconnect, broker cleanup, in-flight handler drain) - Logs
[App] Shutdown complete
- Calls
process.exit(0)(or1ifdestroy()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.fetchfor AWS Lambda / Vercel / Cloudflare Workers, skipapp.listen()entirely. Handlers are not registered (registration happens inlisten(), 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 overdestroy()timing.
Behavioural details
process.exit(0)cancels non-awaited timers and any remaining open handles afterdestroy()returns. Acceptable trade-off for deterministic exit. If you have long-running background jobs, await them in your ownonDestroy().- User signal handlers co-exist with Miia's. If you have your own
process.on('SIGTERM', ...)listener, it still fires alongside Miia's. PassshutdownHooks: falseif 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. TestAppdoes not calllisten()- it goes throughinit()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().