rpc-express-toolkit 4.2.1 → 4.3.2

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 CHANGED
@@ -1,1035 +1,1153 @@
1
- const path = require('path');
2
- const express = require('express');
3
- const expressPkg = require('express/package.json');
4
- const NestedError = require('nested-error-stacks');
5
- const pkg = require('../package.json');
6
-
7
- /** @typedef {import("express").Router} Router */
8
- /** @typedef {import("express").Request} Request */
9
- /** @typedef {import("express").Response} Response */
10
-
11
- const Logger = require('./logger');
12
- const { MiddlewareManager, builtInMiddlewares } = require('./middleware');
13
- const {
14
- SchemaValidator,
15
- commonSchemas,
16
- SchemaBuilder,
17
- } = require('./validation');
18
- const BatchHandler = require('./batch');
19
-
20
- /**
21
- * Default properties to include in error serialization.
22
- * @type {Array<string>}
23
- */
24
- const defaultProperties = [
25
- 'stackTraceLimit',
26
- 'cause',
27
- 'code',
28
- 'message',
29
- 'stack',
30
- 'address',
31
- 'dest',
32
- 'errno',
33
- 'info',
34
- 'path',
35
- 'port',
36
- 'syscall',
37
- 'opensslErrorStack',
38
- 'function',
39
- 'library',
40
- 'reason',
41
- ];
42
-
43
- /**
44
- * Serializes an error into a JSON-compatible format, with optional sanitization.
45
- * @param {Error | NestedError | Object} error - The error to serialize.
46
- * @param {boolean} sanitize - Whether to remove sensitive fields.
47
- * @param {Array<string>} properties - Properties to include in serialization.
48
- * @returns {Object} The serialized error.
49
- */
50
- const serializeError = (
51
- error,
52
- sanitize = false,
53
- properties = defaultProperties
54
- ) => {
55
- if (error === null || error === undefined) return error;
56
-
57
- const result = {};
58
- const includeProperties = sanitize
59
- ? properties.filter((prop) => !['address', 'path'].includes(prop))
60
- : properties;
61
-
62
- includeProperties.forEach((prop) => {
63
- if (error?.[prop] !== undefined) {
64
- result[prop] = error[prop];
65
- }
66
- });
67
-
68
- if (error instanceof NestedError) {
69
- result.type = 'NestedError';
70
- if (error.nested)
71
- result.nested = serializeError(error.nested, sanitize, properties);
72
- } else if (error instanceof Error) {
73
- result.type = 'Error';
74
- } else if (error instanceof Object) {
75
- result.type = 'Object';
76
- Object.assign(result, error);
77
- } else {
78
- result.type = 'Default';
79
- result.instance = error;
80
- }
81
-
82
- return result;
83
- };
84
-
85
- /**
86
- * @template C
87
- */
88
- class RpcEndpoint {
89
- /**
90
- * Serve client scripts for making JSON-RPC calls from the browser.
91
- * @param {Router} router Express.Router instance
92
- * @param {string} [url="/vendor/rpc-client"]
93
- * The path from which client scripts will be served.
94
- */
95
- static serveScripts(router, url = '/vendor/rpc-client') {
96
- router.use(url, express.static(path.join(__dirname, 'clients')));
97
- }
98
-
99
- /** @type {string} */
100
- #endpoint;
101
-
102
- /** @type {{ [name: string]: any }} */
103
- #methods = {};
104
-
105
- /** @type {string} */
106
- #introspectionPrefix = '__rpc';
107
-
108
- /** @type {boolean} */
109
- #isInternalRegistration = false;
110
-
111
- /** @type {Logger} */
112
- #logger;
113
-
114
- /** @type {MiddlewareManager} */
115
- #middleware;
116
-
117
- /** @type {SchemaValidator} */
118
- #validator;
119
-
120
- /** @type {BatchHandler} */
121
- #batchHandler;
122
-
123
- /** @type {Object} */
124
- #options;
125
-
126
- /**
127
- * @param {Router} router The Express router to attach the endpoint to.
128
- * @param {C} context The context object to pass to the method handlers.
129
- * @param {string|Object} [endpointOrOptions="/api"] The endpoint path or options object.
130
- * @param {Object} [options={}] Options object if first param is string.
131
- */
132
- constructor(router, context, endpointOrOptions = '/api', options = {}) {
133
- // Handle both signatures: (router, context, "/api", options) and (router, context, options)
134
- if (typeof endpointOrOptions === 'string') {
135
- this.#endpoint = endpointOrOptions;
136
- this.#options = { ...options };
137
- } else {
138
- this.#endpoint = endpointOrOptions.endpoint || '/api';
139
- this.#options = { ...endpointOrOptions };
140
- }
141
-
142
- // Initialize components
143
- this.#logger = new Logger(this.#options.logging || {});
144
- this.#middleware = new MiddlewareManager();
145
- this.#validator = new SchemaValidator(this.#options.validation || {});
146
- this.#batchHandler = new BatchHandler(this);
147
-
148
- // Set default options
149
- this.#options.safeEnabled = this.#options.safeEnabled === true; // Default false
150
- this.#options.warnOnUnsafe = this.#options.warnOnUnsafe !== false; // Default true
151
- this.#options.strictMode = this.#options.strictMode !== false; // Default true
152
- this.#options.enableIntrospection =
153
- this.#options.enableIntrospection === true; // Default false
154
- this.#options.introspectionPrefix =
155
- this.#options.introspectionPrefix || '__rpc'; // Default __rpc
156
-
157
- this.#introspectionPrefix = this.#options.introspectionPrefix;
158
-
159
- // Validate that the router can handle JSON parsing
160
- this.#validateJsonMiddleware();
161
-
162
- // Register introspection methods if enabled
163
- if (this.#options.enableIntrospection) {
164
- this.#registerIntrospectionMethods();
165
- }
166
-
167
- // Setup built-in middleware if configured
168
- this.#setupBuiltInMiddleware();
169
-
170
- // Wire up routes
171
- this.#setupRoutes(router, context);
172
- }
173
-
174
- /**
175
- * Setup built-in middleware based on options
176
- */
177
- #setupBuiltInMiddleware() {
178
- const { rateLimit, cors, auth, timing, methodWhitelist } = this.#options;
179
-
180
- if (timing !== false) {
181
- this.use('beforeCall', builtInMiddlewares.timing());
182
- }
183
-
184
- if (cors) {
185
- this.use('beforeCall', builtInMiddlewares.cors(cors));
186
- }
187
-
188
- if (rateLimit) {
189
- this.use('beforeCall', builtInMiddlewares.rateLimit(rateLimit));
190
- }
191
-
192
- if (auth) {
193
- this.use('beforeCall', builtInMiddlewares.auth(auth));
194
- }
195
-
196
- if (methodWhitelist && Array.isArray(methodWhitelist)) {
197
- this.use(
198
- 'beforeCall',
199
- builtInMiddlewares.methodWhitelist(methodWhitelist)
200
- );
201
- }
202
- }
203
-
204
- /**
205
- * Register introspection methods (__rpc.*)
206
- * @private
207
- */
208
- #registerIntrospectionMethods() {
209
- this.#isInternalRegistration = true;
210
-
211
- // __rpc.listMethods - List all user methods (excludes __rpc.* methods)
212
- this.addMethod(
213
- `${this.#introspectionPrefix}.listMethods`,
214
- async (_req, _context, _params) =>
215
- Object.keys(this.#methods).filter(
216
- (name) => !name.startsWith(this.#introspectionPrefix)
217
- ),
218
- {
219
- description: 'List all available RPC methods',
220
- exposeSchema: true,
221
- }
222
- );
223
-
224
- // __rpc.describe - Get schema and description of a specific method
225
- this.addMethod(
226
- `${this.#introspectionPrefix}.describe`,
227
- async (_req, _context, params) => {
228
- const methodName = params?.method;
229
-
230
- if (!methodName || typeof methodName !== 'string') {
231
- throw {
232
- code: -32602,
233
- message: 'Invalid params: method name required',
234
- };
235
- }
236
-
237
- // Prevent introspection of __rpc.* methods
238
- if (methodName.startsWith(this.#introspectionPrefix)) {
239
- throw {
240
- code: -32601,
241
- message: 'Cannot describe introspection methods',
242
- };
243
- }
244
-
245
- const methodConfig = this.#methods[methodName];
246
- if (!methodConfig) {
247
- throw { code: -32601, message: `Method not found: ${methodName}` };
248
- }
249
-
250
- // Check if schema is exposed
251
- const config = typeof methodConfig === 'function' ? {} : methodConfig;
252
- if (!config.exposeSchema) {
253
- throw { code: -32601, message: 'Method schema not available' };
254
- }
255
-
256
- return {
257
- name: methodName,
258
- schema: config.schema || null,
259
- description: config.description || '',
260
- };
261
- },
262
- {
263
- schema: {
264
- type: 'object',
265
- properties: {
266
- method: { type: 'string' },
267
- },
268
- required: ['method'],
269
- },
270
- description: 'Get schema and description of a specific method',
271
- exposeSchema: true,
272
- }
273
- );
274
-
275
- // __rpc.describeAll - Get all methods with public schemas
276
- this.addMethod(
277
- `${this.#introspectionPrefix}.describeAll`,
278
- async (_req, _context, _params) => {
279
- const publicMethods = [];
280
-
281
- for (const [name, methodConfig] of Object.entries(this.#methods)) {
282
- // Skip introspection methods
283
- if (name.startsWith(this.#introspectionPrefix)) continue;
284
-
285
- const config = typeof methodConfig === 'function' ? {} : methodConfig;
286
- if (config.exposeSchema) {
287
- publicMethods.push({
288
- name,
289
- schema: config.schema || null,
290
- description: config.description || '',
291
- });
292
- }
293
- }
294
-
295
- return publicMethods;
296
- },
297
- {
298
- description: 'List all methods with public schemas',
299
- exposeSchema: true,
300
- }
301
- );
302
-
303
- // __rpc.version - Get toolkit version
304
- this.addMethod(
305
- `${this.#introspectionPrefix}.version`,
306
- async (_req, _context, _params) => ({
307
- toolkit: 'rpc-express-toolkit',
308
- version: pkg.version,
309
- expressVersion: expressPkg.version,
310
- nodeVersion: process.version,
311
- }),
312
- {
313
- description: 'Get RPC toolkit version information',
314
- exposeSchema: true,
315
- }
316
- );
317
-
318
- // __rpc.capabilities - Get server capabilities
319
- this.addMethod(
320
- `${this.#introspectionPrefix}.capabilities`,
321
- async (_req, _context, _params) => ({
322
- safeMode: this.#options.safeEnabled,
323
- batch: true,
324
- introspection: true,
325
- introspectionPrefix: this.#introspectionPrefix,
326
- validation: !!this.#options.validation,
327
- cors: !!this.#options.cors,
328
- auth: !!this.#options.auth,
329
- rateLimit: !!this.#options.rateLimit,
330
- methodCount: Object.keys(this.#methods).filter(
331
- (n) => !n.startsWith(this.#introspectionPrefix)
332
- ).length,
333
- }),
334
- {
335
- description: 'Get server capabilities and configuration',
336
- exposeSchema: true,
337
- }
338
- );
339
-
340
- this.#isInternalRegistration = false;
341
- }
342
-
343
- /**
344
- * Validate JSON middleware functionality and Express compatibility
345
- * @private
346
- */
347
- #validateJsonMiddleware() {
348
- try {
349
- // Check if express.json is available (Express 4.16.0+)
350
- if (typeof express.json !== 'function') {
351
- throw new Error(
352
- 'express.json() is not available. Please ensure you are using Express 4.16.0 or later, or install body-parser separately.'
353
- );
354
- }
355
-
356
- // Test JSON middleware creation with default options
357
- const jsonMiddleware = express.json({ limit: '10mb' });
358
-
359
- if (typeof jsonMiddleware !== 'function') {
360
- throw new Error(
361
- 'express.json() did not return a valid middleware function'
362
- );
363
- }
364
-
365
- // Verify middleware has correct signature (req, res, next)
366
- if (jsonMiddleware.length !== 3) {
367
- throw new Error(
368
- 'JSON middleware has invalid signature - expected (req, res, next)'
369
- );
370
- }
371
-
372
- this.#logger.debug('JSON middleware validation passed', {
373
- expressJsonAvailable: true,
374
- middlewareValid: true,
375
- endpoint: this.#endpoint,
376
- });
377
- } catch (error) {
378
- const errorMsg =
379
- `JSON middleware validation failed: ${error.message}\n` +
380
- `This router cannot handle JSON-RPC requests properly.\n\n` +
381
- `Possible solutions:\n` +
382
- `- Update Express: npm install express@latest (requires >=4.16.0)\n` +
383
- `- Add JSON parsing middleware manually: router.use(express.json())\n` +
384
- `- Install body-parser: npm install body-parser`;
385
-
386
- this.#logger.error('JSON middleware validation failed', {
387
- error: error.message,
388
- expressVersion: expressPkg?.version || 'unknown',
389
- endpoint: this.#endpoint,
390
- });
391
-
392
- throw new Error(errorMsg);
393
- }
394
- }
395
-
396
- /**
397
- * Setup Express routes
398
- * @param {Router} router
399
- * @param {C} context
400
- */
401
- #setupRoutes(router, context) {
402
- // Health check endpoint
403
- if (this.#options.healthCheck !== false) {
404
- router.get(`${this.#endpoint}/health`, (req, res) => {
405
- res.json({
406
- status: 'ok',
407
- timestamp: new Date().toISOString(),
408
- version: pkg.version,
409
- });
410
- });
411
- }
412
-
413
- // Metrics endpoint
414
- if (this.#options.metrics) {
415
- router.get(`${this.#endpoint}/metrics`, (req, res) => {
416
- res.json(this.getMetrics());
417
- });
418
- }
419
-
420
- // Main JSON-RPC endpoint
421
- router.post(this.#endpoint, async (req, res) => {
422
- const startTime = Date.now();
423
-
424
- try {
425
- // Check if it's a batch request
426
- if (this.#batchHandler.isBatchRequest(req.body)) {
427
- this.#logger.info('Batch request received', {
428
- batchSize: req.body.length,
429
- ip: req.ip,
430
- });
431
-
432
- const results = await this.#batchHandler.processBatch(
433
- req.body,
434
- req,
435
- res,
436
- context
437
- );
438
-
439
- // Only send response if there are results (non-notification requests)
440
- if (results.length > 0) {
441
- // Align batch responses with single-call header negotiation
442
- res.setHeader(
443
- 'X-RPC-Safe-Enabled',
444
- this.#options.safeEnabled ? 'true' : 'false'
445
- );
446
- res.json(results);
447
- } else {
448
- res.status(204).end(); // No content for all-notification batch
449
- }
450
-
451
- this.#logger.info('Batch request completed', {
452
- batchSize: req.body.length,
453
- responseCount: results.length,
454
- duration: Date.now() - startTime,
455
- });
456
-
457
- return;
458
- }
459
-
460
- // Single request processing
461
- await this.#processSingleRequest(req, res, context, startTime);
462
- } catch (error) {
463
- this.#logger.error('Endpoint error', {
464
- error: error.message,
465
- stack: error.stack,
466
- });
467
-
468
- res.json({
469
- jsonrpc: '2.0',
470
- id: null,
471
- error: {
472
- code: -32603,
473
- message: 'Internal error',
474
- data: serializeError(error, true),
475
- },
476
- });
477
- }
478
- });
479
- }
480
-
481
- /**
482
- * Process a single JSON-RPC request
483
- * @param {Request} req
484
- * @param {Response} res
485
- * @param {C} context
486
- * @param {number} startTime
487
- */
488
- async #processSingleRequest(req, res, context, startTime) {
489
- const { jsonrpc, method, params, id } = req.body || {};
490
-
491
- // Log the incoming request
492
- this.#logger.rpcCall(method, params, id, req);
493
-
494
- // Validate JSON-RPC 2.0 request
495
- if (jsonrpc !== '2.0') {
496
- return this.reply(res, {
497
- id,
498
- error: {
499
- code: -32600,
500
- message: `Invalid Request: 'jsonrpc' must be '2.0'.`,
501
- },
502
- });
503
- }
504
-
505
- if (typeof method !== 'string') {
506
- return this.reply(res, {
507
- id,
508
- error: {
509
- code: -32600,
510
- message: `Invalid Request: 'method' must be a string.`,
511
- },
512
- });
513
- }
514
-
515
- const methodConfig = this.#methods[method];
516
- if (!methodConfig) {
517
- return this.reply(res, {
518
- id,
519
- error: { code: -32601, message: `Method "${method}" not found` },
520
- });
521
- }
522
-
523
- const handler =
524
- typeof methodConfig === 'function' ? methodConfig : methodConfig.handler;
525
- const schema =
526
- typeof methodConfig === 'object' ? methodConfig.schema : null;
527
-
528
- try {
529
- // Check client's safe options from request headers
530
- const clientSafeHeader = req.headers['x-rpc-safe-enabled'];
531
-
532
- // Strict mode: se server ha safe enabled ma client non invia header → JSON-RPC ERROR
533
- if (
534
- this.#options.strictMode &&
535
- this.#options.safeEnabled &&
536
- !clientSafeHeader
537
- ) {
538
- return this.reply(res, {
539
- id,
540
- error: {
541
- code: -32600, // Invalid Request
542
- message:
543
- 'RPC Compatibility Error: Server requires safe serialization header but client did not provide it.',
544
- data: {
545
- serverSafeEnabled: this.#options.safeEnabled,
546
- requiredHeader: 'X-RPC-Safe-Enabled',
547
- strictMode: true,
548
- solution:
549
- 'Update client to rpc-express-toolkit v4+ or disable server strict mode',
550
- },
551
- },
552
- });
553
- }
554
-
555
- // Use client's safe options for parameter deserialization
556
- const clientSafeEnabled = clientSafeHeader === 'true';
557
- const deserializationOptions = {
558
- safeEnabled: clientSafeEnabled,
559
- };
560
-
561
- // Deserialize parameters using client's safe options
562
- const deserializedParams = params
563
- ? this.deserializeBigIntsAndDates(params, deserializationOptions)
564
- : params;
565
-
566
- // Prepare middleware context
567
- let middlewareContext = {
568
- req,
569
- res,
570
- method,
571
- params: deserializedParams,
572
- context,
573
- id,
574
- startTime,
575
- };
576
-
577
- // Execute beforeCall middleware
578
- middlewareContext = await this.#middleware.execute(
579
- 'beforeCall',
580
- middlewareContext
581
- );
582
-
583
- // Validate parameters if schema is provided
584
- if (schema) {
585
- middlewareContext = await this.#middleware.execute(
586
- 'beforeValidation',
587
- middlewareContext
588
- );
589
-
590
- const validation = this.#validator.validate(
591
- middlewareContext.params,
592
- schema
593
- );
594
- if (!validation.valid) {
595
- const error = new Error('Validation failed');
596
- error.code = -32602;
597
- error.data = {
598
- validationErrors: validation.errors.map((err) => ({
599
- field: err.instancePath || err.schemaPath,
600
- message: err.message,
601
- value: err.data,
602
- })),
603
- };
604
- throw error;
605
- }
606
-
607
- middlewareContext.params = validation.data;
608
- middlewareContext = await this.#middleware.execute(
609
- 'afterValidation',
610
- middlewareContext
611
- );
612
- }
613
-
614
- // Invoke the handler
615
- const result = await Promise.resolve(
616
- handler(req, context, middlewareContext.params)
617
- );
618
-
619
- // Execute afterCall middleware
620
- middlewareContext.result = result;
621
- await this.#middleware.execute('afterCall', middlewareContext);
622
-
623
- // Log success
624
- const duration = Date.now() - startTime;
625
- this.#logger.rpcSuccess(method, id, duration, result);
626
-
627
- // Convert any BigInt/Date values before JSON-stringifying
628
- const safeResult = this.serializeBigIntsAndDates(result);
629
- this.reply(res, { id, result: safeResult });
630
- } catch (err) {
631
- const duration = Date.now() - startTime;
632
- this.#logger.rpcError(method, id, duration, err);
633
-
634
- // Execute error middleware
635
- try {
636
- await this.#middleware.execute('onError', {
637
- req,
638
- res,
639
- method,
640
- params,
641
- context,
642
- id,
643
- error: err,
644
- startTime,
645
- duration,
646
- });
647
- } catch (middlewareError) {
648
- this.#logger.error('Error middleware failed', {
649
- error: middlewareError.message,
650
- });
651
- }
652
-
653
- this.reply(res, {
654
- id,
655
- error: {
656
- code: err.code || -32603,
657
- message: err.message || `Internal error`,
658
- ...(err.data && { data: err.data }),
659
- error: serializeError(err, true),
660
- },
661
- });
662
- }
663
- // Ensure explicit return for consistent-return rule
664
- return null;
665
- }
666
-
667
- /**
668
- * Register a new JSON-RPC method.
669
- * @param {string} name The method name.
670
- * @param {Function|Object} handlerOrConfig The function to handle calls or config object with handler, schema, exposeSchema, description.
671
- * @param {Object} [optionsOrSchema] Optional config object or schema for backward compatibility.
672
- */
673
- addMethod(name, handlerOrConfig, optionsOrSchema = null) {
674
- // Prevent users from registering introspection methods
675
- if (
676
- name.startsWith(this.#introspectionPrefix) &&
677
- !this.#isInternalRegistration
678
- ) {
679
- throw new Error(
680
- `Method names starting with '${this.#introspectionPrefix}.' are reserved for RPC introspection`
681
- );
682
- }
683
-
684
- let methodConfig;
685
-
686
- if (typeof handlerOrConfig === 'function') {
687
- // Function handler - optionsOrSchema can be schema (backward compat) or options object
688
- if (
689
- optionsOrSchema &&
690
- (optionsOrSchema.schema ||
691
- optionsOrSchema.exposeSchema ||
692
- optionsOrSchema.description)
693
- ) {
694
- // New format: options object
695
- methodConfig = {
696
- handler: handlerOrConfig,
697
- ...optionsOrSchema,
698
- };
699
- } else if (optionsOrSchema) {
700
- // Old format: schema directly
701
- methodConfig = {
702
- handler: handlerOrConfig,
703
- schema: optionsOrSchema,
704
- };
705
- } else {
706
- // Just the handler
707
- methodConfig = handlerOrConfig;
708
- }
709
- } else if (typeof handlerOrConfig === 'object' && handlerOrConfig.handler) {
710
- // Config object
711
- methodConfig = handlerOrConfig;
712
- } else {
713
- throw new Error('Invalid handler configuration');
714
- }
715
-
716
- this.#methods[name] = methodConfig;
717
-
718
- const config = typeof methodConfig === 'function' ? {} : methodConfig;
719
- this.#logger.debug('Method registered', {
720
- method: name,
721
- hasSchema: !!config.schema,
722
- exposeSchema: !!config.exposeSchema,
723
- hasDescription: !!config.description,
724
- });
725
- }
726
-
727
- /**
728
- * Add middleware for specific hooks
729
- * @param {string} hook
730
- * @param {Function} middleware
731
- */
732
- use(hook, middleware) {
733
- this.#middleware.use(hook, middleware);
734
- }
735
-
736
- /**
737
- * Remove method
738
- * @param {string} name
739
- */
740
- removeMethod(name) {
741
- delete this.#methods[name];
742
- this.#logger.debug('Method removed', { method: name });
743
- }
744
-
745
- /**
746
- * Get method configuration
747
- * @param {string} name
748
- * @returns {any}
749
- */
750
- getMethod(name) {
751
- return this.#methods[name];
752
- }
753
-
754
- /**
755
- * List all registered methods
756
- * @returns {string[]}
757
- */
758
- listMethods() {
759
- return Object.keys(this.#methods);
760
- }
761
-
762
- /** @returns {string} The endpoint path (e.g. "/api"). */
763
- get endpoint() {
764
- return this.#endpoint;
765
- }
766
-
767
- /**
768
- * @returns {{ [name: string]: any }}
769
- * All registered methods.
770
- */
771
- get methods() {
772
- return this.#methods;
773
- }
774
-
775
- /**
776
- * Get logger instance
777
- * @returns {Logger}
778
- */
779
- get logger() {
780
- return this.#logger;
781
- }
782
-
783
- /**
784
- * Get middleware manager
785
- * @returns {MiddlewareManager}
786
- */
787
- get middleware() {
788
- return this.#middleware;
789
- }
790
-
791
- /**
792
- * Get validator instance
793
- * @returns {SchemaValidator}
794
- */
795
- get validator() {
796
- return this.#validator;
797
- }
798
-
799
- /**
800
- * Get metrics data
801
- * @returns {Object}
802
- */
803
- getMetrics() {
804
- return {
805
- methods: Object.keys(this.#methods).length,
806
- middleware: {
807
- beforeCall: this.#middleware.getMiddlewares('beforeCall').length,
808
- afterCall: this.#middleware.getMiddlewares('afterCall').length,
809
- onError: this.#middleware.getMiddlewares('onError').length,
810
- },
811
- uptime: process.uptime(),
812
- memory: process.memoryUsage(),
813
- timestamp: new Date().toISOString(),
814
- };
815
- }
816
-
817
- /**
818
- * Send a JSON-RPC 2.0 response.
819
- * @param {Response} res The Express response object.
820
- * @param {{
821
- * id?: string|number|null,
822
- * result?: any,
823
- * error?: { code: number, message: string, data?: any }
824
- * }} responsePayload The JSON-RPC response fields.
825
- */
826
- reply(res, { id, result, error }) {
827
- const response = { jsonrpc: '2.0', id: id === undefined ? null : id };
828
-
829
- if (error) {
830
- response.error = error;
831
- } else {
832
- response.result = result;
833
- }
834
-
835
- // Add safe options headers so client knows how to deserialize
836
- // Send safe options headers to client
837
- res.setHeader(
838
- 'X-RPC-Safe-Enabled',
839
- this.#options.safeEnabled ? 'true' : 'false'
840
- );
841
-
842
- res.json(response);
843
- }
844
-
845
- /**
846
- * Recursively convert BigInt values to strings and Date objects to ISO strings.
847
- * This is crucial because JSON.stringify() cannot handle BigInt natively
848
- * and Date objects are best transmitted in a standardized format.
849
- *
850
- * @param {any} value The value to serialize.
851
- * @returns {any} A version of `value` safe for JSON serialization.
852
- */
853
- serializeBigIntsAndDates(value) {
854
- return this.#serializeValue(value, false);
855
- }
856
-
857
- /**
858
- * Internal serialization method with BigInt and Date warning tracking
859
- * @private
860
- */
861
- #serializeValue(value, hasBigInt = false, hasDate = false) {
862
- if (typeof value === 'bigint') {
863
- // Warn if safeEnabled is disabled and we have BigInt
864
- if (
865
- !this.#options.safeEnabled &&
866
- !hasBigInt &&
867
- this.#options.warnOnUnsafe
868
- ) {
869
- this.#logger.warn(
870
- 'BigInt detected in serialization. Consider enabling safeEnabled option to avoid potential string/BigInt confusion.'
871
- );
872
- }
873
- // Convert BigInt to string with 'n' suffix for proper deserialization
874
- return `${value.toString()}n`;
875
- }
876
- if (value instanceof Date) {
877
- // Warn if safeEnabled is disabled and we have Date
878
- if (
879
- !this.#options.safeEnabled &&
880
- !hasDate &&
881
- this.#options.warnOnUnsafe
882
- ) {
883
- this.#logger.warn(
884
- 'Date detected in serialization. Consider enabling safeEnabled option to avoid potential string/Date confusion.'
885
- );
886
- }
887
- // Convert Date to ISO string with D: prefix if safeEnabled
888
- const isoString = value.toISOString();
889
- return this.#options.safeEnabled ? `D:${isoString}` : isoString;
890
- }
891
- if (typeof value === 'string') {
892
- // Add S: prefix if safeEnabled is true
893
- if (this.#options.safeEnabled) {
894
- return `S:${value}`;
895
- }
896
- return value;
897
- }
898
- if (Array.isArray(value)) {
899
- // Recurse into arrays - don't pass hasBigInt/hasDate to children
900
- // Each element should be checked independently for warnings
901
- return value.map((v) => this.#serializeValue(v, false, false));
902
- }
903
- if (value && typeof value === 'object') {
904
- // Recurse into plain objects - don't pass hasBigInt/hasDate to children
905
- // Each property should be checked independently for warnings
906
- const result = {};
907
- for (const [key, val] of Object.entries(value)) {
908
- result[key] = this.#serializeValue(val, false, false);
909
- }
910
- return result;
911
- }
912
-
913
- // If it's none of the above, return as-is
914
- return value;
915
- }
916
-
917
- /**
918
- * Check if an object has BigInt values deep inside
919
- * @private
920
- */
921
- #hasDeepBigInt(obj) {
922
- if (typeof obj === 'bigint') return true;
923
- if (Array.isArray(obj))
924
- return obj.some((item) => this.#hasDeepBigInt(item));
925
- if (obj && typeof obj === 'object') {
926
- return Object.values(obj).some((val) => this.#hasDeepBigInt(val));
927
- }
928
- return false;
929
- }
930
-
931
- /**
932
- * Check if an object has Date values deep inside
933
- * @private
934
- */
935
- #hasDeepDate(obj) {
936
- if (obj instanceof Date) return true;
937
- if (Array.isArray(obj)) return obj.some((item) => this.#hasDeepDate(item));
938
- if (obj && typeof obj === 'object') {
939
- return Object.values(obj).some((val) => this.#hasDeepDate(val));
940
- }
941
- return false;
942
- }
943
-
944
- /**
945
- * Recursively convert stringified BigInts back to BigInt,
946
- * and ISO 8601 date strings back to Date objects.
947
- *
948
- * This is useful if you receive JSON that was serialized
949
- * by `serializeBigIntsAndDates()` and want to re-hydrate
950
- * the original types.
951
- *
952
- * @param {any} value The value to deserialize.
953
- * @param {Object} [options] Custom deserialization options (uses server options if not provided).
954
- * @param {boolean} [options.safeEnabled] Whether to expect safe prefixes for strings and dates.
955
- * @returns {any} The re-hydrated value.
956
- */
957
- deserializeBigIntsAndDates(value, options = null) {
958
- // Use provided options or fall back to server options
959
- const safeEnabled = options
960
- ? options.safeEnabled
961
- : this.#options.safeEnabled;
962
-
963
- // More comprehensive ISO date regex that handles:
964
- // - UTC: 2023-01-01T12:00:00.000Z
965
- // - With timezone: 2023-01-01T12:00:00.000+01:00
966
- // - Without timezone: 2023-01-01T12:00:00.000 (treated as local)
967
- const ISO_DATE_REGEX =
968
- /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})?$/;
969
-
970
- // 1. Check if it's a string that might be a BigInt, Date, or safe string
971
- if (typeof value === 'string') {
972
- // Safe string check: if safeEnabled and starts with S:
973
- if (safeEnabled && value.startsWith('S:')) {
974
- return value.substring(2); // Remove 'S:' prefix
975
- }
976
-
977
- // Safe date check: if safeEnabled and starts with D:
978
- if (safeEnabled && value.startsWith('D:')) {
979
- const isoString = value.substring(2); // Remove 'D:' prefix
980
- const date = new Date(isoString);
981
- // Double-check that we got a valid date
982
- if (!Number.isNaN(date.getTime())) {
983
- return date;
984
- }
985
- }
986
-
987
- // BigInt check: matches digits (including negative), ending in "n"
988
- if (/^-?\d+n$/.test(value)) {
989
- return BigInt(value.slice(0, -1)); // Remove 'n' and convert
990
- }
991
-
992
- // Date check: matches an ISO 8601 string (only if safeEnabled is false)
993
- if (!safeEnabled && ISO_DATE_REGEX.test(value)) {
994
- const date = new Date(value);
995
- // Double-check that we got a valid date
996
- if (!Number.isNaN(date.getTime())) {
997
- return date;
998
- }
999
- }
1000
- }
1001
-
1002
- // 2. If it's an array, handle each element
1003
- if (Array.isArray(value)) {
1004
- return value.map((v) => this.deserializeBigIntsAndDates(v, options));
1005
- }
1006
-
1007
- // 3. If it's a plain object, recurse into each property
1008
- if (value && typeof value === 'object') {
1009
- return Object.fromEntries(
1010
- Object.entries(value).map(([key, val]) => [
1011
- key,
1012
- this.deserializeBigIntsAndDates(val, options),
1013
- ])
1014
- );
1015
- }
1016
-
1017
- // 4. For everything else (number, boolean, null, undefined, etc.), return as-is
1018
- return value;
1019
- }
1020
- }
1021
-
1022
- // Import the client class
1023
- const RpcClient = require('./clients/rpc-client');
1024
-
1025
- // Export both server and client, plus utilities
1026
- module.exports = RpcEndpoint;
1027
- module.exports.RpcEndpoint = RpcEndpoint;
1028
- module.exports.RpcClient = RpcClient;
1029
- module.exports.Logger = Logger;
1030
- module.exports.MiddlewareManager = MiddlewareManager;
1031
- module.exports.builtInMiddlewares = builtInMiddlewares;
1032
- module.exports.SchemaValidator = SchemaValidator;
1033
- module.exports.commonSchemas = commonSchemas;
1034
- module.exports.SchemaBuilder = SchemaBuilder;
1035
- module.exports.BatchHandler = BatchHandler;
1
+ const path = require('path');
2
+ const express = require('express');
3
+ const expressPkg = require('express/package.json');
4
+ const NestedError = require('nested-error-stacks');
5
+ const {
6
+ RpcClient,
7
+ RpcError,
8
+ RpcHttpError,
9
+ RpcSafeClient,
10
+ } = require('rpc-toolkit-js-client');
11
+ const pkg = require('../package.json');
12
+
13
+ const rpcClientAssetsPath = path.dirname(
14
+ require.resolve('rpc-toolkit-js-client')
15
+ );
16
+ const DEFAULT_MAX_SERIALIZATION_DEPTH = 100;
17
+
18
+ /** @typedef {import("express").Router} Router */
19
+ /** @typedef {import("express").Request} Request */
20
+ /** @typedef {import("express").Response} Response */
21
+
22
+ const Logger = require('./logger');
23
+ const { MiddlewareManager, builtInMiddlewares } = require('./middleware');
24
+ const {
25
+ SchemaValidator,
26
+ commonSchemas,
27
+ SchemaBuilder,
28
+ } = require('./validation');
29
+ const BatchHandler = require('./batch');
30
+ const { hasOwn, validateEnvelope } = require('./protocol');
31
+
32
+ /**
33
+ * Default properties to include in error serialization.
34
+ * @type {Array<string>}
35
+ */
36
+ const defaultProperties = [
37
+ 'stackTraceLimit',
38
+ 'cause',
39
+ 'code',
40
+ 'message',
41
+ 'stack',
42
+ 'address',
43
+ 'dest',
44
+ 'errno',
45
+ 'info',
46
+ 'path',
47
+ 'port',
48
+ 'syscall',
49
+ 'opensslErrorStack',
50
+ 'function',
51
+ 'library',
52
+ 'reason',
53
+ ];
54
+
55
+ /**
56
+ * Serializes an error into a JSON-compatible format, with optional sanitization.
57
+ * @param {Error | NestedError | Object} error - The error to serialize.
58
+ * @param {boolean} sanitize - Whether to remove sensitive fields.
59
+ * @param {Array<string>} properties - Properties to include in serialization.
60
+ * @returns {Object} The serialized error.
61
+ */
62
+ const serializeError = (
63
+ error,
64
+ sanitize = false,
65
+ properties = defaultProperties
66
+ ) => {
67
+ if (error === null || error === undefined) return error;
68
+
69
+ const result = {};
70
+ const includeProperties = sanitize
71
+ ? properties.filter((prop) => !['address', 'path'].includes(prop))
72
+ : properties;
73
+
74
+ includeProperties.forEach((prop) => {
75
+ if (error?.[prop] !== undefined) {
76
+ result[prop] = error[prop];
77
+ }
78
+ });
79
+
80
+ if (error instanceof NestedError) {
81
+ result.type = 'NestedError';
82
+ if (error.nested)
83
+ result.nested = serializeError(error.nested, sanitize, properties);
84
+ } else if (error instanceof Error) {
85
+ result.type = 'Error';
86
+ } else if (error instanceof Object) {
87
+ result.type = 'Object';
88
+ Object.assign(result, error);
89
+ } else {
90
+ result.type = 'Default';
91
+ result.instance = error;
92
+ }
93
+
94
+ return result;
95
+ };
96
+
97
+ /**
98
+ * @template C
99
+ */
100
+ class RpcEndpoint {
101
+ /**
102
+ * Serve client scripts for making JSON-RPC calls from the browser.
103
+ * @param {Router} router Express.Router instance
104
+ * @param {string} [url="/vendor/rpc-client"]
105
+ * The path from which client scripts will be served.
106
+ */
107
+ static serveScripts(router, url = '/vendor/rpc-client') {
108
+ router.use(url, express.static(rpcClientAssetsPath));
109
+ }
110
+
111
+ /** @type {string} */
112
+ #endpoint;
113
+
114
+ /** @type {{ [name: string]: any }} */
115
+ #methods = {};
116
+
117
+ /** @type {string} */
118
+ #introspectionPrefix = '__rpc';
119
+
120
+ /** @type {boolean} */
121
+ #isInternalRegistration = false;
122
+
123
+ /** @type {Logger} */
124
+ #logger;
125
+
126
+ /** @type {MiddlewareManager} */
127
+ #middleware;
128
+
129
+ /** @type {SchemaValidator} */
130
+ #validator;
131
+
132
+ /** @type {BatchHandler} */
133
+ #batchHandler;
134
+
135
+ /** @type {Object} */
136
+ #options;
137
+
138
+ /**
139
+ * @param {Router} router The Express router to attach the endpoint to.
140
+ * @param {C} context The context object to pass to the method handlers.
141
+ * @param {string|Object} [endpointOrOptions="/api"] The endpoint path or options object.
142
+ * @param {Object} [options={}] Options object if first param is string.
143
+ */
144
+ constructor(router, context, endpointOrOptions = '/api', options = {}) {
145
+ // Handle both signatures: (router, context, "/api", options) and (router, context, options)
146
+ if (typeof endpointOrOptions === 'string') {
147
+ this.#endpoint = endpointOrOptions;
148
+ this.#options = { ...options };
149
+ } else {
150
+ this.#endpoint = endpointOrOptions.endpoint || '/api';
151
+ this.#options = { ...endpointOrOptions };
152
+ }
153
+
154
+ // Initialize components
155
+ this.#logger = new Logger(this.#options.logging || {});
156
+ this.#middleware = new MiddlewareManager();
157
+ this.#validator = new SchemaValidator(this.#options.validation || {});
158
+ this.#batchHandler = new BatchHandler(this);
159
+
160
+ // Set default options
161
+ this.#options.safeEnabled = this.#options.safeEnabled === true; // Default false
162
+ this.#options.warnOnUnsafe = this.#options.warnOnUnsafe !== false; // Default true
163
+ this.#options.strictMode = this.#options.strictMode !== false; // Default true
164
+ this.#options.enableIntrospection =
165
+ this.#options.enableIntrospection === true; // Default false
166
+ this.#options.introspectionPrefix =
167
+ this.#options.introspectionPrefix || '__rpc'; // Default __rpc
168
+
169
+ this.#introspectionPrefix = this.#options.introspectionPrefix;
170
+
171
+ // Validate that the router can handle JSON parsing
172
+ this.#validateJsonMiddleware();
173
+
174
+ // Register introspection methods if enabled
175
+ if (this.#options.enableIntrospection) {
176
+ this.#registerIntrospectionMethods();
177
+ }
178
+
179
+ // Setup built-in middleware if configured
180
+ this.#setupBuiltInMiddleware();
181
+
182
+ // Wire up routes
183
+ this.#setupRoutes(router, context);
184
+ }
185
+
186
+ /**
187
+ * Setup built-in middleware based on options
188
+ */
189
+ #setupBuiltInMiddleware() {
190
+ const { rateLimit, cors, auth, timing, methodWhitelist } = this.#options;
191
+
192
+ if (timing !== false) {
193
+ this.use('beforeCall', builtInMiddlewares.timing());
194
+ }
195
+
196
+ if (cors) {
197
+ this.use('beforeCall', builtInMiddlewares.cors(cors));
198
+ }
199
+
200
+ if (rateLimit) {
201
+ this.use('beforeCall', builtInMiddlewares.rateLimit(rateLimit));
202
+ }
203
+
204
+ if (auth) {
205
+ this.use('beforeCall', builtInMiddlewares.auth(auth));
206
+ }
207
+
208
+ if (methodWhitelist && Array.isArray(methodWhitelist)) {
209
+ this.use(
210
+ 'beforeCall',
211
+ builtInMiddlewares.methodWhitelist(methodWhitelist)
212
+ );
213
+ }
214
+ }
215
+
216
+ /**
217
+ * Register introspection methods (__rpc.*)
218
+ * @private
219
+ */
220
+ #registerIntrospectionMethods() {
221
+ this.#isInternalRegistration = true;
222
+
223
+ // __rpc.listMethods - List all user methods (excludes __rpc.* methods)
224
+ this.addMethod(
225
+ `${this.#introspectionPrefix}.listMethods`,
226
+ async (_req, _context, _params) =>
227
+ Object.keys(this.#methods).filter(
228
+ (name) => !name.startsWith(this.#introspectionPrefix)
229
+ ),
230
+ {
231
+ description: 'List all available RPC methods',
232
+ exposeSchema: true,
233
+ }
234
+ );
235
+
236
+ // __rpc.describe - Get schema and description of a specific method
237
+ this.addMethod(
238
+ `${this.#introspectionPrefix}.describe`,
239
+ async (_req, _context, params) => {
240
+ const methodName = params?.method;
241
+
242
+ if (!methodName || typeof methodName !== 'string') {
243
+ throw {
244
+ code: -32602,
245
+ message: 'Invalid params: method name required',
246
+ };
247
+ }
248
+
249
+ // Prevent introspection of __rpc.* methods
250
+ if (methodName.startsWith(this.#introspectionPrefix)) {
251
+ throw {
252
+ code: -32601,
253
+ message: 'Cannot describe introspection methods',
254
+ };
255
+ }
256
+
257
+ const methodConfig = this.#methods[methodName];
258
+ if (!methodConfig) {
259
+ throw { code: -32601, message: `Method not found: ${methodName}` };
260
+ }
261
+
262
+ // Check if schema is exposed
263
+ const config = typeof methodConfig === 'function' ? {} : methodConfig;
264
+ if (!config.exposeSchema) {
265
+ throw { code: -32601, message: 'Method schema not available' };
266
+ }
267
+
268
+ return {
269
+ name: methodName,
270
+ schema: config.schema || null,
271
+ description: config.description || '',
272
+ };
273
+ },
274
+ {
275
+ schema: {
276
+ type: 'object',
277
+ properties: {
278
+ method: { type: 'string' },
279
+ },
280
+ required: ['method'],
281
+ },
282
+ description: 'Get schema and description of a specific method',
283
+ exposeSchema: true,
284
+ }
285
+ );
286
+
287
+ // __rpc.describeAll - Get all methods with public schemas
288
+ this.addMethod(
289
+ `${this.#introspectionPrefix}.describeAll`,
290
+ async (_req, _context, _params) => {
291
+ const publicMethods = [];
292
+
293
+ for (const [name, methodConfig] of Object.entries(this.#methods)) {
294
+ // Skip introspection methods
295
+ if (name.startsWith(this.#introspectionPrefix)) continue;
296
+
297
+ const config = typeof methodConfig === 'function' ? {} : methodConfig;
298
+ if (config.exposeSchema) {
299
+ publicMethods.push({
300
+ name,
301
+ schema: config.schema || null,
302
+ description: config.description || '',
303
+ });
304
+ }
305
+ }
306
+
307
+ return publicMethods;
308
+ },
309
+ {
310
+ description: 'List all methods with public schemas',
311
+ exposeSchema: true,
312
+ }
313
+ );
314
+
315
+ // __rpc.version - Get toolkit version
316
+ this.addMethod(
317
+ `${this.#introspectionPrefix}.version`,
318
+ async (_req, _context, _params) => ({
319
+ toolkit: 'rpc-express-toolkit',
320
+ version: pkg.version,
321
+ expressVersion: expressPkg.version,
322
+ nodeVersion: process.version,
323
+ }),
324
+ {
325
+ description: 'Get RPC toolkit version information',
326
+ exposeSchema: true,
327
+ }
328
+ );
329
+
330
+ // __rpc.capabilities - Get server capabilities
331
+ this.addMethod(
332
+ `${this.#introspectionPrefix}.capabilities`,
333
+ async (_req, _context, _params) => ({
334
+ safeMode: this.#options.safeEnabled,
335
+ batch: true,
336
+ introspection: true,
337
+ introspectionPrefix: this.#introspectionPrefix,
338
+ validation: !!this.#options.validation,
339
+ cors: !!this.#options.cors,
340
+ auth: !!this.#options.auth,
341
+ rateLimit: !!this.#options.rateLimit,
342
+ methodCount: Object.keys(this.#methods).filter(
343
+ (n) => !n.startsWith(this.#introspectionPrefix)
344
+ ).length,
345
+ }),
346
+ {
347
+ description: 'Get server capabilities and configuration',
348
+ exposeSchema: true,
349
+ }
350
+ );
351
+
352
+ this.#isInternalRegistration = false;
353
+ }
354
+
355
+ /**
356
+ * Validate JSON middleware functionality and Express compatibility
357
+ * @private
358
+ */
359
+ #validateJsonMiddleware() {
360
+ try {
361
+ // Check if express.json is available (Express 4.16.0+)
362
+ if (typeof express.json !== 'function') {
363
+ throw new Error(
364
+ 'express.json() is not available. Please ensure you are using Express 4.16.0 or later, or install body-parser separately.'
365
+ );
366
+ }
367
+
368
+ // Test JSON middleware creation with default options
369
+ const jsonMiddleware = express.json({ limit: '10mb' });
370
+
371
+ if (typeof jsonMiddleware !== 'function') {
372
+ throw new Error(
373
+ 'express.json() did not return a valid middleware function'
374
+ );
375
+ }
376
+
377
+ // Verify middleware has correct signature (req, res, next)
378
+ if (jsonMiddleware.length !== 3) {
379
+ throw new Error(
380
+ 'JSON middleware has invalid signature - expected (req, res, next)'
381
+ );
382
+ }
383
+
384
+ this.#logger.debug('JSON middleware validation passed', {
385
+ expressJsonAvailable: true,
386
+ middlewareValid: true,
387
+ endpoint: this.#endpoint,
388
+ });
389
+ } catch (error) {
390
+ const errorMsg =
391
+ `JSON middleware validation failed: ${error.message}\n` +
392
+ `This router cannot handle JSON-RPC requests properly.\n\n` +
393
+ `Possible solutions:\n` +
394
+ `- Update Express: npm install express@latest (requires >=4.16.0)\n` +
395
+ `- Add JSON parsing middleware manually: router.use(express.json())\n` +
396
+ `- Install body-parser: npm install body-parser`;
397
+
398
+ this.#logger.error('JSON middleware validation failed', {
399
+ error: error.message,
400
+ expressVersion: expressPkg?.version || 'unknown',
401
+ endpoint: this.#endpoint,
402
+ });
403
+
404
+ throw new Error(errorMsg);
405
+ }
406
+ }
407
+
408
+ /**
409
+ * Setup Express routes
410
+ * @param {Router} router
411
+ * @param {C} context
412
+ */
413
+ #setupRoutes(router, context) {
414
+ // Health check endpoint
415
+ if (this.#options.healthCheck !== false) {
416
+ router.get(`${this.#endpoint}/health`, (req, res) => {
417
+ res.json({
418
+ status: 'ok',
419
+ timestamp: new Date().toISOString(),
420
+ version: pkg.version,
421
+ });
422
+ });
423
+ }
424
+
425
+ // Metrics endpoint
426
+ if (this.#options.metrics) {
427
+ router.get(`${this.#endpoint}/metrics`, (req, res) => {
428
+ res.json(this.getMetrics());
429
+ });
430
+ }
431
+
432
+ // Main JSON-RPC endpoint
433
+ router.post(this.#endpoint, async (req, res) => {
434
+ const startTime = Date.now();
435
+
436
+ try {
437
+ // Check if it's a batch request
438
+ if (this.#batchHandler.isBatchRequest(req.body)) {
439
+ this.#logger.info('Batch request received', {
440
+ batchSize: req.body.length,
441
+ ip: req.ip,
442
+ });
443
+
444
+ const results = await this.#batchHandler.processBatch(
445
+ req.body,
446
+ req,
447
+ res,
448
+ context
449
+ );
450
+
451
+ // Only send response if there are results (non-notification requests)
452
+ if (!Array.isArray(results)) {
453
+ res.setHeader(
454
+ 'X-RPC-Safe-Enabled',
455
+ this.#options.safeEnabled ? 'true' : 'false'
456
+ );
457
+ res.json(results);
458
+ } else if (results.length > 0) {
459
+ // Align batch responses with single-call header negotiation
460
+ res.setHeader(
461
+ 'X-RPC-Safe-Enabled',
462
+ this.#options.safeEnabled ? 'true' : 'false'
463
+ );
464
+ res.json(results);
465
+ } else {
466
+ res.status(204).end(); // No content for all-notification batch
467
+ }
468
+
469
+ this.#logger.info('Batch request completed', {
470
+ batchSize: req.body.length,
471
+ responseCount: Array.isArray(results) ? results.length : 1,
472
+ duration: Date.now() - startTime,
473
+ });
474
+
475
+ return;
476
+ }
477
+
478
+ // Single request processing
479
+ await this.#processSingleRequest(req, res, context, startTime);
480
+ } catch (error) {
481
+ this.#logger.error('Endpoint error', {
482
+ error: error.message,
483
+ stack: error.stack,
484
+ });
485
+
486
+ res.json({
487
+ jsonrpc: '2.0',
488
+ id: null,
489
+ error: {
490
+ code: -32603,
491
+ message: 'Internal error',
492
+ data: serializeError(error, true),
493
+ },
494
+ });
495
+ }
496
+ });
497
+ }
498
+
499
+ /**
500
+ * Process a single JSON-RPC request
501
+ * @param {Request} req
502
+ * @param {Response} res
503
+ * @param {C} context
504
+ * @param {number} startTime
505
+ */
506
+ async #processSingleRequest(req, res, context, startTime) {
507
+ const { method, params, id } = req.body || {};
508
+ const envelope = validateEnvelope(req.body);
509
+
510
+ // Log the incoming request
511
+ this.#logger.rpcCall(method, params, id, req);
512
+
513
+ if (!envelope.valid) {
514
+ return this.reply(res, {
515
+ id: envelope.responseId,
516
+ error: envelope.error,
517
+ });
518
+ }
519
+
520
+ const methodConfig = this.#methods[method];
521
+ if (!methodConfig) {
522
+ if (envelope.isNotification) {
523
+ res.status(204).end();
524
+ return null;
525
+ }
526
+
527
+ return this.reply(res, {
528
+ id,
529
+ error: { code: -32601, message: `Method "${method}" not found` },
530
+ });
531
+ }
532
+
533
+ const handler =
534
+ typeof methodConfig === 'function' ? methodConfig : methodConfig.handler;
535
+ const schema =
536
+ typeof methodConfig === 'object' ? methodConfig.schema : null;
537
+
538
+ try {
539
+ // Check client's safe options from request headers
540
+ const clientSafeHeader = req.headers['x-rpc-safe-enabled'];
541
+
542
+ // Strict mode: se server ha safe enabled ma client non invia header → JSON-RPC ERROR
543
+ if (
544
+ this.#options.strictMode &&
545
+ this.#options.safeEnabled &&
546
+ !clientSafeHeader
547
+ ) {
548
+ if (envelope.isNotification) {
549
+ res.status(204).end();
550
+ return null;
551
+ }
552
+
553
+ return this.reply(res, {
554
+ id,
555
+ error: {
556
+ code: -32600, // Invalid Request
557
+ message:
558
+ 'RPC Compatibility Error: Server requires safe serialization header but client did not provide it.',
559
+ data: {
560
+ serverSafeEnabled: this.#options.safeEnabled,
561
+ requiredHeader: 'X-RPC-Safe-Enabled',
562
+ strictMode: true,
563
+ solution:
564
+ 'Update client to rpc-express-toolkit v4+ or disable server strict mode',
565
+ },
566
+ },
567
+ });
568
+ }
569
+
570
+ // Use client's safe options for parameter deserialization
571
+ const clientSafeEnabled = clientSafeHeader === 'true';
572
+ const deserializationOptions = {
573
+ safeEnabled: clientSafeEnabled,
574
+ };
575
+
576
+ // Deserialize parameters using client's safe options
577
+ const deserializedParams = hasOwn(req.body, 'params')
578
+ ? this.deserializeBigIntsAndDates(params, deserializationOptions)
579
+ : params;
580
+
581
+ // Prepare middleware context
582
+ let middlewareContext = {
583
+ req,
584
+ res,
585
+ method,
586
+ params: deserializedParams,
587
+ context,
588
+ id,
589
+ startTime,
590
+ };
591
+
592
+ // Execute beforeCall middleware
593
+ middlewareContext = await this.#middleware.execute(
594
+ 'beforeCall',
595
+ middlewareContext
596
+ );
597
+
598
+ // Validate parameters if schema is provided
599
+ if (schema) {
600
+ middlewareContext = await this.#middleware.execute(
601
+ 'beforeValidation',
602
+ middlewareContext
603
+ );
604
+
605
+ const validation = this.#validator.validate(
606
+ middlewareContext.params,
607
+ schema
608
+ );
609
+ if (!validation.valid) {
610
+ const error = new Error('Validation failed');
611
+ error.code = -32602;
612
+ error.data = {
613
+ validationErrors: validation.errors.map((err) => ({
614
+ field: err.instancePath || err.schemaPath,
615
+ message: err.message,
616
+ value: err.data,
617
+ })),
618
+ };
619
+ throw error;
620
+ }
621
+
622
+ middlewareContext.params = validation.data;
623
+ middlewareContext = await this.#middleware.execute(
624
+ 'afterValidation',
625
+ middlewareContext
626
+ );
627
+ }
628
+
629
+ // Invoke the handler
630
+ const result = await Promise.resolve(
631
+ handler(req, context, middlewareContext.params)
632
+ );
633
+
634
+ // Execute afterCall middleware
635
+ middlewareContext.result = result;
636
+ await this.#middleware.execute('afterCall', middlewareContext);
637
+
638
+ // Log success
639
+ const duration = Date.now() - startTime;
640
+ this.#logger.rpcSuccess(method, id, duration, result);
641
+
642
+ // Convert any BigInt/Date values before JSON-stringifying
643
+ const safeResult = this.serializeBigIntsAndDates(result);
644
+ if (envelope.isNotification) {
645
+ res.status(204).end();
646
+ return null;
647
+ }
648
+
649
+ this.reply(res, { id, result: safeResult });
650
+ } catch (err) {
651
+ const duration = Date.now() - startTime;
652
+ this.#logger.rpcError(method, id, duration, err);
653
+
654
+ // Execute error middleware
655
+ try {
656
+ await this.#middleware.execute('onError', {
657
+ req,
658
+ res,
659
+ method,
660
+ params,
661
+ context,
662
+ id,
663
+ error: err,
664
+ startTime,
665
+ duration,
666
+ });
667
+ } catch (middlewareError) {
668
+ this.#logger.error('Error middleware failed', {
669
+ error: middlewareError.message,
670
+ });
671
+ }
672
+
673
+ if (envelope.isNotification) {
674
+ res.status(204).end();
675
+ return null;
676
+ }
677
+
678
+ this.reply(res, {
679
+ id,
680
+ error: {
681
+ code: err.code || -32603,
682
+ message: err.message || `Internal error`,
683
+ ...(err.data && { data: err.data }),
684
+ error: serializeError(err, true),
685
+ },
686
+ });
687
+ }
688
+ // Ensure explicit return for consistent-return rule
689
+ return null;
690
+ }
691
+
692
+ /**
693
+ * Register a new JSON-RPC method.
694
+ * @param {string} name The method name.
695
+ * @param {Function|Object} handlerOrConfig The function to handle calls or config object with handler, schema, exposeSchema, description.
696
+ * @param {Object} [optionsOrSchema] Optional config object or schema for backward compatibility.
697
+ */
698
+ addMethod(name, handlerOrConfig, optionsOrSchema = null) {
699
+ // Prevent users from registering introspection methods
700
+ if (
701
+ name.startsWith(this.#introspectionPrefix) &&
702
+ !this.#isInternalRegistration
703
+ ) {
704
+ throw new Error(
705
+ `Method names starting with '${this.#introspectionPrefix}.' are reserved for RPC introspection`
706
+ );
707
+ }
708
+
709
+ let methodConfig;
710
+
711
+ if (typeof handlerOrConfig === 'function') {
712
+ // Function handler - optionsOrSchema can be schema (backward compat) or options object
713
+ if (
714
+ optionsOrSchema &&
715
+ (optionsOrSchema.schema ||
716
+ optionsOrSchema.exposeSchema ||
717
+ optionsOrSchema.description)
718
+ ) {
719
+ // New format: options object
720
+ methodConfig = {
721
+ handler: handlerOrConfig,
722
+ ...optionsOrSchema,
723
+ };
724
+ } else if (optionsOrSchema) {
725
+ // Old format: schema directly
726
+ methodConfig = {
727
+ handler: handlerOrConfig,
728
+ schema: optionsOrSchema,
729
+ };
730
+ } else {
731
+ // Just the handler
732
+ methodConfig = handlerOrConfig;
733
+ }
734
+ } else if (typeof handlerOrConfig === 'object' && handlerOrConfig.handler) {
735
+ // Config object
736
+ methodConfig = handlerOrConfig;
737
+ } else {
738
+ throw new Error('Invalid handler configuration');
739
+ }
740
+
741
+ this.#methods[name] = methodConfig;
742
+
743
+ const config = typeof methodConfig === 'function' ? {} : methodConfig;
744
+ this.#logger.debug('Method registered', {
745
+ method: name,
746
+ hasSchema: !!config.schema,
747
+ exposeSchema: !!config.exposeSchema,
748
+ hasDescription: !!config.description,
749
+ });
750
+ }
751
+
752
+ /**
753
+ * Add middleware for specific hooks
754
+ * @param {string} hook
755
+ * @param {Function} middleware
756
+ */
757
+ use(hook, middleware) {
758
+ this.#middleware.use(hook, middleware);
759
+ }
760
+
761
+ /**
762
+ * Remove method
763
+ * @param {string} name
764
+ */
765
+ removeMethod(name) {
766
+ delete this.#methods[name];
767
+ this.#logger.debug('Method removed', { method: name });
768
+ }
769
+
770
+ /**
771
+ * Get method configuration
772
+ * @param {string} name
773
+ * @returns {any}
774
+ */
775
+ getMethod(name) {
776
+ return this.#methods[name];
777
+ }
778
+
779
+ /**
780
+ * List all registered methods
781
+ * @returns {string[]}
782
+ */
783
+ listMethods() {
784
+ return Object.keys(this.#methods);
785
+ }
786
+
787
+ /** @returns {string} The endpoint path (e.g. "/api"). */
788
+ get endpoint() {
789
+ return this.#endpoint;
790
+ }
791
+
792
+ /**
793
+ * @returns {{ [name: string]: any }}
794
+ * All registered methods.
795
+ */
796
+ get methods() {
797
+ return this.#methods;
798
+ }
799
+
800
+ /**
801
+ * Get logger instance
802
+ * @returns {Logger}
803
+ */
804
+ get logger() {
805
+ return this.#logger;
806
+ }
807
+
808
+ /**
809
+ * Get middleware manager
810
+ * @returns {MiddlewareManager}
811
+ */
812
+ get middleware() {
813
+ return this.#middleware;
814
+ }
815
+
816
+ /**
817
+ * Get validator instance
818
+ * @returns {SchemaValidator}
819
+ */
820
+ get validator() {
821
+ return this.#validator;
822
+ }
823
+
824
+ /**
825
+ * Get endpoint options
826
+ * @returns {Object}
827
+ */
828
+ get options() {
829
+ return { ...this.#options };
830
+ }
831
+
832
+ /**
833
+ * Get metrics data
834
+ * @returns {Object}
835
+ */
836
+ getMetrics() {
837
+ return {
838
+ methods: Object.keys(this.#methods).length,
839
+ middleware: {
840
+ beforeCall: this.#middleware.getMiddlewares('beforeCall').length,
841
+ afterCall: this.#middleware.getMiddlewares('afterCall').length,
842
+ onError: this.#middleware.getMiddlewares('onError').length,
843
+ },
844
+ uptime: process.uptime(),
845
+ memory: process.memoryUsage(),
846
+ timestamp: new Date().toISOString(),
847
+ };
848
+ }
849
+
850
+ /**
851
+ * Send a JSON-RPC 2.0 response.
852
+ * @param {Response} res The Express response object.
853
+ * @param {{
854
+ * id?: string|number|null,
855
+ * result?: any,
856
+ * error?: { code: number, message: string, data?: any }
857
+ * }} responsePayload The JSON-RPC response fields.
858
+ */
859
+ reply(res, { id, result, error }) {
860
+ const response = { jsonrpc: '2.0', id: id === undefined ? null : id };
861
+
862
+ if (error) {
863
+ response.error = error;
864
+ } else {
865
+ response.result = result;
866
+ }
867
+
868
+ // Add safe options headers so client knows how to deserialize
869
+ // Send safe options headers to client
870
+ res.setHeader(
871
+ 'X-RPC-Safe-Enabled',
872
+ this.#options.safeEnabled ? 'true' : 'false'
873
+ );
874
+
875
+ res.json(response);
876
+ }
877
+
878
+ /**
879
+ * Recursively convert BigInt values to strings and Date objects to ISO strings.
880
+ * This is crucial because JSON.stringify() cannot handle BigInt natively
881
+ * and Date objects are best transmitted in a standardized format.
882
+ *
883
+ * @param {any} value The value to serialize.
884
+ * @returns {any} A version of `value` safe for JSON serialization.
885
+ */
886
+ serializeBigIntsAndDates(value) {
887
+ return this.#serializeValue(value, false, false, {
888
+ depth: 0,
889
+ seen: new WeakSet(),
890
+ });
891
+ }
892
+
893
+ /**
894
+ * Internal serialization method with BigInt and Date warning tracking
895
+ * @private
896
+ */
897
+ #serializeValue(
898
+ value,
899
+ hasBigInt = false,
900
+ hasDate = false,
901
+ state = { depth: 0, seen: new WeakSet() }
902
+ ) {
903
+ const maxDepth =
904
+ this.#options.maxSerializationDepth || DEFAULT_MAX_SERIALIZATION_DEPTH;
905
+ if (state.depth > maxDepth) {
906
+ throw new Error('Serialization depth limit exceeded');
907
+ }
908
+
909
+ if (typeof value === 'bigint') {
910
+ // Warn if safeEnabled is disabled and we have BigInt
911
+ if (
912
+ !this.#options.safeEnabled &&
913
+ !hasBigInt &&
914
+ this.#options.warnOnUnsafe
915
+ ) {
916
+ this.#logger.warn(
917
+ 'BigInt detected in serialization. Consider enabling safeEnabled option to avoid potential string/BigInt confusion.'
918
+ );
919
+ }
920
+ // Convert BigInt to string with 'n' suffix for proper deserialization
921
+ return `${value.toString()}n`;
922
+ }
923
+ if (value instanceof Date) {
924
+ // Warn if safeEnabled is disabled and we have Date
925
+ if (
926
+ !this.#options.safeEnabled &&
927
+ !hasDate &&
928
+ this.#options.warnOnUnsafe
929
+ ) {
930
+ this.#logger.warn(
931
+ 'Date detected in serialization. Consider enabling safeEnabled option to avoid potential string/Date confusion.'
932
+ );
933
+ }
934
+ // Convert Date to ISO string with D: prefix if safeEnabled
935
+ const isoString = value.toISOString();
936
+ return this.#options.safeEnabled ? `D:${isoString}` : isoString;
937
+ }
938
+ if (typeof value === 'string') {
939
+ // Add S: prefix if safeEnabled is true
940
+ if (this.#options.safeEnabled) {
941
+ return `S:${value}`;
942
+ }
943
+ return value;
944
+ }
945
+ if (Array.isArray(value)) {
946
+ if (state.seen.has(value)) {
947
+ throw new Error('Circular reference detected during serialization');
948
+ }
949
+
950
+ state.seen.add(value);
951
+ // Recurse into arrays - don't pass hasBigInt/hasDate to children
952
+ // Each element should be checked independently for warnings
953
+ try {
954
+ return value.map((v) =>
955
+ this.#serializeValue(v, false, false, {
956
+ depth: state.depth + 1,
957
+ seen: state.seen,
958
+ })
959
+ );
960
+ } finally {
961
+ state.seen.delete(value);
962
+ }
963
+ }
964
+ if (value && typeof value === 'object') {
965
+ if (state.seen.has(value)) {
966
+ throw new Error('Circular reference detected during serialization');
967
+ }
968
+
969
+ state.seen.add(value);
970
+ // Recurse into plain objects - don't pass hasBigInt/hasDate to children
971
+ // Each property should be checked independently for warnings
972
+ const result = {};
973
+ try {
974
+ Object.entries(value).forEach(([key, val]) => {
975
+ Object.defineProperty(result, key, {
976
+ value: this.#serializeValue(val, false, false, {
977
+ depth: state.depth + 1,
978
+ seen: state.seen,
979
+ }),
980
+ enumerable: true,
981
+ configurable: true,
982
+ writable: true,
983
+ });
984
+ });
985
+ } finally {
986
+ state.seen.delete(value);
987
+ }
988
+ return result;
989
+ }
990
+
991
+ // If it's none of the above, return as-is
992
+ return value;
993
+ }
994
+
995
+ /**
996
+ * Check if an object has BigInt values deep inside
997
+ * @private
998
+ */
999
+ #hasDeepBigInt(obj) {
1000
+ if (typeof obj === 'bigint') return true;
1001
+ if (Array.isArray(obj))
1002
+ return obj.some((item) => this.#hasDeepBigInt(item));
1003
+ if (obj && typeof obj === 'object') {
1004
+ return Object.values(obj).some((val) => this.#hasDeepBigInt(val));
1005
+ }
1006
+ return false;
1007
+ }
1008
+
1009
+ /**
1010
+ * Check if an object has Date values deep inside
1011
+ * @private
1012
+ */
1013
+ #hasDeepDate(obj) {
1014
+ if (obj instanceof Date) return true;
1015
+ if (Array.isArray(obj)) return obj.some((item) => this.#hasDeepDate(item));
1016
+ if (obj && typeof obj === 'object') {
1017
+ return Object.values(obj).some((val) => this.#hasDeepDate(val));
1018
+ }
1019
+ return false;
1020
+ }
1021
+
1022
+ /**
1023
+ * Recursively convert stringified BigInts back to BigInt,
1024
+ * and ISO 8601 date strings back to Date objects.
1025
+ *
1026
+ * This is useful if you receive JSON that was serialized
1027
+ * by `serializeBigIntsAndDates()` and want to re-hydrate
1028
+ * the original types.
1029
+ *
1030
+ * @param {any} value The value to deserialize.
1031
+ * @param {Object} [options] Custom deserialization options (uses server options if not provided).
1032
+ * @param {boolean} [options.safeEnabled] Whether to expect safe prefixes for strings and dates.
1033
+ * @returns {any} The re-hydrated value.
1034
+ */
1035
+ deserializeBigIntsAndDates(value, options = null, state = null) {
1036
+ const traversalState = state || {
1037
+ depth: 0,
1038
+ seen: new WeakSet(),
1039
+ };
1040
+ const maxDepth =
1041
+ this.#options.maxDeserializationDepth || DEFAULT_MAX_SERIALIZATION_DEPTH;
1042
+ if (traversalState.depth > maxDepth) {
1043
+ throw new Error('Deserialization depth limit exceeded');
1044
+ }
1045
+
1046
+ // Use provided options or fall back to server options
1047
+ const safeEnabled = options
1048
+ ? options.safeEnabled
1049
+ : this.#options.safeEnabled;
1050
+
1051
+ // More comprehensive ISO date regex that handles:
1052
+ // - UTC: 2023-01-01T12:00:00.000Z
1053
+ // - With timezone: 2023-01-01T12:00:00.000+01:00
1054
+ // - Without timezone: 2023-01-01T12:00:00.000 (treated as local)
1055
+ const ISO_DATE_REGEX =
1056
+ /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})?$/;
1057
+
1058
+ // 1. Check if it's a string that might be a BigInt, Date, or safe string
1059
+ if (typeof value === 'string') {
1060
+ // Safe string check: if safeEnabled and starts with S:
1061
+ if (safeEnabled && value.startsWith('S:')) {
1062
+ return value.substring(2); // Remove 'S:' prefix
1063
+ }
1064
+
1065
+ // Safe date check: if safeEnabled and starts with D:
1066
+ if (safeEnabled && value.startsWith('D:')) {
1067
+ const isoString = value.substring(2); // Remove 'D:' prefix
1068
+ const date = new Date(isoString);
1069
+ // Double-check that we got a valid date
1070
+ if (!Number.isNaN(date.getTime())) {
1071
+ return date;
1072
+ }
1073
+ }
1074
+
1075
+ // BigInt check: matches digits (including negative), ending in "n"
1076
+ if (/^-?\d+n$/.test(value)) {
1077
+ return BigInt(value.slice(0, -1)); // Remove 'n' and convert
1078
+ }
1079
+
1080
+ // Date check: matches an ISO 8601 string (only if safeEnabled is false)
1081
+ if (!safeEnabled && ISO_DATE_REGEX.test(value)) {
1082
+ const date = new Date(value);
1083
+ // Double-check that we got a valid date
1084
+ if (!Number.isNaN(date.getTime())) {
1085
+ return date;
1086
+ }
1087
+ }
1088
+ }
1089
+
1090
+ // 2. If it's an array, handle each element
1091
+ if (Array.isArray(value)) {
1092
+ if (traversalState.seen.has(value)) {
1093
+ throw new Error('Circular reference detected during deserialization');
1094
+ }
1095
+
1096
+ traversalState.seen.add(value);
1097
+ try {
1098
+ return value.map((v) =>
1099
+ this.deserializeBigIntsAndDates(v, options, {
1100
+ depth: traversalState.depth + 1,
1101
+ seen: traversalState.seen,
1102
+ })
1103
+ );
1104
+ } finally {
1105
+ traversalState.seen.delete(value);
1106
+ }
1107
+ }
1108
+
1109
+ // 3. If it's a plain object, recurse into each property
1110
+ if (value && typeof value === 'object') {
1111
+ if (traversalState.seen.has(value)) {
1112
+ throw new Error('Circular reference detected during deserialization');
1113
+ }
1114
+
1115
+ traversalState.seen.add(value);
1116
+ const result = {};
1117
+ try {
1118
+ Object.entries(value).forEach(([key, val]) => {
1119
+ Object.defineProperty(result, key, {
1120
+ value: this.deserializeBigIntsAndDates(val, options, {
1121
+ depth: traversalState.depth + 1,
1122
+ seen: traversalState.seen,
1123
+ }),
1124
+ enumerable: true,
1125
+ configurable: true,
1126
+ writable: true,
1127
+ });
1128
+ });
1129
+ } finally {
1130
+ traversalState.seen.delete(value);
1131
+ }
1132
+ return result;
1133
+ }
1134
+
1135
+ // 4. For everything else (number, boolean, null, undefined, etc.), return as-is
1136
+ return value;
1137
+ }
1138
+ }
1139
+
1140
+ // Export both server and client, plus utilities
1141
+ module.exports = RpcEndpoint;
1142
+ module.exports.RpcEndpoint = RpcEndpoint;
1143
+ module.exports.RpcClient = RpcClient;
1144
+ module.exports.RpcError = RpcError;
1145
+ module.exports.RpcHttpError = RpcHttpError;
1146
+ module.exports.RpcSafeClient = RpcSafeClient;
1147
+ module.exports.Logger = Logger;
1148
+ module.exports.MiddlewareManager = MiddlewareManager;
1149
+ module.exports.builtInMiddlewares = builtInMiddlewares;
1150
+ module.exports.SchemaValidator = SchemaValidator;
1151
+ module.exports.commonSchemas = commonSchemas;
1152
+ module.exports.SchemaBuilder = SchemaBuilder;
1153
+ module.exports.BatchHandler = BatchHandler;