meocord 4.2.0 → 4.2.2

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 (103) hide show
  1. package/CHANGELOG.md +322 -0
  2. package/THIRD_PARTY_NOTICES.md +14 -13
  3. package/dist/cjs/_shared/{controller.decorator-CRn9kWob.cjs → controller.decorator-U2NBWcoU.cjs} +416 -108
  4. package/dist/cjs/_shared/{providers-ChvMqnLd.cjs → providers-B4MOFBrW.cjs} +202 -50
  5. package/dist/cjs/_shared/{sharded-cooldown-store-BdAUTdYs.cjs → sharded-cooldown-store-HTUb3Ii_.cjs} +10 -7
  6. package/dist/cjs/_shared/{startup-roots-CQo8mSP8.cjs → startup-roots-lA5xkBFq.cjs} +228 -71
  7. package/dist/cjs/_shared/{translate-error-DhDVbT5M.cjs → translate-error-CbBHUu5P.cjs} +10 -5
  8. package/dist/cjs/common/index.cjs +3 -3
  9. package/dist/cjs/core/index.cjs +188 -169
  10. package/dist/cjs/decorator/index.cjs +52 -7
  11. package/dist/cjs/testing/index.cjs +1854 -311
  12. package/dist/cli.json +1 -1
  13. package/dist/esm/bin/generator.js +4 -5
  14. package/dist/esm/bin/helper/filter-generator.helper.js +4 -5
  15. package/dist/esm/bin/helper/guard-generator.helper.js +4 -5
  16. package/dist/esm/bin/helper/interceptor-generator.helper.js +4 -5
  17. package/dist/esm/bin/helper/observer-generator.helper.js +4 -5
  18. package/dist/esm/bin/helper/pipe-generator.helper.js +4 -5
  19. package/dist/esm/bin/helper/service-generator.helper.js +4 -5
  20. package/dist/esm/bin/meocord.js +84 -25
  21. package/dist/esm/build/load-config.pre-entry.js +13 -3
  22. package/dist/esm/build/rsbuild-config.js +16 -12
  23. package/dist/esm/build/typed-externals.js +36 -0
  24. package/dist/esm/common/cooldown-store.js +10 -2
  25. package/dist/esm/common/errors.js +25 -0
  26. package/dist/esm/common/meocord-text.js +11 -8
  27. package/dist/esm/common/response/call-order.js +9 -4
  28. package/dist/esm/common/response/presenter.js +33 -6
  29. package/dist/esm/common/response/response-state.js +89 -15
  30. package/dist/esm/common/route.js +11 -4
  31. package/dist/esm/common/sharded-cooldown-store.js +1 -1
  32. package/dist/esm/common/translator.js +52 -11
  33. package/dist/esm/core/command-registration.js +93 -24
  34. package/dist/esm/core/command-routes.js +2 -2
  35. package/dist/esm/core/cooldown-runner.js +29 -8
  36. package/dist/esm/core/dispatcher.js +9 -8
  37. package/dist/esm/core/fallback.js +20 -11
  38. package/dist/esm/core/filter-runner.js +2 -2
  39. package/dist/esm/core/guard-runner.js +16 -3
  40. package/dist/esm/core/handler-input.js +4 -3
  41. package/dist/esm/core/handler-pipeline.js +10 -4
  42. package/dist/esm/core/lifecycle-hooks.js +134 -47
  43. package/dist/esm/core/meocord-factory.js +59 -34
  44. package/dist/esm/core/meocord.app.js +116 -75
  45. package/dist/esm/core/message-help.js +32 -6
  46. package/dist/esm/core/observer-runner.js +2 -1
  47. package/dist/esm/core/providers.js +19 -4
  48. package/dist/esm/core/running-calls.js +43 -0
  49. package/dist/esm/core/shard-context.js +5 -6
  50. package/dist/esm/core/shard-manager.js +16 -10
  51. package/dist/esm/core/stage-scope.js +2 -1
  52. package/dist/esm/core/startup-roots.js +21 -1
  53. package/dist/esm/core/theme-resolvers.js +4 -1
  54. package/dist/esm/core/theme-scope.js +7 -2
  55. package/dist/esm/core/theme-validation.js +17 -10
  56. package/dist/esm/decorator/app.decorator.js +31 -0
  57. package/dist/esm/decorator/controller.decorator.js +56 -20
  58. package/dist/esm/decorator/cooldown.decorator.js +17 -0
  59. package/dist/esm/decorator/filter.decorator.js +2 -2
  60. package/dist/esm/decorator/guard.decorator.js +3 -2
  61. package/dist/esm/package.json.js +1 -1
  62. package/dist/esm/testing/catalog.js +20 -13
  63. package/dist/esm/testing/cooldown-store-suite.js +67 -5
  64. package/dist/esm/testing/discord-defaults.js +225 -30
  65. package/dist/esm/testing/meocord-testing-module.js +158 -44
  66. package/dist/esm/testing/mock-fn.js +107 -26
  67. package/dist/esm/testing/mock-interaction.js +1250 -182
  68. package/dist/esm/testing/snowflake.js +9 -2
  69. package/dist/esm/testing/strict-mocks.js +40 -17
  70. package/dist/esm/util/common.util.js +47 -19
  71. package/dist/esm/util/dev-runner.util.js +8 -8
  72. package/dist/esm/util/generator-cli.util.js +4 -5
  73. package/dist/esm/util/interaction.util.js +2 -2
  74. package/dist/esm/util/meocord-config-loader.util.js +10 -2
  75. package/dist/esm/util/meocord-config-validation.util.js +3 -5
  76. package/dist/esm/util/meocord-source-config.util.js +65 -20
  77. package/dist/esm/util/method-wrapper.util.js +16 -0
  78. package/dist/esm/util/platform.util.js +10 -10
  79. package/dist/esm/util/shard-process.util.js +4 -1
  80. package/dist/esm/util/sharding-mode.util.js +5 -7
  81. package/dist/esm/util/shared-state.util.js +21 -0
  82. package/dist/esm/util/tsconfig-paths.util.js +29 -0
  83. package/dist/esm/util/tsconfig.util.js +10 -10
  84. package/dist/esm/util/value.util.js +30 -2
  85. package/dist/types/common/index.d.cts +10 -7
  86. package/dist/types/common/index.d.ts +10 -7
  87. package/dist/types/core/index.d.cts +3 -2
  88. package/dist/types/core/index.d.ts +3 -2
  89. package/dist/types/decorator/index.d.cts +1 -0
  90. package/dist/types/decorator/index.d.ts +1 -0
  91. package/dist/types/{index-CdXsFgjE.d.ts → index-DKJclIi-.d.ts} +45 -21
  92. package/dist/types/{index-nn5p7OoX.d.cts → index-Dlf0ZZtS.d.cts} +45 -21
  93. package/dist/types/interface/index.d.cts +1 -1
  94. package/dist/types/interface/index.d.ts +1 -1
  95. package/dist/types/{route-BZgFoNeB.d.cts → route-Bh1U13yy.d.cts} +3 -2
  96. package/dist/types/{route-BZgFoNeB.d.ts → route-Bh1U13yy.d.ts} +3 -2
  97. package/dist/types/testing/index.d.cts +198 -62
  98. package/dist/types/testing/index.d.ts +198 -62
  99. package/dist/types/{theme-resolvers-UKr3-6Ys.d.cts → theme-resolvers-CyEz9pnv.d.cts} +1 -1
  100. package/dist/types/{theme-resolvers-BcOJVzR9.d.ts → theme-resolvers-DA204O-q.d.ts} +1 -1
  101. package/dist/types/{translator-DHMkkCpg.d.cts → translator-Bg7hwxDF.d.cts} +21 -8
  102. package/dist/types/{translator-BViWKtLJ.d.ts → translator-CeP4bCzp.d.ts} +21 -8
  103. package/package.json +2 -2
@@ -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;
@@ -249,22 +249,48 @@ const sortedEntries = (entries)=>entries.sort((a, b)=>a.usage < b.usage ? -1 : a
249
249
  scope
250
250
  ].filter((line)=>line !== undefined).join('\n');
251
251
  }
252
- /** Splits a reply into Discord-sized messages at line breaks, a line longer than one message cut where it must be. */ function splitReply(text) {
252
+ // Built on first use, for a line longer than a message
253
+ let graphemes;
254
+ /**
255
+ * Where a line longer than one message is cut: after the last character a reader sees as one, such as an emoji, a flag
256
+ * or a letter with its accent, that still fits. A single such character longer than a message is cut between code points.
257
+ */ function fittingCut(piece) {
258
+ let cut = 0;
259
+ graphemes ?? (graphemes = new Intl.Segmenter(undefined, {
260
+ granularity: 'grapheme'
261
+ }));
262
+ for (const { index, segment } of graphemes.segment(piece)){
263
+ if (index + segment.length > MAX_REPLY_LENGTH) break;
264
+ cut = index + segment.length;
265
+ }
266
+ if (cut > 0) return cut;
267
+ const high = piece.charCodeAt(MAX_REPLY_LENGTH - 1);
268
+ return high >= 0xd800 && high <= 0xdbff ? MAX_REPLY_LENGTH - 1 : MAX_REPLY_LENGTH;
269
+ }
270
+ /**
271
+ * Splits a reply into Discord-sized messages at line breaks, a line longer than one message cut where it must be. A
272
+ * message of only whitespace, which Discord refuses as empty, is left out.
273
+ */ function splitReply(text) {
253
274
  const chunks = [];
275
+ const push = (chunk)=>{
276
+ if (chunk.trim() !== '') chunks.push(chunk);
277
+ };
254
278
  let current = '';
255
279
  for (const line of text.split('\n')){
256
- for(let piece = line;; piece = piece.slice(MAX_REPLY_LENGTH)){
257
- const part = piece.slice(0, MAX_REPLY_LENGTH);
280
+ for(let piece = line;;){
281
+ const cut = piece.length <= MAX_REPLY_LENGTH ? piece.length : fittingCut(piece);
282
+ const part = piece.slice(0, cut);
258
283
  const joined = current ? `${current}\n${part}` : part;
259
284
  if (joined.length <= MAX_REPLY_LENGTH) current = joined;
260
285
  else {
261
- chunks.push(current);
286
+ push(current);
262
287
  current = part;
263
288
  }
264
- if (piece.length <= MAX_REPLY_LENGTH) break;
289
+ if (cut === piece.length) break;
290
+ piece = piece.slice(cut);
265
291
  }
266
292
  }
267
- if (current) chunks.push(current);
293
+ push(current);
268
294
  return chunks;
269
295
  }
270
296
 
@@ -5,6 +5,7 @@ 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
7
  import { startupError } from '../util/refusal.util.js';
8
+ import { isConstructor } from '../util/value.util.js';
8
9
  import { META } from '../util/metadata-keys.js';
9
10
 
10
11
  /** Where a container keeps the observers its calls are reported to, in order. */ const OBSERVERS = Symbol('observers');
@@ -12,7 +13,7 @@ const logger = new Logger('Observer');
12
13
  /** Reports each entry that is not an `@Observer` class, naming where it was listed. */ function assertObservers(where, observers) {
13
14
  for (const observer of observers){
14
15
  if (typeof observer !== 'function' || !Reflect.getMetadata(META.observerClass, observer)) {
15
- const name = typeof observer === 'function' ? observer.name : String(observer);
16
+ const name = isConstructor(observer) ? observer.name || 'an anonymous class' : typeof observer === 'function' ? 'a function' : String(observer);
16
17
  startupError(new Error(`${where} takes classes decorated with @Observer(), not ${name}.`));
17
18
  continue;
18
19
  }
@@ -4,6 +4,7 @@ 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
6
  import { startupError, refuse } from '../util/refusal.util.js';
7
+ import { isConstructor } from '../util/value.util.js';
7
8
 
8
9
  const isValueProvider = (provider)=>'useValue' in provider;
9
10
  const isClassProvider = (provider)=>'useClass' in provider;
@@ -43,7 +44,7 @@ const isToken = (value)=>typeof value === 'function' || typeof value === 'string
43
44
  startupError(new Error(`${where}: the provider for ${name} needs exactly one of useValue, useClass and useFactory.`));
44
45
  continue;
45
46
  }
46
- if ('useClass' in entry && typeof entry.useClass !== 'function') {
47
+ if ('useClass' in entry && !isConstructor(entry.useClass)) {
47
48
  startupError(new Error(`${where}: the provider for ${name} has a useClass that is not a class.`));
48
49
  continue;
49
50
  }
@@ -120,6 +121,15 @@ const isToken = (value)=>typeof value === 'function' || typeof value === 'string
120
121
  });
121
122
  for (const dependency of inject)if (isAppClassToken(dependency)) bindClass(dependency);
122
123
  }
124
+ /**
125
+ * Reports each service listed by a string or symbol token that no provider provides, which would otherwise be bound
126
+ * as if it were a class.
127
+ */ function assertProvidedServices(appName, services, providers, where) {
128
+ for (const service of services){
129
+ if (typeof service === 'function' || providers.has(service)) continue;
130
+ startupError(new Error(`${appName}: @MeoCord({ services }) lists ${tokenName(service)}, which nothing provides: add a provider for it to ${where}.`));
131
+ }
132
+ }
123
133
  /**
124
134
  * Reports a class or a factory that asks for a string or symbol token nothing binds, naming both, rather than failing
125
135
  * when the class is first resolved. A class token is bound on demand, so it is never missing.
@@ -253,17 +263,22 @@ const isToken = (value)=>typeof value === 'function' || typeof value === 'string
253
263
  }
254
264
  /**
255
265
  * Runs every factory in `order`, awaiting those that return a promise, so each value is made before
256
- * anything that injects it is resolved. Rejects with {@link providerFailure} for the first that fails.
257
- */ async function resolveProviders(container, providers, order) {
266
+ * anything that injects it is resolved. Rejects with {@link providerFailure} for the first that fails. With `control`,
267
+ * it makes no further value once the app is stopping.
268
+ */ async function resolveProviders(container, providers, order, control) {
258
269
  for (const token of order){
259
270
  const provider = providers.get(token);
260
271
  if (!provider || !isFactoryProvider(provider)) continue;
272
+ if (control?.stopped()) return;
273
+ control?.pending(tokenName(token));
261
274
  try {
262
275
  await container.getAsync(token);
263
276
  } catch (error) {
264
277
  throw providerFailure(token, error);
278
+ } finally{
279
+ control?.pending(undefined);
265
280
  }
266
281
  }
267
282
  }
268
283
 
269
- export { assertProvided, assertTypedParameters, bindProvider, bindsOwnToken, isClassProvider, isFactoryProvider, isValueProvider, providerFailure, providerMap, reachableClasses, resolutionOrder, resolveProviders, tokenDependencies, tokenName };
284
+ export { assertProvided, assertProvidedServices, assertTypedParameters, bindProvider, bindsOwnToken, isClassProvider, isFactoryProvider, isValueProvider, providerFailure, providerMap, reachableClasses, resolutionOrder, resolveProviders, tokenDependencies, tokenName };
@@ -0,0 +1,43 @@
1
+ import { AsyncLocalStorage } from 'node:async_hooks';
2
+
3
+ /**
4
+ * The calls an app is running, which shutdown waits for before the `onShutdown` hooks. A call that asks the app to
5
+ * stop is excused, so a handler awaiting `app.stop()` never waits for itself.
6
+ */ class RunningCalls {
7
+ /** Runs `run` as a call named `label`, tracked until it settles. */ run(label, run) {
8
+ const token = {};
9
+ this.calls.set(token, label);
10
+ const done = this.current.run(token, run);
11
+ const settle = ()=>{
12
+ this.calls.delete(token);
13
+ this.notify();
14
+ };
15
+ done.then(settle, settle);
16
+ return done;
17
+ }
18
+ /** Excuses the call this runs in, and any it runs within, from the wait; outside a call, nothing. */ excuseCurrent() {
19
+ const token = this.current.getStore();
20
+ if (!token || !this.calls.has(token)) return;
21
+ this.excused.add(token);
22
+ this.notify();
23
+ }
24
+ /** The names of the calls a wait would still wait for. */ running() {
25
+ return [
26
+ ...this.calls
27
+ ].filter(([token])=>!this.excused.has(token)).map(([, label])=>label);
28
+ }
29
+ /** Settles once every call not excused has, those that start or are excused meanwhile included. */ async settled() {
30
+ while(this.running().length > 0)await (this.changed ?? (this.changed = Promise.withResolvers())).promise;
31
+ }
32
+ notify() {
33
+ this.changed?.resolve();
34
+ this.changed = undefined;
35
+ }
36
+ constructor(){
37
+ this.current = new AsyncLocalStorage();
38
+ /** Each call under way, by its token, with the name a log gives it. */ this.calls = new Map();
39
+ this.excused = new WeakSet();
40
+ }
41
+ }
42
+
43
+ export { RunningCalls };
@@ -5,16 +5,15 @@ import './theme-validation.js';
5
5
  import '../common/decorator.js';
6
6
  import 'reflect-metadata';
7
7
  import 'discord.js';
8
- import '../common/translator.js';
9
- import '../common/response/send-failure.js';
10
- import '../util/interaction.util.js';
11
- import '../common/response/flags.js';
12
- import 'path';
13
- import '../common/response/components.js';
8
+ import '../common/response/response-state.js';
9
+ import '../common/errors.js';
14
10
  import '../decorator/controller.decorator.js';
15
11
  import './scalar-types.js';
12
+ import 'path';
16
13
  import 'node:fs';
17
14
  import 'node:url';
15
+ import '../common/translator.js';
16
+ import '../common/meocord-text.js';
18
17
 
19
18
  /** Where each shard's client keeps the function that runs a `ShardContext.call` in that shard. */ const SHARD_CALL_KEY = Symbol.for('meocord.shardCall');
20
19
  const packArgs = (args)=>Object.assign({