Lifecycle Hooks
Hook into startup, shutdown, errors, HMR, and build phases with src/robo/ files.
Lifecycle hooks let you run code at specific phases of your Robo's lifetime. They are convention-driven: create a file in src/robo/ with the right name and export a default function. Robo.js calls it at the appropriate time.
Migrating from _start/_stop events? See Upgrading to v0.11 for the full migration guide.
This works for both projects and plugins. Plugins use the same src/robo/ directory structure, and their hooks are discovered automatically during the build.
Directory Structure
You only need the hooks you use. Most projects start with start.ts and add others as needed.
Startup Sequence
When Robo.js starts, it runs three hooks in this order:
init --> prepare --> startFor each hook, plugins run first (in registration order), then the project's hook runs last. This guarantees that plugin resources are available by the time your project code executes.
init
The earliest hook. Fires before the manifest is loaded and the portal is populated. Use it for log drains, monkey-patching, or setting hook priorities.
import type { InitContext } from 'robo.js'
export default async (context: InitContext) => {
// Set up a custom log drain
context.logger.addDrain(myLogDrain)
}export default async (context) => {
// Set up a custom log drain
context.logger.addDrain(myLogDrain)
}Context fields:
| Field | Type | Description |
|---|---|---|
mode | string | Current runtime mode (e.g. 'development', 'production', or custom) |
projectConfig | Config | Full project configuration |
logger | Logger | Logger instance |
env | typeof Env | Environment variable access |
prepare
Fires after the portal is populated but before start hooks. Use it to create shared resources like HTTP servers or database connections that other plugins or your project depend on.
import type { PrepareContext } from 'robo.js'
export default async (context: PrepareContext) => {
const { pluginConfig, state, logger } = context
const server = createServer(pluginConfig.port)
state.set('server', server)
logger.info('Server created')
}export default async (context) => {
const { pluginConfig, state, logger } = context
const server = createServer(pluginConfig.port)
state.set('server', server)
logger.info('Server created')
}Context fields:
| Field | Type | Description |
|---|---|---|
mode | string | Current runtime mode |
projectConfig | Config | Full project configuration |
pluginConfig | TConfig | Plugin-specific config from config/plugins/ |
state | PluginState | Plugin-scoped state storage (isolated per plugin) |
logger | Logger | Logger instance (forked with plugin name for plugins) |
env | typeof Env | Environment variable access |
meta | { name, version } | Plugin package name and version |
start
The main startup hook. Fires after all prepare hooks have completed. By this point, all plugin resources should be ready.
import type { StartContext } from 'robo.js'
export default async (context: StartContext) => {
const { logger, mode } = context
logger.info(`Starting in ${mode} mode`)
await connectToDatabase()
}export default async (context) => {
const { logger, mode } = context
logger.info(`Starting in ${mode} mode`)
await connectToDatabase()
}The start context has the same fields as prepare, plus an optional portal field reserved for future use.
Reporting Status
Plugins can report their status during startup using Robo.status. In development, status items appear in the interactive terminal. In production, they fall back to logger output.
import { Robo } from 'robo.js'
import type { StartContext } from 'robo.js'
export default async (context: StartContext) => {
await connectToDatabase()
Robo.status.set('database', 'Database connected')
}import { Robo } from 'robo.js'
export default async (context) => {
await connectToDatabase()
Robo.status.set('database', 'Database connected')
}API:
| Method | Description |
|---|---|
Robo.status.set(key, value, options?) | Set a persistent status item |
Robo.status.remove(key) | Remove a status item |
Robo.status.flash(message, duration?) | Show a transient notification (default 3000ms) |
Priority system:
The options parameter accepts a priority number (default: 100). Items with priority <= 10 appear on the hint line below the input prompt. Items with higher priority only appear in the /status drawer.
// Visible on the hint line
Robo.status.set('tunnel', 'Tunnel live at https://...', { priority: 2 })
// Only visible via /status command
Robo.status.set('cache', 'Cache warmed: 150 entries')Production behavior:
Outside of dev mode, Robo.status.set() falls back to logger.ready() and Robo.status.flash() falls back to logger.info(). No special handling is needed — your plugin works in both environments.
All startup hooks (init, prepare, start) are subject to the lifecycle timeout configured in config/robo.mjs under timeouts.lifecycle. If a hook takes too long, a warning is logged.
Shutdown Sequence
stop
Fires when Robo.js shuts down. The execution order is reversed compared to startup: the project's stop hook runs first, then plugin stop hooks run in reverse priority order. This ensures things that started first are stopped last.
import type { StopContext } from 'robo.js'
export default async (context: StopContext) => {
const { reason, logger } = context
logger.info(`Stopping because: ${reason}`)
await disconnectDatabase()
}export default async (context) => {
const { reason, logger } = context
logger.info(`Stopping because: ${reason}`)
await disconnectDatabase()
}The stop context extends the start context with one additional field:
| Field | Type | Description |
|---|---|---|
reason | 'signal' | 'error' | 'restart' | Why the shutdown was triggered |
signal-- SIGTERM or SIGINT received (normal shutdown).error-- An uncaught exception triggered the shutdown.restart-- A hot reload restart is in progress.
Stop hook errors are logged but do not prevent other stop hooks from running. Graceful shutdown always completes.
Error Hook
error
Handles unhandled rejections and uncaught exceptions. All error hooks (project and plugins) run in parallel with a 5-second timeout. Errors inside error hooks are caught and logged but never propagate -- error hooks should never crash the process.
import type { ErrorContext } from 'robo.js'
export default async (context: ErrorContext) => {
const { error, type, logger } = context
logger.error(`${type}: ${error}`)
await sendAlertToSlack(error)
}export default async (context) => {
const { error, type, logger } = context
logger.error(`${type}: ${error}`)
await sendAlertToSlack(error)
}Context fields:
| Field | Type | Description |
|---|---|---|
error | unknown | The error that occurred |
type | 'unhandledRejection' | 'uncaughtException' | Type of error |
mode | string | Current runtime mode |
logger | Logger | Logger instance |
env | typeof Env | Environment variable access |
HMR Hook
hmr
Fires during development when files change and hot module replacement occurs. All HMR hooks run in parallel with a 5-second timeout. Errors in HMR hooks are logged but never crash the server.
You can filter which changes trigger your hook by exporting a config object with namespaces and routes arrays.
import type { HmrContext, HmrHookConfig } from 'robo.js'
export const config: HmrHookConfig = {
namespaces: ['server'],
routes: ['api']
}
export default async (context: HmrContext) => {
const { changeType, files, routes, logger } = context
logger.info(`HMR ${changeType}: ${files.join(', ')}`)
// Clear caches, restart services, etc.
}export const config = {
namespaces: ['server'],
routes: ['api']
}
export default async (context) => {
const { changeType, files, routes, logger } = context
logger.info(`HMR ${changeType}: ${files.join(', ')}`)
// Clear caches, restart services, etc.
}Context fields:
| Field | Type | Description |
|---|---|---|
changeType | 'change' | 'add' | 'remove' | Type of file system change |
files | string[] | Source files that changed (relative paths) |
routes | HmrRouteInfo[] | Routes affected by the change, filtered by your config |
mode | string | Current runtime mode |
logger | Logger | Logger instance |
env | typeof Env | Environment variable access |
Config filter fields:
| Field | Type | Description |
|---|---|---|
namespaces | string[] | Only trigger for these namespaces (e.g. ['server']) |
routes | string[] | Only trigger for these route types (e.g. ['api', 'commands']) |
If you omit the config export, the hook triggers for all changes.
Build Hooks
Build hooks run during robo build. They are located in src/robo/build/ and let you customize the build pipeline.
All build hooks receive a shared context with these common fields:
| Field | Type | Description |
|---|---|---|
mode | string | Current build mode |
env | typeof Env | Environment variable access |
logger | Logger | Logger instance |
paths | { root, src, output } | Project paths |
config | Config | Loaded project configuration |
store | BuildStore | Shared key-value store for the build session |
entries | EntriesAccessor | Access to processed route entries (unavailable in build/start) |
The store persists across all three build hooks within the same build, letting you pass data from build/start to build/complete.
build/start
Runs before the build begins. The project's hook runs first, then plugin hooks run in parallel.
import type { BuildContext } from 'robo.js'
export default async (context: BuildContext) => {
context.store.set('buildStart', Date.now())
context.logger.info('Build starting...')
}export default async (context) => {
context.store.set('buildStart', Date.now())
context.logger.info('Build starting...')
}The entries field is undefined in build/start because entries have not been scanned yet.
build/transform
Runs after entries are scanned but before the manifest is generated. The hook receives the entries array as its first argument and must return the (optionally modified) array. Use this to filter out entries or inject metadata.
The project's hook runs first, then plugin hooks run in parallel.
import type { BuildTransformContext } from 'robo.js'
import type { ProcessedEntry } from 'robo.js'
export default (entries: ProcessedEntry[], context: BuildTransformContext) => {
context.logger.info(`Processing ${entries.length} entries`)
return entries.filter(entry => !entry.key.startsWith('internal/'))
}export default (entries, context) => {
context.logger.info(`Processing ${entries.length} entries`)
return entries.filter(entry => !entry.key.startsWith('internal/'))
}build/complete
Runs after the manifest has been written. Use it for post-build tasks like logging summaries, registering commands with external services, or generating additional files.
The project's hook runs first, then plugin hooks run in parallel.
import type { BuildCompleteContext } from 'robo.js'
export default async (context: BuildCompleteContext) => {
const elapsed = Date.now() - context.store.get('buildStart')
context.logger.info(`Build completed in ${elapsed}ms`)
const commands = context.entries.get('discordjs', 'commands')
context.logger.info(`Built ${commands.length} commands`)
}export default async (context) => {
const elapsed = Date.now() - context.store.get('buildStart')
context.logger.info(`Build completed in ${elapsed}ms`)
const commands = context.entries.get('discordjs', 'commands')
context.logger.info(`Built ${commands.length} commands`)
}The build/complete context also provides two additional methods for metadata aggregation:
| Method | Description |
|---|---|
registerMetadataAggregator(namespace, aggregator) | Register a function that processes entries into aggregated metadata |
updateMetadata(namespace, updates) | Merge partial metadata updates for a namespace |
Hook Priority
By default, all hooks have a priority of 100. Hooks with the same priority run in parallel. Lower numbers run first.
There are three ways to control execution order.
Plugin meta options
Set priority per hook in your config/robo.mjs:
/** @type {import('robo.js').Config} */
export default {
plugins: [
['@robojs/server', { port: 3000 }, { hookPriority: { start: 50 } }]
]
}export default {
plugins: [
['@robojs/server', { port: 3000 }, { hookPriority: { start: 50 } }]
]
}The third element in the plugin tuple is the meta options object. The hookPriority field maps hook names to priority values.
Runtime API
Call these functions in your init.ts hook to set priorities at runtime:
import { setHookPriority, prioritizeHookBefore, prioritizeHookAfter } from 'robo.js'
export default () => {
// Explicit priority (lower runs first)
setHookPriority('start', '@robojs/server', 50)
// Relative: run my-plugin before @robojs/server
prioritizeHookBefore('start', 'my-plugin', '@robojs/server')
// Relative: run my-plugin after @robojs/server
prioritizeHookAfter('start', 'my-plugin', '@robojs/server')
}import { setHookPriority, prioritizeHookBefore, prioritizeHookAfter } from 'robo.js'
export default () => {
// Explicit priority (lower runs first)
setHookPriority('start', '@robojs/server', 50)
// Relative: run my-plugin before @robojs/server
prioritizeHookBefore('start', 'my-plugin', '@robojs/server')
// Relative: run my-plugin after @robojs/server
prioritizeHookAfter('start', 'my-plugin', '@robojs/server')
}prioritizeHookBeforesets the priority to the target's priority minus 10.prioritizeHookAftersets the priority to the target's priority plus 10.
Priority resolution order
Runtime overrides take precedence over meta options, which take precedence over the default value:
- Runtime override (via
setHookPriority/prioritizeHookBefore/prioritizeHookAfter) - Meta options (via
hookPriorityin config) - Default (100)
Subdirectory Hooks
You can define multiple hooks of the same type by placing them in a subdirectory named after the hook:
Both database.ts and cache.ts run during the start phase. This is useful when you have multiple independent setup tasks and want to keep them in separate files.
Setup Hook
The setup.ts hook is different from other lifecycle hooks. It runs during npx create-robo or npx robo add, not at runtime. Plugins use it to run installation wizards that collect configuration from the user.
import type { SetupContext } from 'robo.js'
export default async (context: SetupContext) => {
const answers = await context.prompt([
{ type: 'input', name: 'apiKey', message: 'Enter your API key:' }
])
context.logger.info(`Configured with key: ${answers.apiKey}`)
}export default async (context) => {
const answers = await context.prompt([
{ type: 'input', name: 'apiKey', message: 'Enter your API key:' }
])
context.logger.info(`Configured with key: ${answers.apiKey}`)
}Context fields:
| Field | Type | Description |
|---|---|---|
trigger | 'create' | 'add' | Whether the plugin is being set up via create-robo or robo add |
logger | Logger | Logger instance |
env | typeof Env | Environment variable access |
paths | { root, src, config } | Project directory paths |
exec | (command: string) => Promise<{ stdout, stderr }> | Run shell commands |
prompt | (questions) => Promise<T> | Ask the user questions (supports input, password, confirm, list, checkbox) |
package | { name, version, type } | Package info (type is 'template' or 'plugin') |
Route Definitions
Plugins can define custom source directory scanners by placing files in src/robo/routes/. For example, @robojs/discordjs defines routes/commands.ts which tells the build system to scan src/commands/ for slash command handlers.
This is an advanced plugin authoring feature. See Route Definitions for the full guide.
