meocord 4.1.2 → 4.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (140) hide show
  1. package/CHANGELOG.md +362 -0
  2. package/THIRD_PARTY_NOTICES.md +14 -13
  3. package/dist/cjs/_shared/{controller.decorator-BywwkU9O.cjs → controller.decorator-XKMlcUZz.cjs} +669 -185
  4. package/dist/cjs/_shared/{providers-BMMLp2SN.cjs → providers-BZgJyfZS.cjs} +218 -75
  5. package/dist/cjs/_shared/{sharded-cooldown-store-B65iaiAy.cjs → sharded-cooldown-store-CsJJyCZd.cjs} +10 -7
  6. package/dist/cjs/_shared/{startup-roots-lFc69AxC.cjs → startup-roots-CgfPQrNl.cjs} +374 -132
  7. package/dist/cjs/_shared/{translate-error-BnM8FbSH.cjs → translate-error-BUAR1WYG.cjs} +21 -7
  8. package/dist/cjs/common/index.cjs +4 -4
  9. package/dist/cjs/core/index.cjs +365 -310
  10. package/dist/cjs/decorator/index.cjs +85 -61
  11. package/dist/cjs/testing/index.cjs +2236 -496
  12. package/dist/cli.json +1 -1
  13. package/dist/esm/bin/app-template/README.md.template +3 -1
  14. package/dist/esm/bin/app-template/src/controllers/button/sample.button.controller.spec.ts.template +8 -4
  15. package/dist/esm/bin/app-template/src/controllers/button/sample.button.controller.ts.template +2 -2
  16. package/dist/esm/bin/app-template/src/guards/owner.guard.spec.ts.template +8 -4
  17. package/dist/esm/bin/app-template/src/guards/owner.guard.ts.template +1 -1
  18. package/dist/esm/bin/app-template/vitest.config.ts.template +1 -1
  19. package/dist/esm/bin/app-template/vitest.setup.ts.template +7 -3
  20. package/dist/esm/bin/generator.js +4 -5
  21. package/dist/esm/bin/helper/filter-generator.helper.js +4 -5
  22. package/dist/esm/bin/helper/guard-generator.helper.js +4 -5
  23. package/dist/esm/bin/helper/interceptor-generator.helper.js +4 -5
  24. package/dist/esm/bin/helper/observer-generator.helper.js +4 -5
  25. package/dist/esm/bin/helper/pipe-generator.helper.js +4 -5
  26. package/dist/esm/bin/helper/service-generator.helper.js +4 -5
  27. package/dist/esm/bin/meocord.js +84 -25
  28. package/dist/esm/build/load-config.pre-entry.js +17 -4
  29. package/dist/esm/build/rsbuild-config.js +16 -12
  30. package/dist/esm/build/typed-externals.js +36 -0
  31. package/dist/esm/common/cooldown-store.js +10 -2
  32. package/dist/esm/common/decorator.js +6 -6
  33. package/dist/esm/common/deprecation.js +2 -2
  34. package/dist/esm/common/errors.js +27 -2
  35. package/dist/esm/common/execution-context.js +1 -1
  36. package/dist/esm/common/meocord-text.js +11 -8
  37. package/dist/esm/common/metadata.js +1 -1
  38. package/dist/esm/common/response/call-order.js +9 -4
  39. package/dist/esm/common/response/presenter.js +33 -6
  40. package/dist/esm/common/response/response-state.js +84 -10
  41. package/dist/esm/common/route.js +21 -5
  42. package/dist/esm/common/sharded-cooldown-store.js +1 -1
  43. package/dist/esm/common/translate-error.js +1 -1
  44. package/dist/esm/common/translator.js +37 -6
  45. package/dist/esm/core/command-conflicts.js +34 -34
  46. package/dist/esm/core/command-registration.js +93 -24
  47. package/dist/esm/core/command-routes.js +2 -2
  48. package/dist/esm/core/component-routes.js +74 -31
  49. package/dist/esm/core/cooldown-runner.js +29 -8
  50. package/dist/esm/core/dispatcher.js +51 -7
  51. package/dist/esm/core/fallback.js +20 -11
  52. package/dist/esm/core/filter-runner.js +2 -2
  53. package/dist/esm/core/guard-runner.js +16 -3
  54. package/dist/esm/core/handler-input.js +4 -3
  55. package/dist/esm/core/handler-pipeline.js +27 -14
  56. package/dist/esm/core/handler-registry.js +1 -1
  57. package/dist/esm/core/interceptor-runner.js +2 -2
  58. package/dist/esm/core/lifecycle-hooks.js +134 -47
  59. package/dist/esm/core/meocord-factory.js +241 -179
  60. package/dist/esm/core/meocord.app.js +116 -75
  61. package/dist/esm/core/message-routes.js +4 -3
  62. package/dist/esm/core/observer-runner.js +4 -3
  63. package/dist/esm/core/providers.js +45 -23
  64. package/dist/esm/core/route-specificity.js +4 -4
  65. package/dist/esm/core/running-calls.js +43 -0
  66. package/dist/esm/core/scalar-types.js +15 -3
  67. package/dist/esm/core/shard-context.js +6 -7
  68. package/dist/esm/core/shard-manager.js +16 -10
  69. package/dist/esm/core/startup-roots.js +23 -4
  70. package/dist/esm/core/theme-resolvers.js +4 -1
  71. package/dist/esm/core/theme-runtime.js +2 -2
  72. package/dist/esm/core/theme-scope.js +3 -1
  73. package/dist/esm/core/theme-validation.js +17 -10
  74. package/dist/esm/decorator/app.decorator.js +12 -7
  75. package/dist/esm/decorator/command-builder.decorator.js +4 -4
  76. package/dist/esm/decorator/controller-class.decorator.js +9 -4
  77. package/dist/esm/decorator/controller.decorator.js +162 -83
  78. package/dist/esm/decorator/cooldown.decorator.js +20 -3
  79. package/dist/esm/decorator/defer.decorator.js +4 -4
  80. package/dist/esm/decorator/event.decorator.js +5 -5
  81. package/dist/esm/decorator/filter.decorator.js +7 -7
  82. package/dist/esm/decorator/guard.decorator.js +11 -10
  83. package/dist/esm/decorator/inject.decorator.js +4 -4
  84. package/dist/esm/decorator/interceptor.decorator.js +7 -7
  85. package/dist/esm/decorator/observer.decorator.js +4 -4
  86. package/dist/esm/decorator/service.decorator.js +4 -4
  87. package/dist/esm/decorator/theme.decorator.js +3 -3
  88. package/dist/esm/decorator/validation.decorator.js +10 -10
  89. package/dist/esm/package.json.js +1 -1
  90. package/dist/esm/testing/catalog.js +27 -11
  91. package/dist/esm/testing/cooldown-store-suite.js +85 -15
  92. package/dist/esm/testing/discord-defaults.js +249 -21
  93. package/dist/esm/testing/index.js +4 -2
  94. package/dist/esm/testing/inspect-handler.js +20 -3
  95. package/dist/esm/testing/meocord-testing-module.js +352 -205
  96. package/dist/esm/testing/mock-fn.js +173 -22
  97. package/dist/esm/testing/mock-interaction.js +1214 -209
  98. package/dist/esm/testing/response.js +1 -1
  99. package/dist/esm/testing/routing.js +39 -14
  100. package/dist/esm/testing/snowflake.js +9 -2
  101. package/dist/esm/testing/startup-errors.js +26 -0
  102. package/dist/esm/testing/strict-mocks.js +65 -0
  103. package/dist/esm/testing/theme.js +1 -1
  104. package/dist/esm/util/bundle-entry.util.js +2 -1
  105. package/dist/esm/util/common.util.js +47 -19
  106. package/dist/esm/util/dev-runner.util.js +8 -8
  107. package/dist/esm/util/generator-cli.util.js +4 -5
  108. package/dist/esm/util/interaction.util.js +3 -3
  109. package/dist/esm/util/meocord-config-loader.util.js +10 -2
  110. package/dist/esm/util/meocord-config-validation.util.js +4 -5
  111. package/dist/esm/util/meocord-source-config.util.js +65 -20
  112. package/dist/esm/util/metadata-keys.js +1 -0
  113. package/dist/esm/util/method-wrapper.util.js +16 -0
  114. package/dist/esm/util/platform.util.js +10 -10
  115. package/dist/esm/util/refusal.util.js +148 -8
  116. package/dist/esm/util/shard-process.util.js +4 -1
  117. package/dist/esm/util/sharding-mode.util.js +5 -7
  118. package/dist/esm/util/shared-state.util.js +21 -0
  119. package/dist/esm/util/tsconfig-paths.util.js +29 -0
  120. package/dist/esm/util/tsconfig.util.js +10 -10
  121. package/dist/esm/util/value.util.js +12 -1
  122. package/dist/types/common/index.d.cts +8 -8
  123. package/dist/types/common/index.d.ts +8 -8
  124. package/dist/types/core/index.d.cts +10 -7
  125. package/dist/types/core/index.d.ts +10 -7
  126. package/dist/types/decorator/index.d.cts +31 -29
  127. package/dist/types/decorator/index.d.ts +31 -29
  128. package/dist/types/{index-D7n0FBMm.d.ts → index-DKJclIi-.d.ts} +107 -36
  129. package/dist/types/{index-DEP0NYrU.d.cts → index-Dlf0ZZtS.d.cts} +107 -36
  130. package/dist/types/interface/index.d.cts +1 -1
  131. package/dist/types/interface/index.d.ts +1 -1
  132. package/dist/types/{route-C-aMI7Sl.d.cts → route-Bh1U13yy.d.cts} +8 -4
  133. package/dist/types/{route-C-aMI7Sl.d.ts → route-Bh1U13yy.d.ts} +8 -4
  134. package/dist/types/testing/index.d.cts +406 -70
  135. package/dist/types/testing/index.d.ts +406 -70
  136. package/dist/types/{theme-resolvers-COHdX5g6.d.cts → theme-resolvers-CyEz9pnv.d.cts} +1 -1
  137. package/dist/types/{theme-resolvers-DedxyscT.d.ts → theme-resolvers-DA204O-q.d.ts} +1 -1
  138. package/dist/types/{translator-J6tIUfdp.d.ts → translator-Bg7hwxDF.d.cts} +21 -8
  139. package/dist/types/{translator-B4mc1lzJ.d.cts → translator-CeP4bCzp.d.ts} +21 -8
  140. package/package.json +4 -3
@@ -42,7 +42,7 @@ const VISIBLE = new Set([
42
42
  * @group Testing
43
43
  * @category Inspection
44
44
  * @see {@link TestingModule}
45
- * @see {@link https://meocord.dev/docs/4.1/invoke-and-dispatch | Invoke and dispatch}
45
+ * @see {@link https://meocord.dev/docs/4.2/invoke-and-dispatch | Invoke and dispatch}
46
46
  */ function getResponse(interaction) {
47
47
  const state = existingResponse(interaction);
48
48
  // A mock records every answer it gets; respond()'s edits through the channel are its own, so they are merged in
@@ -1,4 +1,4 @@
1
- import { findComponentRouteConflicts, buildComponentRoutes, matchComponentRoute } from '../core/component-routes.js';
1
+ import { findComponentRouteConflicts, buildComponentRoutes, matchComponentRoute, readCustomId } from '../core/component-routes.js';
2
2
  import { isCustomIdRouted } from '../util/interaction.util.js';
3
3
  import { staticMessageStarts, matchMessageRoute, buildMessageRoutes, fitsScope } from '../core/message-routes.js';
4
4
  import { needsGuild } from '../core/message-params.js';
@@ -47,7 +47,7 @@ import { META } from '../util/metadata-keys.js';
47
47
  * @group Testing
48
48
  * @category Inspection
49
49
  * @see {@link findRouteConflicts}
50
- * @see {@link https://meocord.dev/docs/4.1/components | Buttons, selects and modals}
50
+ * @see {@link https://meocord.dev/docs/4.2/components | Buttons, selects and modals}
51
51
  */ function resolveRoute(app, input) {
52
52
  if ('content' in input) {
53
53
  const { messages = {} } = appOptionsOf(app);
@@ -73,7 +73,10 @@ import { META } from '../util/metadata-keys.js';
73
73
  const { route, params, text } = matched;
74
74
  const method = route.meta.methodName;
75
75
  const typed = Object.keys(route.types).length > 0;
76
- return {
76
+ // The routes ranked behind it that the customId matches too, which dispatch never reaches for it
77
+ const behind = routes.slice(routes.indexOf(route) + 1);
78
+ const alsoMatches = behind.filter((other)=>other.meta.type === input.type && readCustomId(other.pattern, other.meta.regex, input.customId)).map((other)=>other.pattern);
79
+ const resolved = {
77
80
  controller: route.controllerClass,
78
81
  method,
79
82
  handler: route.controllerClass.prototype[method],
@@ -82,28 +85,34 @@ import { META } from '../util/metadata-keys.js';
82
85
  values: params
83
86
  }
84
87
  };
88
+ return alsoMatches.length > 0 ? readableOnly(resolved, {
89
+ alsoMatches
90
+ }) : resolved;
85
91
  }
86
92
  /**
87
- * Finds component patterns that can match the same `customId`, which MeoCord otherwise only warns about at startup.
93
+ * Finds component patterns that rank equally and can match the same `customId`, which MeoCord otherwise only warns
94
+ * about at startup.
88
95
  *
89
- * Use it in a test to keep the warning from reaching production: two patterns that trade a literal for a parameter
90
- * in opposite places both take one id, and neither is more specific. Patterns are compared within a component type.
96
+ * Use it in a test to keep the warning from reaching production. Patterns rank segment by segment, a literal before a
97
+ * param and then the narrower type, so two collide only with the same literals and equally narrow params at each
98
+ * position, such as `{action:close|reopen}` and `{step:close|confirm}`, which both take `close`; the one listed
99
+ * first runs. Patterns are compared within a component type.
91
100
  *
92
101
  * @param app - The application class decorated with `@MeoCord`.
93
- * @returns Each pair of patterns that can match the same `customId`, with its component type.
102
+ * @returns Each pair of patterns that rank equally and can match the same `customId`, with its component type.
94
103
  *
95
104
  * @example
96
105
  * ```ts
97
106
  * import { expect } from 'vitest'
98
107
  *
99
108
  * @Controller()
100
- * class CardController {
101
- * @Command('card/{id}/open', CommandType.BUTTON)
102
- * async open(interaction: ButtonInteraction) { await respond(interaction).send('Opened.') }
103
- * @Command('card/new/{kind}', CommandType.BUTTON)
104
- * async create(interaction: ButtonInteraction) { await respond(interaction).send('Created.') }
109
+ * class TicketController {
110
+ * @Command('ticket/{action:close|reopen}', CommandType.BUTTON)
111
+ * async act(interaction: ButtonInteraction) { await respond(interaction).send('Done.') }
112
+ * @Command('ticket/{step:close|confirm}', CommandType.BUTTON)
113
+ * async step(interaction: ButtonInteraction) { await respond(interaction).send('Next.') }
105
114
  * }
106
- * @MeoCord({ controllers: [CardController], clientOptions: { intents: [] } })
115
+ * @MeoCord({ controllers: [TicketController], clientOptions: { intents: [] } })
107
116
  * class App {}
108
117
  * expect(findRouteConflicts(App)).toHaveLength(1)
109
118
  * ```
@@ -112,7 +121,23 @@ import { META } from '../util/metadata-keys.js';
112
121
  * @category Inspection
113
122
  * @see {@link resolveRoute}
114
123
  */ function findRouteConflicts(app) {
115
- return findComponentRouteConflicts(buildComponentRoutes(controllersOf(app)));
124
+ return findComponentRouteConflicts(buildComponentRoutes(controllersOf(app))).map(({ type, patterns, runs })=>readableOnly({
125
+ type: type,
126
+ patterns
127
+ }, {
128
+ runs
129
+ }));
130
+ }
131
+ /**
132
+ * `target` with `fields` added as properties a test reads but `toEqual` and its kin, which compare enumerable keys,
133
+ * don't see, so an assertion written for the fields it had before still holds.
134
+ */ function readableOnly(target, fields) {
135
+ for (const [key, value] of Object.entries(fields))Object.defineProperty(target, key, {
136
+ value,
137
+ enumerable: false,
138
+ configurable: true
139
+ });
140
+ return target;
116
141
  }
117
142
 
118
143
  export { findRouteConflicts, resolveRoute };
@@ -1,8 +1,15 @@
1
+ import { shared } from '../util/shared-state.util.js';
2
+
1
3
  /**
2
4
  * The id of the bot every mock client is logged in as, so a test can mention it. Fixed, and below the
3
5
  * ids other mocks take, so it is the same on every run and in any test order, and no mock shares it.
4
6
  */ const MOCK_BOT_ID = '1300000000000000000';
5
- /** The last id a mock was given; ids count up from a real snowflake, so each mock's is its own. */ let lastSnowflake = 1400000000000000000n;
6
- /** A snowflake-shaped id nothing else a mock holds in this run has, for a mock or the media it resolves. */ const nextSnowflake = ()=>String(++lastSnowflake);
7
+ /**
8
+ * The last id a mock was given, for both builds of this meocord version; ids count up from a real snowflake, so each
9
+ * mock's is its own.
10
+ */ const ids = shared('mockIds', ()=>({
11
+ last: 1400000000000000000n
12
+ }));
13
+ /** A snowflake-shaped id nothing else a mock holds in this run has, for a mock or the media it resolves. */ const nextSnowflake = ()=>String(++ids.last);
7
14
 
8
15
  export { MOCK_BOT_ID, nextSnowflake };
@@ -0,0 +1,26 @@
1
+ import { setStartupErrorsMode } from '../util/refusal.util.js';
2
+
3
+ /**
4
+ * Has decorators keep the startup errors they find, as a bot built with `startupErrors: 'all'` does, so a testing
5
+ * module reports every one at once.
6
+ *
7
+ * Call it once in the test runner's setup file, before any test imports a controller: a decorator runs when its class
8
+ * is defined, as its file is imported. A decorator given what it cannot use then throws nothing as the file loads, and
9
+ * `MeoCordTestingModule.create(...).compile()` logs every startup error of the classes it runs, each with its handler
10
+ * and file, then throws the first. Without it, a decorator throws its error as its class is defined.
11
+ *
12
+ * @example
13
+ * ```ts
14
+ * // vitest.setup.ts
15
+ * reportAllStartupErrors()
16
+ * ```
17
+ *
18
+ * @group Testing
19
+ * @category Module
20
+ * @see {@link MeoCordTestingModule}
21
+ * @see {@link https://meocord.dev/docs/4.2/configuration | Configuration}
22
+ */ function reportAllStartupErrors() {
23
+ setStartupErrorsMode('all');
24
+ }
25
+
26
+ export { reportAllStartupErrors };
@@ -0,0 +1,65 @@
1
+ import { shared } from '../util/shared-state.util.js';
2
+
3
+ // Whether useStrictMocks() is on, and whether a mock was made before it could be, for both builds of this version
4
+ const settings = shared('strictMocks', ()=>({
5
+ strict: false,
6
+ mockMade: false
7
+ }));
8
+ /** Whether mocks compute discord.js's state, as {@link useStrictMocks} turns on. */ const strictMocks = ()=>settings.strict;
9
+ /** Notes that a mock was made, which useStrictMocks() must come before. */ function noteMockMade() {
10
+ settings.mockMade = true;
11
+ }
12
+ /**
13
+ * Has every mock from `meocord/testing` compute the values discord.js computes, where it reads a placeholder otherwise.
14
+ *
15
+ * Call it once in the test runner's setup file. A message's `editable`, `deletable`, `pinnable`, `crosspostable`,
16
+ * `bulkDeletable`, `hasThread` and `partial`, a member's `manageable`, `kickable`, `bannable` and `moderatable`, a
17
+ * role's `editable`, a channel's and a thread's `viewable`, `manageable`, `deletable`, `joinable` and the rest, a voice
18
+ * channel's `full`, and `partial` on users, channels and reactions then run discord.js's own getters against the mock,
19
+ * and `message.thread` is `null` when the channel caches no thread under the message's id. So do a message's
20
+ * `editedAt`, a member's `presence`, a server's `verified` and `systemChannel` and a channel's `parent`, each `null` or
21
+ * `false` until the test gives what it is computed from. A reaction's `me` is `false`, a message's
22
+ * `mentions.repliedUser` and a modal's `message` are `null`, and a reaction has a whole message of its own, so
23
+ * MeoCord's dispatcher fetches neither. No placeholder warning is logged.
24
+ *
25
+ * @remarks
26
+ * Those getters read what a server holds, so strict mocks also have it as Discord sends it: the bot's member is in the
27
+ * server's member cache from the start, @everyone has the permissions Discord gives it in a new server, and a channel
28
+ * or thread made without a server has one of its own, a thread with a text channel as its parent. A message has empty
29
+ * `content`, a thread is neither archived nor locked, and a reaction's `count` is 1. A user, server or channel made
30
+ * with a generated id was created when the mock was made, as a message is. An interaction's `memberPermissions` apply
31
+ * its channel's overwrites, a thread's parent's for a thread, to its member's permissions. An interaction is in its
32
+ * message's channel and server, a guild's or a member's server, or, given a `guildId` alone, a server the bot isn't in.
33
+ * A message the bot sends is its `client.user`'s, and one it sends in a DM is in that DM. Give the bot's member a role
34
+ * to let it do more, such as kick a member its role ranks above. A value the test sets on a mock wins over the computed
35
+ * one. An interaction's answer that discord.js would refuse, such as a second `reply()` after one a test set with
36
+ * `mockResolvedValue`, is refused too, where without it the mock runs it and warns. The next major version (5.0)
37
+ * computes these values, and refuses those answers, without the call.
38
+ *
39
+ * `invoke` also refuses a call that gives a handler fewer arguments than it declares where it builds none, such as a
40
+ * reaction handler given its reaction without its `ReactionEvent`; without strict mocks, it warns once and calls it.
41
+ *
42
+ * `compile()` also refuses an `overrideGuard`, `overrideInterceptor` or `overrideFilter` stub without the stage's
43
+ * method, `canActivate`, `intercept` or `catch`; without strict mocks, it warns and builds the module.
44
+ *
45
+ * @throws Error when a mock already exists, which was made without it.
46
+ *
47
+ * @example
48
+ * ```ts
49
+ * // vitest.setup.ts
50
+ * useStrictMocks()
51
+ * ```
52
+ *
53
+ * @group Testing
54
+ * @category Mocks
55
+ * @see {@link useMockFn}
56
+ * @see {@link https://meocord.dev/docs/4.2/mocks | Mocks}
57
+ */ function useStrictMocks() {
58
+ if (settings.strict) return;
59
+ if (settings.mockMade) {
60
+ throw new Error('useStrictMocks() goes before any mock is made: a mock made before it reads placeholders, and the two kinds would ' + "mix. Call it once, in the test runner's setup file.");
61
+ }
62
+ settings.strict = true;
63
+ }
64
+
65
+ export { noteMockMade, strictMocks, useStrictMocks };
@@ -35,7 +35,7 @@ function mockTheme(overrides, where) {
35
35
  * @group Testing
36
36
  * @category Mocks
37
37
  * @see {@link withTheme}
38
- * @see {@link https://meocord.dev/docs/4.1/theming | Theming}
38
+ * @see {@link https://meocord.dev/docs/4.2/theming | Theming}
39
39
  */ function createMockTheme(...[overrides]) {
40
40
  return mockTheme(overrides, 'createMockTheme');
41
41
  }
@@ -2,6 +2,7 @@ import { existsSync, realpathSync } from 'node:fs';
2
2
 
3
3
  /** Where the built bundle's pre-entry records the bundle's own path. */ const BUNDLE_ENTRY_KEY = Symbol.for('meocord.bundleEntry');
4
4
  /** Where the built bundle's pre-entry records the mode the bundle was built in. */ const BUILD_MODE_KEY = Symbol.for('meocord.buildMode');
5
+ /** Where the built bot's pre-entry records `startupErrors` from its config, before the application's modules load. */ const STARTUP_ERRORS_KEY = Symbol.for('meocord.startupErrorsMode');
5
6
  /**
6
7
  * The file a shard process runs: the built bundle, as its pre-entry recorded it, else the script this
7
8
  * process was started with; undefined when there is none, as for code run with `node -e` outside a built bundle.
@@ -19,4 +20,4 @@ import { existsSync, realpathSync } from 'node:fs';
19
20
  return typeof recorded === 'string' ? recorded : undefined;
20
21
  }
21
22
 
22
- export { BUILD_MODE_KEY, BUNDLE_ENTRY_KEY, buildMode, bundleEntry, isBuiltApplication };
23
+ export { BUILD_MODE_KEY, BUNDLE_ENTRY_KEY, STARTUP_ERRORS_KEY, buildMode, bundleEntry, isBuiltApplication };
@@ -19,28 +19,48 @@ import chalk from 'chalk';
19
19
  console.error(chalk.red(`Error finding package directory for ${moduleName}: Module ${moduleName} not found in node_modules.`));
20
20
  return null;
21
21
  };
22
+ /**
23
+ * Loads `meocord.config.ts` and checks its shape, as a command that builds and a watch-mode reload both do, printing
24
+ * the warnings for options it does not know.
25
+ *
26
+ * @returns The config, or the report of why it cannot be used: missing, failing to load, or with options of the wrong
27
+ * type, each of them listed. A config that fails to load also gives the reason alone, as `loadError`.
28
+ */ function checkSourceConfig() {
29
+ const meocordConfigPath = path.resolve(process.cwd(), 'meocord.config.ts');
30
+ if (!fs.existsSync(meocordConfigPath)) return {
31
+ problem: 'Configuration file "meocord.config.ts" is missing!'
32
+ };
33
+ const loaded = readMeoCordSourceConfig();
34
+ if ('error' in loaded) {
35
+ return {
36
+ problem: `meocord.config.ts could not be loaded, so nothing was built or started:\n ${loaded.error}`,
37
+ loadError: loaded.error
38
+ };
39
+ }
40
+ const problem = shapeProblem(loaded.config);
41
+ return problem ? {
42
+ problem
43
+ } : {
44
+ config: loaded.config
45
+ };
46
+ }
47
+ /** Prints why a config cannot be used, as {@link checkSourceConfig} reports it. */ function reportConfigProblem(problem) {
48
+ console.error(chalk.red(problem));
49
+ }
22
50
  /**
23
51
  * Loads `meocord.config.ts` and checks its shape, exiting when it is missing, fails to load, or has
24
52
  * options of the wrong type; options it does not know are reported and left alone.
25
53
  *
26
54
  * @returns The config, which a build compiles and the bot then runs with.
27
55
  */ async function compileAndValidateConfig() {
28
- const meocordConfigPath = path.resolve(process.cwd(), 'meocord.config.ts');
29
- if (!fs.existsSync(meocordConfigPath)) {
30
- console.error(chalk.red('Configuration file "meocord.config.ts" is missing!'));
56
+ const checked = checkSourceConfig();
57
+ if ('problem' in checked) {
58
+ reportConfigProblem(checked.problem);
31
59
  await wait(100);
32
60
  process.exit(1);
33
61
  return;
34
62
  }
35
- const loaded = readMeoCordSourceConfig();
36
- if ('error' in loaded) {
37
- console.error(chalk.red(`meocord.config.ts could not be loaded, so nothing was built or started:\n ${loaded.error}`));
38
- await wait(100);
39
- process.exit(1);
40
- return;
41
- }
42
- await assertConfigShape(loaded.config);
43
- return loaded.config;
63
+ return checked.config;
44
64
  }
45
65
  /**
46
66
  * Checks the configuration a command that skips the build runs with: the compiled config when a build
@@ -63,13 +83,18 @@ import chalk from 'chalk';
63
83
  return compileAndValidateConfig();
64
84
  }
65
85
  /** Exits with every problem when a configuration has options of the wrong type, and warns about unknown ones. */ async function assertConfigShape(config) {
66
- const { errors, warnings } = configProblems(config);
67
- for (const warning of warnings)console.warn(chalk.yellow(`meocord.config.ts: ${warning}`));
68
- if (errors.length === 0) return;
69
- console.error(chalk.red(`meocord.config.ts has ${errors.length} problem(s):\n${errors.map((error)=>` - ${error}`).join('\n')}`));
86
+ const problem = shapeProblem(config);
87
+ if (!problem) return;
88
+ console.error(chalk.red(problem));
70
89
  await wait(100);
71
90
  process.exit(1);
72
91
  }
92
+ /** Every option of the wrong type in a configuration, as one report, or undefined; options it does not know are warned about. */ function shapeProblem(config) {
93
+ const { errors, warnings } = configProblems(config);
94
+ for (const warning of warnings)console.warn(chalk.yellow(`meocord.config.ts: ${warning}`));
95
+ if (errors.length === 0) return undefined;
96
+ return `meocord.config.ts has ${errors.length} problem(s):\n${errors.map((error)=>` - ${error}`).join('\n')}`;
97
+ }
73
98
  /**
74
99
  * Exits unless the config {@link compileAndValidateConfig} or {@link validateRunConfig} returned has a Discord token.
75
100
  * Starting or registering needs one and building does not, so a new app builds before it has a token.
@@ -80,10 +105,13 @@ import chalk from 'chalk';
80
105
  process.exit(1);
81
106
  }
82
107
  }
83
- /** Sets `NODE_ENV` to `mode` unless it is already set. */ function setEnvironment(mode) {
84
- if (!process.env.NODE_ENV) {
108
+ /**
109
+ * Sets `NODE_ENV` for a command run in `mode`, before its config loads: development whatever the shell holds, so `--dev`
110
+ * reads the development files and registers as development, and production unless the shell set a mode of its own.
111
+ */ function setEnvironment(mode) {
112
+ if (mode === 'development' || !process.env.NODE_ENV) {
85
113
  process.env.NODE_ENV = mode;
86
114
  }
87
115
  }
88
116
 
89
- export { assertConfigShape, compileAndValidateConfig, findModulePackageDir, setEnvironment, validateDiscordToken, validateRunConfig };
117
+ export { assertConfigShape, checkSourceConfig, compileAndValidateConfig, findModulePackageDir, reportConfigProblem, setEnvironment, validateDiscordToken, validateRunConfig };
@@ -19,8 +19,8 @@ const slots = globalThis;
19
19
  * on Windows a signal ends a process outright, skipping its `onShutdown` hooks. The bundle's pre-entry calls it first,
20
20
  * so a stop is heard however early it comes; until an app or shard manager says how it stops, the process exits at
21
21
  * once, as SIGTERM's default would.
22
- */ function listenForDevRunnerStop() {
23
- if (!underDevRunner() || slots[LISTENING]) return;
22
+ */ function listenForDevRunnerStop(shard = isShardProcess(process.env)) {
23
+ if (!underDevRunner(shard) || slots[LISTENING]) return;
24
24
  slots[LISTENING] = true;
25
25
  process.on('message', (message)=>{
26
26
  if (!isDevRunnerCommand(message)) return;
@@ -31,19 +31,19 @@ const slots = globalThis;
31
31
  // A 'message' listener keeps the process alive for its channel; what the bot does decides that, as without one
32
32
  process.channel?.unref();
33
33
  }
34
- /** Has a stop from `meocord start --dev` run `stop`, as SIGINT and SIGTERM do. */ function onDevRunnerStop(stop) {
35
- listenForDevRunnerStop();
34
+ /** Has a stop from `meocord start --dev` run `stop`, as SIGINT and SIGTERM do, in a process whose role is `shard`. */ function onDevRunnerStop(stop, shard) {
35
+ listenForDevRunnerStop(shard);
36
36
  slots[STOP] = stop;
37
37
  }
38
- /** Whether `meocord start --dev` runs this process and listens on its channel. */ function underDevRunner() {
39
- return process.env[DEV_RUNNER_ENV] === '1' && !!process.send && !isShardProcess();
38
+ /** Whether `meocord start --dev` runs this process, a shard or not as `shard` says, and listens on its channel. */ function underDevRunner(shard) {
39
+ return process.env[DEV_RUNNER_ENV] === '1' && !!process.send && !shard;
40
40
  }
41
41
  /**
42
42
  * Tells `meocord start --dev`, when it runs this process, whether the bot could log in, and waits until it is sent, or
43
43
  * until the dev runner is taken to be gone. A caller sets the exit code before sending, so it holds however the send
44
44
  * ends. A shard tells its manager instead, which tells the dev runner in turn.
45
- */ async function tellDevRunner(message) {
46
- if (underDevRunner()) await sendToParent(message);
45
+ */ async function tellDevRunner(message, shard) {
46
+ if (underDevRunner(shard)) await sendToParent(message);
47
47
  }
48
48
 
49
49
  export { DEV_RUNNER_ENV, isDevRunnerCommand, isDevRunnerMessage, listenForDevRunnerStop, onDevRunnerStop, tellDevRunner, underDevRunner };
@@ -7,16 +7,15 @@ import '../core/theme-validation.js';
7
7
  import '../common/decorator.js';
8
8
  import 'reflect-metadata';
9
9
  import 'discord.js';
10
- import '../common/translator.js';
11
- import '../common/response/send-failure.js';
12
- import './interaction.util.js';
13
- import '../common/response/flags.js';
14
- import '../common/response/components.js';
10
+ import '../common/response/response-state.js';
11
+ import '../common/errors.js';
15
12
  import 'node:crypto';
16
13
  import '../decorator/controller.decorator.js';
17
14
  import '../core/scalar-types.js';
18
15
  import 'node:fs';
19
16
  import 'node:url';
17
+ import '../common/translator.js';
18
+ import '../common/meocord-text.js';
20
19
  import { kebabCase, startCase, camelCase } from 'lodash-es';
21
20
  import { fileURLToPath } from 'url';
22
21
 
@@ -1,7 +1,7 @@
1
- import { ApplicationCommandOptionType, ModalSubmitInteraction, ChannelSelectMenuInteraction, MentionableSelectMenuInteraction, RoleSelectMenuInteraction, UserSelectMenuInteraction, StringSelectMenuInteraction, ButtonInteraction, PrimaryEntryPointCommandInteraction, ContextMenuCommandInteraction, ChatInputCommandInteraction, MessageComponentInteraction } from 'discord.js';
1
+ import { ApplicationCommandOptionType, MessageComponentInteraction, ModalSubmitInteraction, ChannelSelectMenuInteraction, MentionableSelectMenuInteraction, RoleSelectMenuInteraction, UserSelectMenuInteraction, StringSelectMenuInteraction, ButtonInteraction, PrimaryEntryPointCommandInteraction, ContextMenuCommandInteraction, ChatInputCommandInteraction } from 'discord.js';
2
2
  import { CommandType } from '../enum/controller.enum.js';
3
3
  import { quoteForLog } from './user-text.util.js';
4
- import { withArticle } from './value.util.js';
4
+ import { setOwn, withArticle } from './value.util.js';
5
5
 
6
6
  /**
7
7
  * The discord.js interaction class each command type handles, shared by `@Command`, the dispatcher and the testing
@@ -99,7 +99,7 @@ import { withArticle } from './value.util.js';
99
99
  walk(option.options ?? []);
100
100
  continue;
101
101
  }
102
- params[option.name] = resolveOptionValue(option);
102
+ setOwn(params, option.name, resolveOptionValue(option));
103
103
  }
104
104
  };
105
105
  walk(data);
@@ -38,12 +38,20 @@ let problem;
38
38
  const reason = (problem.error instanceof Error ? problem.error.message : String(problem.error)).replace(/\.$/, '');
39
39
  const missing = missingPackage(problem.error);
40
40
  const neededBy = missing?.importer ? `, which ${missing.importer} imports,` : '';
41
- const fix = missing ? `Install ${missing.name}${neededBy} in the project` : 'Fix meocord.config.ts';
42
- return `MeoCord config at ${problem.path} failed to load: ${reason}. ${fix}, then run \`meocord build\`.`;
41
+ const fix = missing ? `Install ${missing.name}${neededBy} in the project, then run \`meocord build\`.` : fixedInConfig(problem.error) ? 'Fix meocord.config.ts, then run `meocord build`.' : 'It threw as it loaded: check the environment it reads, such as its .env files.';
42
+ return `MeoCord config at ${problem.path} failed to load: ${reason}. ${fix}`;
43
43
  }
44
44
  const where = problem?.path ?? 'meocord.config.mjs';
45
45
  return `MeoCord config not found at ${where} (working directory ${process.cwd()}). Run \`meocord build\`, and start the bot from the dist it writes.`;
46
46
  }
47
+ /**
48
+ * Whether the config's file or imports are at fault, which a rebuild after fixing it mends: it could not be read as a
49
+ * module, or imports a file that isn't there. Otherwise its code threw as it ran.
50
+ */ function fixedInConfig(error) {
51
+ const code = error?.code;
52
+ if (error instanceof SyntaxError || code === 'ERR_MODULE_NOT_FOUND' || code === 'MODULE_NOT_FOUND') return true;
53
+ return typeof code === 'string' && code.startsWith('ERR_REQUIRE_');
54
+ }
47
55
  /**
48
56
  * The package a module-not-found error names, when it is a package rather than a file: `dotenv` for `dotenv/config`,
49
57
  * `@scope/name` for `@scope/name/sub`, with the installed package that imports it, if one does. Undefined for any other
@@ -46,6 +46,7 @@ const oneOf = (...allowed)=>(value, key)=>allowed.includes(value) ? undefined :
46
46
  optionalExternals: optional(stringList('package names')),
47
47
  rsbuild: optional(func),
48
48
  sourceMappedStacks: optional(boolean),
49
+ startupErrors: optional(oneOf('first', 'all')),
49
50
  shutdownTimeout: optional((value, key)=>{
50
51
  const problem = shutdownTimeoutProblem(value);
51
52
  return problem && `${key} ${problem}`;
@@ -65,14 +66,12 @@ const oneOf = (...allowed)=>(value, key)=>allowed.includes(value) ? undefined :
65
66
  errors: [],
66
67
  warnings: []
67
68
  };
68
- // jiti's interop proxy lists only `default` among its keys, while reading through to it.
69
- const loaded = config?.default ?? config;
70
- if (typeof loaded !== 'object' || loaded === null) {
71
- problems.errors.push(`it must export an object as its default export (got ${describe(loaded)})`);
69
+ if (typeof config !== 'object' || config === null) {
70
+ problems.errors.push(`it must export an object as its default export (got ${describe(config)})`);
72
71
  return problems;
73
72
  }
74
73
  const shape = configShape(problems);
75
- for (const [name, value] of Object.entries(loaded)){
74
+ for (const [name, value] of Object.entries(config)){
76
75
  const check = shape[name];
77
76
  if (!check) {
78
77
  problems.warnings.push(`${name} is not a MeoCord option, so it has no effect.`);
@@ -1,7 +1,8 @@
1
1
  import path from 'path';
2
- import { existsSync, readFileSync } from 'fs';
2
+ import { existsSync } from 'fs';
3
3
  import { createJiti } from 'jiti';
4
- import { parseJsonc } from './json.util.js';
4
+ import { comparablePath, framePath } from './source-path.util.js';
5
+ import { projectPaths } from './tsconfig-paths.util.js';
5
6
 
6
7
  /**
7
8
  * Loads `meocord.config.ts` from source on every call, so a build always uses the current config
@@ -25,31 +26,75 @@ import { parseJsonc } from './json.util.js';
25
26
  config: undefined
26
27
  };
27
28
  try {
28
- const tsConfigPath = path.resolve(process.cwd(), 'tsconfig.json');
29
- const aliases = {};
30
- if (existsSync(tsConfigPath)) {
31
- const tsConfig = parseJsonc(readFileSync(tsConfigPath, 'utf-8'));
32
- const paths = tsConfig?.compilerOptions?.paths;
33
- if (paths) {
34
- for (const [key, values] of Object.entries(paths)){
35
- const aliasKey = key.replace('/*', '');
36
- aliases[aliasKey] = path.resolve(process.cwd(), values[0].replace('/*', ''));
37
- }
38
- }
39
- }
29
+ const aliases = jitiAliases(projectPaths() ?? {});
30
+ // The module as written, so an ES module with no default export is told apart from a CommonJS one
40
31
  const jiti = createJiti(import.meta.url, {
41
- interopDefault: true,
32
+ interopDefault: false,
42
33
  alias: aliases,
43
34
  moduleCache: false
44
35
  });
45
- return {
46
- config: jiti(configPath)
47
- };
36
+ return configOf(jiti(configPath));
48
37
  } catch (error) {
49
38
  return {
50
- error: error instanceof Error ? error.message : String(error)
39
+ error: loadError(error, process.cwd())
51
40
  };
52
41
  }
53
42
  }
43
+ /**
44
+ * The config a loaded module gives: an ES module's default export, or what a CommonJS module assigns to
45
+ * `module.exports`. An ES module with no default export has none.
46
+ */ function configOf(loaded) {
47
+ const module = loaded;
48
+ if (module?.__esModule !== true) return {
49
+ config: loaded
50
+ };
51
+ if ('default' in module) return {
52
+ config: module.default
53
+ };
54
+ // A module that exports nothing, as an empty file, is an empty config, whose values come from the environment
55
+ const names = Object.keys(module);
56
+ if (names.length === 0) return {
57
+ config: {}
58
+ };
59
+ return {
60
+ error: `it must export an object as its default export, and exports only ${names.join(', ')}`
61
+ };
62
+ }
63
+ /**
64
+ * Why the config failed to load, with the first place in the project it failed at when the message does not name one,
65
+ * as for an error the config throws: jiti's message names the file, line and column of a syntax error.
66
+ */ function loadError(error, root) {
67
+ const message = error instanceof Error ? error.message : String(error);
68
+ const frame = error instanceof Error ? projectFrame(error.stack, root) : undefined;
69
+ return frame && !message.includes(frame) ? `${message}\n at ${frame}` : message;
70
+ }
71
+ // A stack frame's location, `file:line:column`, alone or in parentheses after the function: a path or a file:// URL
72
+ const FRAME_LOCATION = /\(?((?:file:\/\/)?(?:\/?[A-Za-z]:)?[\\/][^()]+?):(\d+):(\d+)\)?$/;
73
+ /**
74
+ * The first stack frame in a file of the project's own, as `file:line:column` from the project. On Windows a frame's
75
+ * file can be a file:// URL, or written with forward slashes or a lower-case drive, so each is compared as a path.
76
+ */ function projectFrame(stack, root, windows = process.platform === 'win32') {
77
+ const paths = windows ? path.win32 : path.posix;
78
+ const base = comparablePath(paths.join(paths.resolve(root), paths.sep), windows);
79
+ for (const line of stack?.split('\n').slice(1) ?? []){
80
+ const frame = FRAME_LOCATION.exec(line.trim());
81
+ if (!frame) continue;
82
+ const [, location, row, column] = frame;
83
+ const file = paths.resolve(framePath(location, windows));
84
+ if (!comparablePath(file, windows).startsWith(base) || file.split(paths.sep).includes('node_modules')) continue;
85
+ return `${paths.relative(root, file).split(paths.sep).join('/')}:${row}:${column}`;
86
+ }
87
+ return undefined;
88
+ }
89
+ /**
90
+ * jiti's aliases for tsconfig `paths`: each alias, and its first target, without a trailing `/*`, or `\*` in a Windows
91
+ * path, as the build reads the same paths.
92
+ */ function jitiAliases(paths) {
93
+ const unstarred = (text)=>text.replace(/[\\/]\*$/, '');
94
+ return Object.fromEntries(Object.entries(paths).map(([alias, [target]])=>[
95
+ unstarred(alias),
96
+ unstarred(target)
97
+ ]));
98
+ }
54
99
 
55
- export { loadMeoCordSourceConfig, readMeoCordSourceConfig };
100
+ export { jitiAliases, loadMeoCordSourceConfig, projectFrame, readMeoCordSourceConfig };
@@ -24,6 +24,7 @@ import { MetadataKey } from '../enum/metadata-key.enum.js';
24
24
  /** How many guard wrappers `@UseGuard` and `@Controller` put around one method. */ guardWrappers: 'meocord:guard-wrappers',
25
25
  /** The prototype whose method a class-level `@UseGuard` re-declared on a subclass. */ inheritedFrom: 'meocord:inherited-from',
26
26
  /** `false` when `@Controller({ inheritStages: false })` stops class stages at this class. */ inheritStages: 'meocord:inherit-stages',
27
+ /** `true` when `@Controller({ inheritedRoutes: 'replace' })` drops the routes this class's re-decorated methods inherit. */ replacesInheritedRoutes: 'meocord:replaces-inherited-routes',
27
28
  /** A controller's class-level `@Cooldown`s. */ classCooldowns: 'meocord:class-cooldowns',
28
29
  /** A method's `@Cooldown`s, in declaration order. */ methodCooldowns: 'meocord:method-cooldowns',
29
30
  /** The error types a filter's `@Catch` names; empty to catch everything. */ catchTypes: 'meocord:catch-types',
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Gives a decorator's `wrapper` the `length` and `name` of the method it replaces, so whatever reads the handler, such
3
+ * as `invoke` counting the arguments it takes, sees the method as it was declared.
4
+ */ function wrapsMethod(wrapper, original) {
5
+ Object.defineProperty(wrapper, 'length', {
6
+ value: original.length,
7
+ configurable: true
8
+ });
9
+ Object.defineProperty(wrapper, 'name', {
10
+ value: original.name,
11
+ configurable: true
12
+ });
13
+ return wrapper;
14
+ }
15
+
16
+ export { wrapsMethod };