jiren 1.5.5 → 1.6.5

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.
Files changed (45) hide show
  1. package/README.md +123 -313
  2. package/components/cache.ts +1 -1
  3. package/components/client-node-native.ts +602 -159
  4. package/components/client.ts +51 -29
  5. package/components/metrics.ts +1 -4
  6. package/components/native-node.ts +48 -3
  7. package/components/native.ts +29 -0
  8. package/components/persistent-worker.ts +73 -0
  9. package/components/subprocess-worker.ts +65 -0
  10. package/components/types.ts +2 -0
  11. package/components/worker-pool.ts +169 -0
  12. package/components/worker.ts +39 -23
  13. package/dist/components/cache.d.ts +76 -0
  14. package/dist/components/cache.d.ts.map +1 -0
  15. package/dist/components/cache.js +439 -0
  16. package/dist/components/cache.js.map +1 -0
  17. package/dist/components/client-node-native.d.ts +134 -0
  18. package/dist/components/client-node-native.d.ts.map +1 -0
  19. package/dist/components/client-node-native.js +811 -0
  20. package/dist/components/client-node-native.js.map +1 -0
  21. package/dist/components/metrics.d.ts +104 -0
  22. package/dist/components/metrics.d.ts.map +1 -0
  23. package/dist/components/metrics.js +296 -0
  24. package/dist/components/metrics.js.map +1 -0
  25. package/dist/components/native-node.d.ts +67 -0
  26. package/dist/components/native-node.d.ts.map +1 -0
  27. package/dist/components/native-node.js +137 -0
  28. package/dist/components/native-node.js.map +1 -0
  29. package/dist/components/types.d.ts +252 -0
  30. package/dist/components/types.d.ts.map +1 -0
  31. package/dist/components/types.js +5 -0
  32. package/dist/components/types.js.map +1 -0
  33. package/dist/index-node.d.ts +10 -0
  34. package/dist/index-node.d.ts.map +1 -0
  35. package/dist/index-node.js +12 -0
  36. package/dist/index-node.js.map +1 -0
  37. package/dist/types/index.d.ts +63 -0
  38. package/dist/types/index.d.ts.map +1 -0
  39. package/dist/types/index.js +6 -0
  40. package/dist/types/index.js.map +1 -0
  41. package/index-node.ts +6 -6
  42. package/index.ts +4 -3
  43. package/lib/libhttpclient.dylib +0 -0
  44. package/package.json +15 -8
  45. package/types/index.ts +0 -68
@@ -1,5 +1,11 @@
1
- import { nativeLib as lib } from "./native-node";
2
- import { ResponseCache } from "./cache";
1
+ import {
2
+ nativeLib as lib,
3
+ ZPipelineRequest,
4
+ ZPipelineResponse,
5
+ ZPipelineResult,
6
+ } from "./native-node.js";
7
+ import { ResponseCache } from "./cache.js";
8
+ import { MetricsCollector, type RequestMetric } from "./metrics.js";
3
9
  import koffi from "koffi";
4
10
  import zlib from "zlib";
5
11
  import type {
@@ -10,7 +16,15 @@ import type {
10
16
  UrlRequestOptions,
11
17
  UrlEndpoint,
12
18
  CacheConfig,
13
- } from "./types";
19
+ RetryConfig,
20
+ Interceptors,
21
+ RequestInterceptor,
22
+ ResponseInterceptor,
23
+ ErrorInterceptor,
24
+ InterceptorRequestContext,
25
+ InterceptorResponseContext,
26
+ MetricsAPI,
27
+ } from "./types.js";
14
28
 
15
29
  const STATUS_TEXT: Record<number, string> = {
16
30
  200: "OK",
@@ -27,8 +41,10 @@ const STATUS_TEXT: Record<number, string> = {
27
41
  503: "Service Unavailable",
28
42
  };
29
43
 
30
- /** URL configuration with optional cache */
31
- export type UrlConfig = string | { url: string; cache?: boolean | CacheConfig };
44
+ /** URL configuration with optional cache and antibot */
45
+ export type UrlConfig =
46
+ | string
47
+ | { url: string; cache?: boolean | CacheConfig; antibot?: boolean };
32
48
 
33
49
  /** Options for JirenClient constructor */
34
50
  export interface JirenClientOptions<
@@ -36,15 +52,27 @@ export interface JirenClientOptions<
36
52
  | readonly TargetUrlConfig[]
37
53
  | Record<string, UrlConfig>
38
54
  > {
39
- /** URLs to warmup on client creation (pre-connect + handshake) */
40
- warmup?: string[] | T;
55
+ /** Target URLs to pre-connect on client creation */
56
+ targets?: string[] | T;
41
57
 
42
58
  /** Enable benchmark mode (Force HTTP/2, disable probing) */
43
59
  benchmark?: boolean;
60
+
61
+ /** Global retry configuration */
62
+ retry?: number | RetryConfig;
63
+
64
+ /** Request/response interceptors */
65
+ interceptors?: Interceptors;
66
+
67
+ /** Performance mode: disable metrics, skip cache checks for non-cached endpoints (default: true) */
68
+ performanceMode?: boolean;
69
+
70
+ /** Enable default browser emulation headers (default: true) */
71
+ defaultHeaders?: boolean;
44
72
  }
45
73
 
46
- /** Helper to extract keys from Warmup Config */
47
- export type ExtractWarmupKeys<
74
+ // Helper to extract keys from Target Config
75
+ export type ExtractTargetKeys<
48
76
  T extends readonly TargetUrlConfig[] | Record<string, UrlConfig>
49
77
  > = T extends readonly TargetUrlConfig[]
50
78
  ? T[number]["key"]
@@ -52,15 +80,14 @@ export type ExtractWarmupKeys<
52
80
  ? keyof T
53
81
  : never;
54
82
 
55
- /** Type-safe URL accessor - maps keys to UrlEndpoint objects */
56
83
  export type UrlAccessor<
57
84
  T extends readonly TargetUrlConfig[] | Record<string, UrlConfig>
58
85
  > = {
59
- [K in ExtractWarmupKeys<T>]: UrlEndpoint;
86
+ [K in ExtractTargetKeys<T>]: UrlEndpoint;
60
87
  };
61
88
 
62
89
  /**
63
- * Helper function to define warmup URLs with type inference.
90
+ * Helper to define target URLs with type inference.
64
91
  */
65
92
  export function defineUrls<const T extends readonly TargetUrlConfig[]>(
66
93
  urls: T
@@ -68,49 +95,122 @@ export function defineUrls<const T extends readonly TargetUrlConfig[]>(
68
95
  return urls;
69
96
  }
70
97
 
98
+ // Cleanup native resources on GC
99
+ const clientRegistry = new FinalizationRegistry<any>((ptr) => {
100
+ try {
101
+ lib.symbols.zclient_free(ptr);
102
+ } catch {
103
+ // Ignore cleanup errors
104
+ }
105
+ });
106
+
71
107
  export class JirenClient<
72
108
  T extends readonly TargetUrlConfig[] | Record<string, UrlConfig> =
73
109
  | readonly TargetUrlConfig[]
74
110
  | Record<string, UrlConfig>
75
- > {
76
- private ptr: any = null; // Koffi pointer (Buffer or External)
111
+ > implements Disposable
112
+ {
113
+ private ptr: any = null; // Koffi pointer
77
114
  private urlMap: Map<string, string> = new Map();
78
115
  private cacheConfig: Map<string, { enabled: boolean; ttl: number }> =
79
116
  new Map();
80
117
  private antibotConfig: Map<string, boolean> = new Map();
81
118
  private cache: ResponseCache;
119
+ private inflightRequests: Map<string, Promise<any>> = new Map();
120
+ private globalRetry?: RetryConfig;
121
+ private requestInterceptors: RequestInterceptor[] = [];
122
+ private responseInterceptors: ResponseInterceptor[] = [];
123
+ private errorInterceptors: ErrorInterceptor[] = [];
124
+ private targetsPromise: Promise<void> | null = null;
125
+ private targetsComplete: Set<string> = new Set();
126
+ private performanceMode: boolean = false;
127
+ private useDefaultHeaders: boolean = true;
128
+
129
+ // Pre-computed headers
130
+ private readonly defaultHeadersStr: string;
131
+ private readonly defaultHeaders: Record<string, string> = {
132
+ "user-agent":
133
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
134
+ accept:
135
+ "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8",
136
+ "accept-encoding": "gzip",
137
+ "accept-language": "en-US,en;q=0.9",
138
+ "sec-ch-ua":
139
+ '"Not_A Brand";v="8", "Chromium";v="120", "Google Chrome";v="120"',
140
+ "sec-ch-ua-mobile": "?0",
141
+ "sec-ch-ua-platform": '"macOS"',
142
+ "sec-fetch-dest": "document",
143
+ "sec-fetch-mode": "navigate",
144
+ "sec-fetch-site": "none",
145
+ "sec-fetch-user": "?1",
146
+ "upgrade-insecure-requests": "1",
147
+ };
82
148
 
83
149
  /** Type-safe URL accessor for warmed-up URLs */
84
150
  public readonly url: UrlAccessor<T>;
85
151
 
152
+ // Metrics collector
153
+ private metricsCollector: MetricsCollector;
154
+ /** Public metrics API */
155
+ public readonly metrics: MetricsAPI;
156
+
86
157
  constructor(options?: JirenClientOptions<T>) {
87
158
  this.ptr = lib.symbols.zclient_new();
88
159
  if (!this.ptr) throw new Error("Failed to create native client instance");
89
160
 
161
+ clientRegistry.register(this, this.ptr, this);
162
+
163
+ // Pre-computed default headers string
164
+ const orderedKeys = [
165
+ "sec-ch-ua",
166
+ "sec-ch-ua-mobile",
167
+ "sec-ch-ua-platform",
168
+ "upgrade-insecure-requests",
169
+ "user-agent",
170
+ "accept",
171
+ "sec-fetch-site",
172
+ "sec-fetch-mode",
173
+ "sec-fetch-user",
174
+ "sec-fetch-dest",
175
+ "accept-encoding",
176
+ "accept-language",
177
+ ];
178
+ this.defaultHeadersStr = orderedKeys
179
+ .map((k) => `${k}: ${this.defaultHeaders[k]}`)
180
+ .join("\r\n");
181
+
90
182
  // Initialize cache
91
183
  this.cache = new ResponseCache(100);
92
184
 
185
+ // Initialize metrics
186
+ this.metricsCollector = new MetricsCollector();
187
+ this.metrics = this.metricsCollector;
188
+
189
+ // Performance mode (default: true for maximum speed)
190
+ this.performanceMode = options?.performanceMode ?? true;
191
+
192
+ // Default headers (default: true)
193
+ this.useDefaultHeaders = options?.defaultHeaders ?? true;
194
+
93
195
  // Enable benchmark mode if requested
94
196
  if (options?.benchmark) {
95
197
  lib.symbols.zclient_set_benchmark_mode(this.ptr, true);
96
198
  }
97
199
 
98
- // Process warmup URLs
99
- if (options?.warmup) {
200
+ // Process target URLs
201
+ if (options?.targets) {
100
202
  const urls: string[] = [];
101
- const warmup = options.warmup;
203
+ const targets = options.targets;
102
204
 
103
- if (Array.isArray(warmup)) {
104
- for (const item of warmup) {
205
+ if (Array.isArray(targets)) {
206
+ for (const item of targets) {
105
207
  if (typeof item === "string") {
106
208
  urls.push(item);
107
209
  } else {
108
- // TargetUrlConfig with key and optional cache
109
210
  const config = item as TargetUrlConfig;
110
211
  urls.push(config.url);
111
212
  this.urlMap.set(config.key, config.url);
112
213
 
113
- // Store cache config
114
214
  if (config.cache) {
115
215
  const cacheConfig =
116
216
  typeof config.cache === "boolean"
@@ -119,25 +219,23 @@ export class JirenClient<
119
219
  this.cacheConfig.set(config.key, cacheConfig);
120
220
  }
121
221
 
122
- // Store antibot config
123
222
  if (config.antibot) {
124
223
  this.antibotConfig.set(config.key, true);
125
224
  }
126
225
  }
127
226
  }
128
227
  } else {
129
- // Record<string, UrlConfig>
130
- for (const [key, urlConfig] of Object.entries(warmup)) {
228
+ for (const [key, urlConfig] of Object.entries(targets) as [
229
+ string,
230
+ UrlConfig
231
+ ][]) {
131
232
  if (typeof urlConfig === "string") {
132
- // Simple string URL
133
233
  urls.push(urlConfig);
134
234
  this.urlMap.set(key, urlConfig);
135
235
  } else {
136
- // URL config object with cache
137
236
  urls.push(urlConfig.url);
138
237
  this.urlMap.set(key, urlConfig.url);
139
238
 
140
- // Store cache config
141
239
  if (urlConfig.cache) {
142
240
  const cacheConfig =
143
241
  typeof urlConfig.cache === "boolean"
@@ -146,8 +244,7 @@ export class JirenClient<
146
244
  this.cacheConfig.set(key, cacheConfig);
147
245
  }
148
246
 
149
- // Store antibot config
150
- if ((urlConfig as { antibot?: boolean }).antibot) {
247
+ if (urlConfig.antibot) {
151
248
  this.antibotConfig.set(key, true);
152
249
  }
153
250
  }
@@ -155,11 +252,13 @@ export class JirenClient<
155
252
  }
156
253
 
157
254
  if (urls.length > 0) {
158
- this.warmup(urls);
255
+ this.targetsPromise = this.preconnect(urls).then(() => {
256
+ urls.forEach((url) => this.targetsComplete.add(url));
257
+ this.targetsPromise = null;
258
+ });
159
259
  }
160
260
 
161
- // Preload L2 disk cache entries into L1 memory for cached endpoints
162
- // This ensures user's first request hits L1 (~0.001ms) instead of L2 (~5ms)
261
+ // Preload L2 disk cache entries into L1 memory
163
262
  for (const [key, config] of this.cacheConfig.entries()) {
164
263
  if (config.enabled) {
165
264
  const url = this.urlMap.get(key);
@@ -172,13 +271,39 @@ export class JirenClient<
172
271
 
173
272
  // Create proxy for type-safe URL access
174
273
  this.url = this.createUrlAccessor();
274
+
275
+ // Store global retry config
276
+ if (options?.retry) {
277
+ this.globalRetry =
278
+ typeof options.retry === "number"
279
+ ? { count: options.retry, delay: 100, backoff: 2 }
280
+ : options.retry;
281
+ }
282
+
283
+ // Initialize interceptors
284
+ if (options?.interceptors) {
285
+ this.requestInterceptors = options.interceptors.request || [];
286
+ this.responseInterceptors = options.interceptors.response || [];
287
+ this.errorInterceptors = options.interceptors.error || [];
288
+ }
289
+ }
290
+
291
+ private async waitFor(ms: number) {
292
+ return new Promise((resolve) => setTimeout(resolve, ms));
175
293
  }
176
294
 
177
295
  /**
178
- * Wait for warmup to complete
296
+ * Wait for lazy pre-connection to complete.
297
+ */
298
+ public async waitForTargets(): Promise<void> {
299
+ if (this.targetsPromise) await this.targetsPromise;
300
+ }
301
+
302
+ /**
303
+ * @deprecated Use waitForTargets() instead
179
304
  */
180
305
  public async waitForWarmup(): Promise<void> {
181
- // Native warmup is synchronous, so this is effectively a no-op
306
+ return this.waitForTargets();
182
307
  }
183
308
 
184
309
  /**
@@ -207,58 +332,173 @@ export class JirenClient<
207
332
  get: async <R = any>(
208
333
  options?: UrlRequestOptions
209
334
  ): Promise<JirenResponse<R> | R | string | ArrayBuffer | Blob> => {
210
- const cacheConfig = self.cacheConfig.get(prop);
335
+ if (self.targetsPromise) {
336
+ await self.targetsPromise;
337
+ }
211
338
 
212
- // Check if antibot is enabled for this URL (from warmup config or per-request)
339
+ const cacheConfig = self.cacheConfig.get(prop);
213
340
  const useAntibot =
214
341
  options?.antibot ?? self.antibotConfig.get(prop) ?? false;
215
342
 
343
+ // Fast path: no cache
344
+ if (!cacheConfig?.enabled && self.performanceMode) {
345
+ return self.request<R>("GET", buildUrl(options?.path), null, {
346
+ headers: options?.headers,
347
+ maxRedirects: options?.maxRedirects,
348
+ responseType: options?.responseType,
349
+ antibot: useAntibot,
350
+ });
351
+ }
352
+
353
+ const startTime = performance.now();
354
+
355
+ // Try cache
216
356
  if (cacheConfig?.enabled) {
217
357
  const cached = self.cache.get(baseUrl, options?.path, options);
218
358
  if (cached) {
359
+ const responseTimeMs = performance.now() - startTime;
360
+ const cacheLayer = responseTimeMs < 0.5 ? "l1" : "l2";
361
+
362
+ if (!self.performanceMode) {
363
+ self.metricsCollector.recordRequest(prop, {
364
+ startTime,
365
+ responseTimeMs,
366
+ status: cached.status,
367
+ success: cached.ok,
368
+ bytesSent: 0,
369
+ bytesReceived: 0,
370
+ cacheHit: true,
371
+ cacheLayer,
372
+ dedupeHit: false,
373
+ });
374
+ }
375
+
219
376
  return cached as any;
220
377
  }
221
378
  }
222
379
 
223
- const response = await self.request<R>(
224
- "GET",
225
- buildUrl(options?.path),
226
- null,
227
- {
228
- headers: options?.headers,
229
- maxRedirects: options?.maxRedirects,
230
- responseType: options?.responseType,
231
- antibot: useAntibot,
380
+ // Deduplication
381
+ const dedupKey = `GET:${buildUrl(options?.path)}:${JSON.stringify(
382
+ options?.headers || {}
383
+ )}`;
384
+
385
+ if (self.inflightRequests.has(dedupKey)) {
386
+ const dedupeStart = performance.now();
387
+ const result = await self.inflightRequests.get(dedupKey);
388
+ const responseTimeMs = performance.now() - dedupeStart;
389
+
390
+ if (!self.performanceMode) {
391
+ self.metricsCollector.recordRequest(prop, {
392
+ startTime: dedupeStart,
393
+ responseTimeMs,
394
+ status:
395
+ typeof result === "object" && "status" in result
396
+ ? result.status
397
+ : 200,
398
+ success: true,
399
+ bytesSent: 0,
400
+ bytesReceived: 0,
401
+ cacheHit: false,
402
+ dedupeHit: true,
403
+ });
232
404
  }
233
- );
234
405
 
235
- if (
236
- cacheConfig?.enabled &&
237
- typeof response === "object" &&
238
- "status" in response
239
- ) {
240
- self.cache.set(
241
- baseUrl,
242
- response as JirenResponse,
243
- cacheConfig.ttl,
244
- options?.path,
245
- options
246
- );
406
+ return result;
247
407
  }
248
408
 
249
- return response;
409
+ const requestPromise = (async () => {
410
+ try {
411
+ const response = await self.request<R>(
412
+ "GET",
413
+ buildUrl(options?.path),
414
+ null,
415
+ {
416
+ headers: options?.headers,
417
+ maxRedirects: options?.maxRedirects,
418
+ responseType: options?.responseType,
419
+ antibot: useAntibot,
420
+ }
421
+ );
422
+
423
+ if (
424
+ cacheConfig?.enabled &&
425
+ typeof response === "object" &&
426
+ "status" in response
427
+ ) {
428
+ self.cache.set(
429
+ baseUrl,
430
+ response as JirenResponse,
431
+ cacheConfig.ttl,
432
+ options?.path,
433
+ options
434
+ );
435
+ }
436
+
437
+ const responseTimeMs = performance.now() - startTime;
438
+
439
+ if (!self.performanceMode) {
440
+ self.metricsCollector.recordRequest(prop, {
441
+ startTime,
442
+ responseTimeMs,
443
+ status:
444
+ typeof response === "object" && "status" in response
445
+ ? response.status
446
+ : 200,
447
+ success:
448
+ typeof response === "object" && "ok" in response
449
+ ? response.ok
450
+ : true,
451
+ bytesSent: options?.body
452
+ ? JSON.stringify(options.body).length
453
+ : 0,
454
+ bytesReceived: 0,
455
+ cacheHit: false,
456
+ dedupeHit: false,
457
+ });
458
+ }
459
+
460
+ return response;
461
+ } catch (error) {
462
+ if (!self.performanceMode) {
463
+ const responseTimeMs = performance.now() - startTime;
464
+ self.metricsCollector.recordRequest(prop, {
465
+ startTime,
466
+ responseTimeMs,
467
+ status: 0,
468
+ success: false,
469
+ bytesSent: 0,
470
+ bytesReceived: 0,
471
+ cacheHit: false,
472
+ dedupeHit: false,
473
+ error:
474
+ error instanceof Error ? error.message : String(error),
475
+ });
476
+ }
477
+
478
+ throw error;
479
+ } finally {
480
+ self.inflightRequests.delete(dedupKey);
481
+ }
482
+ })();
483
+
484
+ self.inflightRequests.set(dedupKey, requestPromise);
485
+
486
+ return requestPromise;
250
487
  },
251
488
 
252
489
  post: async <R = any>(
253
- body?: string | null,
254
490
  options?: UrlRequestOptions
255
491
  ): Promise<JirenResponse<R> | R | string | ArrayBuffer | Blob> => {
492
+ const { headers, serializedBody } = self.prepareBody(
493
+ options?.body,
494
+ options?.headers
495
+ );
256
496
  return self.request<R>(
257
497
  "POST",
258
498
  buildUrl(options?.path),
259
- body || null,
499
+ serializedBody,
260
500
  {
261
- headers: options?.headers,
501
+ headers,
262
502
  maxRedirects: options?.maxRedirects,
263
503
  responseType: options?.responseType,
264
504
  }
@@ -266,15 +506,18 @@ export class JirenClient<
266
506
  },
267
507
 
268
508
  put: async <R = any>(
269
- body?: string | null,
270
509
  options?: UrlRequestOptions
271
510
  ): Promise<JirenResponse<R> | R | string | ArrayBuffer | Blob> => {
511
+ const { headers, serializedBody } = self.prepareBody(
512
+ options?.body,
513
+ options?.headers
514
+ );
272
515
  return self.request<R>(
273
516
  "PUT",
274
517
  buildUrl(options?.path),
275
- body || null,
518
+ serializedBody,
276
519
  {
277
- headers: options?.headers,
520
+ headers,
278
521
  maxRedirects: options?.maxRedirects,
279
522
  responseType: options?.responseType,
280
523
  }
@@ -282,15 +525,18 @@ export class JirenClient<
282
525
  },
283
526
 
284
527
  patch: async <R = any>(
285
- body?: string | null,
286
528
  options?: UrlRequestOptions
287
529
  ): Promise<JirenResponse<R> | R | string | ArrayBuffer | Blob> => {
530
+ const { headers, serializedBody } = self.prepareBody(
531
+ options?.body,
532
+ options?.headers
533
+ );
288
534
  return self.request<R>(
289
535
  "PATCH",
290
536
  buildUrl(options?.path),
291
- body || null,
537
+ serializedBody,
292
538
  {
293
- headers: options?.headers,
539
+ headers,
294
540
  maxRedirects: options?.maxRedirects,
295
541
  responseType: options?.responseType,
296
542
  }
@@ -298,15 +544,18 @@ export class JirenClient<
298
544
  },
299
545
 
300
546
  delete: async <R = any>(
301
- body?: string | null,
302
547
  options?: UrlRequestOptions
303
548
  ): Promise<JirenResponse<R> | R | string | ArrayBuffer | Blob> => {
549
+ const { headers, serializedBody } = self.prepareBody(
550
+ options?.body,
551
+ options?.headers
552
+ );
304
553
  return self.request<R>(
305
554
  "DELETE",
306
555
  buildUrl(options?.path),
307
- body || null,
556
+ serializedBody,
308
557
  {
309
- headers: options?.headers,
558
+ headers,
310
559
  maxRedirects: options?.maxRedirects,
311
560
  responseType: options?.responseType,
312
561
  }
@@ -349,24 +598,55 @@ export class JirenClient<
349
598
  });
350
599
  }
351
600
 
601
+ /**
602
+ * Free the native client resources.
603
+ * Note: This is called automatically when the client is garbage collected,
604
+ * or you can use the `using` keyword for automatic cleanup in a scope.
605
+ */
352
606
  public close(): void {
353
607
  if (this.ptr) {
608
+ // Unregister from FinalizationRegistry since we're manually closing
609
+ clientRegistry.unregister(this);
354
610
  lib.symbols.zclient_free(this.ptr);
355
611
  this.ptr = null;
356
612
  }
357
613
  }
358
614
 
359
- public async warmup(urls: string[]): Promise<void> {
615
+ /**
616
+ * Dispose method for the `using` keyword (ECMAScript Explicit Resource Management)
617
+ * @example
618
+ * ```typescript
619
+ * using client = new JirenClient({ targets: [...] });
620
+ * // client is automatically closed when the scope ends
621
+ * ```
622
+ */
623
+ [Symbol.dispose](): void {
624
+ this.close();
625
+ }
626
+
627
+ /**
628
+ * Register interceptors dynamically.
629
+ */
630
+ public use(interceptors: Interceptors): this {
631
+ if (interceptors.request)
632
+ this.requestInterceptors.push(...interceptors.request);
633
+ if (interceptors.response)
634
+ this.responseInterceptors.push(...interceptors.response);
635
+ if (interceptors.error) this.errorInterceptors.push(...interceptors.error);
636
+ return this;
637
+ }
638
+
639
+ /**
640
+ * Pre-connect to URLs in parallel.
641
+ */
642
+ public async preconnect(urls: string[]): Promise<void> {
360
643
  if (!this.ptr) throw new Error("Client is closed");
361
644
 
362
645
  await Promise.all(
363
646
  urls.map(
364
647
  (url) =>
365
648
  new Promise<void>((resolve) => {
366
- const urlBuffer = Buffer.from(url + "\0");
367
- lib.symbols.zclient_prefetch(this.ptr, url); // Koffi handles string auto-conversion if type is 'const char*' but Buffer is safer for null termination?
368
- // Koffi: "const char *" expects a string or Buffer. String is null-terminated by Koffi.
369
- // Using generic string.
649
+ lib.symbols.zclient_prefetch(this.ptr, url);
370
650
  resolve();
371
651
  })
372
652
  )
@@ -374,10 +654,108 @@ export class JirenClient<
374
654
  }
375
655
 
376
656
  /**
377
- * @deprecated Use warmup() instead
657
+ * @deprecated Use preconnect() instead
658
+ */
659
+ public async warmup(urls: string[]): Promise<void> {
660
+ return this.preconnect(urls);
661
+ }
662
+
663
+ /**
664
+ * @deprecated Use preconnect() instead
378
665
  */
379
666
  public prefetch(urls: string[]): void {
380
- this.warmup(urls);
667
+ this.preconnect(urls);
668
+ }
669
+
670
+ /**
671
+ * Pre-warm a single URL (establishes connection, caches DNS, TLS session)
672
+ * Call this before making requests to eliminate first-request latency.
673
+ */
674
+ public async prewarm(url: string): Promise<void> {
675
+ return this.preconnect([url]);
676
+ }
677
+
678
+ /**
679
+ * Execute multiple requests in a single pipelined batch (100k+ RPS)
680
+ * All requests must go to the same host.
681
+ * @param host - The host (e.g., "localhost")
682
+ * @param port - The port (e.g., 4000)
683
+ * @param requests - Array of { method, path } objects
684
+ * @returns Array of response bodies as strings
685
+ */
686
+ public batch(
687
+ host: string,
688
+ port: number,
689
+ requests: Array<{ method: string; path: string }>
690
+ ): string[] {
691
+ if (!this.ptr) throw new Error("Client is closed");
692
+ if (requests.length === 0) return [];
693
+
694
+ // Encode strings to buffers and create request structs
695
+ const requestStructs: any[] = [];
696
+ const buffers: Buffer[] = []; // Keep references to prevent GC
697
+
698
+ for (const req of requests) {
699
+ const methodBuf = Buffer.from(req.method + "\0");
700
+ const pathBuf = Buffer.from(req.path + "\0");
701
+ buffers.push(methodBuf, pathBuf);
702
+
703
+ requestStructs.push({
704
+ method_ptr: methodBuf,
705
+ method_len: req.method.length,
706
+ path_ptr: pathBuf,
707
+ path_len: req.path.length,
708
+ });
709
+ }
710
+
711
+ // Create array of structs
712
+ const requestArray = koffi.as(
713
+ requestStructs,
714
+ koffi.pointer(ZPipelineRequest)
715
+ );
716
+
717
+ // Call native pipelined batch
718
+ const resultPtr = lib.symbols.zclient_request_batch_http1(
719
+ this.ptr,
720
+ host,
721
+ port,
722
+ requestArray,
723
+ requests.length
724
+ );
725
+
726
+ if (!resultPtr) {
727
+ throw new Error("Batch request failed");
728
+ }
729
+
730
+ // Read result struct
731
+ const result = koffi.decode(resultPtr, ZPipelineResult);
732
+ const count = Number(result.count);
733
+
734
+ // Read response array
735
+ const responses: string[] = [];
736
+ const responseArray = koffi.decode(
737
+ result.responses,
738
+ koffi.array(ZPipelineResponse, count)
739
+ );
740
+
741
+ for (let i = 0; i < count; i++) {
742
+ const resp = responseArray[i];
743
+ const bodyLen = Number(resp.body_len);
744
+ if (bodyLen > 0) {
745
+ const bodyBuf = koffi.decode(
746
+ resp.body_ptr,
747
+ koffi.array("uint8_t", bodyLen)
748
+ );
749
+ responses.push(Buffer.from(bodyBuf).toString("utf-8"));
750
+ } else {
751
+ responses.push("");
752
+ }
753
+ }
754
+
755
+ // Free native memory
756
+ lib.symbols.zclient_pipeline_result_free(resultPtr);
757
+
758
+ return responses;
381
759
  }
382
760
 
383
761
  public async request<T = any>(
@@ -443,84 +821,133 @@ export class JirenClient<
443
821
  }
444
822
  }
445
823
 
446
- const defaultHeaders: Record<string, string> = {
447
- "user-agent":
448
- "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
449
- accept:
450
- "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8",
451
- "accept-encoding": "gzip",
452
- "accept-language": "en-US,en;q=0.9",
453
- "sec-ch-ua":
454
- '"Not_A Brand";v="8", "Chromium";v="120", "Google Chrome";v="120"',
455
- "sec-ch-ua-mobile": "?0",
456
- "sec-ch-ua-platform": '"macOS"',
457
- "sec-fetch-dest": "document",
458
- "sec-fetch-mode": "navigate",
459
- "sec-fetch-site": "none",
460
- "sec-fetch-user": "?1",
461
- "upgrade-insecure-requests": "1",
462
- };
463
-
464
- const finalHeaders = { ...defaultHeaders, ...headers };
465
-
466
- // Enforce Chrome header order
467
- const orderedHeaders: Record<string, string> = {};
468
- const keys = [
469
- "sec-ch-ua",
470
- "sec-ch-ua-mobile",
471
- "sec-ch-ua-platform",
472
- "upgrade-insecure-requests",
473
- "user-agent",
474
- "accept",
475
- "sec-fetch-site",
476
- "sec-fetch-mode",
477
- "sec-fetch-user",
478
- "sec-fetch-dest",
479
- "accept-encoding",
480
- "accept-language",
481
- ];
482
-
483
- for (const key of keys) {
484
- if (finalHeaders[key]) {
485
- orderedHeaders[key] = finalHeaders[key];
486
- delete finalHeaders[key];
824
+ // Run interceptors
825
+ let ctx: InterceptorRequestContext = { method, url, headers, body };
826
+ if (this.requestInterceptors.length > 0) {
827
+ for (const interceptor of this.requestInterceptors) {
828
+ ctx = await interceptor(ctx);
487
829
  }
830
+ method = ctx.method;
831
+ url = ctx.url;
832
+ headers = ctx.headers;
833
+ body = ctx.body ?? null;
488
834
  }
489
- for (const [key, value] of Object.entries(finalHeaders)) {
490
- orderedHeaders[key] = value;
835
+
836
+ // Prepare headers
837
+ let headerStr: string;
838
+ const hasCustomHeaders = Object.keys(headers).length > 0;
839
+
840
+ if (hasCustomHeaders) {
841
+ const finalHeaders = this.useDefaultHeaders
842
+ ? { ...this.defaultHeaders, ...headers }
843
+ : headers;
844
+ const orderedHeaders: Record<string, string> = {};
845
+ const keys = [
846
+ "sec-ch-ua",
847
+ "sec-ch-ua-mobile",
848
+ "sec-ch-ua-platform",
849
+ "upgrade-insecure-requests",
850
+ "user-agent",
851
+ "accept",
852
+ "sec-fetch-site",
853
+ "sec-fetch-mode",
854
+ "sec-fetch-user",
855
+ "sec-fetch-dest",
856
+ "accept-encoding",
857
+ "accept-language",
858
+ ];
859
+
860
+ for (const key of keys) {
861
+ if (finalHeaders[key]) {
862
+ orderedHeaders[key] = finalHeaders[key];
863
+ delete finalHeaders[key];
864
+ }
865
+ }
866
+
867
+ for (const [key, value] of Object.entries(finalHeaders)) {
868
+ orderedHeaders[key] = value;
869
+ }
870
+
871
+ headerStr = Object.entries(orderedHeaders)
872
+ .map(([k, v]) => `${k.toLowerCase()}: ${v}`)
873
+ .join("\r\n");
874
+ } else {
875
+ headerStr = this.useDefaultHeaders ? this.defaultHeadersStr : "";
491
876
  }
492
877
 
493
- const headerStr = Object.entries(orderedHeaders)
494
- .map(([k, v]) => `${k.toLowerCase()}: ${v}`)
495
- .join("\r\n");
878
+ // Retry logic
879
+ let retryConfig = this.globalRetry;
880
+ if (options && typeof options === "object" && "retry" in options) {
881
+ const userRetry = (options as any).retry;
882
+ if (typeof userRetry === "number") {
883
+ retryConfig = { count: userRetry, delay: 100, backoff: 2 };
884
+ } else if (typeof userRetry === "object") {
885
+ retryConfig = userRetry;
886
+ }
887
+ }
496
888
 
497
- // Koffi auto-converts strings to const char *.
498
- // Pass strings directly.
499
- // For body and headers, empty string is fine if null.
500
- // But native check might expect null pointer?
501
- // zclient_request expects const char*. If passed null/undefined, Koffi might pass NULL?
502
- // Let's pass null for explicit nulls.
889
+ let attempts = 0;
890
+ const maxAttempts = (retryConfig?.count || 0) + 1;
891
+ let currentDelay = retryConfig?.delay || 100;
892
+ const backoff = retryConfig?.backoff || 2;
893
+ let lastError: any;
894
+
895
+ while (attempts < maxAttempts) {
896
+ attempts++;
897
+ try {
898
+ const respPtr = lib.symbols.zclient_request(
899
+ this.ptr,
900
+ method,
901
+ url,
902
+ headerStr.length > 0 ? headerStr : null,
903
+ body || null,
904
+ maxRedirects,
905
+ antibot
906
+ );
907
+
908
+ if (!respPtr) {
909
+ throw new Error("Native request failed (returned null pointer)");
910
+ }
503
911
 
504
- const respPtr = lib.symbols.zclient_request(
505
- this.ptr,
506
- method,
507
- url,
508
- headerStr.length > 0 ? headerStr : null,
509
- body || null,
510
- maxRedirects,
511
- antibot
512
- );
912
+ const response = this.parseResponse<T>(respPtr, url);
913
+
914
+ // Run response interceptors
915
+ let finalResponse = response;
916
+ if (this.responseInterceptors.length > 0) {
917
+ let responseCtx: InterceptorResponseContext<T> = {
918
+ request: ctx,
919
+ response,
920
+ };
921
+ for (const interceptor of this.responseInterceptors) {
922
+ responseCtx = await interceptor(responseCtx);
923
+ }
924
+ finalResponse = responseCtx.response;
925
+ }
513
926
 
514
- const response = this.parseResponse<T>(respPtr, url);
927
+ if (responseType) {
928
+ if (responseType === "json") return finalResponse.body.json();
929
+ if (responseType === "text") return finalResponse.body.text();
930
+ if (responseType === "arraybuffer")
931
+ return finalResponse.body.arrayBuffer();
932
+ if (responseType === "blob") return finalResponse.body.blob();
933
+ }
515
934
 
516
- if (responseType) {
517
- if (responseType === "json") return response.body.json();
518
- if (responseType === "text") return response.body.text();
519
- if (responseType === "arraybuffer") return response.body.arrayBuffer();
520
- if (responseType === "blob") return response.body.blob();
935
+ return finalResponse;
936
+ } catch (err) {
937
+ if (this.errorInterceptors.length > 0) {
938
+ for (const interceptor of this.errorInterceptors) {
939
+ await interceptor(err as Error, ctx);
940
+ }
941
+ }
942
+ lastError = err;
943
+ if (attempts < maxAttempts) {
944
+ await this.waitFor(currentDelay);
945
+ currentDelay *= backoff;
946
+ }
947
+ }
521
948
  }
522
949
 
523
- return response;
950
+ throw lastError || new Error("Request failed after retries");
524
951
  }
525
952
 
526
953
  private parseResponse<T = any>(respPtr: any, url: string): JirenResponse<T> {
@@ -540,8 +967,6 @@ export class JirenClient<
540
967
  if (headersLen > 0) {
541
968
  const rawHeadersPtr = lib.symbols.zclient_response_headers(respPtr);
542
969
  if (rawHeadersPtr) {
543
- // Copy headers to JS memory
544
- // Koffi decode to buffer: koffi.decode(ptr, "uint8_t", len)
545
970
  const raw = Buffer.from(
546
971
  koffi.decode(rawHeadersPtr, "uint8_t", headersLen)
547
972
  );
@@ -565,15 +990,12 @@ export class JirenClient<
565
990
 
566
991
  let buffer: Buffer = Buffer.alloc(0);
567
992
  if (len > 0 && bodyPtr) {
568
- // Copy body content
569
993
  buffer = Buffer.from(koffi.decode(bodyPtr, "uint8_t", len));
570
994
 
571
- // Handle GZIP compression - search for magic bytes in first 16 bytes
572
- // (handles chunked encoding or other framing that may add prefix bytes)
995
+ // Handle GZIP
573
996
  const contentEncoding = headersProxy["content-encoding"]?.toLowerCase();
574
997
  let gzipOffset = -1;
575
998
 
576
- // Search for gzip magic bytes (0x1f 0x8b) in first 16 bytes
577
999
  for (let i = 0; i < Math.min(16, buffer.length - 1); i++) {
578
1000
  if (buffer[i] === 0x1f && buffer[i + 1] === 0x8b) {
579
1001
  gzipOffset = i;
@@ -583,16 +1005,10 @@ export class JirenClient<
583
1005
 
584
1006
  if (contentEncoding === "gzip" || gzipOffset >= 0) {
585
1007
  try {
586
- // If we found gzip at an offset, slice from there
587
1008
  const gzipData = gzipOffset > 0 ? buffer.slice(gzipOffset) : buffer;
588
- console.log(
589
- `[Jiren] Decompressing gzip (offset: ${
590
- gzipOffset >= 0 ? gzipOffset : 0
591
- }, size: ${gzipData.length})`
592
- );
593
1009
  buffer = zlib.gunzipSync(gzipData);
594
1010
  } catch (e) {
595
- console.warn("Failed to gunzip response body:", e);
1011
+ // Keep original buffer
596
1012
  }
597
1013
  }
598
1014
  }
@@ -643,6 +1059,33 @@ export class JirenClient<
643
1059
  lib.symbols.zclient_response_free(respPtr);
644
1060
  }
645
1061
  }
1062
+
1063
+ /**
1064
+ * Helper to prepare body and headers for requests.
1065
+ */
1066
+ private prepareBody(
1067
+ body: string | object | null | undefined,
1068
+ userHeaders?: Record<string, string>
1069
+ ): { headers: Record<string, string>; serializedBody: string | null } {
1070
+ let serializedBody: string | null = null;
1071
+ const headers = { ...userHeaders };
1072
+
1073
+ if (body !== null && body !== undefined) {
1074
+ if (typeof body === "object") {
1075
+ serializedBody = JSON.stringify(body);
1076
+ const hasContentType = Object.keys(headers).some(
1077
+ (k) => k.toLowerCase() === "content-type"
1078
+ );
1079
+ if (!hasContentType) {
1080
+ headers["Content-Type"] = "application/json";
1081
+ }
1082
+ } else {
1083
+ serializedBody = String(body);
1084
+ }
1085
+ }
1086
+
1087
+ return { headers, serializedBody };
1088
+ }
646
1089
  }
647
1090
 
648
1091
  class NativeHeaders {