@seedcord/http 0.1.0-next.0 → 0.1.0-next.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.mjs CHANGED
@@ -1,1014 +1,424 @@
1
- import { SeedcordErrorCode } from "@seedcord/errors";
1
+ import { n as __reExport, t as __exportAll } from "./rolldown-runtime-BUUfjzRC.mjs";
2
+ import { a as ModalHandler, c as ComponentHandler, d as createSeedcord, f as buildEngine, g as RepliableHandler, h as AutocompleteHandler, i as WebhookLog, l as ContextMenuHandler, m as InteractionHandler, n as Gated, o as SelectMenuHandler, p as buildRouteMaps, r as Subscriber, s as ButtonHandler, t as version, u as SlashHandler } from "./edge.index-BBRnxxkS.mjs";
2
3
  import { SeedcordError, validateDiscordToken } from "@seedcord/errors/internal";
3
- import { Logger, paint } from "@seedcord/logger";
4
- import { ApplicationCommandOptionType, ApplicationCommandType, ComponentType, InteractionResponseType, InteractionType, MessageFlags, RESTJSONErrorCodes, Routes } from "discord-api-types/v10";
5
- import { Converters, Envapter } from "envapt";
6
- import { DiscordAPIError, REST } from "@discordjs/rest";
7
- import { BaseHandler, DispatchContext, Fault, Notice, RepliableHandler as RepliableHandler$1, Silence } from "@seedcord/core";
8
- import { BaseReplySender, GatedMetadataKey, decodeComponentRoute, deferFlags, prefixOf, runHandlerGates, sendFlags, slowGateMonitor } from "@seedcord/core/internal";
4
+ import { Envapter } from "envapt";
5
+ import { REST } from "@discordjs/rest";
6
+ import { Bus } from "@seedcord/core";
7
+ import { HmrManager, InteractionMetadataKey, InteractionRoutes, interactionRoutesOf, setBotColor } from "@seedcord/core/internal";
8
+ import { Logger, LoggerChannelRegistry, paint } from "@seedcord/logger";
9
9
  import { MemoryRateLimiter } from "@seedcord/rate-limiter";
10
- import { buildSlashRoute } from "@seedcord/utils/internal";
10
+ import { Routes } from "discord-api-types/v10";
11
+ import { SeedcordErrorCode } from "@seedcord/errors";
12
+ import { once } from "node:events";
13
+ import { createServer } from "node:http";
14
+ import { CoordinatedShutdown, CoordinatedStartup, HealthCheck, Pluggable, ShutdownPhase, StartupPhase, SubscriberLoader } from "@seedcord/core/node/internal";
15
+ import { installNodeDefaults } from "@seedcord/logger/node";
16
+ import { SeedcordBrand } from "@seedcord/types/internal";
17
+ import { HmrModuleHandler } from "@seedcord/core/hmr";
18
+ import { formatFilePath } from "@seedcord/utils";
19
+ import { traverseDirectory } from "@seedcord/utils/node";
20
+ import { Plugin as Plugin$1 } from "@seedcord/core/plugin";
11
21
 
12
22
  export * from "@seedcord/core"
13
23
 
14
- //#region src/reply/ReplySender.ts
15
- function wireAllowedMentions(mentions) {
16
- const { repliedUser, ...wire } = mentions;
17
- return {
18
- ...wire,
19
- ...repliedUser !== void 0 && { replied_user: repliedUser }
20
- };
21
- }
22
- function wireAttachments(files) {
23
- if (!files?.some((file) => file.description)) return null;
24
- return files.map((file, index) => ({
25
- id: index,
26
- ...file.name && { filename: file.name },
27
- ...file.description && { description: file.description }
28
- }));
29
- }
30
- function callbackData(reply, flags) {
31
- const attachments = wireAttachments(reply.files);
32
- return {
33
- flags,
34
- components: reply.components,
35
- ...reply.allowedMentions && { allowed_mentions: wireAllowedMentions(reply.allowedMentions) },
36
- ...attachments && { attachments }
37
- };
38
- }
39
- /**
40
- * Writes interaction responses over the Discord REST callback and webhook endpoints. Construction is
41
- * internal to the repliable handler bases and the dispatcher.
42
- */
43
- var ReplySender = class extends BaseReplySender {
44
- ref;
45
- rest;
46
- constructor(ref, rest, routeId) {
47
- super(routeId);
48
- this.ref = ref;
49
- this.rest = rest;
50
- }
51
- async writeReply(response, opts) {
52
- const reply = this.serialize(response);
53
- const result = await this.callback(InteractionResponseType.ChannelMessageWithSource, callbackData(reply, sendFlags(opts)), reply.files);
54
- return this.createdMessage(result);
55
- }
56
- async writeDefer(opts) {
57
- await this.callback(InteractionResponseType.DeferredChannelMessageWithSource, { flags: deferFlags(opts) });
58
- }
59
- async writeDeferUpdate() {
60
- await this.callback(InteractionResponseType.DeferredMessageUpdate);
61
- }
62
- async writeUpdate(response) {
63
- const reply = this.serialize(response);
64
- const result = await this.callback(InteractionResponseType.UpdateMessage, callbackData(reply, MessageFlags.IsComponentsV2), reply.files);
65
- return this.createdMessage(result);
66
- }
67
- async writeFollowUp(response, opts) {
68
- const reply = this.serialize(response);
69
- return await this.rest.post(Routes.webhook(this.ref.application_id, this.ref.token), {
70
- body: callbackData(reply, sendFlags(opts)),
71
- query: new URLSearchParams({ wait: "true" }),
72
- ...reply.files && { files: this.rawFiles(reply.files) }
73
- });
74
- }
75
- writeEditOriginal(response) {
76
- return this.editMessage(Routes.webhookMessage(this.ref.application_id, this.ref.token), this.serialize(response));
77
- }
78
- writeEditTarget(targetId, response) {
79
- const route = Routes.webhookMessage(this.ref.application_id, this.ref.token, targetId);
80
- return this.editMessage(route, this.serialize(response));
81
- }
82
- async writeDeleteOriginal() {
83
- await this.rest.delete(Routes.webhookMessage(this.ref.application_id, this.ref.token));
84
- }
85
- async writeDeleteTarget(targetId) {
86
- await this.rest.delete(Routes.webhookMessage(this.ref.application_id, this.ref.token, targetId));
87
- }
88
- async writeModal(data) {
89
- await this.callback(InteractionResponseType.Modal, data);
90
- }
91
- async callback(type, data, files) {
92
- const withResponse = type === InteractionResponseType.ChannelMessageWithSource || type === InteractionResponseType.UpdateMessage;
93
- return await this.rest.post(Routes.interactionCallback(this.ref.id, this.ref.token), {
94
- body: {
95
- type,
96
- ...data !== void 0 && { data }
97
- },
98
- ...withResponse && { query: new URLSearchParams({ with_response: "true" }) },
99
- ...files && { files: this.rawFiles(files) }
100
- });
101
- }
102
- async editMessage(route, reply) {
103
- return await this.rest.patch(route, {
104
- body: callbackData(reply, MessageFlags.IsComponentsV2),
105
- ...reply.files && { files: this.rawFiles(reply.files) }
106
- });
107
- }
108
- rawFiles(files) {
109
- return files.map((file, index) => ({
110
- name: file.name ?? `file-${index}`,
111
- data: file.attachment
112
- }));
113
- }
114
- createdMessage(result) {
115
- return result.resource?.message;
116
- }
24
+ //#region src/manifest/RouteManifest.ts
25
+ /** @internal */
26
+ const EMPTY_MANIFEST = {
27
+ commandRoutes: [],
28
+ componentRoutes: [],
29
+ autocompleteRoutes: [],
30
+ subscriberRoutes: [],
31
+ middlewareRoutes: []
117
32
  };
118
33
 
119
34
  //#endregion
120
- //#region src/handlers/RepliableHandler.ts
35
+ //#region src/node/InteractionDispatcher.ts
121
36
  /**
122
- * Shared base the repliable HTTP interaction handlers extend.
37
+ * Discovers interaction handlers from the filesystem into the engine's live route maps.
123
38
  *
124
- * Not a public entry point. Extend {@link SlashHandler}, {@link ButtonHandler}, {@link ModalHandler},
125
- * {@link SelectMenuHandler}, or {@link ContextMenuHandler} instead. This class binds the core reply base
126
- * to the http sender.
127
- *
128
- * @typeParam Event - The repliable interaction type this handler processes
39
+ * The node host runs it at startup and dev HMR swaps map entries in place. The engine's `resolve`
40
+ * reads `maps` per request. The edge path builds its maps from the generated manifest.
129
41
  */
130
- var RepliableHandler = class extends RepliableHandler$1 {
131
- buildSender(event, core, routeId) {
132
- return new ReplySender({
133
- application_id: event.application_id,
134
- id: event.id,
135
- token: event.token
136
- }, core.rest, routeId);
137
- }
138
- };
139
-
140
- //#endregion
141
- //#region src/gates/context.ts
142
- function interactionGateContext(payload, core, routeId) {
143
- const member = payload.member ?? null;
144
- const resolved = member?.user ?? payload.user;
145
- return {
146
- kind: "interaction",
147
- core,
148
- interaction: payload,
149
- user: resolved,
150
- member,
151
- userId: resolved?.id ?? null,
152
- guildId: payload.guild_id ?? null,
153
- channelId: payload.channel?.id ?? null,
154
- memberRoleIds: member?.roles ?? [],
155
- memberPermissions: member ? BigInt(member.permissions) : null,
156
- appPermissions: BigInt(payload.app_permissions),
157
- routeId
158
- };
159
- }
160
-
161
- //#endregion
162
- //#region src/dispatch/dispatchInteraction.ts
163
- let dispatchLogger;
164
- function logger() {
165
- dispatchLogger ??= new Logger("Dispatcher");
166
- return dispatchLogger;
167
- }
168
- function createCore(config, token) {
169
- return {
170
- config,
171
- rateLimiter: config.store ?? new MemoryRateLimiter(),
172
- rest: new REST().setToken(token),
173
- version: "0.1.0-next.0",
174
- username: void 0,
175
- augmentTarget: "@seedcord/http"
176
- };
177
- }
178
- function isHandlerCtor(value) {
179
- return typeof value === "function" && value.prototype instanceof BaseHandler;
180
- }
181
- function handlerCtorOf(moduleExports) {
182
- if (typeof moduleExports !== "object" || moduleExports === null) return null;
183
- for (const value of Object.values(moduleExports)) if (isHandlerCtor(value)) return value;
184
- return null;
185
- }
186
- const HARMLESS_API_CODES = new Set([
187
- RESTJSONErrorCodes.UnknownInteraction,
188
- RESTJSONErrorCodes.InteractionHasAlreadyBeenAcknowledged,
189
- RESTJSONErrorCodes.UnknownMessage
190
- ]);
191
- async function sendGuarded(routeId, send) {
192
- try {
193
- await send();
194
- } catch (error) {
195
- if (error instanceof DiscordAPIError && HARMLESS_API_CODES.has(error.code)) {
196
- logger().debug(`boundary send hit harmless code ${paint.amber(String(error.code))}`);
197
- return;
198
- }
199
- logger().error(`boundary send failed for route ${paint.sky.bold(routeId)}`, error);
200
- }
201
- }
202
- async function respondEmptyChoices(scope) {
203
- await scope.core.rest.post(Routes.interactionCallback(scope.payload.id, scope.payload.token), { body: {
204
- type: InteractionResponseType.ApplicationCommandAutocompleteResult,
205
- data: { choices: [] }
206
- } });
207
- }
208
- function renderContext(core, uuid) {
209
- const developerUsername = core.config.notifications?.developerUsername;
210
- return developerUsername === void 0 ? { uuid } : {
211
- uuid,
212
- developerUsername
213
- };
214
- }
215
- async function handleNotice(notice, uuid, scope) {
216
- if (notice.report) logger().error(`${notice.name}: ${paint.mute(uuid)}`, notice);
217
- const { sender } = scope;
218
- if (!sender) {
219
- await sendGuarded(scope.routeId, () => respondEmptyChoices(scope));
220
- return;
221
- }
222
- const response = notice.render(renderContext(scope.core, uuid));
223
- await sendGuarded(scope.routeId, () => sender.send(response, { ephemeral: notice.ephemeral }));
224
- }
225
- async function handleRawFault(error, uuid, scope) {
226
- const { core, sender } = scope;
227
- if (core.config.errors?.errorStack ?? false) logger().error(paint.mute(uuid), error);
228
- else logger().error(`${paint.mute(uuid)} | ${error.message}`);
229
- if (!sender) {
230
- await sendGuarded(scope.routeId, () => respondEmptyChoices(scope));
231
- return;
232
- }
233
- const override = core.config.errors?.defaultError;
234
- const response = (override ? new override(uuid) : new Fault()).render(renderContext(core, uuid));
235
- await sendGuarded(scope.routeId, () => sender.send(response, { ephemeral: true }));
236
- }
237
- async function handleFault(caught, scope) {
238
- if (caught instanceof Silence) {
239
- if (caught.reason !== void 0) logger().debug(`Silence: ${caught.reason}`);
240
- return;
241
- }
242
- const uuid = crypto.randomUUID();
243
- if (caught instanceof Notice) {
244
- await handleNotice(caught, uuid, scope);
245
- return;
246
- }
247
- const error = Error.isError(caught) ? caught : new Error(String(caught));
248
- const ignore = new Set(scope.core.config.errors?.ignoreApiCodes ?? []);
249
- if (error instanceof DiscordAPIError && ignore.has(error.code)) {
250
- logger().debug(`swallowed api code ${paint.amber(String(error.code))}`);
251
- return;
252
- }
253
- await handleRawFault(error, uuid, scope);
254
- }
255
- function freshScope(match, payload, core) {
256
- const ref = {
257
- application_id: payload.application_id,
258
- id: payload.id,
259
- token: payload.token
260
- };
261
- const routeId = match.routeId ?? "unhandled";
262
- return {
263
- core,
264
- payload,
265
- routeId,
266
- sender: match.kind === "autocomplete" ? null : new ReplySender(ref, core.rest, routeId)
267
- };
268
- }
269
- /**
270
- * Runs the pre-ack phase for a matched interaction. Loads the route module, constructs the handler, and
271
- * runs its gates, and the sender posts any refusal. Returns the post-ack execute continuation, or null
272
- * when a gate refuses or nothing can run.
273
- */
274
- async function dispatchInteraction(args) {
275
- const { match, payload, core } = args;
276
- let moduleExports;
277
- try {
278
- moduleExports = await match.load();
279
- } catch (caught) {
280
- logger().error(`Route ${paint.sky.bold(match.routeId ?? "unhandled")} failed to load its module.`, caught);
281
- await handleFault(caught, freshScope(match, payload, core));
282
- return null;
283
- }
284
- const ctor = handlerCtorOf(moduleExports);
285
- if (!ctor) {
286
- const routeId = match.routeId ?? "unhandled";
287
- logger().error(`Route ${paint.sky.bold(routeId)} loaded a module with no handler class.`);
288
- await handleFault(/* @__PURE__ */ new Error(`route ${routeId} loaded a module with no handler class`), freshScope(match, payload, core));
289
- return null;
290
- }
291
- const dispatch = new DispatchContext(match.routeId);
292
- let handler;
293
- try {
294
- handler = new ctor(payload, core, dispatch);
295
- } catch (caught) {
296
- await handleFault(caught, freshScope(match, payload, core));
297
- return null;
42
+ var InteractionDispatcher = class {
43
+ handlersDir;
44
+ maps;
45
+ targets;
46
+ /** @internal */
47
+ logger = new Logger("Interactions", { channel: "interactions" });
48
+ isInitialized = false;
49
+ hmrHandler;
50
+ rowOwners = /* @__PURE__ */ new Map();
51
+ loading = false;
52
+ loadedHandlers = [];
53
+ constructor(handlersDir) {
54
+ this.handlersDir = handlersDir;
55
+ this.maps = buildRouteMaps(EMPTY_MANIFEST);
56
+ this.targets = this.buildTargets();
57
+ if (!Envapter.isDevelopment && !Envapter.isTest) return;
58
+ this.hmrHandler = new HmrModuleHandler({
59
+ handlersDir,
60
+ isHandler: this.isHandler.bind(this),
61
+ registerHandler: this.registerHandler.bind(this),
62
+ unregisterHandler: this.unregisterHandler.bind(this),
63
+ getArtifacts: this.getArtifacts.bind(this),
64
+ logger: this.logger
65
+ });
298
66
  }
299
- const scope = {
300
- core,
301
- payload,
302
- routeId: match.routeId ?? handler.constructor.name,
303
- sender: handler instanceof RepliableHandler ? handler.getSender() : null
304
- };
305
- if (!await passedGates(ctor, match, payload, core, scope)) return null;
306
- return async () => {
67
+ /** @internal */
68
+ async init() {
69
+ if (this.isInitialized) return;
70
+ this.isInitialized = true;
71
+ this.loading = true;
72
+ this.loadedHandlers.length = 0;
307
73
  try {
308
- await handler.execute();
309
- } catch (caught) {
310
- await handleFault(caught, scope);
74
+ await traverseDirectory(this.handlersDir, (fullPath, relativePath, imported) => {
75
+ for (const value of Object.values(imported)) {
76
+ if (!this.isHandler(value)) continue;
77
+ this.registerHandler(value, relativePath);
78
+ this.hmrHandler?.trackHandler(fullPath, value);
79
+ }
80
+ });
81
+ } finally {
82
+ this.loading = false;
311
83
  }
312
- };
313
- }
314
- async function passedGates(ctor, match, payload, core, scope) {
315
- if (match.kind === "autocomplete" || payload.type === InteractionType.ApplicationCommandAutocomplete) return true;
316
- const monitor = slowGateMonitor();
317
- try {
318
- await runHandlerGates(ctor, interactionGateContext(payload, core, match.routeId), match.routeId ?? void 0, monitor?.observe);
319
- return true;
320
- } catch (caught) {
321
- await handleFault(caught, scope);
322
- return false;
323
- } finally {
324
- monitor?.report(match.routeId);
325
- }
326
- }
327
-
328
- //#endregion
329
- //#region src/inputs/HttpSlashOptions.ts
330
- var HttpSlashOptions = class {
331
- leaves = /* @__PURE__ */ new Map();
332
- resolved;
333
- constructor(data) {
334
- this.resolved = data.resolved;
335
- let options = data.options ?? [];
336
- const first = options[0];
337
- if (first?.type === ApplicationCommandOptionType.SubcommandGroup) options = first.options;
338
- const second = options[0];
339
- if (second?.type === ApplicationCommandOptionType.Subcommand) options = second.options ?? [];
340
- for (const option of options) if (option.type !== ApplicationCommandOptionType.Subcommand && option.type !== ApplicationCommandOptionType.SubcommandGroup) this.leaves.set(option.name, option);
341
- }
342
- getString(name) {
343
- const leaf = this.leaves.get(name);
344
- return leaf?.type === ApplicationCommandOptionType.String ? leaf.value : null;
345
- }
346
- getInteger(name) {
347
- const leaf = this.leaves.get(name);
348
- return leaf?.type === ApplicationCommandOptionType.Integer ? numeric(leaf.value) : null;
349
- }
350
- getNumber(name) {
351
- const leaf = this.leaves.get(name);
352
- return leaf?.type === ApplicationCommandOptionType.Number ? numeric(leaf.value) : null;
353
- }
354
- getBoolean(name) {
355
- const leaf = this.leaves.get(name);
356
- return leaf?.type === ApplicationCommandOptionType.Boolean ? leaf.value : null;
357
- }
358
- getUser(name) {
359
- const leaf = this.leaves.get(name);
360
- if (leaf?.type !== ApplicationCommandOptionType.User) return null;
361
- return this.resolved?.users?.[leaf.value] ?? null;
362
- }
363
- getMember(name) {
364
- const leaf = this.leaves.get(name);
365
- if (leaf?.type !== ApplicationCommandOptionType.User) return null;
366
- return this.resolved?.members?.[leaf.value] ?? null;
84
+ this.logger.utils.block("Loaded handlers", [...this.logger.utils.entries(this.loadedHandlers), ...this.logger.utils.counts({ routes: this.rowOwners.size })]);
367
85
  }
368
- getChannel(name) {
369
- const leaf = this.leaves.get(name);
370
- if (leaf?.type !== ApplicationCommandOptionType.Channel) return null;
371
- return this.resolved?.channels?.[leaf.value] ?? null;
86
+ /** @internal */
87
+ async onHmr(event) {
88
+ await this.hmrHandler?.handle(event);
372
89
  }
373
- getRole(name) {
374
- const leaf = this.leaves.get(name);
375
- if (leaf?.type !== ApplicationCommandOptionType.Role) return null;
376
- return this.resolved?.roles?.[leaf.value] ?? null;
90
+ isHandler(value) {
91
+ if (typeof value !== "function") return false;
92
+ return (value.prototype instanceof InteractionHandler || value.prototype instanceof AutocompleteHandler) && Reflect.hasMetadata(InteractionMetadataKey, value);
377
93
  }
378
- getAttachment(name) {
379
- const leaf = this.leaves.get(name);
380
- if (leaf?.type !== ApplicationCommandOptionType.Attachment) return null;
381
- return this.resolved?.attachments?.[leaf.value] ?? null;
94
+ getArtifacts(ctor) {
95
+ const routeIds = [];
96
+ for (const [routeId, owner] of this.rowOwners) if (owner.ctor === ctor) routeIds.push(routeId);
97
+ return routeIds;
382
98
  }
383
- getMentionable(name) {
384
- const leaf = this.leaves.get(name);
385
- if (leaf?.type !== ApplicationCommandOptionType.Mentionable) return null;
386
- const resolved = this.resolved;
387
- return resolved?.members?.[leaf.value] ?? resolved?.users?.[leaf.value] ?? resolved?.roles?.[leaf.value] ?? null;
99
+ targetFor(route) {
100
+ return this.targets[route];
388
101
  }
389
- getFocused() {
390
- for (const leaf of this.leaves.values()) if ("focused" in leaf && leaf.focused === true) return {
391
- name: leaf.name,
392
- value: String(leaf.value)
102
+ buildTargets() {
103
+ return {
104
+ [InteractionRoutes.Slash]: {
105
+ map: this.maps.slash,
106
+ kind: "slash"
107
+ },
108
+ [InteractionRoutes.UserContextMenu]: {
109
+ map: this.maps.userContextMenu,
110
+ kind: "userContextMenu"
111
+ },
112
+ [InteractionRoutes.MessageContextMenu]: {
113
+ map: this.maps.messageContextMenu,
114
+ kind: "messageContextMenu"
115
+ },
116
+ [InteractionRoutes.Autocomplete]: {
117
+ map: this.maps.autocomplete,
118
+ kind: "autocomplete"
119
+ },
120
+ [InteractionRoutes.Button]: {
121
+ map: this.maps.components.button,
122
+ kind: "button"
123
+ },
124
+ [InteractionRoutes.StringMenu]: {
125
+ map: this.maps.components.stringSelect,
126
+ kind: "stringMenu"
127
+ },
128
+ [InteractionRoutes.UserMenu]: {
129
+ map: this.maps.components.userSelect,
130
+ kind: "userMenu"
131
+ },
132
+ [InteractionRoutes.RoleMenu]: {
133
+ map: this.maps.components.roleSelect,
134
+ kind: "roleMenu"
135
+ },
136
+ [InteractionRoutes.ChannelMenu]: {
137
+ map: this.maps.components.channelSelect,
138
+ kind: "channelMenu"
139
+ },
140
+ [InteractionRoutes.MentionableMenu]: {
141
+ map: this.maps.components.mentionableSelect,
142
+ kind: "mentionableMenu"
143
+ },
144
+ [InteractionRoutes.Modal]: {
145
+ map: this.maps.components.modal,
146
+ kind: "modal"
147
+ }
393
148
  };
394
- return null;
149
+ }
150
+ registerHandler(ctor, relativePath) {
151
+ const from = formatFilePath(relativePath);
152
+ const writes = [];
153
+ for (const [route, keys] of interactionRoutesOf(ctor)) {
154
+ const target = this.targetFor(route);
155
+ for (const key of keys) {
156
+ const routeId = `${target.kind}:${key}`;
157
+ const existing = this.rowOwners.get(routeId);
158
+ if (existing && existing.ctor !== ctor) throw new SeedcordError(SeedcordErrorCode.InteractionDuplicateRoute, [
159
+ routeId,
160
+ `${existing.ctor.name} (${existing.from})`,
161
+ `${ctor.name} (${from})`
162
+ ]);
163
+ writes.push([target, key]);
164
+ }
165
+ }
166
+ if (writes.length === 0) return;
167
+ for (const [target, key] of writes) {
168
+ const routeId = `${target.kind}:${key}`;
169
+ target.map.set(key, {
170
+ kind: target.kind,
171
+ routeId,
172
+ load: () => Promise.resolve(ctor)
173
+ });
174
+ this.rowOwners.set(routeId, {
175
+ ctor,
176
+ target,
177
+ key,
178
+ from
179
+ });
180
+ }
181
+ if (this.loading) this.loadedHandlers.push({
182
+ name: ctor.name,
183
+ from
184
+ });
185
+ else this.logger.utils.registration(ctor.name, from);
186
+ }
187
+ unregisterHandler(ctor, artifacts) {
188
+ const routeIds = artifacts ?? this.getArtifacts(ctor);
189
+ for (const routeId of routeIds) {
190
+ const owner = this.rowOwners.get(routeId);
191
+ if (owner?.ctor !== ctor) continue;
192
+ this.rowOwners.delete(routeId);
193
+ owner.target.map.delete(owner.key);
194
+ }
395
195
  }
396
196
  };
397
- function numeric(value) {
398
- if (typeof value === "number") return value;
399
- if (value.trim() === "") return null;
400
- const parsed = Number(value);
401
- return Number.isNaN(parsed) ? null : parsed;
402
- }
403
197
 
404
198
  //#endregion
405
- //#region src/dispatch/slashRouteOf.ts
406
- /**
407
- * Computes the dispatch route for a chat-input or autocomplete payload, the raw-payload port of the
408
- * gateway's `slashRouteOf`. Both assemble keys through `buildSlashRoute`, so a dispatched interaction
409
- * and a generated registry key always match.
410
- */
411
- function slashRouteOf(data) {
412
- const first = data.options?.[0];
413
- if (first?.type === ApplicationCommandOptionType.SubcommandGroup) return buildSlashRoute(data.name, first.options[0]?.name, first.name);
414
- if (first?.type === ApplicationCommandOptionType.Subcommand) return buildSlashRoute(data.name, first.name);
415
- return data.name;
199
+ //#region src/node/webBridge.ts
200
+ function toHeaders(raw) {
201
+ const headers = new Headers();
202
+ for (const [name, value] of Object.entries(raw)) {
203
+ if (value === void 0) continue;
204
+ if (Array.isArray(value)) for (const item of value) headers.append(name, item);
205
+ else headers.set(name, value);
206
+ }
207
+ return headers;
208
+ }
209
+ async function toWebRequest(request) {
210
+ const chunks = [];
211
+ for await (const chunk of request) chunks.push(chunk);
212
+ const method = request.method ?? "GET";
213
+ const body = method === "GET" || method === "HEAD" ? void 0 : new Uint8Array(Buffer.concat(chunks));
214
+ return new Request(`http://${request.headers.host ?? "localhost"}${request.url ?? "/"}`, {
215
+ method,
216
+ headers: toHeaders(request.headers),
217
+ ...body !== void 0 && { body }
218
+ });
219
+ }
220
+ async function writeWebResponse(response, out) {
221
+ const headers = Object.fromEntries(response.headers);
222
+ const cookies = response.headers.getSetCookie();
223
+ if (cookies.length > 0) headers["set-cookie"] = cookies;
224
+ out.writeHead(response.status, headers);
225
+ out.end(response.body === null ? void 0 : Buffer.from(await response.arrayBuffer()));
416
226
  }
417
227
 
418
228
  //#endregion
419
- //#region src/handlers/interaction/AutocompleteHandler.ts
229
+ //#region src/node/Seedcord.ts
230
+ const DEFAULT_PORT = 3e3;
231
+ const SERVER_SHUTDOWN_TIMEOUT_MS = 5e3;
232
+ const DRAIN_TIMEOUT_MS = 1e4;
420
233
  /**
421
- * Base class for an autocomplete handler on the HTTP transport.
422
- *
423
- * Pass the command route(s) from the generated registry as the generic. Branch on the focused field with
424
- * `this.match`, read already-entered sibling options with `this.options`, and find which command fired
425
- * with `this.route`.
234
+ * The HTTP-interactions bot host, a long-running node server around the engine.
426
235
  *
427
- * @typeParam Route - One or more route keys from {@link SlashOptionRegistry}, e.g. `'search'`.
236
+ * Discovers handlers from `config.bot.interactions.path`, verifies and dispatches interactions on
237
+ * `start(port)`, and runs coordinated shutdown with an in-flight drain. The edge deploy path calls
238
+ * `createSeedcord` from a generated entry.
428
239
  */
429
- var AutocompleteHandler = class extends BaseHandler {
430
- constructor(event, core, dispatch) {
431
- super(event, core, dispatch);
432
- }
433
- resolver;
434
- get reader() {
435
- this.resolver ??= new HttpSlashOptions(this.event.data);
436
- return this.resolver;
437
- }
438
- /**
439
- * The focused option for this interaction. `value` is always the raw partial string the user is typing,
440
- * even for an integer or number option, so coerce it yourself when you need a number.
441
- */
442
- get focused() {
443
- const focused = this.reader.getFocused();
444
- if (!focused) throw new SeedcordError(SeedcordErrorCode.AutocompleteNoFocusedOption);
445
- return focused;
240
+ var Seedcord = class Seedcord extends Pluggable {
241
+ config;
242
+ /** @internal */
243
+ [SeedcordBrand] = true;
244
+ /** @internal */
245
+ augmentTarget = "@seedcord/http";
246
+ /** @internal */
247
+ version = version;
248
+ /** Workerd-compatible Discord REST client. `start()` sets the token. */
249
+ rest = new REST();
250
+ /** @see {@link IRateLimiter} */
251
+ rateLimiter;
252
+ /** @see {@link Bus} */
253
+ bus;
254
+ subscribers;
255
+ interactions;
256
+ healthCheck;
257
+ hmrManager;
258
+ logger = new Logger("Server", { channel: "bot" });
259
+ server;
260
+ boundPort;
261
+ requestedPort = DEFAULT_PORT;
262
+ fetchedUsername;
263
+ constructor(config) {
264
+ super(new CoordinatedShutdown(), new CoordinatedStartup());
265
+ this.config = config;
266
+ installNodeDefaults(config.logger);
267
+ setBotColor(config.botColor);
268
+ this.hmrManager = new HmrManager();
269
+ this.hmrManager.init();
270
+ if (this.config.bot.interactions.path) this.interactions = new InteractionDispatcher(this.config.bot.interactions.path);
271
+ this.rateLimiter = config.store ?? new MemoryRateLimiter();
272
+ this.bus = new Bus(this);
273
+ this.subscribers = new SubscriberLoader(this.bus, config.subscribers.path);
274
+ this.healthCheck = config.runtime === "edge" ? void 0 : HealthCheck.fromOption(this.shutdown, config.healthCheck);
275
+ this.registerStartupTasks();
276
+ }
277
+ /** The bot's discord username, populated by the ready fetch. */
278
+ get username() {
279
+ return this.fetchedUsername;
280
+ }
281
+ /** The bound server port, populated once `start()` is listening. */
282
+ get port() {
283
+ return this.boundPort;
284
+ }
285
+ pluginCapabilities() {
286
+ const token = Envapter.get("DISCORD_BOT_TOKEN");
287
+ return {
288
+ rest: this.rest,
289
+ ...token !== void 0 && { token }
290
+ };
446
291
  }
447
292
  /**
448
- * Run the arm for the focused field. An autocomplete always dispatches by which field is focused, so
449
- * match is how you read it.
293
+ * Starts the host and runs the startup tasks.
450
294
  *
451
- * Provide one arm per autocompletable field across the registered commands, keyed by field name. Each
452
- * arm receives the focused partial value and a `respond` typed to that field's choice value. The
453
- * arms must cover every autocompletable field, a missing field or an unknown key is a compile error. A
454
- * focused field with no arm, only reachable from a stale-deployed command, throws at runtime.
455
- *
456
- * @param arms - One handler per autocompletable field, keyed by field name.
457
- * @returns The result of the arm that ran.
458
- */
459
- async match(arms) {
460
- const { name, value } = this.focused;
461
- const respond = (choices) => this.respond(choices);
462
- const arm = Object.hasOwn(arms, name) ? arms[name] : void 0;
463
- if (!arm) throw new SeedcordError(SeedcordErrorCode.AutocompleteMatchArmMissing, [name]);
464
- return await arm(value, respond);
465
- }
466
- /** Send autocomplete suggestions, callback type 8. Prefer {@link match}, which restricts each field's choices to its declared type. */
467
- async respond(choices) {
468
- await this.core.rest.post(Routes.interactionCallback(this.event.id, this.event.token), { body: {
469
- type: InteractionResponseType.ApplicationCommandAutocompleteResult,
470
- data: { choices }
471
- } });
472
- }
473
- /** The firing command route, for a field whose completion differs per registered command. */
474
- get route() {
475
- return slashRouteOf(this.event.data);
476
- }
477
- /**
478
- * The already-entered options on this command, restricted to the kinds Discord resolves during autocomplete
479
- * (string, integer, number, boolean) with every read nullable, since a sibling is partial while the user
480
- * types the focused field. The focused field is on `this.focused`.
481
- */
482
- get options() {
483
- return this.reader;
484
- }
485
- };
486
-
487
- //#endregion
488
- //#region src/handlers/defaults/UnhandledAutocomplete.ts
489
- /**
490
- * Default handler for autocomplete no manifest row matches.
491
- * Empty choices are the only legal answer, they clear the pending state.
492
- *
493
- * @internal
494
- */
495
- var UnhandledAutocomplete = class extends AutocompleteHandler {
496
- async execute() {
497
- await this.respond([]);
498
- }
499
- };
500
-
501
- //#endregion
502
- //#region src/handlers/interaction/InteractionHandler.ts
503
- /**
504
- * Shared base the repliable interaction handlers extend.
505
- *
506
- * Not a public entry point. Extend {@link SlashHandler}, {@link ContextMenuHandler}, {@link ButtonHandler},
507
- * {@link SelectMenuHandler}, or {@link ModalHandler} instead. This class adds `showModal` on top of the
508
- * reply members.
509
- *
510
- * @typeParam Event - The repliable interaction type this handler processes
511
- */
512
- var InteractionHandler = class extends RepliableHandler {
513
- constructor(event, core, dispatch) {
514
- super(event, core, dispatch);
515
- }
516
- /**
517
- * Open a modal. Must be the initial response to this interaction. The modal kind rejects this call at
518
- * compile time (Discord forbids a modal in response to a modal).
295
+ * @param port - The port the interaction server binds. {@default `3000` }
519
296
  */
520
- showModal(modal) {
521
- return this.sender.showModal(modal);
522
- }
523
- };
524
-
525
- //#endregion
526
- //#region src/handlers/defaults/UnhandledRepliable.ts
527
- /**
528
- * Default handler for interactions no manifest row matches. A reply failure here
529
- * goes directly to the dispatch fault boundary, so it needs no guard of its own.
530
- *
531
- * @internal
532
- */
533
- var UnhandledRepliable = class extends InteractionHandler {
534
- async execute() {
535
- await this.reply("Feature not implemented yet.");
536
- }
537
- };
538
-
539
- //#endregion
540
- //#region src/dispatch/resolve.ts
541
- const COMPONENT_KIND = {
542
- button: "button",
543
- stringSelect: "stringMenu",
544
- userSelect: "userMenu",
545
- roleSelect: "roleMenu",
546
- channelSelect: "channelMenu",
547
- mentionableSelect: "mentionableMenu",
548
- modal: "modal"
549
- };
550
- function commandKind(type) {
551
- switch (type) {
552
- case ApplicationCommandType.ChatInput: return "slash";
553
- case ApplicationCommandType.User: return "userContextMenu";
554
- case ApplicationCommandType.Message: return "messageContextMenu";
555
- default: return null;
556
- }
557
- }
558
- function componentMapKey(type) {
559
- switch (type) {
560
- case ComponentType.Button: return "button";
561
- case ComponentType.StringSelect: return "stringSelect";
562
- case ComponentType.UserSelect: return "userSelect";
563
- case ComponentType.RoleSelect: return "roleSelect";
564
- case ComponentType.ChannelSelect: return "channelSelect";
565
- case ComponentType.MentionableSelect: return "mentionableSelect";
566
- default: return null;
567
- }
568
- }
569
- function set(map, kind, key, load) {
570
- map.set(key, {
571
- kind,
572
- routeId: `${kind}:${key}`,
573
- load
574
- });
575
- }
576
- /** Builds the per-kind lookup maps. */
577
- function buildRouteMaps(manifest) {
578
- const maps = {
579
- slash: /* @__PURE__ */ new Map(),
580
- userContextMenu: /* @__PURE__ */ new Map(),
581
- messageContextMenu: /* @__PURE__ */ new Map(),
582
- autocomplete: /* @__PURE__ */ new Map(),
583
- components: {
584
- button: /* @__PURE__ */ new Map(),
585
- stringSelect: /* @__PURE__ */ new Map(),
586
- userSelect: /* @__PURE__ */ new Map(),
587
- roleSelect: /* @__PURE__ */ new Map(),
588
- channelSelect: /* @__PURE__ */ new Map(),
589
- mentionableSelect: /* @__PURE__ */ new Map(),
590
- modal: /* @__PURE__ */ new Map()
591
- }
592
- };
593
- for (const row of manifest.commands) {
594
- const kind = commandKind(row.type);
595
- if (kind) set(maps[kind], kind, row.name, row.load);
596
- }
597
- for (const row of manifest.autocomplete) set(maps.autocomplete, "autocomplete", row.name, row.load);
598
- for (const row of manifest.components) set(maps.components[row.kind], COMPONENT_KIND[row.kind], row.prefix, row.load);
599
- return maps;
600
- }
601
- function unhandled(kind) {
602
- return {
603
- kind,
604
- routeId: null,
605
- load: () => Promise.resolve(kind === "autocomplete" ? { UnhandledAutocomplete } : { UnhandledRepliable })
606
- };
607
- }
608
- /**
609
- * Matches a verified non-PING interaction to its manifest row. A known kind with no row resolves to the
610
- * unhandled default, whose handler replies "Feature not implemented yet." (empty choices on autocomplete).
611
- * Null is an unrecognized payload shape, which the engine acks with a 202 without dispatching.
612
- * Components and modals route by the stable customId prefix, so a wire whose layout hash drifted still
613
- * routes to its handler, where decode refuses with `StaleCustomId`.
614
- */
615
- function resolve(maps, interaction) {
616
- switch (interaction.type) {
617
- case InteractionType.ApplicationCommand: {
618
- if (interaction.data.type === ApplicationCommandType.ChatInput) return maps.slash.get(slashRouteOf(interaction.data)) ?? unhandled("slash");
619
- const kind = commandKind(interaction.data.type);
620
- return kind ? maps[kind].get(interaction.data.name) ?? unhandled(kind) : null;
621
- }
622
- case InteractionType.ApplicationCommandAutocomplete: return maps.autocomplete.get(slashRouteOf(interaction.data)) ?? unhandled("autocomplete");
623
- case InteractionType.MessageComponent: {
624
- const key = componentMapKey(interaction.data.component_type);
625
- if (!key) return null;
626
- return maps.components[key].get(prefixOf(interaction.data.custom_id)) ?? unhandled(COMPONENT_KIND[key]);
297
+ async start(port = DEFAULT_PORT) {
298
+ this.requestedPort = port;
299
+ try {
300
+ await super.init();
301
+ } catch (caught) {
302
+ await this.shutdown.run(1, false);
303
+ Seedcord.reset();
304
+ throw caught;
627
305
  }
628
- case InteractionType.ModalSubmit: return maps.components.modal.get(prefixOf(interaction.data.custom_id)) ?? unhandled("modal");
629
- default: return null;
630
- }
631
- }
632
-
633
- //#endregion
634
- //#region src/receiver/Ed25519Verifier.ts
635
- const PUBLIC_KEY_BYTES = 32;
636
- const SIGNATURE_BYTES = 64;
637
- const HEX_RADIX = 16;
638
- const HEX_PATTERN = /^[0-9a-f]+$/i;
639
- function hexToBytes(hex, byteLength) {
640
- if (hex.length !== byteLength * 2 || !HEX_PATTERN.test(hex)) return null;
641
- const bytes = new Uint8Array(byteLength);
642
- for (let index = 0; index < byteLength; index++) bytes[index] = Number.parseInt(hex.slice(index * 2, index * 2 + 2), HEX_RADIX);
643
- return bytes;
644
- }
645
- const encoder = new TextEncoder();
646
- var Ed25519Verifier = class {
647
- key;
648
- constructor(publicKeyHex) {
649
- const publicKey = hexToBytes(publicKeyHex, PUBLIC_KEY_BYTES);
650
- if (!publicKey) throw new SeedcordError(SeedcordErrorCode.ConfigIncorrectPublicKey);
651
- this.key = crypto.subtle.importKey("raw", publicKey, "Ed25519", false, ["verify"]);
652
- }
653
- async verify(signatureHex, timestamp, body) {
654
- const signature = hexToBytes(signatureHex, SIGNATURE_BYTES);
655
- if (!signature) return false;
656
- const timestampBytes = encoder.encode(timestamp);
657
- const message = new Uint8Array(timestampBytes.length + body.length);
658
- message.set(timestampBytes, 0);
659
- message.set(body, timestampBytes.length);
660
- return crypto.subtle.verify("Ed25519", await this.key, signature, message);
661
- }
662
- };
663
-
664
- //#endregion
665
- //#region src/receiver/ReplayGuard.ts
666
- const REPLAY_WINDOW_MS = 3e5;
667
- const SWEEP_GAP_MS = 6e4;
668
- const MS_PER_SECOND = 1e3;
669
- const TIMESTAMP_PATTERN = /^\d+$/;
670
- var ReplayGuard = class {
671
- seen = /* @__PURE__ */ new Map();
672
- lastSweep = Date.now();
673
- accepts(signatureHex, timestampHeader) {
674
- const now = Date.now();
675
- this.maybeSweep(now);
676
- if (!TIMESTAMP_PATTERN.test(timestampHeader)) return false;
677
- const timestampMs = Number(timestampHeader) * MS_PER_SECOND;
678
- if (Math.abs(now - timestampMs) > REPLAY_WINDOW_MS) return false;
679
- const rejectUntil = this.seen.get(signatureHex);
680
- if (rejectUntil !== void 0 && rejectUntil >= now) return false;
681
- this.seen.set(signatureHex, timestampMs + REPLAY_WINDOW_MS);
682
- return true;
683
- }
684
- maybeSweep(now) {
685
- if (now - this.lastSweep < SWEEP_GAP_MS) return;
686
- this.lastSweep = now;
687
- for (const [signature, rejectUntil] of this.seen) if (rejectUntil < now) this.seen.delete(signature);
306
+ return this;
307
+ }
308
+ static reset() {
309
+ super.reset();
310
+ LoggerChannelRegistry.instance.reset();
311
+ }
312
+ registerStartupTasks() {
313
+ if (Envapter.isDevelopment || Envapter.isTest) this.registerHmrAwareModules();
314
+ this.startup.addTask(StartupPhase.Configuration, "bus-initialization", async () => {
315
+ this.bus.logger.utils.initialization("Subscribers", "start");
316
+ await this.subscribers.init();
317
+ this.bus.logger.utils.initialization("Subscribers", "end");
318
+ });
319
+ const { interactions } = this;
320
+ if (interactions) this.startup.addTask(StartupPhase.Configuration, "interactions-initialization", async () => {
321
+ interactions.logger.utils.initialization("Interactions", "start");
322
+ await interactions.init();
323
+ interactions.logger.utils.initialization("Interactions", "end");
324
+ });
325
+ this.startup.addTask(StartupPhase.Ready, "http-server", () => this.listen());
326
+ if (!Envapter.isTest) this.startup.addTask(StartupPhase.Ready, "identity", () => this.fetchUsername());
327
+ const { healthCheck } = this;
328
+ if (healthCheck) this.startup.addTask(StartupPhase.Ready, "health-check", async () => {
329
+ healthCheck.logger.utils.initialization("HealthCheck", "start");
330
+ await healthCheck.init();
331
+ healthCheck.logger.utils.initialization("HealthCheck", "end");
332
+ });
688
333
  }
689
- };
690
-
691
- //#endregion
692
- //#region src/createSeedcord.ts
693
- const SIGNATURE_HEADER = "x-signature-ed25519";
694
- const TIMESTAMP_HEADER = "x-signature-timestamp";
695
- const ACCEPTED = 202;
696
- const BAD_REQUEST = 400;
697
- const UNAUTHORIZED = 401;
698
- const METHOD_NOT_ALLOWED = 405;
699
- const PING = InteractionType.Ping;
700
- const decoder = new TextDecoder();
701
- function hasInteractionType(payload) {
702
- return typeof payload === "object" && payload !== null && "type" in payload && typeof payload.type === "number";
703
- }
704
- /**
705
- * Builds the HTTP-interactions engine, a `(request, ctx?) => Promise<Response>` handler.
706
- *
707
- * The handler verifies the Ed25519 signature over the raw request bytes, rejects stale or replayed
708
- * requests, answers a PING with an in-body PONG, and acks every other interaction with an empty 202.
709
- * The engine dispatches a matched interaction through the manifest before the 202 goes out. Gates run
710
- * first and the sender posts any refusal over the REST callback. `execute()` continues past the 202 under
711
- * `ctx.waitUntil` when the caller passes one (the edge entry), and an engine-held in-flight set tracks
712
- * it otherwise (node paths). It reads only the method, headers, and body. Mount it at any path.
713
- *
714
- * Reads `DISCORD_PUBLIC_KEY` and `DISCORD_BOT_TOKEN` from the environment through envapt and throws a
715
- * `SeedcordError` when either is missing or malformed.
716
- */
717
- function createSeedcord(config, manifest) {
718
- if (!Envapter.has("DISCORD_PUBLIC_KEY")) throw new SeedcordError(SeedcordErrorCode.ConfigMissingPublicKey);
719
- const publicKey = Envapter.getRequired("DISCORD_PUBLIC_KEY", Converters.String);
720
- const token = validateDiscordToken(Envapter.get("DISCORD_BOT_TOKEN"));
721
- const verifier = new Ed25519Verifier(publicKey);
722
- const replays = new ReplayGuard();
723
- const maps = buildRouteMaps(manifest);
724
- const inFlight = /* @__PURE__ */ new Set();
725
- const core = createCore(config, token);
726
- const logger = new Logger("Engine");
727
- async function dispatchMatched(match, payload, ctx) {
728
- const start = await dispatchInteraction({
729
- match,
730
- payload,
731
- core
334
+ registerHmrAwareModules() {
335
+ this.startup.addTask(StartupPhase.Configuration, "hmr-registration", async () => {
336
+ if (this.interactions) this.hmrManager.register(this.interactions);
337
+ this.hmrManager.register(this.subscribers);
338
+ for (const plugin of this.plugins) this.hmrManager.register(plugin);
339
+ await Promise.resolve();
732
340
  });
733
- if (!start) return;
734
- const work = start();
735
- if (ctx) ctx.waitUntil(work);
736
- else {
737
- inFlight.add(work);
738
- work.finally(() => inFlight.delete(work));
739
- }
740
341
  }
741
- return async (request, ctx) => {
742
- if (request.method !== "POST") return new Response(null, {
743
- status: METHOD_NOT_ALLOWED,
744
- headers: { allow: "POST" }
342
+ async listen() {
343
+ const token = validateDiscordToken(Envapter.get("DISCORD_BOT_TOKEN"));
344
+ this.rest.setToken(token);
345
+ const maps = this.interactions?.maps ?? buildRouteMaps(EMPTY_MANIFEST);
346
+ const { handle, inFlight } = buildEngine(this, maps);
347
+ const server = createServer((incoming, outgoing) => {
348
+ (async () => {
349
+ await writeWebResponse(await handle(await toWebRequest(incoming)), outgoing);
350
+ })().catch((error) => {
351
+ outgoing.destroy(Error.isError(error) ? error : new Error(String(error)));
352
+ });
745
353
  });
746
- const signature = request.headers.get(SIGNATURE_HEADER);
747
- const timestamp = request.headers.get(TIMESTAMP_HEADER);
748
- if (signature === null || timestamp === null) return new Response(null, { status: UNAUTHORIZED });
749
- const body = new Uint8Array(await request.arrayBuffer());
750
- if (!await verifier.verify(signature, timestamp, body)) return new Response(null, { status: UNAUTHORIZED });
751
- if (!replays.accepts(signature, timestamp)) return new Response(null, { status: UNAUTHORIZED });
752
- let payload;
753
- try {
754
- payload = JSON.parse(decoder.decode(body));
755
- } catch {
756
- return new Response(null, { status: BAD_REQUEST });
757
- }
758
- if (!hasInteractionType(payload)) return new Response(null, { status: BAD_REQUEST });
759
- if (payload.type === PING) return Response.json({ type: InteractionResponseType.Pong });
354
+ this.server = server;
355
+ server.listen(this.requestedPort);
356
+ await once(server, "listening");
357
+ this.boundPort = server.address().port;
358
+ this.logger.info(`Interactions server listening on port ${paint.sky.bold(String(this.boundPort))}`);
359
+ this.shutdown.addTask(ShutdownPhase.Unbind, "stop-http-server", () => this.stopServer(), SERVER_SHUTDOWN_TIMEOUT_MS);
360
+ this.shutdown.addTask(ShutdownPhase.Drain, "drain-inflight", async () => {
361
+ await Promise.allSettled(inFlight);
362
+ }, DRAIN_TIMEOUT_MS);
363
+ }
364
+ async fetchUsername() {
760
365
  try {
761
- const match = resolve(maps, payload);
762
- if (match) await dispatchMatched(match, payload, ctx);
366
+ const me = await this.rest.get(Routes.user("@me"));
367
+ this.fetchedUsername = me.username;
763
368
  } catch (caught) {
764
- logger.error("dispatch failed, acking anyway", caught);
369
+ this.logger.warn("could not fetch the bot identity", caught);
765
370
  }
766
- return new Response(null, { status: ACCEPTED });
767
- };
768
- }
769
-
770
- //#endregion
771
- //#region src/handlers/interaction/SlashHandler.ts
772
- /**
773
- * Base class for a chat-input (slash) command handler on the HTTP transport.
774
- *
775
- * Pass the route from the generated registry as the generic, then read `this.options` for a single
776
- * command or `this.match` for several. Command authoring stays plain discord.js, `seedcord codegen`
777
- * reads its `toJSON()` to type these options.
778
- *
779
- * @typeParam Route - One or more route keys from {@link SlashOptionRegistry}, e.g. `'ban'` or `'ban' | 'kick'`.
780
- */
781
- var SlashHandler = class extends InteractionHandler {
782
- resolver;
783
- /**
784
- * The typed options for this command's route. Required options drop the null, choices narrow to their
785
- * literal union, and only the getters for kinds this command actually uses appear. Rich kinds resolve
786
- * from the interaction's `resolved` data.
787
- */
788
- get options() {
789
- this.resolver ??= new HttpSlashOptions(this.event.data);
790
- return this.resolver;
791
371
  }
792
- /**
793
- * Run the arm for whichever command fired. Use this only when the handler is registered for several
794
- * routes.
795
- *
796
- * Provide one arm per registered route, keyed by its route string, and each arm receives that route's
797
- * own narrowed options. The arms must cover every route in the generic, a missing route or an unknown
798
- * key is a compile error.
799
- *
800
- * @param arms - One handler per registered route, keyed by route string.
801
- * @returns The result of the arm that ran.
802
- */
803
- async match(arms) {
804
- const route = slashRouteOf(this.event.data);
805
- const arm = Object.hasOwn(arms, route) ? arms[route] : void 0;
806
- if (!arm) throw new SeedcordError(SeedcordErrorCode.SlashMatchArmMissing, [route]);
807
- return await arm(this.options);
808
- }
809
- };
810
-
811
- //#endregion
812
- //#region src/handlers/interaction/ContextMenuHandler.ts
813
- /**
814
- * Base class for a context-menu command handler on the HTTP transport (right-click a user or a message).
815
- *
816
- * Pass the kind from {@link ApplicationCommandType} as the generic. Read the right-clicked entity from
817
- * `this.target`, an `APIUser` for a user menu and an `APIMessage` for a message menu. Context menus carry
818
- * no options, so a handler registered for several names reads `this.target` uniformly with no per-name
819
- * branch.
820
- *
821
- * @typeParam Kind - `ApplicationCommandType.User` or `ApplicationCommandType.Message`.
822
- */
823
- var ContextMenuHandler = class extends InteractionHandler {
824
- get target() {
825
- const data = this.event.data;
826
- return data.type === ApplicationCommandType.User ? data.resolved.users[data.target_id] : data.resolved.messages[data.target_id];
827
- }
828
- /**
829
- * The right-clicked user's guild member, resolved only on user menus fired in a guild. Reading it on a
830
- * message menu is a compile error.
831
- */
832
- get targetMember() {
833
- const data = this.event.data;
834
- return data.type === ApplicationCommandType.User ? data.resolved.members?.[data.target_id] ?? null : null;
835
- }
836
- };
837
-
838
- //#endregion
839
- //#region src/handlers/interaction/components/ComponentHandler.ts
840
- /**
841
- * Shared base the customId-routed component handlers extend on the HTTP transport.
842
- *
843
- * Not a public entry point. Extend {@link ButtonHandler}, {@link SelectMenuHandler}, or {@link ModalHandler}
844
- * instead. This class defines the customId decode, the route matching, and the source-message verbs
845
- * `update` and `deferUpdate`.
846
- *
847
- * @typeParam Event - The component or modal interaction type this handler processes
848
- * @typeParam Defs - The customId route definitions registered on the concrete handler
849
- */
850
- var ComponentHandler = class extends InteractionHandler {
851
- /** Rewrite the source message this component interaction came from. */
852
- update(response) {
853
- return this.sender.update(response);
854
- }
855
- /** Acknowledge the component without changing the source message. */
856
- deferUpdate() {
857
- return this.sender.deferUpdate();
858
- }
859
- decoded;
860
- get route() {
861
- this.decoded ??= decodeComponentRoute(this.constructor, this.event.data.custom_id);
862
- return this.decoded;
863
- }
864
- /**
865
- * The decoded params of the single route this handler is registered for.
866
- *
867
- * Reading this decodes `this.event.data.custom_id` once (cached after the first read) and throws
868
- * `StaleCustomId` or `InvalidCustomId` when the wire no longer matches the current shape, which the
869
- * dispatcher boundary turns into a reply. On a handler registered for several routes this is
870
- * `never`, so use {@link match} instead.
871
- */
872
- get params() {
873
- return this.route.params;
874
- }
875
- /**
876
- * Run the arm for whichever route the component was minted from. Use this only when the handler is
877
- * registered for several routes. A single-route handler reads `this.params` directly. On a multi-route
878
- * handler `this.params` is `never`, so match is the only way to read the decoded params.
879
- *
880
- * Provide one arm per registered route, keyed by its prefix, and each arm receives that route's own
881
- * decoded params. The arms cover every registered route prefix, checked at compile time, and a prefix
882
- * unmatched at runtime throws `CustomIdMatchArmMissing`. Decoding runs before any arm, so a stale or
883
- * corrupt wire throws before an arm body executes.
884
- *
885
- * @param arms - One callback per registered route, keyed by prefix.
886
- * @returns The result of the arm that ran.
887
- *
888
- * @example
889
- * ```ts
890
- * \@ButtonRoute(Approve, Reject)
891
- * class ReviewButtons extends ButtonHandler<[typeof Approve, typeof Reject]> {
892
- * async execute() {
893
- * await this.match({
894
- * approve: ({ userId }) => this.reply(`approved <@${userId}>`),
895
- * reject: ({ userId }) => this.reply(`rejected <@${userId}>`)
896
- * });
897
- * }
898
- * }
899
- * ```
900
- */
901
- async match(arms) {
902
- const { prefix, params } = this.route;
903
- const arm = Object.hasOwn(arms, prefix) ? arms[prefix] : void 0;
904
- if (!arm) throw new SeedcordError(SeedcordErrorCode.CustomIdMatchArmMissing, [prefix]);
905
- return await arm(params);
906
- }
907
- };
908
-
909
- //#endregion
910
- //#region src/handlers/interaction/components/ButtonHandler.ts
911
- /**
912
- * Base class for a button interaction handler on the HTTP transport.
913
- *
914
- * Register the customId definitions this handler decodes with `@ButtonRoute`, list the same ones in the
915
- * generic, then read `this.params` for a single route or `this.match` for several. Reply through the
916
- * handler members, or rewrite the source message with `this.update`. Passing different definitions to the
917
- * decorator and the generic is a compile error.
918
- *
919
- * @typeParam Defs - The customId definitions this handler decodes, e.g. `[typeof ApproveId]`.
920
- */
921
- var ButtonHandler = class extends ComponentHandler {};
922
-
923
- //#endregion
924
- //#region src/handlers/interaction/components/SelectMenuHandler.ts
925
- /**
926
- * Base class for a select menu handler on the HTTP transport.
927
- *
928
- * Pass the select kind first and the customId definitions second, the same order as `@SelectMenuRoute`,
929
- * so `this.event.data` and its `values`/`resolved` are narrowed to that kind. Read `this.params` for a
930
- * single route or `this.match` for several, and reply through the handler members or rewrite the source
931
- * message with `this.update`.
932
- *
933
- * @typeParam Kind - The select kind from {@link SelectMenuKind}, e.g. `SelectMenuKind.User`.
934
- * @typeParam Defs - The customId definitions this handler decodes, e.g. `[typeof AssignId]`.
935
- */
936
- var SelectMenuHandler = class extends ComponentHandler {};
937
-
938
- //#endregion
939
- //#region src/handlers/interaction/components/ModalHandler.ts
940
- /**
941
- * Base class for a modal submit handler on the HTTP transport.
942
- *
943
- * Register the customId definitions this handler decodes with `@ModalRoute` and list the same ones in the
944
- * generic. Read the submitted inputs off `this.event.data.components`. Reply through the handler members.
945
- * Discord forbids opening a modal in response to a modal, so `showModal` fails compilation on this kind.
946
- *
947
- * @typeParam Defs - The customId definitions this handler decodes, e.g. `[typeof ConfigId]`.
948
- */
949
- var ModalHandler = class extends ComponentHandler {
950
- /** Rewrite the message this modal was opened from (only when a component opened it). */
951
- update(response) {
952
- this.ensureSourceMessage("update");
953
- return super.update(response);
954
- }
955
- /** Silently acknowledge, leaving the source message untouched. */
956
- deferUpdate() {
957
- this.ensureSourceMessage("deferUpdate");
958
- return super.deferUpdate();
959
- }
960
- ensureSourceMessage(method) {
961
- if (this.event.message) return;
962
- throw new SeedcordError(SeedcordErrorCode.ReplyUpdateWithoutSource, [method, this.routeId]);
372
+ stopServer() {
373
+ const server = this.server;
374
+ if (!server?.listening) return Promise.resolve();
375
+ return new Promise((resolveClose, rejectClose) => {
376
+ server.close((err) => {
377
+ if (err) {
378
+ rejectClose(err);
379
+ return;
380
+ }
381
+ this.logger.info(paint.coral.bold("Interactions server stopped"));
382
+ resolveClose();
383
+ });
384
+ server.closeIdleConnections();
385
+ });
963
386
  }
964
387
  };
965
388
 
966
389
  //#endregion
967
- //#region src/gates/Gated.ts
390
+ //#region src/plugin.ts
968
391
  /**
969
- * Attaches gates to an HTTP interaction handler. The gates run before `execute`, and a gate refusing stops
970
- * the handler with the reply or drop the gate threw. Multiple gates are ANDed, so each must pass. A gate
971
- * that requires a context the handler does not provide (a button gate on a slash handler, a gate on an
972
- * autocomplete handler, or a `RequirePermissions([...], { in: 'guild' })` gate the http transport cannot
973
- * satisfy) is a compile error at this line, and the error names the gate and the handler kind. Place it
974
- * above the route decorator.
975
- *
976
- * Combine gates with {@link and} or {@link or} to build a single gate from multiple arms.
392
+ * Base class for an HTTP plugin, binding `this.core` to the HTTP {@link Core}.
977
393
  *
978
- * @example
979
- * ```ts
980
- * import { PermissionFlagsBits } from 'discord-api-types/v10';
394
+ * A plugin that runs on either transport extends the base from `@seedcord/core/plugin`, whose
395
+ * `this.core` carries the shared members only.
981
396
  *
982
- * \@Gated(GuildOnly(), RequirePermissions([PermissionFlagsBits.ManageMessages]))
983
- * \@SlashRoute('purge')
984
- * class Purge extends SlashHandler<'purge'> {
985
- * async execute() {
986
- * // both gates passed, so this runs in a guild with a caller who can moderate this channel
987
- * }
988
- * }
989
- * ```
990
- *
991
- * @typeParam Gates - The non-empty tuple of gates to run, in order, against the handler's context.
992
- * @typeParam TCtor - The decorated handler's constructor, checked against every gate's required context.
993
- * @param gates - One or more gates to attach, each ANDed so the handler runs only when all pass.
994
- * @param ctor - The handler class being decorated, rejected at compile time when a gate requires a context it lacks.
995
- *
996
- * @see {@link defineGate}
997
- * @see {@link defineEffectGate}
998
- * @decorator
397
+ * @typeParam Opts - The transport, runtime, and capabilities the plugin declares.
999
398
  */
1000
- function Gated(...gates) {
1001
- return function(ctor) {
1002
- const existing = Reflect.getMetadata(GatedMetadataKey, ctor) ?? [];
1003
- Reflect.defineMetadata(GatedMetadataKey, [...existing, ...gates], ctor);
1004
- };
1005
- }
399
+ var Plugin = class extends Plugin$1 {};
1006
400
 
1007
401
  //#endregion
1008
402
  //#region src/index.ts
1009
- /** Package version */
1010
- const version = "0.1.0-next.0";
403
+ var src_exports = /* @__PURE__ */ __exportAll({
404
+ AutocompleteHandler: () => AutocompleteHandler,
405
+ ButtonHandler: () => ButtonHandler,
406
+ ComponentHandler: () => ComponentHandler,
407
+ ContextMenuHandler: () => ContextMenuHandler,
408
+ Gated: () => Gated,
409
+ InteractionHandler: () => InteractionHandler,
410
+ ModalHandler: () => ModalHandler,
411
+ Plugin: () => Plugin,
412
+ RepliableHandler: () => RepliableHandler,
413
+ Seedcord: () => Seedcord,
414
+ SelectMenuHandler: () => SelectMenuHandler,
415
+ SlashHandler: () => SlashHandler,
416
+ Subscriber: () => Subscriber,
417
+ WebhookLog: () => WebhookLog,
418
+ createSeedcord: () => createSeedcord,
419
+ version: () => version
420
+ });
1011
421
 
1012
422
  //#endregion
1013
- export { AutocompleteHandler, ButtonHandler, ComponentHandler, ContextMenuHandler, Gated, InteractionHandler, ModalHandler, RepliableHandler, SelectMenuHandler, SlashHandler, createSeedcord, version };
423
+ export { AutocompleteHandler, ButtonHandler, ComponentHandler, ContextMenuHandler, Gated, InteractionHandler, ModalHandler, Plugin, RepliableHandler, Seedcord, SelectMenuHandler, SlashHandler, Subscriber, WebhookLog, createSeedcord, version };
1014
424
  //# sourceMappingURL=index.mjs.map