meocord 4.1.2 → 4.2.0

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.
Files changed (95) hide show
  1. package/CHANGELOG.md +98 -0
  2. package/dist/cjs/_shared/{controller.decorator-BywwkU9O.cjs → controller.decorator-CRn9kWob.cjs} +295 -87
  3. package/dist/cjs/_shared/{providers-BMMLp2SN.cjs → providers-ChvMqnLd.cjs} +74 -40
  4. package/dist/cjs/_shared/{sharded-cooldown-store-B65iaiAy.cjs → sharded-cooldown-store-BdAUTdYs.cjs} +1 -1
  5. package/dist/cjs/_shared/{startup-roots-lFc69AxC.cjs → startup-roots-CQo8mSP8.cjs} +158 -72
  6. package/dist/cjs/_shared/{translate-error-BnM8FbSH.cjs → translate-error-DhDVbT5M.cjs} +12 -3
  7. package/dist/cjs/common/index.cjs +4 -4
  8. package/dist/cjs/core/index.cjs +195 -157
  9. package/dist/cjs/decorator/index.cjs +66 -57
  10. package/dist/cjs/testing/index.cjs +733 -301
  11. package/dist/cli.json +1 -1
  12. package/dist/esm/bin/app-template/README.md.template +3 -1
  13. package/dist/esm/bin/app-template/src/controllers/button/sample.button.controller.spec.ts.template +8 -4
  14. package/dist/esm/bin/app-template/src/controllers/button/sample.button.controller.ts.template +2 -2
  15. package/dist/esm/bin/app-template/src/guards/owner.guard.spec.ts.template +8 -4
  16. package/dist/esm/bin/app-template/src/guards/owner.guard.ts.template +1 -1
  17. package/dist/esm/bin/app-template/vitest.config.ts.template +1 -1
  18. package/dist/esm/bin/app-template/vitest.setup.ts.template +7 -3
  19. package/dist/esm/build/load-config.pre-entry.js +4 -1
  20. package/dist/esm/common/decorator.js +6 -6
  21. package/dist/esm/common/deprecation.js +2 -2
  22. package/dist/esm/common/errors.js +2 -2
  23. package/dist/esm/common/execution-context.js +1 -1
  24. package/dist/esm/common/metadata.js +1 -1
  25. package/dist/esm/common/route.js +10 -1
  26. package/dist/esm/common/translate-error.js +1 -1
  27. package/dist/esm/core/command-conflicts.js +34 -34
  28. package/dist/esm/core/component-routes.js +74 -31
  29. package/dist/esm/core/dispatcher.js +51 -7
  30. package/dist/esm/core/filter-runner.js +2 -2
  31. package/dist/esm/core/handler-pipeline.js +27 -14
  32. package/dist/esm/core/handler-registry.js +1 -1
  33. package/dist/esm/core/interceptor-runner.js +2 -2
  34. package/dist/esm/core/meocord-factory.js +196 -157
  35. package/dist/esm/core/message-routes.js +4 -3
  36. package/dist/esm/core/observer-runner.js +4 -3
  37. package/dist/esm/core/providers.js +38 -21
  38. package/dist/esm/core/route-specificity.js +4 -4
  39. package/dist/esm/core/scalar-types.js +15 -3
  40. package/dist/esm/core/shard-context.js +1 -1
  41. package/dist/esm/core/startup-roots.js +2 -3
  42. package/dist/esm/core/theme-runtime.js +2 -2
  43. package/dist/esm/decorator/app.decorator.js +12 -7
  44. package/dist/esm/decorator/command-builder.decorator.js +4 -4
  45. package/dist/esm/decorator/controller-class.decorator.js +9 -4
  46. package/dist/esm/decorator/controller.decorator.js +110 -67
  47. package/dist/esm/decorator/cooldown.decorator.js +3 -3
  48. package/dist/esm/decorator/defer.decorator.js +4 -4
  49. package/dist/esm/decorator/event.decorator.js +5 -5
  50. package/dist/esm/decorator/filter.decorator.js +7 -7
  51. package/dist/esm/decorator/guard.decorator.js +8 -8
  52. package/dist/esm/decorator/inject.decorator.js +4 -4
  53. package/dist/esm/decorator/interceptor.decorator.js +7 -7
  54. package/dist/esm/decorator/observer.decorator.js +4 -4
  55. package/dist/esm/decorator/service.decorator.js +4 -4
  56. package/dist/esm/decorator/theme.decorator.js +3 -3
  57. package/dist/esm/decorator/validation.decorator.js +10 -10
  58. package/dist/esm/package.json.js +1 -1
  59. package/dist/esm/testing/catalog.js +1 -1
  60. package/dist/esm/testing/cooldown-store-suite.js +18 -10
  61. package/dist/esm/testing/discord-defaults.js +60 -7
  62. package/dist/esm/testing/index.js +4 -2
  63. package/dist/esm/testing/inspect-handler.js +20 -3
  64. package/dist/esm/testing/meocord-testing-module.js +229 -188
  65. package/dist/esm/testing/mock-fn.js +80 -10
  66. package/dist/esm/testing/mock-interaction.js +223 -72
  67. package/dist/esm/testing/response.js +1 -1
  68. package/dist/esm/testing/routing.js +39 -14
  69. package/dist/esm/testing/startup-errors.js +26 -0
  70. package/dist/esm/testing/strict-mocks.js +45 -0
  71. package/dist/esm/testing/theme.js +1 -1
  72. package/dist/esm/util/bundle-entry.util.js +2 -1
  73. package/dist/esm/util/interaction.util.js +1 -1
  74. package/dist/esm/util/meocord-config-validation.util.js +1 -0
  75. package/dist/esm/util/metadata-keys.js +1 -0
  76. package/dist/esm/util/refusal.util.js +148 -8
  77. package/dist/types/common/index.d.cts +8 -8
  78. package/dist/types/common/index.d.ts +8 -8
  79. package/dist/types/core/index.d.cts +8 -6
  80. package/dist/types/core/index.d.ts +8 -6
  81. package/dist/types/decorator/index.d.cts +30 -29
  82. package/dist/types/decorator/index.d.ts +30 -29
  83. package/dist/types/{index-D7n0FBMm.d.ts → index-CdXsFgjE.d.ts} +62 -15
  84. package/dist/types/{index-DEP0NYrU.d.cts → index-nn5p7OoX.d.cts} +62 -15
  85. package/dist/types/interface/index.d.cts +1 -1
  86. package/dist/types/interface/index.d.ts +1 -1
  87. package/dist/types/{route-C-aMI7Sl.d.cts → route-BZgFoNeB.d.cts} +5 -2
  88. package/dist/types/{route-C-aMI7Sl.d.ts → route-BZgFoNeB.d.ts} +5 -2
  89. package/dist/types/testing/index.d.cts +245 -31
  90. package/dist/types/testing/index.d.ts +245 -31
  91. package/dist/types/{theme-resolvers-DedxyscT.d.ts → theme-resolvers-BcOJVzR9.d.ts} +1 -1
  92. package/dist/types/{theme-resolvers-COHdX5g6.d.cts → theme-resolvers-UKr3-6Ys.d.cts} +1 -1
  93. package/dist/types/{translator-J6tIUfdp.d.ts → translator-BViWKtLJ.d.ts} +1 -1
  94. package/dist/types/{translator-B4mc1lzJ.d.cts → translator-DHMkkCpg.d.cts} +1 -1
  95. package/package.json +3 -2
@@ -38,6 +38,7 @@ import { existingResponse } from '../common/response/response-state.js';
38
38
  if (state && state !== 'unanswered') return true;
39
39
  return 'replied' in interaction && (interaction.replied || interaction.deferred);
40
40
  }
41
+ /** How long a message's handler may take, with others waiting after it, before it is named as slow. */ const SLOW_HANDLER_MS = 5000;
41
42
  /**
42
43
  * Routes interactions, messages and reactions to the handlers that take them, as the bot does, and runs
43
44
  * each through its pipeline. The app and the testing module share it, so a test routes exactly as the bot.
@@ -293,22 +294,64 @@ import { existingResponse } from '../common/response/response-state.js';
293
294
  message
294
295
  ], error, this.runOptions(call));
295
296
  }
297
+ // The matched handler, then every listener, each resolved from the container only as it starts
298
+ const runs = [];
296
299
  if (target) {
297
300
  const { route, params, start, given } = target;
298
301
  noteInvocation(message, `${start}${commandWordsOf(route.tokens).join(' ')}`);
299
302
  const hooks = messageCommandHooks(route, params, message, start, given, this.messageOptions.types);
300
- await this.invokeHandler(this.getInstance(route.controllerClass), route.method, [
301
- message,
302
- params
303
- ], call, hooks);
303
+ runs.push({
304
+ name: `${route.controllerClass.name}.${route.method}`,
305
+ run: (at)=>this.invokeHandler(this.getInstance(route.controllerClass), route.method, [
306
+ message,
307
+ params
308
+ ], at, hooks)
309
+ });
304
310
  }
305
311
  for (const { controllerClass, method } of this.messageListeners){
306
- await this.invokeHandler(this.getInstance(controllerClass), method, [
307
- message
308
- ], call);
312
+ runs.push({
313
+ name: `${controllerClass.name}.${method}`,
314
+ run: (at)=>this.invokeHandler(this.getInstance(controllerClass), method, [
315
+ message
316
+ ], at)
317
+ });
318
+ }
319
+ if (this.messageOptions.handlers === 'concurrent') await this.runTogether(runs, call);
320
+ else await this.runInTurn(runs, call);
321
+ }
322
+ /** Runs a message's handlers one after another, warning once about one that held the rest back for long. */ async runInTurn(runs, call) {
323
+ const warn = this.messageOptions.slowHandlerWarning ?? this.options.warnSlowMessageHandlers ?? true;
324
+ for (const [index, { name, run }] of runs.entries()){
325
+ const started = performance.now();
326
+ await run(call);
327
+ const waiting = runs.length - 1 - index;
328
+ if (warn && waiting > 0) this.warnSlowOnce(name, performance.now() - started, waiting);
309
329
  }
310
330
  }
311
331
  /**
332
+ * Starts a message's handlers together and settles once all have. Each is reported as settled in the order the
333
+ * handlers are listed, whichever settles first, and a call that rejects, rejects this once every one has settled.
334
+ */ async runTogether(runs, call) {
335
+ const { record } = call;
336
+ const reports = runs.map(()=>[]);
337
+ const results = await Promise.allSettled(runs.map(({ run }, index)=>run(record ? {
338
+ ...call,
339
+ record: {
340
+ settled: (...report)=>reports[index].push(report),
341
+ unhandled: (error)=>record.unhandled(error)
342
+ }
343
+ } : call)));
344
+ for (const report of reports.flat())record?.settled(...report);
345
+ const failed = results.find((result)=>result.status === 'rejected');
346
+ if (failed) throw failed.reason;
347
+ }
348
+ /** Warns, once per handler, that it took `SLOW_HANDLER_MS` or longer with `waiting` handlers held back behind it. */ warnSlowOnce(name, took, waiting) {
349
+ if (took < SLOW_HANDLER_MS || this.warnedSlow.has(name)) return;
350
+ this.warnedSlow.add(name);
351
+ const held = waiting === 1 ? 'the listener after it' : `the ${waiting} listeners after it`;
352
+ this.logger.warn(`${name} took ${(took / 1000).toFixed(1)} s on a message, and held back ${held}. ` + "@MeoCord({ messages: { handlers: 'concurrent' } }) runs them side by side; messages: { slowHandlerWarning: false } turns this off.");
353
+ }
354
+ /**
312
355
  * Answers a message that asks the built-in help, when `messages.help` is on and no handler took the message:
313
356
  * through the presenter's `messageHelp` when it has one, else in plain text, once the app's guards allow it.
314
357
  * `known` is the message's starts when they hold the app's prefixes already. Whether it answered, or a
@@ -429,6 +472,7 @@ import { existingResponse } from '../common/response/response-state.js';
429
472
  this.controllerInstancesCache = new Map();
430
473
  /** Handler and name pairs already warned about, so a collision warns once, not on every submit or selection. */ this.warnedCollisions = new Set();
431
474
  /** The handlers already warned about, so each is named once however often it runs. */ this.warnedUnanswered = new Set();
475
+ /** The message handlers already warned about as slow, so each is named once. */ this.warnedSlow = new Set();
432
476
  this.container = options.container;
433
477
  this.controllerClasses = options.controllerClasses;
434
478
  this.messageOptions = options.messageOptions ?? {};
@@ -1,7 +1,7 @@
1
1
  import 'reflect-metadata';
2
2
  import { perHandler, sourcePrototype, stageClasses } from './guard-runner.js';
3
3
  import { bindShared } from './interceptor-runner.js';
4
- import { refuse } from '../util/refusal.util.js';
4
+ import { startupError } from '../util/refusal.util.js';
5
5
  import { isConstructor } from '../util/value.util.js';
6
6
  import { META } from '../util/metadata-keys.js';
7
7
 
@@ -41,7 +41,7 @@ import { META } from '../util/metadata-keys.js';
41
41
  /** Binds a filter as a singleton, after checking it is one. */ function prepareFilter(container, entry) {
42
42
  const cls = filterClass(entry);
43
43
  if (!Reflect.hasOwnMetadata(META.catchTypes, cls)) {
44
- throw refuse(new Error(`${cls.name || 'A filter'}: used as an exception filter, but not decorated with @Catch().`));
44
+ return startupError(new Error(`${cls.name || 'A filter'}: used as an exception filter, but not decorated with @Catch().`));
45
45
  }
46
46
  bindShared(container, cls);
47
47
  }
@@ -1,17 +1,17 @@
1
1
  import 'reflect-metadata';
2
- import { refuse } from '../util/refusal.util.js';
2
+ import { refuse, startupError } from '../util/refusal.util.js';
3
3
  import { setPresenter } from '../common/response/presenter.js';
4
4
  import { responseOf } from '../common/response/response-state.js';
5
5
  import { handlerDefer, nonInteractionHandler, deferMisuseError, startDefer } from './defer.js';
6
- import { perHandler, handlerGuards, runGuards, callGuardedHandler, isGuardWithParams } from './guard-runner.js';
7
- import { prepareInterceptor, handlerInterceptors, bindShared, interceptorClass, runInterceptors } from './interceptor-runner.js';
6
+ import { perHandler, runGuards, callGuardedHandler, handlerGuards, isGuardWithParams } from './guard-runner.js';
7
+ import { prepareInterceptor, handlerInterceptors, bindShared, runInterceptors, interceptorClass } from './interceptor-runner.js';
8
8
  import { inferContextType, HandlerExecutionContext, UnroutedExecutionContext } from '../common/execution-context.js';
9
9
  import { prepareFilter, handlerFilterLevels, filterClass, matchFilter, callFilter } from './filter-runner.js';
10
- import { handlerInputStages, preparePipe, pipeClass, prepareHandlerArgs } from './input-runner.js';
10
+ import { handlerInputStages, preparePipe, prepareHandlerArgs, pipeClass } from './input-runner.js';
11
11
  import { hasObservers, notifyStart, outcomeOf, responsePhaseOf, notifyObservers } from './observer-runner.js';
12
- import { methodCooldowns, handlerCooldowns, peekCooldowns } from './cooldown-runner.js';
12
+ import { handlerCooldowns, peekCooldowns, methodCooldowns } from './cooldown-runner.js';
13
13
  import { Logger } from '../common/logger.js';
14
- import { configureThemes, beginCallTheme } from './theme-runtime.js';
14
+ import { beginCallTheme, configureThemes } from './theme-runtime.js';
15
15
  import { runInThemeScope } from './theme-scope.js';
16
16
  import { getEventHandlers } from '../decorator/event.decorator.js';
17
17
  import { getCommandMap, getMessageHandlers, getReactionHandlers, getAutocompleteHandlers } from '../decorator/controller.decorator.js';
@@ -57,12 +57,16 @@ const NO_GLOBAL_STAGES = {
57
57
  */ function bindAppPresenter(container, app, client) {
58
58
  const options = Reflect.getMetadata(META.appOptions, app);
59
59
  if (!options?.presenter) return undefined;
60
- bindShared(container, options.presenter);
60
+ prepareAppPresenter(container, app);
61
61
  const presenter = container.get(options.presenter);
62
62
  container.bind(APP_PRESENTER).toConstantValue(presenter);
63
63
  if (client) setPresenter(client, presenter);
64
64
  return presenter;
65
65
  }
66
+ /** Binds the app's presenter class as a shared stage, checking it as one, without making it. */ function prepareAppPresenter(container, app) {
67
+ const options = Reflect.getMetadata(META.appOptions, app);
68
+ if (options?.presenter) bindShared(container, options.presenter);
69
+ }
66
70
  /** Where a container keeps the application's presenter. */ const APP_PRESENTER = Symbol('app_presenter');
67
71
  /** The presenter `bindAppPresenter` resolved into `container`, if any. */ function appPresenterOf(container) {
68
72
  return container.isBound(APP_PRESENTER) ? container.get(APP_PRESENTER) : undefined;
@@ -152,18 +156,21 @@ const stagesByGlobals = new WeakMap();
152
156
  /**
153
157
  * Binds the interceptors, filters and pipes every handler of `controllers` uses, and the global ones, as singletons, so
154
158
  * one that cannot be shared, or a filter without `@Catch`, fails at startup; refuses there `@Defer`, `@Validate`,
155
- * `@UsePipe` and `@Cooldown` where they cannot apply, and two classes that keep state under one name.
159
+ * `@UsePipe` and `@Cooldown` where they cannot apply, and two classes that keep state under one name. Binds only; it
160
+ * makes nothing, so it runs with the other startup checks.
156
161
  */ function prepareHandlerStages(container, controllers) {
157
162
  assertDistinctNamesWhereKeyed(controllers);
158
163
  const globals = globalStagesOf(container);
159
- configureThemes(container, globals.theme, controllers, globals.themeFor);
160
164
  for (const entry of globals.interceptors)prepareInterceptor(container, entry);
161
165
  for (const entry of globals.filters)prepareFilter(container, entry);
162
166
  for (const controller of controllers){
163
167
  const prototype = controller.prototype;
164
168
  for (const method of handlerMethods(prototype)){
165
169
  const kind = handlerDefer(prototype, method) ? nonInteractionHandler(prototype, method) : undefined;
166
- if (kind) throw refuse(deferMisuseError(controller.name, method, kind));
170
+ if (kind) {
171
+ startupError(deferMisuseError(controller.name, method, kind));
172
+ continue;
173
+ }
167
174
  for (const entry of handlerInterceptors(prototype, method))prepareInterceptor(container, entry);
168
175
  for (const entry of handlerFilterLevels(prototype, method, []).flat())prepareFilter(container, entry);
169
176
  for (const { entry } of handlerInputStages(prototype, method).pipes)preparePipe(container, entry);
@@ -171,6 +178,10 @@ const stagesByGlobals = new WeakMap();
171
178
  assertInputStagesOnInteractions(controller, prototype);
172
179
  }
173
180
  }
181
+ /** Records the app's theme for the handlers of `controllers`, once the startup checks have passed: it makes the theme cache. */ function prepareThemes(container, controllers) {
182
+ const globals = globalStagesOf(container);
183
+ configureThemes(container, globals.theme, controllers, globals.themeFor);
184
+ }
174
185
  /** Whether a class has state keyed by its name: a cooldown on a handler, or a `@Once` handler. */ function keyedByName(cls) {
175
186
  const prototype = cls.prototype;
176
187
  const handlers = [
@@ -187,7 +198,8 @@ const stagesByGlobals = new WeakMap();
187
198
  for (const cls of classes){
188
199
  const other = byName.get(cls.name);
189
200
  if (other && other !== cls && (keyedByName(cls) || keyedByName(other))) {
190
- throw refuse(new Error(`${cls.name}: two classes have this name; @Cooldown and @Once tell classes apart by name, so they would share ` + `their counts. Rename one of them.`));
201
+ startupError(new Error(`${cls.name}: two classes have this name; @Cooldown and @Once tell classes apart by name, so they would share ` + `their counts. Rename one of them.`));
202
+ continue;
191
203
  }
192
204
  byName.set(cls.name, cls);
193
205
  }
@@ -215,11 +227,12 @@ const stagesByGlobals = new WeakMap();
215
227
  const { schema, pipes } = handlerInputStages(prototype, method);
216
228
  const handler = kind === 'message' ? 'a message handler without a pattern' : `${kind === 'autocomplete' || kind === 'event' ? 'an' : 'a'} ${kind} handler`;
217
229
  if (schema || pipes.length > 0) {
218
- throw refuse(new Error(`${controller.name}.${method}: @Validate and @UsePipe are for interaction and patterned message handlers, ` + `whose options, customId params, modal fields and pattern params they check, and this is ${handler}.`));
230
+ startupError(new Error(`${controller.name}.${method}: @Validate and @UsePipe are for interaction and patterned message handlers, ` + `whose options, customId params, modal fields and pattern params they check, and this is ${handler}.`));
231
+ continue;
219
232
  }
220
233
  // A controller's own @Cooldown skips these handlers; one on the method itself is a mistake.
221
234
  if (kind !== 'message' && methodCooldowns(prototype, method).length > 0) {
222
- throw refuse(new Error(`${controller.name}.${method}: @Cooldown is for interaction and message handlers, and this is ${handler}.`));
235
+ startupError(new Error(`${controller.name}.${method}: @Cooldown is for interaction and message handlers, and this is ${handler}.`));
223
236
  }
224
237
  }
225
238
  }
@@ -462,4 +475,4 @@ const logger = new Logger('ExceptionFilter');
462
475
  }, options);
463
476
  }
464
477
 
465
- export { appPresenterOf, appStages, bindAppPresenter, bindGlobalStages, classDecorators, globalStagesOf, handleUnroutedError, handlerStageClasses, handlerStages, observeUnclaimed, prepareHandlerStages, runHandler, runInAppTheme };
478
+ export { appPresenterOf, appStages, bindAppPresenter, bindGlobalStages, classDecorators, globalStagesOf, handleUnroutedError, handlerStageClasses, handlerStages, observeUnclaimed, prepareAppPresenter, prepareHandlerStages, prepareThemes, runHandler, runInAppTheme };
@@ -97,7 +97,7 @@ const COMMAND_TYPES = new Set([
97
97
  *
98
98
  * @group Controllers
99
99
  * @see {@link HandlerEntry}
100
- * @see {@link https://meocord.dev/docs/4.1/handler-discovery | Handler discovery}
100
+ * @see {@link https://meocord.dev/docs/4.2/handler-discovery | Handler discovery}
101
101
  */ class HandlerRegistry {
102
102
  /**
103
103
  * Lists the registered handlers.
@@ -4,7 +4,7 @@ import { perHandler, injectedTokens, singletonContextError, sourcePrototype, sta
4
4
  import { ExecutionContext } from '../common/execution-context.js';
5
5
  import { makeInjectable } from '../util/injectable.util.js';
6
6
  import { isAppClassToken } from './lifecycle-order.js';
7
- import { refuse } from '../util/refusal.util.js';
7
+ import { startupError } from '../util/refusal.util.js';
8
8
  import { Logger } from '../common/logger.js';
9
9
  import { META } from '../util/metadata-keys.js';
10
10
 
@@ -35,7 +35,7 @@ function isInterceptorWithParams(entry) {
35
35
  });
36
36
  /** Binds `cls` and its unbound dependencies as singletons, refusing any that injects `ExecutionContext`. */ function bindShared(container, cls) {
37
37
  if (container.isBound(cls)) return;
38
- if (injectedTokens(cls).includes(ExecutionContext)) throw refuse(singletonContextError(cls));
38
+ if (injectedTokens(cls).includes(ExecutionContext)) return startupError(singletonContextError(cls));
39
39
  assertStartupChecked(container, cls);
40
40
  makeInjectable(cls);
41
41
  container.bind(cls).toSelf().inSingletonScope();