meocord 4.2.0 → 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 (95) hide show
  1. package/CHANGELOG.md +264 -0
  2. package/THIRD_PARTY_NOTICES.md +14 -13
  3. package/dist/cjs/_shared/{controller.decorator-CRn9kWob.cjs → controller.decorator-XKMlcUZz.cjs} +376 -100
  4. package/dist/cjs/_shared/{providers-ChvMqnLd.cjs → providers-BZgJyfZS.cjs} +145 -36
  5. package/dist/cjs/_shared/{sharded-cooldown-store-BdAUTdYs.cjs → sharded-cooldown-store-CsJJyCZd.cjs} +10 -7
  6. package/dist/cjs/_shared/{startup-roots-CQo8mSP8.cjs → startup-roots-CgfPQrNl.cjs} +219 -63
  7. package/dist/cjs/_shared/{translate-error-DhDVbT5M.cjs → translate-error-BUAR1WYG.cjs} +10 -5
  8. package/dist/cjs/common/index.cjs +3 -3
  9. package/dist/cjs/core/index.cjs +185 -168
  10. package/dist/cjs/decorator/index.cjs +21 -6
  11. package/dist/cjs/testing/index.cjs +1594 -286
  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 +84 -10
  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 +37 -6
  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/fallback.js +20 -11
  37. package/dist/esm/core/guard-runner.js +16 -3
  38. package/dist/esm/core/handler-input.js +4 -3
  39. package/dist/esm/core/lifecycle-hooks.js +134 -47
  40. package/dist/esm/core/meocord-factory.js +55 -32
  41. package/dist/esm/core/meocord.app.js +116 -75
  42. package/dist/esm/core/providers.js +7 -2
  43. package/dist/esm/core/running-calls.js +43 -0
  44. package/dist/esm/core/shard-context.js +5 -6
  45. package/dist/esm/core/shard-manager.js +16 -10
  46. package/dist/esm/core/startup-roots.js +21 -1
  47. package/dist/esm/core/theme-resolvers.js +4 -1
  48. package/dist/esm/core/theme-scope.js +3 -1
  49. package/dist/esm/core/theme-validation.js +17 -10
  50. package/dist/esm/decorator/controller.decorator.js +56 -20
  51. package/dist/esm/decorator/cooldown.decorator.js +17 -0
  52. package/dist/esm/decorator/guard.decorator.js +3 -2
  53. package/dist/esm/package.json.js +1 -1
  54. package/dist/esm/testing/catalog.js +26 -10
  55. package/dist/esm/testing/cooldown-store-suite.js +67 -5
  56. package/dist/esm/testing/discord-defaults.js +205 -30
  57. package/dist/esm/testing/meocord-testing-module.js +134 -28
  58. package/dist/esm/testing/mock-fn.js +107 -26
  59. package/dist/esm/testing/mock-interaction.js +1026 -172
  60. package/dist/esm/testing/snowflake.js +9 -2
  61. package/dist/esm/testing/strict-mocks.js +37 -17
  62. package/dist/esm/util/common.util.js +47 -19
  63. package/dist/esm/util/dev-runner.util.js +8 -8
  64. package/dist/esm/util/generator-cli.util.js +4 -5
  65. package/dist/esm/util/interaction.util.js +2 -2
  66. package/dist/esm/util/meocord-config-loader.util.js +10 -2
  67. package/dist/esm/util/meocord-config-validation.util.js +3 -5
  68. package/dist/esm/util/meocord-source-config.util.js +65 -20
  69. package/dist/esm/util/method-wrapper.util.js +16 -0
  70. package/dist/esm/util/platform.util.js +10 -10
  71. package/dist/esm/util/shard-process.util.js +4 -1
  72. package/dist/esm/util/sharding-mode.util.js +5 -7
  73. package/dist/esm/util/shared-state.util.js +21 -0
  74. package/dist/esm/util/tsconfig-paths.util.js +29 -0
  75. package/dist/esm/util/tsconfig.util.js +10 -10
  76. package/dist/esm/util/value.util.js +12 -1
  77. package/dist/types/common/index.d.cts +6 -6
  78. package/dist/types/common/index.d.ts +6 -6
  79. package/dist/types/core/index.d.cts +3 -2
  80. package/dist/types/core/index.d.ts +3 -2
  81. package/dist/types/decorator/index.d.cts +1 -0
  82. package/dist/types/decorator/index.d.ts +1 -0
  83. package/dist/types/{index-CdXsFgjE.d.ts → index-DKJclIi-.d.ts} +45 -21
  84. package/dist/types/{index-nn5p7OoX.d.cts → index-Dlf0ZZtS.d.cts} +45 -21
  85. package/dist/types/interface/index.d.cts +1 -1
  86. package/dist/types/interface/index.d.ts +1 -1
  87. package/dist/types/{route-BZgFoNeB.d.cts → route-Bh1U13yy.d.cts} +3 -2
  88. package/dist/types/{route-BZgFoNeB.d.ts → route-Bh1U13yy.d.ts} +3 -2
  89. package/dist/types/testing/index.d.cts +175 -53
  90. package/dist/types/testing/index.d.ts +175 -53
  91. package/dist/types/{theme-resolvers-UKr3-6Ys.d.cts → theme-resolvers-CyEz9pnv.d.cts} +1 -1
  92. package/dist/types/{theme-resolvers-BcOJVzR9.d.ts → theme-resolvers-DA204O-q.d.ts} +1 -1
  93. package/dist/types/{translator-DHMkkCpg.d.cts → translator-Bg7hwxDF.d.cts} +21 -8
  94. package/dist/types/{translator-BViWKtLJ.d.ts → translator-CeP4bCzp.d.ts} +21 -8
  95. package/package.json +2 -2
@@ -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 };
@@ -1,26 +1,46 @@
1
- // Whether useStrictMocks() is on, and whether a mock was made before it could be
2
- let strict = false;
3
- let mockMade = false;
4
- /** Whether mocks compute discord.js's state, as {@link useStrictMocks} turns on. */ const strictMocks = ()=>strict;
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;
5
9
  /** Notes that a mock was made, which useStrictMocks() must come before. */ function noteMockMade() {
6
- mockMade = true;
10
+ settings.mockMade = true;
7
11
  }
8
12
  /**
9
13
  * Has every mock from `meocord/testing` compute the values discord.js computes, where it reads a placeholder otherwise.
10
14
  *
11
15
  * Call it once in the test runner's setup file. A message's `editable`, `deletable`, `pinnable`, `crosspostable`,
12
16
  * `bulkDeletable`, `hasThread` and `partial`, a member's `manageable`, `kickable`, `bannable` and `moderatable`, a
13
- * role's `editable`, a channel's and a thread's `viewable`, `manageable`, `deletable`, `joinable` and the rest, and
14
- * `partial` on users, channels and reactions then run discord.js's own getters against the mock, and `message.thread`
15
- * is `null` when the channel caches no thread under the message's id. No placeholder warning is logged.
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.
16
24
  *
17
25
  * @remarks
18
- * Those getters read what a server holds, so strict mocks also have it as Discord sends it: the bot's member is in
19
- * the server's member cache from the start, @everyone has the permissions Discord gives it in a new server, and a
20
- * channel or thread made without a server has one of its own, a thread with a text channel as its parent. A message
21
- * has empty `content`, a thread is neither archived nor locked, and a reaction's `count` is 1. Give the bot's member
22
- * a role 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
23
- * computed one. The next major version (5.0) computes these values without the call.
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.
24
44
  *
25
45
  * @throws Error when a mock already exists, which was made without it.
26
46
  *
@@ -35,11 +55,11 @@ let mockMade = false;
35
55
  * @see {@link useMockFn}
36
56
  * @see {@link https://meocord.dev/docs/4.2/mocks | Mocks}
37
57
  */ function useStrictMocks() {
38
- if (strict) return;
39
- if (mockMade) {
58
+ if (settings.strict) return;
59
+ if (settings.mockMade) {
40
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.");
41
61
  }
42
- strict = true;
62
+ settings.strict = true;
43
63
  }
44
64
 
45
65
  export { noteMockMade, strictMocks, useStrictMocks };
@@ -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
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
@@ -66,14 +66,12 @@ const oneOf = (...allowed)=>(value, key)=>allowed.includes(value) ? undefined :
66
66
  errors: [],
67
67
  warnings: []
68
68
  };
69
- // jiti's interop proxy lists only `default` among its keys, while reading through to it.
70
- const loaded = config?.default ?? config;
71
- if (typeof loaded !== 'object' || loaded === null) {
72
- 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)})`);
73
71
  return problems;
74
72
  }
75
73
  const shape = configShape(problems);
76
- for (const [name, value] of Object.entries(loaded)){
74
+ for (const [name, value] of Object.entries(config)){
77
75
  const check = shape[name];
78
76
  if (!check) {
79
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 };
@@ -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 };
@@ -1,6 +1,5 @@
1
1
  import { writeFileSync, existsSync, readFileSync } from 'fs';
2
2
  import path from 'path';
3
- import { refuse } from './refusal.util.js';
4
3
  import { isBuiltApplication, bundleEntry } from './bundle-entry.util.js';
5
4
 
6
5
  /**
@@ -42,23 +41,24 @@ import { isBuiltApplication, bundleEntry } from './bundle-entry.util.js';
42
41
  writeFileSync(path.join(distDir, PLATFORM_MANIFEST), `${JSON.stringify(currentPlatform(), null, 2)}\n`);
43
42
  }
44
43
  /**
45
- * Stops a bundle from starting on a platform its native addons were not built for, with one clear
46
- * message instead of a linker error on the first command that loads an addon.
44
+ * Why a bundle cannot start on this platform, naming both, or `undefined` when it can: its manifest names the platform
45
+ * its native addons were built for. The pre-entry checks it before any package loads, where a native addon built for
46
+ * another platform would otherwise fail with an error of its own.
47
47
  * @param distDir - Directory holding the manifest. Defaults to the built bundle's directory, which a process manager's
48
48
  * wrapper in `argv[1]` would hide, and else to the entry script's.
49
- */ function assertBuiltForThisPlatform(distDir = defaultDistDir()) {
50
- if (!distDir) return;
49
+ */ function platformMismatch(distDir = defaultDistDir()) {
50
+ if (!distDir) return undefined;
51
51
  const manifest = path.join(distDir, PLATFORM_MANIFEST);
52
- if (!existsSync(manifest)) return;
52
+ if (!existsSync(manifest)) return undefined;
53
53
  let built;
54
54
  try {
55
55
  built = JSON.parse(readFileSync(manifest, 'utf8'));
56
56
  } catch {
57
- return;
57
+ return undefined;
58
58
  }
59
59
  const running = currentPlatform();
60
- if (isSamePlatform(built, running)) return;
61
- throw refuse(new Error(`${path.basename(distDir)}: this build carries native addons compiled for ${describePlatform(built)}, but is ` + `running on ${describePlatform(running)}. Compiled binaries only load on the platform they were built for. ` + 'Build on the same platform you deploy to -- for a container, run `meocord build` inside the image.'));
60
+ if (isSamePlatform(built, running)) return undefined;
61
+ return `${path.basename(distDir)}: this build carries native addons compiled for ${describePlatform(built)}, but is ` + `running on ${describePlatform(running)}. Compiled binaries only load on the platform they were built for. ` + 'Build on the same platform you deploy to -- for a container, run `meocord build` inside the image.';
62
62
  }
63
63
  /** The built bundle's directory, or the entry script's outside a built bot. */ function defaultDistDir() {
64
64
  const entry = isBuiltApplication() ? bundleEntry() : undefined;
@@ -66,4 +66,4 @@ import { isBuiltApplication, bundleEntry } from './bundle-entry.util.js';
66
66
  return process.argv[1] ? path.dirname(process.argv[1]) : undefined;
67
67
  }
68
68
 
69
- export { PLATFORM_MANIFEST, assertBuiltForThisPlatform, currentPlatform, describePlatform, isSamePlatform, writePlatformManifest };
69
+ export { PLATFORM_MANIFEST, currentPlatform, describePlatform, isSamePlatform, platformMismatch, writePlatformManifest };
@@ -1,5 +1,8 @@
1
1
  /** Set by discord.js in every process its ShardingManager spawns. */ const SHARDING_MANAGER_ENV = 'SHARDING_MANAGER';
2
- /** Whether this process was spawned as a shard by a sharding manager. */ function isShardProcess(env = process.env) {
2
+ /**
3
+ * Whether a process with `env` was spawned as a shard by a sharding manager. Read before anything builds a
4
+ * `ShardingManager`, whose constructor writes the variable into its own process too.
5
+ */ function isShardProcess(env) {
3
6
  return env[SHARDING_MANAGER_ENV] !== undefined;
4
7
  }
5
8
 
@@ -1,6 +1,4 @@
1
1
  import { refuse } from './refusal.util.js';
2
- import { isShardProcess } from './shard-process.util.js';
3
- export { SHARDING_MANAGER_ENV } from './shard-process.util.js';
4
2
 
5
3
  /** Whether a shard's manager is gone: the IPC channel it asks a shard to stop through is closed. */ function managerGone(proc = process) {
6
4
  return proc.connected === false;
@@ -10,10 +8,10 @@ export { SHARDING_MANAGER_ENV } from './shard-process.util.js';
10
8
  return env.NODE_ENV !== 'development' || config.sharding.development === true;
11
9
  }
12
10
  /**
13
- * The role of this process: a shard when a manager spawned it, the manager when process sharding is on,
14
- * and otherwise a single process running every shard it has.
15
- */ function shardingRole(config, env = process.env) {
16
- if (isShardProcess(env)) return 'shard';
11
+ * The role of this process: a shard when a manager spawned it, as `shard` says, the manager when process sharding is
12
+ * on, and otherwise a single process running every shard it has.
13
+ */ function shardingRole(config, shard, env = process.env) {
14
+ if (shard) return 'shard';
17
15
  return processShardingEnabled(config, env) ? 'manager' : 'single';
18
16
  }
19
17
  /**
@@ -52,4 +50,4 @@ export { SHARDING_MANAGER_ENV } from './shard-process.util.js';
52
50
  return clientOptions;
53
51
  }
54
52
 
55
- export { clientOptionsWithSharding, isShardProcess, managerGone, processShardingEnabled, shardingRole };
53
+ export { clientOptionsWithSharding, managerGone, processShardingEnabled, shardingRole };
@@ -0,0 +1,21 @@
1
+ import packageJson from '../package.json.js';
2
+
3
+ /**
4
+ * The name of a key the ES module and CommonJS builds of this meocord version share, and no other version has, for
5
+ * `Symbol.for`: a process that loads both builds of one install keeps one state under it, and two installed versions
6
+ * keep theirs apart. `Symbol.for(sharedName(...))` written out types as a `unique symbol`.
7
+ */ const sharedName = (name)=>`meocord@${packageJson.version}:${name}`;
8
+ /** The key named {@link sharedName}. */ function sharedKey(name) {
9
+ return Symbol.for(sharedName(name));
10
+ }
11
+ /** The state both builds of this version keep on `globalThis` under `name`, made by whichever asks first. */ function shared(name, make) {
12
+ const key = sharedKey(name);
13
+ const store = globalThis;
14
+ if (!Object.hasOwn(store, key)) Object.defineProperty(store, key, {
15
+ value: make(),
16
+ configurable: true
17
+ });
18
+ return store[key];
19
+ }
20
+
21
+ export { shared, sharedKey, sharedName };
@@ -0,0 +1,29 @@
1
+ import { existsSync } from 'fs';
2
+ import path from 'path';
3
+ import { parseTsconfig, createPathsMatcher } from 'get-tsconfig';
4
+
5
+ // Stands in for a pattern's `*` while its targets are worked out, then is put back
6
+ const STAR = '__meocord_paths_star__';
7
+ /**
8
+ * The project's tsconfig `paths`, each target an absolute path, as `tsc` reads them: through `extends`, from `baseUrl`,
9
+ * and with `${configDir}`. Read with get-tsconfig, whatever TypeScript the project installed. Undefined when there are
10
+ * none.
11
+ */ function projectPaths(cwd = process.cwd()) {
12
+ const configPath = path.join(cwd, 'tsconfig.json');
13
+ if (!existsSync(configPath)) return undefined;
14
+ const tsconfig = {
15
+ path: configPath,
16
+ config: parseTsconfig(configPath)
17
+ };
18
+ const paths = tsconfig.config.compilerOptions?.paths;
19
+ // The matcher resolves each target from the file that declares it, or from baseUrl, as tsc does
20
+ const match = createPathsMatcher(tsconfig);
21
+ if (!paths || !match) return undefined;
22
+ // Resolved, so each is the platform's own absolute path: get-tsconfig writes Windows paths with forward slashes
23
+ return Object.fromEntries(Object.keys(paths).map((alias)=>[
24
+ alias,
25
+ match(alias.replace('*', STAR)).map((target)=>path.resolve(target).replace(STAR, '*'))
26
+ ]));
27
+ }
28
+
29
+ export { projectPaths };