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
}
  • store is the same framework DataStore handlers use — already migrated by the time the hook runs.
  • log is scoped to the file's basename, so voltro logs --filter startup:<id> isolates a hook's output.
  • onShutdown(cb) registers a teardown callback. On SIGTERM / SIGINT the 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.
  • id is 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 throw/rejection is logged and does NOT block boot; the rpc surface stays up regardless.
  • Long-lived — the function may hold sockets, intervals, or a fiber that resolves only on shutdown. That's the point — startups are for persistent process-wide work, unlike seeds which run once and return.
  • Torn down cleanly — every onShutdown callback 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.