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 +40 -1
- package/package.json +1 -1
- package/src/commands/registry.js +24 -3
- package/src/embed.js +13 -1
- package/src/server/connection.js +10 -3
- package/test/integration/embed.test.js +65 -0
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
|
|
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
package/src/commands/registry.js
CHANGED
|
@@ -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
|
|
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)
|
|
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
|
-
|
|
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
|
|
package/src/server/connection.js
CHANGED
|
@@ -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
|
});
|