meocord 4.2.2 → 4.2.3

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 (136) hide show
  1. package/CHANGELOG.md +130 -0
  2. package/dist/cjs/_shared/{controller.decorator-U2NBWcoU.cjs → controller.decorator-hp5xdJU4.cjs} +512 -183
  3. package/dist/cjs/_shared/{startup-roots-lA5xkBFq.cjs → event-sources-DzqTODXg.cjs} +265 -80
  4. package/dist/cjs/_shared/{providers-B4MOFBrW.cjs → providers-BBvbnmLP.cjs} +780 -281
  5. package/dist/cjs/_shared/{sharded-cooldown-store-HTUb3Ii_.cjs → sharded-cooldown-store-B62g5O88.cjs} +11 -9
  6. package/dist/cjs/_shared/{translate-error-CbBHUu5P.cjs → translate-error-wsgfsUET.cjs} +1 -1
  7. package/dist/cjs/common/index.cjs +4 -104
  8. package/dist/cjs/core/index.cjs +432 -181
  9. package/dist/cjs/decorator/index.cjs +85 -120
  10. package/dist/cjs/testing/index.cjs +1664 -784
  11. package/dist/cli.json +1 -1
  12. package/dist/esm/bin/app-template/package.json.template +9 -9
  13. package/dist/esm/bin/app-template/src/main.ts.template +3 -2
  14. package/dist/esm/bin/generator.js +6 -4
  15. package/dist/esm/bin/helper/filter-generator.helper.js +6 -4
  16. package/dist/esm/bin/helper/guard-generator.helper.js +6 -4
  17. package/dist/esm/bin/helper/interceptor-generator.helper.js +6 -4
  18. package/dist/esm/bin/helper/observer-generator.helper.js +6 -4
  19. package/dist/esm/bin/helper/pipe-generator.helper.js +6 -4
  20. package/dist/esm/bin/helper/service-generator.helper.js +6 -4
  21. package/dist/esm/bin/meocord.js +106 -48
  22. package/dist/esm/build/native-addons.js +75 -19
  23. package/dist/esm/build/rsbuild-config.js +11 -3
  24. package/dist/esm/build/typed-externals.js +15 -2
  25. package/dist/esm/common/cooldown-store.js +38 -14
  26. package/dist/esm/common/deprecation.js +4 -1
  27. package/dist/esm/common/execution-context.js +3 -1
  28. package/dist/esm/common/explained-error.js +7 -3
  29. package/dist/esm/common/log-level.js +13 -9
  30. package/dist/esm/common/logger.js +4 -2
  31. package/dist/esm/common/meocord-text.js +11 -6
  32. package/dist/esm/common/redis-cooldown-store.js +3 -7
  33. package/dist/esm/common/response/flags.js +17 -1
  34. package/dist/esm/common/response/install-context.js +27 -16
  35. package/dist/esm/common/response/presenter.js +7 -3
  36. package/dist/esm/common/response/response-state.js +27 -5
  37. package/dist/esm/common/sharded-cooldown-store.js +11 -9
  38. package/dist/esm/common/theme.js +3 -1
  39. package/dist/esm/common/translator.js +17 -8
  40. package/dist/esm/core/autocomplete-routes.js +26 -0
  41. package/dist/esm/core/command-conflicts.js +15 -7
  42. package/dist/esm/core/command-registration.js +15 -1
  43. package/dist/esm/core/cooldown-runner.js +7 -9
  44. package/dist/esm/core/defer.js +2 -5
  45. package/dist/esm/core/dispatcher.js +83 -58
  46. package/dist/esm/core/event-requirements.js +67 -19
  47. package/dist/esm/core/event-sources.js +100 -0
  48. package/dist/esm/core/fallback.js +3 -1
  49. package/dist/esm/core/filter-runner.js +4 -4
  50. package/dist/esm/core/guard-runner.js +173 -52
  51. package/dist/esm/core/handler-input.js +24 -3
  52. package/dist/esm/core/handler-pipeline.js +44 -28
  53. package/dist/esm/core/handler-registry.js +51 -21
  54. package/dist/esm/core/input-runner.js +7 -8
  55. package/dist/esm/core/interceptor-runner.js +4 -5
  56. package/dist/esm/core/lifecycle-hooks.js +3 -8
  57. package/dist/esm/core/lifecycle-order.js +1 -15
  58. package/dist/esm/core/meocord-classes.js +14 -1
  59. package/dist/esm/core/meocord-factory.js +80 -53
  60. package/dist/esm/core/meocord.app.js +174 -89
  61. package/dist/esm/core/message-help.js +5 -9
  62. package/dist/esm/core/message-params.js +126 -46
  63. package/dist/esm/core/message-routes.js +225 -45
  64. package/dist/esm/core/message-words.js +30 -2
  65. package/dist/esm/core/observer-runner.js +2 -1
  66. package/dist/esm/core/process-state.js +19 -0
  67. package/dist/esm/core/providers.js +28 -3
  68. package/dist/esm/core/scalar-types.js +57 -16
  69. package/dist/esm/core/shard-context.js +11 -10
  70. package/dist/esm/core/shard-exit.js +3 -3
  71. package/dist/esm/core/shard-manager.js +16 -6
  72. package/dist/esm/core/startup-roots.js +28 -8
  73. package/dist/esm/core/theme-resolvers.js +26 -11
  74. package/dist/esm/core/theme-runtime.js +23 -13
  75. package/dist/esm/core/theme-scope.js +27 -29
  76. package/dist/esm/core/theme-validation.js +1 -1
  77. package/dist/esm/decorator/app.decorator.js +6 -1
  78. package/dist/esm/decorator/command-builder.decorator.js +14 -0
  79. package/dist/esm/decorator/controller-class.decorator.js +1 -1
  80. package/dist/esm/decorator/controller.decorator.js +48 -19
  81. package/dist/esm/decorator/defer.decorator.js +2 -2
  82. package/dist/esm/decorator/event.decorator.js +4 -5
  83. package/dist/esm/decorator/filter.decorator.js +2 -2
  84. package/dist/esm/decorator/guard.decorator.js +4 -61
  85. package/dist/esm/decorator/inject.decorator.js +4 -1
  86. package/dist/esm/decorator/interceptor.decorator.js +2 -2
  87. package/dist/esm/decorator/theme.decorator.js +4 -3
  88. package/dist/esm/decorator/validation.decorator.js +5 -5
  89. package/dist/esm/package.json.js +1 -1
  90. package/dist/esm/testing/catalog.js +29 -13
  91. package/dist/esm/testing/cooldown-store-suite.js +3 -3
  92. package/dist/esm/testing/discord-defaults.js +449 -355
  93. package/dist/esm/testing/inspect-handler.js +91 -10
  94. package/dist/esm/testing/meocord-testing-module.js +195 -77
  95. package/dist/esm/testing/mock-fn.js +8 -2
  96. package/dist/esm/testing/mock-interaction.js +639 -113
  97. package/dist/esm/testing/response-log.js +8 -0
  98. package/dist/esm/testing/response.js +7 -5
  99. package/dist/esm/testing/routing.js +48 -6
  100. package/dist/esm/testing/strict-mocks.js +1 -0
  101. package/dist/esm/testing/theme.js +3 -2
  102. package/dist/esm/util/common.util.js +30 -9
  103. package/dist/esm/util/generator-cli.util.js +6 -4
  104. package/dist/esm/util/inherited-env.util.js +72 -13
  105. package/dist/esm/util/json.util.js +10 -3
  106. package/dist/esm/util/meocord-cli.util.js +3 -2
  107. package/dist/esm/util/meocord-config-loader.util.js +14 -1
  108. package/dist/esm/util/meocord-config-validation.util.js +13 -3
  109. package/dist/esm/util/meocord-source-config.util.js +13 -3
  110. package/dist/esm/util/platform.util.js +3 -2
  111. package/dist/esm/util/refusal.util.js +29 -11
  112. package/dist/esm/util/registration-mode.util.js +14 -1
  113. package/dist/esm/util/timer-limit.util.js +4 -1
  114. package/dist/esm/util/tsconfig-paths.util.js +38 -7
  115. package/dist/esm/util/tsconfig.util.js +16 -58
  116. package/dist/esm/util/user-text.util.js +8 -4
  117. package/dist/esm/util/value.util.js +15 -4
  118. package/dist/types/common/index.d.cts +13 -9
  119. package/dist/types/common/index.d.ts +13 -9
  120. package/dist/types/core/index.d.cts +21 -11
  121. package/dist/types/core/index.d.ts +21 -11
  122. package/dist/types/decorator/index.d.cts +63 -63
  123. package/dist/types/decorator/index.d.ts +63 -63
  124. package/dist/types/{index-DKJclIi-.d.ts → index-Brkd6QGL.d.ts} +81 -35
  125. package/dist/types/{index-Dlf0ZZtS.d.cts → index-CqXImwF_.d.cts} +81 -35
  126. package/dist/types/interface/index.d.cts +1 -1
  127. package/dist/types/interface/index.d.ts +1 -1
  128. package/dist/types/{route-Bh1U13yy.d.ts → route-IQrK-MN2.d.cts} +11 -3
  129. package/dist/types/{route-Bh1U13yy.d.cts → route-bI-okq7g.d.ts} +11 -3
  130. package/dist/types/testing/index.d.cts +137 -54
  131. package/dist/types/testing/index.d.ts +137 -54
  132. package/dist/types/{theme-resolvers-CyEz9pnv.d.cts → theme-resolvers-AtgeaYqF.d.cts} +1 -1
  133. package/dist/types/{theme-resolvers-DA204O-q.d.ts → theme-resolvers-BgqysV8d.d.ts} +1 -1
  134. package/dist/types/{translator-Bg7hwxDF.d.cts → translator-D8aydlCE.d.ts} +12 -11
  135. package/dist/types/{translator-CeP4bCzp.d.ts → translator-iJhDlgwL.d.cts} +12 -11
  136. package/package.json +5 -2
@@ -1,13 +1,13 @@
1
1
  'use strict';
2
2
 
3
3
  require('reflect-metadata');
4
- var providers = require('../_shared/providers-B4MOFBrW.cjs');
4
+ var providers = require('../_shared/providers-BBvbnmLP.cjs');
5
5
  var inversify = require('inversify');
6
6
  var discord_js = require('discord.js');
7
- var controller_decorator = require('../_shared/controller.decorator-U2NBWcoU.cjs');
7
+ var controller_decorator = require('../_shared/controller.decorator-hp5xdJU4.cjs');
8
8
  require('node:crypto');
9
- var shardedCooldownStore = require('../_shared/sharded-cooldown-store-HTUb3Ii_.cjs');
10
- var startupRoots = require('../_shared/startup-roots-lA5xkBFq.cjs');
9
+ var shardedCooldownStore = require('../_shared/sharded-cooldown-store-B62g5O88.cjs');
10
+ var eventSources = require('../_shared/event-sources-DzqTODXg.cjs');
11
11
  var enum_index = require('../enum/index.cjs');
12
12
  var node_fs = require('node:fs');
13
13
  var path = require('node:path');
@@ -17,13 +17,13 @@ require('crypto');
17
17
  require('fs');
18
18
  require('path');
19
19
  require('cli-table3');
20
- require('node:url');
21
20
  require('dayjs');
22
21
  require('dayjs/plugin/utc.js');
23
22
  require('dayjs/plugin/timezone.js');
24
23
  require('module');
25
24
  require('chalk');
26
- require('../_shared/translate-error-CbBHUu5P.cjs');
25
+ require('node:url');
26
+ require('../_shared/translate-error-wsgfsUET.cjs');
27
27
 
28
28
  function _interopDefaultCompat (e) { return e && typeof e === 'object' && 'default' in e ? e : { default: e }; }
29
29
 
@@ -42,6 +42,22 @@ const REACTION_PARTIALS = [
42
42
  discord_js.Partials.Message,
43
43
  discord_js.Partials.Reaction
44
44
  ];
45
+ const POLLS = [
46
+ discord_js.GatewayIntentBits.GuildMessagePolls,
47
+ discord_js.GatewayIntentBits.DirectMessagePolls
48
+ ];
49
+ // discord.js drops a vote on a message it has not cached unless it may build the message, its poll and the answer
50
+ const POLL_PARTIALS = [
51
+ discord_js.Partials.Message,
52
+ discord_js.Partials.Poll,
53
+ discord_js.Partials.PollAnswer
54
+ ];
55
+ // The partials whose warning names members and polls; the others keep the wording for messages, reactions and users
56
+ const CACHED_ELSEWHERE = new Set([
57
+ discord_js.Partials.GuildMember,
58
+ discord_js.Partials.Poll,
59
+ discord_js.Partials.PollAnswer
60
+ ]);
45
61
  /** The intents Discord only sends once they are also enabled for the app in the developer portal. */ const PRIVILEGED = new Set([
46
62
  discord_js.GatewayIntentBits.GuildMembers,
47
63
  discord_js.GatewayIntentBits.GuildPresences,
@@ -79,10 +95,9 @@ const REACTION_PARTIALS = [
79
95
  ]
80
96
  }
81
97
  ])),
98
+ // A member that joins arrives whole; one that leaves or changes reaches its handler only if cached or with this partial
82
99
  ...Object.fromEntries([
83
100
  'guildMemberAdd',
84
- 'guildMemberRemove',
85
- 'guildMemberUpdate',
86
101
  'guildMemberAvailable',
87
102
  'threadMembersUpdate'
88
103
  ].map((event)=>[
@@ -95,6 +110,26 @@ const REACTION_PARTIALS = [
95
110
  ]
96
111
  }
97
112
  ])),
113
+ guildMemberRemove: {
114
+ intents: [
115
+ [
116
+ discord_js.GatewayIntentBits.GuildMembers
117
+ ]
118
+ ],
119
+ partials: [
120
+ discord_js.Partials.GuildMember
121
+ ]
122
+ },
123
+ guildMemberUpdate: {
124
+ intents: [
125
+ [
126
+ discord_js.GatewayIntentBits.GuildMembers
127
+ ]
128
+ ],
129
+ partials: [
130
+ discord_js.Partials.GuildMember
131
+ ]
132
+ },
98
133
  guildAuditLogEntryCreate: {
99
134
  intents: [
100
135
  [
@@ -227,9 +262,7 @@ const REACTION_PARTIALS = [
227
262
  intents: [
228
263
  REACTIONS
229
264
  ],
230
- partials: [
231
- discord_js.Partials.Reaction
232
- ]
265
+ partials: REACTION_PARTIALS
233
266
  },
234
267
  typingStart: {
235
268
  intents: [
@@ -241,26 +274,20 @@ const REACTION_PARTIALS = [
241
274
  },
242
275
  messagePollVoteAdd: {
243
276
  intents: [
244
- [
245
- discord_js.GatewayIntentBits.GuildMessagePolls,
246
- discord_js.GatewayIntentBits.DirectMessagePolls
247
- ]
248
- ]
277
+ POLLS
278
+ ],
279
+ partials: POLL_PARTIALS
249
280
  },
250
281
  messagePollVoteRemove: {
251
282
  intents: [
252
- [
253
- discord_js.GatewayIntentBits.GuildMessagePolls,
254
- discord_js.GatewayIntentBits.DirectMessagePolls
255
- ]
256
- ]
283
+ POLLS
284
+ ],
285
+ partials: POLL_PARTIALS
257
286
  },
258
287
  ...Object.fromEntries([
259
288
  'guildScheduledEventCreate',
260
289
  'guildScheduledEventDelete',
261
- 'guildScheduledEventUpdate',
262
- 'guildScheduledEventUserAdd',
263
- 'guildScheduledEventUserRemove'
290
+ 'guildScheduledEventUpdate'
264
291
  ].map((event)=>[
265
292
  event,
266
293
  {
@@ -271,6 +298,27 @@ const REACTION_PARTIALS = [
271
298
  ]
272
299
  }
273
300
  ])),
301
+ // Each names its user by id alone, which discord.js reads from the cache unless this partial is on
302
+ guildScheduledEventUserAdd: {
303
+ intents: [
304
+ [
305
+ discord_js.GatewayIntentBits.GuildScheduledEvents
306
+ ]
307
+ ],
308
+ partials: [
309
+ discord_js.Partials.User
310
+ ]
311
+ },
312
+ guildScheduledEventUserRemove: {
313
+ intents: [
314
+ [
315
+ discord_js.GatewayIntentBits.GuildScheduledEvents
316
+ ]
317
+ ],
318
+ partials: [
319
+ discord_js.Partials.User
320
+ ]
321
+ },
274
322
  autoModerationRuleCreate: {
275
323
  intents: [
276
324
  [
@@ -384,7 +432,7 @@ function intentName(bit) {
384
432
  (labels.some((label)=>label.startsWith("@MessageHandler('")) ? "; messages: { mention: 'only' } needs none." : '.') : ''));
385
433
  }
386
434
  for (const [partial, labels] of missingPartials){
387
- warnings.push(`Partials.${discord_js.Partials[partial]} is not in clientOptions.partials, so ${labels.join(', ')} will miss ` + (partial === discord_js.Partials.Channel ? 'direct messages: no DM channel is cached after the bot starts, and discord.js drops a message from a ' + 'channel it has not cached.' : 'events about messages, reactions or users the bot has not cached, such as messages sent before it started.'));
435
+ warnings.push(`Partials.${discord_js.Partials[partial]} is not in clientOptions.partials, so ${labels.join(', ')} will miss ` + (partial === discord_js.Partials.Channel ? 'direct messages: no DM channel is cached after the bot starts, and discord.js drops a message from a ' + 'channel it has not cached.' : CACHED_ELSEWHERE.has(partial) ? 'events about members or polls the bot has not cached, such as members who joined before it started.' : 'events about messages, reactions or users the bot has not cached, such as messages sent before it started.'));
388
436
  }
389
437
  return warnings;
390
438
  }
@@ -405,6 +453,21 @@ function intentName(bit) {
405
453
  };
406
454
  }
407
455
 
456
+ /** Where both builds of this version keep the process's state, as {@link shared} names it. */ const PROCESS_STATE = 'processState';
457
+ /**
458
+ * What the process's apps share, kept by both builds of this version, so a process that loads both has one pair of
459
+ * signal listeners and one shutdown, which stops the apps either build started, one exit code a failed start set, and
460
+ * one end of a failed shard.
461
+ */ const processState = controller_decorator.shared(PROCESS_STATE, ()=>({
462
+ /** Closes each started app: runs its shutdown hooks and destroys its client, resolving `false` on failure. */ runningApps: new Set(),
463
+ stopRequest: stopRequests(),
464
+ signalHandlersInstalled: false,
465
+ shuttingDown: undefined,
466
+ /** Whether a failed start, of a provider's factory or the login, set the exit code, so a later login knows to clear it. */ startFailureSetExitCode: false,
467
+ /** Whether `meocord start --dev` was told the bot could not log in, so a later login tells it the bot is online. */ toldDevRunnerLoginFailed: false,
468
+ /** Whether this shard is already ending, so a failure reported twice, as one going on uncaught is, ends it once. */ shardEnding: false
469
+ }));
470
+
408
471
  /** How long a message to the parent process may take to be sent before the parent is taken to be gone. */ const PARENT_SEND_TIMEOUT_MS = 1000;
409
472
  /**
410
473
  * Sends a message over the IPC channel to the parent process and waits until it is sent, or `PARENT_SEND_TIMEOUT_MS`:
@@ -516,14 +579,13 @@ const slots = globalThis;
516
579
  */ async function tellManager(message) {
517
580
  await sendToParent(message);
518
581
  }
519
- /** Whether this shard is already ending, so a failure reported twice, as one going on uncaught is, ends it once. */ let ending = false;
520
582
  /**
521
583
  * Ends a shard whose startup failed, which its manager restarts unless it was told the failure is one every shard
522
584
  * meets: a refusal, told here first, which the manager logs before stopping them all, or a login Discord refused, told
523
585
  * as it failed. The exit waits until the caller has handled the error and the manager has the reason.
524
586
  */ function endFailedShard(error) {
525
- if (ending) return;
526
- ending = true;
587
+ if (processState.shardEnding) return;
588
+ processState.shardEnding = true;
527
589
  const refused = controller_decorator.isRefusal(error);
528
590
  const reason = refused && controller_decorator.describeRefusal(error, process.cwd());
529
591
  if (refused) {
@@ -624,21 +686,31 @@ const slots = globalThis;
624
686
  */ /** Set by `meocord register`: register the commands over REST and exit, without logging in. */ const REGISTER_ONLY_ENV = 'MEOCORD_REGISTER_ONLY';
625
687
  /** Set by `meocord register --guild <id>`: register every command to that guild only. */ const REGISTER_GUILD_ENV = 'MEOCORD_REGISTER_GUILD';
626
688
  /** Set by `meocord start --force-register` and by `meocord register`: send even an unchanged payload. */ const FORCE_REGISTER_ENV = 'MEOCORD_FORCE_REGISTER';
689
+ /**
690
+ * Set by the CLI for every bot it starts: `development` or `production`, the mode the command runs in. A bundle built
691
+ * with `bundleDependencies` has `process.env.NODE_ENV` written in as the mode it was built in, MeoCord's own reads
692
+ * included, so the command's mode reaches it through this variable instead.
693
+ */ const REGISTER_MODE_ENV = 'MEOCORD_REGISTER_MODE';
694
+ /**
695
+ * Whether commands register as development does, to `commands.developmentGuild`: as the CLI command that started the
696
+ * bot says, else as `NODE_ENV` says.
697
+ */ function registersAsDevelopment(env = process.env) {
698
+ const mode = env[REGISTER_MODE_ENV];
699
+ if (mode === 'development' || mode === 'production') return mode === 'development';
700
+ return process.env.NODE_ENV === 'development';
701
+ }
627
702
  /** Whether this process was started to register commands and exit. */ function isRegisterOnly(env = process.env) {
628
703
  return env[REGISTER_ONLY_ENV] === '1';
629
704
  }
630
705
 
631
706
  /** How long an `onReady` hook runs before a warning says the hooks after it are waiting. */ const SLOW_READY_HOOK_MS = 10000;
632
- /** Closes each started app: runs its shutdown hooks and destroys its client, resolving `false` on failure. */ const runningApps = new Set();
633
- const stopRequest = stopRequests();
634
- let signalHandlersInstalled = false;
635
- let shuttingDown;
707
+ const { runningApps } = processState;
636
708
  /**
637
- * Shuts every started app down and exits, with a code already set or 0, and 1 if a client failed to close. Signals call
638
- * it, and in a shard its manager's request or its going. A call within `REPEAT_SIGNAL_WINDOW_MS` of the first is the
709
+ * Shuts every started app down and exits, with the code set when the shutdown began or 0, and 1 if an app failed to
710
+ * close. Signals call it, and in a shard its manager's request or its going. A call within `REPEAT_SIGNAL_WINDOW_MS` of the first is the
639
711
  * same request; one after it forces exit 1, except in a shard, whose manager forces it.
640
712
  */ async function shutdownAndExit(shard) {
641
- const request = stopRequest();
713
+ const request = processState.stopRequest();
642
714
  if (request !== 'first') {
643
715
  // A shard hears Ctrl+C both directly and from its manager, which owns forcing it; so it waits
644
716
  if (request === 'duplicate' || shard) return;
@@ -648,23 +720,28 @@ let shuttingDown;
648
720
  return shutDown();
649
721
  }
650
722
  /** The one shutdown of the process, however many ask for it: a stop request joins one already under way. */ function shutDown() {
651
- shuttingDown ?? (shuttingDown = (async ()=>{
723
+ var _processState;
724
+ (_processState = processState).shuttingDown ?? (_processState.shuttingDown = (async ()=>{
725
+ // A clean stop keeps a code already set, such as the 1 a failed login set, so a supervisor still sees it. Read as
726
+ // the shutdown begins: a code main.ts sets as it hears of the stop is the stop's, not a failure.
727
+ const exitCode = Number(process.exitCode ?? 0) || 0;
652
728
  const closed = await Promise.all([
653
729
  ...runningApps
654
730
  ].map((close)=>close()));
655
- // A clean stop keeps a code already set, such as the 1 a failed login set, so a supervisor still sees it
656
- process.exit(closed.every(Boolean) ? Number(process.exitCode ?? 0) || 0 : 1);
731
+ process.exit(closed.every(Boolean) ? exitCode : 1);
657
732
  })());
658
- return shuttingDown;
733
+ return processState.shuttingDown;
659
734
  }
735
+ /** The errors {@link stoppedBeforeOnline} made, which end a start without being its failure. */ const stops = new WeakSet();
660
736
  /** What `start()` rejects with when shutdown begins before the bot came online, which shutdown has logged. */ function stoppedBeforeOnline() {
661
737
  const error = new Error('The bot was stopped before it came online.');
662
738
  controller_decorator.markExplained(error);
739
+ stops.add(error);
663
740
  return error;
664
741
  }
665
742
  /** One pair of signal listeners for the process, however many apps it starts, for the role the first decided. */ function installSignalHandlers(shard) {
666
- if (signalHandlersInstalled) return;
667
- signalHandlersInstalled = true;
743
+ if (processState.signalHandlersInstalled) return;
744
+ processState.signalHandlersInstalled = true;
668
745
  process.on('SIGINT', ()=>void shutdownAndExit(shard));
669
746
  process.on('SIGTERM', ()=>void shutdownAndExit(shard));
670
747
  // How `meocord start --dev` stops the application to restart it, a signal being no graceful stop on Windows. It is
@@ -717,13 +794,21 @@ class MeoCordApp {
717
794
  }
718
795
  }
719
796
  /**
720
- * Logs the bot in, as {@link MeoCordApplication.start} describes. The providers, services and event handlers are set
721
- * up once, or again after a provider's factory failed; a retry after a failed login reuses them.
797
+ * Logs the bot in, as {@link MeoCordApplication.start} describes. The event handlers are attached once; the providers
798
+ * and services are made for each start, as a failed one, of a factory or the login, shuts them down and drops them.
722
799
  */ async start() {
723
800
  if (isRegisterOnly()) return this.registerOnly();
724
801
  if (this.stopped) throw new Error('This app was stopped; use MeoCordFactory.create to make a new one.');
725
802
  if (this.online) return;
726
- this.starting ?? (this.starting = this.startOnce().catch((error)=>{
803
+ this.starting ?? (this.starting = this.startOnce().catch(async (error)=>{
804
+ // A failed start stays among the running apps while it shuts down what it made, which a stop meanwhile waits
805
+ // for, and then while it holds a value it was given open, which a signal closes as stop() does. One that holds
806
+ // nothing leaves, so a signal later stops only the apps that run; a stopped start stays until its close settles
807
+ if (!this.closing) {
808
+ this.undoing = this.undoStart().finally(()=>this.undoing = undefined);
809
+ await this.undoing;
810
+ if (!this.closing && !this.holdsOpen()) runningApps.delete(this.close);
811
+ }
727
812
  // A shard ends, for its manager to restart, or to stop all if told it is fatal; a stopped start is the stop's
728
813
  if (this.shard && !this.closing) endFailedShard(error);
729
814
  throw error;
@@ -744,6 +829,30 @@ class MeoCordApp {
744
829
  // As a signal's shutdown exits 1 for a client that failed to close, a code another failure set aside
745
830
  if (!await this.close() && !process.exitCode) process.exitCode = 1;
746
831
  }
832
+ /**
833
+ * Shuts down what a failed start made, by the rule a stop follows, and drops it from the container, so the process
834
+ * holds nothing of it open and a retry makes it afresh.
835
+ */ async undoStart() {
836
+ if (!this.attempt || !this.begun) return;
837
+ this.begun = false;
838
+ const { given, constructed } = this.attempt;
839
+ const entries = this.shutdownEntries().filter(({ token })=>!given.has(token));
840
+ // A value the app gave stays constructed, so a stop with no retry still closes it
841
+ for (const token of constructed)if (!given.has(token)) constructed.delete(token);
842
+ this.readySettled.clear();
843
+ this.attempt.drop();
844
+ this.made = false;
845
+ // Nothing came online, so no call runs and no listener needs removing
846
+ await eventSources.runShutdownSequence(this.container, ()=>entries, {
847
+ drainCalls: ()=>this.calls.settled(),
848
+ timeoutMs: this.shutdownTimeout,
849
+ hookFailed: (name, error)=>this.logger.error(`onShutdown failed in ${name}:`, error),
850
+ warn: (message)=>this.logger.warn(message)
851
+ });
852
+ }
853
+ /** Whether a shutdown has an `onShutdown` to run, as for a value the app gave once a failed start is undone. */ holdsOpen() {
854
+ return this.shutdownEntries().some(({ instance })=>typeof instance.onShutdown === 'function');
855
+ }
747
856
  async startOnce() {
748
857
  this.logger.log('Starting bot...');
749
858
  // Before the providers are made, so a signal, or a shard's manager, can stop a start that is slow to come online
@@ -754,7 +863,9 @@ class MeoCordApp {
754
863
  // A bot runs one app, whose theme code outside any call reads, its providers' factories included; claimed for
755
864
  // each start, as a failed one gives it up
756
865
  providers.claimAmbientAppTheme(this.container);
757
- if (!this.prepared) {
866
+ if (!this.made) {
867
+ this.attempt?.begin();
868
+ this.begun = true;
758
869
  // Kept while it runs, so a stop meanwhile waits for it
759
870
  this.preparing = this.prepare();
760
871
  try {
@@ -762,7 +873,11 @@ class MeoCordApp {
762
873
  } finally{
763
874
  this.preparing = undefined;
764
875
  }
765
- this.prepared = true;
876
+ this.made = true;
877
+ }
878
+ if (!this.attached) {
879
+ this.attachHandlers();
880
+ this.attached = true;
766
881
  }
767
882
  if (this.loginFailed) {
768
883
  controller_decorator.warnDeprecatedBehaviour(this.logger, 'Retrying start() after a failed login', 'rejects', 'MeoCordFactory.create to make a new app');
@@ -770,7 +885,7 @@ class MeoCordApp {
770
885
  }
771
886
  await this.login();
772
887
  }
773
- /** Makes the provided values and listed services, and attaches the Discord event handlers. */ async prepare() {
888
+ /** Makes the provided values and listed services. */ async prepare() {
774
889
  // Every provided value is made before anything that injects it is resolved, and before login
775
890
  if (this.startup) {
776
891
  try {
@@ -782,7 +897,7 @@ class MeoCordApp {
782
897
  providers.releaseAmbientAppTheme(this.container);
783
898
  if (process.exitCode === undefined || process.exitCode === 0) {
784
899
  process.exitCode = 1;
785
- MeoCordApp.failedLoginSetExitCode = true;
900
+ processState.startFailureSetExitCode = true;
786
901
  }
787
902
  throw error;
788
903
  }
@@ -795,6 +910,8 @@ class MeoCordApp {
795
910
  this.storeReady = Promise.withResolvers();
796
911
  providers.waitForCooldownStore(this.container, this.storeReady.promise);
797
912
  }
913
+ }
914
+ /** Attaches the Discord event handlers. */ attachHandlers() {
798
915
  this.bot.on('clientReady', (readyClient)=>this.runListener('clientReady', async ()=>{
799
916
  // Only for activities the app listed: without them, a status it sets itself stays as it set it
800
917
  if (this.activities?.length) {
@@ -807,7 +924,7 @@ class MeoCordApp {
807
924
  if (!this.shard) await this.registerCommands();
808
925
  await readyHooks;
809
926
  }));
810
- this.bot.on('interactionCreate', startupRoots.ownInteractionListener((interaction)=>this.runListener('interactionCreate', ()=>this.dispatcher.interaction(interaction), interactionLabel(interaction))));
927
+ this.bot.on('interactionCreate', eventSources.ownInteractionListener((interaction)=>this.runListener('interactionCreate', ()=>this.dispatcher.interaction(interaction), interactionLabel(interaction))));
811
928
  this.bot.on('messageCreate', (message)=>this.runListener('messageCreate', ()=>this.dispatcher.message(message)));
812
929
  this.bot.on('messageReactionAdd', (reaction, user)=>this.runListener('messageReactionAdd', ()=>this.dispatcher.reaction(reaction, {
813
930
  user,
@@ -819,9 +936,10 @@ class MeoCordApp {
819
936
  })));
820
937
  // discord.js drops a reaction in a DM channel it has not cached; this fetches the channel and delivers it
821
938
  deliverUncachedDmReactions(this.bot, this.logger);
822
- this.attachEventHandlers();
823
- this.warnAboutMissingRequirements();
824
- this.noteGlobalStagesOnEvents();
939
+ const sources = this.eventSources();
940
+ this.attachEventHandlers(sources);
941
+ this.warnAboutMissingRequirements(sources);
942
+ this.noteGlobalStagesOnEvents(sources);
825
943
  }
826
944
  /** Logs the bot in, setting the exit code when that fails and clearing it when a later attempt succeeds. */ async login() {
827
945
  // Checked here as well, after the last await before the login: from here to setting abortLogin runs in one turn,
@@ -842,7 +960,6 @@ class MeoCordApp {
842
960
  ]);
843
961
  } catch (error) {
844
962
  if (this.closing) throw error;
845
- runningApps.delete(this.close);
846
963
  // A bot that never came online is not the app whose theme is read outside calls
847
964
  providers.releaseAmbientAppTheme(this.container);
848
965
  const fatal = fatalLoginCode(error);
@@ -857,12 +974,12 @@ class MeoCordApp {
857
974
  if (fatal) await reportFatalLogin(this.shard, fatal, explanation ?? (error instanceof Error ? error.message : String(error)));
858
975
  if (process.exitCode === undefined || process.exitCode === 0) {
859
976
  process.exitCode = 1;
860
- MeoCordApp.failedLoginSetExitCode = true;
977
+ processState.startFailureSetExitCode = true;
861
978
  }
862
979
  await tellDevRunner({
863
980
  meocord: 'login-failed'
864
981
  }, this.shard);
865
- MeoCordApp.toldDevRunnerLoginFailed = true;
982
+ processState.toldDevRunnerLoginFailed = true;
866
983
  this.loginFailed = true;
867
984
  throw error;
868
985
  } finally{
@@ -871,12 +988,12 @@ class MeoCordApp {
871
988
  // A shutdown that began as the login completed has the client to close; the bot is not reported online
872
989
  if (this.closing) throw stoppedBeforeOnline();
873
990
  // 0 rather than undefined, which Bun ignores
874
- if (MeoCordApp.failedLoginSetExitCode && process.exitCode === 1) {
991
+ if (processState.startFailureSetExitCode && process.exitCode === 1) {
875
992
  process.exitCode = 0;
876
- MeoCordApp.failedLoginSetExitCode = false;
993
+ processState.startFailureSetExitCode = false;
877
994
  }
878
- if (MeoCordApp.toldDevRunnerLoginFailed) {
879
- MeoCordApp.toldDevRunnerLoginFailed = false;
995
+ if (processState.toldDevRunnerLoginFailed) {
996
+ processState.toldDevRunnerLoginFailed = false;
880
997
  await tellDevRunner({
881
998
  meocord: 'online'
882
999
  }, this.shard);
@@ -886,11 +1003,16 @@ class MeoCordApp {
886
1003
  }
887
1004
  /**
888
1005
  * Registers the application's commands with Discord, where `meocord.config.ts`'s `commands` says. Runs once the bot
889
- * is ready, except in a shard, whose manager registers. It never throws: a failure is logged and the bot stays
1006
+ * is ready, except in a shard, whose manager registers. Called before the bot is online, it logs a warning and
1007
+ * registers nothing, as the application comes from the login. It never throws: a failure is logged and the bot stays
890
1008
  * online.
891
1009
  */ async registerCommands() {
1010
+ // discord.js sets the application when the gateway's READY arrives
892
1011
  const applicationId = this.bot.application?.id;
893
- if (!applicationId) return;
1012
+ if (!applicationId) {
1013
+ this.logger.warn('registerCommands() registers nothing before the bot is online, as Discord names the application at login. ' + 'The bot registers once it is online; to register without logging in, run `meocord register`.');
1014
+ return;
1015
+ }
894
1016
  const config = controller_decorator.loadMeoCordConfig()?.commands;
895
1017
  if (config?.register === false) {
896
1018
  this.logger.log('Command registration is off (commands.register: false); run `meocord register` to register.');
@@ -902,7 +1024,7 @@ class MeoCordApp {
902
1024
  controllerClasses: this.controllerClasses,
903
1025
  logger: this.logger,
904
1026
  config,
905
- development: process.env.NODE_ENV === 'development',
1027
+ development: registersAsDevelopment(),
906
1028
  force: process.env[FORCE_REGISTER_ENV] === '1'
907
1029
  });
908
1030
  }
@@ -933,24 +1055,25 @@ class MeoCordApp {
933
1055
  controllerClasses: this.controllerClasses,
934
1056
  logger: this.logger,
935
1057
  config: controller_decorator.loadMeoCordConfig()?.commands,
936
- development: process.env.NODE_ENV === 'development',
1058
+ development: registersAsDevelopment(),
937
1059
  onlyGuild: process.env[REGISTER_GUILD_ENV],
938
1060
  force: true
939
1061
  });
940
1062
  process.exit(registered ? 0 : 1);
941
1063
  }
942
1064
  /**
943
- * Adds a client listener for every `@On` and `@Once` handler on the app's controllers and services, each event run on
944
- * the class's one instance from the container. Each call is isolated: an error is logged against the event and the
945
- * handler, and the next listener still runs.
946
- */ attachEventHandlers() {
947
- for (const lifecycleClass of this.lifecycleClasses){
948
- for (const { event, method, once } of providers.getEventHandlers(lifecycleClass.prototype)){
949
- const logError = (error)=>this.logger.error(`Error handling event "${event}" in ${lifecycleClass.name}.${method}:`, error);
1065
+ * Adds a client listener for every `@On` and `@Once` handler on each instance whose lifecycle hooks run, in lifecycle
1066
+ * order, each event run on the instance the container holds. Each call is isolated: an error is logged against the
1067
+ * event and the handler, and the next listener still runs.
1068
+ */ attachEventHandlers(sources) {
1069
+ for (const source of sources){
1070
+ const { name } = source.cls;
1071
+ for (const { event, method, once } of source.handlers){
1072
+ const logError = (error)=>this.logger.error(`Error handling event "${event}" in ${name}.${method}:`, error);
950
1073
  // An error no filter handles is logged, with the handler. A guard denying an event only filters which
951
1074
  // events the handler takes, and a UserError is meant for the sender of the event's message, if it has one
952
1075
  const fallback = async (error, context)=>{
953
- const where = `event "${event}" in ${lifecycleClass.name}.${method}`;
1076
+ const where = `event "${event}" in ${name}.${method}`;
954
1077
  if (error instanceof controller_decorator.GuardDeniedError) this.logger.debug(`Denied ${where}: ${controller_decorator.escapeForLog(error.message)}`);
955
1078
  else if (error instanceof controller_decorator.UserError) {
956
1079
  this.logger.debug(`Refused ${where}: ${controller_decorator.escapeForLog(error.message)}`);
@@ -958,16 +1081,16 @@ class MeoCordApp {
958
1081
  const message = [
959
1082
  ...context.getArgs()
960
1083
  ].reverse().find((arg)=>arg instanceof discord_js.Message);
961
- if (message) return startupRoots.replyWithUserError(message, error, this.logger, this.messageOptions?.replyEmoji);
1084
+ if (message) return eventSources.replyWithUserError(message, error, this.logger, this.messageOptions?.replyEmoji);
962
1085
  } else logError(error);
963
1086
  return undefined;
964
1087
  };
965
- const label = `@${once ? 'Once' : 'On'}('${event}') in ${lifecycleClass.name}.${method}`;
1088
+ const label = `@${once ? 'Once' : 'On'}('${event}') in ${name}.${method}`;
966
1089
  // A call, as a dispatched one is, which shutdown waits for
967
1090
  const listener = (...args)=>this.calls.run(label, async ()=>{
968
1091
  try {
969
- const instance = this.container.get(lifecycleClass);
970
- await providers.runHandler(this.container, instance, method, args, {
1092
+ const instance = source.resolve();
1093
+ if (instance) await providers.runHandler(this.container, instance, method, args, {
971
1094
  fallback,
972
1095
  type: 'event'
973
1096
  });
@@ -984,8 +1107,8 @@ class MeoCordApp {
984
1107
  /**
985
1108
  * Says, once per stage, which global guards and interceptors declare no `types` and so also run
986
1109
  * before `@On` and `@Once` handlers, when the app has any.
987
- */ noteGlobalStagesOnEvents() {
988
- if (!this.lifecycleClasses.some((cls)=>providers.getEventHandlers(cls.prototype).length > 0)) return;
1110
+ */ noteGlobalStagesOnEvents(sources) {
1111
+ if (sources.length === 0) return;
989
1112
  const { guards, interceptors } = providers.globalStagesOf(this.container);
990
1113
  for (const [label, decorator, entries] of [
991
1114
  [
@@ -1008,29 +1131,29 @@ class MeoCordApp {
1008
1131
  /**
1009
1132
  * Warns about handlers whose events the client options will not deliver, once per missing intent or partial. Only a
1010
1133
  * controller's message and reaction handlers are counted, as only those run.
1011
- */ warnAboutMissingRequirements() {
1134
+ */ warnAboutMissingRequirements(sources) {
1012
1135
  const options = this.bot.options;
1013
1136
  if (!options?.intents) return;
1014
- const controllers = new Set(this.controllerClasses);
1015
1137
  const handlers = [];
1016
- for (const lifecycleClass of this.lifecycleClasses){
1017
- const prototype = lifecycleClass.prototype;
1018
- for (const { event, method, once } of providers.getEventHandlers(prototype)){
1138
+ for (const { cls, handlers: events } of sources){
1139
+ for (const { event, method, once } of events){
1019
1140
  handlers.push({
1020
- label: `@${once ? 'Once' : 'On'}('${event}') in ${lifecycleClass.name}.${method}`,
1141
+ label: `@${once ? 'Once' : 'On'}('${event}') in ${cls.name}.${method}`,
1021
1142
  requirements: eventRequirements(event)
1022
1143
  });
1023
1144
  }
1024
- if (!controllers.has(lifecycleClass)) continue;
1145
+ }
1146
+ for (const controller of this.controllerClasses){
1147
+ const prototype = controller.prototype;
1025
1148
  for (const handler of controller_decorator.getMessageHandlers(prototype)){
1026
1149
  handlers.push({
1027
- label: `${startupRoots.messageDecorator(handler.pattern)} in ${lifecycleClass.name}.${handler.method}`,
1150
+ label: `${eventSources.messageDecorator(handler.pattern)} in ${controller.name}.${handler.method}`,
1028
1151
  requirements: messageHandlerRequirements(handler, this.messageOptions ?? {})
1029
1152
  });
1030
1153
  }
1031
1154
  for (const { emoji, method } of controller_decorator.getReactionHandlers(prototype)){
1032
1155
  handlers.push({
1033
- label: `${startupRoots.reactionDecorator(emoji)} in ${lifecycleClass.name}.${method}`,
1156
+ label: `${eventSources.reactionDecorator(emoji)} in ${controller.name}.${method}`,
1034
1157
  requirements: REACTION_HANDLER_REQUIREMENTS
1035
1158
  });
1036
1159
  }
@@ -1042,11 +1165,9 @@ class MeoCordApp {
1042
1165
  * at a time, in dependency order. A failure is logged and the next one still runs, with a warning for each hook
1043
1166
  * whose dependencies failed. Once shutdown begins, no further hook starts.
1044
1167
  */ async runReadyHooks(client) {
1045
- const entries = [];
1046
- this.lifecycleEntries = entries;
1047
- await this.readyPass.run(()=>startupRoots.runReadyHooks(this.container, this.lifecycleUnits, client, {
1168
+ await this.readyPass.run(()=>eventSources.runReadyHooks(this.container, this.lifecycleUnits, client, {
1048
1169
  primary: client.shard ? client.shard.ids.includes(0) : true
1049
- }, entries, {
1170
+ }, {
1050
1171
  resolveFailed: (unit, error)=>this.logger.error(`Could not resolve ${unit.name} to run its lifecycle hooks:`, error),
1051
1172
  hookFailed: (unit, error)=>this.logger.error(`onReady failed in ${unit.name}:`, error),
1052
1173
  dependsOnFailed: (unit, failed)=>this.logger.warn(`Running onReady in ${unit.name} although it depends on ${failed.map(({ name })=>name).join(', ')}, which failed.`),
@@ -1055,15 +1176,37 @@ class MeoCordApp {
1055
1176
  {
1056
1177
  stopped: ()=>this.closing,
1057
1178
  slowAfterMs: SLOW_READY_HOOK_MS,
1058
- settled: (unit)=>unit.cooldownStore && this.storeReady?.resolve(),
1179
+ settled: (unit)=>{
1180
+ this.readySettled.add(unit.token);
1181
+ if (unit.cooldownStore) this.storeReady?.resolve();
1182
+ },
1059
1183
  runOnReady: this.readyPass.runOnReady
1060
1184
  }));
1061
1185
  // Also when shutdown stopped the hooks before the store's, so no call waits on it
1062
1186
  this.storeReady?.resolve();
1063
1187
  }
1064
- /** Runs the `onShutdown` hooks through the shutdown sequence, within the configured `shutdownTimeout`. */ async runShutdownHooks() {
1065
- // Read once the onReady in progress settled; a login that failed never ran onReady, so there is no onShutdown to undo
1066
- await startupRoots.runShutdownSequence(this.container, ()=>this.lifecycleEntries ?? [], {
1188
+ /**
1189
+ * The units a shutdown closes: each one the container constructed whose `onReady`, and that of every unit it depends
1190
+ * on, has settled or doesn't exist, in dependency order. A unit whose `onReady` never ran gets no `onShutdown`, so a
1191
+ * hook that undoes `onReady` never runs without it, and neither does anything that depends on it.
1192
+ */ shutdownEntries() {
1193
+ const units = new Set(this.lifecycleUnits.map(({ token })=>token));
1194
+ const closes = new Set();
1195
+ const entries = [];
1196
+ for (const unit of this.lifecycleUnits){
1197
+ if (!this.attempt?.constructed.has(unit.token)) continue;
1198
+ // Already made, so this returns the instance; a provided value may be anything, null included
1199
+ const instance = this.container.get(unit.token) ?? {};
1200
+ const ready = typeof instance.onReady !== 'function' || this.readySettled.has(unit.token);
1201
+ if (!ready || unit.dependencies.some((dependency)=>units.has(dependency) && !closes.has(dependency))) continue;
1202
+ closes.add(unit.token);
1203
+ entries.push(eventSources.lifecycleEntry(unit, instance));
1204
+ }
1205
+ return entries;
1206
+ }
1207
+ /** Runs the `onShutdown` hooks through the shutdown sequence, within `timeoutMs`: the stop's share of `shutdownTimeout`. */ async runShutdownHooks(timeoutMs) {
1208
+ // Read once the onReady in progress settled
1209
+ await eventSources.runShutdownSequence(this.container, ()=>this.shutdownEntries(), {
1067
1210
  // No new call starts once the listeners are gone; an error the client emits meanwhile still has its listeners
1068
1211
  drainCalls: ()=>{
1069
1212
  const emitter = this.bot;
@@ -1073,7 +1216,7 @@ class MeoCordApp {
1073
1216
  drainAlways: true,
1074
1217
  runningCalls: ()=>this.calls.running(),
1075
1218
  readyPass: this.readyPass,
1076
- timeoutMs: this.shutdownTimeout,
1219
+ timeoutMs,
1077
1220
  hookFailed: (name, error)=>this.logger.error(`onShutdown failed in ${name}:`, error),
1078
1221
  warn: (message)=>this.logger.warn(message)
1079
1222
  });
@@ -1081,7 +1224,8 @@ class MeoCordApp {
1081
1224
  /**
1082
1225
  * Runs the shutdown hooks, if the ready hooks ran, then destroys the client; a stop during login ends the login.
1083
1226
  *
1084
- * @returns Whether the client closed cleanly, which an ended login counts as.
1227
+ * @returns Whether the client closed cleanly, which an ended login counts as, and the startup it waited for, if any,
1228
+ * did not fail.
1085
1229
  */ async closeClient() {
1086
1230
  this.closing = true;
1087
1231
  try {
@@ -1094,80 +1238,98 @@ class MeoCordApp {
1094
1238
  /**
1095
1239
  * Waits for the startup under way to stop making providers and services, at most `shutdownTimeout`; past it, warns
1096
1240
  * with the factory it still awaits, which then makes nothing more.
1241
+ *
1242
+ * @returns Whether the startup failed meanwhile, as a provider's factory that rejects does, rather than stopping.
1097
1243
  */ async waitForStartup(preparing) {
1098
1244
  let timer;
1099
1245
  const outlasted = new Promise((resolve)=>{
1100
- timer = setTimeout(()=>resolve(true), this.shutdownTimeout);
1246
+ timer = setTimeout(()=>resolve('outlasted'), this.shutdownTimeout);
1101
1247
  });
1102
- const settled = preparing.then(()=>false, ()=>false);
1103
- if (await Promise.race([
1248
+ const settled = preparing.then(()=>'stopped', (error)=>error instanceof Error && stops.has(error) ? 'stopped' : 'failed');
1249
+ const outcome = await Promise.race([
1104
1250
  settled,
1105
1251
  outlasted
1106
- ])) {
1252
+ ]);
1253
+ clearTimeout(timer);
1254
+ if (outcome === 'outlasted') {
1107
1255
  const what = this.pendingProvider ? `the factory providing ${this.pendingProvider}` : 'its providers';
1108
1256
  this.logger.warn(`The startup still waits for ${what} after ${this.shutdownTimeout} ms; shutting down without it.`);
1109
1257
  }
1110
- clearTimeout(timer);
1258
+ return outcome === 'failed';
1111
1259
  }
1112
1260
  async shutDownClient() {
1113
1261
  this.logger.log('Shutting down bot...');
1114
- // So nothing is made after the bot has shut down
1115
- if (this.preparing) await this.waitForStartup(this.preparing);
1116
- // Nothing came online, so there are no hooks to undo; the listeners go first, so none of them runs
1262
+ // A failed start's shutdown under way finishes first, so its hooks run in full, before those of what it left open.
1263
+ // `shutdownTimeout` bounds the whole stop, as a shard's manager waits only that long, so the rest gets what is left
1264
+ let timeoutMs = this.shutdownTimeout;
1265
+ if (this.undoing) {
1266
+ const waitedFrom = Date.now();
1267
+ await this.undoing;
1268
+ timeoutMs = Math.max(0, timeoutMs - (Date.now() - waitedFrom));
1269
+ }
1270
+ // So nothing is made after the bot has shut down; a factory that fails meanwhile is a failure of the stop too
1271
+ const startupFailed = this.preparing !== undefined && await this.waitForStartup(this.preparing);
1272
+ // Nothing came online, so no onReady ran; the listeners go first, so none of them runs, then what the start made
1273
+ // without an onReady to wait for shuts down
1117
1274
  if (this.abortLogin) {
1118
1275
  this.logger.log('The bot was still logging in, so it stops without coming online');
1119
1276
  this.bot.removeAllListeners();
1120
1277
  this.abortLogin();
1278
+ await this.runShutdownHooks(timeoutMs);
1121
1279
  this.logger.log('Bot has shut down');
1122
1280
  return true;
1123
1281
  }
1124
1282
  if (this.activityInterval) clearInterval(this.activityInterval);
1125
1283
  // The calls drain on every shutdown, and the onReady in progress settles first
1126
- await this.runShutdownHooks();
1284
+ await this.runShutdownHooks(timeoutMs);
1127
1285
  try {
1128
1286
  this.bot.removeAllListeners();
1129
1287
  await this.bot.destroy();
1130
1288
  this.logger.log('Bot has shut down');
1131
- return true;
1289
+ return !startupFailed;
1132
1290
  } catch (error) {
1133
1291
  this.logger.error('Error during shutdown:', error);
1134
1292
  return false;
1135
1293
  }
1136
1294
  }
1137
- 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){
1295
+ constructor(controllerClasses, container, discordClient, discordToken, activities, /** The instances whose `@On` and `@Once` handlers run, read once the providers are made. */ eventSources$1 = ()=>[], shutdownTimeout, startup, lifecycleUnits, messageOptions = {}, warnUnanswered = false, /** Whether a manager spawned this process, as `MeoCordFactory.create` decided before anything could change it. */ shard = false, /** What each start makes; without one, as an app built by hand in a spec, nothing is shut down before ready. */ attempt){
1138
1296
  this.controllerClasses = controllerClasses;
1139
1297
  this.container = container;
1140
1298
  this.discordClient = discordClient;
1141
1299
  this.discordToken = discordToken;
1142
1300
  this.activities = activities;
1143
- this.lifecycleClasses = lifecycleClasses;
1301
+ this.eventSources = eventSources$1;
1144
1302
  this.startup = startup;
1145
1303
  this.messageOptions = messageOptions;
1146
1304
  this.shard = shard;
1305
+ this.attempt = attempt;
1147
1306
  this.logger = new controller_decorator.Logger(MeoCordApp.name);
1148
- this.fallback = startupRoots.createFallback(this.logger, ()=>this.messageOptions, {
1307
+ this.fallback = eventSources.createFallback(this.logger, ()=>this.messageOptions, {
1149
1308
  cooldownNotice: (refusal)=>providers.claimCooldownNotice(this.container, refusal),
1150
1309
  storeDownNotice: (who)=>providers.claimStoreDownNotice(this.container, who)
1151
1310
  });
1152
1311
  this.activityInterval = null;
1153
1312
  /** Where the activities' rotation is: the index of the next one shown. */ this.nextActivity = 0;
1154
1313
  /** Whether shutdown has begun, so the ready hooks start no more. */ this.closing = false;
1314
+ /** The tokens of the units whose `onReady` the ready pass has settled, or found they have none. */ this.readySettled = new Set();
1155
1315
  /**
1156
1316
  * Closes the app once, however many stops and signals ask: whether the client was destroyed cleanly. The app stays
1157
1317
  * among the running ones until the close settles, so a signal or a manager's request meanwhile waits for it.
1158
1318
  */ this.close = ()=>this.stopped ?? (this.stopped = this.closeClient().finally(()=>runningApps.delete(this.close)));
1159
- /** The calls under way, which shutdown waits for, less one that asked the app to stop. */ this.calls = new startupRoots.RunningCalls();
1160
- /** The ready hooks' pass, whose `onReady` in progress shutdown waits for, less one that asked the app to stop. */ this.readyPass = new startupRoots.ReadyPass();
1161
- /** Whether the providers, services and event handlers are set up, which a retry after a failed login reuses. */ this.prepared = false;
1319
+ /** The calls under way, which shutdown waits for, less one that asked the app to stop. */ this.calls = new eventSources.RunningCalls();
1320
+ /** The ready hooks' pass, whose `onReady` in progress shutdown waits for, less one that asked the app to stop. */ this.readyPass = new eventSources.ReadyPass();
1321
+ /** Whether a start has marked where it began and not been undone, so a failed one drops what it made exactly once. */ this.begun = false;
1322
+ /** Whether this start's providers and services are made; a failed start drops them, so a retry makes them again. */ this.made = false;
1323
+ /** Whether the Discord event handlers are attached, once for the app: they resolve what they run as each event comes. */ this.attached = false;
1162
1324
  /** Whether the bot logged in, after which `start()` has nothing to do. */ this.online = false;
1163
1325
  /** Whether a login of this app failed, so a retry restores the client discord.js destroyed. */ this.loginFailed = false;
1164
1326
  // First, so nothing logged from here on, before login or after, prints it
1165
1327
  controller_decorator.hideInLogs(discordToken);
1166
- this.lifecycleUnits = lifecycleUnits ?? providers.classUnits(container, lifecycleClasses);
1328
+ this.lifecycleUnits = lifecycleUnits ?? [];
1167
1329
  this.bot = this.discordClient;
1168
1330
  // Built now, so a pattern that cannot be read or two that match the same messages or customIds stop the bot as it
1169
1331
  // is created, before start() attaches anything
1170
- this.dispatcher = new startupRoots.Dispatcher({
1332
+ this.dispatcher = new eventSources.Dispatcher({
1171
1333
  container,
1172
1334
  controllerClasses,
1173
1335
  messageOptions,
@@ -1178,11 +1340,9 @@ class MeoCordApp {
1178
1340
  });
1179
1341
  this.dispatcher.getComponentRoutes();
1180
1342
  // Read through the config's own check: a bot started without the CLI loads its config unchecked
1181
- this.shutdownTimeout = startupRoots.shutdownTimeoutOf(shutdownTimeout, (message)=>this.logger.warn(message));
1343
+ this.shutdownTimeout = eventSources.shutdownTimeoutOf(shutdownTimeout, (message)=>this.logger.warn(message));
1182
1344
  }
1183
1345
  }
1184
- /** Whether a failed login set the process exit code, so a later successful one knows to clear it. */ MeoCordApp.failedLoginSetExitCode = false;
1185
- /** Whether `meocord start --dev` was told the bot could not log in, so a later login tells it the bot is online. */ MeoCordApp.toldDevRunnerLoginFailed = false;
1186
1346
 
1187
1347
  /** The mode whose .env files Bun reads for a `NODE_ENV`: `production` or `test` as given, else `development`. */ const envMode = (nodeEnv)=>nodeEnv === 'production' || nodeEnv === 'test' ? nodeEnv : 'development';
1188
1348
  /**
@@ -1214,16 +1374,11 @@ class MeoCordApp {
1214
1374
  const productionFiles = envFiles('production');
1215
1375
  // In Bun's order, a later file winning, as the config's dotenv also settles them
1216
1376
  const production = Object.assign({}, ...productionFiles.map((file)=>read(root, file)));
1217
- const development = new Map();
1218
- for (const file of envFiles(env.NODE_ENV).filter((file)=>!productionFiles.includes(file))){
1219
- for (const [key, value] of Object.entries(read(root, file)))development.set(key, {
1220
- value,
1221
- file
1222
- });
1223
- }
1377
+ const files = envFiles(env.NODE_ENV);
1378
+ // Listed file by file, in Bun's order
1224
1379
  const loaded = [
1225
- ...development
1226
- ].filter(([key, { value }])=>env[key] === value && production[key] !== value);
1380
+ ...bunLoadedValues(env, root, files)
1381
+ ].filter(([key, { value, file }])=>!productionFiles.includes(file) && env[key] === value && production[key] !== value).sort(([, a], [, b])=>files.indexOf(a.file) - files.indexOf(b.file));
1227
1382
  if (loaded.length === 0) return none;
1228
1383
  return {
1229
1384
  files: [
@@ -1237,6 +1392,68 @@ class MeoCordApp {
1237
1392
  const mode = envMode(nodeEnv);
1238
1393
  return `Bun loaded ${controller_decorator.listed(files)} because NODE_ENV is ${nodeEnv || 'unset'}, and this is a production build, so ` + `${controller_decorator.listed(keys)} ${keys.length === 1 ? `has its ${mode} value` : `have their ${mode} values`}; set ` + 'NODE_ENV=production, or start with `bun --no-env-file`.';
1239
1394
  }
1395
+ /**
1396
+ * The value Bun gives each variable the .env files set, and the file it comes from: Bun loads them before any code
1397
+ * runs, the file that wins in `files` first, sets only a variable not already set, and expands `$NAME`, `${NAME}` and
1398
+ * `${NAME:-default}` line by line from what is set so far, a later line of the same file giving its text as written.
1399
+ * A variable the shell set keeps the shell's value, which `env` holds, so it differs from the one given here.
1400
+ */ function bunLoadedValues(env, root, files) {
1401
+ // Each variable's first line in Bun's order: the one Bun loads it from
1402
+ const lines = [];
1403
+ const first = new Map();
1404
+ for (const file of [
1405
+ ...files
1406
+ ].reverse()){
1407
+ const own = read(root, file);
1408
+ for (const [key, text] of Object.entries(own)){
1409
+ if (!first.has(key)) first.set(key, lines.length);
1410
+ lines.push({
1411
+ key,
1412
+ text,
1413
+ file,
1414
+ own
1415
+ });
1416
+ }
1417
+ }
1418
+ const values = new Map();
1419
+ const fromShell = new Map();
1420
+ // A variable the shell set holds a value its line doesn't give; one its own line refers to back counts as from it
1421
+ const shellSet = (key)=>{
1422
+ if (!fromShell.has(key)) {
1423
+ fromShell.set(key, false);
1424
+ fromShell.set(key, env[key] !== undefined && env[key] !== valueAt(first.get(key)));
1425
+ }
1426
+ return fromShell.get(key);
1427
+ };
1428
+ const valueAt = (index)=>{
1429
+ if (!values.has(index)) {
1430
+ const { text, file, own } = lines[index];
1431
+ const lookup = (name)=>{
1432
+ const at = first.get(name);
1433
+ if (at === undefined || shellSet(name) || at < index) return env[name] ?? '';
1434
+ // Not loaded yet: its text as written in this file, or unset until a later file loads it
1435
+ return lines[at].file === file ? own[name] : '';
1436
+ };
1437
+ values.set(index, expandAsBun(text, lookup));
1438
+ }
1439
+ return values.get(index);
1440
+ };
1441
+ return new Map([
1442
+ ...first
1443
+ ].map(([key, index])=>[
1444
+ key,
1445
+ {
1446
+ value: valueAt(index),
1447
+ file: lines[index].file
1448
+ }
1449
+ ]));
1450
+ }
1451
+ /** A `$NAME` or `${NAME}` reference, with `${NAME:-default}`'s default, or a `\$` Bun reads as a dollar sign. */ const BUN_REFERENCE = /\\\$|\$\{([A-Za-z0-9_]+)(?::-([^}]*))?\}|\$([A-Za-z0-9_]+)/g;
1452
+ /** A .env value as Bun expands it, each reference read through `lookup`; any other `$` stays as it is. */ const expandAsBun = (text, lookup)=>text.replace(BUN_REFERENCE, (match, braced, fallback, bare)=>{
1453
+ if (match === '\\$') return '$';
1454
+ const value = lookup(braced ?? bare);
1455
+ return fallback !== undefined && value === '' ? fallback : value;
1456
+ });
1240
1457
  /** A .env file's values, or none when it is not there to read. */ function read(root, file) {
1241
1458
  let text;
1242
1459
  try {
@@ -1279,7 +1496,10 @@ const sleep = (ms)=>new Promise((resolve)=>setTimeout(resolve, ms));
1279
1496
  return this.exit(1);
1280
1497
  }
1281
1498
  this.logger.log('Starting shards in separate processes...');
1282
- if (!await this.register()) return this.exitForLogin();
1499
+ const registered = await this.register();
1500
+ // A stop that came while it waited ends the start here: nothing after it asks Discord, spawns, listens or exits
1501
+ if (this.stopping) return;
1502
+ if (!registered) return this.exitForLogin();
1283
1503
  process.on('SIGINT', ()=>void this.stopAndExit());
1284
1504
  process.on('SIGTERM', ()=>void this.stopAndExit());
1285
1505
  // How `meocord start --dev` stops the bot to restart it; no stop request of the user's, whose first Ctrl+C joins it
@@ -1289,6 +1509,7 @@ const sleep = (ms)=>new Promise((resolve)=>setTimeout(resolve, ms));
1289
1509
  const shards = config.sharding?.shards ?? 'auto';
1290
1510
  total = shards === 'auto' ? await (this.options.recommendedShardCount ?? discord_js.fetchRecommendedShardCount)(token) : shards;
1291
1511
  } catch (error) {
1512
+ if (this.stopping) return;
1292
1513
  if (isRefusedToken(error)) {
1293
1514
  this.logger.error(tokenMessage(token));
1294
1515
  this.logger.debug('Asking for the shard count failed:', error);
@@ -1297,6 +1518,7 @@ const sleep = (ms)=>new Promise((resolve)=>setTimeout(resolve, ms));
1297
1518
  }
1298
1519
  return this.exitForLogin();
1299
1520
  }
1521
+ if (this.stopping) return;
1300
1522
  const manager = (this.options.createManager ?? ((path, opts)=>new discord_js.ShardingManager(path, opts)))(file, {
1301
1523
  token,
1302
1524
  totalShards: total,
@@ -1330,13 +1552,15 @@ const sleep = (ms)=>new Promise((resolve)=>setTimeout(resolve, ms));
1330
1552
  try {
1331
1553
  const rest = this.options.createRest?.(token) ?? new discord_js.REST().setToken(token);
1332
1554
  const applicationId = (await rest.get(discord_js.Routes.currentApplication())).id;
1555
+ // Stopped while it asked: the commands are left as they are
1556
+ if (this.stopping) return true;
1333
1557
  await providers.registerCommands({
1334
1558
  rest,
1335
1559
  applicationId,
1336
1560
  controllerClasses,
1337
1561
  logger: this.logger,
1338
1562
  config: config.commands,
1339
- development: process.env.NODE_ENV === 'development',
1563
+ development: registersAsDevelopment(),
1340
1564
  force: process.env[FORCE_REGISTER_ENV] === '1'
1341
1565
  });
1342
1566
  } catch (error) {
@@ -1378,7 +1602,8 @@ const sleep = (ms)=>new Promise((resolve)=>setTimeout(resolve, ms));
1378
1602
  if (this.now() - state.spawnedAt >= RESPAWN_RESET_MS) state.attempts = 0;
1379
1603
  const delay = Math.min(RESPAWN_BASE_MS * 2 ** state.attempts, RESPAWN_CAP_MS);
1380
1604
  state.attempts++;
1381
- this.logger.warn(`Shard ${shard.id} exited with code ${child?.exitCode ?? 'unknown'}; restarting it in ${delay} ms.`);
1605
+ const how = child?.signalCode ? `on ${child.signalCode}` : `with code ${child?.exitCode ?? 'unknown'}`;
1606
+ this.logger.warn(`Shard ${shard.id} exited ${how}; restarting it in ${delay} ms.`);
1382
1607
  state.timer = setTimeout(()=>{
1383
1608
  state.timer = undefined;
1384
1609
  if (!this.stopping && !this.fatal) void this.spawn(shard);
@@ -1451,7 +1676,7 @@ const sleep = (ms)=>new Promise((resolve)=>setTimeout(resolve, ms));
1451
1676
  if (shard.process) shard.kill();
1452
1677
  });
1453
1678
  }
1454
- const wait = this.shutdownTimeout + startupRoots.SHUTDOWN_MARGIN_MS;
1679
+ const wait = this.shutdownTimeout + eventSources.SHUTDOWN_MARGIN_MS;
1455
1680
  let timer;
1456
1681
  const timedOut = new Promise((resolve)=>{
1457
1682
  timer = setTimeout(()=>resolve('timeout'), wait);
@@ -1492,7 +1717,7 @@ const sleep = (ms)=>new Promise((resolve)=>setTimeout(resolve, ms));
1492
1717
  this.sleep = options.sleep ?? sleep;
1493
1718
  this.now = options.now ?? (()=>Date.now());
1494
1719
  this.stopRequest = stopRequests(this.now);
1495
- this.shutdownTimeout = startupRoots.shutdownTimeoutOf(options.config.shutdownTimeout, (message)=>this.logger.warn(message));
1720
+ this.shutdownTimeout = eventSources.shutdownTimeoutOf(options.config.shutdownTimeout, (message)=>this.logger.warn(message));
1496
1721
  }
1497
1722
  }
1498
1723
 
@@ -1507,8 +1732,7 @@ const sleep = (ms)=>new Promise((resolve)=>setTimeout(resolve, ms));
1507
1732
  container.bind(cls).toSelf().inSingletonScope();
1508
1733
  // By constructor type or @inject token; an interface-typed parameter records Object, which is skipped
1509
1734
  for (const dep of providers.injectedTokens(cls)){
1510
- if (dep === controller_decorator.Translator && !container.isBound(controller_decorator.Translator)) controller_decorator.startupError(controller_decorator.missingTranslatorError(cls));
1511
- else if (providers.isAppClassToken(dep)) bindDependencies(container, dep, providers$1);
1735
+ if (providers.isAppClassToken(dep)) bindDependencies(container, dep, providers$1);
1512
1736
  }
1513
1737
  }
1514
1738
  /**
@@ -1597,6 +1821,10 @@ const sleep = (ms)=>new Promise((resolve)=>setTimeout(resolve, ms));
1597
1821
  controller_decorator.markExplained(error);
1598
1822
  }
1599
1823
  }
1824
+ // A built bot that refuses its app exits 1, as one whose start fails does, whatever main.ts does with the error
1825
+ if (!shard && controller_decorator.isBuiltApplication() && controller_decorator.isRefusal(error) && (process.exitCode === undefined || process.exitCode === 0)) {
1826
+ process.exitCode = 1;
1827
+ }
1600
1828
  throw error;
1601
1829
  }
1602
1830
  }
@@ -1627,31 +1855,31 @@ const sleep = (ms)=>new Promise((resolve)=>setTimeout(resolve, ms));
1627
1855
  // error each check finds is reported, the first thrown
1628
1856
  const providers$1 = controller_decorator.collectStartupErrors(()=>{
1629
1857
  declared.forEach(controller_decorator.startupError);
1630
- startupRoots.assertDistinctCommands(options.controllers);
1858
+ eventSources.assertDistinctCommands(options.controllers);
1631
1859
  const providers$1 = providers.providerMap(options.providers ?? [], '@MeoCord({ providers })');
1632
1860
  providers.assertProvidedServices(typeof target === 'function' ? target.name : String(target), options.services ?? [], providers$1, '@MeoCord({ providers })');
1633
1861
  return providers$1;
1634
1862
  });
1635
1863
  // A themeFor class is bound on its own, as the cooldown store is, and resolved like a service
1636
1864
  const themeResolver = controller_decorator.themeResolverClass(options.themeFor);
1637
- // The app's classes and every class they inject, which the container binds
1638
- const roots = [
1639
- ...options.controllers,
1640
- ...options.services ?? [],
1641
- ...themeResolver ? [
1642
- themeResolver
1643
- ] : [],
1644
- ...options.cooldownStore ? [
1645
- options.cooldownStore
1646
- ] : [],
1647
- ...providers.appObservers(target)
1648
- ];
1865
+ // Every class the app runs, its stages and presenter and what they inject included
1866
+ const stages = providers.appStages(target);
1867
+ const runs = eventSources.startupClasses({
1868
+ controllers: options.controllers,
1869
+ services: options.services ?? [],
1870
+ providers: providers$1,
1871
+ stages,
1872
+ presenter: options.presenter,
1873
+ cooldownStore: options.cooldownStore,
1874
+ themeFor: options.themeFor,
1875
+ observers: providers.appObservers(target)
1876
+ });
1649
1877
  // A shard's manager runs the same checks, so a sharded bot warns once
1650
1878
  if (!shard) {
1651
- startupRoots.warnUnregisteredCommands(options.controllers);
1652
- startupRoots.warnInheritedRoutes(options.controllers);
1653
- startupRoots.warnOverlappingPatterns(options.controllers);
1654
- startupRoots.warnHandlersOffControllers(options.controllers, providers.reachableClasses(roots, providers$1));
1879
+ eventSources.warnUnregisteredCommands(options.controllers);
1880
+ eventSources.warnInheritedRoutes(options.controllers);
1881
+ eventSources.warnOverlappingPatterns(options.controllers);
1882
+ eventSources.warnHandlersOffControllers(options.controllers, runs.classes);
1655
1883
  const developmentEnv = bunDevelopmentValues();
1656
1884
  if (developmentEnv.keys.length > 0) {
1657
1885
  this.logger.warn(bunDevelopmentWarning(developmentEnv));
@@ -1672,20 +1900,9 @@ const sleep = (ms)=>new Promise((resolve)=>setTimeout(resolve, ms));
1672
1900
  }
1673
1901
  // The checks of what the app binds and runs report every error each finds, and the first is thrown
1674
1902
  return controller_decorator.collectStartupErrors(()=>{
1675
- // Every class the app runs, its stages and presenter included, checked before binding, where inversify would
1676
- // otherwise fail first with an error about compiler options, or a guard only at its first call
1677
- const stages = providers.appStages(target);
1678
- const runs = startupRoots.startupClasses({
1679
- controllers: options.controllers,
1680
- services: options.services ?? [],
1681
- providers: providers$1,
1682
- stages,
1683
- presenter: options.presenter,
1684
- cooldownStore: options.cooldownStore,
1685
- themeFor: options.themeFor,
1686
- observers: providers.appObservers(target)
1687
- });
1688
- startupRoots.assertStartupClasses(runs, {
1903
+ // Every class the app runs checked before binding, where inversify would otherwise fail first with an error about
1904
+ // compiler options, or a guard only at its first call
1905
+ eventSources.assertStartupClasses(runs, {
1689
1906
  translator: options.i18n !== undefined || providers$1.has(controller_decorator.Translator)
1690
1907
  });
1691
1908
  // Outside a built application, the kept errors of the stages and other classes the app's classes reach
@@ -1698,7 +1915,7 @@ const sleep = (ms)=>new Promise((resolve)=>setTimeout(resolve, ms));
1698
1915
  providers.markStartupChecked(container, [
1699
1916
  ...runs.classes,
1700
1917
  ...providers.meocordClasses()
1701
- ], startupRoots.lateGuardCheck(providers$1, translator));
1918
+ ], eventSources.lateGuardCheck(providers$1, translator));
1702
1919
  providers.bindGlobalStages(container, stages);
1703
1920
  // Bound now, so no provider stands in for it; made once the checks pass, the one Client the app logs in with
1704
1921
  const clientOptions = shardedCooldownStore.clientOptionsWithSharding(this.effectiveConfig(meocordConfig, shard), options.clientOptions);
@@ -1709,7 +1926,8 @@ const sleep = (ms)=>new Promise((resolve)=>setTimeout(resolve, ms));
1709
1926
  }
1710
1927
  // Bound before the app's classes, so a class that injects it gets this instance; filled once they are bound
1711
1928
  const appClasses = [];
1712
- container.bind(providers.HandlerRegistry).toConstantValue(new providers.HandlerRegistry(appClasses, options.messages, ()=>options.i18n));
1929
+ const registry = new providers.HandlerRegistry(appClasses, options.messages, ()=>options.i18n, options.controllers);
1930
+ container.bind(providers.HandlerRegistry).toConstantValue(registry);
1713
1931
  container.bind(providers.ShardContext).toDynamicValue(()=>{
1714
1932
  const client = container.get(discord_js.Client);
1715
1933
  return new providers.ShardContext(client, (service, method, args)=>Reflect.get(client, providers.SHARD_CALL_KEY)(service, method, args));
@@ -1729,12 +1947,9 @@ const sleep = (ms)=>new Promise((resolve)=>setTimeout(resolve, ms));
1729
1947
  // After MeoCord's own tokens, which a provider may not replace, and before the app's classes, so
1730
1948
  // a class token that is provided is not also bound as itself
1731
1949
  for (const [token, provider] of providers$1){
1732
- // Bound later, or for each call, so not caught below; supported until 5.0 refuses it
1733
- if (token === controller_decorator.ThemeCache || token === controller_decorator.ExecutionContext) {
1734
- controller_decorator.warnOnce(this.logger, `${target.name}: @MeoCord({ providers }) provides ${providers.tokenName(token)}, which MeoCord binds itself; ` + 'the next major version (5.0) refuses it. Remove the provider.');
1735
- }
1736
- if (container.isBound(token)) {
1737
- controller_decorator.startupError(new Error(`${target.name}: @MeoCord({ providers }) cannot provide ${providers.tokenName(token)}, which MeoCord binds itself.`));
1950
+ const refusal = providers.appProviderRefusal(`${target.name}`, token, (own)=>container.isBound(own), this.logger);
1951
+ if (refusal) {
1952
+ controller_decorator.startupError(refusal);
1738
1953
  continue;
1739
1954
  }
1740
1955
  providers.bindProvider(container, provider, (cls)=>bindDependencies(container, cls, providers$1));
@@ -1751,7 +1966,7 @@ const sleep = (ms)=>new Promise((resolve)=>setTimeout(resolve, ms));
1751
1966
  const observers = providers.appObservers(target);
1752
1967
  for (const observer of observers)bindDependencies(container, observer, providers$1);
1753
1968
  // What the stages and presenter inject, bound now so a service among them is made as the bot comes online
1754
- const stageServices = startupRoots.stageDependencies(runs, providers$1, options.presenter);
1969
+ const stageServices = eventSources.stageDependencies(runs, providers$1, options.presenter);
1755
1970
  for (const service of stageServices)bindDependencies(container, service, providers$1);
1756
1971
  // What follows walks what these bound, so a class refused here is not reported again as missing
1757
1972
  controller_decorator.stopOnStartupErrors();
@@ -1819,26 +2034,35 @@ const sleep = (ms)=>new Promise((resolve)=>setTimeout(resolve, ms));
1819
2034
  providers.buildMessageRoutes(options.controllers, options.messages);
1820
2035
  // Every check has run, and touched only the container: from here the app is made, once none found an error
1821
2036
  controller_decorator.stopOnStartupErrors();
2037
+ for (const warning of eventSources.perCallEventWarnings(runs, providers$1, options.presenter))this.logger.warn(warning);
1822
2038
  const discordClient = container.get(discord_js.Client);
1823
2039
  Reflect.set(discordClient, providers.SHARD_CALL_KEY, providers.shardCallHandler(container, ()=>callable, 'this app'));
1824
- // Stamp every class the app runs with the container, so @UseGuard resolves guards on a direct call to any of them
1825
- for (const cls of runs.classes){
1826
- Reflect.defineMetadata(controller_decorator.META.container, container, cls);
1827
- }
1828
- providers.recordInstanceContainers(container, runs.classes);
2040
+ // Every class the app runs, so @UseGuard resolves guards on a direct call to any of them
2041
+ providers.prepareDirectCalls(container, runs.classes);
1829
2042
  providers.prepareThemes(container, appClasses);
1830
2043
  // Run by start() before it logs in: what may inject a provided value is resolved once every factory
1831
2044
  // has made its value, including those that return a promise
1832
2045
  const logger = this.logger;
2046
+ let sources;
1833
2047
  const startup = async (control)=>{
2048
+ let made;
1834
2049
  try {
1835
- await providers.resolveProviders(container, providers$1, order, control);
2050
+ made = await providers.resolveProviders(container, providers$1, order, control);
1836
2051
  } catch (error) {
1837
2052
  logger.error(`${error.message}. The bot cannot start without it.`);
1838
2053
  logger.debug('Provider failure:', error.cause);
1839
2054
  controller_decorator.markExplained(error);
1840
2055
  throw error;
1841
2056
  }
2057
+ // A stop came while the providers were being made: what follows resolves provided values, which would make the
2058
+ // factories it left unmade
2059
+ if (!made) return;
2060
+ // Read once every provided value is made, and before anything the app makes could list its handlers
2061
+ if (!sources) {
2062
+ sources = eventSources.eventSources(container, providers$1, lifecycle, appClasses, (text)=>logger.warn(text));
2063
+ providers.listEventSources(registry, sources);
2064
+ providers.prepareLateThemes(container, sources.map(({ cls })=>cls));
2065
+ }
1842
2066
  // The listed services are made now, so constructors that attach listeners or connect run before login; a stop
1843
2067
  // meanwhile makes no more of them
1844
2068
  for (const svc of options.services ?? []){
@@ -1853,12 +2077,39 @@ const sleep = (ms)=>new Promise((resolve)=>setTimeout(resolve, ms));
1853
2077
  providers.registerClientTheme(discordClient, container);
1854
2078
  controller_decorator.registerClientTranslator(discordClient, options.i18n);
1855
2079
  };
1856
- 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
1857
- options.warnUnanswered ?? process.env.NODE_ENV === 'development', shard);
2080
+ return new MeoCordApp(options.controllers, container, discordClient, meocordConfig.discordToken, options.activities, ()=>sources ?? [], meocordConfig.shutdownTimeout, startup, lifecycle, options.messages, // In development only, unless the app says otherwise
2081
+ options.warnUnanswered ?? process.env.NODE_ENV === 'development', shard, startAttempt(container, lifecycle, providers$1));
1858
2082
  });
1859
2083
  }
1860
2084
  }
1861
2085
  MeoCordFactory.logger = new controller_decorator.Logger();
2086
+ /**
2087
+ * What the app's starts make: each lifecycle unit is recorded as the container constructs it, as the testing module
2088
+ * records them, and a container snapshot marks where a start begins, so a failed start drops every singleton it made,
2089
+ * the values factory providers keep included.
2090
+ */ function startAttempt(container, lifecycle, providers$1) {
2091
+ const constructed = new Set();
2092
+ for (const { token } of lifecycle){
2093
+ container.onActivation(token, (_context, instance)=>{
2094
+ constructed.add(token);
2095
+ return instance;
2096
+ });
2097
+ }
2098
+ const drop = ()=>{
2099
+ container.restore();
2100
+ providers.forgetFactoryValues(container);
2101
+ };
2102
+ const given = new Set(lifecycle.map(({ token })=>token).filter((token)=>{
2103
+ const provider = providers$1.get(token);
2104
+ return provider !== undefined && providers.isValueProvider(provider);
2105
+ }));
2106
+ return {
2107
+ constructed,
2108
+ given,
2109
+ begin: ()=>container.snapshot(),
2110
+ drop
2111
+ };
2112
+ }
1862
2113
 
1863
2114
  exports.HandlerRegistry = providers.HandlerRegistry;
1864
2115
  exports.ShardContext = providers.ShardContext;