rpc-express-toolkit 2.0.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/LICENSE +21 -0
- package/README.md +796 -0
- package/examples/example.js +41 -0
- package/examples/server-client-example.js +114 -0
- package/package.json +95 -0
- package/src/batch.js +237 -0
- package/src/clients/rpc-client.js +249 -0
- package/src/clients/rpc-client.min.js +1 -0
- package/src/clients/rpc-client.min.mjs +1 -0
- package/src/clients/rpc-client.mjs +275 -0
- package/src/index.d.ts +147 -0
- package/src/index.js +580 -0
- package/src/logger.js +153 -0
- package/src/middleware.js +224 -0
- package/src/validation.js +221 -0
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
const express = require('express');
|
|
2
|
+
const { RpcEndpoint } = require('../src/index');
|
|
3
|
+
|
|
4
|
+
const app = express();
|
|
5
|
+
|
|
6
|
+
app.use(express.json());
|
|
7
|
+
|
|
8
|
+
const context = { user: 'admin' };
|
|
9
|
+
|
|
10
|
+
// Attach to the app router at POST /api endpoint
|
|
11
|
+
const rpc = new RpcEndpoint(app, context);
|
|
12
|
+
|
|
13
|
+
rpc.addMethod('greet', (req, ctx, params) => {
|
|
14
|
+
const { name } = params;
|
|
15
|
+
return `Hello, ${name}!`;
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
rpc.addMethod('getTime', (req, ctx, params) => {
|
|
19
|
+
return new Date().toISOString();
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
rpc.addMethod('invalid-token', (req, ctx, params) => {
|
|
23
|
+
// Example: Validate the token from request headers
|
|
24
|
+
const token = req.headers.authorization;
|
|
25
|
+
if (!token || !isValidToken(token)) { // Replace `isValidToken` with your validation logic
|
|
26
|
+
throw new Error('Invalid or missing token');
|
|
27
|
+
}
|
|
28
|
+
return 'OK';
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
// Example token validation function (replace with your actual logic)
|
|
32
|
+
function isValidToken(token) {
|
|
33
|
+
// Simple example: Check if the token matches a predefined value
|
|
34
|
+
const expectedToken = 'my-secret-token'; // Replace with your actual token handling logic
|
|
35
|
+
return token === expectedToken;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const PORT = 3000;
|
|
39
|
+
app.listen(PORT, () => {
|
|
40
|
+
console.log(`Server listening on port ${PORT}`);
|
|
41
|
+
});
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
const express = require('express');
|
|
2
|
+
const { RpcEndpoint, RpcClient } = require('../src/index');
|
|
3
|
+
|
|
4
|
+
// === SERVER SETUP ===
|
|
5
|
+
const app = express();
|
|
6
|
+
app.use(express.json());
|
|
7
|
+
|
|
8
|
+
const context = {
|
|
9
|
+
database: new Map(),
|
|
10
|
+
userId: 'system'
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
// Create JSON-RPC endpoint with clean names
|
|
14
|
+
const rpc = new RpcEndpoint(app, context);
|
|
15
|
+
|
|
16
|
+
// Add some methods
|
|
17
|
+
rpc.addMethod('saveData', (req, ctx, params) => {
|
|
18
|
+
const { key, value } = params;
|
|
19
|
+
ctx.database.set(key, value);
|
|
20
|
+
return { success: true, message: `Data saved with key: ${key}` };
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
rpc.addMethod('getData', (req, ctx, params) => {
|
|
24
|
+
const { key } = params;
|
|
25
|
+
const value = ctx.database.get(key);
|
|
26
|
+
if (value === undefined) {
|
|
27
|
+
throw new Error(`Key '${key}' not found`);
|
|
28
|
+
}
|
|
29
|
+
return { key, value };
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
rpc.addMethod('getAllData', (req, ctx) => {
|
|
33
|
+
return Array.from(ctx.database.entries()).map(([key, value]) => ({ key, value }));
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
rpc.addMethod('testBigInt', (req, ctx, params) => {
|
|
37
|
+
const bigNumber = BigInt(params.number || '123456789012345678901234567890');
|
|
38
|
+
return {
|
|
39
|
+
original: params.number,
|
|
40
|
+
bigint: bigNumber,
|
|
41
|
+
doubled: bigNumber * 2n,
|
|
42
|
+
timestamp: new Date()
|
|
43
|
+
};
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
// Start server
|
|
47
|
+
const PORT = process.env.PORT || 3000;
|
|
48
|
+
const server = app.listen(PORT, () => {
|
|
49
|
+
console.log(`š JSON-RPC Server running on http://localhost:${PORT}`);
|
|
50
|
+
console.log(`š” Endpoint: http://localhost:${PORT}/api`);
|
|
51
|
+
|
|
52
|
+
// === CLIENT DEMO ===
|
|
53
|
+
setTimeout(async () => {
|
|
54
|
+
console.log('\nš§ Testing client...');
|
|
55
|
+
|
|
56
|
+
const client = new RpcClient(`http://localhost:${PORT}/api`);
|
|
57
|
+
|
|
58
|
+
try {
|
|
59
|
+
// Test 1: Save some data
|
|
60
|
+
console.log('š Saving data...');
|
|
61
|
+
const saveResult = await client.call('saveData', { key: 'test', value: 'Hello World!' });
|
|
62
|
+
console.log('ā
Save result:', saveResult);
|
|
63
|
+
|
|
64
|
+
// Test 2: Get data back
|
|
65
|
+
console.log('\nš Getting data...');
|
|
66
|
+
const getData = await client.call('getData', { key: 'test' });
|
|
67
|
+
console.log('ā
Get result:', getData);
|
|
68
|
+
|
|
69
|
+
// Test 3: Test BigInt handling
|
|
70
|
+
console.log('\nš¢ Testing BigInt...');
|
|
71
|
+
const bigIntResult = await client.call('testBigInt', { number: '999999999999999999999999999999' });
|
|
72
|
+
console.log('ā
BigInt result:', bigIntResult);
|
|
73
|
+
console.log(' - Original BigInt type:', typeof bigIntResult.bigint);
|
|
74
|
+
console.log(' - Doubled BigInt type:', typeof bigIntResult.doubled);
|
|
75
|
+
console.log(' - Timestamp type:', typeof bigIntResult.timestamp);
|
|
76
|
+
|
|
77
|
+
// Test 4: Get all data
|
|
78
|
+
console.log('\nš Getting all data...');
|
|
79
|
+
const allData = await client.call('getAllData');
|
|
80
|
+
console.log('ā
All data:', allData);
|
|
81
|
+
|
|
82
|
+
// Test 5: Test error handling
|
|
83
|
+
console.log('\nā Testing error handling...');
|
|
84
|
+
try {
|
|
85
|
+
await client.call('getData', { key: 'nonexistent' });
|
|
86
|
+
} catch (error) {
|
|
87
|
+
console.log('ā
Error caught correctly:', error.message);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
console.log('\n⨠All tests completed successfully!');
|
|
91
|
+
|
|
92
|
+
} catch (error) {
|
|
93
|
+
console.error('ā Client error:', error);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Close server after demo
|
|
97
|
+
setTimeout(() => {
|
|
98
|
+
server.close(() => {
|
|
99
|
+
console.log('\nš Server closed');
|
|
100
|
+
process.exit(0);
|
|
101
|
+
});
|
|
102
|
+
}, 1000);
|
|
103
|
+
|
|
104
|
+
}, 1000);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
// Handle graceful shutdown
|
|
108
|
+
process.on('SIGINT', () => {
|
|
109
|
+
console.log('\nš Shutting down server...');
|
|
110
|
+
server.close(() => {
|
|
111
|
+
console.log('š Server closed');
|
|
112
|
+
process.exit(0);
|
|
113
|
+
});
|
|
114
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "rpc-express-toolkit",
|
|
3
|
+
"version": "2.0.0",
|
|
4
|
+
"description": "Enterprise-ready JSON-RPC 2.0 toolkit for Express.js with simplified APIs, structured logging, middleware system, schema validation, and comprehensive BigInt/Date support.",
|
|
5
|
+
"main": "src/index.js",
|
|
6
|
+
"types": "src/index.d.ts",
|
|
7
|
+
"files": [
|
|
8
|
+
"src/",
|
|
9
|
+
"examples/",
|
|
10
|
+
"README.md",
|
|
11
|
+
"LICENSE"
|
|
12
|
+
],
|
|
13
|
+
"scripts": {
|
|
14
|
+
"test": "jest",
|
|
15
|
+
"lint": "eslint src/ examples/",
|
|
16
|
+
"lint:fix": "eslint src/ examples/ --fix",
|
|
17
|
+
"clean": "node -e \"require('fs').existsSync('dist') && require('fs').rmSync('dist', {recursive: true})\"",
|
|
18
|
+
"build": "npm run build:minify",
|
|
19
|
+
"build:minify": "npm run build:js && npm run build:mjs",
|
|
20
|
+
"build:js": "terser src/clients/rpc-client.js -o src/clients/rpc-client.min.js --compress --mangle --comments false",
|
|
21
|
+
"build:mjs": "terser src/clients/rpc-client.mjs -o src/clients/rpc-client.min.mjs --compress --mangle --comments false --module",
|
|
22
|
+
"prepublishOnly": "npm run build",
|
|
23
|
+
"precommit": "npm run lint && npm test"
|
|
24
|
+
},
|
|
25
|
+
"repository": {
|
|
26
|
+
"type": "git",
|
|
27
|
+
"url": "git+https://github.com/n-car/rpc-express-toolkit.git"
|
|
28
|
+
},
|
|
29
|
+
"keywords": [
|
|
30
|
+
"json-rpc",
|
|
31
|
+
"express",
|
|
32
|
+
"toolkit",
|
|
33
|
+
"rpc",
|
|
34
|
+
"server",
|
|
35
|
+
"client",
|
|
36
|
+
"middleware",
|
|
37
|
+
"validation",
|
|
38
|
+
"bigint",
|
|
39
|
+
"enterprise",
|
|
40
|
+
"nodejs"
|
|
41
|
+
],
|
|
42
|
+
"author": "Nicola Carpanese <nicola.carpanese@gmail.com>",
|
|
43
|
+
"license": "MIT",
|
|
44
|
+
"bugs": {
|
|
45
|
+
"url": "https://github.com/n-car/rpc-express-toolkit/issues"
|
|
46
|
+
},
|
|
47
|
+
"homepage": "https://github.com/n-car/rpc-express-toolkit#readme",
|
|
48
|
+
"engines": {
|
|
49
|
+
"node": ">=14.0.0"
|
|
50
|
+
},
|
|
51
|
+
"devDependencies": {
|
|
52
|
+
"eslint": "^8.57.1",
|
|
53
|
+
"eslint-config-airbnb-base": "^15.0.0",
|
|
54
|
+
"eslint-config-prettier": "^8.8.0",
|
|
55
|
+
"eslint-plugin-import": "^2.31.0",
|
|
56
|
+
"eslint-plugin-prettier": "^4.2.1",
|
|
57
|
+
"husky": "^8.0.0",
|
|
58
|
+
"jest": "^29.7.0",
|
|
59
|
+
"lint-staged": "^13.0.0",
|
|
60
|
+
"prettier": "^3.4.2",
|
|
61
|
+
"supertest": "^6.3.3",
|
|
62
|
+
"terser": "^5.43.1"
|
|
63
|
+
},
|
|
64
|
+
"dependencies": {
|
|
65
|
+
"ajv": "^8.12.0",
|
|
66
|
+
"ajv-formats": "^2.1.1",
|
|
67
|
+
"express": "^4.21.2",
|
|
68
|
+
"nested-error-stacks": "^2.1.1"
|
|
69
|
+
},
|
|
70
|
+
"optionalDependencies": {
|
|
71
|
+
"node-fetch": "^2.7.0"
|
|
72
|
+
},
|
|
73
|
+
"contributors": [
|
|
74
|
+
{
|
|
75
|
+
"name": "Nicola Carpanese",
|
|
76
|
+
"email": "nicola.carpanese@gmail.com",
|
|
77
|
+
"url": "https://github.com/n-car"
|
|
78
|
+
}
|
|
79
|
+
],
|
|
80
|
+
"husky": {
|
|
81
|
+
"hooks": {
|
|
82
|
+
"pre-commit": "lint-staged"
|
|
83
|
+
}
|
|
84
|
+
},
|
|
85
|
+
"lint-staged": {
|
|
86
|
+
"src/**/*.js": [
|
|
87
|
+
"eslint --fix",
|
|
88
|
+
"prettier --write"
|
|
89
|
+
],
|
|
90
|
+
"examples/**/*.js": [
|
|
91
|
+
"eslint --fix",
|
|
92
|
+
"prettier --write"
|
|
93
|
+
]
|
|
94
|
+
}
|
|
95
|
+
}
|
package/src/batch.js
ADDED
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Batch request handler for JSON-RPC 2.0
|
|
5
|
+
*/
|
|
6
|
+
class BatchHandler {
|
|
7
|
+
constructor(endpoint) {
|
|
8
|
+
this.endpoint = endpoint;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Check if request is a batch request
|
|
13
|
+
* @param {any} body
|
|
14
|
+
* @returns {boolean}
|
|
15
|
+
*/
|
|
16
|
+
isBatchRequest(body) {
|
|
17
|
+
return Array.isArray(body) && body.length > 0;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Validate batch request
|
|
22
|
+
* @param {Array} batch
|
|
23
|
+
* @returns {Object}
|
|
24
|
+
*/
|
|
25
|
+
validateBatch(batch) {
|
|
26
|
+
if (!Array.isArray(batch)) {
|
|
27
|
+
return {
|
|
28
|
+
valid: false,
|
|
29
|
+
error: { code: -32600, message: 'Invalid Request: Batch must be an array' }
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (batch.length === 0) {
|
|
34
|
+
return {
|
|
35
|
+
valid: false,
|
|
36
|
+
error: { code: -32600, message: 'Invalid Request: Batch cannot be empty' }
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Check for duplicate IDs in the batch
|
|
41
|
+
const ids = batch.map(req => req.id).filter(id => id !== undefined && id !== null);
|
|
42
|
+
const uniqueIds = new Set(ids);
|
|
43
|
+
|
|
44
|
+
if (ids.length !== uniqueIds.size) {
|
|
45
|
+
return {
|
|
46
|
+
valid: false,
|
|
47
|
+
error: { code: -32600, message: 'Invalid Request: Duplicate IDs in batch' }
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return { valid: true };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Process a batch of JSON-RPC requests
|
|
56
|
+
* @param {Array} batch
|
|
57
|
+
* @param {Object} req
|
|
58
|
+
* @param {Object} res
|
|
59
|
+
* @param {any} context
|
|
60
|
+
* @returns {Promise<Array>}
|
|
61
|
+
*/
|
|
62
|
+
async processBatch(batch, req, res, context) {
|
|
63
|
+
const validation = this.validateBatch(batch);
|
|
64
|
+
if (!validation.valid) {
|
|
65
|
+
return [{
|
|
66
|
+
jsonrpc: '2.0',
|
|
67
|
+
id: null,
|
|
68
|
+
error: validation.error
|
|
69
|
+
}];
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Process all requests in parallel
|
|
73
|
+
const promises = batch.map(async (request, index) => {
|
|
74
|
+
try {
|
|
75
|
+
return await this.processSingleRequest(request, req, context, index);
|
|
76
|
+
} catch (error) {
|
|
77
|
+
return {
|
|
78
|
+
jsonrpc: '2.0',
|
|
79
|
+
id: request.id || null,
|
|
80
|
+
error: {
|
|
81
|
+
code: error.code || -32603,
|
|
82
|
+
message: error.message || 'Internal error',
|
|
83
|
+
data: { batchIndex: index }
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
const results = await Promise.all(promises);
|
|
90
|
+
|
|
91
|
+
// Filter out notifications (requests without id)
|
|
92
|
+
return results.filter(result => result !== null);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Process a single request within a batch
|
|
97
|
+
* @param {Object} request
|
|
98
|
+
* @param {Object} req
|
|
99
|
+
* @param {any} context
|
|
100
|
+
* @param {number} batchIndex
|
|
101
|
+
* @returns {Promise<Object|null>}
|
|
102
|
+
*/
|
|
103
|
+
async processSingleRequest(request, req, context, batchIndex) {
|
|
104
|
+
const { jsonrpc, method, params, id } = request;
|
|
105
|
+
|
|
106
|
+
// Validate JSON-RPC 2.0 request structure
|
|
107
|
+
if (jsonrpc !== "2.0") {
|
|
108
|
+
return {
|
|
109
|
+
jsonrpc: '2.0',
|
|
110
|
+
id: id || null,
|
|
111
|
+
error: {
|
|
112
|
+
code: -32600,
|
|
113
|
+
message: `Invalid Request: 'jsonrpc' must be '2.0'`,
|
|
114
|
+
data: { batchIndex }
|
|
115
|
+
}
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (typeof method !== "string") {
|
|
120
|
+
return {
|
|
121
|
+
jsonrpc: '2.0',
|
|
122
|
+
id: id || null,
|
|
123
|
+
error: {
|
|
124
|
+
code: -32600,
|
|
125
|
+
message: `Invalid Request: 'method' must be a string`,
|
|
126
|
+
data: { batchIndex }
|
|
127
|
+
}
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const handler = this.endpoint.methods[method];
|
|
132
|
+
if (!handler) {
|
|
133
|
+
return {
|
|
134
|
+
jsonrpc: '2.0',
|
|
135
|
+
id: id || null,
|
|
136
|
+
error: {
|
|
137
|
+
code: -32601,
|
|
138
|
+
message: `Method "${method}" not found`,
|
|
139
|
+
data: { batchIndex }
|
|
140
|
+
}
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// If no id is provided, this is a notification - don't return response
|
|
145
|
+
const isNotification = id === undefined || id === null;
|
|
146
|
+
|
|
147
|
+
try {
|
|
148
|
+
// Execute middleware
|
|
149
|
+
let middlewareContext = {
|
|
150
|
+
req,
|
|
151
|
+
res: null, // No response object for batch items
|
|
152
|
+
method,
|
|
153
|
+
params,
|
|
154
|
+
context,
|
|
155
|
+
batchIndex,
|
|
156
|
+
isNotification
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
if (this.endpoint.middleware) {
|
|
160
|
+
middlewareContext = await this.endpoint.middleware.execute('beforeCall', middlewareContext);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Execute the handler
|
|
164
|
+
const result = await Promise.resolve(handler(req, context, middlewareContext.params));
|
|
165
|
+
|
|
166
|
+
// Execute after middleware
|
|
167
|
+
if (this.endpoint.middleware) {
|
|
168
|
+
middlewareContext.result = result;
|
|
169
|
+
await this.endpoint.middleware.execute('afterCall', middlewareContext);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// For notifications, return null (will be filtered out)
|
|
173
|
+
if (isNotification) {
|
|
174
|
+
return null;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Serialize the result
|
|
178
|
+
const safeResult = this.endpoint.serializeBigIntsAndDates(result);
|
|
179
|
+
|
|
180
|
+
return {
|
|
181
|
+
jsonrpc: '2.0',
|
|
182
|
+
id,
|
|
183
|
+
result: safeResult
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
} catch (error) {
|
|
187
|
+
// Execute error middleware
|
|
188
|
+
if (this.endpoint.middleware) {
|
|
189
|
+
try {
|
|
190
|
+
await this.endpoint.middleware.execute('onError', {
|
|
191
|
+
...middlewareContext,
|
|
192
|
+
error,
|
|
193
|
+
batchIndex
|
|
194
|
+
});
|
|
195
|
+
} catch (middlewareError) {
|
|
196
|
+
// Ignore middleware errors in error handling
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// For notifications, return null even on error
|
|
201
|
+
if (isNotification) {
|
|
202
|
+
return null;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
return {
|
|
206
|
+
jsonrpc: '2.0',
|
|
207
|
+
id: id || null,
|
|
208
|
+
error: {
|
|
209
|
+
code: error.code || -32603,
|
|
210
|
+
message: error.message || 'Internal error',
|
|
211
|
+
data: { batchIndex }
|
|
212
|
+
}
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Get batch statistics
|
|
219
|
+
* @param {Array} batch
|
|
220
|
+
* @returns {Object}
|
|
221
|
+
*/
|
|
222
|
+
getBatchStats(batch) {
|
|
223
|
+
const notifications = batch.filter(req => req.id === undefined || req.id === null).length;
|
|
224
|
+
const requests = batch.length - notifications;
|
|
225
|
+
const methods = [...new Set(batch.map(req => req.method))];
|
|
226
|
+
|
|
227
|
+
return {
|
|
228
|
+
total: batch.length,
|
|
229
|
+
requests,
|
|
230
|
+
notifications,
|
|
231
|
+
uniqueMethods: methods.length,
|
|
232
|
+
methods
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
module.exports = BatchHandler;
|