meocord 4.2.1 → 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 (137) hide show
  1. package/CHANGELOG.md +188 -0
  2. package/dist/cjs/_shared/{controller.decorator-XKMlcUZz.cjs → controller.decorator-hp5xdJU4.cjs} +550 -189
  3. package/dist/cjs/_shared/{startup-roots-CgfPQrNl.cjs → event-sources-DzqTODXg.cjs} +274 -88
  4. package/dist/cjs/_shared/{providers-BZgJyfZS.cjs → providers-BBvbnmLP.cjs} +837 -295
  5. package/dist/cjs/_shared/{sharded-cooldown-store-CsJJyCZd.cjs → sharded-cooldown-store-B62g5O88.cjs} +11 -9
  6. package/dist/cjs/_shared/{translate-error-BUAR1WYG.cjs → translate-error-wsgfsUET.cjs} +1 -1
  7. package/dist/cjs/common/index.cjs +4 -104
  8. package/dist/cjs/core/index.cjs +435 -182
  9. package/dist/cjs/decorator/index.cjs +116 -121
  10. package/dist/cjs/testing/index.cjs +1923 -808
  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 +32 -10
  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 +27 -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 +92 -66
  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 +6 -6
  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 +54 -32
  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 +83 -54
  60. package/dist/esm/core/meocord.app.js +174 -89
  61. package/dist/esm/core/message-help.js +37 -15
  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 +4 -2
  66. package/dist/esm/core/process-state.js +19 -0
  67. package/dist/esm/core/providers.js +39 -4
  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/stage-scope.js +2 -1
  73. package/dist/esm/core/startup-roots.js +28 -8
  74. package/dist/esm/core/theme-resolvers.js +26 -11
  75. package/dist/esm/core/theme-runtime.js +23 -13
  76. package/dist/esm/core/theme-scope.js +31 -30
  77. package/dist/esm/core/theme-validation.js +1 -1
  78. package/dist/esm/decorator/app.decorator.js +37 -1
  79. package/dist/esm/decorator/command-builder.decorator.js +14 -0
  80. package/dist/esm/decorator/controller-class.decorator.js +1 -1
  81. package/dist/esm/decorator/controller.decorator.js +48 -19
  82. package/dist/esm/decorator/defer.decorator.js +2 -2
  83. package/dist/esm/decorator/event.decorator.js +4 -5
  84. package/dist/esm/decorator/filter.decorator.js +4 -4
  85. package/dist/esm/decorator/guard.decorator.js +4 -61
  86. package/dist/esm/decorator/inject.decorator.js +4 -1
  87. package/dist/esm/decorator/interceptor.decorator.js +2 -2
  88. package/dist/esm/decorator/theme.decorator.js +4 -3
  89. package/dist/esm/decorator/validation.decorator.js +5 -5
  90. package/dist/esm/package.json.js +1 -1
  91. package/dist/esm/testing/catalog.js +32 -25
  92. package/dist/esm/testing/cooldown-store-suite.js +3 -3
  93. package/dist/esm/testing/discord-defaults.js +449 -335
  94. package/dist/esm/testing/inspect-handler.js +91 -10
  95. package/dist/esm/testing/meocord-testing-module.js +217 -91
  96. package/dist/esm/testing/mock-fn.js +8 -2
  97. package/dist/esm/testing/mock-interaction.js +864 -124
  98. package/dist/esm/testing/response-log.js +8 -0
  99. package/dist/esm/testing/response.js +7 -5
  100. package/dist/esm/testing/routing.js +48 -6
  101. package/dist/esm/testing/strict-mocks.js +8 -4
  102. package/dist/esm/testing/theme.js +3 -2
  103. package/dist/esm/util/common.util.js +30 -9
  104. package/dist/esm/util/generator-cli.util.js +6 -4
  105. package/dist/esm/util/inherited-env.util.js +72 -13
  106. package/dist/esm/util/json.util.js +10 -3
  107. package/dist/esm/util/meocord-cli.util.js +3 -2
  108. package/dist/esm/util/meocord-config-loader.util.js +14 -1
  109. package/dist/esm/util/meocord-config-validation.util.js +13 -3
  110. package/dist/esm/util/meocord-source-config.util.js +13 -3
  111. package/dist/esm/util/platform.util.js +3 -2
  112. package/dist/esm/util/refusal.util.js +29 -11
  113. package/dist/esm/util/registration-mode.util.js +14 -1
  114. package/dist/esm/util/timer-limit.util.js +4 -1
  115. package/dist/esm/util/tsconfig-paths.util.js +38 -7
  116. package/dist/esm/util/tsconfig.util.js +16 -58
  117. package/dist/esm/util/user-text.util.js +8 -4
  118. package/dist/esm/util/value.util.js +33 -5
  119. package/dist/types/common/index.d.cts +17 -10
  120. package/dist/types/common/index.d.ts +17 -10
  121. package/dist/types/core/index.d.cts +21 -11
  122. package/dist/types/core/index.d.ts +21 -11
  123. package/dist/types/decorator/index.d.cts +63 -63
  124. package/dist/types/decorator/index.d.ts +63 -63
  125. package/dist/types/{index-DKJclIi-.d.ts → index-Brkd6QGL.d.ts} +81 -35
  126. package/dist/types/{index-Dlf0ZZtS.d.cts → index-CqXImwF_.d.cts} +81 -35
  127. package/dist/types/interface/index.d.cts +1 -1
  128. package/dist/types/interface/index.d.ts +1 -1
  129. package/dist/types/{route-Bh1U13yy.d.ts → route-IQrK-MN2.d.cts} +11 -3
  130. package/dist/types/{route-Bh1U13yy.d.cts → route-bI-okq7g.d.ts} +11 -3
  131. package/dist/types/testing/index.d.cts +158 -61
  132. package/dist/types/testing/index.d.ts +158 -61
  133. package/dist/types/{theme-resolvers-CyEz9pnv.d.cts → theme-resolvers-AtgeaYqF.d.cts} +1 -1
  134. package/dist/types/{theme-resolvers-DA204O-q.d.ts → theme-resolvers-BgqysV8d.d.ts} +1 -1
  135. package/dist/types/{translator-Bg7hwxDF.d.cts → translator-D8aydlCE.d.ts} +12 -11
  136. package/dist/types/{translator-CeP4bCzp.d.ts → translator-iJhDlgwL.d.cts} +12 -11
  137. package/package.json +5 -2
@@ -1,9 +1,12 @@
1
1
  import { ExecutionContext } from '../common/execution-context.js';
2
+ import { warnOnce } from '../common/deprecation.js';
3
+ import { ThemeCache } from './theme-resolvers.js';
2
4
  import { injectedTokens, undecoratedConstructor, untypedParameter } from './guard-runner.js';
3
- import { meocordClasses, meocordClassAdvice } from './meocord-classes.js';
5
+ import { meocordClassAdvice, meocordClasses } from './meocord-classes.js';
4
6
  import { isAppClassToken } from './lifecycle-order.js';
5
7
  import { makeInjectable } from '../util/injectable.util.js';
6
8
  import { startupError, refuse } from '../util/refusal.util.js';
9
+ import { isConstructor } from '../util/value.util.js';
7
10
 
8
11
  const isValueProvider = (provider)=>'useValue' in provider;
9
12
  const isClassProvider = (provider)=>'useClass' in provider;
@@ -14,6 +17,18 @@ const isFactoryProvider = (provider)=>'useFactory' in provider;
14
17
  return `'${String(token)}'`;
15
18
  }
16
19
  const isToken = (value)=>typeof value === 'function' || typeof value === 'string' || typeof value === 'symbol';
20
+ /**
21
+ * Checks one of an app's own `@MeoCord({ providers })` as the bot does. A provider of a token MeoCord binds later, or
22
+ * for each call, is warned about until 5.0 refuses it; one of a token `isOwn` names, which MeoCord binds before the
23
+ * providers, is refused.
24
+ *
25
+ * @returns The refusal, for the caller to report, or `undefined`.
26
+ */ function appProviderRefusal(appName, token, isOwn, logger) {
27
+ if (token === ThemeCache || token === ExecutionContext) {
28
+ warnOnce(logger, `${appName}: @MeoCord({ providers }) provides ${tokenName(token)}, which MeoCord binds itself; ` + 'the next major version (5.0) refuses it. Remove the provider.');
29
+ }
30
+ return isOwn(token) ? new Error(`${appName}: @MeoCord({ providers }) cannot provide ${tokenName(token)}, which MeoCord binds itself.`) : undefined;
31
+ }
17
32
  /**
18
33
  * Checks each provider's shape and indexes them by token, reporting each that cannot be bound, such as one with no
19
34
  * token, not exactly one of `useValue`, `useClass` and `useFactory`, one that injects `ExecutionContext`, or a token
@@ -43,7 +58,7 @@ const isToken = (value)=>typeof value === 'function' || typeof value === 'string
43
58
  startupError(new Error(`${where}: the provider for ${name} needs exactly one of useValue, useClass and useFactory.`));
44
59
  continue;
45
60
  }
46
- if ('useClass' in entry && typeof entry.useClass !== 'function') {
61
+ if ('useClass' in entry && !isConstructor(entry.useClass)) {
47
62
  startupError(new Error(`${where}: the provider for ${name} has a useClass that is not a class.`));
48
63
  continue;
49
64
  }
@@ -73,6 +88,10 @@ const isToken = (value)=>typeof value === 'function' || typeof value === 'string
73
88
  }
74
89
  return map;
75
90
  }
91
+ /** For each container, how to forget the value each factory provider made, so a start that failed is made afresh. */ const madeValues = new WeakMap();
92
+ /** Forgets every value `container`'s factory providers made, so the next resolve runs each factory again. */ function forgetFactoryValues(container) {
93
+ for (const forget of madeValues.get(container) ?? [])forget();
94
+ }
76
95
  /**
77
96
  * Binds one provider into `container` as a singleton. `bindClass` binds a class a provider depends
78
97
  * on, as the app binds its own, so a provided class or a factory's `inject` can name any class.
@@ -93,6 +112,10 @@ const isToken = (value)=>typeof value === 'function' || typeof value === 'string
93
112
  // Made once, as a singleton, but kept only once made: a factory that throws or rejects runs again on the next
94
113
  // resolve, so a start() after it failed tries it again. Resolves while one is pending share it.
95
114
  let kept;
115
+ madeValues.set(container, [
116
+ ...madeValues.get(container) ?? [],
117
+ ()=>kept = undefined
118
+ ]);
96
119
  container.bind(token).toDynamicValue((context)=>{
97
120
  if (kept) return kept.value;
98
121
  // Resolved in dependency order before anything needs it, so each injected value is already made
@@ -120,6 +143,15 @@ const isToken = (value)=>typeof value === 'function' || typeof value === 'string
120
143
  });
121
144
  for (const dependency of inject)if (isAppClassToken(dependency)) bindClass(dependency);
122
145
  }
146
+ /**
147
+ * Reports each service listed by a string or symbol token that no provider provides, which would otherwise be bound
148
+ * as if it were a class.
149
+ */ function assertProvidedServices(appName, services, providers, where) {
150
+ for (const service of services){
151
+ if (typeof service === 'function' || providers.has(service)) continue;
152
+ startupError(new Error(`${appName}: @MeoCord({ services }) lists ${tokenName(service)}, which nothing provides: add a provider for it to ${where}.`));
153
+ }
154
+ }
123
155
  /**
124
156
  * Reports a class or a factory that asks for a string or symbol token nothing binds, naming both, rather than failing
125
157
  * when the class is first resolved. A class token is bound on demand, so it is never missing.
@@ -255,11 +287,13 @@ const isToken = (value)=>typeof value === 'function' || typeof value === 'string
255
287
  * Runs every factory in `order`, awaiting those that return a promise, so each value is made before
256
288
  * anything that injects it is resolved. Rejects with {@link providerFailure} for the first that fails. With `control`,
257
289
  * it makes no further value once the app is stopping.
290
+ *
291
+ * @returns Whether every factory was made: `false` when the app began stopping first.
258
292
  */ async function resolveProviders(container, providers, order, control) {
259
293
  for (const token of order){
260
294
  const provider = providers.get(token);
261
295
  if (!provider || !isFactoryProvider(provider)) continue;
262
- if (control?.stopped()) return;
296
+ if (control?.stopped()) return false;
263
297
  control?.pending(tokenName(token));
264
298
  try {
265
299
  await container.getAsync(token);
@@ -269,6 +303,7 @@ const isToken = (value)=>typeof value === 'function' || typeof value === 'string
269
303
  control?.pending(undefined);
270
304
  }
271
305
  }
306
+ return true;
272
307
  }
273
308
 
274
- export { assertProvided, assertTypedParameters, bindProvider, bindsOwnToken, isClassProvider, isFactoryProvider, isValueProvider, providerFailure, providerMap, reachableClasses, resolutionOrder, resolveProviders, tokenDependencies, tokenName };
309
+ export { appProviderRefusal, assertProvided, assertProvidedServices, assertTypedParameters, bindProvider, bindsOwnToken, forgetFactoryValues, isClassProvider, isFactoryProvider, isValueProvider, providerFailure, providerMap, reachableClasses, resolutionOrder, resolveProviders, tokenDependencies, tokenName };
@@ -21,34 +21,70 @@ const UNIT_MS = lookupTable({
21
21
  w: 604800000
22
22
  });
23
23
  /** A number written in full, `whole` for an integer: `undefined` for anything else. */ function number(word, whole) {
24
- if (!(whole ? /^[+-]?\d+$/ : /^[+-]?(\d+\.?\d*|\.\d+)(e[+-]?\d+)?$/i).test(word)) return undefined;
24
+ // Each run of digits splits only one way, so refusing a word takes time linear in its length
25
+ if (!(whole ? /^[+-]?\d+$/ : /^[+-]?(\d+(?:\.\d*)?|\.\d+)(e[+-]?\d+)?$/i).test(word)) return undefined;
25
26
  const value = Number(word);
26
27
  return Number.isFinite(value) && (!whole || Number.isSafeInteger(value)) ? value : undefined;
27
28
  }
28
29
  /** `yes`, `no`, `true`, `false`, `on` or `off`, in any case, as a boolean. */ const bool = (word)=>BOOLEANS[word.toLowerCase()];
29
- /** A length of time written as amounts and units, such as `90s`, `2h30m` or `1.5d`, in milliseconds. */ function duration(word) {
30
- const parts = word.toLowerCase().match(/(\d+(?:\.\d+)?)(ms|s|m|h|d|w)/g);
30
+ /**
31
+ * A length of time written as amounts and units, such as `90s`, `2h30m` or `1.5d`, in milliseconds: `undefined` for one
32
+ * too long to add up.
33
+ */ function duration(word) {
34
+ // Sticky: each part starts where the last ended, so the scan stops at the first that doesn't and reads each part once
35
+ const parts = word.toLowerCase().match(/(\d+(?:\.\d+)?)(ms|s|m|h|d|w)/gy);
31
36
  if (!parts || parts.join('') !== word.toLowerCase()) return undefined;
32
- return parts.reduce((total, part)=>{
37
+ const total = parts.reduce((sum, part)=>{
33
38
  const [, amount, unit] = /^(\d+(?:\.\d+)?)(ms|s|m|h|d|w)$/.exec(part);
34
- return total + Number(amount) * UNIT_MS[unit];
39
+ return sum + Number(amount) * UNIT_MS[unit];
35
40
  }, 0);
41
+ // Too long to add up, as number() refuses a value past the largest number
42
+ return Number.isFinite(total) ? total : undefined;
36
43
  }
37
44
  /** The largest snowflake: Discord's IDs are unsigned 64-bit integers. */ const MAX_SNOWFLAKE = 2n ** 64n - 1n;
45
+ /** The fewest and most digits a snowflake has, and the words of that many digits, with no leading zero. */ const SNOWFLAKE_DIGITS = {
46
+ min: 17,
47
+ max: 20
48
+ };
49
+ const SNOWFLAKE = new RegExp(`^[1-9]\\d{${SNOWFLAKE_DIGITS.min - 1},${SNOWFLAKE_DIGITS.max - 1}}$`);
38
50
  /**
39
51
  * A Discord ID, kept as its text: 17 to 20 digits, no leading zero, up to the largest 64-bit value. An ID's top 42 bits
40
52
  * are milliseconds since 2015-01-01, so every ID made from 2015-01-28 on has 17 digits or more, and none is a safe
41
53
  * integer.
42
- */ const snowflake = (word)=>/^[1-9]\d{16,19}$/.test(word) && BigInt(word) <= MAX_SNOWFLAKE ? word : undefined;
43
- /** A UUID in its canonical 8-4-4-4-12 form, in either case, kept as written. */ const uuid = (word)=>/^[\da-f]{8}-[\da-f]{4}-[\da-f]{4}-[\da-f]{4}-[\da-f]{12}$/i.test(word) ? word : undefined;
54
+ */ const snowflake = (word)=>SNOWFLAKE.test(word) && BigInt(word) <= MAX_SNOWFLAKE ? word : undefined;
55
+ /** A UUID's canonical 8-4-4-4-12 form, 36 characters with its dashes. */ const UUID = /^[\da-f]{8}-[\da-f]{4}-[\da-f]{4}-[\da-f]{4}-[\da-f]{12}$/i;
56
+ const UUID_LENGTH = 36;
57
+ /** A UUID in its canonical 8-4-4-4-12 form, in either case, kept as written. */ const uuid = (word)=>UUID.test(word) ? word : undefined;
44
58
  /** The words a type such as `on|off` chooses from, or `undefined` for any other type. */ const choicesOf = (type)=>type.includes('|') ? type.split('|') : undefined;
45
- /** The types a component's customId segment can take: each parsed from the segment alone, with no request. */ const SEGMENT_TYPES = lookupTable({
46
- string: (segment)=>segment,
47
- int: (segment)=>number(segment, true),
48
- number: (segment)=>number(segment, false),
49
- bool,
50
- snowflake,
51
- uuid
59
+ /** The shortest segment of any type: one character. */ const SHORTEST_SEGMENT = 1;
60
+ /**
61
+ * The types a component's customId segment can take: each parsed from the segment alone, with no request, and the
62
+ * fewest characters a segment of it has, which the check for a pattern no customId can match counts.
63
+ */ const SEGMENT_TYPES = lookupTable({
64
+ string: {
65
+ parse: (segment)=>segment,
66
+ shortest: SHORTEST_SEGMENT
67
+ },
68
+ int: {
69
+ parse: (segment)=>number(segment, true),
70
+ shortest: SHORTEST_SEGMENT
71
+ },
72
+ number: {
73
+ parse: (segment)=>number(segment, false),
74
+ shortest: SHORTEST_SEGMENT
75
+ },
76
+ bool: {
77
+ parse: bool,
78
+ shortest: Math.min(...Object.keys(BOOLEANS).map((word)=>word.length))
79
+ },
80
+ snowflake: {
81
+ parse: snowflake,
82
+ shortest: SNOWFLAKE_DIGITS.min
83
+ },
84
+ uuid: {
85
+ parse: uuid,
86
+ shortest: UUID_LENGTH
87
+ }
52
88
  });
53
89
  /**
54
90
  * Whether `type` can type a customId segment: `string`, `int`, `number`, `bool`, `snowflake`, `uuid`, or words to
@@ -57,7 +93,12 @@ const UNIT_MS = lookupTable({
57
93
  /** A customId segment as its type's value, words to choose from compared as written: `undefined` when it is not one. */ function parseSegment(type, segment) {
58
94
  const choices = choicesOf(type);
59
95
  if (choices) return choices.includes(segment) ? segment : undefined;
60
- return SEGMENT_TYPES[type](segment);
96
+ return SEGMENT_TYPES[type].parse(segment);
97
+ }
98
+ /** The fewest characters a customId segment of `type` has: words to choose from count their shortest word. */ function shortestSegment(type) {
99
+ const choices = choicesOf(type);
100
+ if (choices) return Math.min(...choices.map((word)=>word.length));
101
+ return SEGMENT_TYPES[type]?.shortest ?? SHORTEST_SEGMENT;
61
102
  }
62
103
 
63
- export { bool, choicesOf, duration, isSegmentType, lookupTable, number, parseSegment, snowflake, uuid };
104
+ export { bool, choicesOf, duration, isSegmentType, lookupTable, number, parseSegment, shortestSegment, snowflake, uuid };
@@ -1,19 +1,20 @@
1
1
  import { randomUUID } from 'node:crypto';
2
2
  import { Logger } from '../common/logger.js';
3
- import './theme-scope.js';
4
- import './theme-validation.js';
3
+ import '../common/theme.js';
5
4
  import '../common/decorator.js';
6
5
  import 'reflect-metadata';
7
6
  import 'discord.js';
7
+ import '../common/deprecation.js';
8
8
  import '../common/response/response-state.js';
9
+ import './theme-scope.js';
9
10
  import '../common/errors.js';
11
+ import '../common/cooldown-store.js';
10
12
  import '../decorator/controller.decorator.js';
11
13
  import './scalar-types.js';
12
- import 'path';
13
- import 'node:fs';
14
- import 'node:url';
14
+ import '../util/refusal.util.js';
15
15
  import '../common/translator.js';
16
16
  import '../common/meocord-text.js';
17
+ import './theme-resolvers.js';
17
18
 
18
19
  /** Where each shard's client keeps the function that runs a `ShardContext.call` in that shard. */ const SHARD_CALL_KEY = Symbol.for('meocord.shardCall');
19
20
  const packArgs = (args)=>Object.assign({
@@ -122,11 +123,11 @@ async function withTimeout(promise, ms, what) {
122
123
  *
123
124
  * With process sharding that is one result per shard; with one process, a single result listing every
124
125
  * shard. The arguments and the result pass as JSON in every mode, one process and tests included, so a
125
- * `Date` arrives as a string and a `Map` as `{}` wherever it runs, and a value JSON cannot carry, such as a
126
- * `BigInt`, fails the call. A method whose params JSON would change, such as one taking a `Date`, cannot be called:
127
- * the argument is refused, naming what to declare instead. An `undefined` argument arrives as `undefined`. A process
128
- * that throws, lacks the service, or does not answer within 10 seconds gives an error result instead of failing the
129
- * others.
126
+ * `Date` arrives as a string, a `Map` as `{}` and a class instance as its own fields, without its getters, wherever
127
+ * it runs, and a value JSON cannot carry, such as a `BigInt`, fails the call. A method whose params JSON would
128
+ * change, such as one taking a `Date`, cannot be called: the argument is refused, naming what to declare instead. An
129
+ * `undefined` argument arrives as `undefined`. A process that throws, lacks the service, or does not answer within
130
+ * 10 seconds gives an error result instead of failing the others.
130
131
  *
131
132
  * @param service - The controller, the service, or a class a provider stands in for; each process resolves its own
132
133
  * instance.
@@ -1,4 +1,5 @@
1
1
  import { isExplainedError, markExplained } from '../common/explained-error.js';
2
+ import { processState } from './process-state.js';
2
3
  import { REFUSED_CODE } from './shard-messages.js';
3
4
  import { sendToParent } from '../util/parent-send.util.js';
4
5
  import { isRefusal, describeRefusal, handOffRefusal } from '../util/refusal.util.js';
@@ -9,14 +10,13 @@ import { isRefusal, describeRefusal, handOffRefusal } from '../util/refusal.util
9
10
  */ async function tellManager(message) {
10
11
  await sendToParent(message);
11
12
  }
12
- /** Whether this shard is already ending, so a failure reported twice, as one going on uncaught is, ends it once. */ let ending = false;
13
13
  /**
14
14
  * Ends a shard whose startup failed, which its manager restarts unless it was told the failure is one every shard
15
15
  * meets: a refusal, told here first, which the manager logs before stopping them all, or a login Discord refused, told
16
16
  * as it failed. The exit waits until the caller has handled the error and the manager has the reason.
17
17
  */ function endFailedShard(error) {
18
- if (ending) return;
19
- ending = true;
18
+ if (processState.shardEnding) return;
19
+ processState.shardEnding = true;
20
20
  const refused = isRefusal(error);
21
21
  const reason = refused && describeRefusal(error, process.cwd());
22
22
  if (refused) {
@@ -1,10 +1,11 @@
1
1
  import { fetchRecommendedShardCount, ShardingManager, REST, Routes } from 'discord.js';
2
2
  import { Logger, hideInLogs } from '../common/logger.js';
3
- import './theme-scope.js';
4
- import './theme-validation.js';
3
+ import '../common/theme.js';
5
4
  import '../common/decorator.js';
6
5
  import 'reflect-metadata';
6
+ import '../common/deprecation.js';
7
7
  import '../common/response/response-state.js';
8
+ import './theme-scope.js';
8
9
  import '../common/errors.js';
9
10
  import { MemoryCooldownStore } from '../common/cooldown-store.js';
10
11
  import 'node:crypto';
@@ -13,10 +14,11 @@ import './scalar-types.js';
13
14
  import { answerCooldown } from '../common/sharded-cooldown-store.js';
14
15
  import '../common/translator.js';
15
16
  import '../common/meocord-text.js';
17
+ import './theme-resolvers.js';
16
18
  import { registerCommands } from './command-registration.js';
17
19
  import { SHUTDOWN_MARGIN_MS, shutdownTimeoutOf } from '../util/shutdown-timeout.util.js';
18
20
  import { bundleEntry } from '../util/bundle-entry.util.js';
19
- import { FORCE_REGISTER_ENV } from '../util/registration-mode.util.js';
21
+ import { FORCE_REGISTER_ENV, registersAsDevelopment } from '../util/registration-mode.util.js';
20
22
  import { isShardMessage, REFUSED_CODE } from './shard-messages.js';
21
23
  import { tokenMessage, isRefusedToken } from './login-failure.js';
22
24
  import { onDevRunnerStop, tellDevRunner, underDevRunner } from '../util/dev-runner.util.js';
@@ -54,7 +56,10 @@ const sleep = (ms)=>new Promise((resolve)=>setTimeout(resolve, ms));
54
56
  return this.exit(1);
55
57
  }
56
58
  this.logger.log('Starting shards in separate processes...');
57
- if (!await this.register()) return this.exitForLogin();
59
+ const registered = await this.register();
60
+ // A stop that came while it waited ends the start here: nothing after it asks Discord, spawns, listens or exits
61
+ if (this.stopping) return;
62
+ if (!registered) return this.exitForLogin();
58
63
  process.on('SIGINT', ()=>void this.stopAndExit());
59
64
  process.on('SIGTERM', ()=>void this.stopAndExit());
60
65
  // How `meocord start --dev` stops the bot to restart it; no stop request of the user's, whose first Ctrl+C joins it
@@ -64,6 +69,7 @@ const sleep = (ms)=>new Promise((resolve)=>setTimeout(resolve, ms));
64
69
  const shards = config.sharding?.shards ?? 'auto';
65
70
  total = shards === 'auto' ? await (this.options.recommendedShardCount ?? fetchRecommendedShardCount)(token) : shards;
66
71
  } catch (error) {
72
+ if (this.stopping) return;
67
73
  if (isRefusedToken(error)) {
68
74
  this.logger.error(tokenMessage(token));
69
75
  this.logger.debug('Asking for the shard count failed:', error);
@@ -72,6 +78,7 @@ const sleep = (ms)=>new Promise((resolve)=>setTimeout(resolve, ms));
72
78
  }
73
79
  return this.exitForLogin();
74
80
  }
81
+ if (this.stopping) return;
75
82
  const manager = (this.options.createManager ?? ((path, opts)=>new ShardingManager(path, opts)))(file, {
76
83
  token,
77
84
  totalShards: total,
@@ -105,13 +112,15 @@ const sleep = (ms)=>new Promise((resolve)=>setTimeout(resolve, ms));
105
112
  try {
106
113
  const rest = this.options.createRest?.(token) ?? new REST().setToken(token);
107
114
  const applicationId = (await rest.get(Routes.currentApplication())).id;
115
+ // Stopped while it asked: the commands are left as they are
116
+ if (this.stopping) return true;
108
117
  await registerCommands({
109
118
  rest,
110
119
  applicationId,
111
120
  controllerClasses,
112
121
  logger: this.logger,
113
122
  config: config.commands,
114
- development: process.env.NODE_ENV === 'development',
123
+ development: registersAsDevelopment(),
115
124
  force: process.env[FORCE_REGISTER_ENV] === '1'
116
125
  });
117
126
  } catch (error) {
@@ -153,7 +162,8 @@ const sleep = (ms)=>new Promise((resolve)=>setTimeout(resolve, ms));
153
162
  if (this.now() - state.spawnedAt >= RESPAWN_RESET_MS) state.attempts = 0;
154
163
  const delay = Math.min(RESPAWN_BASE_MS * 2 ** state.attempts, RESPAWN_CAP_MS);
155
164
  state.attempts++;
156
- this.logger.warn(`Shard ${shard.id} exited with code ${child?.exitCode ?? 'unknown'}; restarting it in ${delay} ms.`);
165
+ const how = child?.signalCode ? `on ${child.signalCode}` : `with code ${child?.exitCode ?? 'unknown'}`;
166
+ this.logger.warn(`Shard ${shard.id} exited ${how}; restarting it in ${delay} ms.`);
157
167
  state.timer = setTimeout(()=>{
158
168
  state.timer = undefined;
159
169
  if (!this.stopping && !this.fatal) void this.spawn(shard);
@@ -46,7 +46,8 @@ import { META } from '../util/metadata-keys.js';
46
46
  }
47
47
  }
48
48
  /** The class of a stage entry. */ function stageClass(entry) {
49
- return isConstructor(entry) ? entry : entry.provide;
49
+ // Entries were checked as they were declared, so a function is the class itself
50
+ return typeof entry === 'function' ? entry : entry.provide;
50
51
  }
51
52
  /** The context types a stage declared, or `undefined` when it runs for every type. */ function stageTypes(entry) {
52
53
  return Reflect.getMetadata(META.stageTypes, stageClass(entry));
@@ -5,6 +5,7 @@ import { reachableClasses, assertTypedParameters } from './providers.js';
5
5
  import { themeResolverClass } from './theme-resolvers.js';
6
6
  import { isAppClassToken } from './lifecycle-order.js';
7
7
  import { ExecutionContext } from '../common/execution-context.js';
8
+ import { getEventHandlers } from '../decorator/event.decorator.js';
8
9
  import { META } from '../util/metadata-keys.js';
9
10
  import { startupError } from '../util/refusal.util.js';
10
11
 
@@ -63,19 +64,38 @@ import { startupError } from '../util/refusal.util.js';
63
64
  seen.add(cls);
64
65
  return injectedTokens(cls).some((token)=>token === ExecutionContext || isAppClassToken(token) && !providers.has(token) && !seen.has(token) && reachesContext(token, providers, seen));
65
66
  }
66
- /**
67
- * The classes the app's stages and presenter inject, which they would make only at their first call. Made with the
68
- * app's own classes, a service among them gets its lifecycle hooks. One that reaches the call's `ExecutionContext` is
69
- * made for each call, so it is left out, and a stage a test replaces is not among the stages, so what it would inject
70
- * is not made.
71
- */ function stageDependencies({ decorators }, providers, presenter) {
67
+ /** The app's classes the stages and presenter inject. */ function stageInjected({ decorators }, presenter) {
72
68
  const stages = [
73
69
  ...decorators
74
70
  ].filter(([, decorator])=>decorator !== '@Controller()').map(([stage])=>stage);
75
71
  if (typeof presenter === 'function') stages.push(presenter);
76
72
  return [
77
73
  ...new Set(stages.flatMap((stage)=>injectedTokens(stage)))
78
- ].filter(isAppClassToken).filter((cls)=>!reachesContext(cls, providers));
74
+ ].filter(isAppClassToken);
75
+ }
76
+ /**
77
+ * The classes the app's stages and presenter inject, which they would make only at their first call. Made with the
78
+ * app's own classes, a service among them gets its lifecycle hooks. One that reaches the call's `ExecutionContext` is
79
+ * made for each call, so it is left out, and a stage a test replaces is not among the stages, so what it would inject
80
+ * is not made.
81
+ */ function stageDependencies(startup, providers, presenter) {
82
+ return stageInjected(startup, presenter).filter((cls)=>!reachesContext(cls, providers));
83
+ }
84
+ /**
85
+ * A warning for each `@On` and `@Once` handler on a class the stages and presenter reach that is made for each call,
86
+ * as it reaches `ExecutionContext`: no one instance of it receives events.
87
+ */ function perCallEventWarnings(startup, providers, presenter) {
88
+ const perCall = new Set();
89
+ // What a class made for each call injects is made for each call too only if it also reaches the context
90
+ const visit = (cls)=>{
91
+ if (perCall.has(cls) || providers.has(cls) || !reachesContext(cls, providers)) return;
92
+ perCall.add(cls);
93
+ injectedTokens(cls).filter(isAppClassToken).forEach(visit);
94
+ };
95
+ stageInjected(startup, presenter).forEach(visit);
96
+ return [
97
+ ...perCall
98
+ ].flatMap((cls)=>getEventHandlers(cls.prototype).map(({ event, method, once })=>`${cls.name}.${method}: @${once ? 'Once' : 'On'}('${event}') is on a class made for each call, as it injects ` + 'ExecutionContext or a class that does, so it receives no events. The next major version (5.0) refuses it; ' + 'move the handler to a service that does not.'));
79
99
  }
80
100
  /** How a container checks a guard first met at a call: the guard and what it injects, as the startup checks would. */ function lateGuardCheck(providers, translator) {
81
101
  return (guard)=>assertStartupClasses({
@@ -98,4 +118,4 @@ import { startupError } from '../util/refusal.util.js';
98
118
  for (const needsOne of classes.filter((cls)=>injectedTokens(cls).includes(Translator)))startupError(missingTranslatorError(needsOne));
99
119
  }
100
120
 
101
- export { assertStartupClasses, lateGuardCheck, stageDependencies, startupClasses };
121
+ export { assertStartupClasses, lateGuardCheck, perCallEventWarnings, stageDependencies, startupClasses };
@@ -1,6 +1,9 @@
1
+ import { User } from 'discord.js';
1
2
  import { Logger } from '../common/logger.js';
2
3
  import { themeProblems } from './theme-validation.js';
3
4
  import { outsideThemeScope, copyLayer } from './theme-scope.js';
5
+ import { quoteForLog } from '../util/user-text.util.js';
6
+ import { shared } from '../util/shared-state.util.js';
4
7
 
5
8
  /** How long a resolver's result is kept, in seconds, unless `themeCache.ttlSeconds` says otherwise. */ const DEFAULT_THEME_TTL_SECONDS = 300;
6
9
  /** How many servers' results are kept, unless `themeCache.maxGuilds` says otherwise. */ const DEFAULT_THEME_MAX_GUILDS = 10000;
@@ -113,15 +116,25 @@ const logger = new Logger('Theme');
113
116
  ]).finally(()=>clearTimeout(timer));
114
117
  }
115
118
  /**
116
- * A result as a layer: copied, then checked; one with problems is left out, with a warning once per id when `report`.
117
- * `null`, as a database gives for a missing row, is no theme, as `undefined` is.
119
+ * A result as a layer: copied, then checked; one with problems, or that throws as it is read, is left out, with a
120
+ * warning once per id when `report`. `null`, as a database gives for a missing row, is no theme, as `undefined` is.
118
121
  */ accept(id, result, report) {
119
122
  if (result === undefined || result === null) return undefined;
120
- const layer = copyLayer(result);
121
- // A value from the app's data, such as a user's saved choice, shown as a log line shows outside text
122
- const problems = themeProblems(layer, `themeFor.${this.kind} for ${this.kind} ${id}`, {
123
- forLog: true
124
- });
123
+ const where = `themeFor.${this.kind} for ${this.kind} ${id}`;
124
+ let layer;
125
+ let problems;
126
+ try {
127
+ layer = copyLayer(result);
128
+ // A value from the app's data, such as a user's saved choice, shown as a log line shows outside text
129
+ problems = themeProblems(layer, where, {
130
+ forLog: true
131
+ });
132
+ } catch (error) {
133
+ // A cycle overflows the copy, and a getter may throw: the result is left out as one with problems is
134
+ problems = [
135
+ `${where}: theme cannot be read: ${quoteForLog(String(error?.message ?? error))}`
136
+ ];
137
+ }
125
138
  if (problems.length === 0) {
126
139
  if (report) this.warned.delete(id);
127
140
  return layer;
@@ -250,13 +263,14 @@ const logger = new Logger('Theme');
250
263
  const idOf = (value)=>typeof value === 'string' ? value : undefined;
251
264
  /**
252
265
  * The server and user a call comes from: an interaction's or a message's server and its user or author; a
253
- * reaction's message's server and the user who reacted, which a reaction handler gets second; an event argument's
254
- * server and user, when it has them.
266
+ * reaction's message's server and the user who reacted, which a reaction handler gets second as `{ user }` and a
267
+ * reaction event as the `User`; an event argument's server and user, when it has them.
255
268
  */ function themeTargets(args) {
256
269
  const [first, second] = args;
257
270
  if (!first || typeof first !== 'object') return {};
258
271
  const guildId = idOf(first.guildId) ?? idOf(first.guild?.id) ?? idOf(first.message?.guildId);
259
- const userId = idOf(first.user?.id) ?? idOf(first.author?.id) ?? idOf(second?.user?.id);
272
+ // Only a User's own id: an event of an old and a new channel, role or message would give the new one's otherwise
273
+ const userId = idOf(first.user?.id) ?? idOf(first.author?.id) ?? idOf(second?.user?.id) ?? (second instanceof User ? idOf(second.id) : undefined);
260
274
  return {
261
275
  guildId,
262
276
  userId
@@ -283,7 +297,8 @@ const idOf = (value)=>typeof value === 'string' ? value : undefined;
283
297
  user
284
298
  ];
285
299
  }
286
- const stores = new WeakMap();
300
+ // One per process for this meocord version, as the rest of an app's theme state is
301
+ const stores = shared('themeCaches', ()=>new WeakMap());
287
302
  /**
288
303
  * An app's cache of the themes `@MeoCord({ themeFor })` looked up, by server and by user.
289
304
  *
@@ -1,12 +1,14 @@
1
1
  import 'reflect-metadata';
2
- import { stageClasses, sourcePrototype } from './guard-runner.js';
2
+ import { stageClasses, stageOwners } from './guard-runner.js';
3
3
  import { makeInjectable } from '../util/injectable.util.js';
4
4
  import { lookupLayers, resolverCaches, ThemeCache, attachThemeCache } from './theme-resolvers.js';
5
- import { releaseAmbientTheme, claimAmbientTheme, mergeTheme, defaultTheme, themeLayersVersion, ambientThemeVersion, ownsAmbientTheme, hasAmbientTheme, setThemeOutsideCalls, useTheme } from './theme-scope.js';
5
+ import { setThemeOutsideCalls, releaseAmbientTheme, claimAmbientTheme, defaultTheme, mergeTheme, themeLayersVersion, ambientThemeVersion, ownsAmbientTheme, useTheme } from './theme-scope.js';
6
6
  import { META } from '../util/metadata-keys.js';
7
+ import { shared } from '../util/shared-state.util.js';
7
8
 
8
- /** Classes with a `@UseTheme`, on the class or one of its methods. */ const THEMED_CLASSES = new WeakSet();
9
- const apps = new WeakMap();
9
+ /** Classes with a `@UseTheme`, on the class or one of its methods, from either build of this version. */ const THEMED_CLASSES = shared('themedClasses', ()=>new WeakSet());
10
+ // One per process for this meocord version, so an app started through one build finds its theme from the other
11
+ const apps = shared('appThemes', ()=>new WeakMap());
10
12
  /** The classes on `cls`'s prototype chain, itself first. */ function chainOf(cls) {
11
13
  const chain = [];
12
14
  for(let prototype = cls.prototype; prototype && prototype !== Object.prototype; prototype = Object.getPrototypeOf(prototype)){
@@ -37,6 +39,13 @@ const apps = new WeakMap();
37
39
  }
38
40
  attachThemeCache(container.get(ThemeCache), resolvers);
39
41
  }
42
+ /** Counts `classes`, whose handlers are prepared once the app's providers are made, among those a theme can vary by. */ function prepareLateThemes(container, classes) {
43
+ const themes = apps.get(container);
44
+ if (!themes || themes.varies || !classes.some((cls)=>chainOf(cls).some((link)=>THEMED_CLASSES.has(link)))) return;
45
+ themes.varies = true;
46
+ // Built again on its next read, with each handler's own theme
47
+ themes.version = -1;
48
+ }
40
49
  /** The app's themes, built again when the layers beneath the app's have changed. */ function current(container) {
41
50
  const themes = apps.get(container);
42
51
  if (themes && themes.version !== themeLayersVersion()) {
@@ -51,13 +60,12 @@ const apps = new WeakMap();
51
60
  }
52
61
  /**
53
62
  * Decides whether the app's calls need a scope, again whenever the layers beneath it or the app read outside a call
54
- * change. They do when the handlers can differ in theme. Otherwise a call reads the theme outside a call, so it
55
- * needs one unless that is its own theme: when the app owns it, or when no app does and the app's theme is MeoCord's
56
- * defaults.
63
+ * change. They do when the handlers can differ in theme. Otherwise a call reads the theme outside a call, so it needs
64
+ * one unless its app owns that theme: another app may claim it while the call runs, and the call keeps the theme it
65
+ * started with.
57
66
  */ function rescope(container, themes) {
58
67
  themes.ambientVersion = ambientThemeVersion();
59
- const readsOwnTheme = ownsAmbientTheme(container) || !hasAmbientTheme() && themes.app === defaultTheme();
60
- themes.scoped = themes.varies || !readsOwnTheme ? themes.app : undefined;
68
+ themes.scoped = themes.varies || !ownsAmbientTheme(container) ? themes.app : undefined;
61
69
  }
62
70
  /** The app's theme: MeoCord's defaults and the app's `@MeoCord({ theme })`, or the defaults for a container with none. */ function appTheme(container) {
63
71
  return current(container)?.app ?? defaultTheme();
@@ -75,8 +83,10 @@ const apps = new WeakMap();
75
83
  for (const cls of stageClasses(prototype, methodName)){
76
84
  theme = mergeTheme(theme, Reflect.getOwnMetadata(META.classTheme, cls));
77
85
  }
78
- const source = sourcePrototype(prototype, methodName);
79
- if (source) theme = mergeTheme(theme, Reflect.getOwnMetadata(META.methodTheme, source, methodName));
86
+ // The method's layer over its class's, and an override's over the method it overrides
87
+ for (const owner of stageOwners(prototype, methodName)){
88
+ theme = mergeTheme(theme, Reflect.getOwnMetadata(META.methodTheme, owner, methodName));
89
+ }
80
90
  byMethod.set(methodName, theme);
81
91
  return theme;
82
92
  }
@@ -115,7 +125,7 @@ const apps = new WeakMap();
115
125
  ready
116
126
  };
117
127
  }
118
- const clientApps = new WeakMap();
128
+ const clientApps = shared('clientThemes', ()=>new WeakMap());
119
129
  /** Records which app a client's interactions come to, so an answer outside any call can find its theme. */ function registerClientTheme(client, container) {
120
130
  clientApps.set(client, container);
121
131
  }
@@ -139,4 +149,4 @@ setThemeOutsideCalls((interaction)=>{
139
149
  releaseAmbientTheme(container);
140
150
  }
141
151
 
142
- export { THEMED_CLASSES, appTheme, beginCallTheme, claimAmbientAppTheme, configureThemes, registerClientTheme, releaseAmbientAppTheme };
152
+ export { THEMED_CLASSES, appTheme, beginCallTheme, claimAmbientAppTheme, configureThemes, prepareLateThemes, registerClientTheme, releaseAmbientAppTheme };