resplite 1.1.12 → 1.2.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 CHANGED
@@ -87,7 +87,7 @@ OK
87
87
 
88
88
  ### Standalone server script (fixed port)
89
89
 
90
- Run this as a persistent background process (`node server.js`). RESPLite will listen on port 6380 and stay up until the process is killed.
90
+ Run this as a persistent background process (`node server.js`). RESPLite will listen on port 6380 and stay up until the process receives SIGINT (Ctrl+C) or SIGTERM; then it closes the server and exits cleanly.
91
91
 
92
92
  ```javascript
93
93
  // server.js
@@ -95,6 +95,14 @@ import { createRESPlite } from 'resplite/embed';
95
95
 
96
96
  const srv = await createRESPlite({ port: 6380, db: './data.db' });
97
97
  console.log(`RESPLite listening on ${srv.host}:${srv.port}`);
98
+
99
+ async function shutdown() {
100
+ await srv.close();
101
+ process.exit(0);
102
+ }
103
+
104
+ process.on('SIGINT', shutdown);
105
+ process.on('SIGTERM', shutdown);
98
106
  ```
99
107
 
100
108
  Then connect from any other script or process:
@@ -142,6 +150,37 @@ await client.quit();
142
150
  await srv.close();
143
151
  ```
144
152
 
153
+ ### Observability (event hooks)
154
+
155
+ When embedding RESPLite you can pass optional hooks to log unknown commands, command errors, or socket errors (e.g. for `warn`/`error` in your logger). The client still receives the same RESP responses; hooks are for observability only.
156
+
157
+ ```javascript
158
+ import pino from 'pino';
159
+ const log = pino(); // or your logger
160
+
161
+ const srv = await createRESPlite({
162
+ port: 6380,
163
+ db: './data.db',
164
+ hooks: {
165
+ onUnknownCommand({ command, argsCount, clientAddress }) {
166
+ log.warn({ command, argsCount, clientAddress }, 'RESPLite: unsupported command');
167
+ },
168
+ onCommandError({ command, error, clientAddress }) {
169
+ log.warn({ command, error, clientAddress }, 'RESPLite: command error');
170
+ },
171
+ onSocketError({ error, clientAddress }) {
172
+ log.error({ err: error, clientAddress }, 'RESPLite: connection error');
173
+ },
174
+ },
175
+ });
176
+ ```
177
+
178
+ | Hook | When it is called |
179
+ |------|--------------------|
180
+ | `onUnknownCommand` | Client sent a command not implemented by RESPLite (e.g. `SUBSCRIBE`, `PUBLISH`). |
181
+ | `onCommandError` | A command failed (wrong type, invalid args, or handler threw). |
182
+ | `onSocketError` | The connection socket emitted an error (e.g. `ECONNRESET`). |
183
+
145
184
  ### Strings, TTL, and key operations
146
185
 
147
186
  ```javascript
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "resplite",
3
- "version": "1.1.12",
3
+ "version": "1.2.0",
4
4
  "description": "A RESP2 server with practical Redis compatibility, backed by SQLite",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -135,7 +135,7 @@ const HANDLERS = new Map([
135
135
  * Dispatch command. Full argv: [commandNameBuf, ...argBuffers].
136
136
  * @param {object} engine
137
137
  * @param {Buffer[]} argv - first element is command name, rest are arguments
138
- * @param {object} [context] - optional connection context (connectionId, writeResponse) for blocking commands
138
+ * @param {object} [context] - optional connection context (connectionId, clientAddress, writeResponse, onUnknownCommand, onCommandError)
139
139
  * @returns {{ result: unknown } | { error: string } | { quit: true } | { block: object }}
140
140
  */
141
141
  export function dispatch(engine, argv, context) {
@@ -146,17 +146,38 @@ export function dispatch(engine, argv, context) {
146
146
  const args = argv.slice(1);
147
147
  const handler = HANDLERS.get(cmd);
148
148
  if (!handler) {
149
+ context?.onUnknownCommand?.({
150
+ command: cmd,
151
+ argsCount: args.length,
152
+ clientAddress: context.clientAddress ?? '',
153
+ connectionId: context.connectionId ?? 0,
154
+ });
149
155
  return { error: unsupported() };
150
156
  }
151
157
  try {
152
158
  const result = handler(engine, args, context);
153
159
  if (result && result.quit) return result;
154
- if (result && result.error) return result;
160
+ if (result && result.error) {
161
+ context?.onCommandError?.({
162
+ command: cmd,
163
+ error: result.error,
164
+ clientAddress: context.clientAddress ?? '',
165
+ connectionId: context.connectionId ?? 0,
166
+ });
167
+ return result;
168
+ }
155
169
  if (result && result.block) return result;
156
170
  return { result };
157
171
  } catch (err) {
158
172
  const msg = err && err.message ? err.message : String(err);
159
- return { error: msg.startsWith('ERR ') ? msg : 'ERR ' + msg };
173
+ const errorMsg = msg.startsWith('ERR ') ? msg : 'ERR ' + msg;
174
+ context?.onCommandError?.({
175
+ command: cmd,
176
+ error: errorMsg,
177
+ clientAddress: context.clientAddress ?? '',
178
+ connectionId: context.connectionId ?? 0,
179
+ });
180
+ return { error: errorMsg };
160
181
  }
161
182
  }
162
183
 
package/src/embed.js CHANGED
@@ -16,6 +16,16 @@ import { openDb } from './storage/sqlite/db.js';
16
16
 
17
17
  export { handleConnection, createEngine, openDb };
18
18
 
19
+ /**
20
+ * Optional event hooks for observability (e.g. logging unknown commands or errors).
21
+ * All hooks are optional. Called with plain objects; do not mutate.
22
+ *
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).
26
+ * @property {(payload: { error: Error, clientAddress: string, connectionId: number }) => void} [onSocketError] Invoked when a connection socket emits an error (e.g. ECONNRESET).
27
+ */
28
+
19
29
  /**
20
30
  * Start an embedded RESPLite server.
21
31
  *
@@ -24,6 +34,7 @@ export { handleConnection, createEngine, openDb };
24
34
  * @param {string} [options.host='127.0.0.1'] Host to listen on.
25
35
  * @param {number} [options.port=0] Port to listen on (0 = OS-assigned).
26
36
  * @param {string} [options.pragmaTemplate='default'] PRAGMA preset (default|performance|safety|minimal|none).
37
+ * @param {RESPliteHooks} [options.hooks] Optional event hooks for observability (onUnknownCommand, onCommandError, onSocketError).
27
38
  * @returns {Promise<{ port: number, host: string, close: () => Promise<void> }>}
28
39
  */
29
40
  export async function createRESPlite({
@@ -31,6 +42,7 @@ export async function createRESPlite({
31
42
  host = '127.0.0.1',
32
43
  port = 0,
33
44
  pragmaTemplate = 'default',
45
+ hooks = {},
34
46
  } = {}) {
35
47
  const db = openDb(dbPath, { pragmaTemplate });
36
48
  const engine = createEngine({ db });
@@ -39,7 +51,7 @@ export async function createRESPlite({
39
51
  const server = net.createServer((socket) => {
40
52
  connections.add(socket);
41
53
  socket.once('close', () => connections.delete(socket));
42
- handleConnection(socket, engine);
54
+ handleConnection(socket, engine, hooks);
43
55
  });
44
56
  await new Promise((resolve) => server.listen(port, host, resolve));
45
57
 
@@ -12,17 +12,22 @@ let nextConnectionId = 0;
12
12
  /**
13
13
  * @param {import('net').Socket} socket
14
14
  * @param {object} engine
15
+ * @param {object} [hooks] Optional: onUnknownCommand, onCommandError, onSocketError
15
16
  */
16
- export function handleConnection(socket, engine) {
17
+ export function handleConnection(socket, engine, hooks = {}) {
17
18
  const reader = new RESPReader();
18
19
  const connectionId = ++nextConnectionId;
20
+ const clientAddress = `${socket.remoteAddress ?? 'unknown'}:${socket.remotePort ?? 0}`;
19
21
  const context = {
20
22
  connectionId,
23
+ clientAddress,
21
24
  monitorMode: false,
22
- clientAddress: `${socket.remoteAddress ?? 'unknown'}:${socket.remotePort ?? 0}`,
23
25
  writeResponse(buf) {
24
26
  if (socket.writable) socket.write(buf);
25
27
  },
28
+ onUnknownCommand: hooks.onUnknownCommand,
29
+ onCommandError: hooks.onCommandError,
30
+ onSocketError: hooks.onSocketError,
26
31
  };
27
32
 
28
33
  function writeResult(out) {
@@ -94,5 +99,7 @@ export function handleConnection(socket, engine) {
94
99
  unregisterMonitorClient(connectionId);
95
100
  });
96
101
 
97
- socket.on('error', () => {});
102
+ socket.on('error', (err) => {
103
+ context.onSocketError?.({ error: err, clientAddress: context.clientAddress, connectionId: context.connectionId });
104
+ });
98
105
  }
@@ -88,4 +88,69 @@ describe('createRESPlite', () => {
88
88
  await client.quit();
89
89
  await srv.close();
90
90
  });
91
+
92
+ it('unsupported command still returns ERR command not supported yet to client', async () => {
93
+ const srv = await createRESPlite();
94
+ const client = await redisClient(srv.port);
95
+ try {
96
+ await client.sendCommand(['SUBSCRIBE', 'ch']);
97
+ assert.fail('expected error');
98
+ } catch (e) {
99
+ assert.ok(e.message.includes('not supported'), e.message);
100
+ }
101
+ await client.quit();
102
+ await srv.close();
103
+ });
104
+
105
+ it('onUnknownCommand hook is called for unsupported commands', async () => {
106
+ const unknownCalls = [];
107
+ const srv = await createRESPlite({
108
+ hooks: {
109
+ onUnknownCommand(payload) {
110
+ unknownCalls.push(payload);
111
+ },
112
+ },
113
+ });
114
+ const client = await redisClient(srv.port);
115
+ try {
116
+ await client.sendCommand(['SUBSCRIBE', 'ch']);
117
+ } catch (_) {}
118
+ try {
119
+ await client.sendCommand(['PUBLISH', 'ch', 'x']);
120
+ } catch (_) {}
121
+ await client.quit();
122
+ await srv.close();
123
+ const commands = unknownCalls.map((c) => c.command);
124
+ assert.ok(commands.includes('SUBSCRIBE'), 'expected SUBSCRIBE in ' + commands.join(', '));
125
+ assert.ok(commands.includes('PUBLISH'), 'expected PUBLISH in ' + commands.join(', '));
126
+ const sub = unknownCalls.find((c) => c.command === 'SUBSCRIBE');
127
+ const pub = unknownCalls.find((c) => c.command === 'PUBLISH');
128
+ assert.equal(sub.argsCount, 1);
129
+ assert.equal(pub.argsCount, 2);
130
+ assert.equal(typeof sub.connectionId, 'number');
131
+ assert.ok(sub.clientAddress.length > 0);
132
+ });
133
+
134
+ it('onCommandError hook is called when command returns or throws error', async () => {
135
+ const errorCalls = [];
136
+ const srv = await createRESPlite({
137
+ hooks: {
138
+ onCommandError(payload) {
139
+ errorCalls.push(payload);
140
+ },
141
+ },
142
+ });
143
+ const client = await redisClient(srv.port);
144
+ await client.set('k', 'str');
145
+ try {
146
+ await client.hGet('k', 'f');
147
+ } catch (_) {}
148
+ await client.quit();
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);
155
+ });
91
156
  });