resplite 1.2.12 → 1.2.16

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, argsCount, clientAddress }) {
67
- log.warn({ command, argsCount, clientAddress }, 'unsupported 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({ err: error, clientAddress }, 'connection 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "resplite",
3
- "version": "1.2.12",
3
+ "version": "1.2.16",
4
4
  "description": "A RESP2 server with practical Redis compatibility, backed by SQLite",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -0,0 +1,35 @@
1
+ /**
2
+ * CLIENT - connection introspection (SETNAME, GETNAME, ID).
3
+ * Requires connection context.
4
+ */
5
+
6
+ function argStr(buf) {
7
+ return Buffer.isBuffer(buf) ? buf.toString('utf8') : String(buf);
8
+ }
9
+
10
+ export function handleClient(engine, args, context) {
11
+ if (!args || args.length < 1) {
12
+ return { error: 'ERR wrong number of arguments for \'CLIENT\' command' };
13
+ }
14
+ const sub = argStr(args[0]).toUpperCase();
15
+ if (sub === 'SETNAME') {
16
+ if (args.length !== 2) {
17
+ return { error: 'ERR wrong number of arguments for \'CLIENT SETNAME\' command' };
18
+ }
19
+ context.connectionName = argStr(args[1]);
20
+ return { simple: 'OK' };
21
+ }
22
+ if (sub === 'GETNAME') {
23
+ if (args.length !== 1) {
24
+ return { error: 'ERR wrong number of arguments for \'CLIENT GETNAME\' command' };
25
+ }
26
+ return context.connectionName ?? null;
27
+ }
28
+ if (sub === 'ID') {
29
+ if (args.length !== 1) {
30
+ return { error: 'ERR wrong number of arguments for \'CLIENT ID\' command' };
31
+ }
32
+ return context.connectionId;
33
+ }
34
+ return { error: 'ERR unknown subcommand or wrong number of arguments for \'CLIENT\'. Try CLIENT HELP.' };
35
+ }
@@ -9,6 +9,7 @@ import * as quit from './quit.js';
9
9
  import * as get from './get.js';
10
10
  import * as set from './set.js';
11
11
  import * as del from './del.js';
12
+ import * as unlink from './unlink.js';
12
13
  import * as exists from './exists.js';
13
14
  import * as type from './type.js';
14
15
  import * as object from './object.js';
@@ -70,6 +71,7 @@ import * as ftSugadd from './ft-sugadd.js';
70
71
  import * as ftSugget from './ft-sugget.js';
71
72
  import * as ftSugdel from './ft-sugdel.js';
72
73
  import * as monitor from './monitor.js';
74
+ import * as client from './client.js';
73
75
 
74
76
  const HANDLERS = new Map([
75
77
  ['PING', (e, a) => ping.handlePing()],
@@ -78,6 +80,7 @@ const HANDLERS = new Map([
78
80
  ['GET', (e, a) => get.handleGet(e, a)],
79
81
  ['SET', (e, a) => set.handleSet(e, a)],
80
82
  ['DEL', (e, a) => del.handleDel(e, a)],
83
+ ['UNLINK', (e, a) => unlink.handleUnlink(e, a)],
81
84
  ['EXISTS', (e, a) => exists.handleExists(e, a)],
82
85
  ['TYPE', (e, a) => type.handleType(e, a)],
83
86
  ['OBJECT', (e, a) => object.handleObject(e, a)],
@@ -139,6 +142,7 @@ const HANDLERS = new Map([
139
142
  ['FT.SUGGET', (e, a) => ftSugget.handleFtSugget(e, a)],
140
143
  ['FT.SUGDEL', (e, a) => ftSugdel.handleFtSugdel(e, a)],
141
144
  ['MONITOR', (e, a, ctx) => monitor.handleMonitor(a, ctx)],
145
+ ['CLIENT', (e, a, ctx) => client.handleClient(e, a, ctx)],
142
146
  ]);
143
147
 
144
148
  /**
@@ -154,11 +158,13 @@ export function dispatch(engine, argv, context) {
154
158
  }
155
159
  const cmd = (Buffer.isBuffer(argv[0]) ? argv[0].toString('utf8') : String(argv[0])).toUpperCase();
156
160
  const args = argv.slice(1);
161
+ const argvStrings = argv.map((b) => (Buffer.isBuffer(b) ? b.toString('utf8') : String(b)));
157
162
  const handler = HANDLERS.get(cmd);
158
163
  if (!handler) {
159
164
  context?.onUnknownCommand?.({
160
165
  command: cmd,
161
166
  argsCount: args.length,
167
+ argv: argvStrings ?? [cmd],
162
168
  clientAddress: context.clientAddress ?? '',
163
169
  connectionId: context.connectionId ?? 0,
164
170
  });
@@ -171,6 +177,7 @@ export function dispatch(engine, argv, context) {
171
177
  context?.onCommandError?.({
172
178
  command: cmd,
173
179
  error: result.error,
180
+ argv: argvStrings ?? [cmd],
174
181
  clientAddress: context.clientAddress ?? '',
175
182
  connectionId: context.connectionId ?? 0,
176
183
  });
@@ -184,6 +191,7 @@ export function dispatch(engine, argv, context) {
184
191
  context?.onCommandError?.({
185
192
  command: cmd,
186
193
  error: errorMsg,
194
+ argv: argvStrings ?? [cmd],
187
195
  clientAddress: context.clientAddress ?? '',
188
196
  connectionId: context.connectionId ?? 0,
189
197
  });
@@ -0,0 +1,12 @@
1
+ /**
2
+ * UNLINK key [key ...] - same as DEL; returns count of removed keys.
3
+ * In Redis, UNLINK is non-blocking; in RESPlite we delegate to DEL.
4
+ */
5
+
6
+ export function handleUnlink(engine, args) {
7
+ if (!args || args.length < 1) {
8
+ return { error: 'ERR wrong number of arguments for \'UNLINK\' command' };
9
+ }
10
+ const n = engine.del(args);
11
+ return n;
12
+ }
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
 
@@ -21,6 +21,7 @@ export function handleConnection(socket, engine, hooks = {}) {
21
21
  const context = {
22
22
  connectionId,
23
23
  clientAddress,
24
+ connectionName: null,
24
25
  monitorMode: false,
25
26
  writeResponse(buf) {
26
27
  if (socket.writable) socket.write(buf);
@@ -0,0 +1,103 @@
1
+ import { describe, it, before, after } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import net from 'node:net';
4
+ import { createTestServer } from '../helpers/server.js';
5
+ import { sendCommand, argv } from '../helpers/client.js';
6
+ import { encode } from '../../src/resp/encoder.js';
7
+ import { tryParseValue } from '../../src/resp/parser.js';
8
+
9
+ function sendTwoCommands(port, argv1, argv2) {
10
+ return new Promise((resolve, reject) => {
11
+ let recv = Buffer.alloc(0);
12
+ const results = [];
13
+ const socket = net.createConnection({ port, host: '127.0.0.1' }, () => {
14
+ socket.write(encode(argv1));
15
+ socket.write(encode(argv2));
16
+ });
17
+ const t = setTimeout(() => {
18
+ socket.destroy();
19
+ reject(new Error('sendTwoCommands timeout'));
20
+ }, 3000);
21
+ socket.on('data', (chunk) => {
22
+ recv = Buffer.concat([recv, chunk]);
23
+ while (results.length < 2) {
24
+ const parsed = tryParseValue(recv, 0);
25
+ if (parsed === null) break;
26
+ results.push(parsed.value);
27
+ recv = recv.subarray(parsed.end);
28
+ }
29
+ if (results.length === 2) {
30
+ clearTimeout(t);
31
+ socket.destroy();
32
+ resolve(results);
33
+ }
34
+ });
35
+ socket.on('error', (err) => {
36
+ clearTimeout(t);
37
+ reject(err);
38
+ });
39
+ });
40
+ }
41
+
42
+ describe('CLIENT integration', () => {
43
+ let s;
44
+ let port;
45
+
46
+ before(async () => {
47
+ s = await createTestServer();
48
+ port = s.port;
49
+ });
50
+
51
+ after(async () => {
52
+ await s.closeAsync();
53
+ });
54
+
55
+ it('CLIENT ID returns connection id', async () => {
56
+ const reply = await sendCommand(port, argv('CLIENT', 'ID'));
57
+ const v = tryParseValue(reply, 0).value;
58
+ assert.strictEqual(typeof v, 'number');
59
+ assert.ok(Number.isInteger(v) && v >= 1);
60
+ });
61
+
62
+ it('CLIENT GETNAME returns null when no name set', async () => {
63
+ const reply = await sendCommand(port, argv('CLIENT', 'GETNAME'));
64
+ const v = tryParseValue(reply, 0).value;
65
+ assert.strictEqual(v, null);
66
+ });
67
+
68
+ it('CLIENT SETNAME then GETNAME roundtrip', async () => {
69
+ const [setResult, getResult] = await sendTwoCommands(
70
+ port,
71
+ argv('CLIENT', 'SETNAME', 'my-conn'),
72
+ argv('CLIENT', 'GETNAME')
73
+ );
74
+ assert.equal(setResult, 'OK');
75
+ assert.ok(Buffer.isBuffer(getResult));
76
+ assert.equal(getResult.toString('utf8'), 'my-conn');
77
+ });
78
+
79
+ it('CLIENT SETNAME with empty name is allowed', async () => {
80
+ const [setResult, getResult] = await sendTwoCommands(
81
+ port,
82
+ argv('CLIENT', 'SETNAME', ''),
83
+ argv('CLIENT', 'GETNAME')
84
+ );
85
+ assert.equal(setResult, 'OK');
86
+ assert.ok(Buffer.isBuffer(getResult));
87
+ assert.equal(getResult.length, 0);
88
+ });
89
+
90
+ it('CLIENT wrong subcommand returns error', async () => {
91
+ const reply = await sendCommand(port, argv('CLIENT', 'LIST'));
92
+ const v = tryParseValue(reply, 0).value;
93
+ assert.ok(v && v.error);
94
+ assert.match(v.error, /unknown subcommand|CLIENT HELP/);
95
+ });
96
+
97
+ it('CLIENT without subcommand returns error', async () => {
98
+ const reply = await sendCommand(port, argv('CLIENT'));
99
+ const v = tryParseValue(reply, 0).value;
100
+ assert.ok(v && v.error);
101
+ assert.match(v.error, /wrong number of arguments/);
102
+ });
103
+ });
@@ -147,10 +147,10 @@ describe('createRESPlite', () => {
147
147
  } catch (_) {}
148
148
  await client.quit();
149
149
  await srv.close();
150
- assert.equal(errorCalls.length, 1);
151
- assert.equal(errorCalls[0].command, 'HGET');
152
- assert.ok(errorCalls[0].error.includes('WRONGTYPE'));
153
- assert.equal(typeof errorCalls[0].connectionId, 'number');
154
- assert.ok(errorCalls[0].clientAddress.length > 0);
150
+ const hgetError = errorCalls.find((c) => c.command === 'HGET');
151
+ assert.ok(hgetError, 'expected at least one HGET error (got: ' + errorCalls.map((c) => c.command).join(', ') + ')');
152
+ assert.ok(hgetError.error.includes('WRONGTYPE'));
153
+ assert.equal(typeof hgetError.connectionId, 'number');
154
+ assert.ok(hgetError.clientAddress.length > 0);
155
155
  });
156
156
  });
@@ -33,6 +33,17 @@ describe('Strings integration', () => {
33
33
  assert.equal(reply.toString('ascii'), ':1\r\n');
34
34
  });
35
35
 
36
+ it('UNLINK returns count like DEL', async () => {
37
+ await sendCommand(port, argv('SET', 'u1', 'a'));
38
+ await sendCommand(port, argv('SET', 'u2', 'b'));
39
+ const one = await sendCommand(port, argv('UNLINK', 'u1'));
40
+ assert.equal(one.toString('ascii'), ':1\r\n');
41
+ const two = await sendCommand(port, argv('UNLINK', 'u2', 'u3'));
42
+ assert.equal(two.toString('ascii'), ':1\r\n');
43
+ const zero = await sendCommand(port, argv('UNLINK', 'u1'));
44
+ assert.equal(zero.toString('ascii'), ':0\r\n');
45
+ });
46
+
36
47
  it('EXISTS returns count', async () => {
37
48
  await sendCommand(port, argv('SET', 'ex1', 'a'));
38
49
  const r = await sendCommand(port, argv('EXISTS', 'ex1', 'ex2'));