Skip to content
GitHub

Lifecycle hooks

MeoCord 4.2 · Messages and events · page 18 of 41 · since 4.1.0

Start work once the bot is online, and stop it cleanly before the bot shuts down.

You'll learn

  • Run code when the bot is ready, with `OnReady`
  • Clean up before it stops, with `OnShutdown`
  • Know the order hooks run in, and what happens when one fails

A controller or service implements OnReady to do work once the bot is online, and OnShutdown to clean up before it stops. The hooks run in dependency order, so a database is connected before the scheduler that uses it, and closed after.

When to use it

Use onReady for work that needs the client online: setting the bot's activity, starting a timer, warming a cache. Use onShutdown to stop that work: clear timers, flush metrics, close connections.

For something to do in response to Discord, use gateway events instead.

Example

services/reminder.scheduler.ts
import { type Client } from 'discord.js'
import { Service } from 'meocord/decorator'
import { type OnReady, type OnShutdown, type ReadyInfo } from 'meocord/interface'

@Service()
export class ReminderScheduler implements OnReady, OnShutdown {
  private readonly due: { userId: string; text: string }[] = []
  private timer?: ReturnType<typeof setInterval>

  add(userId: string, text: string) {
    this.due.push({ userId, text })
  }

  onReady(client: Client<true>, { primary }: ReadyInfo) {
    // With sharding, only the process running shard 0 sends reminders
    if (primary) this.timer = setInterval(() => void this.send(client), 60_000)
  }

  onShutdown() {
    clearInterval(this.timer)
  }

  private async send(client: Client<true>) {
    for (const { userId, text } of this.due.splice(0)) await client.users.send(userId, text)
  }
}

How it works

Every controller and service the app binds gets hooks: those listed in @MeoCord({ controllers, services }), everything they depend on, what @MeoCord({ providers }) supplies, the app's cooldown store and its themeFor class. Observers get them too. Guards, interceptors and filters get none, unless the app also binds it: listed in services or providers, or injected by a class that gets hooks.

onReady

onReady runs once the client is ready. It receives the client and { primary }, which says whether this process should do one-off work: true for a bot in one process, and with process sharding only in the process running shard 0.

The hooks run one at a time, each class after the classes it injects. Classes with no dependency between them run in declaration order: the app's cooldown store first, then the providers, the services, the themeFor class, the controllers and the observers. Command registration runs alongside and never delays them. A hook still running after 10 seconds is named in a warning, and the hooks after it wait for it.

onShutdown

onShutdown runs on SIGINT, SIGTERM or app.stop(), before the client is destroyed, in reverse order, so a class stops before the classes it uses. The bot waits for the whole sequence up to shutdownTimeout in meocord.config.ts, 10 seconds by default, then shuts down whether or not it finished.

  • A second signal more than a second after the first exits at once. One sooner counts as the same request, since a terminal's Ctrl+C can arrive twice.
  • If the bot never became ready, because the login failed, no onShutdown hook runs.
  • A signal while the onReady hooks are still running shuts down only the classes the hooks had reached: those whose onReady finished, and those before them without one. No further onReady starts.

Stopping from code

await app.stop() stops the bot without a signal, as an owner-only shutdown command, a graceful restart or an integration test needs. It runs the onShutdown hooks under shutdownTimeout and closes the client. A bot in one process keeps its process running.

  • With process sharding, it stops every shard, whichever process calls it. The manager's stop() keeps the manager running; a shard's ends that shard's process with the others.
  • A client that fails to close, or a shard the manager has to kill, sets process.exitCode to 1, unless another code is set, as a signal's shutdown would.
  • A stop while the bot logs in ends that login, so its start() rejects.
  • Calls after the first wait for it. A stopped app doesn't start again; create a new one with MeoCordFactory.create.
  • A signal while stop() runs waits for its hooks to finish rather than exiting at once.

Failures

A hook that throws is logged, and the next one still runs. When a class's onReady failed, the classes that depend on it still run theirs, with a warning naming the failed dependency.

Testing

A hook is a method, so a unit test calls it as the bot would, with a client from createMockClient:

services/reminder.scheduler.spec.ts
describe('ReminderScheduler', () => {
  beforeEach(() => vi.useFakeTimers())
  afterEach(() => vi.useRealTimers())

  it('sends due reminders every minute from onReady until onShutdown', async () => {
    const client = createMockClient()
    const scheduler = new ReminderScheduler()
    scheduler.add('111111111111111111', 'Water the plants')

    scheduler.onReady(client, { primary: true })
    await vi.advanceTimersByTimeAsync(60_000)
    expect(client.users.send).toHaveBeenCalledWith('111111111111111111', 'Water the plants')

    scheduler.onShutdown()
    scheduler.add('111111111111111111', 'Too late')
    await vi.advanceTimersByTimeAsync(60_000)
    expect(client.users.send).toHaveBeenCalledTimes(1)
  })

  it('schedules nothing outside the primary process', async () => {
    const client = createMockClient()
    const scheduler = new ReminderScheduler()
    scheduler.add('111111111111111111', 'Water the plants')

    scheduler.onReady(client, { primary: false })
    await vi.advanceTimersByTimeAsync(60_000)
    expect(client.users.send).not.toHaveBeenCalled()
  })
})

A testing module runs them in the bot's order: await module.init({ ready: true }) runs every onReady, with a mock client and { primary: true } unless ready names others, and await module.close() runs the onShutdown hooks, in reverse, of everything the module constructed. Every hook runs even when one throws; init or close then rejects with that error, or an AggregateError naming each hook that threw.

Gotchas

  • One-off work runs on every shard. With process sharding, check primary before work only one process should do, such as posting a daily summary.
  • A slow onReady holds up the rest. Hooks run one at a time. Start long work without awaiting it, and stop it in onShutdown.
  • Work left running at shutdown. A timer the class doesn't clear keeps running until the process exits. Clear it in onShutdown.

Build it

Once the bot is online, its profile says how to reach it: "Listening to /feedback".

tutorial/activity.service.ts
// Once the bot is online, its profile says how to reach it
@Service()
export class ActivityService implements OnReady {
  onReady(client: Client<true>) {
    client.user.setActivity('/feedback', { type: ActivityType.Listening })
  }
}

Add ActivityService to services in the app. onReady runs once the client is ready, so client.user is there to set.

Next steps