resplite 1.2.14 → 1.2.18
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
CHANGED
|
@@ -63,14 +63,14 @@ const srv = await createRESPlite({
|
|
|
63
63
|
port: 6380,
|
|
64
64
|
db: './data.db',
|
|
65
65
|
hooks: {
|
|
66
|
-
onUnknownCommand({ command,
|
|
67
|
-
log.warn({ command,
|
|
66
|
+
onUnknownCommand({ command, argv, clientAddress }) {
|
|
67
|
+
log.warn({ command, argv, clientAddress }, 'unsupported command');
|
|
68
68
|
},
|
|
69
|
-
onCommandError({ command, error, clientAddress }) {
|
|
70
|
-
log.warn({ command, error, clientAddress }, 'command error');
|
|
69
|
+
onCommandError({ command, argv, error, clientAddress }) {
|
|
70
|
+
log.warn({ command, argv, error, clientAddress }, 'command error');
|
|
71
71
|
},
|
|
72
72
|
onSocketError({ error, clientAddress }) {
|
|
73
|
-
log.error({
|
|
73
|
+
log.error({ error, clientAddress }, 'connection error');
|
|
74
74
|
},
|
|
75
75
|
},
|
|
76
76
|
});
|
|
@@ -78,8 +78,8 @@ const srv = await createRESPlite({
|
|
|
78
78
|
|
|
79
79
|
Available hooks:
|
|
80
80
|
|
|
81
|
-
- `onUnknownCommand`: client sent a command not implemented by RESPLite, such as `SUBSCRIBE` or `PUBLISH`.
|
|
82
|
-
- `onCommandError`: a command failed because of wrong type, invalid args, or a handler error.
|
|
81
|
+
- `onUnknownCommand`: client sent a command not implemented by RESPLite, such as `SUBSCRIBE` or `PUBLISH`. Payload includes `argv` (full command line as strings, e.g. `['CLIENT','LIST']`) so you can log exactly what was sent.
|
|
82
|
+
- `onCommandError`: a command failed because of wrong type, invalid args, or a handler error. Payload includes `argv` for the full command line.
|
|
83
83
|
- `onSocketError`: the connection socket emitted an error, for example `ECONNRESET`.
|
|
84
84
|
|
|
85
85
|
If you want a tiny in-process smoke test that starts RESPLite and connects with the `redis` client in the same script, see [Minimal embedded example](#minimal-embedded-example) below.
|
package/package.json
CHANGED
package/src/commands/client.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* CLIENT - connection introspection (SETNAME, GETNAME, ID).
|
|
2
|
+
* CLIENT - connection introspection (SETNAME, GETNAME, ID) and SETINFO (no-op for client lib version).
|
|
3
3
|
* Requires connection context.
|
|
4
4
|
*/
|
|
5
5
|
|
|
@@ -31,5 +31,11 @@ export function handleClient(engine, args, context) {
|
|
|
31
31
|
}
|
|
32
32
|
return context.connectionId;
|
|
33
33
|
}
|
|
34
|
+
if (sub === 'SETINFO') {
|
|
35
|
+
if (args.length !== 3) {
|
|
36
|
+
return { error: 'ERR wrong number of arguments for \'CLIENT SETINFO\' command' };
|
|
37
|
+
}
|
|
38
|
+
return { simple: 'OK' };
|
|
39
|
+
}
|
|
34
40
|
return { error: 'ERR unknown subcommand or wrong number of arguments for \'CLIENT\'. Try CLIENT HELP.' };
|
|
35
41
|
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* COMMAND - introspection: list commands, count, or info for specific commands.
|
|
3
|
+
* Reply format compatible with Redis COMMAND (array of [name, arity, flags, firstKey, lastKey, step, acl_categories]).
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/** @type {Set<string>} Commands that modify data (write). */
|
|
7
|
+
const WRITE_COMMANDS = new Set([
|
|
8
|
+
'SET', 'MSET', 'DEL', 'UNLINK', 'EXPIRE', 'PEXPIRE', 'PERSIST', 'INCR', 'DECR', 'INCRBY', 'DECRBY',
|
|
9
|
+
'HSET', 'HDEL', 'HINCRBY', 'SADD', 'SREM', 'LPUSH', 'RPUSH', 'LPOP', 'RPOP', 'LREM', 'ZADD', 'ZREM',
|
|
10
|
+
'FT.CREATE', 'FT.ADD', 'FT.DEL', 'FT.SUGADD', 'FT.SUGDEL', 'CLIENT',
|
|
11
|
+
]);
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Build Redis-style command doc: [name, arity, flags, firstKey, lastKey, step, acl_categories].
|
|
15
|
+
* @param {string} name - Command name (lowercase for reply).
|
|
16
|
+
* @returns {Array<string|number|string[]>}
|
|
17
|
+
*/
|
|
18
|
+
function docFor(name) {
|
|
19
|
+
const lower = name.toLowerCase();
|
|
20
|
+
const flags = WRITE_COMMANDS.has(name) ? ['write', 'fast'] : ['readonly', 'fast'];
|
|
21
|
+
let arity = 2;
|
|
22
|
+
let firstKey = 1;
|
|
23
|
+
let lastKey = 1;
|
|
24
|
+
let step = 1;
|
|
25
|
+
if (['MGET', 'MSET', 'DEL', 'UNLINK', 'EXISTS', 'KEYS', 'SCAN', 'PING', 'ECHO', 'QUIT', 'TYPE', 'OBJECT', 'SQLITE.INFO', 'CACHE.INFO', 'MEMORY.INFO', 'COMMAND', 'MONITOR', 'CLIENT'].includes(name)) {
|
|
26
|
+
if (['PING', 'ECHO', 'QUIT', 'COMMAND', 'MONITOR'].includes(name)) {
|
|
27
|
+
firstKey = 0;
|
|
28
|
+
lastKey = 0;
|
|
29
|
+
step = 0;
|
|
30
|
+
arity = name === 'COMMAND' ? -1 : (name === 'ECHO' ? 2 : 1);
|
|
31
|
+
} else if (['MGET', 'EXISTS', 'KEYS', 'SCAN'].includes(name)) {
|
|
32
|
+
arity = -2;
|
|
33
|
+
lastKey = -1;
|
|
34
|
+
} else if (name === 'MSET') {
|
|
35
|
+
arity = -3;
|
|
36
|
+
lastKey = -1;
|
|
37
|
+
step = 2;
|
|
38
|
+
} else if (['DEL', 'UNLINK'].includes(name)) {
|
|
39
|
+
arity = -2;
|
|
40
|
+
lastKey = -1;
|
|
41
|
+
}
|
|
42
|
+
} else if (name.startsWith('FT.') || name.startsWith('SQLITE.') || name.startsWith('CACHE.') || name.startsWith('MEMORY.')) {
|
|
43
|
+
firstKey = 0;
|
|
44
|
+
lastKey = 0;
|
|
45
|
+
step = 0;
|
|
46
|
+
arity = -2;
|
|
47
|
+
} else if (['BLPOP', 'BRPOP'].includes(name)) {
|
|
48
|
+
arity = -3;
|
|
49
|
+
lastKey = -1;
|
|
50
|
+
step = 1;
|
|
51
|
+
} else if (['HMGET', 'HGETALL', 'HGET', 'HSET', 'HDEL', 'HEXISTS', 'HINCRBY', 'HLEN'].includes(name)) {
|
|
52
|
+
arity = (name === 'HGET' || name === 'HLEN' || name === 'HEXISTS') ? 3 : -3;
|
|
53
|
+
}
|
|
54
|
+
return [lower, arity, flags, firstKey, lastKey, step, []];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* @param {object} engine
|
|
59
|
+
* @param {Buffer[]} args - subcommand and optional names for INFO
|
|
60
|
+
* @param {{ getCommandNames?: () => string[] }} context
|
|
61
|
+
*/
|
|
62
|
+
export function handleCommand(engine, args, context) {
|
|
63
|
+
const allNames = context?.getCommandNames ? context.getCommandNames() : [];
|
|
64
|
+
const sub = (args && args.length > 0 && Buffer.isBuffer(args[0])) ? args[0].toString('utf8').toUpperCase() : '';
|
|
65
|
+
|
|
66
|
+
if (!sub || sub === '') {
|
|
67
|
+
const reply = allNames.map((n) => docFor(n));
|
|
68
|
+
return reply;
|
|
69
|
+
}
|
|
70
|
+
if (sub === 'COUNT') {
|
|
71
|
+
return allNames.length;
|
|
72
|
+
}
|
|
73
|
+
if (sub === 'INFO') {
|
|
74
|
+
const names = (args.slice(1) || []).map((b) => (Buffer.isBuffer(b) ? b.toString('utf8') : String(b)).toUpperCase());
|
|
75
|
+
const set = new Set(allNames);
|
|
76
|
+
const reply = names.map((n) => set.has(n) ? docFor(n) : null).filter((x) => x != null);
|
|
77
|
+
return reply;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return { error: 'ERR unknown subcommand or wrong number of arguments for \'COMMAND\'. Try COMMAND HELP.' };
|
|
81
|
+
}
|
package/src/commands/registry.js
CHANGED
|
@@ -72,6 +72,7 @@ import * as ftSugget from './ft-sugget.js';
|
|
|
72
72
|
import * as ftSugdel from './ft-sugdel.js';
|
|
73
73
|
import * as monitor from './monitor.js';
|
|
74
74
|
import * as client from './client.js';
|
|
75
|
+
import * as command from './command.js';
|
|
75
76
|
|
|
76
77
|
const HANDLERS = new Map([
|
|
77
78
|
['PING', (e, a) => ping.handlePing()],
|
|
@@ -143,6 +144,7 @@ const HANDLERS = new Map([
|
|
|
143
144
|
['FT.SUGDEL', (e, a) => ftSugdel.handleFtSugdel(e, a)],
|
|
144
145
|
['MONITOR', (e, a, ctx) => monitor.handleMonitor(a, ctx)],
|
|
145
146
|
['CLIENT', (e, a, ctx) => client.handleClient(e, a, ctx)],
|
|
147
|
+
['COMMAND', (e, a, ctx) => command.handleCommand(e, a, ctx)],
|
|
146
148
|
]);
|
|
147
149
|
|
|
148
150
|
/**
|
|
@@ -158,11 +160,14 @@ export function dispatch(engine, argv, context) {
|
|
|
158
160
|
}
|
|
159
161
|
const cmd = (Buffer.isBuffer(argv[0]) ? argv[0].toString('utf8') : String(argv[0])).toUpperCase();
|
|
160
162
|
const args = argv.slice(1);
|
|
163
|
+
const argvStrings = argv.map((b) => (Buffer.isBuffer(b) ? b.toString('utf8') : String(b)));
|
|
164
|
+
if (context) context.getCommandNames = () => Array.from(HANDLERS.keys());
|
|
161
165
|
const handler = HANDLERS.get(cmd);
|
|
162
166
|
if (!handler) {
|
|
163
167
|
context?.onUnknownCommand?.({
|
|
164
168
|
command: cmd,
|
|
165
169
|
argsCount: args.length,
|
|
170
|
+
argv: argvStrings ?? [cmd],
|
|
166
171
|
clientAddress: context.clientAddress ?? '',
|
|
167
172
|
connectionId: context.connectionId ?? 0,
|
|
168
173
|
});
|
|
@@ -175,6 +180,7 @@ export function dispatch(engine, argv, context) {
|
|
|
175
180
|
context?.onCommandError?.({
|
|
176
181
|
command: cmd,
|
|
177
182
|
error: result.error,
|
|
183
|
+
argv: argvStrings ?? [cmd],
|
|
178
184
|
clientAddress: context.clientAddress ?? '',
|
|
179
185
|
connectionId: context.connectionId ?? 0,
|
|
180
186
|
});
|
|
@@ -188,6 +194,7 @@ export function dispatch(engine, argv, context) {
|
|
|
188
194
|
context?.onCommandError?.({
|
|
189
195
|
command: cmd,
|
|
190
196
|
error: errorMsg,
|
|
197
|
+
argv: argvStrings ?? [cmd],
|
|
191
198
|
clientAddress: context.clientAddress ?? '',
|
|
192
199
|
connectionId: context.connectionId ?? 0,
|
|
193
200
|
});
|
package/src/embed.js
CHANGED
|
@@ -21,8 +21,8 @@ export { handleConnection, createEngine, openDb };
|
|
|
21
21
|
* All hooks are optional. Called with plain objects; do not mutate.
|
|
22
22
|
*
|
|
23
23
|
* @typedef {object} RESPliteHooks
|
|
24
|
-
* @property {(payload: { command: string, argsCount: number, clientAddress: string, connectionId: number }) => void} [onUnknownCommand] Invoked when the client sends a command not implemented by RESPLite.
|
|
25
|
-
* @property {(payload: { command: string, error: string, clientAddress: string, connectionId: number }) => void} [onCommandError] Invoked when a command handler throws or returns an error (e.g. WRONGTYPE, invalid args).
|
|
24
|
+
* @property {(payload: { command: string, argsCount: number, argv: string[], clientAddress: string, connectionId: number }) => void} [onUnknownCommand] Invoked when the client sends a command not implemented by RESPLite. `argv` is the full command line as strings (e.g. `['CLIENT','LIST']`) for logging.
|
|
25
|
+
* @property {(payload: { command: string, error: string, argv: string[], clientAddress: string, connectionId: number }) => void} [onCommandError] Invoked when a command handler throws or returns an error (e.g. WRONGTYPE, invalid args). `argv` is the full command line as strings for logging.
|
|
26
26
|
* @property {(payload: { error: Error, clientAddress: string, connectionId: number }) => void} [onSocketError] Invoked when a connection socket emits an error (e.g. ECONNRESET).
|
|
27
27
|
*/
|
|
28
28
|
|
|
@@ -87,6 +87,13 @@ describe('CLIENT integration', () => {
|
|
|
87
87
|
assert.equal(getResult.length, 0);
|
|
88
88
|
});
|
|
89
89
|
|
|
90
|
+
it('CLIENT SETINFO LIB-VER / LIB-NAME returns OK (no-op)', async () => {
|
|
91
|
+
const reply = await sendCommand(port, argv('CLIENT', 'SETINFO', 'LIB-VER', '1.5.17'));
|
|
92
|
+
assert.equal(tryParseValue(reply, 0).value, 'OK');
|
|
93
|
+
const reply2 = await sendCommand(port, argv('CLIENT', 'SETINFO', 'LIB-NAME', 'myclient'));
|
|
94
|
+
assert.equal(tryParseValue(reply2, 0).value, 'OK');
|
|
95
|
+
});
|
|
96
|
+
|
|
90
97
|
it('CLIENT wrong subcommand returns error', async () => {
|
|
91
98
|
const reply = await sendCommand(port, argv('CLIENT', 'LIST'));
|
|
92
99
|
const v = tryParseValue(reply, 0).value;
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Integration tests for COMMAND (introspection).
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { describe, it, before, after } from 'node:test';
|
|
6
|
+
import assert from 'node:assert/strict';
|
|
7
|
+
import { createTestServer } from '../helpers/server.js';
|
|
8
|
+
import { sendCommand, argv } from '../helpers/client.js';
|
|
9
|
+
import { tryParseValue } from '../../src/resp/parser.js';
|
|
10
|
+
|
|
11
|
+
describe('COMMAND integration', () => {
|
|
12
|
+
let s;
|
|
13
|
+
let port;
|
|
14
|
+
|
|
15
|
+
before(async () => {
|
|
16
|
+
s = await createTestServer();
|
|
17
|
+
port = s.port;
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
after(async () => {
|
|
21
|
+
await s.closeAsync();
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('COMMAND (no args) returns array of command docs', async () => {
|
|
25
|
+
const reply = await sendCommand(port, argv('COMMAND'));
|
|
26
|
+
const v = tryParseValue(reply, 0).value;
|
|
27
|
+
assert.ok(Array.isArray(v), 'expected array');
|
|
28
|
+
assert.ok(v.length > 0, 'expected at least one command');
|
|
29
|
+
const pingDoc = v.find((doc) => {
|
|
30
|
+
const n = Buffer.isBuffer(doc[0]) ? doc[0].toString('utf8') : doc[0];
|
|
31
|
+
return n === 'ping';
|
|
32
|
+
});
|
|
33
|
+
assert.ok(pingDoc, 'expected ping in command list');
|
|
34
|
+
const first = pingDoc;
|
|
35
|
+
assert.ok(Array.isArray(first), 'each doc is array');
|
|
36
|
+
const name = Buffer.isBuffer(first[0]) ? first[0].toString('utf8') : first[0];
|
|
37
|
+
assert.strictEqual(name, 'ping', 'first element is name (lowercase)');
|
|
38
|
+
assert.strictEqual(typeof first[1], 'number', 'arity');
|
|
39
|
+
assert.ok(Array.isArray(first[2]), 'flags');
|
|
40
|
+
const flags = first[2].map((f) => (Buffer.isBuffer(f) ? f.toString('utf8') : f));
|
|
41
|
+
assert.ok(flags.includes('readonly') || flags.includes('write'));
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('COMMAND COUNT returns integer', async () => {
|
|
45
|
+
const reply = await sendCommand(port, argv('COMMAND', 'COUNT'));
|
|
46
|
+
const v = tryParseValue(reply, 0).value;
|
|
47
|
+
assert.strictEqual(typeof v, 'number');
|
|
48
|
+
assert.ok(Number.isInteger(v) && v > 0);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('COMMAND INFO name returns doc for that command', async () => {
|
|
52
|
+
const reply = await sendCommand(port, argv('COMMAND', 'INFO', 'GET'));
|
|
53
|
+
const v = tryParseValue(reply, 0).value;
|
|
54
|
+
assert.ok(Array.isArray(v));
|
|
55
|
+
assert.ok(v.length >= 1);
|
|
56
|
+
const getDoc = v[0];
|
|
57
|
+
const name = Buffer.isBuffer(getDoc[0]) ? getDoc[0].toString('utf8') : getDoc[0];
|
|
58
|
+
assert.strictEqual(name, 'get');
|
|
59
|
+
assert.strictEqual(getDoc[1], 2, 'GET arity 2');
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('COMMAND INFO unknown returns empty array', async () => {
|
|
63
|
+
const reply = await sendCommand(port, argv('COMMAND', 'INFO', 'NOSUCHCOMMAND'));
|
|
64
|
+
const v = tryParseValue(reply, 0).value;
|
|
65
|
+
assert.ok(Array.isArray(v));
|
|
66
|
+
assert.strictEqual(v.length, 0);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('COMMAND unknown subcommand returns error', async () => {
|
|
70
|
+
const reply = await sendCommand(port, argv('COMMAND', 'NOSUCH'));
|
|
71
|
+
const v = tryParseValue(reply, 0).value;
|
|
72
|
+
assert.ok(v && v.error);
|
|
73
|
+
assert.match(v.error, /unknown subcommand|COMMAND/);
|
|
74
|
+
});
|
|
75
|
+
});
|