chapterjs 0.3.0 → 0.4.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.
- package/README.md +2 -2
- package/dist/cli/build.js +14 -1
- package/dist/cli/dev.js +29 -1
- package/dist/cli/env.d.ts +5 -0
- package/dist/cli/env.js +39 -3
- package/dist/cli/project.d.ts +30 -7
- package/dist/cli/project.js +85 -11
- package/dist/cli/redis.d.ts +23 -0
- package/dist/cli/redis.js +57 -0
- package/dist/cli/start.js +43 -17
- package/dist/cli/test.js +4 -2
- package/dist/events/registry.d.ts +252 -239
- package/dist/events/registry.js +125 -100
- package/dist/index.d.ts +4 -2
- package/dist/index.js +1 -0
- package/dist/ranking/declaration.d.ts +12 -0
- package/dist/ranking/declaration.js +48 -0
- package/dist/ranking/ranking.d.ts +102 -0
- package/dist/ranking/ranking.js +129 -0
- package/dist/redis/client.d.ts +36 -0
- package/dist/redis/client.js +252 -0
- package/dist/redis/protocol.d.ts +22 -0
- package/dist/redis/protocol.js +99 -0
- package/dist/redis/url.d.ts +24 -0
- package/dist/redis/url.js +50 -0
- package/dist/store/backend.d.ts +49 -4
- package/dist/store/backend.js +66 -11
- package/dist/store/declaration.d.ts +3 -0
- package/dist/store/declaration.js +8 -3
- package/dist/store/file-backend.d.ts +3 -1
- package/dist/store/file-backend.js +29 -0
- package/dist/store/ipc-backend.d.ts +24 -1
- package/dist/store/ipc-backend.js +49 -0
- package/dist/store/memory-backend.d.ts +4 -2
- package/dist/store/memory-backend.js +27 -1
- package/dist/store/ranking-backend.d.ts +45 -0
- package/dist/store/ranking-backend.js +82 -0
- package/dist/store/redis-backend.d.ts +38 -0
- package/dist/store/redis-backend.js +245 -0
- package/dist/store/redis-ranking.d.ts +15 -0
- package/dist/store/redis-ranking.js +69 -0
- package/dist/store/scope.d.ts +11 -0
- package/dist/store/scope.js +23 -0
- package/dist/store/store.d.ts +39 -5
- package/dist/store/store.js +79 -32
- package/dist/structures/invite.d.ts +6 -0
- package/dist/structures/invite.js +11 -0
- package/package.json +1 -1
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import type { Guild } from '../structures/guild.js';
|
|
2
|
+
import { type StoreStorage } from '../store/backend.js';
|
|
3
|
+
import { type RankingOrder } from '../store/ranking-backend.js';
|
|
4
|
+
import type { StoreScope } from '../store/store.js';
|
|
5
|
+
export type { RankingOrder };
|
|
6
|
+
/** What a file gives to `ranking()`. */
|
|
7
|
+
export interface RankingConfig {
|
|
8
|
+
/**
|
|
9
|
+
* `'guild'` (the default): a ranking per server, and a function that
|
|
10
|
+
* runs in a server reads and writes the ranking of that server, with
|
|
11
|
+
* nothing to say. `'global'`: one ranking for the whole bot.
|
|
12
|
+
*/
|
|
13
|
+
scope?: StoreScope;
|
|
14
|
+
/**
|
|
15
|
+
* Where the scores are kept: `'file'` (the default), a file in the
|
|
16
|
+
* `data/` folder of the project, or `'redis'`, the Redis of `REDIS_URL`
|
|
17
|
+
* in `.env`, where a ranking is a sorted set.
|
|
18
|
+
*/
|
|
19
|
+
storage?: StoreStorage;
|
|
20
|
+
/**
|
|
21
|
+
* Which end comes first: `'highest'` (the default), the highest score
|
|
22
|
+
* first, or `'lowest'`, the lowest first (a time, a number of tries).
|
|
23
|
+
*/
|
|
24
|
+
order?: RankingOrder;
|
|
25
|
+
}
|
|
26
|
+
/** A member of a ranking, as `top()` lists them. */
|
|
27
|
+
export interface RankingEntry {
|
|
28
|
+
/** The id given to `add()` or `set()`: a member, or anything else. */
|
|
29
|
+
id: string;
|
|
30
|
+
score: number;
|
|
31
|
+
/** Where it is, from 1. Equal scores are ordered by id. */
|
|
32
|
+
rank: number;
|
|
33
|
+
}
|
|
34
|
+
/** A ranking, for one server or for the whole bot. */
|
|
35
|
+
export interface ScopedRanking {
|
|
36
|
+
/**
|
|
37
|
+
* Adds `points` (negative to take away) to the score of `id`, starting
|
|
38
|
+
* from 0, in one go: nothing added meanwhile is lost, even by another
|
|
39
|
+
* process. Resolves with the new score.
|
|
40
|
+
*/
|
|
41
|
+
add(id: string, points: number): Promise<number>;
|
|
42
|
+
/** Sets the score of `id`, whatever it was. */
|
|
43
|
+
set(id: string, score: number): Promise<void>;
|
|
44
|
+
/** The score of `id`, or `undefined` when it has none. */
|
|
45
|
+
score(id: string): Promise<number | undefined>;
|
|
46
|
+
/** Where `id` is, from 1, or `undefined` when it has no score. */
|
|
47
|
+
rankOf(id: string): Promise<number | undefined>;
|
|
48
|
+
/**
|
|
49
|
+
* The first `count` (10 by default) members, after skipping `skip` (0
|
|
50
|
+
* by default): `top(10, 10)` is the second page.
|
|
51
|
+
*/
|
|
52
|
+
top(count?: number, skip?: number): Promise<RankingEntry[]>;
|
|
53
|
+
/** Forgets the score of `id`. `true` when it had one. */
|
|
54
|
+
remove(id: string): Promise<boolean>;
|
|
55
|
+
/** How many members have a score. */
|
|
56
|
+
size(): Promise<number>;
|
|
57
|
+
/** Forgets every score. Resolves with how many there were. */
|
|
58
|
+
clear(): Promise<number>;
|
|
59
|
+
}
|
|
60
|
+
/** What `ranking()` returns: a declaration the framework finds in the exports of a file. */
|
|
61
|
+
export interface Ranking extends ScopedRanking {
|
|
62
|
+
/**
|
|
63
|
+
* The same ranking, for one server: what a task, or a function that
|
|
64
|
+
* runs outside a server, uses to read and write the ranking of that
|
|
65
|
+
* server.
|
|
66
|
+
*/
|
|
67
|
+
in(guild: Guild | string): ScopedRanking;
|
|
68
|
+
}
|
|
69
|
+
/** A ranking as the loader knows it: its config, and once loaded, its name. */
|
|
70
|
+
export interface RankingState {
|
|
71
|
+
readonly config: unknown;
|
|
72
|
+
name?: string;
|
|
73
|
+
scope: StoreScope;
|
|
74
|
+
storage: StoreStorage;
|
|
75
|
+
order: RankingOrder;
|
|
76
|
+
}
|
|
77
|
+
export declare function rankingStateOf(value: unknown): RankingState | undefined;
|
|
78
|
+
export declare const isRankingDeclaration: (value: unknown) => boolean;
|
|
79
|
+
/** Binds a declared ranking to the name of its export. */
|
|
80
|
+
export declare function bindRanking(declared: unknown, bound: {
|
|
81
|
+
name: string;
|
|
82
|
+
scope: StoreScope;
|
|
83
|
+
storage: StoreStorage;
|
|
84
|
+
order: RankingOrder;
|
|
85
|
+
}): void;
|
|
86
|
+
/**
|
|
87
|
+
* Declares a ranking: a score per member, kept in order from one restart
|
|
88
|
+
* to the next. Export the result from any file of `src/`: the name of the
|
|
89
|
+
* export is the name of the ranking. Per server unless `scope: 'global'`
|
|
90
|
+
* is given, the highest score first unless `order: 'lowest'` is.
|
|
91
|
+
*
|
|
92
|
+
* ```ts
|
|
93
|
+
* import { ranking } from 'chapterjs';
|
|
94
|
+
*
|
|
95
|
+
* export const xp = ranking();
|
|
96
|
+
*
|
|
97
|
+
* // In an event of a message, in a server:
|
|
98
|
+
* await xp.add(message.author.id, 10);
|
|
99
|
+
* const best = await xp.top(10);
|
|
100
|
+
* ```
|
|
101
|
+
*/
|
|
102
|
+
export declare function ranking(config?: RankingConfig): Ranking;
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
// What a file declares with `ranking()`: scores per member, kept in order,
|
|
2
|
+
// per server by default, from one restart to the next. Named by its
|
|
3
|
+
// export, like a store: `export const xp = ranking()` is the ranking `xp`.
|
|
4
|
+
// Its scores live where its `storage` says, through the rankings of that
|
|
5
|
+
// backend (`store/ranking-backend.ts`): sorted sets in Redis, entries of
|
|
6
|
+
// the store backend anywhere else.
|
|
7
|
+
import { storeBackend } from '../store/backend.js';
|
|
8
|
+
import { rankingsOf, } from '../store/ranking-backend.js';
|
|
9
|
+
import { currentGuildId, guildIdOf, scopeParts } from '../store/scope.js';
|
|
10
|
+
const STATE = Symbol.for('chapterjs.ranking');
|
|
11
|
+
export function rankingStateOf(value) {
|
|
12
|
+
if (typeof value !== 'object' || value === null)
|
|
13
|
+
return undefined;
|
|
14
|
+
return value[STATE];
|
|
15
|
+
}
|
|
16
|
+
export const isRankingDeclaration = (value) => rankingStateOf(value) !== undefined;
|
|
17
|
+
/** Binds a declared ranking to the name of its export. */
|
|
18
|
+
export function bindRanking(declared, bound) {
|
|
19
|
+
const state = rankingStateOf(declared);
|
|
20
|
+
if (!state)
|
|
21
|
+
throw new TypeError('Not a ranking.');
|
|
22
|
+
Object.assign(state, bound);
|
|
23
|
+
}
|
|
24
|
+
const checkId = (id) => {
|
|
25
|
+
if (typeof id !== 'string' || id === '') {
|
|
26
|
+
throw new TypeError(`The id of a member of a ranking is a text that is not empty, got ${JSON.stringify(id) ?? typeof id}. Write an id as a text: member.id, not a number.`);
|
|
27
|
+
}
|
|
28
|
+
return id;
|
|
29
|
+
};
|
|
30
|
+
const checkScore = (value, what) => {
|
|
31
|
+
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
|
32
|
+
throw new TypeError(`${what} is a number, got ${JSON.stringify(value) ?? typeof value}.`);
|
|
33
|
+
}
|
|
34
|
+
return value;
|
|
35
|
+
};
|
|
36
|
+
const checkCount = (value, what) => {
|
|
37
|
+
if (!Number.isInteger(value) || value < 0) {
|
|
38
|
+
throw new TypeError(`${what} is a whole number, 0 or more, got ${JSON.stringify(value) ?? typeof value}.`);
|
|
39
|
+
}
|
|
40
|
+
return value;
|
|
41
|
+
};
|
|
42
|
+
/** The ranking as its functions see it: the backend, the name, the scope. */
|
|
43
|
+
function scoped(state, guildOf) {
|
|
44
|
+
const ready = () => {
|
|
45
|
+
if (!state.name) {
|
|
46
|
+
throw new Error('This ranking was not loaded by ChapterJS: a ranking is exported from a file of src/ (export const name = ranking()), and used once the bot started.');
|
|
47
|
+
}
|
|
48
|
+
return {
|
|
49
|
+
backend: rankingsOf(storeBackend(state.storage, state.name)),
|
|
50
|
+
name: state.name,
|
|
51
|
+
scope: scopeParts('ranking', state.name, state.scope, guildOf),
|
|
52
|
+
};
|
|
53
|
+
};
|
|
54
|
+
return {
|
|
55
|
+
async add(id, points) {
|
|
56
|
+
const { backend, name, scope } = ready();
|
|
57
|
+
return backend.add(name, scope, checkId(id), checkScore(points, 'What add() adds'));
|
|
58
|
+
},
|
|
59
|
+
async set(id, score) {
|
|
60
|
+
const { backend, name, scope } = ready();
|
|
61
|
+
await backend.set(name, scope, checkId(id), checkScore(score, 'The score set()'));
|
|
62
|
+
},
|
|
63
|
+
async score(id) {
|
|
64
|
+
const { backend, name, scope } = ready();
|
|
65
|
+
return backend.score(name, scope, checkId(id));
|
|
66
|
+
},
|
|
67
|
+
async rankOf(id) {
|
|
68
|
+
const { backend, name, scope } = ready();
|
|
69
|
+
const rank = await backend.rank(name, scope, checkId(id), state.order);
|
|
70
|
+
return rank === undefined ? undefined : rank + 1;
|
|
71
|
+
},
|
|
72
|
+
async top(count = 10, skip = 0) {
|
|
73
|
+
const { backend, name, scope } = ready();
|
|
74
|
+
checkCount(count, 'How many top() lists');
|
|
75
|
+
checkCount(skip, 'How many top() skips');
|
|
76
|
+
const ranked = await backend.range(name, scope, state.order, skip, count);
|
|
77
|
+
return ranked.map((entry, index) => ({
|
|
78
|
+
id: entry.id,
|
|
79
|
+
score: entry.score,
|
|
80
|
+
rank: skip + index + 1,
|
|
81
|
+
}));
|
|
82
|
+
},
|
|
83
|
+
async remove(id) {
|
|
84
|
+
const { backend, name, scope } = ready();
|
|
85
|
+
return backend.remove(name, scope, checkId(id));
|
|
86
|
+
},
|
|
87
|
+
async size() {
|
|
88
|
+
const { backend, name, scope } = ready();
|
|
89
|
+
return backend.size(name, scope);
|
|
90
|
+
},
|
|
91
|
+
async clear() {
|
|
92
|
+
const { backend, name, scope } = ready();
|
|
93
|
+
return backend.clear(name, scope);
|
|
94
|
+
},
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Declares a ranking: a score per member, kept in order from one restart
|
|
99
|
+
* to the next. Export the result from any file of `src/`: the name of the
|
|
100
|
+
* export is the name of the ranking. Per server unless `scope: 'global'`
|
|
101
|
+
* is given, the highest score first unless `order: 'lowest'` is.
|
|
102
|
+
*
|
|
103
|
+
* ```ts
|
|
104
|
+
* import { ranking } from 'chapterjs';
|
|
105
|
+
*
|
|
106
|
+
* export const xp = ranking();
|
|
107
|
+
*
|
|
108
|
+
* // In an event of a message, in a server:
|
|
109
|
+
* await xp.add(message.author.id, 10);
|
|
110
|
+
* const best = await xp.top(10);
|
|
111
|
+
* ```
|
|
112
|
+
*/
|
|
113
|
+
export function ranking(config = {}) {
|
|
114
|
+
const state = {
|
|
115
|
+
config,
|
|
116
|
+
scope: 'guild',
|
|
117
|
+
storage: 'file',
|
|
118
|
+
order: 'highest',
|
|
119
|
+
};
|
|
120
|
+
const declared = {
|
|
121
|
+
...scoped(state, () => currentGuildId() ?? null),
|
|
122
|
+
in: guild => {
|
|
123
|
+
const id = guildIdOf(guild);
|
|
124
|
+
return scoped(state, () => id);
|
|
125
|
+
},
|
|
126
|
+
};
|
|
127
|
+
Object.defineProperty(declared, STATE, { value: state });
|
|
128
|
+
return declared;
|
|
129
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { type RedisReply } from './protocol.js';
|
|
2
|
+
import { type RedisAddress } from './url.js';
|
|
3
|
+
export interface RedisClientOptions {
|
|
4
|
+
/** The connection could not be made, or was lost: said once per outage. */
|
|
5
|
+
onError?: (message: string) => void;
|
|
6
|
+
/** The connection is back after an outage. */
|
|
7
|
+
onReconnect?: () => void;
|
|
8
|
+
/** How long a connection attempt may take. */
|
|
9
|
+
connectTimeout?: number;
|
|
10
|
+
/** The first wait before trying again, doubled each time up to `maxRetryDelay`. */
|
|
11
|
+
retryDelay?: number;
|
|
12
|
+
maxRetryDelay?: number;
|
|
13
|
+
}
|
|
14
|
+
/** One connection to Redis, which only opens when a command needs it. */
|
|
15
|
+
export declare class RedisClient {
|
|
16
|
+
#private;
|
|
17
|
+
readonly address: RedisAddress;
|
|
18
|
+
constructor(address: RedisAddress, options?: RedisClientOptions);
|
|
19
|
+
/** `host:port` and the database, for messages. */
|
|
20
|
+
get where(): string;
|
|
21
|
+
/** Whether a command sent now goes out at once. */
|
|
22
|
+
get connected(): boolean;
|
|
23
|
+
/**
|
|
24
|
+
* Sends a command and resolves with its reply. An error reply (`-ERR`)
|
|
25
|
+
* rejects with a `RedisError`; a connection that fails or is lost
|
|
26
|
+
* rejects with what happened, in plain words.
|
|
27
|
+
*/
|
|
28
|
+
send(...args: readonly (string | number)[]): Promise<RedisReply>;
|
|
29
|
+
/**
|
|
30
|
+
* Makes sure Redis answers: the connection is opened, logged in and on
|
|
31
|
+
* the right database, or the reason it can't be is thrown.
|
|
32
|
+
*/
|
|
33
|
+
ping(): Promise<void>;
|
|
34
|
+
/** Ends the connection; every command sent from now on fails. */
|
|
35
|
+
close(): Promise<void>;
|
|
36
|
+
}
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
// The one connection of the process to Redis, on Node's `net` and `tls`
|
|
2
|
+
// and nothing else. Commands are sent in order and answered in order
|
|
3
|
+
// (RESP pipelines by itself), so every call is a promise of its reply.
|
|
4
|
+
// The connection is opened on the first command, logs in and picks the
|
|
5
|
+
// database by itself, and comes back alone when it was lost: a command
|
|
6
|
+
// sent meanwhile fails at once with why, instead of waiting for nobody.
|
|
7
|
+
// https://redis.io/docs/latest/commands/auth/ and /commands/select/
|
|
8
|
+
import { connect as connectTcp } from 'node:net';
|
|
9
|
+
import { connect as connectTls } from 'node:tls';
|
|
10
|
+
import { encodeCommand, RedisError, ReplyReader, } from './protocol.js';
|
|
11
|
+
import { describeRedis } from './url.js';
|
|
12
|
+
/** A thrown reason, in plain words, with what to do. */
|
|
13
|
+
function explain(address, error) {
|
|
14
|
+
const where = `Redis at ${describeRedis(address)}`;
|
|
15
|
+
// Already said in these words (a password refused during the handshake).
|
|
16
|
+
if (error instanceof Error && error.message.startsWith(where)) {
|
|
17
|
+
return error.message;
|
|
18
|
+
}
|
|
19
|
+
if (error instanceof RedisError) {
|
|
20
|
+
if (error.code === 'WRONGPASS') {
|
|
21
|
+
return `${where} refused the password: check REDIS_PASSWORD (or the password in REDIS_URL) in .env.`;
|
|
22
|
+
}
|
|
23
|
+
if (error.code === 'NOAUTH') {
|
|
24
|
+
return `${where} asks for a password: set REDIS_PASSWORD (or the password in REDIS_URL) in .env.`;
|
|
25
|
+
}
|
|
26
|
+
if (error.code === 'NOPERM') {
|
|
27
|
+
return `${where} refused the user: check the user in REDIS_URL, and what it may do.`;
|
|
28
|
+
}
|
|
29
|
+
return `${where} refused the connection: ${error.message}`;
|
|
30
|
+
}
|
|
31
|
+
const code = error?.code;
|
|
32
|
+
switch (code) {
|
|
33
|
+
case 'ECONNREFUSED':
|
|
34
|
+
return `${where} can't be reached: nothing answers there. Start Redis, or check REDIS_URL in .env.`;
|
|
35
|
+
case 'ENOTFOUND':
|
|
36
|
+
case 'EAI_AGAIN':
|
|
37
|
+
return `${where} can't be reached: the host is unknown. Check REDIS_URL in .env.`;
|
|
38
|
+
case 'ETIMEDOUT':
|
|
39
|
+
return `${where} can't be reached: it did not answer in time. Check REDIS_URL in .env, and that the machine lets you reach it.`;
|
|
40
|
+
case 'ECONNRESET':
|
|
41
|
+
case 'EPIPE':
|
|
42
|
+
return `${where} closed the connection.`;
|
|
43
|
+
default:
|
|
44
|
+
return `${where} can't be reached: ${error instanceof Error ? error.message : String(error)}`;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
/** One connection to Redis, which only opens when a command needs it. */
|
|
48
|
+
export class RedisClient {
|
|
49
|
+
address;
|
|
50
|
+
#options;
|
|
51
|
+
#socket = null;
|
|
52
|
+
#state = 'idle';
|
|
53
|
+
/** Commands sent, waiting for their reply, in order. */
|
|
54
|
+
#waiting = [];
|
|
55
|
+
/** Commands given while connecting, sent once the connection is ready. */
|
|
56
|
+
#queue = [];
|
|
57
|
+
/** Why the last attempt failed: what a command gets while waiting to retry. */
|
|
58
|
+
#outage = null;
|
|
59
|
+
#wasReady = false;
|
|
60
|
+
#attempts = 0;
|
|
61
|
+
#retryTimer = null;
|
|
62
|
+
constructor(address, options = {}) {
|
|
63
|
+
this.address = address;
|
|
64
|
+
this.#options = {
|
|
65
|
+
connectTimeout: 5000,
|
|
66
|
+
retryDelay: 1000,
|
|
67
|
+
maxRetryDelay: 30_000,
|
|
68
|
+
...options,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
/** `host:port` and the database, for messages. */
|
|
72
|
+
get where() {
|
|
73
|
+
return describeRedis(this.address);
|
|
74
|
+
}
|
|
75
|
+
/** Whether a command sent now goes out at once. */
|
|
76
|
+
get connected() {
|
|
77
|
+
return this.#state === 'ready';
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Sends a command and resolves with its reply. An error reply (`-ERR`)
|
|
81
|
+
* rejects with a `RedisError`; a connection that fails or is lost
|
|
82
|
+
* rejects with what happened, in plain words.
|
|
83
|
+
*/
|
|
84
|
+
send(...args) {
|
|
85
|
+
return new Promise((resolve, reject) => {
|
|
86
|
+
const pending = { resolve, reject };
|
|
87
|
+
switch (this.#state) {
|
|
88
|
+
case 'closed':
|
|
89
|
+
reject(new Error(`Redis at ${this.where} is closed: the bot stopped.`));
|
|
90
|
+
return;
|
|
91
|
+
case 'ready':
|
|
92
|
+
this.#write(args, pending);
|
|
93
|
+
return;
|
|
94
|
+
case 'connecting':
|
|
95
|
+
this.#queue.push({ args, pending });
|
|
96
|
+
return;
|
|
97
|
+
case 'idle':
|
|
98
|
+
if (this.#outage && this.#retryTimer) {
|
|
99
|
+
reject(new Error(this.#outage));
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
this.#queue.push({ args, pending });
|
|
103
|
+
this.#connect();
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Makes sure Redis answers: the connection is opened, logged in and on
|
|
109
|
+
* the right database, or the reason it can't be is thrown.
|
|
110
|
+
*/
|
|
111
|
+
async ping() {
|
|
112
|
+
const reply = await this.send('PING');
|
|
113
|
+
if (reply !== 'PONG') {
|
|
114
|
+
throw new Error(`Redis at ${this.where} answered ${JSON.stringify(reply)} to PING: is REDIS_URL the address of a Redis server?`);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
#write(args, pending) {
|
|
118
|
+
this.#waiting.push(pending);
|
|
119
|
+
this.#socket.write(encodeCommand(args));
|
|
120
|
+
}
|
|
121
|
+
#connect() {
|
|
122
|
+
this.#state = 'connecting';
|
|
123
|
+
const { host, port } = this.address;
|
|
124
|
+
const socket = this.address.tls
|
|
125
|
+
? connectTls({ host, port, servername: host })
|
|
126
|
+
: connectTcp({ host, port });
|
|
127
|
+
this.#socket = socket;
|
|
128
|
+
socket.setNoDelay(true);
|
|
129
|
+
const reader = new ReplyReader();
|
|
130
|
+
const timer = setTimeout(() => {
|
|
131
|
+
socket.destroy(Object.assign(new Error('timed out'), { code: 'ETIMEDOUT' }));
|
|
132
|
+
}, this.#options.connectTimeout);
|
|
133
|
+
// The end of this connection, whenever it comes: while connecting, or
|
|
134
|
+
// once it served. Told once: a destroyed socket says error, then close.
|
|
135
|
+
let ended = false;
|
|
136
|
+
const fail = (error) => {
|
|
137
|
+
clearTimeout(timer);
|
|
138
|
+
if (ended)
|
|
139
|
+
return;
|
|
140
|
+
ended = true;
|
|
141
|
+
if (socket !== this.#socket)
|
|
142
|
+
return;
|
|
143
|
+
this.#socket = null;
|
|
144
|
+
socket.destroy();
|
|
145
|
+
const message = explain(this.address, error);
|
|
146
|
+
// Everything sent or about to be sent fails: nobody is there.
|
|
147
|
+
const waiting = this.#waiting;
|
|
148
|
+
const queue = this.#queue;
|
|
149
|
+
this.#waiting = [];
|
|
150
|
+
this.#queue = [];
|
|
151
|
+
for (const pending of waiting)
|
|
152
|
+
pending.reject(new Error(message));
|
|
153
|
+
for (const { pending } of queue)
|
|
154
|
+
pending.reject(new Error(message));
|
|
155
|
+
if (this.#state === 'closed')
|
|
156
|
+
return;
|
|
157
|
+
this.#state = 'idle';
|
|
158
|
+
if (this.#outage === null) {
|
|
159
|
+
this.#options.onError?.(`${message} Trying again every few seconds.`);
|
|
160
|
+
}
|
|
161
|
+
const delay = Math.min(this.#options.retryDelay * 2 ** this.#attempts, this.#options.maxRetryDelay);
|
|
162
|
+
this.#attempts++;
|
|
163
|
+
this.#outage = `${message} Trying again in ${Math.round(delay / 1000) || 1} s.`;
|
|
164
|
+
this.#retryTimer = setTimeout(() => {
|
|
165
|
+
this.#retryTimer = null;
|
|
166
|
+
if (this.#state === 'idle')
|
|
167
|
+
this.#connect();
|
|
168
|
+
}, delay);
|
|
169
|
+
this.#retryTimer.unref();
|
|
170
|
+
};
|
|
171
|
+
socket.on('error', fail);
|
|
172
|
+
socket.on('close', () => fail(Object.assign(new Error('closed'), { code: 'ECONNRESET' })));
|
|
173
|
+
socket.on('data', chunk => {
|
|
174
|
+
reader.push(chunk);
|
|
175
|
+
try {
|
|
176
|
+
let reply;
|
|
177
|
+
while ((reply = reader.next()) !== undefined) {
|
|
178
|
+
const pending = this.#waiting.shift();
|
|
179
|
+
if (!pending)
|
|
180
|
+
continue;
|
|
181
|
+
if (reply instanceof RedisError) {
|
|
182
|
+
// A password refused, or missing, is about the connection, not
|
|
183
|
+
// the command: said in the words of .env.
|
|
184
|
+
pending.reject(reply.code === 'NOAUTH' || reply.code === 'WRONGPASS'
|
|
185
|
+
? new Error(explain(this.address, reply))
|
|
186
|
+
: reply);
|
|
187
|
+
}
|
|
188
|
+
else
|
|
189
|
+
pending.resolve(reply);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
catch (error) {
|
|
193
|
+
socket.destroy(error);
|
|
194
|
+
}
|
|
195
|
+
});
|
|
196
|
+
socket.once(this.address.tls ? 'secureConnect' : 'connect', () => {
|
|
197
|
+
this.#handshake(socket)
|
|
198
|
+
.then(() => {
|
|
199
|
+
clearTimeout(timer);
|
|
200
|
+
if (this.#state !== 'connecting' || socket !== this.#socket)
|
|
201
|
+
return;
|
|
202
|
+
this.#state = 'ready';
|
|
203
|
+
this.#attempts = 0;
|
|
204
|
+
const back = this.#outage !== null && this.#wasReady;
|
|
205
|
+
this.#outage = null;
|
|
206
|
+
this.#wasReady = true;
|
|
207
|
+
if (back)
|
|
208
|
+
this.#options.onReconnect?.();
|
|
209
|
+
const queue = this.#queue;
|
|
210
|
+
this.#queue = [];
|
|
211
|
+
for (const { args, pending } of queue)
|
|
212
|
+
this.#write(args, pending);
|
|
213
|
+
})
|
|
214
|
+
.catch(fail);
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
/** Logs in and picks the database, as the first commands of a connection. */
|
|
218
|
+
async #handshake(socket) {
|
|
219
|
+
const { username, password, db } = this.address;
|
|
220
|
+
const steps = [];
|
|
221
|
+
const ask = (...args) => {
|
|
222
|
+
steps.push(new Promise((resolve, reject) => {
|
|
223
|
+
this.#waiting.push({ resolve, reject });
|
|
224
|
+
socket.write(encodeCommand(args));
|
|
225
|
+
}));
|
|
226
|
+
};
|
|
227
|
+
if (password !== null) {
|
|
228
|
+
ask('AUTH', ...(username === null ? [password] : [username, password]));
|
|
229
|
+
}
|
|
230
|
+
if (db !== 0)
|
|
231
|
+
ask('SELECT', String(db));
|
|
232
|
+
await Promise.all(steps);
|
|
233
|
+
}
|
|
234
|
+
/** Ends the connection; every command sent from now on fails. */
|
|
235
|
+
async close() {
|
|
236
|
+
this.#state = 'closed';
|
|
237
|
+
if (this.#retryTimer) {
|
|
238
|
+
clearTimeout(this.#retryTimer);
|
|
239
|
+
this.#retryTimer = null;
|
|
240
|
+
}
|
|
241
|
+
const socket = this.#socket;
|
|
242
|
+
this.#socket = null;
|
|
243
|
+
const message = `Redis at ${this.where} is closed: the bot stopped.`;
|
|
244
|
+
for (const pending of this.#waiting)
|
|
245
|
+
pending.reject(new Error(message));
|
|
246
|
+
for (const { pending } of this.#queue)
|
|
247
|
+
pending.reject(new Error(message));
|
|
248
|
+
this.#waiting = [];
|
|
249
|
+
this.#queue = [];
|
|
250
|
+
socket?.destroy();
|
|
251
|
+
}
|
|
252
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/** What Redis answers: a text, a number, nothing, or a list of those. */
|
|
2
|
+
export type RedisReply = string | number | null | RedisReply[];
|
|
3
|
+
/** A `-ERR …` reply: Redis refused the command and says why. */
|
|
4
|
+
export declare class RedisError extends Error {
|
|
5
|
+
name: string;
|
|
6
|
+
/** The first word of the message: `ERR`, `WRONGPASS`, `NOAUTH`… */
|
|
7
|
+
readonly code: string;
|
|
8
|
+
constructor(message: string);
|
|
9
|
+
}
|
|
10
|
+
/** A command as Redis reads it: an array of bulk strings. */
|
|
11
|
+
export declare function encodeCommand(args: readonly (string | number)[]): Buffer;
|
|
12
|
+
/**
|
|
13
|
+
* Reads replies out of the bytes received so far. `push()` adds what
|
|
14
|
+
* arrived, `next()` gives the next whole reply, or `undefined` while a
|
|
15
|
+
* reply is still incomplete: what was read of it is kept for the next
|
|
16
|
+
* push, so a reply cut anywhere by the network is read whole.
|
|
17
|
+
*/
|
|
18
|
+
export declare class ReplyReader {
|
|
19
|
+
#private;
|
|
20
|
+
push(chunk: Buffer): void;
|
|
21
|
+
next(): RedisReply | undefined;
|
|
22
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// RESP, the protocol of Redis, on nothing but Buffers: how a command is
|
|
2
|
+
// written on the wire, and how what comes back is read, one reply at a
|
|
3
|
+
// time, from bytes that arrive in any number of pieces. RESP2 only: every
|
|
4
|
+
// Redis speaks it, and the few commands the framework sends need no more.
|
|
5
|
+
// https://redis.io/docs/latest/develop/reference/protocol-spec/
|
|
6
|
+
/** A `-ERR …` reply: Redis refused the command and says why. */
|
|
7
|
+
export class RedisError extends Error {
|
|
8
|
+
name = 'RedisError';
|
|
9
|
+
/** The first word of the message: `ERR`, `WRONGPASS`, `NOAUTH`… */
|
|
10
|
+
code;
|
|
11
|
+
constructor(message) {
|
|
12
|
+
super(message);
|
|
13
|
+
this.code = message.split(' ', 1)[0] ?? '';
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
const CRLF = '\r\n';
|
|
17
|
+
/** A command as Redis reads it: an array of bulk strings. */
|
|
18
|
+
export function encodeCommand(args) {
|
|
19
|
+
const parts = [Buffer.from(`*${args.length}${CRLF}`)];
|
|
20
|
+
for (const arg of args) {
|
|
21
|
+
const bytes = Buffer.from(String(arg));
|
|
22
|
+
parts.push(Buffer.from(`$${bytes.length}${CRLF}`), bytes, Buffer.from(CRLF));
|
|
23
|
+
}
|
|
24
|
+
return Buffer.concat(parts);
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Reads replies out of the bytes received so far. `push()` adds what
|
|
28
|
+
* arrived, `next()` gives the next whole reply, or `undefined` while a
|
|
29
|
+
* reply is still incomplete: what was read of it is kept for the next
|
|
30
|
+
* push, so a reply cut anywhere by the network is read whole.
|
|
31
|
+
*/
|
|
32
|
+
export class ReplyReader {
|
|
33
|
+
#buffer = Buffer.alloc(0);
|
|
34
|
+
push(chunk) {
|
|
35
|
+
this.#buffer =
|
|
36
|
+
this.#buffer.length === 0 ? chunk : Buffer.concat([this.#buffer, chunk]);
|
|
37
|
+
}
|
|
38
|
+
next() {
|
|
39
|
+
const read = this.#read(0);
|
|
40
|
+
if (!read)
|
|
41
|
+
return undefined;
|
|
42
|
+
this.#buffer = this.#buffer.subarray(read.end);
|
|
43
|
+
return read.reply;
|
|
44
|
+
}
|
|
45
|
+
/** A line ending in CRLF from `start`: its text and where the next starts. */
|
|
46
|
+
#line(start) {
|
|
47
|
+
const at = this.#buffer.indexOf(CRLF, start);
|
|
48
|
+
if (at === -1)
|
|
49
|
+
return null;
|
|
50
|
+
return { text: this.#buffer.toString('utf8', start, at), end: at + 2 };
|
|
51
|
+
}
|
|
52
|
+
#read(start) {
|
|
53
|
+
if (start >= this.#buffer.length)
|
|
54
|
+
return null;
|
|
55
|
+
const type = this.#buffer[start];
|
|
56
|
+
const line = this.#line(start + 1);
|
|
57
|
+
if (!line)
|
|
58
|
+
return null;
|
|
59
|
+
switch (type) {
|
|
60
|
+
case 0x2b: // +
|
|
61
|
+
return { reply: line.text, end: line.end };
|
|
62
|
+
case 0x2d: // -
|
|
63
|
+
return { reply: new RedisError(line.text), end: line.end };
|
|
64
|
+
case 0x3a: // :
|
|
65
|
+
return { reply: Number(line.text), end: line.end };
|
|
66
|
+
case 0x24: {
|
|
67
|
+
// $
|
|
68
|
+
const length = Number(line.text);
|
|
69
|
+
if (length === -1)
|
|
70
|
+
return { reply: null, end: line.end };
|
|
71
|
+
const end = line.end + length + 2;
|
|
72
|
+
if (this.#buffer.length < end)
|
|
73
|
+
return null;
|
|
74
|
+
return {
|
|
75
|
+
reply: this.#buffer.toString('utf8', line.end, line.end + length),
|
|
76
|
+
end,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
case 0x2a: {
|
|
80
|
+
// *
|
|
81
|
+
const count = Number(line.text);
|
|
82
|
+
if (count === -1)
|
|
83
|
+
return { reply: null, end: line.end };
|
|
84
|
+
const items = [];
|
|
85
|
+
let at = line.end;
|
|
86
|
+
for (let index = 0; index < count; index++) {
|
|
87
|
+
const item = this.#read(at);
|
|
88
|
+
if (!item)
|
|
89
|
+
return null;
|
|
90
|
+
items.push(item.reply);
|
|
91
|
+
at = item.end;
|
|
92
|
+
}
|
|
93
|
+
return { reply: items, end: at };
|
|
94
|
+
}
|
|
95
|
+
default:
|
|
96
|
+
throw new Error(`Redis answered something that is not RESP (a line starting with ${JSON.stringify(String.fromCharCode(type ?? 0))}): is REDIS_URL the address of a Redis server?`);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/** Where Redis is and how to connect: what the client is created with. */
|
|
2
|
+
export interface RedisAddress {
|
|
3
|
+
host: string;
|
|
4
|
+
port: number;
|
|
5
|
+
/** `rediss://`: the connection is encrypted with TLS. */
|
|
6
|
+
tls: boolean;
|
|
7
|
+
/** The user of the ACL, when the URL names one; the default user otherwise. */
|
|
8
|
+
username: string | null;
|
|
9
|
+
password: string | null;
|
|
10
|
+
/** The database index: `0` unless the URL ends with `/N`. */
|
|
11
|
+
db: number;
|
|
12
|
+
}
|
|
13
|
+
export declare const REDIS_URL_HELP = "Write it like redis://localhost:6379 (rediss:// for a TLS connection, /1 at the end for a database other than 0), with the user and password when there are some: redis://user:password@host:6379.";
|
|
14
|
+
/** `host:port`, and the database when it is not the first: what messages show. Never the password. */
|
|
15
|
+
export declare const describeRedis: (address: RedisAddress) => string;
|
|
16
|
+
/**
|
|
17
|
+
* Reads a `REDIS_URL`, with the `REDIS_PASSWORD` set beside it winning over
|
|
18
|
+
* the one of the URL. Answers what is wrong when it is not a Redis address.
|
|
19
|
+
*/
|
|
20
|
+
export declare function parseRedisUrl(text: string, password: string): {
|
|
21
|
+
address: RedisAddress;
|
|
22
|
+
} | {
|
|
23
|
+
problem: string;
|
|
24
|
+
};
|