meocord 4.1.2 → 4.2.1

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 (140) hide show
  1. package/CHANGELOG.md +362 -0
  2. package/THIRD_PARTY_NOTICES.md +14 -13
  3. package/dist/cjs/_shared/{controller.decorator-BywwkU9O.cjs → controller.decorator-XKMlcUZz.cjs} +669 -185
  4. package/dist/cjs/_shared/{providers-BMMLp2SN.cjs → providers-BZgJyfZS.cjs} +218 -75
  5. package/dist/cjs/_shared/{sharded-cooldown-store-B65iaiAy.cjs → sharded-cooldown-store-CsJJyCZd.cjs} +10 -7
  6. package/dist/cjs/_shared/{startup-roots-lFc69AxC.cjs → startup-roots-CgfPQrNl.cjs} +374 -132
  7. package/dist/cjs/_shared/{translate-error-BnM8FbSH.cjs → translate-error-BUAR1WYG.cjs} +21 -7
  8. package/dist/cjs/common/index.cjs +4 -4
  9. package/dist/cjs/core/index.cjs +365 -310
  10. package/dist/cjs/decorator/index.cjs +85 -61
  11. package/dist/cjs/testing/index.cjs +2236 -496
  12. package/dist/cli.json +1 -1
  13. package/dist/esm/bin/app-template/README.md.template +3 -1
  14. package/dist/esm/bin/app-template/src/controllers/button/sample.button.controller.spec.ts.template +8 -4
  15. package/dist/esm/bin/app-template/src/controllers/button/sample.button.controller.ts.template +2 -2
  16. package/dist/esm/bin/app-template/src/guards/owner.guard.spec.ts.template +8 -4
  17. package/dist/esm/bin/app-template/src/guards/owner.guard.ts.template +1 -1
  18. package/dist/esm/bin/app-template/vitest.config.ts.template +1 -1
  19. package/dist/esm/bin/app-template/vitest.setup.ts.template +7 -3
  20. package/dist/esm/bin/generator.js +4 -5
  21. package/dist/esm/bin/helper/filter-generator.helper.js +4 -5
  22. package/dist/esm/bin/helper/guard-generator.helper.js +4 -5
  23. package/dist/esm/bin/helper/interceptor-generator.helper.js +4 -5
  24. package/dist/esm/bin/helper/observer-generator.helper.js +4 -5
  25. package/dist/esm/bin/helper/pipe-generator.helper.js +4 -5
  26. package/dist/esm/bin/helper/service-generator.helper.js +4 -5
  27. package/dist/esm/bin/meocord.js +84 -25
  28. package/dist/esm/build/load-config.pre-entry.js +17 -4
  29. package/dist/esm/build/rsbuild-config.js +16 -12
  30. package/dist/esm/build/typed-externals.js +36 -0
  31. package/dist/esm/common/cooldown-store.js +10 -2
  32. package/dist/esm/common/decorator.js +6 -6
  33. package/dist/esm/common/deprecation.js +2 -2
  34. package/dist/esm/common/errors.js +27 -2
  35. package/dist/esm/common/execution-context.js +1 -1
  36. package/dist/esm/common/meocord-text.js +11 -8
  37. package/dist/esm/common/metadata.js +1 -1
  38. package/dist/esm/common/response/call-order.js +9 -4
  39. package/dist/esm/common/response/presenter.js +33 -6
  40. package/dist/esm/common/response/response-state.js +84 -10
  41. package/dist/esm/common/route.js +21 -5
  42. package/dist/esm/common/sharded-cooldown-store.js +1 -1
  43. package/dist/esm/common/translate-error.js +1 -1
  44. package/dist/esm/common/translator.js +37 -6
  45. package/dist/esm/core/command-conflicts.js +34 -34
  46. package/dist/esm/core/command-registration.js +93 -24
  47. package/dist/esm/core/command-routes.js +2 -2
  48. package/dist/esm/core/component-routes.js +74 -31
  49. package/dist/esm/core/cooldown-runner.js +29 -8
  50. package/dist/esm/core/dispatcher.js +51 -7
  51. package/dist/esm/core/fallback.js +20 -11
  52. package/dist/esm/core/filter-runner.js +2 -2
  53. package/dist/esm/core/guard-runner.js +16 -3
  54. package/dist/esm/core/handler-input.js +4 -3
  55. package/dist/esm/core/handler-pipeline.js +27 -14
  56. package/dist/esm/core/handler-registry.js +1 -1
  57. package/dist/esm/core/interceptor-runner.js +2 -2
  58. package/dist/esm/core/lifecycle-hooks.js +134 -47
  59. package/dist/esm/core/meocord-factory.js +241 -179
  60. package/dist/esm/core/meocord.app.js +116 -75
  61. package/dist/esm/core/message-routes.js +4 -3
  62. package/dist/esm/core/observer-runner.js +4 -3
  63. package/dist/esm/core/providers.js +45 -23
  64. package/dist/esm/core/route-specificity.js +4 -4
  65. package/dist/esm/core/running-calls.js +43 -0
  66. package/dist/esm/core/scalar-types.js +15 -3
  67. package/dist/esm/core/shard-context.js +6 -7
  68. package/dist/esm/core/shard-manager.js +16 -10
  69. package/dist/esm/core/startup-roots.js +23 -4
  70. package/dist/esm/core/theme-resolvers.js +4 -1
  71. package/dist/esm/core/theme-runtime.js +2 -2
  72. package/dist/esm/core/theme-scope.js +3 -1
  73. package/dist/esm/core/theme-validation.js +17 -10
  74. package/dist/esm/decorator/app.decorator.js +12 -7
  75. package/dist/esm/decorator/command-builder.decorator.js +4 -4
  76. package/dist/esm/decorator/controller-class.decorator.js +9 -4
  77. package/dist/esm/decorator/controller.decorator.js +162 -83
  78. package/dist/esm/decorator/cooldown.decorator.js +20 -3
  79. package/dist/esm/decorator/defer.decorator.js +4 -4
  80. package/dist/esm/decorator/event.decorator.js +5 -5
  81. package/dist/esm/decorator/filter.decorator.js +7 -7
  82. package/dist/esm/decorator/guard.decorator.js +11 -10
  83. package/dist/esm/decorator/inject.decorator.js +4 -4
  84. package/dist/esm/decorator/interceptor.decorator.js +7 -7
  85. package/dist/esm/decorator/observer.decorator.js +4 -4
  86. package/dist/esm/decorator/service.decorator.js +4 -4
  87. package/dist/esm/decorator/theme.decorator.js +3 -3
  88. package/dist/esm/decorator/validation.decorator.js +10 -10
  89. package/dist/esm/package.json.js +1 -1
  90. package/dist/esm/testing/catalog.js +27 -11
  91. package/dist/esm/testing/cooldown-store-suite.js +85 -15
  92. package/dist/esm/testing/discord-defaults.js +249 -21
  93. package/dist/esm/testing/index.js +4 -2
  94. package/dist/esm/testing/inspect-handler.js +20 -3
  95. package/dist/esm/testing/meocord-testing-module.js +352 -205
  96. package/dist/esm/testing/mock-fn.js +173 -22
  97. package/dist/esm/testing/mock-interaction.js +1214 -209
  98. package/dist/esm/testing/response.js +1 -1
  99. package/dist/esm/testing/routing.js +39 -14
  100. package/dist/esm/testing/snowflake.js +9 -2
  101. package/dist/esm/testing/startup-errors.js +26 -0
  102. package/dist/esm/testing/strict-mocks.js +65 -0
  103. package/dist/esm/testing/theme.js +1 -1
  104. package/dist/esm/util/bundle-entry.util.js +2 -1
  105. package/dist/esm/util/common.util.js +47 -19
  106. package/dist/esm/util/dev-runner.util.js +8 -8
  107. package/dist/esm/util/generator-cli.util.js +4 -5
  108. package/dist/esm/util/interaction.util.js +3 -3
  109. package/dist/esm/util/meocord-config-loader.util.js +10 -2
  110. package/dist/esm/util/meocord-config-validation.util.js +4 -5
  111. package/dist/esm/util/meocord-source-config.util.js +65 -20
  112. package/dist/esm/util/metadata-keys.js +1 -0
  113. package/dist/esm/util/method-wrapper.util.js +16 -0
  114. package/dist/esm/util/platform.util.js +10 -10
  115. package/dist/esm/util/refusal.util.js +148 -8
  116. package/dist/esm/util/shard-process.util.js +4 -1
  117. package/dist/esm/util/sharding-mode.util.js +5 -7
  118. package/dist/esm/util/shared-state.util.js +21 -0
  119. package/dist/esm/util/tsconfig-paths.util.js +29 -0
  120. package/dist/esm/util/tsconfig.util.js +10 -10
  121. package/dist/esm/util/value.util.js +12 -1
  122. package/dist/types/common/index.d.cts +8 -8
  123. package/dist/types/common/index.d.ts +8 -8
  124. package/dist/types/core/index.d.cts +10 -7
  125. package/dist/types/core/index.d.ts +10 -7
  126. package/dist/types/decorator/index.d.cts +31 -29
  127. package/dist/types/decorator/index.d.ts +31 -29
  128. package/dist/types/{index-D7n0FBMm.d.ts → index-DKJclIi-.d.ts} +107 -36
  129. package/dist/types/{index-DEP0NYrU.d.cts → index-Dlf0ZZtS.d.cts} +107 -36
  130. package/dist/types/interface/index.d.cts +1 -1
  131. package/dist/types/interface/index.d.ts +1 -1
  132. package/dist/types/{route-C-aMI7Sl.d.cts → route-Bh1U13yy.d.cts} +8 -4
  133. package/dist/types/{route-C-aMI7Sl.d.ts → route-Bh1U13yy.d.ts} +8 -4
  134. package/dist/types/testing/index.d.cts +406 -70
  135. package/dist/types/testing/index.d.ts +406 -70
  136. package/dist/types/{theme-resolvers-COHdX5g6.d.cts → theme-resolvers-CyEz9pnv.d.cts} +1 -1
  137. package/dist/types/{theme-resolvers-DedxyscT.d.ts → theme-resolvers-DA204O-q.d.ts} +1 -1
  138. package/dist/types/{translator-J6tIUfdp.d.ts → translator-Bg7hwxDF.d.cts} +21 -8
  139. package/dist/types/{translator-B4mc1lzJ.d.cts → translator-CeP4bCzp.d.ts} +21 -8
  140. package/package.json +4 -3
@@ -5,18 +5,15 @@ import './theme-scope.js';
5
5
  import './theme-validation.js';
6
6
  import '../common/decorator.js';
7
7
  import 'reflect-metadata';
8
+ import '../common/response/response-state.js';
8
9
  import { GuardDeniedError, UserError } from '../common/errors.js';
9
- import '../common/translator.js';
10
- import '../common/response/send-failure.js';
11
- import '../util/interaction.util.js';
12
- import '../common/response/flags.js';
13
- import 'path';
14
- import '../common/response/components.js';
15
10
  import 'node:crypto';
16
11
  import { getMessageHandlers, getReactionHandlers } from '../decorator/controller.decorator.js';
17
12
  import './scalar-types.js';
18
13
  import { isShardMessage } from './shard-messages.js';
19
14
  import { managerGone } from '../util/sharding-mode.util.js';
15
+ import '../common/translator.js';
16
+ import '../common/meocord-text.js';
20
17
  import { markExplained } from '../common/explained-error.js';
21
18
  import { messageDecorator, reactionDecorator } from './command-conflicts.js';
22
19
  import { ReactionHandlerAction } from '../enum/controller.enum.js';
@@ -27,7 +24,8 @@ import { stageTypes, stageClass } from './stage-scope.js';
27
24
  import { getEventHandlers } from '../decorator/event.decorator.js';
28
25
  import { eventRequirements, messageHandlerRequirements, REACTION_HANDLER_REQUIREMENTS, missingRequirementWarnings } from './event-requirements.js';
29
26
  import { classUnits } from './lifecycle-order.js';
30
- import { runReadyHooks, runShutdownSequence, callsSettled } from './lifecycle-hooks.js';
27
+ import { runReadyHooks, runShutdownSequence, ReadyPass } from './lifecycle-hooks.js';
28
+ import { RunningCalls } from './running-calls.js';
31
29
  import { stopRequests } from '../util/stop-request.util.js';
32
30
  import { tellDevRunner, onDevRunnerStop } from '../util/dev-runner.util.js';
33
31
  import { fatalLoginCode, explainLoginFailure, tokenMessage, isRefusedToken } from './login-failure.js';
@@ -41,7 +39,6 @@ import { loadMeoCordConfig } from '../util/meocord-config-loader.util.js';
41
39
  import { isRegisterOnly, FORCE_REGISTER_ENV, REGISTER_GUILD_ENV } from '../util/registration-mode.util.js';
42
40
  import { shutdownTimeoutOf } from '../util/shutdown-timeout.util.js';
43
41
  import { escapeForLog } from '../util/user-text.util.js';
44
- import { isShardProcess } from '../util/shard-process.util.js';
45
42
 
46
43
  /** How long an `onReady` hook runs before a warning says the hooks after it are waiting. */ const SLOW_READY_HOOK_MS = 10000;
47
44
  /** Closes each started app: runs its shutdown hooks and destroys its client, resolving `false` on failure. */ const runningApps = new Set();
@@ -52,11 +49,11 @@ let shuttingDown;
52
49
  * Shuts every started app down and exits, with a code already set or 0, and 1 if a client failed to close. Signals call
53
50
  * it, and in a shard its manager's request or its going. A call within `REPEAT_SIGNAL_WINDOW_MS` of the first is the
54
51
  * same request; one after it forces exit 1, except in a shard, whose manager forces it.
55
- */ async function shutdownAndExit() {
52
+ */ async function shutdownAndExit(shard) {
56
53
  const request = stopRequest();
57
54
  if (request !== 'first') {
58
55
  // A shard hears Ctrl+C both directly and from its manager, which owns forcing it; so it waits
59
- if (request === 'duplicate' || isShardProcess()) return;
56
+ if (request === 'duplicate' || shard) return;
60
57
  process.exit(1);
61
58
  return;
62
59
  }
@@ -77,47 +74,48 @@ let shuttingDown;
77
74
  markExplained(error);
78
75
  return error;
79
76
  }
80
- /** One pair of signal listeners for the process, however many apps it starts. */ function installSignalHandlers() {
77
+ /** One pair of signal listeners for the process, however many apps it starts, for the role the first decided. */ function installSignalHandlers(shard) {
81
78
  if (signalHandlersInstalled) return;
82
79
  signalHandlersInstalled = true;
83
- process.on('SIGINT', ()=>void shutdownAndExit());
84
- process.on('SIGTERM', ()=>void shutdownAndExit());
80
+ process.on('SIGINT', ()=>void shutdownAndExit(shard));
81
+ process.on('SIGTERM', ()=>void shutdownAndExit(shard));
85
82
  // How `meocord start --dev` stops the application to restart it, a signal being no graceful stop on Windows. It is
86
83
  // not the user's stop request: their first Ctrl+C during it joins this shutdown, and only a repeat forces exit 1.
87
- onDevRunnerStop(()=>void shutDown());
84
+ onDevRunnerStop(()=>void shutDown(), shard);
88
85
  // A shard stops when its manager asks, or when the manager is gone and cannot ask
89
- if (isShardProcess()) {
86
+ if (shard) {
90
87
  process.on('message', (message)=>{
91
- if (isShardMessage(message) && message.meocord === 'shutdown') void shutdownAndExit();
88
+ if (isShardMessage(message) && message.meocord === 'shutdown') void shutdownAndExit(shard);
92
89
  });
93
- process.on('disconnect', ()=>void shutdownAndExit());
90
+ process.on('disconnect', ()=>void shutdownAndExit(shard));
94
91
  }
95
92
  }
96
- /** Tells the manager a shard cannot log in, and why, and waits until the message is sent. */ async function reportFatalLogin(code, reason) {
97
- if (isShardProcess()) await tellManager({
93
+ /** Tells the manager a shard cannot log in, and why, and waits until the message is sent. */ async function reportFatalLogin(shard, code, reason) {
94
+ if (shard) await tellManager({
98
95
  meocord: 'fatal',
99
96
  code,
100
97
  message: reason
101
98
  });
102
99
  }
100
+ /** How a shutdown warning names an interaction still being handled: by its command, or a component's customId. */ function interactionLabel(interaction) {
101
+ if ('commandName' in interaction) return `interaction "/${escapeForLog(interaction.commandName)}"`;
102
+ if ('customId' in interaction) return `interaction "${escapeForLog(interaction.customId)}"`;
103
+ return '"interactionCreate"';
104
+ }
103
105
  class MeoCordApp {
104
106
  /**
105
107
  * Runs an event handler so its failure is logged against the event instead of surfacing as an
106
108
  * unhandled rejection, which would terminate the whole bot.
107
- */ async runListener(event, run) {
108
- const handled = (async ()=>{
109
+ */ async runListener(event, run, label = `"${event}"`) {
110
+ const handle = async ()=>{
109
111
  try {
110
112
  await run();
111
113
  } catch (error) {
112
114
  this.logger.error(`Unhandled error while handling "${event}":`, error);
113
115
  }
114
- })();
115
- // A call, which the cooldown store outlasts at shutdown; the ready listener's work is not one
116
- if (event !== 'clientReady') {
117
- this.calls.add(handled);
118
- void handled.then(()=>this.calls.delete(handled));
119
- }
120
- await handled;
116
+ };
117
+ // A call, which shutdown waits for before the onShutdown hooks; the ready listener's work is not one
118
+ await (event === 'clientReady' ? handle() : this.calls.run(label, handle));
121
119
  }
122
120
  /**
123
121
  * Shows the next of the app's activities, in the order listed, starting again after the last. Guarded apart from
@@ -139,7 +137,7 @@ class MeoCordApp {
139
137
  if (this.online) return;
140
138
  this.starting ?? (this.starting = this.startOnce().catch((error)=>{
141
139
  // A shard ends, for its manager to restart, or to stop all if told it is fatal; a stopped start is the stop's
142
- if (isShardProcess() && !this.closing) endFailedShard(error);
140
+ if (this.shard && !this.closing) endFailedShard(error);
143
141
  throw error;
144
142
  }).finally(()=>this.starting = undefined));
145
143
  return this.starting;
@@ -149,7 +147,10 @@ class MeoCordApp {
149
147
  * which then asks this one too, so the process ends. A client that fails to close sets `process.exitCode` to 1,
150
148
  * unless a code is already set.
151
149
  */ async stop() {
152
- if (isShardProcess()) await tellManager({
150
+ // A handler or an onReady awaiting stop() would otherwise hold up the shutdown that waits for it
151
+ this.calls.excuseCurrent();
152
+ this.readyPass.excuseCurrent();
153
+ if (this.shard) await tellManager({
153
154
  meocord: 'stop'
154
155
  });
155
156
  // As a signal's shutdown exits 1 for a client that failed to close, a code another failure set aside
@@ -159,14 +160,20 @@ class MeoCordApp {
159
160
  this.logger.log('Starting bot...');
160
161
  // Before the providers are made, so a signal, or a shard's manager, can stop a start that is slow to come online
161
162
  runningApps.add(this.close);
162
- installSignalHandlers();
163
+ installSignalHandlers(this.shard);
163
164
  // A shard whose manager is already gone has no one to answer to, and nothing would stop it later
164
- if (isShardProcess() && managerGone()) void shutdownAndExit();
165
+ if (this.shard && managerGone()) void shutdownAndExit(this.shard);
165
166
  // A bot runs one app, whose theme code outside any call reads, its providers' factories included; claimed for
166
167
  // each start, as a failed one gives it up
167
168
  claimAmbientAppTheme(this.container);
168
169
  if (!this.prepared) {
169
- await this.prepare();
170
+ // Kept while it runs, so a stop meanwhile waits for it
171
+ this.preparing = this.prepare();
172
+ try {
173
+ await this.preparing;
174
+ } finally{
175
+ this.preparing = undefined;
176
+ }
170
177
  this.prepared = true;
171
178
  }
172
179
  if (this.loginFailed) {
@@ -179,7 +186,10 @@ class MeoCordApp {
179
186
  // Every provided value is made before anything that injects it is resolved, and before login
180
187
  if (this.startup) {
181
188
  try {
182
- await this.startup();
189
+ await this.startup({
190
+ stopped: ()=>this.closing,
191
+ pending: (name)=>this.pendingProvider = name
192
+ });
183
193
  } catch (error) {
184
194
  releaseAmbientAppTheme(this.container);
185
195
  if (process.exitCode === undefined || process.exitCode === 0) {
@@ -189,7 +199,8 @@ class MeoCordApp {
189
199
  throw error;
190
200
  }
191
201
  }
192
- // A stop that came while the providers were being made has nothing to close: nothing is attached or logged in
202
+ // A stop that came while the providers were being made has waited for them, and the startup made no more after it;
203
+ // nothing is attached or logged in
193
204
  if (this.closing) throw stoppedBeforeOnline();
194
205
  // Calls wait for the store's onReady, which may connect it, before asking it
195
206
  if (this.lifecycleUnits.some((unit)=>unit.cooldownStore)) {
@@ -205,10 +216,10 @@ class MeoCordApp {
205
216
  // Started before registration and not waited on by it, so a slow or failed registration never holds them up
206
217
  const readyHooks = this.runReadyHooks(readyClient ?? this.bot);
207
218
  // With process sharding, the manager registers once for every shard
208
- if (!isShardProcess()) await this.registerCommands();
219
+ if (!this.shard) await this.registerCommands();
209
220
  await readyHooks;
210
221
  }));
211
- this.bot.on('interactionCreate', ownInteractionListener((interaction)=>this.runListener('interactionCreate', ()=>this.dispatcher.interaction(interaction))));
222
+ this.bot.on('interactionCreate', ownInteractionListener((interaction)=>this.runListener('interactionCreate', ()=>this.dispatcher.interaction(interaction), interactionLabel(interaction))));
212
223
  this.bot.on('messageCreate', (message)=>this.runListener('messageCreate', ()=>this.dispatcher.message(message)));
213
224
  this.bot.on('messageReactionAdd', (reaction, user)=>this.runListener('messageReactionAdd', ()=>this.dispatcher.reaction(reaction, {
214
225
  user,
@@ -251,18 +262,18 @@ class MeoCordApp {
251
262
  const explanation = fatal && explainLoginFailure(fatal, this.bot.options?.intents, this.discordToken);
252
263
  if (explanation) {
253
264
  // The explanation is what to act on, and the stack only for debugging. A shard's manager logs it instead.
254
- if (!isShardProcess()) this.logger.error(explanation);
265
+ if (!this.shard) this.logger.error(explanation);
255
266
  this.logger.debug('Login failed:', error);
256
267
  markExplained(error);
257
268
  }
258
- if (fatal) await reportFatalLogin(fatal, explanation ?? (error instanceof Error ? error.message : String(error)));
269
+ if (fatal) await reportFatalLogin(this.shard, fatal, explanation ?? (error instanceof Error ? error.message : String(error)));
259
270
  if (process.exitCode === undefined || process.exitCode === 0) {
260
271
  process.exitCode = 1;
261
272
  MeoCordApp.failedLoginSetExitCode = true;
262
273
  }
263
274
  await tellDevRunner({
264
275
  meocord: 'login-failed'
265
- });
276
+ }, this.shard);
266
277
  MeoCordApp.toldDevRunnerLoginFailed = true;
267
278
  this.loginFailed = true;
268
279
  throw error;
@@ -280,7 +291,7 @@ class MeoCordApp {
280
291
  MeoCordApp.toldDevRunnerLoginFailed = false;
281
292
  await tellDevRunner({
282
293
  meocord: 'online'
283
- });
294
+ }, this.shard);
284
295
  }
285
296
  this.online = true;
286
297
  this.logger.log('Bot is online!');
@@ -363,18 +374,20 @@ class MeoCordApp {
363
374
  } else logError(error);
364
375
  return undefined;
365
376
  };
366
- const listener = async (...args)=>{
367
- try {
368
- const instance = this.container.get(lifecycleClass);
369
- await runHandler(this.container, instance, method, args, {
370
- fallback,
371
- type: 'event'
372
- });
373
- } catch (error) {
374
- // Only resolving the instance can fail here; the pipeline hands every other error to the fallback
375
- logError(error);
376
- }
377
- };
377
+ const label = `@${once ? 'Once' : 'On'}('${event}') in ${lifecycleClass.name}.${method}`;
378
+ // A call, as a dispatched one is, which shutdown waits for
379
+ const listener = (...args)=>this.calls.run(label, async ()=>{
380
+ try {
381
+ const instance = this.container.get(lifecycleClass);
382
+ await runHandler(this.container, instance, method, args, {
383
+ fallback,
384
+ type: 'event'
385
+ });
386
+ } catch (error) {
387
+ // Only resolving the instance can fail here; the pipeline hands every other error to the fallback
388
+ logError(error);
389
+ }
390
+ });
378
391
  if (once) this.bot.once(event, listener);
379
392
  else this.bot.on(event, listener);
380
393
  }
@@ -443,29 +456,35 @@ class MeoCordApp {
443
456
  */ async runReadyHooks(client) {
444
457
  const entries = [];
445
458
  this.lifecycleEntries = entries;
446
- await runReadyHooks(this.container, this.lifecycleUnits, client, {
447
- primary: client.shard ? client.shard.ids.includes(0) : true
448
- }, entries, {
449
- resolveFailed: (unit, error)=>this.logger.error(`Could not resolve ${unit.name} to run its lifecycle hooks:`, error),
450
- hookFailed: (unit, error)=>this.logger.error(`onReady failed in ${unit.name}:`, error),
451
- dependsOnFailed: (unit, failed)=>this.logger.warn(`Running onReady in ${unit.name} although it depends on ${failed.map(({ name })=>name).join(', ')}, which failed.`),
452
- slow: (unit)=>this.logger.warn(`onReady in ${unit.name} has run for over ${SLOW_READY_HOOK_MS} ms; the hooks after it are waiting.`)
453
- }, // Shutdown has begun: the client is going away, so no further hook starts
454
- {
455
- stopped: ()=>this.closing,
456
- slowAfterMs: SLOW_READY_HOOK_MS,
457
- settled: (unit)=>unit.cooldownStore && this.storeReady?.resolve()
458
- });
459
+ await this.readyPass.run(()=>runReadyHooks(this.container, this.lifecycleUnits, client, {
460
+ primary: client.shard ? client.shard.ids.includes(0) : true
461
+ }, entries, {
462
+ resolveFailed: (unit, error)=>this.logger.error(`Could not resolve ${unit.name} to run its lifecycle hooks:`, error),
463
+ hookFailed: (unit, error)=>this.logger.error(`onReady failed in ${unit.name}:`, error),
464
+ dependsOnFailed: (unit, failed)=>this.logger.warn(`Running onReady in ${unit.name} although it depends on ${failed.map(({ name })=>name).join(', ')}, which failed.`),
465
+ slow: (unit)=>this.logger.warn(`onReady in ${unit.name} has run for over ${SLOW_READY_HOOK_MS} ms; the hooks after it are waiting.`)
466
+ }, // Shutdown has begun: the client is going away, so no further hook starts
467
+ {
468
+ stopped: ()=>this.closing,
469
+ slowAfterMs: SLOW_READY_HOOK_MS,
470
+ settled: (unit)=>unit.cooldownStore && this.storeReady?.resolve(),
471
+ runOnReady: this.readyPass.runOnReady
472
+ }));
459
473
  // Also when shutdown stopped the hooks before the store's, so no call waits on it
460
474
  this.storeReady?.resolve();
461
475
  }
462
- /** Runs the `onShutdown` hooks through the shutdown sequence, within the configured `shutdownTimeout`. */ async runShutdownHooks(entries) {
463
- await runShutdownSequence(this.container, entries, {
464
- // No new call starts once the listeners are gone
476
+ /** Runs the `onShutdown` hooks through the shutdown sequence, within the configured `shutdownTimeout`. */ async runShutdownHooks() {
477
+ // Read once the onReady in progress settled; a login that failed never ran onReady, so there is no onShutdown to undo
478
+ await runShutdownSequence(this.container, ()=>this.lifecycleEntries ?? [], {
479
+ // No new call starts once the listeners are gone; an error the client emits meanwhile still has its listeners
465
480
  drainCalls: ()=>{
466
- this.bot.removeAllListeners();
467
- return callsSettled(this.calls);
481
+ const emitter = this.bot;
482
+ for (const event of emitter.eventNames())if (event !== 'error') emitter.removeAllListeners(event);
483
+ return this.calls.settled();
468
484
  },
485
+ drainAlways: true,
486
+ runningCalls: ()=>this.calls.running(),
487
+ readyPass: this.readyPass,
469
488
  timeoutMs: this.shutdownTimeout,
470
489
  hookFailed: (name, error)=>this.logger.error(`onShutdown failed in ${name}:`, error),
471
490
  warn: (message)=>this.logger.warn(message)
@@ -484,8 +503,28 @@ class MeoCordApp {
484
503
  releaseAmbientAppTheme(this.container);
485
504
  }
486
505
  }
506
+ /**
507
+ * Waits for the startup under way to stop making providers and services, at most `shutdownTimeout`; past it, warns
508
+ * with the factory it still awaits, which then makes nothing more.
509
+ */ async waitForStartup(preparing) {
510
+ let timer;
511
+ const outlasted = new Promise((resolve)=>{
512
+ timer = setTimeout(()=>resolve(true), this.shutdownTimeout);
513
+ });
514
+ const settled = preparing.then(()=>false, ()=>false);
515
+ if (await Promise.race([
516
+ settled,
517
+ outlasted
518
+ ])) {
519
+ const what = this.pendingProvider ? `the factory providing ${this.pendingProvider}` : 'its providers';
520
+ this.logger.warn(`The startup still waits for ${what} after ${this.shutdownTimeout} ms; shutting down without it.`);
521
+ }
522
+ clearTimeout(timer);
523
+ }
487
524
  async shutDownClient() {
488
525
  this.logger.log('Shutting down bot...');
526
+ // So nothing is made after the bot has shut down
527
+ if (this.preparing) await this.waitForStartup(this.preparing);
489
528
  // Nothing came online, so there are no hooks to undo; the listeners go first, so none of them runs
490
529
  if (this.abortLogin) {
491
530
  this.logger.log('The bot was still logging in, so it stops without coming online');
@@ -495,8 +534,8 @@ class MeoCordApp {
495
534
  return true;
496
535
  }
497
536
  if (this.activityInterval) clearInterval(this.activityInterval);
498
- // A login that failed never ran onReady, so there is nothing for onShutdown to undo
499
- if (this.lifecycleEntries) await this.runShutdownHooks(this.lifecycleEntries);
537
+ // The calls drain on every shutdown, and the onReady in progress settles first
538
+ await this.runShutdownHooks();
500
539
  try {
501
540
  this.bot.removeAllListeners();
502
541
  await this.bot.destroy();
@@ -507,7 +546,7 @@ class MeoCordApp {
507
546
  return false;
508
547
  }
509
548
  }
510
- constructor(controllerClasses, container, discordClient, discordToken, activities, lifecycleClasses = [], shutdownTimeout, startup, lifecycleUnits, messageOptions = {}, warnUnanswered = false){
549
+ constructor(controllerClasses, container, discordClient, discordToken, activities, lifecycleClasses = [], shutdownTimeout, startup, lifecycleUnits, messageOptions = {}, warnUnanswered = false, /** Whether a manager spawned this process, as `MeoCordFactory.create` decided before anything could change it. */ shard = false){
511
550
  this.controllerClasses = controllerClasses;
512
551
  this.container = container;
513
552
  this.discordClient = discordClient;
@@ -516,6 +555,7 @@ class MeoCordApp {
516
555
  this.lifecycleClasses = lifecycleClasses;
517
556
  this.startup = startup;
518
557
  this.messageOptions = messageOptions;
558
+ this.shard = shard;
519
559
  this.logger = new Logger(MeoCordApp.name);
520
560
  this.fallback = createFallback(this.logger, ()=>this.messageOptions, {
521
561
  cooldownNotice: (refusal)=>claimCooldownNotice(this.container, refusal),
@@ -528,7 +568,8 @@ class MeoCordApp {
528
568
  * Closes the app once, however many stops and signals ask: whether the client was destroyed cleanly. The app stays
529
569
  * among the running ones until the close settles, so a signal or a manager's request meanwhile waits for it.
530
570
  */ this.close = ()=>this.stopped ?? (this.stopped = this.closeClient().finally(()=>runningApps.delete(this.close)));
531
- /** The calls under way, each settling once its handling has. */ this.calls = new Set();
571
+ /** The calls under way, which shutdown waits for, less one that asked the app to stop. */ this.calls = new RunningCalls();
572
+ /** The ready hooks' pass, whose `onReady` in progress shutdown waits for, less one that asked the app to stop. */ this.readyPass = new ReadyPass();
532
573
  /** Whether the providers, services and event handlers are set up, which a retry after a failed login reuses. */ this.prepared = false;
533
574
  /** Whether the bot logged in, after which `start()` has nothing to do. */ this.online = false;
534
575
  /** Whether a login of this app failed, so a retry restores the client discord.js destroyed. */ this.loginFailed = false;
@@ -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();
@@ -236,15 +253,20 @@ const isToken = (value)=>typeof value === 'function' || typeof value === 'string
236
253
  }
237
254
  /**
238
255
  * Runs every factory in `order`, awaiting those that return a promise, so each value is made before
239
- * anything that injects it is resolved. Rejects with {@link providerFailure} for the first that fails.
240
- */ async function resolveProviders(container, providers, order) {
256
+ * anything that injects it is resolved. Rejects with {@link providerFailure} for the first that fails. With `control`,
257
+ * it makes no further value once the app is stopping.
258
+ */ async function resolveProviders(container, providers, order, control) {
241
259
  for (const token of order){
242
260
  const provider = providers.get(token);
243
261
  if (!provider || !isFactoryProvider(provider)) continue;
262
+ if (control?.stopped()) return;
263
+ control?.pending(tokenName(token));
244
264
  try {
245
265
  await container.getAsync(token);
246
266
  } catch (error) {
247
267
  throw providerFailure(token, error);
268
+ } finally{
269
+ control?.pending(undefined);
248
270
  }
249
271
  }
250
272
  }
@@ -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
  }