Skip to content

Commit 8d8924d

Browse files
vcarlclaude
andcommitted
Implement listener registry pattern for HMR support
Separates "login once" from "bind listeners" logic to enable proper hot module reloading. Previously, the gateway flag prevented duplicate logins but also prevented listener code from being updated on HMR. Key changes: - Add listenerRegistry.ts with registerListener/removeAllListeners - Add HMR state helpers to client.server.ts (isClientReady, clearScheduledTasks) - Modify schedule.ts to return timer handles for cleanup - Refactor gateway.ts to split login from listener binding - Update all listener modules to use registerListener On HMR, old listeners are removed before rebinding new ones with fresh closures. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 425a647 commit 8d8924d

13 files changed

+445
-209
lines changed

app/discord/activityTracker.ts

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,15 @@ import { getMessageStats } from "#~/helpers/discord.js";
55
import { threadStats } from "#~/helpers/metrics";
66
import { log, trackPerformance } from "#~/helpers/observability";
77

8+
import { registerListener } from "./listenerRegistry";
89
import { getOrFetchChannel } from "./utils";
910

1011
export async function startActivityTracking(client: Client) {
1112
log("info", "ActivityTracker", "Starting activity tracking", {
1213
guildCount: client.guilds.cache.size,
1314
});
1415

15-
client.on(Events.MessageCreate, async (msg) => {
16+
registerListener(client, Events.MessageCreate, async (msg) => {
1617
if (msg.author.system) return;
1718
if (msg.channel.type !== ChannelType.GuildText || msg.author.bot) {
1819
return;
@@ -69,7 +70,7 @@ export async function startActivityTracking(client: Client) {
6970
threadStats.messageTracked(msg);
7071
});
7172

72-
client.on(Events.MessageUpdate, async (msg) => {
73+
registerListener(client, Events.MessageUpdate, async (msg) => {
7374
await trackPerformance(
7475
"processMessageUpdate",
7576
async () => {
@@ -93,7 +94,7 @@ export async function startActivityTracking(client: Client) {
9394
);
9495
});
9596

96-
client.on(Events.MessageDelete, async (msg) => {
97+
registerListener(client, Events.MessageDelete, async (msg) => {
9798
if (msg.system || msg.author?.bot) {
9899
return;
99100
}
@@ -113,7 +114,7 @@ export async function startActivityTracking(client: Client) {
113114
);
114115
});
115116

116-
client.on(Events.MessageReactionAdd, async (msg) => {
117+
registerListener(client, Events.MessageReactionAdd, async (msg) => {
117118
await trackPerformance(
118119
"processReactionAdd",
119120
async () => {
@@ -131,7 +132,7 @@ export async function startActivityTracking(client: Client) {
131132
);
132133
});
133134

134-
client.on(Events.MessageReactionRemove, async (msg) => {
135+
registerListener(client, Events.MessageReactionRemove, async (msg) => {
135136
await trackPerformance(
136137
"processReactionRemove",
137138
async () => {

app/discord/automod.ts

Lines changed: 18 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {
1616
} from "#~/models/reportedMessages.server";
1717

1818
import { client } from "./client.server";
19+
import { registerListener } from "./listenerRegistry";
1920

2021
const AUTO_SPAM_THRESHOLD = 3;
2122

@@ -69,21 +70,25 @@ async function handleAutomodAction(execution: AutoModerationActionExecution) {
6970

7071
export default async (bot: Client) => {
7172
// Handle Discord's built-in automod actions
72-
bot.on(Events.AutoModerationActionExecution, async (execution) => {
73-
try {
74-
log("info", "automod.logging", "handling automod event", { execution });
75-
await handleAutomodAction(execution);
76-
} catch (e) {
77-
log("error", "Automod", "Failed to handle automod action", {
78-
error: e,
79-
userId: execution.userId,
80-
guildId: execution.guild.id,
81-
});
82-
}
83-
});
73+
registerListener(
74+
bot,
75+
Events.AutoModerationActionExecution,
76+
async (execution) => {
77+
try {
78+
log("info", "automod.logging", "handling automod event", { execution });
79+
await handleAutomodAction(execution);
80+
} catch (e) {
81+
log("error", "Automod", "Failed to handle automod action", {
82+
error: e,
83+
userId: execution.userId,
84+
guildId: execution.guild.id,
85+
});
86+
}
87+
},
88+
);
8489

8590
// Handle our custom spam detection
86-
bot.on(Events.MessageCreate, async (msg) => {
91+
registerListener(bot, Events.MessageCreate, async (msg) => {
8792
if (msg.author.id === bot.user?.id || !msg.guild) return;
8893

8994
const [member, message] = await Promise.all([

app/discord/client.server.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,39 @@ import { ReacordDiscordJs } from "reacord";
44
import { discordToken } from "#~/helpers/env.server";
55
import { log, trackPerformance } from "#~/helpers/observability";
66

7+
// HMR state helpers - persisted across module reloads
8+
declare global {
9+
var __discordClientReady: boolean | undefined;
10+
var __discordScheduledTasks: ReturnType<typeof setTimeout>[] | undefined;
11+
}
12+
13+
export function isClientReady(): boolean {
14+
return globalThis.__discordClientReady ?? false;
15+
}
16+
17+
export function setClientReady(): void {
18+
globalThis.__discordClientReady = true;
19+
}
20+
21+
export function registerScheduledTask(
22+
timer: ReturnType<typeof setTimeout>,
23+
): void {
24+
globalThis.__discordScheduledTasks ??= [];
25+
globalThis.__discordScheduledTasks.push(timer);
26+
}
27+
28+
export function clearScheduledTasks(): void {
29+
const tasks = globalThis.__discordScheduledTasks ?? [];
30+
if (tasks.length > 0) {
31+
log("info", "Client", `Clearing ${tasks.length} scheduled tasks for HMR`);
32+
}
33+
for (const timer of tasks) {
34+
clearTimeout(timer);
35+
clearInterval(timer);
36+
}
37+
globalThis.__discordScheduledTasks = [];
38+
}
39+
740
export const client = new Client({
841
intents: [
942
GatewayIntentBits.Guilds,

app/discord/deployCommands.server.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
} from "discord.js";
1111

1212
import { ssrDiscordSdk } from "#~/discord/api";
13+
import { registerListener } from "#~/discord/listenerRegistry";
1314
import {
1415
isMessageComponentCommand,
1516
isMessageContextCommand,
@@ -41,7 +42,7 @@ export const deployCommands = async (client: Client) => {
4142
? deployProdCommands(client, localCommands)
4243
: deployTestCommands(client, localCommands));
4344

44-
client.on(Events.InteractionCreate, (interaction) => {
45+
registerListener(client, Events.InteractionCreate, (interaction) => {
4546
log("info", "deployCommands", "Handling interaction", {
4647
type: interaction.type,
4748
id: interaction.id,

app/discord/escalationResolver.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
} from "discord.js";
1010

1111
import { tallyVotes } from "#~/commands/escalate/voting.js";
12+
import { registerScheduledTask } from "#~/discord/client.server";
1213
import {
1314
humanReadableResolutions,
1415
resolutions,
@@ -298,7 +299,15 @@ export function startEscalationResolver(client: Client): void {
298299
{},
299300
);
300301

301-
scheduleTask("EscalationResolver", ONE_MINUTE * 15, () => {
302+
const handle = scheduleTask("EscalationResolver", ONE_MINUTE * 15, () => {
302303
void checkPendingEscalations(client);
303304
});
305+
306+
// Register timers for HMR cleanup
307+
if (handle) {
308+
registerScheduledTask(handle.initialTimer);
309+
// The interval timer is created inside the setTimeout, so we need to
310+
// register it when it's available. Since clearScheduledTasks clears both
311+
// timeouts and intervals, the initial timer registration will handle cleanup.
312+
}
304313
}

0 commit comments

Comments
 (0)