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/src/index.js ADDED
@@ -0,0 +1,580 @@
1
+ "use strict";
2
+
3
+ const path = require("path");
4
+ const express = require("express");
5
+
6
+ /** @typedef {import("express").Router} Router */
7
+ /** @typedef {import("express").Request} Request */
8
+ /** @typedef {import("express").Response} Response */
9
+
10
+ const NestedError = require('nested-error-stacks');
11
+ const Logger = require('./logger');
12
+ const { MiddlewareManager, builtInMiddlewares } = require('./middleware');
13
+ const { SchemaValidator, commonSchemas, SchemaBuilder } = require('./validation');
14
+ const BatchHandler = require('./batch');
15
+
16
+ /**
17
+ * Default properties to include in error serialization.
18
+ * @type {Array<string>}
19
+ */
20
+ const defaultProperties = [
21
+ 'stackTraceLimit', 'cause', 'code', 'message', 'stack', 'address',
22
+ 'dest', 'errno', 'info', 'path', 'port', 'syscall', 'opensslErrorStack',
23
+ 'function', 'library', 'reason'
24
+ ];
25
+
26
+ /**
27
+ * Serializes an error into a JSON-compatible format, with optional sanitization.
28
+ * @param {Error | NestedError | Object} error - The error to serialize.
29
+ * @param {boolean} sanitize - Whether to remove sensitive fields.
30
+ * @param {Array<string>} properties - Properties to include in serialization.
31
+ * @returns {Object} The serialized error.
32
+ */
33
+ const serializeError = (error, sanitize = false, properties = defaultProperties) => {
34
+ if (error === null || error === undefined) return error;
35
+
36
+ const result = {};
37
+ const includeProperties = sanitize
38
+ ? properties.filter(prop => !['address', 'path'].includes(prop))
39
+ : properties;
40
+
41
+ includeProperties.forEach(prop => {
42
+ if (error?.[prop] !== undefined) {
43
+ result[prop] = error[prop];
44
+ }
45
+ });
46
+
47
+ if (error instanceof NestedError) {
48
+ result.type = 'NestedError';
49
+ if (error.nested) result.nested = serializeError(error.nested, sanitize, properties);
50
+ } else if (error instanceof Error) {
51
+ result.type = 'Error';
52
+ } else if (error instanceof Object) {
53
+ result.type = 'Object';
54
+ Object.assign(result, error);
55
+ } else {
56
+ result.type = 'Default';
57
+ result.instance = error;
58
+ }
59
+
60
+ return result;
61
+ };
62
+
63
+ /**
64
+ * @template C
65
+ */
66
+ class RpcEndpoint {
67
+ /**
68
+ * Serve client scripts for making JSON-RPC calls from the browser.
69
+ * @param {Router} router Express.Router instance
70
+ * @param {string} [url="/vendor/rpc-client"]
71
+ * The path from which client scripts will be served.
72
+ */
73
+ static serveScripts(router, url = "/vendor/rpc-client") {
74
+ router.use(url, express.static(path.join(__dirname, "clients")));
75
+ }
76
+
77
+ /** @type {string} */
78
+ #endpoint;
79
+
80
+ /** @type {{ [name: string]: any }} */
81
+ #methods = {};
82
+
83
+ /** @type {Logger} */
84
+ #logger;
85
+
86
+ /** @type {MiddlewareManager} */
87
+ #middleware;
88
+
89
+ /** @type {SchemaValidator} */
90
+ #validator;
91
+
92
+ /** @type {BatchHandler} */
93
+ #batchHandler;
94
+
95
+ /** @type {Object} */
96
+ #options;
97
+
98
+ /**
99
+ * @param {Router} router The Express router to attach the endpoint to.
100
+ * @param {C} context The context object to pass to the method handlers.
101
+ * @param {string|Object} [endpointOrOptions="/api"] The endpoint path or options object.
102
+ * @param {Object} [options={}] Options object if first param is string.
103
+ */
104
+ constructor(router, context, endpointOrOptions = "/api", options = {}) {
105
+ // Handle both signatures: (router, context, "/api", options) and (router, context, options)
106
+ if (typeof endpointOrOptions === 'string') {
107
+ this.#endpoint = endpointOrOptions;
108
+ this.#options = { ...options };
109
+ } else {
110
+ this.#endpoint = endpointOrOptions.endpoint || "/api";
111
+ this.#options = { ...endpointOrOptions };
112
+ }
113
+
114
+ // Initialize components
115
+ this.#logger = new Logger(this.#options.logging || {});
116
+ this.#middleware = new MiddlewareManager();
117
+ this.#validator = new SchemaValidator(this.#options.validation || {});
118
+ this.#batchHandler = new BatchHandler(this);
119
+
120
+ // Setup built-in middleware if configured
121
+ this.#setupBuiltInMiddleware();
122
+
123
+ // Wire up routes
124
+ this.#setupRoutes(router, context);
125
+ }
126
+
127
+ /**
128
+ * Setup built-in middleware based on options
129
+ */
130
+ #setupBuiltInMiddleware() {
131
+ const { rateLimit, cors, auth, timing, methodWhitelist } = this.#options;
132
+
133
+ if (timing !== false) {
134
+ this.use('beforeCall', builtInMiddlewares.timing());
135
+ }
136
+
137
+ if (cors) {
138
+ this.use('beforeCall', builtInMiddlewares.cors(cors));
139
+ }
140
+
141
+ if (rateLimit) {
142
+ this.use('beforeCall', builtInMiddlewares.rateLimit(rateLimit));
143
+ }
144
+
145
+ if (auth) {
146
+ this.use('beforeCall', builtInMiddlewares.auth(auth));
147
+ }
148
+
149
+ if (methodWhitelist && Array.isArray(methodWhitelist)) {
150
+ this.use('beforeCall', builtInMiddlewares.methodWhitelist(methodWhitelist));
151
+ }
152
+ }
153
+
154
+ /**
155
+ * Setup Express routes
156
+ * @param {Router} router
157
+ * @param {C} context
158
+ */
159
+ #setupRoutes(router, context) {
160
+ // Health check endpoint
161
+ if (this.#options.healthCheck !== false) {
162
+ router.get(`${this.#endpoint}/health`, (req, res) => {
163
+ res.json({
164
+ status: 'ok',
165
+ timestamp: new Date().toISOString(),
166
+ version: require('../package.json').version
167
+ });
168
+ });
169
+ }
170
+
171
+ // Metrics endpoint
172
+ if (this.#options.metrics) {
173
+ router.get(`${this.#endpoint}/metrics`, (req, res) => {
174
+ res.json(this.getMetrics());
175
+ });
176
+ }
177
+
178
+ // Main JSON-RPC endpoint
179
+ router.post(this.#endpoint, async (req, res) => {
180
+ const startTime = Date.now();
181
+
182
+ try {
183
+ // Check if it's a batch request
184
+ if (this.#batchHandler.isBatchRequest(req.body)) {
185
+ this.#logger.info('Batch request received', {
186
+ batchSize: req.body.length,
187
+ ip: req.ip
188
+ });
189
+
190
+ const results = await this.#batchHandler.processBatch(req.body, req, res, context);
191
+
192
+ // Only send response if there are results (non-notification requests)
193
+ if (results.length > 0) {
194
+ res.json(results);
195
+ } else {
196
+ res.status(204).end(); // No content for all-notification batch
197
+ }
198
+
199
+ this.#logger.info('Batch request completed', {
200
+ batchSize: req.body.length,
201
+ responseCount: results.length,
202
+ duration: Date.now() - startTime
203
+ });
204
+
205
+ return;
206
+ }
207
+
208
+ // Single request processing
209
+ await this.#processSingleRequest(req, res, context, startTime);
210
+
211
+ } catch (error) {
212
+ this.#logger.error('Endpoint error', { error: error.message, stack: error.stack });
213
+
214
+ res.json({
215
+ jsonrpc: "2.0",
216
+ id: null,
217
+ error: {
218
+ code: -32603,
219
+ message: "Internal error",
220
+ data: serializeError(error, true)
221
+ }
222
+ });
223
+ }
224
+ });
225
+ }
226
+
227
+ /**
228
+ * Process a single JSON-RPC request
229
+ * @param {Request} req
230
+ * @param {Response} res
231
+ * @param {C} context
232
+ * @param {number} startTime
233
+ */
234
+ async #processSingleRequest(req, res, context, startTime) {
235
+ const { jsonrpc, method, params, id } = req.body || {};
236
+
237
+ // Log the incoming request
238
+ this.#logger.rpcCall(method, params, id, req);
239
+
240
+ // Validate JSON-RPC 2.0 request
241
+ if (jsonrpc !== "2.0") {
242
+ return this.reply(res, {
243
+ id,
244
+ error: { code: -32600, message: `Invalid Request: 'jsonrpc' must be '2.0'.` }
245
+ });
246
+ }
247
+
248
+ if (typeof method !== "string") {
249
+ return this.reply(res, {
250
+ id,
251
+ error: { code: -32600, message: `Invalid Request: 'method' must be a string.` }
252
+ });
253
+ }
254
+
255
+ const methodConfig = this.#methods[method];
256
+ if (!methodConfig) {
257
+ return this.reply(res, {
258
+ id,
259
+ error: { code: -32601, message: `Method "${method}" not found` }
260
+ });
261
+ }
262
+
263
+ const handler = typeof methodConfig === 'function' ? methodConfig : methodConfig.handler;
264
+ const schema = typeof methodConfig === 'object' ? methodConfig.schema : null;
265
+
266
+ try {
267
+ // Prepare middleware context
268
+ let middlewareContext = {
269
+ req,
270
+ res,
271
+ method,
272
+ params,
273
+ context,
274
+ id,
275
+ startTime
276
+ };
277
+
278
+ // Execute beforeCall middleware
279
+ middlewareContext = await this.#middleware.execute('beforeCall', middlewareContext);
280
+
281
+ // Validate parameters if schema is provided
282
+ if (schema) {
283
+ middlewareContext = await this.#middleware.execute('beforeValidation', middlewareContext);
284
+
285
+ const validation = this.#validator.validate(middlewareContext.params, schema);
286
+ if (!validation.valid) {
287
+ const error = new Error('Validation failed');
288
+ error.code = -32602;
289
+ error.data = {
290
+ validationErrors: validation.errors.map(err => ({
291
+ field: err.instancePath || err.schemaPath,
292
+ message: err.message,
293
+ value: err.data
294
+ }))
295
+ };
296
+ throw error;
297
+ }
298
+
299
+ middlewareContext.params = validation.data;
300
+ middlewareContext = await this.#middleware.execute('afterValidation', middlewareContext);
301
+ }
302
+
303
+ // Invoke the handler
304
+ const result = await Promise.resolve(handler(req, context, middlewareContext.params));
305
+
306
+ // Execute afterCall middleware
307
+ middlewareContext.result = result;
308
+ await this.#middleware.execute('afterCall', middlewareContext);
309
+
310
+ // Log success
311
+ const duration = Date.now() - startTime;
312
+ this.#logger.rpcSuccess(method, id, duration, result);
313
+
314
+ // Convert any BigInt/Date values before JSON-stringifying
315
+ const safeResult = this.serializeBigIntsAndDates(result);
316
+ this.reply(res, { id, result: safeResult });
317
+
318
+ } catch (err) {
319
+ const duration = Date.now() - startTime;
320
+ this.#logger.rpcError(method, id, duration, err);
321
+
322
+ // Execute error middleware
323
+ try {
324
+ await this.#middleware.execute('onError', {
325
+ req,
326
+ res,
327
+ method,
328
+ params,
329
+ context,
330
+ id,
331
+ error: err,
332
+ startTime,
333
+ duration
334
+ });
335
+ } catch (middlewareError) {
336
+ this.#logger.error('Error middleware failed', { error: middlewareError.message });
337
+ }
338
+
339
+ this.reply(res, {
340
+ id,
341
+ error: {
342
+ code: err.code || -32603,
343
+ message: err.message || `Internal error`,
344
+ ...(err.data && { data: err.data }),
345
+ error: serializeError(err, true)
346
+ }
347
+ });
348
+ }
349
+ }
350
+
351
+ /**
352
+ * Register a new JSON-RPC method.
353
+ * @param {string} name The method name.
354
+ * @param {Function|Object} handlerOrConfig The function to handle calls or config object.
355
+ * @param {Object} [schema] Optional schema for parameter validation.
356
+ */
357
+ addMethod(name, handlerOrConfig, schema = null) {
358
+ if (typeof handlerOrConfig === 'function') {
359
+ this.#methods[name] = schema ? { handler: handlerOrConfig, schema } : handlerOrConfig;
360
+ } else if (typeof handlerOrConfig === 'object' && handlerOrConfig.handler) {
361
+ this.#methods[name] = handlerOrConfig;
362
+ } else {
363
+ throw new Error('Invalid handler configuration');
364
+ }
365
+
366
+ this.#logger.debug('Method registered', { method: name, hasSchema: !!schema });
367
+ }
368
+
369
+ /**
370
+ * Add middleware for specific hooks
371
+ * @param {string} hook
372
+ * @param {Function} middleware
373
+ */
374
+ use(hook, middleware) {
375
+ this.#middleware.use(hook, middleware);
376
+ }
377
+
378
+ /**
379
+ * Remove method
380
+ * @param {string} name
381
+ */
382
+ removeMethod(name) {
383
+ delete this.#methods[name];
384
+ this.#logger.debug('Method removed', { method: name });
385
+ }
386
+
387
+ /**
388
+ * Get method configuration
389
+ * @param {string} name
390
+ * @returns {any}
391
+ */
392
+ getMethod(name) {
393
+ return this.#methods[name];
394
+ }
395
+
396
+ /**
397
+ * List all registered methods
398
+ * @returns {string[]}
399
+ */
400
+ listMethods() {
401
+ return Object.keys(this.#methods);
402
+ }
403
+
404
+ /** @returns {string} The endpoint path (e.g. "/api"). */
405
+ get endpoint() {
406
+ return this.#endpoint;
407
+ }
408
+
409
+ /**
410
+ * @returns {{ [name: string]: any }}
411
+ * All registered methods.
412
+ */
413
+ get methods() {
414
+ return this.#methods;
415
+ }
416
+
417
+ /**
418
+ * Get logger instance
419
+ * @returns {Logger}
420
+ */
421
+ get logger() {
422
+ return this.#logger;
423
+ }
424
+
425
+ /**
426
+ * Get middleware manager
427
+ * @returns {MiddlewareManager}
428
+ */
429
+ get middleware() {
430
+ return this.#middleware;
431
+ }
432
+
433
+ /**
434
+ * Get validator instance
435
+ * @returns {SchemaValidator}
436
+ */
437
+ get validator() {
438
+ return this.#validator;
439
+ }
440
+
441
+ /**
442
+ * Get metrics data
443
+ * @returns {Object}
444
+ */
445
+ getMetrics() {
446
+ return {
447
+ methods: Object.keys(this.#methods).length,
448
+ middleware: {
449
+ beforeCall: this.#middleware.getMiddlewares('beforeCall').length,
450
+ afterCall: this.#middleware.getMiddlewares('afterCall').length,
451
+ onError: this.#middleware.getMiddlewares('onError').length
452
+ },
453
+ uptime: process.uptime(),
454
+ memory: process.memoryUsage(),
455
+ timestamp: new Date().toISOString()
456
+ };
457
+ }
458
+
459
+ /**
460
+ * Send a JSON-RPC 2.0 response.
461
+ * @param {Response} res The Express response object.
462
+ * @param {{
463
+ * id?: string|number|null,
464
+ * result?: any,
465
+ * error?: { code: number, message: string, data?: any }
466
+ * }} responsePayload The JSON-RPC response fields.
467
+ */
468
+ reply(res, { id, result, error }) {
469
+ const response = { jsonrpc: "2.0", id: id === undefined ? null : id };
470
+
471
+ if (error) {
472
+ response.error = error;
473
+ } else {
474
+ response.result = result;
475
+ }
476
+
477
+ res.json(response);
478
+ }
479
+
480
+ /**
481
+ * Recursively convert BigInt values to strings and Date objects to ISO strings.
482
+ * This is crucial because JSON.stringify() cannot handle BigInt natively
483
+ * and Date objects are best transmitted in a standardized format.
484
+ *
485
+ * @param {any} value The value to serialize.
486
+ * @returns {any} A version of `value` safe for JSON serialization.
487
+ */
488
+ serializeBigIntsAndDates(value) {
489
+ if (typeof value === "bigint") {
490
+ // Convert BigInt to string
491
+ return value.toString();
492
+ } else if (Array.isArray(value)) {
493
+ // Recurse into arrays
494
+ return value.map((v) => this.serializeBigIntsAndDates(v));
495
+ } else if (value instanceof Date) {
496
+ // Convert Date to ISO string (UTC)
497
+ return value.toISOString();
498
+ } else if (value && typeof value === "object") {
499
+ // Recurse into plain objects
500
+ const result = {};
501
+ for (const [key, val] of Object.entries(value)) {
502
+ result[key] = this.serializeBigIntsAndDates(val);
503
+ }
504
+ return result;
505
+ }
506
+
507
+ // If it's none of the above, return as-is
508
+ return value;
509
+ }
510
+
511
+ /**
512
+ * Recursively convert stringified BigInts back to BigInt,
513
+ * and ISO 8601 date strings back to Date objects.
514
+ *
515
+ * This is useful if you receive JSON that was serialized
516
+ * by `serializeBigIntsAndDates()` and want to re-hydrate
517
+ * the original types.
518
+ *
519
+ * @param {any} value The value to deserialize.
520
+ * @returns {any} The re-hydrated value.
521
+ */
522
+ deserializeBigIntsAndDates(value) {
523
+ // More comprehensive ISO date regex that handles:
524
+ // - UTC: 2023-01-01T12:00:00.000Z
525
+ // - With timezone: 2023-01-01T12:00:00.000+01:00
526
+ // - Without timezone: 2023-01-01T12:00:00.000 (treated as local)
527
+ const ISO_DATE_REGEX = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})?$/;
528
+
529
+ // 1. Check if it's a string that might be a BigInt or a Date
530
+ if (typeof value === "string") {
531
+ // BigInt check: matches digits (including negative), optionally ending in "n"
532
+ // e.g., "42n", "42", "-42n", "-42"
533
+ if (/^-?\d+n?$/.test(value)) {
534
+ return BigInt(value.replace(/n$/, ""));
535
+ }
536
+
537
+ // Date check: matches an ISO 8601 string
538
+ if (ISO_DATE_REGEX.test(value)) {
539
+ const date = new Date(value);
540
+ // Double-check that we got a valid date
541
+ if (!isNaN(date.getTime())) {
542
+ return date;
543
+ }
544
+ }
545
+ }
546
+
547
+ // 2. If it's an array, handle each element
548
+ if (Array.isArray(value)) {
549
+ return value.map((v) => this.deserializeBigIntsAndDates(v));
550
+ }
551
+
552
+ // 3. If it's a plain object, recurse into each property
553
+ if (value && typeof value === "object") {
554
+ return Object.fromEntries(
555
+ Object.entries(value).map(([key, val]) => [
556
+ key,
557
+ this.deserializeBigIntsAndDates(val)
558
+ ])
559
+ );
560
+ }
561
+
562
+ // 4. For everything else (number, boolean, null, undefined, etc.), return as-is
563
+ return value;
564
+ }
565
+ }
566
+
567
+ // Import the client class
568
+ const RpcClient = require('./clients/rpc-client');
569
+
570
+ // Export both server and client, plus utilities
571
+ module.exports = RpcEndpoint;
572
+ module.exports.RpcEndpoint = RpcEndpoint;
573
+ module.exports.RpcClient = RpcClient;
574
+ module.exports.Logger = Logger;
575
+ module.exports.MiddlewareManager = MiddlewareManager;
576
+ module.exports.builtInMiddlewares = builtInMiddlewares;
577
+ module.exports.SchemaValidator = SchemaValidator;
578
+ module.exports.commonSchemas = commonSchemas;
579
+ module.exports.SchemaBuilder = SchemaBuilder;
580
+ module.exports.BatchHandler = BatchHandler;