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
@@ -27,20 +27,21 @@ import { bunDevelopmentValues, bunDevelopmentWarning } from '../util/inherited-e
27
27
  import { assertBuiltForThisPlatform } from '../util/platform.util.js';
28
28
  import { isRegisterOnly } from '../util/registration-mode.util.js';
29
29
  import { injectedTokens, singletonContextError } from './guard-runner.js';
30
- import { appStages, bindGlobalStages, prepareHandlerStages, bindAppPresenter } from './handler-pipeline.js';
30
+ import { appStages, bindGlobalStages, prepareHandlerStages, prepareThemes, bindAppPresenter } from './handler-pipeline.js';
31
31
  import { startupClasses, assertStartupClasses, lateGuardCheck } from './startup-roots.js';
32
32
  import { markStartupChecked, assertStartupChecked } from './startup-checked.js';
33
+ import { makeInjectable } from '../util/injectable.util.js';
33
34
  import { meocordClasses } from './meocord-classes.js';
35
+ import { buildMessageRoutes } from './message-routes.js';
34
36
  import { appObservers, bindObservers } from './observer-runner.js';
35
- import { makeInjectable } from '../util/injectable.util.js';
36
37
  import { isAppClassToken } from './lifecycle-order.js';
37
- import { providerMap, reachableClasses, bindsOwnToken, tokenName, bindProvider, resolutionOrder, isClassProvider, assertProvided, tokenDependencies, resolveProviders } from './providers.js';
38
+ import { reachableClasses, providerMap, bindsOwnToken, tokenName, bindProvider, resolutionOrder, isClassProvider, assertProvided, tokenDependencies, resolveProviders } from './providers.js';
38
39
  import { HandlerRegistry } from './handler-registry.js';
39
40
  import { ShardManager } from './shard-manager.js';
40
- import { assertDistinctCommands, warnUnregisteredCommands, warnInheritedRoutes, warnOverlappingPatterns, warnHandlersOffControllers } from './command-conflicts.js';
41
+ import { warnUnregisteredCommands, warnInheritedRoutes, warnOverlappingPatterns, warnHandlersOffControllers, assertDistinctCommands } from './command-conflicts.js';
41
42
  import { ShardContext, SHARD_CALL_KEY, shardCallHandler } from './shard-context.js';
42
43
  import { registerClientTheme } from './theme-runtime.js';
43
- import { isRefusal, describeRefusal, refuse } from '../util/refusal.util.js';
44
+ import { isRefusal, logStartupErrors, describeRefusal, allDeclaredErrors, declaredErrorsOf, collectStartupErrors, refuse, startupError, stopOnStartupErrors } from '../util/refusal.util.js';
44
45
  import { endFailedShard } from './shard-exit.js';
45
46
  import { isBuiltApplication } from '../util/bundle-entry.util.js';
46
47
  import { META } from '../util/metadata-keys.js';
@@ -51,14 +52,14 @@ import { isShardProcess } from '../util/shard-process.util.js';
51
52
  */ function bindDependencies(container, cls, providers) {
52
53
  // A provided class is bound by its own provider, wherever in the list that provider comes
53
54
  if (container.isBound(cls) || providers.has(cls)) return;
54
- if (injectedTokens(cls).includes(ExecutionContext)) throw refuse(singletonContextError(cls));
55
+ if (injectedTokens(cls).includes(ExecutionContext)) return startupError(singletonContextError(cls));
55
56
  assertStartupChecked(container, cls);
56
57
  makeInjectable(cls);
57
58
  container.bind(cls).toSelf().inSingletonScope();
58
59
  // By constructor type or @inject token; an interface-typed parameter records Object, which is skipped
59
60
  for (const dep of injectedTokens(cls)){
60
- if (dep === Translator && !container.isBound(Translator)) throw refuse(missingTranslatorError(cls));
61
- if (isAppClassToken(dep)) bindDependencies(container, dep, providers);
61
+ if (dep === Translator && !container.isBound(Translator)) startupError(missingTranslatorError(cls));
62
+ else if (isAppClassToken(dep)) bindDependencies(container, dep, providers);
62
63
  }
63
64
  }
64
65
  /**
@@ -124,34 +125,51 @@ import { isShardProcess } from '../util/shard-process.util.js';
124
125
  * load, naming the file and the reason, with what to do about it, when a provider cannot
125
126
  * be bound, such as one for a token MeoCord binds itself, when two handlers take one command, or two builder
126
127
  * classes build one, naming both, and for any other mistake it refuses as the app loads, such as two component
127
- * patterns that match the same customIds. In a built application it is logged first, with where in the source it
128
- * comes from, so `isExplainedError()` tells a caller not to log it again; in a shard of process sharding, the
129
- * manager logs it.
128
+ * patterns that match the same customIds. Each check reports every error it finds, and the first is thrown,
129
+ * unchanged. When there are several, each is logged first, with where in the source it comes from; a lone one is
130
+ * logged in a built application. Either way `isExplainedError()` then tells a caller not to log it again; in a
131
+ * shard of process sharding, the manager logs it. With `startupErrors: 'all'` in `meocord.config.ts`, the errors
132
+ * decorators kept are among them.
130
133
  */ static create(target) {
131
134
  try {
132
135
  return this.createApplication(target);
133
136
  } catch (error) {
134
137
  // A shard ends, so its manager restarts it, or stops every shard for a refusal, which it logs
135
138
  if (isShardProcess()) endFailedShard(error);
136
- else if (isBuiltApplication() && isRefusal(error) && !isExplainedError(error)) {
137
- this.logger.error(describeRefusal(error, process.cwd()));
138
- markExplained(error);
139
+ else if (isRefusal(error) && !isExplainedError(error)) {
140
+ // Every startup error found together is logged, in a test too; a lone one is logged in a built application
141
+ if (logStartupErrors(error, (text)=>this.logger.error(text), 'the bot did not start')) markExplained(error);
142
+ else if (isBuiltApplication()) {
143
+ this.logger.error(describeRefusal(error, process.cwd()));
144
+ markExplained(error);
145
+ }
139
146
  }
140
147
  throw error;
141
148
  }
142
149
  }
143
150
  static createApplication(target) {
144
151
  const options = Reflect.getMetadata(META.appOptions, target);
152
+ // With startupErrors: 'all', what the decorators kept: a built bot's every one, else the app's and its controllers'
153
+ const declared = isBuiltApplication() ? allDeclaredErrors() : declaredErrorsOf([
154
+ target,
155
+ ...options?.controllers ?? []
156
+ ]);
145
157
  if (!options) {
158
+ // A refused @MeoCord stores no options, so its own errors say why
159
+ collectStartupErrors(()=>declared.forEach(startupError));
146
160
  throw refuse(new Error(`${typeof target === 'function' ? target.name : String(target)}: not decorated with @MeoCord(), so there is no app to create.`));
147
161
  }
148
162
  const meocordConfig = loadMeoCordConfig();
149
163
  if (!meocordConfig) {
150
164
  throw refuse(new Error(compiledConfigMessage()));
151
165
  }
152
- // Before any of the three ways a bot runs, so none registers or dispatches a command only one handler could take
153
- assertDistinctCommands(options.controllers);
154
- const providers = providerMap(options.providers ?? [], '@MeoCord({ providers })');
166
+ // Before any of the three ways a bot runs, so none registers or dispatches a command only one handler could take; every
167
+ // error each check finds is reported, the first thrown
168
+ const providers = collectStartupErrors(()=>{
169
+ declared.forEach(startupError);
170
+ assertDistinctCommands(options.controllers);
171
+ return providerMap(options.providers ?? [], '@MeoCord({ providers })');
172
+ });
155
173
  // A themeFor class is bound on its own, as the cooldown store is, and resolved like a service
156
174
  const themeResolver = themeResolverClass(options.themeFor);
157
175
  // The app's classes and every class they inject, which the container binds
@@ -193,153 +211,174 @@ import { isShardProcess } from '../util/shard-process.util.js';
193
211
  config: meocordConfig
194
212
  });
195
213
  }
196
- // Every class the app runs, its stages and presenter included, checked before binding, where inversify would
197
- // otherwise fail first with an error about compiler options, or a guard only at its first call
198
- const stages = appStages(target);
199
- const runs = startupClasses({
200
- controllers: options.controllers,
201
- services: options.services ?? [],
202
- providers,
203
- stages,
204
- presenter: options.presenter,
205
- cooldownStore: options.cooldownStore,
206
- themeFor: options.themeFor,
207
- observers: appObservers(target)
208
- });
209
- assertStartupClasses(runs, {
210
- translator: options.i18n !== undefined || providers.has(Translator)
211
- });
212
- const container = new Container();
213
- const translator = options.i18n !== undefined || providers.has(Translator);
214
- markStartupChecked(container, [
215
- ...runs.classes,
216
- ...meocordClasses()
217
- ], lateGuardCheck(providers, translator));
218
- bindGlobalStages(container, stages);
219
- // Bind the Discord client as a constant value
220
- const discordClient = new Client(clientOptionsWithSharding(this.effectiveConfig(meocordConfig), options.clientOptions));
221
- container.bind(Client).toConstantValue(discordClient);
222
- if (options.i18n) {
223
- container.bind(Translator).toConstantValue(options.i18n);
224
- bindsOwnToken(container, Translator);
225
- }
226
- // Bound before the app's classes, so a class that injects it gets this instance; filled once they are bound
227
- const appClasses = [];
228
- container.bind(HandlerRegistry).toConstantValue(new HandlerRegistry(appClasses, options.messages, ()=>options.i18n));
229
- container.bind(ShardContext).toConstantValue(new ShardContext(discordClient, (service, method, args)=>Reflect.get(discordClient, SHARD_CALL_KEY)(service, method, args)));
230
- bindsOwnToken(container, HandlerRegistry);
231
- bindsOwnToken(container, ShardContext);
232
- container.bind(COOLDOWN_POLICY).toConstantValue(cooldownPolicyFrom(options));
233
- // A store of the app's own is resolved like a service, so it can inject its client
234
- if (options.cooldownStore) {
235
- bindDependencies(container, options.cooldownStore, providers);
236
- container.bind(CooldownStore).toService(options.cooldownStore);
237
- } else {
238
- container.bind(CooldownStore).toConstantValue(new MemoryCooldownStore());
239
- }
240
- // A class that injects the token depends on the app's store, the one unit whose hooks run
241
- bindsOwnToken(container, CooldownStore, options.cooldownStore);
242
- // After MeoCord's own tokens, which a provider may not replace, and before the app's classes, so
243
- // a class token that is provided is not also bound as itself
244
- for (const [token, provider] of providers){
245
- if (container.isBound(token)) {
246
- throw refuse(new Error(`${target.name}: @MeoCord({ providers }) cannot provide ${tokenName(token)}, which MeoCord binds itself.`));
214
+ // The checks of what the app binds and runs report every error each finds, and the first is thrown
215
+ return collectStartupErrors(()=>{
216
+ // Every class the app runs, its stages and presenter included, checked before binding, where inversify would
217
+ // otherwise fail first with an error about compiler options, or a guard only at its first call
218
+ const stages = appStages(target);
219
+ const runs = startupClasses({
220
+ controllers: options.controllers,
221
+ services: options.services ?? [],
222
+ providers,
223
+ stages,
224
+ presenter: options.presenter,
225
+ cooldownStore: options.cooldownStore,
226
+ themeFor: options.themeFor,
227
+ observers: appObservers(target)
228
+ });
229
+ assertStartupClasses(runs, {
230
+ translator: options.i18n !== undefined || providers.has(Translator)
231
+ });
232
+ // Outside a built application, the kept errors of the stages and other classes the app's classes reach
233
+ if (!isBuiltApplication()) declaredErrorsOf(runs.classes).forEach(startupError);
234
+ // The checks of the wiring below run on classes that passed these, so a mistake in one is not reported again there
235
+ stopOnStartupErrors();
236
+ // The checks bind into this container and change nothing else: what they bind is made once all have passed
237
+ const container = new Container();
238
+ const translator = options.i18n !== undefined || providers.has(Translator);
239
+ markStartupChecked(container, [
240
+ ...runs.classes,
241
+ ...meocordClasses()
242
+ ], lateGuardCheck(providers, translator));
243
+ bindGlobalStages(container, stages);
244
+ // Bound now, so no provider stands in for it; made once the checks pass, the one Client the app logs in with
245
+ const clientOptions = clientOptionsWithSharding(this.effectiveConfig(meocordConfig), options.clientOptions);
246
+ container.bind(Client).toDynamicValue(()=>new Client(clientOptions)).inSingletonScope();
247
+ if (options.i18n) {
248
+ container.bind(Translator).toConstantValue(options.i18n);
249
+ bindsOwnToken(container, Translator);
247
250
  }
248
- bindProvider(container, provider, (cls)=>bindDependencies(container, cls, providers));
249
- }
250
- // Bind all controllers and their transitive dependencies
251
- for (const ctrl of options.controllers){
252
- bindDependencies(container, ctrl, providers);
253
- }
254
- for (const svc of options.services ?? []){
255
- bindDependencies(container, svc, providers);
256
- }
257
- if (themeResolver) bindDependencies(container, themeResolver, providers);
258
- // Observers are services too: bound here so their lifecycle hooks run in dependency order
259
- const observers = appObservers(target);
260
- for (const observer of observers)bindDependencies(container, observer, providers);
261
- // Providers first, then the services, the controllers and the observers, each after what it depends on. The app's
262
- // own classes, which a class injecting CooldownStore does not make of the store
263
- const order = resolutionOrder(container, providers, [
264
- ...providers.keys(),
265
- ...options.services ?? [],
266
- ...themeResolver ? [
267
- themeResolver
268
- ] : [],
269
- ...options.controllers,
270
- ...observers
271
- ], {
272
- followOwnTokens: false
273
- });
274
- appClasses.push(...order.filter((token)=>{
275
- const provider = providers.get(token);
276
- return isAppClassToken(token) && (!provider || isClassProvider(provider) && provider.useClass === token);
277
- }));
278
- assertProvided(container, providers, runs.classes, '@MeoCord({ providers })');
279
- // The store first, after only what it injects: it is ready before anything a call reaches, and shuts down last
280
- const store = options.cooldownStore;
281
- const lifecycle = (store ? resolutionOrder(container, providers, [
282
- store,
283
- ...order
284
- ]) : order).map((token)=>({
285
- token,
286
- name: tokenName(token),
287
- dependencies: tokenDependencies(container, providers, token),
288
- ...token === store && {
289
- cooldownStore: true
290
- }
291
- }));
292
- // What ShardContext.call reaches: the app's classes, and the classes its providers stand in for
293
- const callable = [
294
- ...new Set([
295
- ...appClasses,
296
- ...[
297
- ...providers.keys()
298
- ].filter(isAppClassToken)
299
- ])
300
- ];
301
- // From another shard a call names its class, so with process sharding every name it can reach is one class's
302
- if (meocordConfig.sharding?.mode === 'process') {
303
- const names = new Set();
304
- for (const cls of callable){
305
- if (names.has(cls.name)) {
306
- throw refuse(new Error(`${cls.name}: two classes have this name; with process sharding, ShardContext.call finds a class in another ` + 'shard by its name, so give each controller, service and provided class a distinct name.'));
251
+ // Bound before the app's classes, so a class that injects it gets this instance; filled once they are bound
252
+ const appClasses = [];
253
+ container.bind(HandlerRegistry).toConstantValue(new HandlerRegistry(appClasses, options.messages, ()=>options.i18n));
254
+ container.bind(ShardContext).toDynamicValue(()=>{
255
+ const client = container.get(Client);
256
+ return new ShardContext(client, (service, method, args)=>Reflect.get(client, SHARD_CALL_KEY)(service, method, args));
257
+ }).inSingletonScope();
258
+ bindsOwnToken(container, HandlerRegistry);
259
+ bindsOwnToken(container, ShardContext);
260
+ container.bind(COOLDOWN_POLICY).toConstantValue(cooldownPolicyFrom(options));
261
+ // A store of the app's own is resolved like a service, so it can inject its client
262
+ if (options.cooldownStore) {
263
+ bindDependencies(container, options.cooldownStore, providers);
264
+ container.bind(CooldownStore).toService(options.cooldownStore);
265
+ } else {
266
+ container.bind(CooldownStore).toConstantValue(new MemoryCooldownStore());
267
+ }
268
+ // A class that injects the token depends on the app's store, the one unit whose hooks run
269
+ bindsOwnToken(container, CooldownStore, options.cooldownStore);
270
+ // After MeoCord's own tokens, which a provider may not replace, and before the app's classes, so
271
+ // a class token that is provided is not also bound as itself
272
+ for (const [token, provider] of providers){
273
+ if (container.isBound(token)) {
274
+ startupError(new Error(`${target.name}: @MeoCord({ providers }) cannot provide ${tokenName(token)}, which MeoCord binds itself.`));
275
+ continue;
307
276
  }
308
- names.add(cls.name);
277
+ bindProvider(container, provider, (cls)=>bindDependencies(container, cls, providers));
309
278
  }
310
- }
311
- Reflect.set(discordClient, SHARD_CALL_KEY, shardCallHandler(container, ()=>callable, 'this app'));
312
- // Stamp every class the app runs with the container, so @UseGuard resolves guards on a direct call to any of them
313
- for (const cls of runs.classes){
314
- Reflect.defineMetadata(META.container, container, cls);
315
- }
316
- prepareHandlerStages(container, appClasses);
317
- bindObservers(container, observers);
318
- // Run by start() before it logs in: what may inject a provided value is resolved once every factory
319
- // has made its value, including those that return a promise
320
- const logger = this.logger;
321
- const startup = async ()=>{
322
- try {
323
- await resolveProviders(container, providers, order);
324
- } catch (error) {
325
- logger.error(`${error.message}. The bot cannot start without it.`);
326
- logger.debug('Provider failure:', error.cause);
327
- markExplained(error);
328
- throw error;
279
+ // Bind all controllers and their transitive dependencies
280
+ for (const ctrl of options.controllers){
281
+ bindDependencies(container, ctrl, providers);
329
282
  }
330
- // The listed services are made now, so constructors that attach listeners or connect run before login
331
283
  for (const svc of options.services ?? []){
332
- container.get(svc);
284
+ bindDependencies(container, svc, providers);
333
285
  }
334
- if (shardingRole(meocordConfig) === 'shard' && container.get(CooldownStore) instanceof MemoryCooldownStore) {
335
- warnPerShardCooldowns(options.controllers, logger);
286
+ if (themeResolver) bindDependencies(container, themeResolver, providers);
287
+ // Observers are services too: bound here so their lifecycle hooks run in dependency order
288
+ const observers = appObservers(target);
289
+ for (const observer of observers)bindDependencies(container, observer, providers);
290
+ // What follows walks what these bound, so a class refused here is not reported again as missing
291
+ stopOnStartupErrors();
292
+ // Providers first, then the services, the controllers and the observers, each after what it depends on. The app's
293
+ // own classes, which a class injecting CooldownStore does not make of the store
294
+ const order = resolutionOrder(container, providers, [
295
+ ...providers.keys(),
296
+ ...options.services ?? [],
297
+ ...themeResolver ? [
298
+ themeResolver
299
+ ] : [],
300
+ ...options.controllers,
301
+ ...observers
302
+ ], {
303
+ followOwnTokens: false
304
+ });
305
+ appClasses.push(...order.filter((token)=>{
306
+ const provider = providers.get(token);
307
+ return isAppClassToken(token) && (!provider || isClassProvider(provider) && provider.useClass === token);
308
+ }));
309
+ assertProvided(container, providers, runs.classes, '@MeoCord({ providers })');
310
+ // The store first, after only what it injects: it is ready before anything a call reaches, and shuts down last
311
+ const store = options.cooldownStore;
312
+ const lifecycle = (store ? resolutionOrder(container, providers, [
313
+ store,
314
+ ...order
315
+ ]) : order).map((token)=>({
316
+ token,
317
+ name: tokenName(token),
318
+ dependencies: tokenDependencies(container, providers, token),
319
+ ...token === store && {
320
+ cooldownStore: true
321
+ }
322
+ }));
323
+ // What ShardContext.call reaches: the app's classes, and the classes its providers stand in for
324
+ const callable = [
325
+ ...new Set([
326
+ ...appClasses,
327
+ ...[
328
+ ...providers.keys()
329
+ ].filter(isAppClassToken)
330
+ ])
331
+ ];
332
+ // From another shard a call names its class, so with process sharding every name it can reach is one class's
333
+ if (meocordConfig.sharding?.mode === 'process') {
334
+ const names = new Set();
335
+ for (const cls of callable){
336
+ if (names.has(cls.name)) {
337
+ startupError(new Error(`${cls.name}: two classes have this name; with process sharding, ShardContext.call finds a class in another ` + 'shard by its name, so give each controller, service and provided class a distinct name.'));
338
+ continue;
339
+ }
340
+ names.add(cls.name);
341
+ }
336
342
  }
337
- bindAppPresenter(container, target, discordClient);
338
- registerClientTheme(discordClient, container);
339
- registerClientTranslator(discordClient, options.i18n);
340
- };
341
- return new MeoCordApp(options.controllers, container, discordClient, meocordConfig.discordToken, options.activities, appClasses, meocordConfig.shutdownTimeout, startup, lifecycle, options.messages, // In development only, unless the app says otherwise
342
- options.warnUnanswered ?? process.env.NODE_ENV === 'development');
343
+ prepareHandlerStages(container, appClasses);
344
+ bindObservers(container, observers);
345
+ // Read here with the rest, so a pattern that cannot be read is reported with them, before the dispatcher warns
346
+ buildMessageRoutes(options.controllers, options.messages);
347
+ // Every check has run, and touched only the container: from here the app is made, once none found an error
348
+ stopOnStartupErrors();
349
+ const discordClient = container.get(Client);
350
+ Reflect.set(discordClient, SHARD_CALL_KEY, shardCallHandler(container, ()=>callable, 'this app'));
351
+ // Stamp every class the app runs with the container, so @UseGuard resolves guards on a direct call to any of them
352
+ for (const cls of runs.classes){
353
+ Reflect.defineMetadata(META.container, container, cls);
354
+ }
355
+ prepareThemes(container, appClasses);
356
+ // Run by start() before it logs in: what may inject a provided value is resolved once every factory
357
+ // has made its value, including those that return a promise
358
+ const logger = this.logger;
359
+ const startup = async ()=>{
360
+ try {
361
+ await resolveProviders(container, providers, order);
362
+ } catch (error) {
363
+ logger.error(`${error.message}. The bot cannot start without it.`);
364
+ logger.debug('Provider failure:', error.cause);
365
+ markExplained(error);
366
+ throw error;
367
+ }
368
+ // The listed services are made now, so constructors that attach listeners or connect run before login
369
+ for (const svc of options.services ?? []){
370
+ container.get(svc);
371
+ }
372
+ if (shardingRole(meocordConfig) === 'shard' && container.get(CooldownStore) instanceof MemoryCooldownStore) {
373
+ warnPerShardCooldowns(options.controllers, logger);
374
+ }
375
+ bindAppPresenter(container, target, discordClient);
376
+ registerClientTheme(discordClient, container);
377
+ registerClientTranslator(discordClient, options.i18n);
378
+ };
379
+ return new MeoCordApp(options.controllers, container, discordClient, meocordConfig.discordToken, options.activities, appClasses, meocordConfig.shutdownTimeout, startup, lifecycle, options.messages, // In development only, unless the app says otherwise
380
+ options.warnUnanswered ?? process.env.NODE_ENV === 'development');
381
+ });
343
382
  }
344
383
  }
345
384
  MeoCordFactory.logger = new Logger();
@@ -2,7 +2,7 @@ import { getMessageHandlers } from '../decorator/controller.decorator.js';
2
2
  import { routeSpecificity } from './route-specificity.js';
3
3
  import { isKnownParamType, BUILT_IN_TYPES, isGuildType, fitsParamType } from './message-params.js';
4
4
  import { isSpace, splitFlagWords, splitWords, restFrom } from './message-words.js';
5
- import { refuse } from '../util/refusal.util.js';
5
+ import { startupError } from '../util/refusal.util.js';
6
6
 
7
7
  /** Whether a route's scope lets it run where the message was sent. */ const fitsScope = (scope, inGuild)=>inGuild === undefined || scope === 'any' || scope === 'guild' === inGuild;
8
8
  const PARAM = /^\{(\w+)(?::([\w-]+(?:\|[\w-]+)*))?(\.\.\.)?(\?)?\}$/;
@@ -201,7 +201,8 @@ const FLAG = /^\{--(\w+)(?::([\w-]+(?:\|[\w-]+)*))?(\?)?\}$/;
201
201
  if (own.hidden !== undefined && typeof own.hidden !== 'boolean') throw new Error(`hidden is true or false, not ${JSON.stringify(own.hidden)}.`);
202
202
  aliases = aliasPatterns(pattern, parsed.tokens, own.aliases);
203
203
  } catch (error) {
204
- throw refuse(new Error(`${controllerClass.name}.${method}: @MessageHandler('${pattern}'): ${error.message}`));
204
+ startupError(new Error(`${controllerClass.name}.${method}: @MessageHandler('${pattern}'): ${error.message}`));
205
+ continue;
205
206
  }
206
207
  const shared = {
207
208
  controllerClass,
@@ -240,7 +241,7 @@ const FLAG = /^\{--(\w+)(?::([\w-]+(?:\|[\w-]+)*))?(\?)?\}$/;
240
241
  routes.splice(j--, 1);
241
242
  continue;
242
243
  }
243
- throw refuse(new Error(`${a.controllerClass.name}.${a.method}: ${describeRoute(a)} and ${describeRoute(b)} in ${b.controllerClass.name}.${b.method} ` + `match the same messages, so only one of them could ever answer those. Change one pattern, or give one its own prefix.`));
244
+ startupError(new Error(`${a.controllerClass.name}.${a.method}: ${describeRoute(a)} and ${describeRoute(b)} in ${b.controllerClass.name}.${b.method} ` + `match the same messages, so only one of them could ever answer those. Change one pattern, or give one its own prefix.`));
244
245
  }
245
246
  }
246
247
  return routes;
@@ -4,16 +4,17 @@ import { Logger } from '../common/logger.js';
4
4
  import { bindShared } from './interceptor-runner.js';
5
5
  import { appliesTo } from './stage-scope.js';
6
6
  import { respond } from '../common/response/response-state.js';
7
- import { refuse } from '../util/refusal.util.js';
7
+ import { startupError } from '../util/refusal.util.js';
8
8
  import { META } from '../util/metadata-keys.js';
9
9
 
10
10
  /** Where a container keeps the observers its calls are reported to, in order. */ const OBSERVERS = Symbol('observers');
11
11
  const logger = new Logger('Observer');
12
- /** Throws unless every entry is an `@Observer` class, naming where it was listed. */ function assertObservers(where, observers) {
12
+ /** Reports each entry that is not an `@Observer` class, naming where it was listed. */ function assertObservers(where, observers) {
13
13
  for (const observer of observers){
14
14
  if (typeof observer !== 'function' || !Reflect.getMetadata(META.observerClass, observer)) {
15
15
  const name = typeof observer === 'function' ? observer.name : String(observer);
16
- throw refuse(new Error(`${where} takes classes decorated with @Observer(), not ${name}.`));
16
+ startupError(new Error(`${where} takes classes decorated with @Observer(), not ${name}.`));
17
+ continue;
17
18
  }
18
19
  }
19
20
  }
@@ -3,7 +3,7 @@ import { injectedTokens, undecoratedConstructor, untypedParameter } from './guar
3
3
  import { meocordClasses, meocordClassAdvice } from './meocord-classes.js';
4
4
  import { isAppClassToken } from './lifecycle-order.js';
5
5
  import { makeInjectable } from '../util/injectable.util.js';
6
- import { refuse } from '../util/refusal.util.js';
6
+ import { startupError, refuse } from '../util/refusal.util.js';
7
7
 
8
8
  const isValueProvider = (provider)=>'useValue' in provider;
9
9
  const isClassProvider = (provider)=>'useClass' in provider;
@@ -15,9 +15,9 @@ const isFactoryProvider = (provider)=>'useFactory' in provider;
15
15
  }
16
16
  const isToken = (value)=>typeof value === 'function' || typeof value === 'string' || typeof value === 'symbol';
17
17
  /**
18
- * Checks each provider's shape and indexes them by token, throwing on the first that cannot be bound, such as one with
19
- * no token, not exactly one of `useValue`, `useClass` and `useFactory`, one that injects `ExecutionContext`, or a
20
- * token provided twice.
18
+ * Checks each provider's shape and indexes them by token, reporting each that cannot be bound, such as one with no
19
+ * token, not exactly one of `useValue`, `useClass` and `useFactory`, one that injects `ExecutionContext`, or a token
20
+ * provided twice, as a startup error; see `startupError`.
21
21
  */ function providerMap(providers, where) {
22
22
  const map = new Map();
23
23
  for (const provider of providers){
@@ -26,10 +26,12 @@ const isToken = (value)=>typeof value === 'function' || typeof value === 'string
26
26
  if (typeof entry === 'function') {
27
27
  const name = tokenName(entry);
28
28
  const listing = where.includes('@MeoCord') ? 'list a service in @MeoCord({ services })' : 'a class a controller or service injects is bound for you, so it needs no listing';
29
- throw refuse(new Error(`${where}: ${name} is a class, not a provider: ${listing}. To put something in its place, write ` + `{ provide: ${name}, useValue } or { provide: ${name}, useClass }.`));
29
+ startupError(new Error(`${where}: ${name} is a class, not a provider: ${listing}. To put something in its place, write ` + `{ provide: ${name}, useValue } or { provide: ${name}, useClass }.`));
30
+ continue;
30
31
  }
31
32
  if (!entry || typeof entry !== 'object' || !isToken(entry.provide)) {
32
- throw refuse(new Error(`${where}: a provider has no token: set provide to a class, a string or a symbol.`));
33
+ startupError(new Error(`${where}: a provider has no token: set provide to a class, a string or a symbol.`));
34
+ continue;
33
35
  }
34
36
  const name = tokenName(entry.provide);
35
37
  const kinds = [
@@ -38,26 +40,35 @@ const isToken = (value)=>typeof value === 'function' || typeof value === 'string
38
40
  'useFactory'
39
41
  ].filter((kind)=>kind in entry);
40
42
  if (kinds.length !== 1) {
41
- throw refuse(new Error(`${where}: the provider for ${name} needs exactly one of useValue, useClass and useFactory.`));
43
+ startupError(new Error(`${where}: the provider for ${name} needs exactly one of useValue, useClass and useFactory.`));
44
+ continue;
42
45
  }
43
46
  if ('useClass' in entry && typeof entry.useClass !== 'function') {
44
- throw refuse(new Error(`${where}: the provider for ${name} has a useClass that is not a class.`));
47
+ startupError(new Error(`${where}: the provider for ${name} has a useClass that is not a class.`));
48
+ continue;
45
49
  }
46
50
  if ('useClass' in entry && injectedTokens(entry.useClass).includes(ExecutionContext)) {
47
- throw refuse(new Error(`${where}: the provider for ${name} uses ${tokenName(entry.useClass)}, which injects ExecutionContext, but it is ` + "made once and shared, so it would keep the first call's context for every later call. Inject " + 'ExecutionContext only into guards.'));
51
+ startupError(new Error(`${where}: the provider for ${name} uses ${tokenName(entry.useClass)}, which injects ExecutionContext, but it is ` + "made once and shared, so it would keep the first call's context for every later call. Inject " + 'ExecutionContext only into guards.'));
52
+ continue;
48
53
  }
49
54
  if ('useFactory' in entry) {
50
55
  if (typeof entry.useFactory !== 'function') {
51
- throw refuse(new Error(`${where}: the provider for ${name} has a useFactory that is not a function.`));
56
+ startupError(new Error(`${where}: the provider for ${name} has a useFactory that is not a function.`));
57
+ continue;
52
58
  }
53
59
  if (entry.inject !== undefined && !(Array.isArray(entry.inject) && entry.inject.every(isToken))) {
54
- throw refuse(new Error(`${where}: the provider for ${name} has an inject that is not a list of tokens.`));
60
+ startupError(new Error(`${where}: the provider for ${name} has an inject that is not a list of tokens.`));
61
+ continue;
55
62
  }
56
63
  if (entry.inject?.includes(ExecutionContext)) {
57
- throw refuse(new Error(`${where}: the provider for ${name} injects ExecutionContext, but its factory runs once and its value is ` + "shared, so it would keep the first call's context for every later call. Inject ExecutionContext only into guards."));
64
+ startupError(new Error(`${where}: the provider for ${name} injects ExecutionContext, but its factory runs once and its value is ` + "shared, so it would keep the first call's context for every later call. Inject ExecutionContext only into guards."));
65
+ continue;
58
66
  }
59
67
  }
60
- if (map.has(entry.provide)) throw refuse(new Error(`${where}: ${name} is provided twice.`));
68
+ if (map.has(entry.provide)) {
69
+ startupError(new Error(`${where}: ${name} is provided twice.`));
70
+ continue;
71
+ }
61
72
  map.set(entry.provide, provider);
62
73
  }
63
74
  return map;
@@ -110,22 +121,23 @@ const isToken = (value)=>typeof value === 'function' || typeof value === 'string
110
121
  for (const dependency of inject)if (isAppClassToken(dependency)) bindClass(dependency);
111
122
  }
112
123
  /**
113
- * Throws when a class or a factory asks for a string or symbol token that nothing binds, naming both,
114
- * rather than failing when the class is first resolved. A class token is bound on demand, so it is
115
- * never missing.
124
+ * Reports a class or a factory that asks for a string or symbol token nothing binds, naming both, rather than failing
125
+ * when the class is first resolved. A class token is bound on demand, so it is never missing.
116
126
  */ function assertProvided(container, providers, classes, where) {
117
127
  const missing = (token)=>(typeof token === 'string' || typeof token === 'symbol') && !container.isBound(token);
118
128
  for (const cls of classes){
119
129
  const token = injectedTokens(cls).find(missing);
120
130
  if (token !== undefined) {
121
- throw refuse(new Error(`${cls.name}: it injects ${tokenName(token)}, which nothing provides: add a provider for it to ${where}.`));
131
+ startupError(new Error(`${cls.name}: it injects ${tokenName(token)}, which nothing provides: add a provider for it to ${where}.`));
132
+ continue;
122
133
  }
123
134
  }
124
135
  for (const [provided, provider] of providers){
125
136
  const dependencies = isFactoryProvider(provider) ? provider.inject ?? [] : isClassProvider(provider) ? injectedTokens(provider.useClass) : [];
126
137
  const token = dependencies.find(missing);
127
138
  if (token !== undefined) {
128
- throw refuse(new Error(`${tokenName(provided)}: its provider injects ${tokenName(token)}, which nothing provides: add a provider for it to ${where}.`));
139
+ startupError(new Error(`${tokenName(provided)}: its provider injects ${tokenName(token)}, which nothing provides: add a provider for it to ${where}.`));
140
+ continue;
129
141
  }
130
142
  }
131
143
  }
@@ -158,16 +170,21 @@ const isToken = (value)=>typeof value === 'function' || typeof value === 'string
158
170
  */ function assertTypedParameters(classes, decorators) {
159
171
  for (const cls of classes){
160
172
  const instead = meocordClassAdvice(cls);
161
- if (instead && undecoratedConstructor(cls)) throw refuse(new Error(`${cls.name}: MeoCord does not inject it; ${instead}.`));
173
+ if (instead && undecoratedConstructor(cls)) {
174
+ startupError(new Error(`${cls.name}: MeoCord does not inject it; ${instead}.`));
175
+ continue;
176
+ }
162
177
  if (undecoratedConstructor(cls)) {
163
178
  const decorator = decorators.get(cls);
164
- throw refuse(new Error(`${cls.name}: its constructor takes parameters, but ${cls.name} has no decorator, so TypeScript recorded none of ` + `their types and it cannot be created. ${decorator ? `Decorate it with ${decorator}.` : 'Decorate it with @Service(), or give a class from a package a provider in @MeoCord({ providers }).'}`));
179
+ startupError(new Error(`${cls.name}: its constructor takes parameters, but ${cls.name} has no decorator, so TypeScript recorded none of ` + `their types and it cannot be created. ${decorator ? `Decorate it with ${decorator}.` : 'Decorate it with @Service(), or give a class from a package a provider in @MeoCord({ providers }).'}`));
180
+ continue;
165
181
  }
166
182
  const index = untypedParameter(cls);
167
183
  if (index === -1) continue;
168
184
  const injectors = classes.filter((other)=>other !== cls && injectedTokens(other).includes(cls)).map((other)=>other.name);
169
185
  const injectedBy = injectors.length === 0 ? '' : ` (${injectors.length === 1 ? injectors[0] : `${injectors.slice(0, -1).join(', ')} and ${injectors.at(-1)}`} ` + `inject${injectors.length === 1 ? 's' : ''} ${cls.name})`;
170
- throw refuse(new Error(`${cls.name}: parameter ${index + 1} of its constructor has no runtime type, so it cannot be created. Usually ` + `${cls.name} and a class it injects import each other${injectedBy}, or the parameter is typed with an ` + 'interface or an `import type`. Move what they both need into a third service, or inject the parameter ' + 'with @Inject(token).'));
186
+ startupError(new Error(`${cls.name}: parameter ${index + 1} of its constructor has no runtime type, so it cannot be created. Usually ` + `${cls.name} and a class it injects import each other${injectedBy}, or the parameter is typed with an ` + 'interface or an `import type`. Move what they both need into a third service, or inject the parameter ' + 'with @Inject(token).'));
187
+ continue;
171
188
  }
172
189
  }
173
190
  /** For each container, the tokens MeoCord binds itself, each to the app's class it stands for, if any. */ const ownTokens = new WeakMap();
@@ -1,8 +1,8 @@
1
1
  /**
2
- * How specific a route pattern is, for ranking the patterns that can match one input: more literal
3
- * text first, then a fixed length before one taking the rest, then no optional param before one, then
4
- * fewer params, then narrower typed params, whose segments take fewer values. Component patterns count literal
5
- * characters; message patterns count literal words.
2
+ * How specific a route pattern is, for ranking the message patterns that can match one message: more literal
3
+ * words first, then a fixed length before one taking the rest, then no optional param before one, then
4
+ * fewer params, then narrower typed params, whose words take fewer values. Component patterns count literal
5
+ * characters, for CommandMeta.specificity alone.
6
6
  */ function routeSpecificity({ literals, params, rest = false, optional = false, typed = 0 }) {
7
7
  return literals * 1000 - (rest ? 500 : 0) - (optional ? 250 : 0) - params + typed / 1000;
8
8
  }