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
@@ -0,0 +1,203 @@
1
+ // What a file declares with `store()`: a place to keep data from one
2
+ // restart to the next, per server by default, typed by what it holds,
3
+ // with an expiration if wanted. Named by its export, like a button:
4
+ // `export const warnings = store<Warning[]>()` is the store `warnings`.
5
+ // What `store()` returns works once the loader bound it to that name and
6
+ // the bot runs; the data itself lives in a backend (`backend.ts`).
7
+ import { parseDuration } from '../util/duration.js';
8
+ import { storeBackend, } from './backend.js';
9
+ import { currentGuildId } from './scope.js';
10
+ const STATE = Symbol.for('chapterjs.store');
11
+ /** The state of a declared store, if the value is one. */
12
+ export function storeStateOf(value) {
13
+ if (typeof value !== 'object' || value === null)
14
+ return undefined;
15
+ return value[STATE];
16
+ }
17
+ export const isStoreDeclaration = (value) => storeStateOf(value) !== undefined;
18
+ /** Binds a declared store to the name of its export. */
19
+ export function bindStore(declared, bound) {
20
+ const state = storeStateOf(declared);
21
+ if (!state)
22
+ throw new TypeError('Not a store.');
23
+ state.name = bound.name;
24
+ state.scope = bound.scope;
25
+ state.expires = bound.expires;
26
+ }
27
+ const parts = (key) => {
28
+ const list = typeof key === 'string' ? [key] : [...key];
29
+ if (list.length === 0) {
30
+ throw new TypeError('A key of a store is a text that is not empty, or a list of such texts with at least one.');
31
+ }
32
+ for (const part of list) {
33
+ if (typeof part !== 'string') {
34
+ throw new TypeError(`A key of a store is made of texts, got ${JSON.stringify(part) ?? typeof part}. Write an id as a text: member.id, not a number.`);
35
+ }
36
+ if (part === '') {
37
+ throw new TypeError('A key of a store is a text that is not empty, or a list of such texts with at least one.');
38
+ }
39
+ }
40
+ return list;
41
+ };
42
+ /**
43
+ * Refuses what would not survive a trip through JSON: a `Date` would come
44
+ * back as a text, a `Map` as `{}`, a class instance as a plain object.
45
+ */
46
+ export function checkJson(value, path = 'the value') {
47
+ if (value === undefined) {
48
+ throw new TypeError(`${path} is undefined: a store keeps JSON. To forget a value, use delete().`);
49
+ }
50
+ if (value === null)
51
+ return;
52
+ switch (typeof value) {
53
+ case 'string':
54
+ case 'boolean':
55
+ return;
56
+ case 'number':
57
+ if (!Number.isFinite(value)) {
58
+ throw new TypeError(`${path} is ${value}, which JSON can't hold: a store keeps JSON.`);
59
+ }
60
+ return;
61
+ case 'bigint':
62
+ case 'function':
63
+ case 'symbol':
64
+ throw new TypeError(`${path} is a ${typeof value}, which JSON can't hold: a store keeps JSON (texts, numbers, booleans, lists and plain objects).`);
65
+ }
66
+ if (Array.isArray(value)) {
67
+ value.forEach((item, index) => checkJson(item, `${path}[${index}]`));
68
+ return;
69
+ }
70
+ const proto = Object.getPrototypeOf(value);
71
+ if (proto !== null && proto !== Object.prototype) {
72
+ const name = value.constructor?.name ??
73
+ 'an object of a class';
74
+ throw new TypeError(`${path} is a ${name}, which JSON can't hold: a store keeps JSON (texts, numbers, booleans, lists and plain objects). Keep what you need of it: an id, a text, a number.`);
75
+ }
76
+ for (const [key, item] of Object.entries(value)) {
77
+ if (item !== undefined)
78
+ checkJson(item, `${path}.${key}`);
79
+ }
80
+ }
81
+ /** When a value set now expires, from what was asked. */
82
+ function expiresAt(options, fallback, now) {
83
+ if (!options || options.expires === undefined) {
84
+ return fallback === null ? null : now + fallback;
85
+ }
86
+ const { expires } = options;
87
+ if (expires === null)
88
+ return null;
89
+ if (expires instanceof Date) {
90
+ const ms = expires.getTime();
91
+ if (!Number.isFinite(ms)) {
92
+ throw new TypeError('"expires" is a Date that is not a valid date.');
93
+ }
94
+ return ms;
95
+ }
96
+ const ms = typeof expires === 'string' ? parseDuration(expires) : null;
97
+ if (ms === null || ms < 1000) {
98
+ throw new TypeError(`"expires" is a duration (a number and a unit: s, m, h or d, like '10m' or '1d', 1 s at least), a Date, or null to keep the value forever. Got ${JSON.stringify(expires)}.`);
99
+ }
100
+ return now + ms;
101
+ }
102
+ /** The store as its functions see it: the backend, the name, the scope prefix. */
103
+ function scoped(state, guildOf) {
104
+ const ready = () => {
105
+ if (!state.name) {
106
+ throw new Error('This store was not loaded by ChapterJS: a store is exported from a file of src/ (export const name = store()), and used once the bot started.');
107
+ }
108
+ const backend = storeBackend();
109
+ if (state.scope === 'global') {
110
+ return { backend, name: state.name, prefix: [] };
111
+ }
112
+ const guildId = guildOf();
113
+ if (!guildId) {
114
+ throw new Error(`${state.name} is a store per server, and this code does not run in one: use ${state.name}.in(guild) to say which server, or declare the store with scope: 'global'.`);
115
+ }
116
+ return { backend, name: state.name, prefix: [guildId] };
117
+ };
118
+ const full = (prefix, key) => [
119
+ ...prefix,
120
+ ...parts(key),
121
+ ];
122
+ const set = async (key, value, options, keep) => {
123
+ const { backend, name, prefix } = ready();
124
+ checkJson(value);
125
+ const now = Date.now();
126
+ const at = keep !== undefined && (!options || options.expires === undefined)
127
+ ? keep
128
+ : expiresAt(options, state.expires, now);
129
+ await backend.set(name, full(prefix, key), { value, expiresAt: at });
130
+ };
131
+ return {
132
+ async get(key) {
133
+ const { backend, name, prefix } = ready();
134
+ const entry = await backend.get(name, full(prefix, key));
135
+ return entry?.value;
136
+ },
137
+ set: (key, value, options) => set(key, value, options, undefined),
138
+ async update(key, change, options) {
139
+ const { backend, name, prefix } = ready();
140
+ const entry = await backend.get(name, full(prefix, key));
141
+ const next = change(entry?.value);
142
+ await set(key, next, options, entry ? entry.expiresAt : undefined);
143
+ return next;
144
+ },
145
+ async delete(key) {
146
+ const { backend, name, prefix } = ready();
147
+ return backend.delete(name, full(prefix, key));
148
+ },
149
+ async entries(prefixKey) {
150
+ const { backend, name, prefix } = ready();
151
+ const listed = await backend.entries(name, prefixKey === undefined ? prefix : full(prefix, prefixKey));
152
+ return listed.map(entry => ({
153
+ key: entry.key.slice(prefix.length),
154
+ value: entry.value,
155
+ expires: entry.expiresAt === null ? null : new Date(entry.expiresAt),
156
+ }));
157
+ },
158
+ async ttl(key) {
159
+ const { backend, name, prefix } = ready();
160
+ const entry = await backend.get(name, full(prefix, key));
161
+ if (!entry)
162
+ return undefined;
163
+ return entry.expiresAt === null
164
+ ? null
165
+ : Math.max(0, entry.expiresAt - Date.now());
166
+ },
167
+ };
168
+ }
169
+ /**
170
+ * Declares a store: data the bot keeps from one restart to the next,
171
+ * typed by what it holds. Export the result from any file of `src/`: the
172
+ * name of the export is the name of the store, and its file in `data/`.
173
+ * Per server unless `scope: 'global'` is given, with an expiration if
174
+ * `expires` is.
175
+ *
176
+ * ```ts
177
+ * import { store } from 'chapterjs';
178
+ *
179
+ * export const warnings = store<Warning[]>({ expires: '30d' });
180
+ *
181
+ * // In a command, in a server:
182
+ * await warnings.update(member.id, (list = []) => [...list, warning]);
183
+ * ```
184
+ */
185
+ export function store(config = {}) {
186
+ const state = { config, scope: 'guild', expires: null };
187
+ const guildIdOf = (guild) => {
188
+ const id = typeof guild === 'string' ? guild : guild?.id;
189
+ if (typeof id !== 'string' || id === '') {
190
+ throw new TypeError('in() takes a server (a Guild) or its id, like warnings.in(guild) or warnings.in(guild.id).');
191
+ }
192
+ return id;
193
+ };
194
+ const declared = {
195
+ ...scoped(state, () => currentGuildId() ?? null),
196
+ in: guild => {
197
+ const id = guildIdOf(guild);
198
+ return scoped(state, () => id);
199
+ },
200
+ };
201
+ Object.defineProperty(declared, STATE, { value: state });
202
+ return declared;
203
+ }
@@ -25,6 +25,12 @@ export interface SchedulerOptions {
25
25
  /** Something the developer should know, that is not an error of theirs. */
26
26
  onWarning: (file: string, message: string) => void;
27
27
  }
28
+ /**
29
+ * Runs a task once, as the scheduler does when its time comes: for no
30
+ * server (what a store per server reads), with the error of a `run` that
31
+ * throws given to `onError`. Resolves once the run is done.
32
+ */
33
+ export declare function runTask(entry: TaskEntry, context: Omit<TaskContext, 'now'>, now: Date, onError: (file: string, error: unknown) => void): Promise<void>;
28
34
  /** Tasks run by the timers of this process. */
29
35
  export declare class TimerScheduler implements Scheduler {
30
36
  #private;
@@ -1,9 +1,18 @@
1
1
  // Runs the tasks of the project when they are due. Behind an interface:
2
2
  // this one keeps timers in the process; a bot on several machines would
3
3
  // share one through another implementation, with no change to the files.
4
+ import { withGuild } from '../store/scope.js';
4
5
  import { describeSchedule, nextRun, scheduleKey } from './schedule.js';
5
6
  /** The longest a timer of Node can wait. */
6
7
  const MAX_DELAY = 2 ** 31 - 1;
8
+ /**
9
+ * Runs a task once, as the scheduler does when its time comes: for no
10
+ * server (what a store per server reads), with the error of a `run` that
11
+ * throws given to `onError`. Resolves once the run is done.
12
+ */
13
+ export function runTask(entry, context, now, onError) {
14
+ return new Promise(resolve => resolve(withGuild(null, () => entry.task.run(Object.freeze({ ...context, now }))))).then(() => { }, (error) => onError(entry.file, error));
15
+ }
7
16
  /** Tasks run by the timers of this process. */
8
17
  export class TimerScheduler {
9
18
  #options;
@@ -98,9 +107,7 @@ export class TimerScheduler {
98
107
  return;
99
108
  }
100
109
  scheduled.running = true;
101
- new Promise(resolve => resolve(scheduled.entry.task.run(Object.freeze({ ...context, now }))))
102
- .catch((error) => this.#options.onError(file, error))
103
- .finally(() => {
110
+ runTask(scheduled.entry, context, now, this.#options.onError).finally(() => {
104
111
  scheduled.running = false;
105
112
  });
106
113
  }
@@ -0,0 +1,9 @@
1
+ /** An expectation of a test that the bot did not meet. */
2
+ export declare class TestFailure extends Error {
3
+ readonly name = "TestFailure";
4
+ constructor(message: string);
5
+ }
6
+ /** Throws a `TestFailure` unless `condition` holds. */
7
+ export declare function expectThat(condition: boolean, message: string): void;
8
+ /** A value as a failure shows it: quoted, cut when very long. */
9
+ export declare function shown(value: unknown): string;
@@ -0,0 +1,20 @@
1
+ // What a check of `chapterjs test` throws when the bot did not do what the
2
+ // test expected: a plain sentence, shown with the file and the line of the
3
+ // scenario that asked.
4
+ /** An expectation of a test that the bot did not meet. */
5
+ export class TestFailure extends Error {
6
+ name = 'TestFailure';
7
+ constructor(message) {
8
+ super(message);
9
+ }
10
+ }
11
+ /** Throws a `TestFailure` unless `condition` holds. */
12
+ export function expectThat(condition, message) {
13
+ if (!condition)
14
+ throw new TestFailure(message);
15
+ }
16
+ /** A value as a failure shows it: quoted, cut when very long. */
17
+ export function shown(value) {
18
+ const text = typeof value === 'string' ? JSON.stringify(value) : String(value);
19
+ return text.length > 200 ? `${text.slice(0, 197)}..."` : text;
20
+ }
@@ -0,0 +1,59 @@
1
+ import { type IncomingHttpHeaders } from 'node:http';
2
+ /** A request the code under test sent to the fake Discord. */
3
+ export interface FakeDiscordRequest {
4
+ method: string;
5
+ /** The path without the API version: `/guilds/1/members`. */
6
+ path: string;
7
+ /** The query string parameters; repeated ones keep every value. */
8
+ query: Record<string, string[]>;
9
+ headers: IncomingHttpHeaders;
10
+ /**
11
+ * The JSON body. For a multipart request: the content of its
12
+ * `payload_json` part.
13
+ */
14
+ body: unknown;
15
+ /** The files of a multipart request, by form field name. */
16
+ files: Record<string, {
17
+ name: string;
18
+ type: string;
19
+ text: string;
20
+ }>;
21
+ }
22
+ /** What the fake Discord answers. */
23
+ export interface FakeDiscordResponse {
24
+ /** Default: 200, or 204 when there is no body. */
25
+ status?: number;
26
+ body?: unknown;
27
+ headers?: Record<string, string>;
28
+ /** Milliseconds to wait before answering. */
29
+ delay?: number;
30
+ }
31
+ export type FakeDiscordResponder = FakeDiscordResponse | ((request: FakeDiscordRequest) => FakeDiscordResponse | Promise<FakeDiscordResponse>);
32
+ export interface FakeDiscord {
33
+ /** The base URL to give to the code under test, without the version. */
34
+ url: string;
35
+ /** Every request received, in order. */
36
+ requests: FakeDiscordRequest[];
37
+ /**
38
+ * Sets the answer to a route. A list of answers is consumed one request
39
+ * at a time; the last one stays. The path has no version and no query.
40
+ */
41
+ on(method: string, path: string, ...responses: FakeDiscordResponder[]): void;
42
+ /** The requests received for one route. */
43
+ requestsTo(method: string, path: string): FakeDiscordRequest[];
44
+ /** Stops the server. */
45
+ close(): Promise<void>;
46
+ }
47
+ export interface FakeDiscordOptions {
48
+ /**
49
+ * Answers the routes that were not set with `on()`. Without it, they
50
+ * answer 404 with Discord's "Unknown" error shape.
51
+ */
52
+ fallback?: FakeDiscordResponder;
53
+ }
54
+ /** What Discord answers for a route that does not exist. */
55
+ export declare const NOT_FOUND: FakeDiscordResponse;
56
+ /**
57
+ * Starts the fake Discord. The caller closes it.
58
+ */
59
+ export declare function fakeDiscord(options?: FakeDiscordOptions): Promise<FakeDiscord>;
@@ -0,0 +1,120 @@
1
+ // A local HTTP server that plays the REST API of Discord: the real client
2
+ // of the framework runs against it. Answers are programmed per route, or
3
+ // given by a fallback for everything else; every request is recorded.
4
+ import { createServer } from 'node:http';
5
+ /** What Discord answers for a route that does not exist. */
6
+ export const NOT_FOUND = {
7
+ status: 404,
8
+ body: { message: '404: Not Found', code: 0 },
9
+ };
10
+ /**
11
+ * Starts the fake Discord. The caller closes it.
12
+ */
13
+ export async function fakeDiscord(options = {}) {
14
+ const routes = new Map();
15
+ const requests = [];
16
+ const server = createServer(async (req, res) => {
17
+ const chunks = [];
18
+ for await (const chunk of req)
19
+ chunks.push(chunk);
20
+ const raw = Buffer.concat(chunks);
21
+ const url = new URL(req.url ?? '/', 'http://localhost');
22
+ const path = url.pathname.replace(/^\/v\d+/, '');
23
+ const query = {};
24
+ for (const [key, value] of url.searchParams)
25
+ (query[key] ??= []).push(value);
26
+ let body;
27
+ const files = {};
28
+ const type = req.headers['content-type'] ?? '';
29
+ if (type.startsWith('multipart/form-data')) {
30
+ const form = await new Request('http://localhost', {
31
+ method: 'POST',
32
+ headers: { 'content-type': type },
33
+ body: raw,
34
+ }).formData();
35
+ for (const [field, value] of form) {
36
+ if (typeof value === 'string') {
37
+ if (field === 'payload_json')
38
+ body = JSON.parse(value);
39
+ }
40
+ else {
41
+ files[field] = {
42
+ name: value.name,
43
+ type: value.type,
44
+ text: await value.text(),
45
+ };
46
+ }
47
+ }
48
+ }
49
+ else if (raw.length > 0) {
50
+ const text = raw.toString('utf8');
51
+ try {
52
+ body = JSON.parse(text);
53
+ }
54
+ catch {
55
+ body = text;
56
+ }
57
+ }
58
+ const request = {
59
+ method: req.method ?? 'GET',
60
+ path,
61
+ query,
62
+ headers: req.headers,
63
+ body,
64
+ files,
65
+ };
66
+ requests.push(request);
67
+ const queue = routes.get(`${request.method} ${path}`);
68
+ let responder = options.fallback ?? NOT_FOUND;
69
+ if (queue && queue.length > 0) {
70
+ responder = queue.length > 1 ? queue.shift() : queue[0];
71
+ }
72
+ let response;
73
+ try {
74
+ response =
75
+ typeof responder === 'function' ? await responder(request) : responder;
76
+ }
77
+ catch (error) {
78
+ // A fallback that throws is a bug of the fake, not of the bot: say so.
79
+ response = {
80
+ status: 500,
81
+ body: {
82
+ message: `The fake Discord failed: ${error instanceof Error ? error.message : String(error)}`,
83
+ code: 0,
84
+ },
85
+ };
86
+ }
87
+ if (response.delay) {
88
+ await new Promise(resolve => setTimeout(resolve, response.delay));
89
+ }
90
+ const hasBody = response.body !== undefined;
91
+ const payload = !hasBody
92
+ ? ''
93
+ : typeof response.body === 'string'
94
+ ? response.body
95
+ : JSON.stringify(response.body);
96
+ res.writeHead(response.status ?? (hasBody ? 200 : 204), {
97
+ ...(hasBody && typeof response.body !== 'string'
98
+ ? { 'content-type': 'application/json' }
99
+ : {}),
100
+ ...response.headers,
101
+ });
102
+ res.end(payload);
103
+ });
104
+ await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
105
+ const { port } = server.address();
106
+ return {
107
+ url: `http://127.0.0.1:${port}`,
108
+ requests,
109
+ on(method, path, ...responses) {
110
+ routes.set(`${method} ${path}`, responses);
111
+ },
112
+ requestsTo(method, path) {
113
+ return requests.filter(request => request.method === method && request.path === path);
114
+ },
115
+ close: () => new Promise(resolve => {
116
+ server.closeAllConnections();
117
+ server.close(() => resolve());
118
+ }),
119
+ };
120
+ }
@@ -0,0 +1,59 @@
1
+ /** A payload exchanged with the gateway: `{ op, d, s, t }`. */
2
+ export interface FakeGatewayPayload {
3
+ op: number;
4
+ d?: unknown;
5
+ s?: number | null;
6
+ t?: string | null;
7
+ }
8
+ /** One WebSocket connection the code under test opened. */
9
+ export interface FakeGatewayConnection {
10
+ /** The query string of the URL it connected to (`v`, `encoding`...). */
11
+ query: Record<string, string>;
12
+ /** Every payload received from the code under test, in order. */
13
+ received: FakeGatewayPayload[];
14
+ /** Set once the connection is closed, with the code the client gave. */
15
+ closed: {
16
+ code: number | null;
17
+ } | null;
18
+ /** Sends any payload. */
19
+ send(payload: FakeGatewayPayload): void;
20
+ /** Sends a Dispatch (opcode 0) with the next sequence number. */
21
+ dispatch(type: string, data: unknown): void;
22
+ /** Closes the connection with a close code, as Discord does. */
23
+ close(code: number): void;
24
+ /** Drops the connection without a close frame (a network failure). */
25
+ drop(): void;
26
+ /** Resolves with the next (or an already received) payload that matches. */
27
+ waitFor(match: number | ((payload: FakeGatewayPayload) => boolean), timeout?: number): Promise<FakeGatewayPayload>;
28
+ /** Resolves when the connection is closed, with the client's close code. */
29
+ waitForClose(timeout?: number): Promise<number | null>;
30
+ }
31
+ /** How the fake gateway reacts; change it at any time. */
32
+ export interface FakeGatewayBehavior {
33
+ /** The `heartbeat_interval` of Hello, in ms; `null` sends no Hello. */
34
+ heartbeatInterval: number | null;
35
+ /** Whether heartbeats are acknowledged. */
36
+ ackHeartbeats: boolean;
37
+ /**
38
+ * Called for each Identify (opcode 2). By default answers with a Ready
39
+ * built from `ready`.
40
+ */
41
+ onIdentify: (connection: FakeGatewayConnection, data: unknown) => void;
42
+ /** Called for each Resume (opcode 6). By default answers Resumed. */
43
+ onResume: (connection: FakeGatewayConnection, data: unknown) => void;
44
+ /** Fields of the Ready event sent by the default `onIdentify`. */
45
+ ready: Record<string, unknown>;
46
+ }
47
+ export interface FakeGateway {
48
+ /** The URL to connect to: `ws://127.0.0.1:port`. */
49
+ url: string;
50
+ behavior: FakeGatewayBehavior;
51
+ /** Every connection received, in order. */
52
+ connections: FakeGatewayConnection[];
53
+ /** Resolves with the connection number `index` (0 is the first one). */
54
+ connection(index: number, timeout?: number): Promise<FakeGatewayConnection>;
55
+ /** Drops every connection and stops the server. */
56
+ close(): Promise<void>;
57
+ }
58
+ /** Starts the fake gateway. The caller closes it. */
59
+ export declare function fakeGateway(options?: Partial<FakeGatewayBehavior>): Promise<FakeGateway>;
@@ -0,0 +1,123 @@
1
+ // A local WebSocket server that plays the gateway of Discord: the real
2
+ // shard code of the framework connects to it. By default it says Hello,
3
+ // acknowledges heartbeats, answers Identify with Ready and Resume with
4
+ // Resumed; every part of that can be changed, and each connection driven
5
+ // by hand (dispatch an event, close with a code, drop the socket).
6
+ // https://docs.discord.com/developers/events/gateway
7
+ import { webSocketServer } from './websocket.js';
8
+ /** Starts the fake gateway. The caller closes it. */
9
+ export async function fakeGateway(options = {}) {
10
+ const connections = [];
11
+ const connectionWaiters = [];
12
+ let sessions = 0;
13
+ const behavior = {
14
+ heartbeatInterval: 45_000,
15
+ ackHeartbeats: true,
16
+ ready: {},
17
+ onIdentify(connection) {
18
+ connection.dispatch('READY', {
19
+ v: 10,
20
+ user: { id: '100000000000000002', username: 'bot', discriminator: '0' },
21
+ guilds: [],
22
+ session_id: `session-${++sessions}`,
23
+ resume_gateway_url: server.url,
24
+ application: { id: '100000000000000002', flags: 0 },
25
+ ...behavior.ready,
26
+ });
27
+ },
28
+ onResume(connection) {
29
+ connection.dispatch('RESUMED', {});
30
+ },
31
+ ...options,
32
+ };
33
+ const server = await webSocketServer((peer, _request, on) => {
34
+ let sequence = 0;
35
+ const waiters = [];
36
+ const connection = {
37
+ query: peer.query,
38
+ received: [],
39
+ get closed() {
40
+ return peer.closed;
41
+ },
42
+ send(payload) {
43
+ peer.sendText(JSON.stringify(payload));
44
+ },
45
+ dispatch(type, data) {
46
+ connection.send({ op: 0, t: type, s: ++sequence, d: data });
47
+ },
48
+ close: code => peer.close(code),
49
+ drop: () => peer.drop(),
50
+ waitFor(match, timeout = 2000) {
51
+ const test = typeof match === 'number'
52
+ ? (payload) => payload.op === match
53
+ : match;
54
+ const found = connection.received.find(test);
55
+ if (found)
56
+ return Promise.resolve(found);
57
+ return new Promise((resolve, reject) => {
58
+ const timer = setTimeout(() => reject(new Error(`The fake gateway waited ${timeout}ms for a payload that never came. Received: ${JSON.stringify(connection.received)}`)), timeout);
59
+ waiters.push({
60
+ match: test,
61
+ resolve: payload => {
62
+ clearTimeout(timer);
63
+ resolve(payload);
64
+ },
65
+ });
66
+ });
67
+ },
68
+ waitForClose: timeout => peer.waitForClose(timeout),
69
+ };
70
+ on.message(data => {
71
+ if (typeof data !== 'string')
72
+ return;
73
+ const payload = JSON.parse(data);
74
+ connection.received.push(payload);
75
+ for (let index = waiters.length - 1; index >= 0; index--) {
76
+ if (waiters[index].match(payload)) {
77
+ waiters.splice(index, 1)[0].resolve(payload);
78
+ }
79
+ }
80
+ if (payload.op === 1 && behavior.ackHeartbeats) {
81
+ connection.send({ op: 11 });
82
+ }
83
+ else if (payload.op === 2) {
84
+ behavior.onIdentify(connection, payload.d);
85
+ }
86
+ else if (payload.op === 6) {
87
+ behavior.onResume(connection, payload.d);
88
+ }
89
+ });
90
+ connections.push(connection);
91
+ for (const resolve of connectionWaiters.splice(0))
92
+ resolve();
93
+ if (behavior.heartbeatInterval !== null) {
94
+ connection.send({
95
+ op: 10,
96
+ d: { heartbeat_interval: behavior.heartbeatInterval },
97
+ });
98
+ }
99
+ });
100
+ return {
101
+ url: server.url,
102
+ behavior,
103
+ connections,
104
+ async connection(index, timeout = 2000) {
105
+ const deadline = Date.now() + timeout;
106
+ while (connections.length <= index) {
107
+ const left = deadline - Date.now();
108
+ if (left <= 0) {
109
+ throw new Error(`The fake gateway waited ${timeout}ms for connection ${index}: only ${connections.length} were opened.`);
110
+ }
111
+ await new Promise(resolve => {
112
+ const timer = setTimeout(resolve, left);
113
+ connectionWaiters.push(() => {
114
+ clearTimeout(timer);
115
+ resolve();
116
+ });
117
+ });
118
+ }
119
+ return connections[index];
120
+ },
121
+ close: () => server.close(),
122
+ };
123
+ }