rpc-express-toolkit 4.0.0 → 4.0.1

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.
@@ -1,392 +1,403 @@
1
- /**
2
- * JSON-RPC 2.0 Client for browser and Node.js environments.
3
- * Handles BigInt and Date serialization/deserialization automatically.
4
- *
5
- * Note: We extend BigInt.prototype.toJSON to handle automatic serialization
6
- * when JSON.stringify() is called, as BigInt doesn't have native JSON support.
7
- */
8
-
9
- // Extend BigInt prototype to handle JSON serialization
10
- if (typeof BigInt !== 'undefined' && !BigInt.prototype.toJSON) {
11
- BigInt.prototype.toJSON = function () {
12
- return this.toString();
13
- };
14
- }
15
-
16
- // Polyfill for Node.js environments that don't have fetch
17
- let fetchFn;
18
- if (typeof fetch !== 'undefined') {
19
- fetchFn = fetch;
20
- } else {
21
- // Try to use node-fetch if available, otherwise use built-in fetch in Node.js 18+
22
- try {
23
- fetchFn = require('node-fetch');
24
- } catch (e) {
25
- // For Node.js 18+, use global fetch
26
- if (typeof globalThis !== 'undefined' && globalThis.fetch) {
27
- fetchFn = globalThis.fetch;
28
- } else {
29
- throw new Error(
30
- 'fetch is not available. Please install node-fetch or use Node.js 18+'
31
- );
32
- }
33
- }
34
- }
35
-
36
- class RpcClient {
37
- #endpoint;
38
- #defaultHeaders;
39
- #requestId;
40
- #fetchOptions;
41
- #options;
42
-
43
- /**
44
- * @param {string} endpoint The JSON-RPC endpoint URL.
45
- * @param {Object} [defaultHeaders={}] Optional default headers to include in requests.
46
- * @param {Object} [options={}] Optional configuration options.
47
- * @param {boolean} [options.rejectUnauthorized=true] Whether to reject unauthorized SSL certificates. Set to false for development with self-signed certificates.
48
- * @param {boolean} [options.safeEnabled=false] Whether to use safe serialization for both strings and dates to avoid confusion with BigInt/ISO values. Default false for JSON-RPC 2.0 compliance.
49
- * @param {boolean} [options.warnOnUnsafe=true] Whether to show educational warnings when BigInt/Date objects are serialized without safe mode. Default true.
50
- */
51
- constructor(endpoint, defaultHeaders = {}, options = {}) {
52
- this.#endpoint = endpoint;
53
- this.#defaultHeaders = {
54
- 'Content-Type': 'application/json', // Default header
55
- ...defaultHeaders, // Merge with user-provided defaults
56
- };
57
- // Initialize request ID counter with timestamp + random component for uniqueness
58
- this.#requestId = Date.now() * 1000 + Math.floor(Math.random() * 1000);
59
-
60
- // Store options
61
- this.#options = {
62
- safeEnabled: options.safeEnabled === true, // Default false
63
- warnOnUnsafe: options.warnOnUnsafe !== false, // Default true
64
- ...options
65
- };
66
-
67
- // Store fetch options for Node.js environments
68
- this.#fetchOptions = {};
69
- // SSL validation: advanced options (agent/ca) have been removed for simplicity and compatibility.
70
- // To bypass self-signed certificates in development, set:
71
- // process.env.NODE_TLS_REJECT_UNAUTHORIZED = "0";
72
- // See README for details and best practices.
73
- }
74
-
75
- /**
76
- * Generate a unique request ID
77
- * @returns {number} Unique request ID
78
- */
79
- #generateId() {
80
- return ++this.#requestId;
81
- }
82
-
83
- /**
84
- * Make a JSON-RPC call to the server.
85
- * @template T
86
- * @param {string} method The RPC method name.
87
- * @param {any} params The parameters to pass to the RPC method.
88
- * @param {string|number|null} [id] The request ID (auto-generated if not provided).
89
- * @param {Object} [overrideHeaders={}] Optional headers to override defaults for this request.
90
- * @returns {Promise<T>} The result of the RPC call.
91
- */
92
- async call(method, params, id = undefined, overrideHeaders = {}) {
93
- // Auto-generate ID if not provided (null means notification)
94
- const requestId = id === undefined ? this.#generateId() : id;
95
-
96
- // Build the payload according to the spec: params omitted if undefined/null
97
- const requestBody = {
98
- jsonrpc: '2.0',
99
- method,
100
- id: requestId,
101
- };
102
- if (params !== undefined && params !== null) {
103
- // Serialize parameters to handle BigInt and safe strings
104
- requestBody.params = this.serializeBigIntsAndDates(params);
105
- }
106
-
107
- try {
108
- const response = await fetchFn(this.#endpoint, {
109
- method: 'POST',
110
- headers: {
111
- ...this.#defaultHeaders,
112
- // Add safe options header so server knows what client expects
113
- 'X-RPC-Safe-Enabled': this.#options.safeEnabled ? 'true' : 'false',
114
- ...overrideHeaders,
115
- },
116
- // Since we have BigInt.prototype.toJSON, we don't need manual serialization
117
- // JSON.stringify will handle BigInt and Date automatically
118
- body: JSON.stringify(requestBody),
119
- ...this.#fetchOptions, // Include any additional fetch options (e.g., SSL settings)
120
- });
121
-
122
- if (!response.ok) {
123
- throw new Error(
124
- `HTTP Error: ${response.status} ${response.statusText}`
125
- );
126
- }
127
-
128
- const responseBody = await response.json();
129
-
130
- if (responseBody.error) {
131
- throw responseBody.error;
132
- }
133
-
134
- // Check server's safe options from response headers - strict compatibility checking
135
- const serverSafeHeader = response.headers.get('X-RPC-Safe-Enabled');
136
-
137
- // Errore rigoroso: se client ha safe enabled ma server non risponde con header
138
- if (this.#options.safeEnabled && serverSafeHeader === null) {
139
- throw new Error(
140
- 'RPC Compatibility Error: Client has safe serialization enabled but server did not respond with compatibility header (X-RPC-Safe-Enabled). ' +
141
- 'This may indicate a version mismatch or non-toolkit server. ' +
142
- 'Solutions: (1) Update server to rpc-express-toolkit v4+, (2) Disable client safeEnabled option, or (3) Use a compatible JSON-RPC server.'
143
- );
144
- }
145
-
146
- // Warning: se client ha safe disabled ma server offre compatibilità
147
- if (!this.#options.safeEnabled && serverSafeHeader === 'true' && this.#options.warnOnUnsafe) {
148
- console.warn(
149
- '⚠️ RPC Compatibility Notice: Server supports safe serialization but client has safeEnabled=false. ' +
150
- 'Consider enabling safeEnabled option for better BigInt/Date handling and forward compatibility.'
151
- );
152
- }
153
-
154
- const serverSafeEnabled = serverSafeHeader === 'true';
155
-
156
- // Create deserialization options based on server's configuration
157
- const deserializationOptions = {
158
- safeEnabled: serverSafeEnabled
159
- };
160
-
161
- // Convert back BigInts and Dates in the result using server's options
162
- return this.deserializeBigIntsAndDates(responseBody.result, deserializationOptions);
163
- } catch (error) {
164
- console.error('RPC call failed:', error);
165
- throw error;
166
- }
167
- }
168
-
169
- /**
170
- * Send a notification (no response expected).
171
- * @param {string} method The RPC method name.
172
- * @param {any} params The parameters to pass to the RPC method.
173
- * @param {Object} [overrideHeaders={}] Optional headers to override defaults for this request.
174
- * @returns {Promise<void>} Promise that resolves when notification is sent.
175
- */
176
- async notify(method, params = {}, overrideHeaders = {}) {
177
- await this.call(method, params, null, overrideHeaders);
178
- }
179
-
180
- /**
181
- * Make multiple JSON-RPC calls in a single batch request.
182
- * @template T
183
- * @param {Array<{method: string, params?: any, id?: string|number}>} requests Array of request objects.
184
- * @param {Object} [overrideHeaders={}] Optional headers to override defaults for this request.
185
- * @returns {Promise<Array<T>>} Array of results in the same order as requests.
186
- */
187
- async batch(requests, overrideHeaders = {}) {
188
- const batchRequests = requests.map((req) => {
189
- const obj = {
190
- jsonrpc: '2.0',
191
- method: req.method,
192
- id: req.id !== undefined ? req.id : this.#generateId(),
193
- };
194
- if (req.params !== undefined && req.params !== null) {
195
- // Serialize parameters to handle BigInt and safe strings
196
- obj.params = this.serializeBigIntsAndDates(req.params);
197
- }
198
- return obj;
199
- });
200
-
201
- try {
202
- const response = await fetchFn(this.#endpoint, {
203
- method: 'POST',
204
- headers: {
205
- ...this.#defaultHeaders,
206
- // Add safe options header so server knows what client expects
207
- 'X-RPC-Safe-Enabled': this.#options.safeEnabled ? 'true' : 'false',
208
- ...overrideHeaders,
209
- },
210
- body: JSON.stringify(batchRequests),
211
- ...this.#fetchOptions, // Include any additional fetch options (e.g., SSL settings)
212
- });
213
-
214
- if (!response.ok) {
215
- throw new Error(
216
- `HTTP Error: ${response.status} ${response.statusText}`
217
- );
218
- }
219
-
220
- const responseBody = await response.json();
221
-
222
- // Check server's safe options from response headers - strict compatibility checking
223
- const serverSafeHeader = response.headers.get('X-RPC-Safe-Enabled');
224
-
225
- // Errore rigoroso: se client ha safe enabled ma server non risponde con header
226
- if (this.#options.safeEnabled && serverSafeHeader === null) {
227
- throw new Error(
228
- 'RPC Compatibility Error: Client has safe serialization enabled but server did not respond with compatibility header (X-RPC-Safe-Enabled). ' +
229
- 'This may indicate a version mismatch or non-toolkit server. ' +
230
- 'Solutions: (1) Update server to rpc-express-toolkit v4+, (2) Disable client safeEnabled option, or (3) Use a compatible JSON-RPC server.'
231
- );
232
- }
233
-
234
- // Warning: se client ha safe disabled ma server offre compatibilità
235
- if (!this.#options.safeEnabled && serverSafeHeader === 'true' && this.#options.warnOnUnsafe) {
236
- console.warn(
237
- '⚠️ RPC Compatibility Notice: Server supports safe serialization but client has safeEnabled=false. ' +
238
- 'Consider enabling safeEnabled option for better BigInt/Date handling and forward compatibility.'
239
- );
240
- }
241
-
242
- const serverSafeEnabled = serverSafeHeader === 'true';
243
-
244
- // Create deserialization options based on server's configuration
245
- const deserializationOptions = {
246
- safeEnabled: serverSafeEnabled
247
- };
248
-
249
- // Handle batch response
250
- if (Array.isArray(responseBody)) {
251
- return responseBody.map((res) => {
252
- if (res.error) {
253
- throw res.error;
254
- }
255
- return this.deserializeBigIntsAndDates(res.result, deserializationOptions);
256
- });
257
- } else {
258
- // Single response in batch
259
- if (responseBody.error) {
260
- throw responseBody.error;
261
- }
262
- return [this.deserializeBigIntsAndDates(responseBody.result, deserializationOptions)];
263
- }
264
- } catch (error) {
265
- console.error('Batch RPC call failed:', error);
266
- throw error;
267
- }
268
- }
269
-
270
- /**
271
- * Recursively convert BigInt values to strings and Date objects to ISO strings
272
- * so they can be JSON-serialized.
273
- * @param {any} value
274
- * @returns {any}
275
- */
276
- serializeBigIntsAndDates(value) {
277
- if (typeof value === 'bigint') {
278
- // Convert BigInt to string with 'n' suffix for proper deserialization
279
- return value.toString() + 'n';
280
- } else if (value instanceof Date) {
281
- // Convert Date to ISO string with D: prefix if safeEnabled
282
- const isoString = value.toISOString();
283
- if (this.#options.safeEnabled) {
284
- return `D:${isoString}`;
285
- } else {
286
- // Show educational warning if enabled
287
- if (this.#options.warnOnUnsafe) {
288
- console.warn('⚠️ Date serialization: Using plain ISO string format for JSON-RPC 2.0 compliance. Date objects will be deserialized as strings on the receiving end. Consider enabling safeEnabled or using string timestamps for better type safety.');
289
- }
290
- return isoString;
291
- }
292
- } else if (typeof value === 'string') {
293
- // Add S: prefix if safeEnabled is true
294
- if (this.#options.safeEnabled) {
295
- return 'S:' + value;
296
- } else {
297
- // Show educational warning if enabled and string could be confused with BigInt
298
- if (this.#options.warnOnUnsafe && /^-?\d+n?$/.test(value)) {
299
- console.warn(`⚠️ String serialization: String "${value}" could be confused with BigInt. Consider enabling safeEnabled for disambiguation or use explicit typing.`);
300
- }
301
- return value;
302
- }
303
- } else if (Array.isArray(value)) {
304
- // Recurse into arrays
305
- return value.map((v) => this.serializeBigIntsAndDates(v));
306
- } else if (value && typeof value === 'object') {
307
- // Recurse into plain objects
308
- const result = {};
309
- for (const [key, val] of Object.entries(value)) {
310
- result[key] = this.serializeBigIntsAndDates(val);
311
- }
312
- return result;
313
- }
314
-
315
- // If it's neither an array, an object, a Date, nor a bigint, return as-is
316
- return value;
317
- }
318
-
319
- /**
320
- * Recursively convert stringified BigInt values back to BigInt
321
- * and ISO 8601 date strings back to Date objects.
322
- *
323
- * @param {any} value The value to deserialize.
324
- * @param {Object} [options] Custom deserialization options (uses client options if not provided).
325
- * @param {boolean} [options.safeEnabled] Whether to expect safe prefixes for strings and dates.
326
- * @returns {any}
327
- */
328
- deserializeBigIntsAndDates(value, options = null) {
329
- // Use provided options or fall back to client options
330
- const safeEnabled = options ? options.safeEnabled : this.#options.safeEnabled;
331
-
332
- // More comprehensive ISO date regex that handles:
333
- // - UTC: 2023-01-01T12:00:00.000Z
334
- // - With timezone: 2023-01-01T12:00:00.000+01:00
335
- // - Without timezone: 2023-01-01T12:00:00.000 (treated as local)
336
- const ISO_DATE_REGEX =
337
- /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})?$/;
338
-
339
- // 1. Check if it's a string that might be a BigInt, Date, or safe string
340
- if (typeof value === 'string') {
341
- // Safe string check: if safeEnabled and starts with S:
342
- if (safeEnabled && value.startsWith('S:')) {
343
- return value.substring(2); // Remove 'S:' prefix
344
- }
345
-
346
- // Safe date check: if safeEnabled and starts with D:
347
- if (safeEnabled && value.startsWith('D:')) {
348
- const isoString = value.substring(2); // Remove 'D:' prefix
349
- const date = new Date(isoString);
350
- // Double-check that we got a valid date
351
- if (!isNaN(date.getTime())) {
352
- return date;
353
- }
354
- }
355
-
356
- // BigInt check: only convert strings that explicitly end with "n"
357
- // e.g., "42n", "-42n" but NOT "42", "0123456"
358
- if (/^-?\d+n$/.test(value)) {
359
- return BigInt(value.slice(0, -1)); // Remove 'n' and convert
360
- }
361
-
362
- // Date check: matches an ISO 8601 string (only if safeEnabled is false)
363
- if (!safeEnabled && ISO_DATE_REGEX.test(value)) {
364
- const date = new Date(value);
365
- // Ensure it's valid
366
- if (!isNaN(date.getTime())) {
367
- return date;
368
- }
369
- }
370
- }
371
-
372
- // 2. If it's an array, handle each element
373
- if (Array.isArray(value)) {
374
- return value.map((v) => this.deserializeBigIntsAndDates(v, options));
375
- }
376
-
377
- // 3. If it's a plain object, recurse into each property
378
- if (value && typeof value === 'object') {
379
- return Object.fromEntries(
380
- Object.entries(value).map(([key, val]) => [
381
- key,
382
- this.deserializeBigIntsAndDates(val, options),
383
- ])
384
- );
385
- }
386
-
387
- // 4. Fallback for primitives, etc.
388
- return value;
389
- }
390
- }
391
-
392
- module.exports = RpcClient;
1
+ /**
2
+ * JSON-RPC 2.0 Client for browser and Node.js environments.
3
+ * Handles BigInt and Date serialization/deserialization automatically
4
+ * using explicit serializers (no global prototype extensions).
5
+ */
6
+
7
+ // Select fetch implementation (Node 18+ or browser)
8
+ let fetchFn;
9
+ if (typeof globalThis !== 'undefined' && globalThis.fetch) {
10
+ fetchFn = globalThis.fetch.bind(globalThis);
11
+ } else {
12
+ throw new Error(
13
+ 'globalThis.fetch is not available. Node.js 18+ is required.'
14
+ );
15
+ }
16
+
17
+ class RpcClient {
18
+ #endpoint;
19
+
20
+ #defaultHeaders;
21
+
22
+ #requestId;
23
+
24
+ #fetchOptions;
25
+
26
+ #options;
27
+
28
+ /**
29
+ * @param {string} endpoint The JSON-RPC endpoint URL.
30
+ * @param {Object} [defaultHeaders={}] Optional default headers to include in requests.
31
+ * @param {Object} [options={}] Optional configuration options.
32
+ * @param {boolean} [options.rejectUnauthorized=true] Whether to reject unauthorized SSL certificates. Set to false for development with self-signed certificates.
33
+ * @param {boolean} [options.safeEnabled=false] Whether to use safe serialization for both strings and dates to avoid confusion with BigInt/ISO values. Default false for JSON-RPC 2.0 compliance.
34
+ * @param {boolean} [options.warnOnUnsafe=true] Whether to show educational warnings when BigInt/Date objects are serialized without safe mode. Default true.
35
+ */
36
+ constructor(endpoint, defaultHeaders = {}, options = {}) {
37
+ this.#endpoint = endpoint;
38
+ this.#defaultHeaders = {
39
+ 'Content-Type': 'application/json', // Default header
40
+ ...defaultHeaders, // Merge with user-provided defaults
41
+ };
42
+ // Initialize request ID counter with timestamp + random component for uniqueness
43
+ this.#requestId = Date.now() * 1000 + Math.floor(Math.random() * 1000);
44
+
45
+ // Store options
46
+ this.#options = {
47
+ safeEnabled: options.safeEnabled === true, // Default false
48
+ warnOnUnsafe: options.warnOnUnsafe !== false, // Default true
49
+ ...options,
50
+ };
51
+
52
+ // Store fetch options for Node.js environments
53
+ this.#fetchOptions = {};
54
+ // SSL validation: advanced options (agent/ca) have been removed for simplicity and compatibility.
55
+ // To bypass self-signed certificates in development, set:
56
+ // process.env.NODE_TLS_REJECT_UNAUTHORIZED = "0";
57
+ // See README for details and best practices.
58
+ }
59
+
60
+ /**
61
+ * Generate a unique request ID
62
+ * @returns {number} Unique request ID
63
+ */
64
+ #generateId() {
65
+ return ++this.#requestId;
66
+ }
67
+
68
+ /**
69
+ * Make a JSON-RPC call to the server.
70
+ * @template T
71
+ * @param {string} method The RPC method name.
72
+ * @param {any} params The parameters to pass to the RPC method.
73
+ * @param {string|number|null} [id] The request ID (auto-generated if not provided).
74
+ * @param {Object} [overrideHeaders={}] Optional headers to override defaults for this request.
75
+ * @returns {Promise<T>} The result of the RPC call.
76
+ */
77
+ async call(method, params, id = undefined, overrideHeaders = {}) {
78
+ // Auto-generate ID if not provided (null means notification)
79
+ const requestId = id === undefined ? this.#generateId() : id;
80
+
81
+ // Build the payload according to the spec: params omitted if undefined/null
82
+ const requestBody = {
83
+ jsonrpc: '2.0',
84
+ method,
85
+ id: requestId,
86
+ };
87
+ if (params !== undefined && params !== null) {
88
+ // Serialize parameters to handle BigInt and safe strings
89
+ requestBody.params = this.serializeBigIntsAndDates(params);
90
+ }
91
+
92
+ try {
93
+ const response = await fetchFn(this.#endpoint, {
94
+ method: 'POST',
95
+ headers: {
96
+ ...this.#defaultHeaders,
97
+ // Add safe options header so server knows what client expects
98
+ 'X-RPC-Safe-Enabled': this.#options.safeEnabled ? 'true' : 'false',
99
+ ...overrideHeaders,
100
+ },
101
+ // Params are pre-serialized (BigInt/Date) via serializeBigIntsAndDates
102
+ body: JSON.stringify(requestBody),
103
+ ...this.#fetchOptions, // Include any additional fetch options (e.g., SSL settings)
104
+ });
105
+
106
+ if (!response.ok) {
107
+ throw new Error(
108
+ `HTTP Error: ${response.status} ${response.statusText}`
109
+ );
110
+ }
111
+
112
+ const responseBody = await response.json();
113
+
114
+ if (responseBody.error) {
115
+ throw responseBody.error;
116
+ }
117
+
118
+ // Check server's safe options from response headers - strict compatibility checking
119
+ const serverSafeHeader = response.headers.get('X-RPC-Safe-Enabled');
120
+
121
+ // Errore rigoroso: se client ha safe enabled ma server non risponde con header
122
+ if (this.#options.safeEnabled && serverSafeHeader === null) {
123
+ throw new Error(
124
+ 'RPC Compatibility Error: Client has safe serialization enabled but server did not respond with compatibility header (X-RPC-Safe-Enabled). ' +
125
+ 'This may indicate a version mismatch or non-toolkit server. ' +
126
+ 'Solutions: (1) Update server to rpc-express-toolkit v4+, (2) Disable client safeEnabled option, or (3) Use a compatible JSON-RPC server.'
127
+ );
128
+ }
129
+
130
+ // Warning: se client ha safe disabled ma server offre compatibilità
131
+ if (
132
+ !this.#options.safeEnabled &&
133
+ serverSafeHeader === 'true' &&
134
+ this.#options.warnOnUnsafe
135
+ ) {
136
+ console.warn(
137
+ '⚠️ RPC Compatibility Notice: Server supports safe serialization but client has safeEnabled=false. ' +
138
+ 'Consider enabling safeEnabled option for better BigInt/Date handling and forward compatibility.'
139
+ );
140
+ }
141
+
142
+ const serverSafeEnabled = serverSafeHeader === 'true';
143
+
144
+ // Create deserialization options based on server's configuration
145
+ const deserializationOptions = {
146
+ safeEnabled: serverSafeEnabled,
147
+ };
148
+
149
+ // Convert back BigInts and Dates in the result using server's options
150
+ return this.deserializeBigIntsAndDates(
151
+ responseBody.result,
152
+ deserializationOptions
153
+ );
154
+ } catch (error) {
155
+ console.error('RPC call failed:', error);
156
+ throw error;
157
+ }
158
+ }
159
+
160
+ /**
161
+ * Send a notification (no response expected).
162
+ * @param {string} method The RPC method name.
163
+ * @param {any} params The parameters to pass to the RPC method.
164
+ * @param {Object} [overrideHeaders={}] Optional headers to override defaults for this request.
165
+ * @returns {Promise<void>} Promise that resolves when notification is sent.
166
+ */
167
+ async notify(method, params = {}, overrideHeaders = {}) {
168
+ await this.call(method, params, null, overrideHeaders);
169
+ }
170
+
171
+ /**
172
+ * Make multiple JSON-RPC calls in a single batch request.
173
+ * @template T
174
+ * @param {Array<{method: string, params?: any, id?: string|number}>} requests Array of request objects.
175
+ * @param {Object} [overrideHeaders={}] Optional headers to override defaults for this request.
176
+ * @returns {Promise<Array<T>>} Array of results in the same order as requests.
177
+ */
178
+ async batch(requests, overrideHeaders = {}) {
179
+ const batchRequests = requests.map((req) => {
180
+ const obj = {
181
+ jsonrpc: '2.0',
182
+ method: req.method,
183
+ id: req.id !== undefined ? req.id : this.#generateId(),
184
+ };
185
+ if (req.params !== undefined && req.params !== null) {
186
+ // Serialize parameters to handle BigInt and safe strings
187
+ obj.params = this.serializeBigIntsAndDates(req.params);
188
+ }
189
+ return obj;
190
+ });
191
+
192
+ try {
193
+ const response = await fetchFn(this.#endpoint, {
194
+ method: 'POST',
195
+ headers: {
196
+ ...this.#defaultHeaders,
197
+ // Add safe options header so server knows what client expects
198
+ 'X-RPC-Safe-Enabled': this.#options.safeEnabled ? 'true' : 'false',
199
+ ...overrideHeaders,
200
+ },
201
+ // Params are pre-serialized per item
202
+ body: JSON.stringify(batchRequests),
203
+ ...this.#fetchOptions, // Include any additional fetch options (e.g., SSL settings)
204
+ });
205
+
206
+ if (!response.ok) {
207
+ throw new Error(
208
+ `HTTP Error: ${response.status} ${response.statusText}`
209
+ );
210
+ }
211
+
212
+ const responseBody = await response.json();
213
+
214
+ // Check server's safe options from response headers - strict compatibility checking
215
+ const serverSafeHeader = response.headers.get('X-RPC-Safe-Enabled');
216
+
217
+ // Errore rigoroso: se client ha safe enabled ma server non risponde con header
218
+ if (this.#options.safeEnabled && serverSafeHeader === null) {
219
+ throw new Error(
220
+ 'RPC Compatibility Error: Client has safe serialization enabled but server did not respond with compatibility header (X-RPC-Safe-Enabled). ' +
221
+ 'This may indicate a version mismatch or non-toolkit server. ' +
222
+ 'Solutions: (1) Update server to rpc-express-toolkit v4+, (2) Disable client safeEnabled option, or (3) Use a compatible JSON-RPC server.'
223
+ );
224
+ }
225
+
226
+ // Warning: se client ha safe disabled ma server offre compatibilità
227
+ if (
228
+ !this.#options.safeEnabled &&
229
+ serverSafeHeader === 'true' &&
230
+ this.#options.warnOnUnsafe
231
+ ) {
232
+ console.warn(
233
+ '⚠️ RPC Compatibility Notice: Server supports safe serialization but client has safeEnabled=false. ' +
234
+ 'Consider enabling safeEnabled option for better BigInt/Date handling and forward compatibility.'
235
+ );
236
+ }
237
+
238
+ const serverSafeEnabled = serverSafeHeader === 'true';
239
+
240
+ // Create deserialization options based on server's configuration
241
+ const deserializationOptions = {
242
+ safeEnabled: serverSafeEnabled,
243
+ };
244
+
245
+ // Handle batch response
246
+ if (Array.isArray(responseBody)) {
247
+ return responseBody.map((res) => {
248
+ if (res.error) {
249
+ throw res.error;
250
+ }
251
+ return this.deserializeBigIntsAndDates(
252
+ res.result,
253
+ deserializationOptions
254
+ );
255
+ });
256
+ }
257
+ // Single response in batch
258
+ if (responseBody.error) {
259
+ throw responseBody.error;
260
+ }
261
+ return [
262
+ this.deserializeBigIntsAndDates(
263
+ responseBody.result,
264
+ deserializationOptions
265
+ ),
266
+ ];
267
+ } catch (error) {
268
+ console.error('Batch RPC call failed:', error);
269
+ throw error;
270
+ }
271
+ }
272
+
273
+ /**
274
+ * Recursively convert BigInt values to strings and Date objects to ISO strings
275
+ * so they can be JSON-serialized.
276
+ * @param {any} value
277
+ * @returns {any}
278
+ */
279
+ serializeBigIntsAndDates(value) {
280
+ if (typeof value === 'bigint') {
281
+ // Convert BigInt to string with 'n' suffix for proper deserialization
282
+ return `${value.toString()}n`;
283
+ }
284
+ if (value instanceof Date) {
285
+ // Convert Date to ISO string with D: prefix if safeEnabled
286
+ const isoString = value.toISOString();
287
+ if (this.#options.safeEnabled) {
288
+ return `D:${isoString}`;
289
+ }
290
+ // Show educational warning if enabled
291
+ if (this.#options.warnOnUnsafe) {
292
+ console.warn(
293
+ '⚠️ Date serialization: Using plain ISO string format for JSON-RPC 2.0 compliance. Date objects will be deserialized as strings on the receiving end. Consider enabling safeEnabled or using string timestamps for better type safety.'
294
+ );
295
+ }
296
+ return isoString;
297
+ }
298
+ if (typeof value === 'string') {
299
+ // Add S: prefix if safeEnabled is true
300
+ if (this.#options.safeEnabled) {
301
+ return `S:${value}`;
302
+ }
303
+ // Show educational warning if enabled and string could be confused with BigInt
304
+ if (this.#options.warnOnUnsafe && /^-?\d+n?$/.test(value)) {
305
+ console.warn(
306
+ `⚠️ String serialization: String "${value}" could be confused with BigInt. Consider enabling safeEnabled for disambiguation or use explicit typing.`
307
+ );
308
+ }
309
+ return value;
310
+ }
311
+ if (Array.isArray(value)) {
312
+ // Recurse into arrays
313
+ return value.map((v) => this.serializeBigIntsAndDates(v));
314
+ }
315
+ if (value && typeof value === 'object') {
316
+ // Recurse into plain objects
317
+ const result = {};
318
+ for (const [key, val] of Object.entries(value)) {
319
+ result[key] = this.serializeBigIntsAndDates(val);
320
+ }
321
+ return result;
322
+ }
323
+
324
+ // If it's neither an array, an object, a Date, nor a bigint, return as-is
325
+ return value;
326
+ }
327
+
328
+ /**
329
+ * Recursively convert stringified BigInt values back to BigInt
330
+ * and ISO 8601 date strings back to Date objects.
331
+ *
332
+ * @param {any} value The value to deserialize.
333
+ * @param {Object} [options] Custom deserialization options (uses client options if not provided).
334
+ * @param {boolean} [options.safeEnabled] Whether to expect safe prefixes for strings and dates.
335
+ * @returns {any}
336
+ */
337
+ deserializeBigIntsAndDates(value, options = null) {
338
+ // Use provided options or fall back to client options
339
+ const safeEnabled = options
340
+ ? options.safeEnabled
341
+ : this.#options.safeEnabled;
342
+
343
+ // More comprehensive ISO date regex that handles:
344
+ // - UTC: 2023-01-01T12:00:00.000Z
345
+ // - With timezone: 2023-01-01T12:00:00.000+01:00
346
+ // - Without timezone: 2023-01-01T12:00:00.000 (treated as local)
347
+ const ISO_DATE_REGEX =
348
+ /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})?$/;
349
+
350
+ // 1. Check if it's a string that might be a BigInt, Date, or safe string
351
+ if (typeof value === 'string') {
352
+ // Safe string check: if safeEnabled and starts with S:
353
+ if (safeEnabled && value.startsWith('S:')) {
354
+ return value.substring(2); // Remove 'S:' prefix
355
+ }
356
+
357
+ // Safe date check: if safeEnabled and starts with D:
358
+ if (safeEnabled && value.startsWith('D:')) {
359
+ const isoString = value.substring(2); // Remove 'D:' prefix
360
+ const date = new Date(isoString);
361
+ // Double-check that we got a valid date
362
+ if (!Number.isNaN(date.getTime())) {
363
+ return date;
364
+ }
365
+ }
366
+
367
+ // BigInt check: only convert strings that explicitly end with "n"
368
+ // e.g., "42n", "-42n" but NOT "42", "0123456"
369
+ if (/^-?\d+n$/.test(value)) {
370
+ return BigInt(value.slice(0, -1)); // Remove 'n' and convert
371
+ }
372
+
373
+ // Date check: matches an ISO 8601 string (only if safeEnabled is false)
374
+ if (!safeEnabled && ISO_DATE_REGEX.test(value)) {
375
+ const date = new Date(value);
376
+ // Ensure it's valid
377
+ if (!Number.isNaN(date.getTime())) {
378
+ return date;
379
+ }
380
+ }
381
+ }
382
+
383
+ // 2. If it's an array, handle each element
384
+ if (Array.isArray(value)) {
385
+ return value.map((v) => this.deserializeBigIntsAndDates(v, options));
386
+ }
387
+
388
+ // 3. If it's a plain object, recurse into each property
389
+ if (value && typeof value === 'object') {
390
+ return Object.fromEntries(
391
+ Object.entries(value).map(([key, val]) => [
392
+ key,
393
+ this.deserializeBigIntsAndDates(val, options),
394
+ ])
395
+ );
396
+ }
397
+
398
+ // 4. Fallback for primitives, etc.
399
+ return value;
400
+ }
401
+ }
402
+
403
+ module.exports = RpcClient;