Startup hooks
*.startup.tsx — run code once when the app boots: warm a cache, open a long-lived connection, start a background consumer. Default-export a function; register teardown via onShutdown.
A startup hook runs code once when the app boots — after migrations, once the rpc server is listening. Use it for the long-lived, app-singleton work that doesn't fit any request-driven primitive: warm a cache, open a persistent connection (a message-broker consumer, a websocket to an upstream), start a background interval, or register a process-wide service.
A *.startup.tsx (or *.startup.ts) file default-exports a function — there's no define* wrapper:
// startup/warmCache.startup.ts
import type { StartupContext } from '@voltro/runtime'
export default async ({ store, log, onShutdown, id }: StartupContext) => {
log.info('warming the dashboard cache')
const timer = setInterval(() => void refreshDashboardCache(store), 60_000)
// Register teardown — runs on SIGTERM / SIGINT, LIFO across all startups.
onShutdown(() => clearInterval(timer))
}Discovery walks every *.startup.tsx; the default export must be a function. Multiple startups in one app are fine — each gets its own scope and is torn down independently.
The context
interface StartupContext {
readonly store: DataStore // already-migrated
readonly log: SyncLogger // scope=startup:<id>
readonly onShutdown: (cb: () => void | Promise<void>) => void // register teardown
readonly id: string // basename without .startup.tsx
}storeis the same frameworkDataStorehandlers use — already migrated by the time the hook runs.logis scoped to the file's basename, sovoltro logs --filter startup:<id>isolates a hook's output.onShutdown(cb)registers a teardown callback. OnSIGTERM/SIGINTthe framework runs every registered callback in reverse order (LIFO), awaiting each — with a hard 5s timeout per callback so a hung teardown can't block shutdown. Always release what you acquire here.idis the stable basename (no.startup.tsx), handy for keys / log scoping.
Lifecycle
- Runs once, at boot — after the schema is migrated and the rpc server is listening.
- A failure REFUSES the boot. A throw or rejection aborts startup, naming the file and the cause. So do a startup file that cannot be imported, and one with no default-exported function. This used to be a warning that let the server come up, and the reason it changed is that a startup is where an app REGISTERS things the request path depends on —
setRowFilterabove all. A server that came up without its row filter looked healthy and was not. If a failure is genuinely acceptable for one startup, catch it inside that function, where a reviewer can see the decision. - Long-lived in effect, but the function RETURNS. Start the work, hand the teardown to
onShutdown, return. That's the point — startups are for persistent process-wide work, unlike seeds which run once and return. A function that never returns refuses the boot afterVOLTRO_STARTUP_TIMEOUT_MS(60s default), because awaiting forever is what made a slow startup that then failed impossible to report: the boot had already moved on, so the failure arrived with nothing left to refuse. - Torn down cleanly — every
onShutdowncallback fires on process exit (LIFO), so connections and timers release without leaking across supervisor restarts.
When to use it vs the alternatives
| Need | Use |
|---|---|
| Seed rows once at boot, then return | *.seed.ts |
| Hold a long-lived connection / interval / background consumer for the process lifetime | *.startup.tsx (this) |
| React to every commit on a table | *.subscribe.ts |
| Periodic work on a cron schedule | *.cron.tsx |
| Durable, crash-surviving multi-step work | *.workflow.tsx |
If the work is "do X once and finish", it's a seed. If it's "keep X running until the process stops", it's a startup.