@minesa-org/mini-interaction 0.1.12 → 0.1.13

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.
@@ -14,6 +14,7 @@ export type MiniInteractionOptions = {
14
14
  utilsDirectory?: string | false;
15
15
  fetchImplementation?: typeof fetch;
16
16
  verifyKeyImplementation?: VerifyKeyFunction;
17
+ timeoutConfig?: InteractionTimeoutConfig;
17
18
  };
18
19
  /** Payload structure for role connection metadata registration. */
19
20
  export type RoleConnectionMetadataField = {
@@ -37,6 +38,15 @@ export type MiniInteractionHandlerResult = {
37
38
  error: string;
38
39
  };
39
40
  };
41
+ /** Configuration for interaction timeout handling. */
42
+ export type InteractionTimeoutConfig = {
43
+ /** Maximum time in milliseconds to wait for initial response (default: 2800ms) */
44
+ initialResponseTimeout?: number;
45
+ /** Whether to enable timeout warnings (default: true) */
46
+ enableTimeoutWarnings?: boolean;
47
+ /** Whether to force deferReply for slow operations (default: true) */
48
+ autoDeferSlowOperations?: boolean;
49
+ };
40
50
  /** Handler signature invoked for Discord button interactions. */
41
51
  export type MiniInteractionButtonHandler = (interaction: ButtonInteraction) => Promise<APIInteractionResponse | void> | APIInteractionResponse | void;
42
52
  /** Handler signature invoked for Discord string select menu interactions. */
@@ -147,6 +157,7 @@ export declare class MiniInteraction {
147
157
  private readonly commandsDirectory;
148
158
  private readonly componentsDirectory;
149
159
  readonly utilsDirectory: string | null;
160
+ private readonly timeoutConfig;
150
161
  private readonly commands;
151
162
  private readonly componentHandlers;
152
163
  private readonly modalHandlers;
@@ -160,7 +171,7 @@ export declare class MiniInteraction {
160
171
  /**
161
172
  * Creates a new MiniInteraction client with optional command auto-loading and custom runtime hooks.
162
173
  */
163
- constructor({ applicationId, publicKey, commandsDirectory, componentsDirectory, utilsDirectory, fetchImplementation, verifyKeyImplementation, }: MiniInteractionOptions);
174
+ constructor({ applicationId, publicKey, commandsDirectory, componentsDirectory, utilsDirectory, fetchImplementation, verifyKeyImplementation, timeoutConfig, }: MiniInteractionOptions);
164
175
  private normalizeCommandData;
165
176
  private registerCommand;
166
177
  /**
@@ -260,8 +271,8 @@ export declare class MiniInteraction {
260
271
  * The following placeholders are available in the HTML file:
261
272
  * - `{{username}}`, `{{discriminator}}`, `{{user_id}}`, `{{user_tag}}`
262
273
  * - `{{access_token}}`, `{{refresh_token}}`, `{{token_type}}`, `{{scope}}`, `{{expires_at}}`
263
- * - `{{state}}`
264
- */
274
+ * - `{{state}}`
275
+ */
265
276
  connectedOAuthPage(filePath: string): DiscordOAuthCallbackTemplates["success"];
266
277
  /**
267
278
  * Loads an HTML file and returns an error template that can be reused for all failure cases.
@@ -277,7 +288,7 @@ export declare class MiniInteraction {
277
288
  private loadHtmlTemplate;
278
289
  /**
279
290
  * Replaces placeholder tokens in a template with escaped HTML values.
280
- */
291
+ */
281
292
  private renderHtmlTemplate;
282
293
  /**
283
294
  * Normalizes placeholder tokens to the bare key the HTML renderer expects.
@@ -30,6 +30,7 @@ export class MiniInteraction {
30
30
  commandsDirectory;
31
31
  componentsDirectory;
32
32
  utilsDirectory;
33
+ timeoutConfig;
33
34
  commands = new Map();
34
35
  componentHandlers = new Map();
35
36
  modalHandlers = new Map();
@@ -43,7 +44,7 @@ export class MiniInteraction {
43
44
  /**
44
45
  * Creates a new MiniInteraction client with optional command auto-loading and custom runtime hooks.
45
46
  */
46
- constructor({ applicationId, publicKey, commandsDirectory, componentsDirectory, utilsDirectory, fetchImplementation, verifyKeyImplementation, }) {
47
+ constructor({ applicationId, publicKey, commandsDirectory, componentsDirectory, utilsDirectory, fetchImplementation, verifyKeyImplementation, timeoutConfig, }) {
47
48
  if (!applicationId) {
48
49
  throw new Error("[MiniInteraction] applicationId is required");
49
50
  }
@@ -70,6 +71,12 @@ export class MiniInteraction {
70
71
  utilsDirectory === false
71
72
  ? null
72
73
  : this.resolveUtilsDirectory(utilsDirectory);
74
+ this.timeoutConfig = {
75
+ initialResponseTimeout: 2800, // Leave 200ms buffer before Discord's 3s limit
76
+ enableTimeoutWarnings: true,
77
+ autoDeferSlowOperations: true,
78
+ ...timeoutConfig,
79
+ };
73
80
  }
74
81
  normalizeCommandData(data) {
75
82
  if (typeof data === "object" && data !== null) {
@@ -94,12 +101,14 @@ export class MiniInteraction {
94
101
  data: normalizedData,
95
102
  };
96
103
  this.commands.set(commandName, normalizedCommand);
97
- if (normalizedCommand.components && Array.isArray(normalizedCommand.components)) {
104
+ if (normalizedCommand.components &&
105
+ Array.isArray(normalizedCommand.components)) {
98
106
  for (const component of normalizedCommand.components) {
99
107
  this.useComponent(component);
100
108
  }
101
109
  }
102
- if (normalizedCommand.modals && Array.isArray(normalizedCommand.modals)) {
110
+ if (normalizedCommand.modals &&
111
+ Array.isArray(normalizedCommand.modals)) {
103
112
  for (const modal of normalizedCommand.modals) {
104
113
  this.useModal(modal);
105
114
  }
@@ -354,6 +363,7 @@ export class MiniInteraction {
354
363
  * @param request - The request payload containing headers and body data.
355
364
  */
356
365
  async handleRequest(request) {
366
+ const requestStartTime = Date.now();
357
367
  const { body, signature, timestamp } = request;
358
368
  if (!signature || !timestamp) {
359
369
  return {
@@ -398,6 +408,15 @@ export class MiniInteraction {
398
408
  if (interaction.type === InteractionType.ModalSubmit) {
399
409
  return this.handleModalSubmit(interaction);
400
410
  }
411
+ // Check total processing time
412
+ const totalProcessingTime = Date.now() - requestStartTime;
413
+ if (this.timeoutConfig.enableTimeoutWarnings &&
414
+ totalProcessingTime >
415
+ this.timeoutConfig.initialResponseTimeout * 0.9) {
416
+ console.warn(`[MiniInteraction] WARNING: Interaction processing took ${totalProcessingTime}ms ` +
417
+ `(${Math.round((totalProcessingTime / 3000) * 100)}% of Discord's 3-second limit). ` +
418
+ `Consider optimizing or using deferReply() for slow operations.`);
419
+ }
401
420
  return {
402
421
  status: 400,
403
422
  body: {
@@ -511,8 +530,8 @@ export class MiniInteraction {
511
530
  * The following placeholders are available in the HTML file:
512
531
  * - `{{username}}`, `{{discriminator}}`, `{{user_id}}`, `{{user_tag}}`
513
532
  * - `{{access_token}}`, `{{refresh_token}}`, `{{token_type}}`, `{{scope}}`, `{{expires_at}}`
514
- * - `{{state}}`
515
- */
533
+ * - `{{state}}`
534
+ */
516
535
  connectedOAuthPage(filePath) {
517
536
  const template = this.loadHtmlTemplate(filePath);
518
537
  return ({ user, tokens, state }) => {
@@ -569,7 +588,7 @@ export class MiniInteraction {
569
588
  }
570
589
  /**
571
590
  * Replaces placeholder tokens in a template with escaped HTML values.
572
- */
591
+ */
573
592
  renderHtmlTemplate(template, values, options) {
574
593
  const rawKeys = options?.rawKeys instanceof Set
575
594
  ? options.rawKeys
@@ -666,7 +685,9 @@ export class MiniInteraction {
666
685
  const location = typeof options.successRedirect === "function"
667
686
  ? options.successRedirect(authorizeContext)
668
687
  : options.successRedirect;
669
- if (location && !response.headersSent && !response.writableEnded) {
688
+ if (location &&
689
+ !response.headersSent &&
690
+ !response.writableEnded) {
670
691
  response.statusCode = 302;
671
692
  response.setHeader("location", location);
672
693
  response.end();
@@ -1057,27 +1078,32 @@ export class MiniInteraction {
1057
1078
  }
1058
1079
  try {
1059
1080
  const interactionWithHelpers = createMessageComponentInteraction(interaction);
1060
- const response = await handler(interactionWithHelpers);
1061
- const resolvedResponse = response ?? interactionWithHelpers.getResponse();
1062
- if (!resolvedResponse) {
1063
- return {
1064
- status: 500,
1065
- body: {
1066
- error: `[MiniInteraction] Component "${customId}" did not return a response. ` +
1067
- "Return an APIInteractionResponse to acknowledge the interaction.",
1068
- },
1069
- };
1070
- }
1081
+ // Wrap component handler with timeout
1082
+ const timeoutWrapper = createTimeoutWrapper(async () => {
1083
+ const response = await handler(interactionWithHelpers);
1084
+ const resolvedResponse = response ?? interactionWithHelpers.getResponse();
1085
+ if (!resolvedResponse) {
1086
+ throw new Error(`Component "${customId}" did not return a response. ` +
1087
+ "Return an APIInteractionResponse to acknowledge the interaction.");
1088
+ }
1089
+ return resolvedResponse;
1090
+ }, this.timeoutConfig.initialResponseTimeout, `Component "${customId}"`, this.timeoutConfig.enableTimeoutWarnings);
1091
+ const resolvedResponse = await timeoutWrapper();
1071
1092
  return {
1072
1093
  status: 200,
1073
1094
  body: resolvedResponse,
1074
1095
  };
1075
1096
  }
1076
1097
  catch (error) {
1098
+ const errorMessage = error instanceof Error ? error.message : String(error);
1099
+ if (errorMessage.includes("Handler timeout")) {
1100
+ console.error(`[MiniInteraction] CRITICAL: Component "${customId}" timed out. ` +
1101
+ `This will result in "didn't respond in time" errors for users.`);
1102
+ }
1077
1103
  return {
1078
1104
  status: 500,
1079
1105
  body: {
1080
- error: `[MiniInteraction] Component "${customId}" failed: ${String(error)}`,
1106
+ error: `[MiniInteraction] Component "${customId}" failed: ${errorMessage}`,
1081
1107
  },
1082
1108
  };
1083
1109
  }
@@ -1107,27 +1133,32 @@ export class MiniInteraction {
1107
1133
  }
1108
1134
  try {
1109
1135
  const interactionWithHelpers = createModalSubmitInteraction(interaction);
1110
- const response = await handler(interactionWithHelpers);
1111
- const resolvedResponse = response ?? interactionWithHelpers.getResponse();
1112
- if (!resolvedResponse) {
1113
- return {
1114
- status: 500,
1115
- body: {
1116
- error: `[MiniInteraction] Modal "${customId}" did not return a response. ` +
1117
- "Return an APIInteractionResponse to acknowledge the interaction.",
1118
- },
1119
- };
1120
- }
1136
+ // Wrap modal handler with timeout
1137
+ const timeoutWrapper = createTimeoutWrapper(async () => {
1138
+ const response = await handler(interactionWithHelpers);
1139
+ const resolvedResponse = response ?? interactionWithHelpers.getResponse();
1140
+ if (!resolvedResponse) {
1141
+ throw new Error(`Modal "${customId}" did not return a response. ` +
1142
+ "Return an APIInteractionResponse to acknowledge the interaction.");
1143
+ }
1144
+ return resolvedResponse;
1145
+ }, this.timeoutConfig.initialResponseTimeout, `Modal "${customId}"`, this.timeoutConfig.enableTimeoutWarnings);
1146
+ const resolvedResponse = await timeoutWrapper();
1121
1147
  return {
1122
1148
  status: 200,
1123
1149
  body: resolvedResponse,
1124
1150
  };
1125
1151
  }
1126
1152
  catch (error) {
1153
+ const errorMessage = error instanceof Error ? error.message : String(error);
1154
+ if (errorMessage.includes("Handler timeout")) {
1155
+ console.error(`[MiniInteraction] CRITICAL: Modal "${customId}" timed out. ` +
1156
+ `This will result in "didn't respond in time" errors for users.`);
1157
+ }
1127
1158
  return {
1128
1159
  status: 500,
1129
1160
  body: {
1130
- error: `[MiniInteraction] Modal "${customId}" failed: ${String(error)}`,
1161
+ error: `[MiniInteraction] Modal "${customId}" failed: ${errorMessage}`,
1131
1162
  },
1132
1163
  };
1133
1164
  }
@@ -1159,41 +1190,50 @@ export class MiniInteraction {
1159
1190
  try {
1160
1191
  let response;
1161
1192
  let resolvedResponse = null;
1162
- // Check if it's a chat input (slash) command
1163
- if (commandInteraction.data.type ===
1164
- ApplicationCommandType.ChatInput) {
1165
- const interactionWithHelpers = createCommandInteraction(commandInteraction);
1166
- response = await command.handler(interactionWithHelpers);
1167
- resolvedResponse =
1168
- response ?? interactionWithHelpers.getResponse();
1169
- }
1170
- else if (commandInteraction.data.type === ApplicationCommandType.User) {
1171
- // User context menu command
1172
- const interactionWithHelpers = createUserContextMenuInteraction(commandInteraction);
1173
- response = await command.handler(interactionWithHelpers);
1174
- resolvedResponse =
1175
- response ?? interactionWithHelpers.getResponse();
1176
- }
1177
- else if (commandInteraction.data.type ===
1178
- ApplicationCommandType.PrimaryEntryPoint) {
1179
- const interactionWithHelpers = createAppCommandInteraction(commandInteraction);
1180
- response = await command.handler(interactionWithHelpers);
1181
- resolvedResponse =
1182
- response ?? interactionWithHelpers.getResponse();
1183
- }
1184
- else if (commandInteraction.data.type === ApplicationCommandType.Message) {
1185
- // Message context menu command
1186
- const interactionWithHelpers = createMessageContextMenuInteraction(commandInteraction);
1187
- response = await command.handler(interactionWithHelpers);
1188
- resolvedResponse =
1189
- response ?? interactionWithHelpers.getResponse();
1190
- }
1191
- else {
1192
- // Unknown command type
1193
- response = await command.handler(commandInteraction);
1194
- resolvedResponse = response ?? null;
1195
- }
1193
+ // Create a timeout wrapper for the command handler
1194
+ const timeoutWrapper = createTimeoutWrapper(async () => {
1195
+ // Check if it's a chat input (slash) command
1196
+ if (commandInteraction.data.type ===
1197
+ ApplicationCommandType.ChatInput) {
1198
+ const interactionWithHelpers = createCommandInteraction(commandInteraction);
1199
+ response = await command.handler(interactionWithHelpers);
1200
+ resolvedResponse =
1201
+ response ?? interactionWithHelpers.getResponse();
1202
+ }
1203
+ else if (commandInteraction.data.type ===
1204
+ ApplicationCommandType.User) {
1205
+ // User context menu command
1206
+ const interactionWithHelpers = createUserContextMenuInteraction(commandInteraction);
1207
+ response = await command.handler(interactionWithHelpers);
1208
+ resolvedResponse =
1209
+ response ?? interactionWithHelpers.getResponse();
1210
+ }
1211
+ else if (commandInteraction.data.type ===
1212
+ ApplicationCommandType.PrimaryEntryPoint) {
1213
+ const interactionWithHelpers = createAppCommandInteraction(commandInteraction);
1214
+ response = await command.handler(interactionWithHelpers);
1215
+ resolvedResponse =
1216
+ response ?? interactionWithHelpers.getResponse();
1217
+ }
1218
+ else if (commandInteraction.data.type ===
1219
+ ApplicationCommandType.Message) {
1220
+ // Message context menu command
1221
+ const interactionWithHelpers = createMessageContextMenuInteraction(commandInteraction);
1222
+ response = await command.handler(interactionWithHelpers);
1223
+ resolvedResponse =
1224
+ response ?? interactionWithHelpers.getResponse();
1225
+ }
1226
+ else {
1227
+ // Unknown command type
1228
+ response = await command.handler(commandInteraction);
1229
+ resolvedResponse = response ?? null;
1230
+ }
1231
+ }, this.timeoutConfig.initialResponseTimeout, `Command "${commandName}"`, this.timeoutConfig.enableTimeoutWarnings);
1232
+ await timeoutWrapper();
1196
1233
  if (!resolvedResponse) {
1234
+ console.error(`[MiniInteraction] Command "${commandName}" did not return a response. ` +
1235
+ "This indicates the handler completed but no response was generated. " +
1236
+ "Check that deferReply(), reply(), showModal(), or a direct response is returned.");
1197
1237
  return {
1198
1238
  status: 500,
1199
1239
  body: {
@@ -1209,10 +1249,18 @@ export class MiniInteraction {
1209
1249
  };
1210
1250
  }
1211
1251
  catch (error) {
1252
+ const errorMessage = error instanceof Error ? error.message : String(error);
1253
+ // Check if this was a timeout error
1254
+ if (errorMessage.includes("Handler timeout")) {
1255
+ console.error(`[MiniInteraction] CRITICAL: Command "${commandName}" timed out before responding to Discord. ` +
1256
+ `This will result in "didn't respond in time" errors for users. ` +
1257
+ `Handler took longer than ${this.timeoutConfig.initialResponseTimeout}ms to complete. ` +
1258
+ `Consider using deferReply() for operations that take more than 3 seconds.`);
1259
+ }
1212
1260
  return {
1213
1261
  status: 500,
1214
1262
  body: {
1215
- error: `[MiniInteraction] Command "${commandName}" failed: ${String(error)}`,
1263
+ error: `[MiniInteraction] Command "${commandName}" failed: ${errorMessage}`,
1216
1264
  },
1217
1265
  };
1218
1266
  }
@@ -1382,3 +1430,45 @@ function resolveOAuthConfig(provided) {
1382
1430
  redirectUri,
1383
1431
  };
1384
1432
  }
1433
+ /**
1434
+ * Wraps a handler function with timeout detection and error handling.
1435
+ */
1436
+ function createTimeoutWrapper(handler, timeoutMs, handlerName, enableWarnings = true) {
1437
+ return async (...args) => {
1438
+ const startTime = Date.now();
1439
+ let timeoutId;
1440
+ const timeoutPromise = new Promise((_, reject) => {
1441
+ timeoutId = setTimeout(() => {
1442
+ const elapsed = Date.now() - startTime;
1443
+ console.error(`[MiniInteraction] ${handlerName} timed out after ${elapsed}ms (limit: ${timeoutMs}ms)`);
1444
+ reject(new Error(`Handler timeout: ${handlerName} exceeded ${timeoutMs}ms limit`));
1445
+ }, timeoutMs);
1446
+ });
1447
+ try {
1448
+ const result = await Promise.race([
1449
+ Promise.resolve(handler(...args)),
1450
+ timeoutPromise,
1451
+ ]);
1452
+ if (timeoutId) {
1453
+ clearTimeout(timeoutId);
1454
+ }
1455
+ const elapsed = Date.now() - startTime;
1456
+ if (enableWarnings && elapsed > timeoutMs * 0.8) {
1457
+ console.warn(`[MiniInteraction] ${handlerName} completed in ${elapsed}ms (${Math.round((elapsed / timeoutMs) * 100)}% of timeout limit)`);
1458
+ }
1459
+ return result;
1460
+ }
1461
+ catch (error) {
1462
+ if (timeoutId) {
1463
+ clearTimeout(timeoutId);
1464
+ }
1465
+ // Re-throw the error with additional context
1466
+ if (error instanceof Error &&
1467
+ error.message.includes("Handler timeout")) {
1468
+ throw error;
1469
+ }
1470
+ console.error(`[MiniInteraction] ${handlerName} failed:`, error);
1471
+ throw error;
1472
+ }
1473
+ };
1474
+ }
@@ -149,6 +149,7 @@ export interface CommandInteraction extends Omit<APIChatInputApplicationCommandI
149
149
  showModal(data: APIModalInteractionResponseCallbackData | {
150
150
  toJSON(): APIModalInteractionResponseCallbackData;
151
151
  }): APIModalInteractionResponse;
152
+ withTimeoutProtection<T>(operation: () => Promise<T>, deferOptions?: DeferReplyOptions): Promise<T>;
152
153
  }
153
154
  /**
154
155
  * Wraps a raw application command interaction with helper methods and option resolvers.
@@ -398,6 +398,40 @@ export function createCommandInteraction(interaction) {
398
398
  data: resolvedData,
399
399
  });
400
400
  },
401
+ /**
402
+ * Creates a delayed response wrapper that automatically defers if the operation takes too long.
403
+ * Use this for operations that might exceed Discord's 3-second limit.
404
+ *
405
+ * @param operation - The async operation to perform
406
+ * @param deferOptions - Options for automatic deferral
407
+ */
408
+ async withTimeoutProtection(operation, deferOptions) {
409
+ const startTime = Date.now();
410
+ let deferred = false;
411
+ // Set up a timer to auto-defer after 2.5 seconds
412
+ const deferTimer = setTimeout(async () => {
413
+ if (!deferred) {
414
+ console.warn("[MiniInteraction] Auto-deferring interaction due to slow operation. " +
415
+ "Consider using deferReply() explicitly for better user experience.");
416
+ this.deferReply(deferOptions);
417
+ deferred = true;
418
+ }
419
+ }, 2500);
420
+ try {
421
+ const result = await operation();
422
+ clearTimeout(deferTimer);
423
+ const elapsed = Date.now() - startTime;
424
+ if (elapsed > 2000 && !deferred) {
425
+ console.warn(`[MiniInteraction] Operation completed in ${elapsed}ms. ` +
426
+ "Consider using deferReply() for operations > 2 seconds.");
427
+ }
428
+ return result;
429
+ }
430
+ catch (error) {
431
+ clearTimeout(deferTimer);
432
+ throw error;
433
+ }
434
+ },
401
435
  };
402
436
  return commandInteraction;
403
437
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@minesa-org/mini-interaction",
3
- "version": "0.1.12",
3
+ "version": "0.1.13",
4
4
  "description": "Mini interaction, connecting your app with Discord via HTTP-interaction (Vercel support).",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",