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
@@ -1,4 +1,6 @@
1
+ import { AsyncLocalStorage } from 'node:async_hooks';
1
2
  import { storeOperationsSettled } from './cooldown-runner.js';
3
+ import { RunningCalls } from './running-calls.js';
2
4
 
3
5
  /** The entry of a unit whose instance is `instance`. */ function lifecycleEntry(unit, instance) {
4
6
  return {
@@ -11,11 +13,45 @@ import { storeOperationsSettled } from './cooldown-runner.js';
11
13
  }
12
14
  };
13
15
  }
16
+ /**
17
+ * The ready hooks' pass, which shutdown waits for before it reads which units to shut down, so a unit whose `onReady`
18
+ * finishes meanwhile is shut down too. An `onReady` still running that asks the app to stop is not waited for; a timer
19
+ * or callback an `onReady` that has settled left behind excuses nothing.
20
+ */ class ReadyPass {
21
+ /** Runs `pass`, the ready hooks, as the pass shutdown waits for. */ run(pass) {
22
+ return this.passes.run('onReady', pass);
23
+ }
24
+ /** Excuses the pass when this runs within the `onReady` in progress, as one that stops the app does. */ excuseCurrent() {
25
+ if (this.current && this.hooks.getStore() === this.current) this.passes.excuseCurrent();
26
+ }
27
+ /** Settles once the pass has, unless excused. */ settled() {
28
+ return this.passes.settled();
29
+ }
30
+ /** The unit whose `onReady` a wait would still wait for, if any. */ running() {
31
+ return this.passes.running().length > 0 ? [
32
+ this.unit ?? 'a unit'
33
+ ] : [];
34
+ }
35
+ constructor(){
36
+ this.passes = new RunningCalls();
37
+ /** The `onReady` each async context belongs to. */ this.hooks = new AsyncLocalStorage();
38
+ /** Runs a unit's `onReady` as the one in progress. */ this.runOnReady = async (unit, run)=>{
39
+ const hook = {};
40
+ this.current = hook;
41
+ this.unit = unit.name;
42
+ try {
43
+ await this.hooks.run(hook, run);
44
+ } finally{
45
+ if (this.current === hook) this.current = undefined;
46
+ }
47
+ };
48
+ }
49
+ }
14
50
  /**
15
51
  * Resolves each unit and runs its `onReady` one at a time, in the units' dependency order. A failure is
16
52
  * reported and the next unit still runs. Each unit whose `onReady` settled, or that has none, is pushed
17
53
  * to `entries` as it finishes, so a shutdown that starts meanwhile closes exactly those.
18
- */ async function runReadyHooks(container, units, client, info, entries, report, { stopped = ()=>false, slowAfterMs, settled } = {}) {
54
+ */ async function runReadyHooks(container, units, client, info, entries, report, { stopped = ()=>false, slowAfterMs, settled, runOnReady } = {}) {
19
55
  const failed = new Set();
20
56
  // For each unit, the failed units it depends on, directly or through another dependency
21
57
  const failedUpstream = new Map();
@@ -23,6 +59,9 @@ import { storeOperationsSettled } from './cooldown-runner.js';
23
59
  unit.token,
24
60
  unit
25
61
  ]));
62
+ // The token each instance was first reached by: one instance two tokens reach, an alias or one value provided twice,
63
+ // runs its hooks once
64
+ const firstToken = new Map();
26
65
  for (const unit of units){
27
66
  if (stopped()) break;
28
67
  const upstream = new Set();
@@ -41,9 +80,17 @@ import { storeOperationsSettled } from './cooldown-runner.js';
41
80
  settled?.(unit);
42
81
  continue;
43
82
  }
83
+ const first = firstToken.get(instance);
84
+ if (first !== undefined) {
85
+ // Failed or not as the token that ran its hooks did, for what depends on this one
86
+ if (failed.has(first)) failed.add(unit.token);
87
+ settled?.(unit);
88
+ continue;
89
+ }
90
+ firstToken.set(instance, unit.token);
44
91
  const entry = lifecycleEntry(unit, instance);
45
- // Only a unit whose onReady has settled, or that has none, is shut down: a stop mid-ready skips
46
- // the one still starting, and those not reached yet
92
+ // Only a unit whose onReady has settled, or that has none, is shut down: a stop mid-ready waits for the one still
93
+ // starting, within its time, and skips those not reached yet
47
94
  if (typeof instance.onReady !== 'function') {
48
95
  entries.push(entry);
49
96
  settled?.(unit);
@@ -54,7 +101,8 @@ import { storeOperationsSettled } from './cooldown-runner.js';
54
101
  ]);
55
102
  const slow = slowAfterMs !== undefined && report.slow ? setTimeout(()=>report.slow(unit), slowAfterMs) : undefined;
56
103
  try {
57
- await instance.onReady(client, info);
104
+ const onReady = ()=>instance.onReady(client, info);
105
+ await (runOnReady ? runOnReady(unit, onReady) : onReady());
58
106
  } catch (error) {
59
107
  failed.add(unit.token);
60
108
  report.hookFailed(unit, error);
@@ -67,12 +115,14 @@ import { storeOperationsSettled } from './cooldown-runner.js';
67
115
  }
68
116
  /**
69
117
  * Runs the entries' `onShutdown` hooks one at a time, in reverse, so a unit stops before those it
70
- * depends on. A failure is reported and the next hook still runs.
71
- */ async function runShutdownHooks(entries, hookFailed) {
118
+ * depends on. A failure is reported and the next hook still runs. An instance already in `ran` is not shut down again,
119
+ * so one that two entries reach stops once.
120
+ */ async function runShutdownHooks(entries, hookFailed, ran = new Set()) {
72
121
  for (const { name, instance } of [
73
122
  ...entries
74
123
  ].reverse()){
75
- if (typeof instance.onShutdown !== 'function') continue;
124
+ if (typeof instance.onShutdown !== 'function' || ran.has(instance)) continue;
125
+ ran.add(instance);
76
126
  try {
77
127
  await instance.onShutdown();
78
128
  } catch (error) {
@@ -80,54 +130,91 @@ import { storeOperationsSettled } from './cooldown-runner.js';
80
130
  }
81
131
  }
82
132
  }
83
- /**
84
- * Shuts down the entries there are when it begins, as a bot and a testing module both do. When the cooldown store, or
85
- * anything it injects directly or through another, has an `onShutdown`, the calls under way finish first, then the
86
- * store operations they started, so the store's last writes and releases still reach it. The other hooks then run in
87
- * reverse dependency order, and the store and what it injects last. The whole sequence is waited for at most
88
- * `timeoutMs`.
89
- */ async function runShutdownSequence(container, entries, { drainCalls, timeoutMs, hookFailed, warn }) {
90
- // A unit whose onReady settles while the calls drain was still starting when shutdown began, so it is not shut down
91
- entries = [
92
- ...entries
93
- ];
94
- const byToken = new Map(entries.map((entry)=>[
95
- entry.token,
96
- entry
97
- ]));
98
- // The store and everything it reaches through what it injects
99
- const storeSide = new Set();
100
- const reach = (entry)=>{
101
- if (!entry || storeSide.has(entry)) return;
102
- storeSide.add(entry);
103
- for (const dependency of entry.dependencies)reach(byToken.get(dependency));
104
- };
105
- for (const entry of entries)if (entry.cooldownStore) reach(entry);
106
- const needsStore = [
107
- ...storeSide
108
- ].some((entry)=>typeof entry.instance.onShutdown === 'function');
109
- const sequence = (async ()=>{
110
- if (needsStore) {
111
- await drainCalls();
112
- await storeOperationsSettled(container);
113
- }
114
- // Nothing on the store's side injects anything outside it, so running it last keeps the reverse dependency order
115
- await runShutdownHooks(entries.filter((entry)=>!storeSide.has(entry)), hookFailed);
116
- await runShutdownHooks(entries.filter((entry)=>storeSide.has(entry)), hookFailed);
117
- })();
133
+ /** The part of `timeoutMs` the drain leaves for the hooks: a quarter, at least 1 s, and never more than half. */ function hooksReserveMs(timeoutMs) {
134
+ return Math.min(Math.max(timeoutMs / 4, 1000), timeoutMs / 2);
135
+ }
136
+ /** A timer whose promise resolves with `value` after `ms`, and `cancel` to clear it. */ function after(ms, value) {
118
137
  let timer;
119
- const timedOut = new Promise((resolve)=>{
120
- timer = setTimeout(()=>resolve('timeout'), timeoutMs);
138
+ const promise = new Promise((resolve)=>{
139
+ timer = setTimeout(()=>resolve(value), ms);
121
140
  });
141
+ return {
142
+ promise,
143
+ cancel: ()=>clearTimeout(timer)
144
+ };
145
+ }
146
+ /**
147
+ * Shuts down the entries there are once an `onReady` in progress has settled, as a bot and a testing module both do.
148
+ * The calls under way finish first, on every shutdown with `drainAlways`, and otherwise when the cooldown store, or
149
+ * anything it injects directly or through another, has an `onShutdown`; then the store operations they started, so the
150
+ * store's last writes and releases still reach it. The other hooks then run in reverse dependency order, and the store
151
+ * and what it injects last.
152
+ *
153
+ * The whole sequence is waited for at most `timeoutMs`. The wait for the `onReady` and the drain stop early enough to
154
+ * leave the hooks {@link hooksReserveMs}, with a warning naming what still runs, and the hooks run in the time left.
155
+ */ async function runShutdownSequence(container, readEntries, { drainCalls, drainAlways = false, runningCalls, readyPass, timeoutMs, hookFailed, warn }) {
156
+ const whole = after(timeoutMs, 'timeout');
157
+ const reserve = hooksReserveMs(timeoutMs);
158
+ const drainEnds = after(timeoutMs - reserve, false);
159
+ // At once on a shutdown that always drains, so no call starts while an onReady finishes
160
+ let draining = drainAlways ? drainCalls().then(()=>true) : undefined;
122
161
  try {
162
+ const readying = readyPass?.running() ?? [];
163
+ if (readying.length > 0 && !await Promise.race([
164
+ readyPass.settled().then(()=>true),
165
+ drainEnds.promise
166
+ ])) {
167
+ warn(`onReady in ${readying.join(', ')} is still running after ${timeoutMs - reserve} ms; shutting down without waiting for it.`);
168
+ }
169
+ // Read once the onReady in progress has settled, so its unit is among them
170
+ const entries = [
171
+ ...readEntries()
172
+ ];
173
+ const byToken = new Map(entries.map((entry)=>[
174
+ entry.token,
175
+ entry
176
+ ]));
177
+ // The store and everything it reaches through what it injects
178
+ const storeSide = new Set();
179
+ const reach = (entry)=>{
180
+ if (!entry || storeSide.has(entry)) return;
181
+ storeSide.add(entry);
182
+ for (const dependency of entry.dependencies)reach(byToken.get(dependency));
183
+ };
184
+ for (const entry of entries)if (entry.cooldownStore) reach(entry);
185
+ const needsStore = [
186
+ ...storeSide
187
+ ].some((entry)=>typeof entry.instance.onShutdown === 'function');
188
+ let drained = true;
189
+ if (needsStore) draining ?? (draining = drainCalls().then(()=>true));
190
+ if (draining) {
191
+ drained = await Promise.race([
192
+ draining,
193
+ drainEnds.promise
194
+ ]);
195
+ if (!drained) {
196
+ const running = runningCalls?.() ?? [];
197
+ warn(`Calls still running after ${timeoutMs - reserve} ms${running.length > 0 ? `: ${running.join(', ')}` : ''}; ` + `running the onShutdown hooks in the ${reserve} ms left.`);
198
+ }
199
+ }
200
+ drainEnds.cancel();
201
+ const sequence = (async ()=>{
202
+ // Only after the calls settled: a call still running may never let its store operations settle
203
+ if (needsStore && drained) await storeOperationsSettled(container);
204
+ // Nothing on the store's side injects anything outside it, so running it last keeps the reverse dependency order
205
+ const ran = new Set();
206
+ await runShutdownHooks(entries.filter((entry)=>!storeSide.has(entry)), hookFailed, ran);
207
+ await runShutdownHooks(entries.filter((entry)=>storeSide.has(entry)), hookFailed, ran);
208
+ })();
123
209
  if (await Promise.race([
124
210
  sequence,
125
- timedOut
211
+ whole.promise
126
212
  ]) === 'timeout') {
127
213
  warn(`onShutdown hooks did not finish within ${timeoutMs} ms; shutting down anyway.`);
128
214
  }
129
215
  } finally{
130
- clearTimeout(timer);
216
+ drainEnds.cancel();
217
+ whole.cancel();
131
218
  }
132
219
  }
133
220
  /** Settles once every promise in `calls` has, those added while waiting included. */ async function callsSettled(calls) {
@@ -136,4 +223,4 @@ import { storeOperationsSettled } from './cooldown-runner.js';
136
223
  ]);
137
224
  }
138
225
 
139
- export { callsSettled, lifecycleEntry, runReadyHooks, runShutdownHooks, runShutdownSequence };
226
+ export { ReadyPass, callsSettled, hooksReserveMs, lifecycleEntry, runReadyHooks, runShutdownHooks, runShutdownSequence };
@@ -3,39 +3,36 @@ import { COOLDOWN_POLICY, cooldownPolicyFrom, handlerCooldowns } from './cooldow
3
3
  import { Container } from 'inversify';
4
4
  import { Client } from 'discord.js';
5
5
  import { Logger } from '../common/logger.js';
6
+ import { warnOnce } from '../common/deprecation.js';
6
7
  import './theme-scope.js';
7
8
  import './theme-validation.js';
8
9
  import '../common/decorator.js';
9
10
  import { ExecutionContext } from '../common/execution-context.js';
10
- import { registerClientTranslator } from '../common/meocord-text.js';
11
+ import '../common/errors.js';
11
12
  import { CooldownStore, MemoryCooldownStore } from '../common/cooldown-store.js';
12
13
  import 'node:crypto';
13
14
  import { getCommandMap, getMessageHandlers } from '../decorator/controller.decorator.js';
14
15
  import './scalar-types.js';
15
16
  import { processShardingEnabled, shardingRole, clientOptionsWithSharding } from '../util/sharding-mode.util.js';
16
17
  import { Translator, missingTranslatorError } from '../common/translator.js';
17
- import '../common/response/send-failure.js';
18
- import '../util/interaction.util.js';
19
- import '../common/response/flags.js';
20
- import 'path';
21
- import '../common/response/components.js';
22
- import { themeResolverClass } from './theme-resolvers.js';
18
+ import { registerClientTranslator } from '../common/meocord-text.js';
19
+ import '../common/response/response-state.js';
23
20
  import { isExplainedError, markExplained } from '../common/explained-error.js';
21
+ import { themeResolverClass, ThemeCache } from './theme-resolvers.js';
24
22
  import { MeoCordApp } from './meocord.app.js';
25
23
  import { loadMeoCordConfig, compiledConfigMessage } from '../util/meocord-config-loader.util.js';
26
24
  import { bunDevelopmentValues, bunDevelopmentWarning } from '../util/inherited-env.util.js';
27
- import { assertBuiltForThisPlatform } from '../util/platform.util.js';
28
25
  import { isRegisterOnly } from '../util/registration-mode.util.js';
29
- import { injectedTokens, singletonContextError } from './guard-runner.js';
26
+ import { recordInstanceContainers, injectedTokens, singletonContextError } from './guard-runner.js';
30
27
  import { appStages, bindGlobalStages, prepareHandlerStages, prepareThemes, bindAppPresenter } from './handler-pipeline.js';
31
- import { startupClasses, assertStartupClasses, lateGuardCheck } from './startup-roots.js';
28
+ import { startupClasses, assertStartupClasses, lateGuardCheck, stageDependencies } from './startup-roots.js';
32
29
  import { markStartupChecked, assertStartupChecked } from './startup-checked.js';
33
30
  import { makeInjectable } from '../util/injectable.util.js';
34
31
  import { meocordClasses } from './meocord-classes.js';
35
32
  import { buildMessageRoutes } from './message-routes.js';
36
33
  import { appObservers, bindObservers } from './observer-runner.js';
37
34
  import { isAppClassToken } from './lifecycle-order.js';
38
- import { reachableClasses, providerMap, bindsOwnToken, tokenName, bindProvider, resolutionOrder, isClassProvider, assertProvided, tokenDependencies, resolveProviders } from './providers.js';
35
+ import { reachableClasses, providerMap, assertProvidedServices, bindsOwnToken, tokenName, bindProvider, resolutionOrder, isClassProvider, assertProvided, tokenDependencies, resolveProviders } from './providers.js';
39
36
  import { HandlerRegistry } from './handler-registry.js';
40
37
  import { ShardManager } from './shard-manager.js';
41
38
  import { warnUnregisteredCommands, warnInheritedRoutes, warnOverlappingPatterns, warnHandlersOffControllers, assertDistinctCommands } from './command-conflicts.js';
@@ -102,8 +99,8 @@ import { isShardProcess } from '../util/shard-process.util.js';
102
99
  * The config a single process runs with. Under `meocord start --dev` without `sharding.development`,
103
100
  * process sharding falls back to running every shard in this process, so the watcher restarts one
104
101
  * process and leaves no shards behind.
105
- */ static effectiveConfig(config) {
106
- if (config.sharding?.mode !== 'process' || isShardProcess() || processShardingEnabled(config)) return config;
102
+ */ static effectiveConfig(config, shard) {
103
+ if (config.sharding?.mode !== 'process' || shard || processShardingEnabled(config)) return config;
107
104
  this.logger.info("sharding.mode 'process' is off in development, so every shard runs in this process; set " + 'sharding.development: true to run them in separate processes.');
108
105
  return {
109
106
  ...config,
@@ -123,7 +120,8 @@ import { isShardProcess } from '../util/shard-process.util.js';
123
120
  * @returns The application, which `start()` logs in.
124
121
  * @throws Error when the class has no `@MeoCord`, when the compiled config is missing beside the bundle or fails to
125
122
  * load, naming the file and the reason, with what to do about it, when a provider cannot
126
- * be bound, such as one for a token MeoCord binds itself, when two handlers take one command, or two builder
123
+ * be bound, such as one for a token MeoCord binds itself (a provider for `ThemeCache` or `ExecutionContext` is warned
124
+ * about instead, and refused from the next major version, 5.0), when two handlers take one command, or two builder
127
125
  * classes build one, naming both, and for any other mistake it refuses as the app loads, such as two component
128
126
  * patterns that match the same customIds. Each check reports every error it finds, and the first is thrown,
129
127
  * unchanged. When there are several, each is logged first, with where in the source it comes from; a lone one is
@@ -131,11 +129,14 @@ import { isShardProcess } from '../util/shard-process.util.js';
131
129
  * shard of process sharding, the manager logs it. With `startupErrors: 'all'` in `meocord.config.ts`, the errors
132
130
  * decorators kept are among them.
133
131
  */ static create(target) {
132
+ // Decided first, once, and handed to all that asks: a ShardingManager this app builds writes the variable into its
133
+ // own process too, which would make the manager read as a shard
134
+ const shard = isShardProcess(process.env);
134
135
  try {
135
- return this.createApplication(target);
136
+ return this.createApplication(target, shard);
136
137
  } catch (error) {
137
138
  // A shard ends, so its manager restarts it, or stops every shard for a refusal, which it logs
138
- if (isShardProcess()) endFailedShard(error);
139
+ if (shard) endFailedShard(error);
139
140
  else if (isRefusal(error) && !isExplainedError(error)) {
140
141
  // Every startup error found together is logged, in a test too; a lone one is logged in a built application
141
142
  if (logStartupErrors(error, (text)=>this.logger.error(text), 'the bot did not start')) markExplained(error);
@@ -147,18 +148,25 @@ import { isShardProcess } from '../util/shard-process.util.js';
147
148
  throw error;
148
149
  }
149
150
  }
150
- static createApplication(target) {
151
- const options = Reflect.getMetadata(META.appOptions, target);
151
+ static createApplication(target, shard) {
152
+ const appOptions = Reflect.getMetadata(META.appOptions, target);
152
153
  // With startupErrors: 'all', what the decorators kept: a built bot's every one, else the app's and its controllers'
153
154
  const declared = isBuiltApplication() ? allDeclaredErrors() : declaredErrorsOf([
154
155
  target,
155
- ...options?.controllers ?? []
156
+ ...appOptions?.controllers ?? []
156
157
  ]);
157
- if (!options) {
158
+ if (!appOptions) {
158
159
  // A refused @MeoCord stores no options, so its own errors say why
159
160
  collectStartupErrors(()=>declared.forEach(startupError));
160
161
  throw refuse(new Error(`${typeof target === 'function' ? target.name : String(target)}: not decorated with @MeoCord(), so there is no app to create.`));
161
162
  }
163
+ // A controller listed twice is one controller, as the command checks and fromApp already take it
164
+ const options = {
165
+ ...appOptions,
166
+ controllers: [
167
+ ...new Set(appOptions.controllers)
168
+ ]
169
+ };
162
170
  const meocordConfig = loadMeoCordConfig();
163
171
  if (!meocordConfig) {
164
172
  throw refuse(new Error(compiledConfigMessage()));
@@ -168,7 +176,9 @@ import { isShardProcess } from '../util/shard-process.util.js';
168
176
  const providers = collectStartupErrors(()=>{
169
177
  declared.forEach(startupError);
170
178
  assertDistinctCommands(options.controllers);
171
- return providerMap(options.providers ?? [], '@MeoCord({ providers })');
179
+ const providers = providerMap(options.providers ?? [], '@MeoCord({ providers })');
180
+ assertProvidedServices(typeof target === 'function' ? target.name : String(target), options.services ?? [], providers, '@MeoCord({ providers })');
181
+ return providers;
172
182
  });
173
183
  // A themeFor class is bound on its own, as the cooldown store is, and resolved like a service
174
184
  const themeResolver = themeResolverClass(options.themeFor);
@@ -185,7 +195,7 @@ import { isShardProcess } from '../util/shard-process.util.js';
185
195
  ...appObservers(target)
186
196
  ];
187
197
  // A shard's manager runs the same checks, so a sharded bot warns once
188
- if (!isShardProcess()) {
198
+ if (!shard) {
189
199
  warnUnregisteredCommands(options.controllers);
190
200
  warnInheritedRoutes(options.controllers);
191
201
  warnOverlappingPatterns(options.controllers);
@@ -200,11 +210,8 @@ import { isShardProcess } from '../util/shard-process.util.js';
200
210
  if (isRegisterOnly()) {
201
211
  return new MeoCordApp(options.controllers, new Container(), new Client(options.clientOptions), meocordConfig.discordToken);
202
212
  }
203
- // Before anything is resolved: a controller or service is what first loads a native addon, and one built for another
204
- // platform would otherwise fail there with a linker error. A manager checks too, before it registers or spawns.
205
- assertBuiltForThisPlatform();
206
213
  // A process-sharding manager only spawns shards, so it binds, constructs and connects nothing itself.
207
- if (shardingRole(meocordConfig) === 'manager') {
214
+ if (shardingRole(meocordConfig, shard) === 'manager') {
208
215
  return new ShardManager({
209
216
  controllerClasses: options.controllers,
210
217
  token: meocordConfig.discordToken,
@@ -242,7 +249,7 @@ import { isShardProcess } from '../util/shard-process.util.js';
242
249
  ], lateGuardCheck(providers, translator));
243
250
  bindGlobalStages(container, stages);
244
251
  // Bound now, so no provider stands in for it; made once the checks pass, the one Client the app logs in with
245
- const clientOptions = clientOptionsWithSharding(this.effectiveConfig(meocordConfig), options.clientOptions);
252
+ const clientOptions = clientOptionsWithSharding(this.effectiveConfig(meocordConfig, shard), options.clientOptions);
246
253
  container.bind(Client).toDynamicValue(()=>new Client(clientOptions)).inSingletonScope();
247
254
  if (options.i18n) {
248
255
  container.bind(Translator).toConstantValue(options.i18n);
@@ -270,6 +277,10 @@ import { isShardProcess } from '../util/shard-process.util.js';
270
277
  // After MeoCord's own tokens, which a provider may not replace, and before the app's classes, so
271
278
  // a class token that is provided is not also bound as itself
272
279
  for (const [token, provider] of providers){
280
+ // Bound later, or for each call, so not caught below; supported until 5.0 refuses it
281
+ if (token === ThemeCache || token === ExecutionContext) {
282
+ warnOnce(this.logger, `${target.name}: @MeoCord({ providers }) provides ${tokenName(token)}, which MeoCord binds itself; ` + 'the next major version (5.0) refuses it. Remove the provider.');
283
+ }
273
284
  if (container.isBound(token)) {
274
285
  startupError(new Error(`${target.name}: @MeoCord({ providers }) cannot provide ${tokenName(token)}, which MeoCord binds itself.`));
275
286
  continue;
@@ -287,6 +298,9 @@ import { isShardProcess } from '../util/shard-process.util.js';
287
298
  // Observers are services too: bound here so their lifecycle hooks run in dependency order
288
299
  const observers = appObservers(target);
289
300
  for (const observer of observers)bindDependencies(container, observer, providers);
301
+ // What the stages and presenter inject, bound now so a service among them is made as the bot comes online
302
+ const stageServices = stageDependencies(runs, providers, options.presenter);
303
+ for (const service of stageServices)bindDependencies(container, service, providers);
290
304
  // What follows walks what these bound, so a class refused here is not reported again as missing
291
305
  stopOnStartupErrors();
292
306
  // Providers first, then the services, the controllers and the observers, each after what it depends on. The app's
@@ -307,12 +321,19 @@ import { isShardProcess } from '../util/shard-process.util.js';
307
321
  return isAppClassToken(token) && (!provider || isClassProvider(provider) && provider.useClass === token);
308
322
  }));
309
323
  assertProvided(container, providers, runs.classes, '@MeoCord({ providers })');
324
+ // With what the stages inject among them, each after what it depends on, so their hooks run as a listed service's do
325
+ const roots = stageServices.length > 0 ? resolutionOrder(container, providers, [
326
+ ...order,
327
+ ...stageServices
328
+ ], {
329
+ followOwnTokens: false
330
+ }) : order;
310
331
  // The store first, after only what it injects: it is ready before anything a call reaches, and shuts down last
311
332
  const store = options.cooldownStore;
312
333
  const lifecycle = (store ? resolutionOrder(container, providers, [
313
334
  store,
314
- ...order
315
- ]) : order).map((token)=>({
335
+ ...roots
336
+ ]) : roots).map((token)=>({
316
337
  token,
317
338
  name: tokenName(token),
318
339
  dependencies: tokenDependencies(container, providers, token),
@@ -352,24 +373,28 @@ import { isShardProcess } from '../util/shard-process.util.js';
352
373
  for (const cls of runs.classes){
353
374
  Reflect.defineMetadata(META.container, container, cls);
354
375
  }
376
+ recordInstanceContainers(container, runs.classes);
355
377
  prepareThemes(container, appClasses);
356
378
  // Run by start() before it logs in: what may inject a provided value is resolved once every factory
357
379
  // has made its value, including those that return a promise
358
380
  const logger = this.logger;
359
- const startup = async ()=>{
381
+ const startup = async (control)=>{
360
382
  try {
361
- await resolveProviders(container, providers, order);
383
+ await resolveProviders(container, providers, order, control);
362
384
  } catch (error) {
363
385
  logger.error(`${error.message}. The bot cannot start without it.`);
364
386
  logger.debug('Provider failure:', error.cause);
365
387
  markExplained(error);
366
388
  throw error;
367
389
  }
368
- // The listed services are made now, so constructors that attach listeners or connect run before login
390
+ // The listed services are made now, so constructors that attach listeners or connect run before login; a stop
391
+ // meanwhile makes no more of them
369
392
  for (const svc of options.services ?? []){
393
+ if (control.stopped()) return;
370
394
  container.get(svc);
371
395
  }
372
- if (shardingRole(meocordConfig) === 'shard' && container.get(CooldownStore) instanceof MemoryCooldownStore) {
396
+ if (control.stopped()) return;
397
+ if (shard && container.get(CooldownStore) instanceof MemoryCooldownStore) {
373
398
  warnPerShardCooldowns(options.controllers, logger);
374
399
  }
375
400
  bindAppPresenter(container, target, discordClient);
@@ -377,7 +402,7 @@ import { isShardProcess } from '../util/shard-process.util.js';
377
402
  registerClientTranslator(discordClient, options.i18n);
378
403
  };
379
404
  return new MeoCordApp(options.controllers, container, discordClient, meocordConfig.discordToken, options.activities, appClasses, meocordConfig.shutdownTimeout, startup, lifecycle, options.messages, // In development only, unless the app says otherwise
380
- options.warnUnanswered ?? process.env.NODE_ENV === 'development');
405
+ options.warnUnanswered ?? process.env.NODE_ENV === 'development', shard);
381
406
  });
382
407
  }
383
408
  }