The lifecycle
Control when a plugin starts and stops with init(), ready(), and dispose(). Covers the startup and shutdown phases, moving a method to another phase, timeouts, and what happens when init() or dispose() fails.
A plugin's setup often depends on timing. A database has to connect before the first handler reads it, a status update needs a logged-in client, and a timer should stop before the process exits. Code in a constructor runs too early for all three.
seedcord calls your plugin at those points instead. init() is the one method every plugin writes. If you add ready() or dispose(), seedcord calls those too.
import { Plugin } from '@seedcord/gateway';
export class Presence extends Plugin {
private timer?: NodeJS.Timeout;
public async init(): Promise<void> {
this.logger.info('presence plugin starting');
}
public override async ready(): Promise<void> {
this.timer = setInterval(() => {
this.core.bot.client.user?.setActivity('with types');
}, 60_000);
}
public override async dispose(): Promise<void> {
clearInterval(this.timer);
}
}setActivity needs a logged-in client, so Presence starts its timer in ready(). dispose() clears that timer, since an interval left running keeps calling a client that has logged out.
Gateway only
this.core.bot holds the discord.js client, which only a gateway bot has. Discord takes a presence update over the gateway alone, so an http bot can't run Presence, though init(), ready(), and dispose() work the same there.
Where each one runs
Startup runs three phases in order.
| startup phase | what has happened | plugins |
|---|---|---|
Configuration | nothing connected | init() runs here by default |
Login | the gateway session opens, and http runs its REST-only startup work | |
Ready | handlers are live and interactions dispatch | ready() always runs here |
Shutdown runs four phases.
| shutdown phase | what happens | plugins |
|---|---|---|
Unbind | new interactions and requests stop | |
Drain | in-flight work finishes and internal services stop | |
Disconnect | external resources close | dispose() runs here by default |
Logout | the client disconnects last, so a draining handler can still reference it |
Say you attach db, then settings, which loads each server's settings from db. Both write all three methods and keep the default phases.
startup
├─ Configuration
│ ├─ db.init()
│ └─ settings.init() starts once db.init() has finished
├─ Login
└─ Ready
├─ db.ready() every init() has finished
└─ settings.ready()
shutdown
├─ Unbind
├─ Drain
├─ Disconnect
│ ├─ settings.dispose() db is still open
│ └─ db.dispose()
└─ LogoutYour plugins start in the order you attach them and stop in the reverse order. Attach a plugin before any plugin that reads from it. seedcord puts all of a phase's plugin calls in one task, so they run one after another while the phase's other tasks run in parallel with them. Your handlers can already receive interactions and events while ready() is still running. Set up anything a handler needs in init().
Moving a method to another phase
The defaults fit most plugins, but some don't. If your dispose() writes a final batch into a database that another plugin connects, that write has to finish before the database plugin disconnects. Your dispose() then belongs in Drain, one phase earlier. Pass a PluginLifecycleSpec as the second argument to super(). If you leave a field out, it keeps its default.
export class Presence extends Plugin {
public constructor(host: CoreBase) {
super(host, {
init: { phase: StartupPhase.Login },
ready: { timeout: 20_000 },
dispose: { phase: ShutdownPhase.Drain, timeout: 30_000 }
});
}
public async init(): Promise<void> {}
}Presence moves init() to Login, gives ready() 20 seconds, and moves dispose() to Drain with 30 seconds. The ready field takes a timeout without a phase, since ready() always runs in the Ready phase. If you move an init() to Ready, it still runs before every plugin's ready().
Timeouts
An init() waiting on a service that never answers would hang your bot's startup forever, so each method gets its own timeout. init() and ready() default to 15000ms, and dispose() defaults to 10000ms.
If a method runs past its timeout, seedcord throws LifecycleTaskTimeout. The task name in the message carries the attach key and which method ran.
Task "Plugin (db)" timed out after 15000ms.
Task "Plugin:db:ready" timed out after 15000ms.
Task "Plugin:db:dispose" timed out after 10000ms.A timeout of zero, a negative number, or Infinity throws PluginInvalidLifecycleTimeout in the constructor, before any of your code runs.
The shutdown deadline
Those timeouts limit one method each. Your hosting platform kills the process if shutdown as a whole takes too long, so seedcord also bounds the whole shutdown with a single deadline, 25000ms by default. Once it elapses seedcord skips the phases it never reached and exits. A task already running keeps going until the process exits three seconds later. A deadline of zero, a negative number, or Infinity throws LifecycleInvalidShutdownDeadline.
If shutdown starts while the bot is still starting, it waits for startup to finish, and that wait counts against the same deadline. If the deadline passes first, seedcord doesn't dispose the plugins that finish starting after that.
The 25000ms default fits the 30 seconds Kubernetes waits before SIGKILL, with the three-second exit delay added. docker stop allows 10 seconds, so set shutdownDeadline to 5000 there, which leaves two seconds after the exit delay. You can also widen the window with docker stop -t.
import { resolve } from 'node:path';
import { Seedcord } from '@seedcord/gateway';
import { GatewayIntentBits } from 'discord.js';
const seedcord = new Seedcord({
bot: {
clientOptions: { intents: [GatewayIntentBits.Guilds] },
interactions: {
path: resolve(import.meta.dirname, './handlers')
},
commands: { path: null },
events: { path: null }
},
subscribers: { path: null },
// 25s plus the 3s log flush fits inside kubernetes' 30s window
lifecycle: { shutdownDeadline: 25_000 }
});When init fails
An init() usually fails because a service it connects to is down or can't be reached. When that happens, startup stops and start() rejects with LifecyclePhaseFailures, carrying your error in its errors array. What gets cleaned up depends on whether init() threw or ran out of time.
When it throws
seedcord calls dispose() on every plugin whose init() had already finished, in reverse attach order. The plugin that threw doesn't get a dispose() call. Anything its init() opened before the error stays open unless init() closes it.
export class Feeds extends Plugin {
private readonly client = new FeedClient();
public async init(): Promise<void> {
await this.client.connect();
try {
await this.client.subscribe('updates');
} catch (error) {
await this.client.close();
throw error;
}
}
}If subscribe throws, Feeds closes the connection in its catch, then rethrows so startup still fails.
When it times out
Startup fails, but your init() keeps running in the background. What happens next depends on whether it finishes:
- It finishes later. seedcord calls that plugin's
dispose()as soon as it does, without waiting for the other plugins'dispose()calls. If thatdispose()throws, seedcord only logs a warning. - It never finishes.
dispose()never runs. Give whateverinit()waits on its own timeout, so a hang turns into an error yourcatchhandles.
Once startup has failed, the same Seedcord can't start again. Calling start() on it a second time throws LifecycleRestartAfterFailure, so exit and let your process manager start a fresh one. When using seedcord dev, press r instead, which loads your bot into a fresh Seedcord.
When dispose fails
If one dispose() fails, the others still run. With db and settings from earlier, a throw from settings.dispose() still lets db.dispose() close the connection. Once they've all run, seedcord throws the failure it caught. If several failed, it throws one AggregateError carrying PluginDisposeFailures, with each error in its errors array.