chapterjs 0.2.2 → 0.3.0

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 (65) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +65 -0
  3. package/dist/cli/cli.js +41 -6
  4. package/dist/cli/cluster.d.ts +3 -0
  5. package/dist/cli/cluster.js +4 -1
  6. package/dist/cli/dev.js +2 -0
  7. package/dist/cli/project.d.ts +38 -0
  8. package/dist/cli/project.js +51 -10
  9. package/dist/cli/start.js +18 -0
  10. package/dist/cli/test.d.ts +29 -0
  11. package/dist/cli/test.js +444 -0
  12. package/dist/commands/autocomplete.d.ts +1 -1
  13. package/dist/commands/autocomplete.js +37 -41
  14. package/dist/commands/router.d.ts +4 -1
  15. package/dist/commands/router.js +16 -17
  16. package/dist/components/router.d.ts +7 -2
  17. package/dist/components/router.js +17 -16
  18. package/dist/discord/types/channel.d.ts +9 -1
  19. package/dist/discord/types/guild.d.ts +5 -5
  20. package/dist/discord/types/permissions.d.ts +6 -0
  21. package/dist/discord/types/poll.d.ts +7 -1
  22. package/dist/events/router.d.ts +12 -4
  23. package/dist/events/router.js +29 -12
  24. package/dist/index.d.ts +2 -0
  25. package/dist/index.js +1 -0
  26. package/dist/interactions/dispatch.d.ts +7 -1
  27. package/dist/interactions/dispatch.js +7 -2
  28. package/dist/loader/hot.js +22 -2
  29. package/dist/loader/loader.d.ts +7 -0
  30. package/dist/loader/loader.js +22 -12
  31. package/dist/store/backend.d.ts +33 -0
  32. package/dist/store/backend.js +27 -0
  33. package/dist/store/declaration.d.ts +15 -0
  34. package/dist/store/declaration.js +53 -0
  35. package/dist/store/file-backend.d.ts +22 -0
  36. package/dist/store/file-backend.js +172 -0
  37. package/dist/store/ipc-backend.d.ts +53 -0
  38. package/dist/store/ipc-backend.js +87 -0
  39. package/dist/store/memory-backend.d.ts +15 -0
  40. package/dist/store/memory-backend.js +56 -0
  41. package/dist/store/scope.d.ts +8 -0
  42. package/dist/store/scope.js +20 -0
  43. package/dist/store/store.d.ts +115 -0
  44. package/dist/store/store.js +203 -0
  45. package/dist/tasks/scheduler.d.ts +6 -0
  46. package/dist/tasks/scheduler.js +10 -3
  47. package/dist/testing/failure.d.ts +9 -0
  48. package/dist/testing/failure.js +20 -0
  49. package/dist/testing/fake-discord.d.ts +59 -0
  50. package/dist/testing/fake-discord.js +120 -0
  51. package/dist/testing/fake-gateway.d.ts +59 -0
  52. package/dist/testing/fake-gateway.js +123 -0
  53. package/dist/testing/harness.d.ts +387 -0
  54. package/dist/testing/harness.js +1418 -0
  55. package/dist/testing/index.d.ts +4 -0
  56. package/dist/testing/index.js +5 -0
  57. package/dist/testing/scenario.d.ts +39 -0
  58. package/dist/testing/scenario.js +58 -0
  59. package/dist/testing/smoke.d.ts +20 -0
  60. package/dist/testing/smoke.js +659 -0
  61. package/dist/testing/websocket.d.ts +34 -0
  62. package/dist/testing/websocket.js +167 -0
  63. package/dist/testing/world.d.ts +145 -0
  64. package/dist/testing/world.js +1479 -0
  65. package/package.json +20 -3
@@ -14,6 +14,11 @@ export declare class ComponentRouter {
14
14
  constructor(options: ComponentRouterOptions);
15
15
  /** Replaces every component (after a load or a reload). */
16
16
  set(entries: Iterable<ComponentEntry>): void;
17
- /** Handles an Interaction Create event, if it is a component or a form. */
18
- dispatch(ctx: Context, raw: RawInteraction): void;
17
+ /**
18
+ * Handles an Interaction Create event, if it is a component or a form.
19
+ * Returns a promise of everything being done (the function run, the
20
+ * person answered), or `null` when the interaction is not for this
21
+ * router. Nothing of the bot waits for it: `chapterjs test` does.
22
+ */
23
+ dispatch(ctx: Context, raw: RawInteraction): Promise<void> | null;
19
24
  }
@@ -33,18 +33,23 @@ export class ComponentRouter {
33
33
  }
34
34
  this.#components = components;
35
35
  }
36
- /** Handles an Interaction Create event, if it is a component or a form. */
36
+ /**
37
+ * Handles an Interaction Create event, if it is a component or a form.
38
+ * Returns a promise of everything being done (the function run, the
39
+ * person answered), or `null` when the interaction is not for this
40
+ * router. Nothing of the bot waits for it: `chapterjs test` does.
41
+ */
37
42
  dispatch(ctx, raw) {
38
43
  const isComponent = raw.type === InteractionType.MessageComponent;
39
44
  const isModal = raw.type === InteractionType.ModalSubmit;
40
45
  if (!isComponent && !isModal)
41
- return;
46
+ return null;
42
47
  const data = raw.data;
43
48
  if (!data || typeof data.custom_id !== 'string')
44
- return;
49
+ return null;
45
50
  const author = authorOf(raw);
46
51
  if (!author)
47
- return;
52
+ return null;
48
53
  const user = ctx.entities.user(author.user);
49
54
  const { user: _user, member: _member, message: rawMessage, ...rest } = raw;
50
55
  const message = rawMessage
@@ -79,7 +84,7 @@ export class ComponentRouter {
79
84
  message: message,
80
85
  });
81
86
  if (isComponent && !message)
82
- return;
87
+ return null;
83
88
  // Embeds are never in the map: what is left has a `run`.
84
89
  if (!entry ||
85
90
  entry.component.kind === 'embed' ||
@@ -87,27 +92,23 @@ export class ComponentRouter {
87
92
  SELECT_TYPES[entry.component.type] !==
88
93
  data.component_type)) {
89
94
  // Sent before the export was renamed, removed or changed of kind.
90
- refuse(interaction, 'gone', { what });
91
- return;
95
+ return refuse(interaction, 'gone', { what });
92
96
  }
93
97
  const { component, file } = entry;
94
98
  const values = readData(component.data, parts);
95
99
  if (!values) {
96
100
  // The data of the component changed since the message was sent.
97
- refuse(interaction, 'outdated', { what });
98
- return;
101
+ return refuse(interaction, 'outdated', { what });
99
102
  }
100
103
  const placed = resolvePlace(ctx, raw, user, author.member, component.where, warning => this.#options.onWarning(file, `${name} ${warning}`));
101
104
  if (!placed.place) {
102
- refuse(interaction, placed.refusal, { what });
103
- return;
105
+ return refuse(interaction, placed.refusal, { what });
104
106
  }
105
107
  const { place } = placed;
106
108
  if (component.kind !== 'modal' &&
107
109
  component.who === 'author' &&
108
110
  rawMessage?.interaction_metadata?.user.id !== user.id) {
109
- refuse(interaction, 'authorOnly', { what });
110
- return;
111
+ return refuse(interaction, 'authorOnly', { what });
111
112
  }
112
113
  remember(interaction, {
113
114
  guild: place.guild ?? undefined,
@@ -127,8 +128,7 @@ export class ComponentRouter {
127
128
  }
128
129
  catch (error) {
129
130
  this.#options.onError(file, error);
130
- refuse(interaction, 'failed', { what });
131
- return;
131
+ return refuse(interaction, 'failed', { what });
132
132
  }
133
133
  const context = Object.freeze({
134
134
  interaction,
@@ -144,12 +144,13 @@ export class ComponentRouter {
144
144
  ephemeral: ephemeralOf(component),
145
145
  })),
146
146
  });
147
- runInteraction({
147
+ return runInteraction({
148
148
  interaction,
149
149
  file,
150
150
  name,
151
151
  what,
152
152
  reporter: this.#options,
153
+ guildId: place.guild?.id ?? null,
153
154
  // Nothing changes on screen while a component is answered; a form
154
155
  // sent is like a command used.
155
156
  defer: () => interaction instanceof ComponentInteraction
@@ -251,6 +251,14 @@ export interface RawForumTag {
251
251
  /** the unicode character of the emoji * */
252
252
  emoji_name: string | null;
253
253
  }
254
+ /**
255
+ * A forum tag as a request gives it: a new tag has no `id` yet, an existing
256
+ * one keeps its own.
257
+ * @see https://docs.discord.com/developers/resources/channel#forum-tag-object
258
+ */
259
+ export type RawForumTagInput = Omit<RawForumTag, 'id'> & {
260
+ id?: Snowflake;
261
+ };
254
262
  /**
255
263
  * JSON Params (Group DM)
256
264
  * @see https://docs.discord.com/developers/resources/channel#modify-channel-json-params-group-dm
@@ -293,7 +301,7 @@ export interface ModifyGuildChannelJSONParams {
293
301
  /** channel flags combined as a bitfield. */
294
302
  flags?: number;
295
303
  /** the set of tags that can be used in a `GUILD_FORUM` or a `GUILD_MEDIA` channel; limited to 20 */
296
- available_tags?: RawForumTag[];
304
+ available_tags?: RawForumTagInput[];
297
305
  /** the emoji to show in the add reaction button on a thread in a `GUILD_FORUM` or a `GUILD_MEDIA` channel */
298
306
  default_reaction_emoji?: RawDefaultReaction | null;
299
307
  /** the initial `rate_limit_per_user` to set on newly created threads in a channel. this field is copied to the thread at creation time and does not live update. */
@@ -1,9 +1,9 @@
1
1
  import type { RawApplication } from './application.js';
2
- import type { ChannelType, ForumLayoutType, ForumSortOrderType, RawChannel, RawDefaultReaction, RawForumTag, RawPermissionOverwrite, RawThreadMember, VideoQualityMode } from './channel.js';
2
+ import type { ChannelType, ForumLayoutType, ForumSortOrderType, RawChannel, RawDefaultReaction, RawForumTagInput, RawPermissionOverwrite, RawThreadMember, VideoQualityMode } from './channel.js';
3
3
  import type { ISO8601Timestamp, ImageData, Locale, Snowflake } from './common.js';
4
4
  import type { RawEmoji } from './emoji.js';
5
5
  import type { OAuth2Scope } from './oauth2.js';
6
- import type { RawRole, RawRoleColors } from './permissions.js';
6
+ import type { RawRole, RawRoleColorsInput } from './permissions.js';
7
7
  import type { RawSticker } from './sticker.js';
8
8
  import type { RawAvatarDecorationData, RawCollectibles, RawUser } from './user.js';
9
9
  /**
@@ -605,7 +605,7 @@ export interface CreateGuildChannelJSONParams {
605
605
  /** emoji to show in the add reaction button on a thread in a `GUILD_FORUM` or a `GUILD_MEDIA` channel */
606
606
  default_reaction_emoji?: RawDefaultReaction | null;
607
607
  /** set of tags that can be used in a `GUILD_FORUM` or a `GUILD_MEDIA` channel */
608
- available_tags?: RawForumTag[] | null;
608
+ available_tags?: RawForumTagInput[] | null;
609
609
  /** the default sort order type used to order posts in `GUILD_FORUM` and `GUILD_MEDIA` channels */
610
610
  default_sort_order?: ForumSortOrderType | null;
611
611
  /** the default forum layout view used to display posts in `GUILD_FORUM` channels */
@@ -773,7 +773,7 @@ export interface CreateGuildRoleJSONParams {
773
773
  /** **Deprecated** RGB color value */
774
774
  color?: number;
775
775
  /** the role's colors */
776
- colors?: RawRoleColors;
776
+ colors?: RawRoleColorsInput;
777
777
  /** whether the role should be displayed separately in the sidebar */
778
778
  hoist?: boolean;
779
779
  /** the role's icon image (if the guild has the `ROLE_ICONS` feature) */
@@ -805,7 +805,7 @@ export interface ModifyGuildRoleJSONParams {
805
805
  /** **Deprecated** RGB color value */
806
806
  color?: number;
807
807
  /** the role's colors */
808
- colors?: RawRoleColors;
808
+ colors?: RawRoleColorsInput;
809
809
  /** whether the role should be displayed separately in the sidebar */
810
810
  hoist?: boolean;
811
811
  /** the role's icon image (if the guild has the `ROLE_ICONS` feature) */
@@ -172,6 +172,12 @@ export interface RawRoleColors {
172
172
  /** the tertiary color for the role, this will turn the gradient into a holographic style */
173
173
  tertiary_color: number | null;
174
174
  }
175
+ /**
176
+ * The colors of a role as a request gives them: only the primary color is
177
+ * needed, the two others make a gradient when given.
178
+ * @see https://docs.discord.com/developers/topics/permissions#role-object-role-colors-object
179
+ */
180
+ export type RawRoleColorsInput = Pick<RawRoleColors, 'primary_color'> & Partial<Omit<RawRoleColors, 'primary_color'>>;
175
181
  /**
176
182
  * Role Flags
177
183
  * @see https://docs.discord.com/developers/topics/permissions#role-object-role-flags
@@ -27,7 +27,7 @@ export interface RawPollCreateRequest {
27
27
  /** The question of the poll. Only `text` is supported. */
28
28
  question: RawPollMedia;
29
29
  /** Each of the answers available in the poll, up to 10 */
30
- answers: RawPollAnswer[];
30
+ answers: RawPollAnswerInput[];
31
31
  /** Number of hours the poll should be open for, up to 32 days. Defaults to 24 */
32
32
  duration?: number;
33
33
  /** Whether a user can select multiple answers. Defaults to false */
@@ -54,6 +54,12 @@ export interface RawPollMedia {
54
54
  /** The emoji of the field */
55
55
  emoji?: Partial<RawEmoji>;
56
56
  }
57
+ /**
58
+ * A poll answer as a request gives it: `answer_id` is only sent as part of
59
+ * responses from Discord's API/Gateway.
60
+ * @see https://docs.discord.com/developers/resources/poll#poll-answer-object-poll-answer-object-structure
61
+ */
62
+ export type RawPollAnswerInput = Omit<RawPollAnswer, 'answer_id'>;
57
63
  /**
58
64
  * Poll Answer Object Structure
59
65
  * @see https://docs.discord.com/developers/resources/poll#poll-answer-object-poll-answer-object-structure
@@ -36,11 +36,19 @@ export declare class EventRouter {
36
36
  * `dispatch` needs back; nothing is read for events nobody listens to.
37
37
  */
38
38
  before<E extends GatewayDispatchEventName>(ctx: Context, event: E, data: GatewayDispatchEvents[E]): unknown[] | undefined;
39
- /** Runs the handlers of a gateway event, once the cache reflects it. */
39
+ /**
40
+ * Runs the handlers of a gateway event, once the cache reflects it.
41
+ * Resolves once every handler is done (what was asked to Discord first
42
+ * included). Nothing of the bot waits for it: `chapterjs test` does.
43
+ */
40
44
  dispatch<E extends GatewayDispatchEventName>(ctx: Context, event: E, data: GatewayDispatchEvents[E], extra: {
41
45
  joined: boolean;
42
46
  before: unknown[] | undefined;
43
- }): void;
44
- /** Runs the handlers of an event with what they receive. */
45
- emit<Name extends EventName>(name: Name, context: EventContexts[Name]): void;
47
+ }): Promise<void>;
48
+ /**
49
+ * Runs the handlers of an event with what they receive, for the server
50
+ * it happened in (what a store per server reads). Resolves once every
51
+ * handler is done.
52
+ */
53
+ emit<Name extends EventName>(name: Name, context: EventContexts[Name], guildId?: string | null): Promise<void>;
46
54
  }
@@ -1,4 +1,5 @@
1
1
  // Delivers gateway events to the handlers of the project.
2
+ import { withGuild } from '../store/scope.js';
2
3
  import { DEFAULT_CACHE_LIMITS } from '../cache/cache.js';
3
4
  import { GatewayIntent } from '../discord/intents.js';
4
5
  import { audienceLocale, translation } from '../messages/translate.js';
@@ -78,11 +79,16 @@ export class EventRouter {
78
79
  ? source.before(ctx, data)
79
80
  : undefined);
80
81
  }
81
- /** Runs the handlers of a gateway event, once the cache reflects it. */
82
+ /**
83
+ * Runs the handlers of a gateway event, once the cache reflects it.
84
+ * Resolves once every handler is done (what was asked to Discord first
85
+ * included). Nothing of the bot waits for it: `chapterjs test` does.
86
+ */
82
87
  dispatch(ctx, event, data, extra) {
83
88
  const sources = BY_GATEWAY_EVENT.get(event);
84
89
  if (!sources)
85
- return;
90
+ return Promise.resolve();
91
+ const pending = [];
86
92
  sources.forEach((source, index) => {
87
93
  if (!this.#handlers.has(source.name))
88
94
  return;
@@ -93,41 +99,52 @@ export class EventRouter {
93
99
  prepared,
94
100
  });
95
101
  if (!built)
96
- return;
102
+ return Promise.resolve();
97
103
  // One gateway event can be several occurrences (messages deleted
98
104
  // together, emojis changed at once): each is delivered.
99
105
  const occurrences = Array.isArray(built)
100
106
  ? built
101
107
  : [built];
102
108
  const guildOf = EVENTS[source.name].guildOf;
109
+ const emitted = [];
103
110
  for (const context of occurrences) {
104
111
  // `t` speaks the language of the server the event happened in.
105
112
  const guild = guildOf?.(context);
106
- this.emit(source.name, {
113
+ emitted.push(this.emit(source.name, {
107
114
  ...context,
108
115
  ...translation(ctx, audienceLocale({ guild })),
109
- });
116
+ }, guild?.id ?? null));
110
117
  }
118
+ return Promise.all(emitted).then(() => { });
111
119
  };
112
120
  const preparing = source.prepare?.(ctx, data);
113
- if (!preparing)
114
- return deliver();
115
- preparing.then(deliver, (error) => this.#onSkipped(source.name, error));
121
+ if (!preparing) {
122
+ pending.push(deliver());
123
+ return;
124
+ }
125
+ pending.push(preparing.then(deliver, (error) => this.#onSkipped(source.name, error)));
116
126
  });
127
+ return Promise.all(pending).then(() => { });
117
128
  }
118
- /** Runs the handlers of an event with what they receive. */
119
- emit(name, context) {
129
+ /**
130
+ * Runs the handlers of an event with what they receive, for the server
131
+ * it happened in (what a store per server reads). Resolves once every
132
+ * handler is done.
133
+ */
134
+ emit(name, context, guildId = null) {
120
135
  const handlers = this.#handlers.get(name);
121
136
  if (!handlers)
122
- return;
137
+ return Promise.resolve();
123
138
  // Handlers can't change what the others receive.
124
139
  const frozen = Object.freeze({ ...context });
125
140
  const accepts = EVENTS[name].accepts;
141
+ const running = [];
126
142
  for (const { file, event } of handlers) {
127
143
  if (accepts && !accepts(frozen, event.options))
128
144
  continue;
129
145
  // One handler failing never stops the others, nor the bot.
130
- new Promise(resolve => resolve(event.handler(frozen))).catch((error) => this.#onError(file, error));
146
+ running.push(new Promise(resolve => resolve(withGuild(guildId, () => event.handler(frozen)))).then(() => { }, (error) => this.#onError(file, error)));
131
147
  }
148
+ return Promise.all(running).then(() => { });
132
149
  }
133
150
  }
package/dist/index.d.ts CHANGED
@@ -29,6 +29,8 @@ export { language } from './messages/messages.js';
29
29
  export type { CommandsTranslations, FrameworkTexts, LanguageConfig, LanguageDeclaration, MergedMessages, MessagesOf, MessageText, MessageTexts, PluralText, ProjectCommands, ProjectLanguageDefaults, ParamsArgs, ParamsOf, Placeholders, ProjectLocale, ProjectLocales, LocaleGiven, ProjectMessages, TranslationContext, Translator, } from './messages/messages.js';
30
30
  export { presence } from './presence/presence.js';
31
31
  export type { Activity, ActivityKind, PlainActivity, PresenceConfig, PresenceDeclaration, PresenceStatusName, StreamingActivity, } from './presence/presence.js';
32
+ export { store } from './store/store.js';
33
+ export type { ScopedStore, Store, StoreConfig, StoreEntry, StoreKey, StoreScope, StoreSetOptions, } from './store/store.js';
32
34
  export { task } from './tasks/task.js';
33
35
  export type { CronTaskConfig, EveryTaskConfig, TaskConfig, TaskContext, TaskDeclaration, } from './tasks/task.js';
34
36
  export type { AssetFile, AssetOptions } from './assets/asset.js';
package/dist/index.js CHANGED
@@ -21,6 +21,7 @@ export { event } from './events/event.js';
21
21
  // Tasks: what the files of src/tasks/ are written with.
22
22
  export { language } from './messages/messages.js';
23
23
  export { presence } from './presence/presence.js';
24
+ export { store } from './store/store.js';
24
25
  export { task } from './tasks/task.js';
25
26
  // Errors handlers can catch.
26
27
  export { DiscordApiError, DiscordUnavailableError, InvalidTokenError, } from './rest/errors.js';
@@ -80,6 +80,8 @@ export interface RunOptions {
80
80
  defer: () => Promise<void>;
81
81
  /** What to tell the developer when `run` finishes without answering. */
82
82
  unanswered: string;
83
+ /** The server the function runs for: what a store per server reads. */
84
+ guildId: string | null;
83
85
  run: () => unknown;
84
86
  }
85
87
  /**
@@ -87,5 +89,9 @@ export interface RunOptions {
87
89
  * seconds: when `run` takes longer, the framework says the answer is
88
90
  * coming. When `run` throws, the person gets a plain answer and the
89
91
  * developer the error with its file and line.
92
+ *
93
+ * Resolves once everything is done (the function, and the answer sent for
94
+ * it when it failed): nothing of the bot waits for it, `chapterjs test`
95
+ * does.
90
96
  */
91
- export declare function runInteraction(options: RunOptions): void;
97
+ export declare function runInteraction(options: RunOptions): Promise<void>;
@@ -3,6 +3,7 @@
3
3
  // there, and how its function is run so that the person always gets an
4
4
  // answer and the developer always gets the error. Written once here;
5
5
  // `commands/router.ts` and `components/router.ts` add what is theirs.
6
+ import { withGuild } from '../store/scope.js';
6
7
  import { setTimeout as sleep } from 'node:timers/promises';
7
8
  import { phrase } from '../messages/phrases.js';
8
9
  import { DiscordApiError } from '../rest/errors.js';
@@ -74,6 +75,10 @@ export function refuse(interaction, key, params) {
74
75
  * seconds: when `run` takes longer, the framework says the answer is
75
76
  * coming. When `run` throws, the person gets a plain answer and the
76
77
  * developer the error with its file and line.
78
+ *
79
+ * Resolves once everything is done (the function, and the answer sent for
80
+ * it when it failed): nothing of the bot waits for it, `chapterjs test`
81
+ * does.
77
82
  */
78
83
  export function runInteraction(options) {
79
84
  const { interaction, file, name, reporter } = options;
@@ -81,7 +86,7 @@ export function runInteraction(options) {
81
86
  sleep(reporter.deferAfter ?? 2000, undefined, { signal: stop.signal })
82
87
  .then(() => options.defer())
83
88
  .catch(() => { });
84
- new Promise(resolve => resolve(options.run()))
89
+ return new Promise(resolve => resolve(withGuild(options.guildId, options.run)))
85
90
  .then(() => {
86
91
  if (!interaction.answered) {
87
92
  reporter.onWarning(file, `${name} ${options.unanswered}`);
@@ -104,7 +109,7 @@ export function runInteraction(options) {
104
109
  : interaction.answered
105
110
  ? interaction.followUp({ content, ephemeral: true })
106
111
  : interaction.reply({ content, ephemeral: true });
107
- tell.catch(() => { });
112
+ return tell.then(() => { }, () => { });
108
113
  })
109
114
  .finally(() => stop.abort());
110
115
  }
@@ -45,8 +45,15 @@ export function enableProjectLoader(dir, { reload }) {
45
45
  result = nextResolve(specifier, context);
46
46
  }
47
47
  catch (error) {
48
- if (!fromProject || EXTENSION.test(specifier))
48
+ // Not found, or a folder (Node refuses those as they are).
49
+ const code = error.code;
50
+ if (!fromProject ||
51
+ (code !== 'ERR_MODULE_NOT_FOUND' &&
52
+ code !== 'ERR_UNSUPPORTED_DIR_IMPORT')) {
49
53
  throw error;
54
+ }
55
+ if (EXTENSION.test(specifier))
56
+ throw missing(specifier, false);
50
57
  // No extension: look for the file people mean.
51
58
  for (const candidate of CANDIDATES) {
52
59
  try {
@@ -58,7 +65,7 @@ export function enableProjectLoader(dir, { reload }) {
58
65
  }
59
66
  }
60
67
  if (!result)
61
- throw error;
68
+ throw missing(specifier, true);
62
69
  }
63
70
  if (hot && root !== null && result.url.startsWith(root)) {
64
71
  return {
@@ -70,6 +77,19 @@ export function enableProjectLoader(dir, { reload }) {
70
77
  },
71
78
  });
72
79
  }
80
+ /**
81
+ * The error for an import of the project that names no file: said in the
82
+ * words of the import, not with the absolute paths Node gives.
83
+ */
84
+ function missing(specifier, tried) {
85
+ const name = specifier.replace(/\/$/, '').split('/').pop() ?? specifier;
86
+ const looked = tried
87
+ ? ` No ${name}.ts, ${name}.js or ${name}/index.ts is there.`
88
+ : '';
89
+ const error = new Error(`'${specifier}' does not exist.${looked} Check the path and the name of the file.`);
90
+ error.code = 'ERR_MODULE_NOT_FOUND';
91
+ return error;
92
+ }
73
93
  /** Makes the next imports load the files again. */
74
94
  export function nextGeneration() {
75
95
  generation++;
@@ -59,6 +59,11 @@ export interface FoundFile {
59
59
  }
60
60
  /** Every source file of `src/`, in a stable order. */
61
61
  export declare function listSources(projectDir: string): Promise<FoundFile[]>;
62
+ /**
63
+ * Every test file of `src/` (`*.test.ts`, `*.spec.ts`), in a stable order:
64
+ * what `chapterjs test` runs, and what the bot never loads.
65
+ */
66
+ export declare function listTests(projectDir: string): Promise<FoundFile[]>;
62
67
  /** A file of the project, run: what it exports. */
63
68
  export interface LoadedModule {
64
69
  /** The path from the project folder: `src/ping.ts`. */
@@ -72,6 +77,8 @@ export interface LoadedSources {
72
77
  }
73
78
  /** Runs every file of `src/`. A file that fails to run never throws. */
74
79
  export declare function loadSources(projectDir: string): Promise<LoadedSources>;
80
+ /** Runs the files given. A file that fails to run never throws. */
81
+ export declare function loadFiles(files: readonly FoundFile[]): Promise<LoadedSources>;
75
82
  /**
76
83
  * Reads the declarations of one kind from what the files export. Every
77
84
  * export is looked at; the ones the kind recognises are read, the others
@@ -16,7 +16,7 @@ const DECLARATION = /\.d\.m?ts$/;
16
16
  /** Tests next to the code: they are for a test runner, not for the bot. */
17
17
  const TEST = /\.(?:test|spec)\.[^.]+$/;
18
18
  /** The source files of a folder and its subfolders, in a stable order. */
19
- async function scan(dir) {
19
+ async function scan(dir, tests = false) {
20
20
  let entries;
21
21
  try {
22
22
  entries = await readdir(dir, { withFileTypes: true });
@@ -30,10 +30,10 @@ async function scan(dir) {
30
30
  for (const entry of entries.sort((a, b) => (a.name < b.name ? -1 : 1))) {
31
31
  const path = join(dir, entry.name);
32
32
  if (entry.isDirectory())
33
- files.push(...(await scan(path)));
33
+ files.push(...(await scan(path, tests)));
34
34
  else if (SOURCE.test(entry.name) &&
35
35
  !DECLARATION.test(entry.name) &&
36
- !TEST.test(entry.name)) {
36
+ TEST.test(entry.name) === tests) {
37
37
  files.push(path);
38
38
  }
39
39
  }
@@ -41,19 +41,29 @@ async function scan(dir) {
41
41
  }
42
42
  /** Every source file of `src/`, in a stable order. */
43
43
  export async function listSources(projectDir) {
44
- const paths = await scan(join(projectDir, 'src'));
45
- return paths
46
- .map(path => ({
47
- file: relative(projectDir, path).split(sep).join('/'),
48
- path,
49
- }))
50
- .sort((a, b) => (a.file < b.file ? -1 : 1));
44
+ return found(projectDir, await scan(join(projectDir, 'src')));
51
45
  }
46
+ /**
47
+ * Every test file of `src/` (`*.test.ts`, `*.spec.ts`), in a stable order:
48
+ * what `chapterjs test` runs, and what the bot never loads.
49
+ */
50
+ export async function listTests(projectDir) {
51
+ return found(projectDir, await scan(join(projectDir, 'src'), true));
52
+ }
53
+ const found = (projectDir, paths) => paths
54
+ .map(path => ({
55
+ file: relative(projectDir, path).split(sep).join('/'),
56
+ path,
57
+ }))
58
+ .sort((a, b) => (a.file < b.file ? -1 : 1));
52
59
  /** Runs every file of `src/`. A file that fails to run never throws. */
53
60
  export async function loadSources(projectDir) {
54
- const found = await listSources(projectDir);
61
+ return loadFiles(await listSources(projectDir));
62
+ }
63
+ /** Runs the files given. A file that fails to run never throws. */
64
+ export async function loadFiles(files) {
55
65
  const result = { modules: [], failed: [] };
56
- await Promise.all(found.map(async ({ file, path }) => {
66
+ await Promise.all(files.map(async ({ file, path }) => {
57
67
  try {
58
68
  result.modules.push({ file, exports: await importFile(path) });
59
69
  }
@@ -0,0 +1,33 @@
1
+ /** One value of a store, with when it expires. */
2
+ export interface StoreEntry {
3
+ value: unknown;
4
+ /** When the value expires, in ms since the epoch, or `null` for never. */
5
+ expiresAt: number | null;
6
+ }
7
+ /** A key as the backend sees it: every part, the scope included. */
8
+ export type StoreKeyParts = readonly string[];
9
+ /** An entry with its key, as `entries()` lists them. */
10
+ export interface StoreListedEntry extends StoreEntry {
11
+ key: StoreKeyParts;
12
+ }
13
+ /** What keeps the data of every store. Expired entries are never given. */
14
+ export interface StoreBackend {
15
+ get(store: string, key: StoreKeyParts): Promise<StoreEntry | undefined>;
16
+ set(store: string, key: StoreKeyParts, entry: StoreEntry): Promise<void>;
17
+ delete(store: string, key: StoreKeyParts): Promise<boolean>;
18
+ /** The entries whose key starts with `prefix`, in no particular order. */
19
+ entries(store: string, prefix: StoreKeyParts): Promise<StoreListedEntry[]>;
20
+ /** Writes what is pending, if anything. */
21
+ close(): Promise<void>;
22
+ }
23
+ /** The map key of a key: unambiguous whatever the parts hold. */
24
+ export declare const encodeKey: (key: StoreKeyParts) => string;
25
+ export declare const decodeKey: (encoded: string) => string[];
26
+ /** Whether `key` starts with every part of `prefix`. */
27
+ export declare function startsWith(key: StoreKeyParts, prefix: StoreKeyParts): boolean;
28
+ /** Whether an entry is still good at `now`. */
29
+ export declare const alive: (entry: StoreEntry, now?: number) => boolean;
30
+ /** Gives every store its backend: called by the CLI, once per process. */
31
+ export declare function setStoreBackend(backend: StoreBackend | null): void;
32
+ /** The backend of the process, or an error that says when stores work. */
33
+ export declare function storeBackend(): StoreBackend;
@@ -0,0 +1,27 @@
1
+ // Where the data of the stores is kept, behind an interface: a file per
2
+ // store by default (`file-backend.ts`), the first process of a cluster for
3
+ // the others (`ipc-backend.ts`), and later anything else. A backend knows
4
+ // nothing of servers: the scope of a store is part of the key it gets.
5
+ /** The map key of a key: unambiguous whatever the parts hold. */
6
+ export const encodeKey = (key) => JSON.stringify(key);
7
+ export const decodeKey = (encoded) => JSON.parse(encoded);
8
+ /** Whether `key` starts with every part of `prefix`. */
9
+ export function startsWith(key, prefix) {
10
+ if (prefix.length > key.length)
11
+ return false;
12
+ return prefix.every((part, index) => key[index] === part);
13
+ }
14
+ /** Whether an entry is still good at `now`. */
15
+ export const alive = (entry, now = Date.now()) => entry.expiresAt === null || entry.expiresAt > now;
16
+ let current = null;
17
+ /** Gives every store its backend: called by the CLI, once per process. */
18
+ export function setStoreBackend(backend) {
19
+ current = backend;
20
+ }
21
+ /** The backend of the process, or an error that says when stores work. */
22
+ export function storeBackend() {
23
+ if (!current) {
24
+ throw new Error('Stores only work while the bot runs: use a store in the functions of your files (run), not when a file loads.');
25
+ }
26
+ return current;
27
+ }
@@ -0,0 +1,15 @@
1
+ import type { Declaration } from '../loader/loader.js';
2
+ import { type StoreScope } from './store.js';
3
+ /** A store, checked: what the project keeps of it. */
4
+ export interface LoadedStore {
5
+ /** The name of the store: the name of the export that declares it. */
6
+ readonly name: string;
7
+ readonly scope: StoreScope;
8
+ /** The default expiration, in ms, or `null` for never. */
9
+ readonly expires: number | null;
10
+ }
11
+ /**
12
+ * A store, declared anywhere in `src/` with `store()` and exported: the
13
+ * name of the export is the name of the store.
14
+ */
15
+ export declare const storeDeclaration: Declaration<LoadedStore>;