Seyfert LogoSeyfert
Back to blog
// release notes
v5.0.0July 11, 2026

Cygnus X-1

A first-class plugin runtime, plus the great papercut purge — context helpers, builder guards, tighter types, and the breaking changes to go with them.

The "We Stopped Mutating the Client Behind Your Back" Release

Here's the dirty secret nobody talked about: "extending Seyfert" meant cracking open the Client, welding on some properties, and hoping the next library in line wouldn't rip them off. It worked. It also aged like milk left on a Caracas rooftop.

Seyfert v5.0.0 ships a real, typed plugin runtime — lifecycle, ordering, requirements, teardown, the whole ceremony — and then, because we were already elbow-deep in the codebase, we went on a crusade against roughly a hundred papercuts you'd quietly learned to live with.

You're welcome.


First-Class Plugins

This is the headline. Read it twice if you need to.

Seyfert now has an actual plugin API instead of "set some fields on the client and hope." A plugin can add or remove commands, components and modals, register middlewares, extend the client and context, add cache resources and lang overlays, observe REST and command execution, wrap gateway traffic, expose shared services, and emit diagnostics. Basically everything you used to do by hand — except now there are types, a lifecycle, and the confidence that two plugins won't knife each other in the dark.

Say you want to ship a reusable economy feature — a service on the client, a middleware that guards balances, and a connection that opens on start and closes on shutdown.

const balanceGuard = createMiddleware(({ next }) => next());

// Packaging it meant a setup function that performs surgery on the client.
function useEconomy(client: Client) {
  // Bolt a service on — untyped, so every `client.economy` read is `any`.
  (client as any).economy = new EconomyService();

  // Merge a middleware in and hope nothing else clobbered the object.
  client.middlewares = {
    ...client.middlewares,
    balanceGuard,
  };

  // Connect before use? Wire it yourself. Close it on shutdown?
  // ...also yourself. There was no teardown — you just had to remember.
}

const client = new Client();
useEconomy(client); // and call this, in the right order, in every bot
const balanceGuard = createMiddleware(({ next }) => next());

const economyPlugin = createPlugin({
  name: 'economy',
  // Typed client extension — `client.economy` is real and inferred now.
  client: { economy: () => new EconomyService() },
  register(api) {
    api.middlewares.add('balanceGuard', balanceGuard, { global: true });
  },
  setup: client => client.economy.connect(),
  teardown: client => client.economy.close(), // cleanup that actually runs
});

const client = new Client({ plugins: definePlugins(economyPlugin) });

That before/after is the whole pitch. Package a feature once, get types and a real lifecycle, and never perform open-heart surgery on the Client again.

Plugins have their own tab in the docs — lifecycle, ordering, shared services, extensions, gateway and REST interceptors, diagnostics — the full picture lives there. We're not reprinting the manual here. Go read it. It's good.


The Great Papercut Purge

With the plugin work already tearing the hood open, we looked around and thought: while we're in here…

What followed was a reckoning with every rough edge we'd been stepping on since v2. Here are the ones worth showing.

Lowercase option keys

Discord rejects non-lowercase option names. That's a fact you used to discover at deploy time, staring at a 400 response. Seyfert now rejects them at compile time — in your editor, before Discord ever sees it.

const options = {
  // Looks fine. Fails only once the command upload hits Discord.
  User: createStringOption({ description: 'User id', required: true }),
};
const options = {
  // v5 yells at you here, in your editor, before Discord ever sees it.
  user: createStringOption({ description: 'User id', required: true }),
};

Red squiggly > 400 response. Every time.

Autocomplete choice types

Autocomplete choices are now tied to the option type. Numeric options reject string values; string options reject numeric ones. The days of 'four' on an integer option — and Discord silently disagreeing — are over.

createIntegerOption({
  description: 'Roll a die',
  autocomplete(interaction) {
    // 'four' on an integer option. Discord disagreed. Quietly.
    interaction.respond([{ name: 'D4', value: 'four' }]);
  },
});
createIntegerOption({
  description: 'Roll a die',
  autocomplete(interaction) {
    interaction.respond([{ name: 'D4', value: 4 }]);
  },
});

Context helpers that respect your time

Half of working with a context used to be tunneling through ctx.interaction for things the context should've just handed you. We looked at the repetition and felt embarrassed. Now it does.

// Modals, member/channel fetches, modal fields, updates, message id —
// all routed through ctx.interaction (or the raw client), every time.
await ctx.interaction.modal(profileModal, { waitFor: 1_000 });

const member = ctx.guildId
  ? await ctx.client.members.fetch(ctx.guildId, ctx.author.id)
  : undefined;
const channel = await ctx.client.channels.fetch(ctx.channelId);

const username = ctx.interaction.getInputValue('username', true);
await ctx.interaction.update({ content: 'Saved' }, true);
const messageId = ctx.interaction.message.id;
const submitted = await ctx.modal(profileModal, { waitFor: 1_000 });

// Both take 'cache' | 'rest' | 'flow' with mode-aware return types
// ('cache' is sync, the rest return a Promise).
const member = await ctx.fetchMember('flow');
const channel = await ctx.channel('flow');

if (submitted) {
  const username = submitted.getInputValue('username', true);
  await submitted.update({ content: 'Saved' });
  const messageId = submitted.message.id;
}

Prefix contexts throw a clear Cannot use modal without an interaction. instead of failing in a confusing way, modal contexts expose every field accessor (channels, roles, users, files, radios, checkboxes…), and component contexts keep interaction.values typed and guards like isStringSelectMenu() useful.

Builders that fail before Discord does

Builders used to happily serialize half-built junk and let the API reject it. You'd ship a poll with no question, a modal with no title, and Discord would be the one to tell you. Embarrassing for everyone involved.

Now toJSON() checks its own work.

// No question, one answer. Discord will reject this — eventually.
new PollBuilder()
  .setAnswers({ text: 'Yes' })
  .toJSON();
// Missing question or empty answers now throw on toJSON().
new PollBuilder()
  .setQuestion({ text: 'Deploy?' })
  .setAnswers({ text: 'Yes' }, { text: 'No' })
  .toJSON();

Modal, MediaGalleryItem, and StringSelectOption validate the same way, invalid hex colors fail early, and RadioGroupOption now requires its data up front instead of being an empty shell you fill in later:

const option = new RadioGroupOption(); // an empty option, validated by vibes
const group = new RadioGroup().setCustomId('topics').setOptions(option);
const group = new RadioGroup()
  .setCustomId('topics')
  .setOptions(
    new RadioGroupOption({ label: 'General', value: 'general' }),
    new RadioGroupOption({ label: 'Other', value: 'other' }),
  );

StringSelectMenu also accepts raw API option objects now, and a typed StringSelectMenu<'value'> rejects raw values outside its union. Validated by the compiler, not by vibes.

Moderation and messages, with the IDs removed

Moderation helpers stopped speaking raw Discord (delete_message_seconds) and started speaking Seyfert. Poll and member-search helpers stopped making you pass the same IDs you already had. Small changes. The kind you feel in your fingertips every day.

// Raw snake_case, positional reason, and IDs you already have on hand.
await member.ban({ delete_message_seconds: 60 }, 'cleanup');
await member.timeout(10); // 10 = ten *seconds*, if you remembered the unit

await client.messages.endPoll(channelId, messageId);
await client.messages.getAnswerVoters(channelId, messageId, 1);
await client.members.search(guildId); // no query? sure, here's everyone-ish
await member.ban({ deleteMessageSeconds: 60, reason: 'cleanup' });
await member.timeout(10_000, 'spam'); // 10_000 ms = ten seconds, plus a reason

await message.endPoll();
const voters = await message.getAnswerVoters(1, true); // validates the id locally
await client.members.search(guildId, { query: 'alice', limit: 5 });

Voice state also grew booleans — isMuted, isDeafened, isCameraOn, isStreaming, isSuppressed — so you stop writing voiceState.mute || voiceState.selfMute for the hundredth time.

Collections that keep their keys

Collection.filter(...) and find(...) preserve type guards, and filterCollection(...) returns a real Collection with keys intact — no more rebuilding one from entries().

const active = new Collection(
  [...members.entries()].filter(([, member]) => member.active),
);
const active = members.filterCollection(member => member.active);

LimitedCollection.values() and entries() now hand you plain values; use rawValues() / rawEntries() when you actually want the expiration metadata. It also rejects NaN limits while keeping 0, negative, and Infinity behavior. Because NaN as a collection limit was never a feature — it was a cry for help.

REST observers and inline intents

Instrumenting REST used to mean overwriting onSuccessRequest and hoping nothing else wanted it too. Last assignment wins. Two libraries instrumenting REST? Good luck.

Now you register isolated observers — and they stack.

// Last assignment wins. Two libraries instrumenting REST? Good luck.
client.rest.onSuccessRequest = (method, url) => {
  metrics.increment('rest.success', { method, url });
};
const dispose = client.rest.observe({
  onRequest({ method, url }) { /* about to fire */ },
  onSuccess({ method, url, response }) {
    metrics.increment('rest.success', { method, url, status: response.status });
  },
  onFail({ method, url, error }) { /* it didn't go well */ },
  onRatelimit({ method, url, response }) { /* Discord said slow down */ },
});

dispose(); // unsubscribe whenever you're done

Four hooks — onRequest, onSuccess, onFail, onRatelimit — all with readonly, lazily-built payloads, and isolated failures (one throwing observer doesn't take down the rest). Plugin observers run first, then direct observers, then legacy callbacks.

And because typing GatewayIntentBits.Guilds got old three major versions ago, intents accept strings inline now:

await client.start({ connection: { intents: GatewayIntentBits.Guilds } });
await client.start({ connection: { intents: ['Guilds'] } });

Pick your style. We don't judge.

Formatter helpers that know what they return

Formatter grew genuinely typed template-literal helpers, and timestamp(...) finally defaults to relative time instead of a style nobody picked on purpose.

// Hand-rolled strings, no type safety, and a timestamp default of "whatever".
const block = `>>> ${notes}`;
const emoji = `<:${name}:${id}>`;
Formatter.timestamp(date); // defaults to a fixed style, not relative
Formatter.blockQuote(notes);       // typed `>>> ${string}`
Formatter.emojiMention(id, name);  // typed `<:name:id>`
Formatter.channelLink(channelId);  // typed ChannelLink
Formatter.timestamp(date);         // now defaults to 'R' (relative time)

quote, blockQuote, emojiMention, and channelLink return narrow template-literal types now, and timestamp(...) also accepts millisecond numbers. Small thing, but your editor finally autocompletes the shape. That's the whole point.

Typed groups, middlewares, and locales

A few new typed building blocks that make the daily grind less grindy: defineGroups(...) declares subcommand groups in a reusable, inferred object; middlewares('auth', 'rateLimit') captures middleware names with inference instead of a loose string[]; and preferGuildLocale flips ctx.t to resolve the guild locale before the user's.

const mws = middlewares('auth', 'rateLimit'); // names are inferred, not stringly-typed

const client = new Client({
  // Reach for the guild's language first, fall back to the user's.
  langs: { preferGuildLocale: true },
});

Everything else, by subsystem

The rest of the hundred. Not big enough for a code sample each, too real to bury in a "various fixes" line. Grouped so you can scan to your subsystem and move on.

Commands, middleware & decorators

  • A middleware that throws or rejects now reaches onInternalError with the original error. Only stop('reason') is treated as a denial.
  • A denial's metadata.middleware now names the middleware that actually called stop(), not whichever one sat at the current index.
  • command.middlewares is a readonly tuple now — mutating it in place (.push(...)) is a type error. As it should be.
  • Group(groupsDef, key) gained an overload that type-checks the key against a groups-definition object.
  • Options(...) gained a (new () => SubCommand)[] overload alongside the lowercase-record one.
  • InferMiddlewares<T> extracts the middleware-name union from a middlewares(...) tuple.
  • New exported group types: GroupDefinition, GroupDefinitions, LocalizedGroupDefinition, TranslatedGroupDefinition.
  • Command-locale resolution warns (Locale key "x" resolved to nothing …) on missing keys instead of shipping blanks, and stopped mutating the shared aliases array.

Contexts

  • GuildCommandContext narrows channel(...) to guild channels and fetchMember(...) to a non-undefined member; new GuildCommandChannel type backs it.
  • write(...) / editOrReply(...) return void unless fetchReply is true — the false branch is no longer a fuzzy void | Message | … union.
  • Component contexts gained a StringSelectValues generic and a ctx.message getter, and isButton() now reads interaction.componentType (was interaction.data.componentType).
  • Modal contexts gained the full accessor set: getChannels, getRoles, getUsers, getMentionables, getRadioValues, getCheckbox, getCheckboxValues, getInputValue, getFiles, plus update(...) and deferUpdate().
  • ComponentCommand.onInternalError / ModalCommand.onInternalError now receive the offending component / modal instance before error.

Builders & structures

  • ComponentCollectorStopReason is exported now.
  • GuildMember.roles.keys is readonly, roles.highest() resolves to undefined when there are no roles, and manageable() parallel-fetches and returns false on incomplete role data.
  • GuildRole.edit(body, reason?) takes an audit-log reason.
  • MessagesMethods.list(fetchOptions?) made its query optional, and embed serialization accepts any toJSON()-bearing object, not just Embed instances.
  • BitField.keys() returns typed (keyof T)[] instead of string[], and has(...) / Permissions.has / strictHas accept a single bit or an array.
  • PermissionsBitField.resolve(...) throws TypeError on an unknown permission string instead of silently resolving to nothing.
  • BaseInteraction.reply() tracks an in-flight _repliedPromise and throws INTERACTION_ALREADY_REPLIED on a double-reply race; edits await the pending reply first.

Cache

  • keys() / values() / count() accept '*' to query across every guild or channel scope at once, and bulkGet(...) infers its result shape from the key tuples you pass.
  • GuildBasedResource.flush(guild) lost its default — pass '*' to flush everything — while GuildRelatedResource.flush(guild = '*') gained one.

Shorters & utilities

  • ChannelShorter.pins(...) returns pinnedAt as an epoch-ms number, not an ISO string.
  • resolveColor(...) throws on a malformed #hex string instead of quietly producing NaN.
  • OAuth2URLOptions.permissions is optional now.
  • New exported utility types MakePresent, PickPresent, NonNullish, PropWhen; ResolverProps.embeds also accepts InMessageEmbed.
  • SeyfertError.is(error, code) narrows a caught error by code ('CANNOT_USE_MODAL', 'SEYFERT_CONFIG_LOAD_ERROR', …).

Events

  • createEvent(...) and ClientEvent.run return Awaitable<unknown> instead of any, so handlers can return useful values without fighting the type system.
  • Custom (non-gateway) event handlers no longer receive a trailing shardId.
  • Throwing inside a once event resets it so it can fire again, and VOICE_CHANNEL_STATUS_UPDATE hands you a resolved VoiceChannel | undefined instead of a raw cache promise.

Langs

  • Lang files warn instead of failing silently on a missing default export, accept a single named-object export as a fallback, and skip ambiguous or invalid files with explicit warnings.

Client & runtime

  • A bad token throws INVALID_TOKEN (at client start and per request) instead of a generic assertion failure.
  • HTTP interactions answer PING with PONG directly, before the command handler runs.
  • A failed seyfert.config load throws SEYFERT_CONFIG_LOAD_ERROR with the original error preserved as cause.
  • The client logger is configurable via new Client({ logger: { … } }), and the setting is now honored on worker clients too.
  • UserAvatarDefault narrowed from number to 0 | 1 | 2 | 3 | 4 | 5.

REST

  • App code (not just plugins) can register observers via client.rest.observe(...), and RestArgumentsRequiredQuery is exported.
  • Rate-limit retries preserve the full request — query, files, token, appendToFormData — instead of dropping fields on the way back into the queue. Fun bug.

New root exports

Importable by name now: BanOptions, ChannelLink, MessageLink, Timestamp, TimestampStyle, EmbedColors, HeadingLevel, OAuth2URLOptions, SeyfertError, SeyfertErrorCode, SeyfertErrorMessages, createValidationMetadata, PropWhen, StructPropState, StructStates, LimitedCollectionData, GuildRole, BotConfig, HttpConfig, ClientMiddlewares, AllClientEvents, CollectorRunParameters, Collectors, ParseClientEventName, ShardData, ShardManagerOptions, WorkerData, WorkerManagerOptions, WorkerInfo, WorkerShardInfo, Logger, LogLevels, LoggerOptions, CustomizeLoggerCallback, and AssignFilenameCallback.


Breaking Changes

The price of progress. Every change below is a before → after you can migrate against, with a one-line note on what moved and why.

Client augmentation

Extending the Client moved off two hand-maintained interfaces and onto one central SeyfertRegistry:

declare module 'seyfert' {
  interface UsingClient extends ParseClient<Client<true>> {} 
  interface RegisteredMiddlewares extends ParseMiddlewares<typeof middlewares> {} 
  interface SeyfertRegistry { 
    client: ParseClient<Client<true>>; 
    middlewares: typeof middlewares; 
  } 
}

UsingClient, RegisteredMiddlewares, DefaultLocale and the plugin list are all derived from SeyfertRegistry now — augment its client / middlewares / langs / plugins keys instead of each type by hand.

import { type ParseMiddlewares } from 'seyfert'; 
interface RegisteredMiddlewares extends ParseMiddlewares<typeof middlewares> {} 
interface SeyfertRegistry { middlewares: typeof middlewares } 

ParseMiddlewares is gone — createMiddleware(...) already returns the right shape, so the bare typeof middlewares is enough.

import type { APIGuildCreateOverwrite } from 'seyfert/lib/types'; 
import type { RESTAPIGuildCreateOverwrite } from 'seyfert/lib/types'; 

Pure rename, to match the REST* naming of the rest of the type surface.

Middlewares

pass() is gone — stop() with no arguments now skips the interaction silently.

const guard = createMiddleware(({ next, stop, pass }) => { 
const guard = createMiddleware(({ next, stop }) => { 
  if (ignore) return pass(); 
  if (ignore) return stop(); // skip silently, no error
  if (blocked) return stop('Not allowed'); // stop with an error — unchanged
  return next();
});

stop() with no arguments replaces pass() (skip and do nothing); stop('reason') still stops execution and reports the error to the handler.

Moderation

Bans, timeouts and search stopped speaking raw Discord and started requiring the query you already had.

await member.ban({ delete_message_seconds: 60 }, 'cleanup'); 
await member.ban({ deleteMessageSeconds: 60, reason: 'cleanup' }); 

reason moved inside the options object and the key is camelCase; the same { deleteMessageSeconds, reason } shape applies to GuildBan.create(...), MemberShorter.ban(...) and BanShorter.create(...).

await member.timeout(10); // 10 = ten seconds
await member.timeout(10_000, 'spam'); // milliseconds now, + reason

timeout(...) takes milliseconds, not seconds, plus an optional reason.

await client.members.search(guildId); 
await client.members.search(guildId, { query: 'alice' }); 

The query is required now.

Interactions & builders

await ctx.interaction.deferUpdate(true); 
await ctx.interaction.deferUpdate(); 

The withResponse argument is gone.

new RadioGroup().setOptions([optionA, optionB]); // positional array
new RadioGroup().setOptions(optionA, optionB); // rest params

RadioGroup.setOptions(...) and StringSelectMenu.setOptions(...) take rest params instead of an array.

const opt = new RadioGroupOption(); 
const opt = new RadioGroupOption({ label: 'A', value: 'a' }); 

The empty constructor is gone — pass the data up front.

const msg = await ctx.editResponse(body); // ComponentContext only
const msg = await ctx.editOrReply(body, true); // pass fetchReply for the Message

ComponentContext.editResponse(...) was removed; editOrReply(...) returns void unless you pass fetchReply. (ModalContext.editResponse still exists.)

if (await ctx.interaction.replied) {} 
if (ctx.interaction.replied) {} 

BaseInteraction.replied is a plain boolean now, not a pending-operation promise.

const keys: string[] = permissions.keys(); 
const keys = permissions.keys(); // (keyof typeof PermissionFlagsBits)[]

BitField.keys() returns typed keys, not string[].

new PermissionsBitField().resolve(bits); // instance method
PermissionsBitField.resolve('NotAPerm'); // silent no-op
PermissionsBitField.resolve('Administrator'); // static only; throws on bad input

The instance resolve(...) was removed; the static one throws on unknown strings and on negative / unsafe-integer inputs.

const json = new Modal().toJSON();
// returned a partial object
// throws now if required fields (custom_id, title) are missing

toJSON() on Poll, Modal, MediaGalleryItem, StringSelectOption and RadioGroupOption throws when required fields are missing.

options = { MyOption: createStringOption({ /* ... */ }) }; 
options = { myoption: createStringOption({ /* ... */ }) }; 

Command option keys must be lowercase, and a typed StringSelectMenu<'value'> rejects out-of-union raw values.

selectMenu.disabed; 
selectMenu.disabled; 
await entryPoint.withReponse(); 
await entryPoint.withResponse(); 

Two public typos fixed (old spellings removed): .disabled on select-menu builders, and EntryPointInteraction.withResponse(...).

Collections, cache & utilities

const ts: bigint = snowflakeToTimestamp(id); 
const ts: number = snowflakeToTimestamp(id); // unix ms

Returns a number (unix ms), not a bigint.

cache.roles.flush(); 
cache.roles.flush('*'); 

GuildBasedResource.flush(...) has no default — pass '*' to clear everything.

for (const meta of limited.values()) {} // yielded { value, expire }
for (const value of limited.values()) {} // plain values now
for (const meta of limited.rawValues()) {} // metadata lives here

values() / entries() yield plain data; rawValues() / rawEntries() carry the metadata. LimitedCollection also throws on a NaN limit.

const res = cache.bulkGet(keys); // full union result
const res = cache.bulkGet([['users', id]]); // result inferred from the keys

Cache.bulkGet(...) infers its result shape from the keys you pass; BulkGetKey is the new key type.

cache.bulkGet([['bans', userId]]); 
cache.bulkGet([['bans', userId, guildId]]); 

bans is a guild-based resource now, so its key needs the guildId.

const at: string = pin.pinnedAt; // ISO string
const at: number = pin.pinnedAt; // unix ms

ChannelShorter.pins(...) returns pinnedAt as a number, and resolveColor('#zzz') throws on a malformed #hex now.

const m = (await channel.members()).get(id); // Collection
const m = (await channel.members()).find(x => x.id === id); // array

VoiceChannel.members(...) resolves to a GuildMemberStructure[], not a Collection.

const img = await resolveImage('https://x/404.png'); // read the error body
const img = await resolveImage('https://x/ok.png'); // throws on non-OK now

resolveImage(...) / resolveAttachmentData(...) throw INVALID_ATTACHMENT_TYPE when a remote URL responds non-OK.

adapter.relationships.get(id)?.includes(x); // string[]
adapter.relationships.get(id)?.has(x); // Set<string>

MemoryAdapter.relationships is a Set now — only matters if you read it from a custom adapter.

cache.hasPrenseceUpdates; 
cache.hasPresenceUpdates; 

Getter typo fixed.

Events, collectors & runtime

client.collectors.run('MESSAGE_CREATE', filter); // raw gateway name
client.collectors.run('messageCreate', filter); // camelCase client name

Use the camelCase client event name, not the raw gateway name.

import type { CollectorRunPameters } from 'seyfert'; 
import type { CollectorRunParameters } from 'seyfert'; 

Typo fixed.

client.collectors.run('messageCreate', filter);
// only the first matching collector ran
// every collector whose filter matches now runs

The early break is gone — tighten your filters if you relied on first-match-wins.

createEvent({ data: { name: 'myEvent' }, run(payload, client, shardId) {} }); 
createEvent({ data: { name: 'myEvent' }, run(payload, client) {} }); 

Custom (non-gateway) handlers no longer get a trailing shardId; run is typed Awaitable<unknown>.

createEvent({
  data: { name: 'VOICE_CHANNEL_STATUS_UPDATE' },
  run(payload) {}, // raw cache value
  run(channel) {}, // resolved VoiceChannel | undefined
});

VOICE_CHANNEL_STATUS_UPDATE hands you a resolved VoiceChannel | undefined now.

onInternalError(client, error) {} 
onInternalError(client, component, error) {} 

ComponentCommand.onInternalError / ModalCommand.onInternalError gained a component / modal parameter before error.

client.setServices({ handleCommand: new HandleCommand(client) }); 
client.setServices({ handleCommand: HandleCommand }); 

ServicesOptions.handleCommand takes the HandleCommand constructor, not an instance.

new WorkerManager({ /* ... */ }); 
new WorkerManager({ mode: 'threads', path: './bot.js' }); // or mode: 'custom' + adapter

WorkerManagerOptions is a discriminated union over modecustom needs an adapter, threads / clusters need a path.

new WorkerClient({ onShardDisconnect() {} }); 
// listen to the shard disconnect / reconnect events instead

WorkerClientOptions.onShardDisconnect / onShardReconnect (and the Client equivalents) are deprecated.

type UserAvatarDefault = number; 
type UserAvatarDefault = 0 | 1 | 2 | 3 | 4 | 5; 

UserAvatarDefault narrowed to 0 | 1 | 2 | 3 | 4 | 5.


Upgrade

pnpm install [email protected]

Five major versions in, and Seyfert finally lets you extend it without performing surgery on the Client. The plugins are the future; the hundred papercuts are the part you'll feel tomorrow morning.

Not every release reinvents the framework. This one just made it yours.

Seyfert v5.0.0 — plugins in the front, papercuts in the back.