Introduction
@wolfstar/http-framework currently speaks to Discord exclusively through the interactions HTTP endpoint: a bot receives INTERACTION_CREATE payloads on a webhook and responds synchronously. That's a great fit for stateless, serverless-friendly deployments, but it means a bot built on the framework has no way to react to anything that only exists on the Gateway — MESSAGE_CREATE, presence/voice state updates, guild member events, raw moderation-relevant events, etc. Today the only paths forward are to bring in a second, unrelated framework (e.g. Carbon) just for the Gateway half, or to hand-roll a WebSocket client per project.
This was requested directly in wolfstar-project/stars-components#51, which asks for a package that:
- Provides Gateway connectivity "comparable to Carbon's architecture or the RFC outlined in discordjs/discord.js#11426"
- Supports both local (in-memory) and Redis-backed caching
- Is functionally on par with what Carbon and discord.js already offer
- Supports compressing cached data
This RFC proposes @wolfstar/plugin-gateway, exporting a GatewayClient that extends @wolfstar/http-framework's Client — not a separate object bridged in through a plugin hook. A bot built on GatewayClient keeps every interactions capability of the base Client (load(), listen(), the command/interaction-handler stores, Client.use()) for free, and gains Gateway connectivity, caching, and a set of ready-made Structures on top, in the spirit of the "mini discord.js" Client being prototyped upstream in discordjs/discord.js#10983. Crucially, and unlike raw Gateway dispatch handling, GatewayClient events hand consumers Structure instances (Message, User, Guild, …) rather than raw snake_case API JSON — the same direction discord.js#10983 and the @discordjs/structures package are taking upstream.
The caching half is intentionally split out into its own, more focused RFC — see the companion issue linked in the footnotes. GatewayClient's Managers only depend on the Cache interface defined there, not on any specific store implementation.
Design principles
- Extend, don't wrap.
GatewayClient extends Client. A bot that wants Gateway support constructs a GatewayClient instead of a Client and otherwise writes exactly the code it already knows — command stores, interaction handlers, client.load(), client.listen() all keep working unmodified.
- Events carry Structures, not raw payloads.
client.on('messageCreate', (message: Message) => …) gets a Message with typed getters (message.author, message.content, …), not client.on(GatewayDispatchEvents.MessageCreate, (data: APIMessage) => …). Consumers shouldn't need to know the raw API shape to write a handler.
- Gateway events are listener pieces too.
@wolfstar/http-framework already has a file-based Listener/ListenerStore piece system (loaded via client.load()) for everything else the framework emits. Because GatewayClient extends Client, that same piece loader should work for Gateway-sourced events without a second, parallel mechanism — @wolfstar/plugin-gateway ships EventGatewayListener, a thin Listener subclass, rather than inventing a separate listener API.
- Don't reinvent sharding.
@discordjs/ws's WebSocketManager already solves reconnects, resumes, rate-limited identify, and shard orchestration correctly. GatewayClient wraps it rather than re-implementing it.
- Cache is a seam, not a requirement.
GatewayClient accepts any object satisfying the Cache interface from the companion RFC. No cache, an in-memory cache, and a Redis-backed cache are all equally valid — Managers build Structures on top of whatever Cache returns.
Structures
Rather than inventing a parallel structure system, @wolfstar/plugin-gateway depends directly on @discordjs/structures for the base Structure<DataType> class: it already gives us data storage behind a kData symbol, in-place patching via [kPatch], cheap [kClone], and a DataTemplate mechanism to strip fields we never want to keep resident. wolfstar-specific concrete structures live in this package and extend it:
import { Structure, kData } from '@discordjs/structures';
import type { APIUser } from 'discord-api-types/v10';
export class User extends Structure<APIUser> {
public get id() {
return this[kData].id;
}
public get username() {
return this[kData].username;
}
public get discriminator() {
return this[kData].discriminator;
}
public displayAvatarURL(): string | undefined {
return this[kData].avatar
? `https://cdn.discordapp.com/avatars/${this.id}/${this[kData].avatar}.png`
: undefined;
}
}
Managers
Each entity type gets a CachedManager, mirroring the createStructure pattern from discord.js#10983's ChannelManager:
import type { Snowflake } from 'discord-api-types/globals';
import type { Structure } from '@discordjs/structures';
import type { Cache, EntityCache } from '@wolfstar/plugin-cache'; // see companion RFC
export abstract class CachedManager<Value extends Structure<{ id: Snowflake }>, Raw = unknown> {
public constructor(protected readonly client: GatewayClient) {}
protected abstract entityCache(cache: Cache): EntityCache<Raw>;
protected abstract createStructure(data: Partial<Raw>): Value;
public async get(id: Snowflake): Promise<Value | undefined> {
const raw = await this.entityCache(this.client.cache).get(id);
return raw ? this.createStructure(raw) : undefined;
}
}
export class UserManager extends CachedManager<User, APIUser> {
protected override entityCache(cache: Cache) {
return cache.users;
}
protected override createStructure(data: Partial<APIUser>): User {
return new User(data);
}
}
GatewayClient
import type { GatewayIntentBits } from 'discord-api-types/v10';
import { Client, type ClientOptions } from '@wolfstar/http-framework';
import type { Cache } from '@wolfstar/plugin-cache'; // see companion RFC
export interface GatewayClientOptions extends ClientOptions {
intents: GatewayIntentBits | number;
/** Explicit shard count/range, or "auto" to let the Gateway session-start-limit endpoint decide. */
shards?: number | number[] | 'auto';
/** Optional cache implementation; see the companion Cache RFC. Defaults to a no-op cache. */
cache?: Cache;
}
export class GatewayClient extends Client {
public readonly cache?: Cache;
public readonly users: UserManager;
public readonly guilds: GuildManager;
public readonly channels: ChannelManager;
public constructor(options: GatewayClientOptions);
/** Starts the WebSocketManager and connects all configured shards. */
public connect(): Promise<void>;
}
On dispatch, GatewayClient feeds the raw payload to the relevant EntityCache first, then asks the corresponding Manager to build (or patch) the Structure before emitting it — reusing the same AsyncEventEmitter the base Client already uses for interaction events:
gateway.on(WebSocketShardEvents.Dispatch, async ({ data: payload }, shardId) => {
switch (payload.t) {
case GatewayDispatchEvents.MessageCreate: {
await this.cache?.messages.set(payload.d.id, payload.d);
this.emit('messageCreate', new Message(payload.d), shardId);
break;
}
// ...one case per supported dispatch, table-driven rather than hand-written per event
}
});
import { GatewayClient } from '@wolfstar/plugin-gateway';
import { createInMemoryCache } from '@wolfstar/plugin-cache';
import { GatewayIntentBits } from 'discord-api-types/v10';
const client = new GatewayClient({
discordToken: process.env.DISCORD_TOKEN,
intents: GatewayIntentBits.Guilds | GatewayIntentBits.GuildMessages,
cache: createInMemoryCache(),
});
await client.load(); // inherited from Client: loads commands & interaction handlers, unchanged
client.on('messageCreate', (message) => {
console.log(`${message.author.username}: ${message.content}`);
});
await client.connect();
await client.listen({ port: 8_080 }); // interactions webhook still works, unchanged
EventGatewayListener: file-based Gateway event pieces
@wolfstar/http-framework already loads Listener pieces from a listeners/ directory via client.load(), targeting an emitter (resolved either from a concrete Emitter or a container key, e.g. 'client') and an event name, with run(...) receiving whatever that event emits. Since GatewayClient extends Client, Gateway-derived events emitted with this.emit('messageCreate', …) land on the exact same emitter regular framework listeners already target — so @wolfstar/plugin-gateway doesn't need a second piece store, just a strongly-typed Listener subclass that pins emitter to the client and maps each event name to its Structure-typed arguments:
import { Listener, type Awaitable } from '@wolfstar/http-framework';
import type { Message, Guild, User } from './structures/index.js';
export interface GatewayEventMap {
messageCreate: [message: Message];
guildCreate: [guild: Guild];
userUpdate: [oldUser: User | null, newUser: User];
// ...one entry per dispatch this package builds a Structure + event for
}
export abstract class EventGatewayListener<Event extends keyof GatewayEventMap = keyof GatewayEventMap> extends Listener<
Listener.Options & { event: Event }
> {
public constructor(context: Listener.LoaderContext, options: { event: Event; once?: boolean }) {
super(context, { ...options, emitter: 'client' });
}
public abstract override run(...args: GatewayEventMap[Event]): Awaitable<unknown>;
}
A consumer then gets exactly the same file-based, auto-discovered authoring experience as every other framework listener — client.load() picks these up for free, no separate registration call needed beyond enabling GatewayClient:
// listeners/log-messages.ts
import { EventGatewayListener } from '@wolfstar/plugin-gateway';
export class LogMessagesListener extends EventGatewayListener<'messageCreate'> {
public constructor(context: EventGatewayListener.LoaderContext) {
super(context, { event: 'messageCreate' });
}
public override run(message: Message) {
console.log(`${message.author.username}: ${message.content}`);
}
}
RegisterAsGatewayListener: decorator-based registration
@wolfstar/plugin-subcommands-advanced (already in this repo) sets the precedent for this ecosystem: it ships RegisterAsSubcommand/RegisterAsSubcommandGroup, class decorators that let a piece skip writing a constructor entirely — the same trick @sapphire/decorators' ApplyOptions uses upstream in the wider Sapphire ecosystem (a ClassDecorator that wraps the target class's constructor to inject the given options into its super() call). @wolfstar/plugin-gateway should follow the same in-repo convention rather than pull in @sapphire/decorators as a new dependency — EventGatewayListener already needs event to be strongly typed against GatewayEventMap, which a generic ApplyOptions<T> can't give us without an explicit type argument at every call site anyway:
export function RegisterAsGatewayListener<Event extends keyof GatewayEventMap>(
event: Event,
options?: { once?: boolean },
): ClassDecorator {
return (target) =>
class extends (target as typeof EventGatewayListener) {
public constructor(context: Listener.LoaderContext) {
super(context, { event, ...options });
}
} as unknown as typeof target;
}
// listeners/log-messages.ts
import { EventGatewayListener, RegisterAsGatewayListener } from '@wolfstar/plugin-gateway';
import type { Message } from '@wolfstar/plugin-gateway';
@RegisterAsGatewayListener('messageCreate')
export class LogMessagesListener extends EventGatewayListener<'messageCreate'> {
public override run(message: Message) {
console.log(`${message.author.username}: ${message.content}`);
}
}
Both forms stay supported — the decorator is sugar over the constructor-based form above, not a replacement for it, exactly like ApplyOptions/RegisterAsSubcommand are sugar over calling super() by hand in their own ecosystems.
Problems
- You opt in at construction time, not at runtime. Because
GatewayClient extends Client rather than composing one, an existing bot already instantiated as new Client() can't gain Gateway support later without switching its constructor call to new GatewayClient(). A composition-based design (a GatewayPlugin bridged in via Client.use()) would avoid that, at the cost of the Managers needing to reach into an external Client instance instead of this. This RFC picks subclassing for the tighter integration and the more direct this.cache/this.users access patterns shown above — but it's a real trade-off, not a free lunch, and is the opposite of what discord.js#10983 chose upstream (it composes a protected core: CoreClient rather than extending). Given @wolfstar/http-framework only has one Client concept to begin with (unlike discord.js's several historical Client-shaped things), extension seems like the better fit here, but this deserves explicit sign-off before implementation.
- Table-driven dispatch → Structure construction doesn't scale as hand-written code. The
switch sketched above needs to become a generated or declaratively-defined mapping from GatewayDispatchEvents to { entityCache, createStructure, eventName } rather than one case per event written by hand — and that same table is what GatewayEventMap (for EventGatewayListener) needs to stay in sync with.
emitter: 'client' resolution. Listener.Options.emitter can be a container-key string resolved against @sapphire/pieces's container. We need to confirm the base Client already registers itself under that 'client' key on construction (so EventGatewayListener "just works" for any GatewayClient instance without extra wiring), rather than assuming it and finding out otherwise during implementation.
RegisterAsGatewayListener's exact mechanism needs to match RegisterAsSubcommand's, not just rhyme with it. The sketch above (subclassing and overriding the constructor) is the standard way to implement this kind of decorator without a dependency on @sapphire/decorators, but @wolfstar/plugin-subcommands-advanced already solved this exact problem once in this repo — RegisterAsGatewayListener should be implemented the same way RegisterAsSubcommand is, not just aim for the same developer experience, so the two don't drift into two different decorator idioms for the same framework.
- Structure coverage.
@discordjs/structures is itself still pre-1.0 and only covers a subset of entities upstream. We need to decide whether @wolfstar/plugin-gateway only ships Structures for what it actually needs (User, Guild, Channel, Message, …) and falls back to raw API types for anything else, at least for v1.
- Single source of truth across interactions and Gateway. Since
GatewayClient extends Client, an interaction payload's member/user fields and a Gateway-cached User need to resolve to consistent data. Do interaction handlers get upgraded to also read through client.users, or do they keep working against the raw interaction payload as today? Needs a decision so we don't end up with two divergent "current user state" views on the same object.
- Naming.
GatewayClient vs. something closer to the upstream PR's naming (ClientGateway, or nesting it as Client.Gateway) is a bikeshed worth having explicitly rather than silently picking one.
- Interaction double-delivery. A bot that keeps its HTTP interactions endpoint and enables Gateway intents will, for some interaction types, also receive
INTERACTION_CREATE over the Gateway. GatewayClient likely needs to drop INTERACTION_CREATE dispatches unconditionally rather than attempt de-duplication, but this needs confirming against actual Gateway behavior for interactions-endpoint apps.
- Process model mismatch.
@wolfstar/http-framework is designed to be comfortable in short-lived/serverless HTTP handlers; a Gateway connection is inherently long-lived and stateful. GatewayClient effectively requires a persistent-process deployment mode, a meaningfully different operational story from the rest of the framework. Does @wolfstar/plugin-gateway belong in this repository, or should it (and its cache companion) live in the stars-components monorepo next to @wolfstar/http-framework itself? Worth resolving before implementation starts.
- Multi-process sharding.
@discordjs/ws's WebSocketManager supports pluggable IShardingStrategy implementations for multi-process setups, but defaults to single-process. Proposing v1 only supports single-process sharding (matches most self-hosted wolfstar deployments), leaving multi-process as a follow-up.
Alternatives considered
As noted in stars-components#51, the alternatives are depending on Carbon directly, or maintaining a fully custom in-house Gateway client. Both were rejected for the same reason this RFC leans on @discordjs/ws and @discordjs/structures: Gateway session/resume/rate-limit handling and Discord entity modeling are fiddly to get right, already exist, and aren't where @wolfstar/http-framework should be spending its maintenance budget. A composition-based design (a plugin bridged onto an existing Client via lifecycle hooks, with a fully standalone Gateway-only client alongside it) was drafted for an earlier revision of this RFC and rejected in favor of the single GatewayClient extends Client shape above — see the "Problems" section for the trade-off.
Footnotes
Introduction
@wolfstar/http-frameworkcurrently speaks to Discord exclusively through the interactions HTTP endpoint: a bot receivesINTERACTION_CREATEpayloads on a webhook and responds synchronously. That's a great fit for stateless, serverless-friendly deployments, but it means a bot built on the framework has no way to react to anything that only exists on the Gateway —MESSAGE_CREATE, presence/voice state updates, guild member events, raw moderation-relevant events, etc. Today the only paths forward are to bring in a second, unrelated framework (e.g. Carbon) just for the Gateway half, or to hand-roll a WebSocket client per project.This was requested directly in wolfstar-project/stars-components#51, which asks for a package that:
This RFC proposes
@wolfstar/plugin-gateway, exporting aGatewayClientthatextends@wolfstar/http-framework'sClient— not a separate object bridged in through a plugin hook. A bot built onGatewayClientkeeps every interactions capability of the baseClient(load(),listen(), the command/interaction-handler stores,Client.use()) for free, and gains Gateway connectivity, caching, and a set of ready-made Structures on top, in the spirit of the "mini discord.js"Clientbeing prototyped upstream in discordjs/discord.js#10983. Crucially, and unlike raw Gateway dispatch handling,GatewayClientevents hand consumers Structure instances (Message,User,Guild, …) rather than raw snake_case API JSON — the same direction discord.js#10983 and the@discordjs/structurespackage are taking upstream.The caching half is intentionally split out into its own, more focused RFC — see the companion issue linked in the footnotes.
GatewayClient's Managers only depend on theCacheinterface defined there, not on any specific store implementation.Design principles
GatewayClient extends Client. A bot that wants Gateway support constructs aGatewayClientinstead of aClientand otherwise writes exactly the code it already knows — command stores, interaction handlers,client.load(),client.listen()all keep working unmodified.client.on('messageCreate', (message: Message) => …)gets aMessagewith typed getters (message.author,message.content, …), notclient.on(GatewayDispatchEvents.MessageCreate, (data: APIMessage) => …). Consumers shouldn't need to know the raw API shape to write a handler.@wolfstar/http-frameworkalready has a file-basedListener/ListenerStorepiece system (loaded viaclient.load()) for everything else the framework emits. BecauseGatewayClient extends Client, that same piece loader should work for Gateway-sourced events without a second, parallel mechanism —@wolfstar/plugin-gatewayshipsEventGatewayListener, a thinListenersubclass, rather than inventing a separate listener API.@discordjs/ws'sWebSocketManageralready solves reconnects, resumes, rate-limited identify, and shard orchestration correctly.GatewayClientwraps it rather than re-implementing it.GatewayClientaccepts any object satisfying theCacheinterface from the companion RFC. No cache, an in-memory cache, and a Redis-backed cache are all equally valid — Managers build Structures on top of whateverCachereturns.Structures
Rather than inventing a parallel structure system,
@wolfstar/plugin-gatewaydepends directly on@discordjs/structuresfor the baseStructure<DataType>class: it already gives us data storage behind akDatasymbol, in-place patching via[kPatch], cheap[kClone], and aDataTemplatemechanism to strip fields we never want to keep resident. wolfstar-specific concrete structures live in this package and extend it:Managers
Each entity type gets a
CachedManager, mirroring thecreateStructurepattern from discord.js#10983'sChannelManager:GatewayClientOn dispatch,
GatewayClientfeeds the raw payload to the relevantEntityCachefirst, then asks the corresponding Manager to build (or patch) the Structure before emitting it — reusing the sameAsyncEventEmitterthe baseClientalready uses for interaction events:EventGatewayListener: file-based Gateway event pieces@wolfstar/http-frameworkalready loadsListenerpieces from alisteners/directory viaclient.load(), targeting anemitter(resolved either from a concreteEmitteror a container key, e.g.'client') and aneventname, withrun(...)receiving whatever that event emits. SinceGatewayClient extends Client, Gateway-derived events emitted withthis.emit('messageCreate', …)land on the exact same emitter regular framework listeners already target — so@wolfstar/plugin-gatewaydoesn't need a second piece store, just a strongly-typedListenersubclass that pinsemitterto the client and maps each event name to itsStructure-typed arguments:A consumer then gets exactly the same file-based, auto-discovered authoring experience as every other framework listener —
client.load()picks these up for free, no separate registration call needed beyond enablingGatewayClient:RegisterAsGatewayListener: decorator-based registration@wolfstar/plugin-subcommands-advanced(already in this repo) sets the precedent for this ecosystem: it shipsRegisterAsSubcommand/RegisterAsSubcommandGroup, class decorators that let a piece skip writing a constructor entirely — the same trick@sapphire/decorators'ApplyOptionsuses upstream in the wider Sapphire ecosystem (aClassDecoratorthat wraps the target class's constructor to inject the given options into itssuper()call).@wolfstar/plugin-gatewayshould follow the same in-repo convention rather than pull in@sapphire/decoratorsas a new dependency —EventGatewayListeneralready needseventto be strongly typed againstGatewayEventMap, which a genericApplyOptions<T>can't give us without an explicit type argument at every call site anyway:Both forms stay supported — the decorator is sugar over the constructor-based form above, not a replacement for it, exactly like
ApplyOptions/RegisterAsSubcommandare sugar over callingsuper()by hand in their own ecosystems.Problems
GatewayClient extends Clientrather than composing one, an existing bot already instantiated asnew Client()can't gain Gateway support later without switching its constructor call tonew GatewayClient(). A composition-based design (aGatewayPluginbridged in viaClient.use()) would avoid that, at the cost of the Managers needing to reach into an externalClientinstance instead ofthis. This RFC picks subclassing for the tighter integration and the more directthis.cache/this.usersaccess patterns shown above — but it's a real trade-off, not a free lunch, and is the opposite of what discord.js#10983 chose upstream (it composes aprotected core: CoreClientrather than extending). Given@wolfstar/http-frameworkonly has oneClientconcept to begin with (unlike discord.js's several historical Client-shaped things), extension seems like the better fit here, but this deserves explicit sign-off before implementation.switchsketched above needs to become a generated or declaratively-defined mapping fromGatewayDispatchEventsto{ entityCache, createStructure, eventName }rather than one case per event written by hand — and that same table is whatGatewayEventMap(forEventGatewayListener) needs to stay in sync with.emitter: 'client'resolution.Listener.Options.emittercan be a container-key string resolved against@sapphire/pieces'scontainer. We need to confirm the baseClientalready registers itself under that'client'key on construction (soEventGatewayListener"just works" for anyGatewayClientinstance without extra wiring), rather than assuming it and finding out otherwise during implementation.RegisterAsGatewayListener's exact mechanism needs to matchRegisterAsSubcommand's, not just rhyme with it. The sketch above (subclassing and overriding the constructor) is the standard way to implement this kind of decorator without a dependency on@sapphire/decorators, but@wolfstar/plugin-subcommands-advancedalready solved this exact problem once in this repo —RegisterAsGatewayListenershould be implemented the same wayRegisterAsSubcommandis, not just aim for the same developer experience, so the two don't drift into two different decorator idioms for the same framework.@discordjs/structuresis itself still pre-1.0 and only covers a subset of entities upstream. We need to decide whether@wolfstar/plugin-gatewayonly ships Structures for what it actually needs (User,Guild,Channel,Message, …) and falls back to raw API types for anything else, at least for v1.GatewayClient extends Client, an interaction payload'smember/userfields and a Gateway-cachedUserneed to resolve to consistent data. Do interaction handlers get upgraded to also read throughclient.users, or do they keep working against the raw interaction payload as today? Needs a decision so we don't end up with two divergent "current user state" views on the same object.GatewayClientvs. something closer to the upstream PR's naming (ClientGateway, or nesting it asClient.Gateway) is a bikeshed worth having explicitly rather than silently picking one.INTERACTION_CREATEover the Gateway.GatewayClientlikely needs to dropINTERACTION_CREATEdispatches unconditionally rather than attempt de-duplication, but this needs confirming against actual Gateway behavior for interactions-endpoint apps.@wolfstar/http-frameworkis designed to be comfortable in short-lived/serverless HTTP handlers; a Gateway connection is inherently long-lived and stateful.GatewayClienteffectively requires a persistent-process deployment mode, a meaningfully different operational story from the rest of the framework. Does@wolfstar/plugin-gatewaybelong in this repository, or should it (and its cache companion) live in the stars-components monorepo next to@wolfstar/http-frameworkitself? Worth resolving before implementation starts.@discordjs/ws'sWebSocketManagersupports pluggableIShardingStrategyimplementations for multi-process setups, but defaults to single-process. Proposing v1 only supports single-process sharding (matches most self-hosted wolfstar deployments), leaving multi-process as a follow-up.Alternatives considered
As noted in stars-components#51, the alternatives are depending on Carbon directly, or maintaining a fully custom in-house Gateway client. Both were rejected for the same reason this RFC leans on
@discordjs/wsand@discordjs/structures: Gateway session/resume/rate-limit handling and Discord entity modeling are fiddly to get right, already exist, and aren't where@wolfstar/http-frameworkshould be spending its maintenance budget. A composition-based design (a plugin bridged onto an existingClientvia lifecycle hooks, with a fully standalone Gateway-only client alongside it) was drafted for an earlier revision of this RFC and rejected in favor of the singleGatewayClient extends Clientshape above — see the "Problems" section for the trade-off.Footnotes
@discordjs/nextClient,Structure/Manager/createStructurepattern: discordjs/discord.js#10983@discordjs/structures@discordjs/ws@wolfstar/http-framework's existingClient/plugin system this RFC builds on:Client,Plugin,Client.use()inpackages/http-framework/src/libRegisterAsGatewayListener:@wolfstar/plugin-subcommands-advanced'sRegisterAsSubcommand/RegisterAsSubcommandGroupdecorators@sapphire/decorators'ApplyOptions@wolfstar/plugin-gateway(Cache,EntityCache,CachedManager) #55