jiren 1.5.5 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/README.md +77 -21
  2. package/components/cache.ts +1 -1
  3. package/components/client-node-native.ts +497 -159
  4. package/components/client.ts +51 -29
  5. package/components/metrics.ts +1 -4
  6. package/components/native-node.ts +7 -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/worker-pool.ts +169 -0
  11. package/components/worker.ts +39 -23
  12. package/dist/components/cache.d.ts +76 -0
  13. package/dist/components/cache.d.ts.map +1 -0
  14. package/dist/components/cache.js +439 -0
  15. package/dist/components/cache.js.map +1 -0
  16. package/dist/components/client-node-native.d.ts +114 -0
  17. package/dist/components/client-node-native.d.ts.map +1 -0
  18. package/dist/components/client-node-native.js +744 -0
  19. package/dist/components/client-node-native.js.map +1 -0
  20. package/dist/components/metrics.d.ts +104 -0
  21. package/dist/components/metrics.d.ts.map +1 -0
  22. package/dist/components/metrics.js +296 -0
  23. package/dist/components/metrics.js.map +1 -0
  24. package/dist/components/native-node.d.ts +60 -0
  25. package/dist/components/native-node.d.ts.map +1 -0
  26. package/dist/components/native-node.js +108 -0
  27. package/dist/components/native-node.js.map +1 -0
  28. package/dist/components/types.d.ts +250 -0
  29. package/dist/components/types.d.ts.map +1 -0
  30. package/dist/components/types.js +5 -0
  31. package/dist/components/types.js.map +1 -0
  32. package/dist/index-node.d.ts +10 -0
  33. package/dist/index-node.d.ts.map +1 -0
  34. package/dist/index-node.js +12 -0
  35. package/dist/index-node.js.map +1 -0
  36. package/dist/types/index.d.ts +63 -0
  37. package/dist/types/index.d.ts.map +1 -0
  38. package/dist/types/index.js +6 -0
  39. package/dist/types/index.js.map +1 -0
  40. package/index-node.ts +6 -6
  41. package/index.ts +4 -3
  42. package/lib/libhttpclient.dylib +0 -0
  43. package/package.json +13 -5
  44. package/types/index.ts +0 -68
@@ -1,5 +1,6 @@
1
- import { nativeLib as lib } from "./native-node";
2
- import { ResponseCache } from "./cache";
1
+ import { nativeLib as lib } from "./native-node.js";
2
+ import { ResponseCache } from "./cache.js";
3
+ import { MetricsCollector, type RequestMetric } from "./metrics.js";
3
4
  import koffi from "koffi";
4
5
  import zlib from "zlib";
5
6
  import type {
@@ -10,7 +11,15 @@ import type {
10
11
  UrlRequestOptions,
11
12
  UrlEndpoint,
12
13
  CacheConfig,
13
- } from "./types";
14
+ RetryConfig,
15
+ Interceptors,
16
+ RequestInterceptor,
17
+ ResponseInterceptor,
18
+ ErrorInterceptor,
19
+ InterceptorRequestContext,
20
+ InterceptorResponseContext,
21
+ MetricsAPI,
22
+ } from "./types.js";
14
23
 
15
24
  const STATUS_TEXT: Record<number, string> = {
16
25
  200: "OK",
@@ -27,8 +36,10 @@ const STATUS_TEXT: Record<number, string> = {
27
36
  503: "Service Unavailable",
28
37
  };
29
38
 
30
- /** URL configuration with optional cache */
31
- export type UrlConfig = string | { url: string; cache?: boolean | CacheConfig };
39
+ /** URL configuration with optional cache and antibot */
40
+ export type UrlConfig =
41
+ | string
42
+ | { url: string; cache?: boolean | CacheConfig; antibot?: boolean };
32
43
 
33
44
  /** Options for JirenClient constructor */
34
45
  export interface JirenClientOptions<
@@ -36,15 +47,24 @@ export interface JirenClientOptions<
36
47
  | readonly TargetUrlConfig[]
37
48
  | Record<string, UrlConfig>
38
49
  > {
39
- /** URLs to warmup on client creation (pre-connect + handshake) */
40
- warmup?: string[] | T;
50
+ /** Target URLs to pre-connect on client creation */
51
+ targets?: string[] | T;
41
52
 
42
53
  /** Enable benchmark mode (Force HTTP/2, disable probing) */
43
54
  benchmark?: boolean;
55
+
56
+ /** Global retry configuration */
57
+ retry?: number | RetryConfig;
58
+
59
+ /** Request/response interceptors */
60
+ interceptors?: Interceptors;
61
+
62
+ /** Performance mode: disable metrics, skip cache checks for non-cached endpoints (default: true) */
63
+ performanceMode?: boolean;
44
64
  }
45
65
 
46
- /** Helper to extract keys from Warmup Config */
47
- export type ExtractWarmupKeys<
66
+ // Helper to extract keys from Target Config
67
+ export type ExtractTargetKeys<
48
68
  T extends readonly TargetUrlConfig[] | Record<string, UrlConfig>
49
69
  > = T extends readonly TargetUrlConfig[]
50
70
  ? T[number]["key"]
@@ -52,15 +72,14 @@ export type ExtractWarmupKeys<
52
72
  ? keyof T
53
73
  : never;
54
74
 
55
- /** Type-safe URL accessor - maps keys to UrlEndpoint objects */
56
75
  export type UrlAccessor<
57
76
  T extends readonly TargetUrlConfig[] | Record<string, UrlConfig>
58
77
  > = {
59
- [K in ExtractWarmupKeys<T>]: UrlEndpoint;
78
+ [K in ExtractTargetKeys<T>]: UrlEndpoint;
60
79
  };
61
80
 
62
81
  /**
63
- * Helper function to define warmup URLs with type inference.
82
+ * Helper to define target URLs with type inference.
64
83
  */
65
84
  export function defineUrls<const T extends readonly TargetUrlConfig[]>(
66
85
  urls: T
@@ -68,49 +87,118 @@ export function defineUrls<const T extends readonly TargetUrlConfig[]>(
68
87
  return urls;
69
88
  }
70
89
 
90
+ // Cleanup native resources on GC
91
+ const clientRegistry = new FinalizationRegistry<any>((ptr) => {
92
+ try {
93
+ lib.symbols.zclient_free(ptr);
94
+ } catch {
95
+ // Ignore cleanup errors
96
+ }
97
+ });
98
+
71
99
  export class JirenClient<
72
100
  T extends readonly TargetUrlConfig[] | Record<string, UrlConfig> =
73
101
  | readonly TargetUrlConfig[]
74
102
  | Record<string, UrlConfig>
75
- > {
76
- private ptr: any = null; // Koffi pointer (Buffer or External)
103
+ > implements Disposable
104
+ {
105
+ private ptr: any = null; // Koffi pointer
77
106
  private urlMap: Map<string, string> = new Map();
78
107
  private cacheConfig: Map<string, { enabled: boolean; ttl: number }> =
79
108
  new Map();
80
109
  private antibotConfig: Map<string, boolean> = new Map();
81
110
  private cache: ResponseCache;
111
+ private inflightRequests: Map<string, Promise<any>> = new Map();
112
+ private globalRetry?: RetryConfig;
113
+ private requestInterceptors: RequestInterceptor[] = [];
114
+ private responseInterceptors: ResponseInterceptor[] = [];
115
+ private errorInterceptors: ErrorInterceptor[] = [];
116
+ private targetsPromise: Promise<void> | null = null;
117
+ private targetsComplete: Set<string> = new Set();
118
+ private performanceMode: boolean = false;
119
+
120
+ // Pre-computed headers
121
+ private readonly defaultHeadersStr: string;
122
+ private readonly defaultHeaders: Record<string, string> = {
123
+ "user-agent":
124
+ "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",
125
+ accept:
126
+ "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8",
127
+ "accept-encoding": "gzip",
128
+ "accept-language": "en-US,en;q=0.9",
129
+ "sec-ch-ua":
130
+ '"Not_A Brand";v="8", "Chromium";v="120", "Google Chrome";v="120"',
131
+ "sec-ch-ua-mobile": "?0",
132
+ "sec-ch-ua-platform": '"macOS"',
133
+ "sec-fetch-dest": "document",
134
+ "sec-fetch-mode": "navigate",
135
+ "sec-fetch-site": "none",
136
+ "sec-fetch-user": "?1",
137
+ "upgrade-insecure-requests": "1",
138
+ };
82
139
 
83
140
  /** Type-safe URL accessor for warmed-up URLs */
84
141
  public readonly url: UrlAccessor<T>;
85
142
 
143
+ // Metrics collector
144
+ private metricsCollector: MetricsCollector;
145
+ /** Public metrics API */
146
+ public readonly metrics: MetricsAPI;
147
+
86
148
  constructor(options?: JirenClientOptions<T>) {
87
149
  this.ptr = lib.symbols.zclient_new();
88
150
  if (!this.ptr) throw new Error("Failed to create native client instance");
89
151
 
152
+ clientRegistry.register(this, this.ptr, this);
153
+
154
+ // Pre-computed default headers string
155
+ const orderedKeys = [
156
+ "sec-ch-ua",
157
+ "sec-ch-ua-mobile",
158
+ "sec-ch-ua-platform",
159
+ "upgrade-insecure-requests",
160
+ "user-agent",
161
+ "accept",
162
+ "sec-fetch-site",
163
+ "sec-fetch-mode",
164
+ "sec-fetch-user",
165
+ "sec-fetch-dest",
166
+ "accept-encoding",
167
+ "accept-language",
168
+ ];
169
+ this.defaultHeadersStr = orderedKeys
170
+ .map((k) => `${k}: ${this.defaultHeaders[k]}`)
171
+ .join("\r\n");
172
+
90
173
  // Initialize cache
91
174
  this.cache = new ResponseCache(100);
92
175
 
176
+ // Initialize metrics
177
+ this.metricsCollector = new MetricsCollector();
178
+ this.metrics = this.metricsCollector;
179
+
180
+ // Performance mode (default: true for maximum speed)
181
+ this.performanceMode = options?.performanceMode ?? true;
182
+
93
183
  // Enable benchmark mode if requested
94
184
  if (options?.benchmark) {
95
185
  lib.symbols.zclient_set_benchmark_mode(this.ptr, true);
96
186
  }
97
187
 
98
- // Process warmup URLs
99
- if (options?.warmup) {
188
+ // Process target URLs
189
+ if (options?.targets) {
100
190
  const urls: string[] = [];
101
- const warmup = options.warmup;
191
+ const targets = options.targets;
102
192
 
103
- if (Array.isArray(warmup)) {
104
- for (const item of warmup) {
193
+ if (Array.isArray(targets)) {
194
+ for (const item of targets) {
105
195
  if (typeof item === "string") {
106
196
  urls.push(item);
107
197
  } else {
108
- // TargetUrlConfig with key and optional cache
109
198
  const config = item as TargetUrlConfig;
110
199
  urls.push(config.url);
111
200
  this.urlMap.set(config.key, config.url);
112
201
 
113
- // Store cache config
114
202
  if (config.cache) {
115
203
  const cacheConfig =
116
204
  typeof config.cache === "boolean"
@@ -119,25 +207,23 @@ export class JirenClient<
119
207
  this.cacheConfig.set(config.key, cacheConfig);
120
208
  }
121
209
 
122
- // Store antibot config
123
210
  if (config.antibot) {
124
211
  this.antibotConfig.set(config.key, true);
125
212
  }
126
213
  }
127
214
  }
128
215
  } else {
129
- // Record<string, UrlConfig>
130
- for (const [key, urlConfig] of Object.entries(warmup)) {
216
+ for (const [key, urlConfig] of Object.entries(targets) as [
217
+ string,
218
+ UrlConfig
219
+ ][]) {
131
220
  if (typeof urlConfig === "string") {
132
- // Simple string URL
133
221
  urls.push(urlConfig);
134
222
  this.urlMap.set(key, urlConfig);
135
223
  } else {
136
- // URL config object with cache
137
224
  urls.push(urlConfig.url);
138
225
  this.urlMap.set(key, urlConfig.url);
139
226
 
140
- // Store cache config
141
227
  if (urlConfig.cache) {
142
228
  const cacheConfig =
143
229
  typeof urlConfig.cache === "boolean"
@@ -146,8 +232,7 @@ export class JirenClient<
146
232
  this.cacheConfig.set(key, cacheConfig);
147
233
  }
148
234
 
149
- // Store antibot config
150
- if ((urlConfig as { antibot?: boolean }).antibot) {
235
+ if (urlConfig.antibot) {
151
236
  this.antibotConfig.set(key, true);
152
237
  }
153
238
  }
@@ -155,11 +240,13 @@ export class JirenClient<
155
240
  }
156
241
 
157
242
  if (urls.length > 0) {
158
- this.warmup(urls);
243
+ this.targetsPromise = this.preconnect(urls).then(() => {
244
+ urls.forEach((url) => this.targetsComplete.add(url));
245
+ this.targetsPromise = null;
246
+ });
159
247
  }
160
248
 
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)
249
+ // Preload L2 disk cache entries into L1 memory
163
250
  for (const [key, config] of this.cacheConfig.entries()) {
164
251
  if (config.enabled) {
165
252
  const url = this.urlMap.get(key);
@@ -172,13 +259,39 @@ export class JirenClient<
172
259
 
173
260
  // Create proxy for type-safe URL access
174
261
  this.url = this.createUrlAccessor();
262
+
263
+ // Store global retry config
264
+ if (options?.retry) {
265
+ this.globalRetry =
266
+ typeof options.retry === "number"
267
+ ? { count: options.retry, delay: 100, backoff: 2 }
268
+ : options.retry;
269
+ }
270
+
271
+ // Initialize interceptors
272
+ if (options?.interceptors) {
273
+ this.requestInterceptors = options.interceptors.request || [];
274
+ this.responseInterceptors = options.interceptors.response || [];
275
+ this.errorInterceptors = options.interceptors.error || [];
276
+ }
277
+ }
278
+
279
+ private async waitFor(ms: number) {
280
+ return new Promise((resolve) => setTimeout(resolve, ms));
175
281
  }
176
282
 
177
283
  /**
178
- * Wait for warmup to complete
284
+ * Wait for lazy pre-connection to complete.
285
+ */
286
+ public async waitForTargets(): Promise<void> {
287
+ if (this.targetsPromise) await this.targetsPromise;
288
+ }
289
+
290
+ /**
291
+ * @deprecated Use waitForTargets() instead
179
292
  */
180
293
  public async waitForWarmup(): Promise<void> {
181
- // Native warmup is synchronous, so this is effectively a no-op
294
+ return this.waitForTargets();
182
295
  }
183
296
 
184
297
  /**
@@ -207,58 +320,173 @@ export class JirenClient<
207
320
  get: async <R = any>(
208
321
  options?: UrlRequestOptions
209
322
  ): Promise<JirenResponse<R> | R | string | ArrayBuffer | Blob> => {
210
- const cacheConfig = self.cacheConfig.get(prop);
323
+ if (self.targetsPromise) {
324
+ await self.targetsPromise;
325
+ }
211
326
 
212
- // Check if antibot is enabled for this URL (from warmup config or per-request)
327
+ const cacheConfig = self.cacheConfig.get(prop);
213
328
  const useAntibot =
214
329
  options?.antibot ?? self.antibotConfig.get(prop) ?? false;
215
330
 
331
+ // Fast path: no cache
332
+ if (!cacheConfig?.enabled && self.performanceMode) {
333
+ return self.request<R>("GET", buildUrl(options?.path), null, {
334
+ headers: options?.headers,
335
+ maxRedirects: options?.maxRedirects,
336
+ responseType: options?.responseType,
337
+ antibot: useAntibot,
338
+ });
339
+ }
340
+
341
+ const startTime = performance.now();
342
+
343
+ // Try cache
216
344
  if (cacheConfig?.enabled) {
217
345
  const cached = self.cache.get(baseUrl, options?.path, options);
218
346
  if (cached) {
347
+ const responseTimeMs = performance.now() - startTime;
348
+ const cacheLayer = responseTimeMs < 0.5 ? "l1" : "l2";
349
+
350
+ if (!self.performanceMode) {
351
+ self.metricsCollector.recordRequest(prop, {
352
+ startTime,
353
+ responseTimeMs,
354
+ status: cached.status,
355
+ success: cached.ok,
356
+ bytesSent: 0,
357
+ bytesReceived: 0,
358
+ cacheHit: true,
359
+ cacheLayer,
360
+ dedupeHit: false,
361
+ });
362
+ }
363
+
219
364
  return cached as any;
220
365
  }
221
366
  }
222
367
 
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,
368
+ // Deduplication
369
+ const dedupKey = `GET:${buildUrl(options?.path)}:${JSON.stringify(
370
+ options?.headers || {}
371
+ )}`;
372
+
373
+ if (self.inflightRequests.has(dedupKey)) {
374
+ const dedupeStart = performance.now();
375
+ const result = await self.inflightRequests.get(dedupKey);
376
+ const responseTimeMs = performance.now() - dedupeStart;
377
+
378
+ if (!self.performanceMode) {
379
+ self.metricsCollector.recordRequest(prop, {
380
+ startTime: dedupeStart,
381
+ responseTimeMs,
382
+ status:
383
+ typeof result === "object" && "status" in result
384
+ ? result.status
385
+ : 200,
386
+ success: true,
387
+ bytesSent: 0,
388
+ bytesReceived: 0,
389
+ cacheHit: false,
390
+ dedupeHit: true,
391
+ });
232
392
  }
233
- );
234
393
 
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
- );
394
+ return result;
247
395
  }
248
396
 
249
- return response;
397
+ const requestPromise = (async () => {
398
+ try {
399
+ const response = await self.request<R>(
400
+ "GET",
401
+ buildUrl(options?.path),
402
+ null,
403
+ {
404
+ headers: options?.headers,
405
+ maxRedirects: options?.maxRedirects,
406
+ responseType: options?.responseType,
407
+ antibot: useAntibot,
408
+ }
409
+ );
410
+
411
+ if (
412
+ cacheConfig?.enabled &&
413
+ typeof response === "object" &&
414
+ "status" in response
415
+ ) {
416
+ self.cache.set(
417
+ baseUrl,
418
+ response as JirenResponse,
419
+ cacheConfig.ttl,
420
+ options?.path,
421
+ options
422
+ );
423
+ }
424
+
425
+ const responseTimeMs = performance.now() - startTime;
426
+
427
+ if (!self.performanceMode) {
428
+ self.metricsCollector.recordRequest(prop, {
429
+ startTime,
430
+ responseTimeMs,
431
+ status:
432
+ typeof response === "object" && "status" in response
433
+ ? response.status
434
+ : 200,
435
+ success:
436
+ typeof response === "object" && "ok" in response
437
+ ? response.ok
438
+ : true,
439
+ bytesSent: options?.body
440
+ ? JSON.stringify(options.body).length
441
+ : 0,
442
+ bytesReceived: 0,
443
+ cacheHit: false,
444
+ dedupeHit: false,
445
+ });
446
+ }
447
+
448
+ return response;
449
+ } catch (error) {
450
+ if (!self.performanceMode) {
451
+ const responseTimeMs = performance.now() - startTime;
452
+ self.metricsCollector.recordRequest(prop, {
453
+ startTime,
454
+ responseTimeMs,
455
+ status: 0,
456
+ success: false,
457
+ bytesSent: 0,
458
+ bytesReceived: 0,
459
+ cacheHit: false,
460
+ dedupeHit: false,
461
+ error:
462
+ error instanceof Error ? error.message : String(error),
463
+ });
464
+ }
465
+
466
+ throw error;
467
+ } finally {
468
+ self.inflightRequests.delete(dedupKey);
469
+ }
470
+ })();
471
+
472
+ self.inflightRequests.set(dedupKey, requestPromise);
473
+
474
+ return requestPromise;
250
475
  },
251
476
 
252
477
  post: async <R = any>(
253
- body?: string | null,
254
478
  options?: UrlRequestOptions
255
479
  ): Promise<JirenResponse<R> | R | string | ArrayBuffer | Blob> => {
480
+ const { headers, serializedBody } = self.prepareBody(
481
+ options?.body,
482
+ options?.headers
483
+ );
256
484
  return self.request<R>(
257
485
  "POST",
258
486
  buildUrl(options?.path),
259
- body || null,
487
+ serializedBody,
260
488
  {
261
- headers: options?.headers,
489
+ headers,
262
490
  maxRedirects: options?.maxRedirects,
263
491
  responseType: options?.responseType,
264
492
  }
@@ -266,15 +494,18 @@ export class JirenClient<
266
494
  },
267
495
 
268
496
  put: async <R = any>(
269
- body?: string | null,
270
497
  options?: UrlRequestOptions
271
498
  ): Promise<JirenResponse<R> | R | string | ArrayBuffer | Blob> => {
499
+ const { headers, serializedBody } = self.prepareBody(
500
+ options?.body,
501
+ options?.headers
502
+ );
272
503
  return self.request<R>(
273
504
  "PUT",
274
505
  buildUrl(options?.path),
275
- body || null,
506
+ serializedBody,
276
507
  {
277
- headers: options?.headers,
508
+ headers,
278
509
  maxRedirects: options?.maxRedirects,
279
510
  responseType: options?.responseType,
280
511
  }
@@ -282,15 +513,18 @@ export class JirenClient<
282
513
  },
283
514
 
284
515
  patch: async <R = any>(
285
- body?: string | null,
286
516
  options?: UrlRequestOptions
287
517
  ): Promise<JirenResponse<R> | R | string | ArrayBuffer | Blob> => {
518
+ const { headers, serializedBody } = self.prepareBody(
519
+ options?.body,
520
+ options?.headers
521
+ );
288
522
  return self.request<R>(
289
523
  "PATCH",
290
524
  buildUrl(options?.path),
291
- body || null,
525
+ serializedBody,
292
526
  {
293
- headers: options?.headers,
527
+ headers,
294
528
  maxRedirects: options?.maxRedirects,
295
529
  responseType: options?.responseType,
296
530
  }
@@ -298,15 +532,18 @@ export class JirenClient<
298
532
  },
299
533
 
300
534
  delete: async <R = any>(
301
- body?: string | null,
302
535
  options?: UrlRequestOptions
303
536
  ): Promise<JirenResponse<R> | R | string | ArrayBuffer | Blob> => {
537
+ const { headers, serializedBody } = self.prepareBody(
538
+ options?.body,
539
+ options?.headers
540
+ );
304
541
  return self.request<R>(
305
542
  "DELETE",
306
543
  buildUrl(options?.path),
307
- body || null,
544
+ serializedBody,
308
545
  {
309
- headers: options?.headers,
546
+ headers,
310
547
  maxRedirects: options?.maxRedirects,
311
548
  responseType: options?.responseType,
312
549
  }
@@ -349,24 +586,55 @@ export class JirenClient<
349
586
  });
350
587
  }
351
588
 
589
+ /**
590
+ * Free the native client resources.
591
+ * Note: This is called automatically when the client is garbage collected,
592
+ * or you can use the `using` keyword for automatic cleanup in a scope.
593
+ */
352
594
  public close(): void {
353
595
  if (this.ptr) {
596
+ // Unregister from FinalizationRegistry since we're manually closing
597
+ clientRegistry.unregister(this);
354
598
  lib.symbols.zclient_free(this.ptr);
355
599
  this.ptr = null;
356
600
  }
357
601
  }
358
602
 
359
- public async warmup(urls: string[]): Promise<void> {
603
+ /**
604
+ * Dispose method for the `using` keyword (ECMAScript Explicit Resource Management)
605
+ * @example
606
+ * ```typescript
607
+ * using client = new JirenClient({ targets: [...] });
608
+ * // client is automatically closed when the scope ends
609
+ * ```
610
+ */
611
+ [Symbol.dispose](): void {
612
+ this.close();
613
+ }
614
+
615
+ /**
616
+ * Register interceptors dynamically.
617
+ */
618
+ public use(interceptors: Interceptors): this {
619
+ if (interceptors.request)
620
+ this.requestInterceptors.push(...interceptors.request);
621
+ if (interceptors.response)
622
+ this.responseInterceptors.push(...interceptors.response);
623
+ if (interceptors.error) this.errorInterceptors.push(...interceptors.error);
624
+ return this;
625
+ }
626
+
627
+ /**
628
+ * Pre-connect to URLs in parallel.
629
+ */
630
+ public async preconnect(urls: string[]): Promise<void> {
360
631
  if (!this.ptr) throw new Error("Client is closed");
361
632
 
362
633
  await Promise.all(
363
634
  urls.map(
364
635
  (url) =>
365
636
  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.
637
+ lib.symbols.zclient_prefetch(this.ptr, url);
370
638
  resolve();
371
639
  })
372
640
  )
@@ -374,10 +642,17 @@ export class JirenClient<
374
642
  }
375
643
 
376
644
  /**
377
- * @deprecated Use warmup() instead
645
+ * @deprecated Use preconnect() instead
646
+ */
647
+ public async warmup(urls: string[]): Promise<void> {
648
+ return this.preconnect(urls);
649
+ }
650
+
651
+ /**
652
+ * @deprecated Use preconnect() instead
378
653
  */
379
654
  public prefetch(urls: string[]): void {
380
- this.warmup(urls);
655
+ this.preconnect(urls);
381
656
  }
382
657
 
383
658
  public async request<T = any>(
@@ -443,84 +718,131 @@ export class JirenClient<
443
718
  }
444
719
  }
445
720
 
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
- ];
721
+ // Run interceptors
722
+ let ctx: InterceptorRequestContext = { method, url, headers, body };
723
+ if (this.requestInterceptors.length > 0) {
724
+ for (const interceptor of this.requestInterceptors) {
725
+ ctx = await interceptor(ctx);
726
+ }
727
+ method = ctx.method;
728
+ url = ctx.url;
729
+ headers = ctx.headers;
730
+ body = ctx.body ?? null;
731
+ }
732
+
733
+ // Prepare headers
734
+ let headerStr: string;
735
+ const hasCustomHeaders = Object.keys(headers).length > 0;
736
+
737
+ if (hasCustomHeaders) {
738
+ const finalHeaders = { ...this.defaultHeaders, ...headers };
739
+ const orderedHeaders: Record<string, string> = {};
740
+ const keys = [
741
+ "sec-ch-ua",
742
+ "sec-ch-ua-mobile",
743
+ "sec-ch-ua-platform",
744
+ "upgrade-insecure-requests",
745
+ "user-agent",
746
+ "accept",
747
+ "sec-fetch-site",
748
+ "sec-fetch-mode",
749
+ "sec-fetch-user",
750
+ "sec-fetch-dest",
751
+ "accept-encoding",
752
+ "accept-language",
753
+ ];
754
+
755
+ for (const key of keys) {
756
+ if (finalHeaders[key]) {
757
+ orderedHeaders[key] = finalHeaders[key];
758
+ delete finalHeaders[key];
759
+ }
760
+ }
482
761
 
483
- for (const key of keys) {
484
- if (finalHeaders[key]) {
485
- orderedHeaders[key] = finalHeaders[key];
486
- delete finalHeaders[key];
762
+ for (const [key, value] of Object.entries(finalHeaders)) {
763
+ orderedHeaders[key] = value;
487
764
  }
765
+
766
+ headerStr = Object.entries(orderedHeaders)
767
+ .map(([k, v]) => `${k.toLowerCase()}: ${v}`)
768
+ .join("\r\n");
769
+ } else {
770
+ headerStr = this.defaultHeadersStr;
488
771
  }
489
- for (const [key, value] of Object.entries(finalHeaders)) {
490
- orderedHeaders[key] = value;
772
+
773
+ // Retry logic
774
+ let retryConfig = this.globalRetry;
775
+ if (options && typeof options === "object" && "retry" in options) {
776
+ const userRetry = (options as any).retry;
777
+ if (typeof userRetry === "number") {
778
+ retryConfig = { count: userRetry, delay: 100, backoff: 2 };
779
+ } else if (typeof userRetry === "object") {
780
+ retryConfig = userRetry;
781
+ }
491
782
  }
492
783
 
493
- const headerStr = Object.entries(orderedHeaders)
494
- .map(([k, v]) => `${k.toLowerCase()}: ${v}`)
495
- .join("\r\n");
784
+ let attempts = 0;
785
+ const maxAttempts = (retryConfig?.count || 0) + 1;
786
+ let currentDelay = retryConfig?.delay || 100;
787
+ const backoff = retryConfig?.backoff || 2;
788
+ let lastError: any;
789
+
790
+ while (attempts < maxAttempts) {
791
+ attempts++;
792
+ try {
793
+ const respPtr = lib.symbols.zclient_request(
794
+ this.ptr,
795
+ method,
796
+ url,
797
+ headerStr.length > 0 ? headerStr : null,
798
+ body || null,
799
+ maxRedirects,
800
+ antibot
801
+ );
802
+
803
+ if (!respPtr) {
804
+ throw new Error("Native request failed (returned null pointer)");
805
+ }
496
806
 
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.
503
-
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
- );
807
+ const response = this.parseResponse<T>(respPtr, url);
808
+
809
+ // Run response interceptors
810
+ let finalResponse = response;
811
+ if (this.responseInterceptors.length > 0) {
812
+ let responseCtx: InterceptorResponseContext<T> = {
813
+ request: ctx,
814
+ response,
815
+ };
816
+ for (const interceptor of this.responseInterceptors) {
817
+ responseCtx = await interceptor(responseCtx);
818
+ }
819
+ finalResponse = responseCtx.response;
820
+ }
513
821
 
514
- const response = this.parseResponse<T>(respPtr, url);
822
+ if (responseType) {
823
+ if (responseType === "json") return finalResponse.body.json();
824
+ if (responseType === "text") return finalResponse.body.text();
825
+ if (responseType === "arraybuffer")
826
+ return finalResponse.body.arrayBuffer();
827
+ if (responseType === "blob") return finalResponse.body.blob();
828
+ }
515
829
 
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();
830
+ return finalResponse;
831
+ } catch (err) {
832
+ if (this.errorInterceptors.length > 0) {
833
+ for (const interceptor of this.errorInterceptors) {
834
+ await interceptor(err as Error, ctx);
835
+ }
836
+ }
837
+ lastError = err;
838
+ if (attempts < maxAttempts) {
839
+ await this.waitFor(currentDelay);
840
+ currentDelay *= backoff;
841
+ }
842
+ }
521
843
  }
522
844
 
523
- return response;
845
+ throw lastError || new Error("Request failed after retries");
524
846
  }
525
847
 
526
848
  private parseResponse<T = any>(respPtr: any, url: string): JirenResponse<T> {
@@ -540,8 +862,6 @@ export class JirenClient<
540
862
  if (headersLen > 0) {
541
863
  const rawHeadersPtr = lib.symbols.zclient_response_headers(respPtr);
542
864
  if (rawHeadersPtr) {
543
- // Copy headers to JS memory
544
- // Koffi decode to buffer: koffi.decode(ptr, "uint8_t", len)
545
865
  const raw = Buffer.from(
546
866
  koffi.decode(rawHeadersPtr, "uint8_t", headersLen)
547
867
  );
@@ -565,15 +885,12 @@ export class JirenClient<
565
885
 
566
886
  let buffer: Buffer = Buffer.alloc(0);
567
887
  if (len > 0 && bodyPtr) {
568
- // Copy body content
569
888
  buffer = Buffer.from(koffi.decode(bodyPtr, "uint8_t", len));
570
889
 
571
- // Handle GZIP compression - search for magic bytes in first 16 bytes
572
- // (handles chunked encoding or other framing that may add prefix bytes)
890
+ // Handle GZIP
573
891
  const contentEncoding = headersProxy["content-encoding"]?.toLowerCase();
574
892
  let gzipOffset = -1;
575
893
 
576
- // Search for gzip magic bytes (0x1f 0x8b) in first 16 bytes
577
894
  for (let i = 0; i < Math.min(16, buffer.length - 1); i++) {
578
895
  if (buffer[i] === 0x1f && buffer[i + 1] === 0x8b) {
579
896
  gzipOffset = i;
@@ -583,16 +900,10 @@ export class JirenClient<
583
900
 
584
901
  if (contentEncoding === "gzip" || gzipOffset >= 0) {
585
902
  try {
586
- // If we found gzip at an offset, slice from there
587
903
  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
904
  buffer = zlib.gunzipSync(gzipData);
594
905
  } catch (e) {
595
- console.warn("Failed to gunzip response body:", e);
906
+ // Keep original buffer
596
907
  }
597
908
  }
598
909
  }
@@ -643,6 +954,33 @@ export class JirenClient<
643
954
  lib.symbols.zclient_response_free(respPtr);
644
955
  }
645
956
  }
957
+
958
+ /**
959
+ * Helper to prepare body and headers for requests.
960
+ */
961
+ private prepareBody(
962
+ body: string | object | null | undefined,
963
+ userHeaders?: Record<string, string>
964
+ ): { headers: Record<string, string>; serializedBody: string | null } {
965
+ let serializedBody: string | null = null;
966
+ const headers = { ...userHeaders };
967
+
968
+ if (body !== null && body !== undefined) {
969
+ if (typeof body === "object") {
970
+ serializedBody = JSON.stringify(body);
971
+ const hasContentType = Object.keys(headers).some(
972
+ (k) => k.toLowerCase() === "content-type"
973
+ );
974
+ if (!hasContentType) {
975
+ headers["Content-Type"] = "application/json";
976
+ }
977
+ } else {
978
+ serializedBody = String(body);
979
+ }
980
+ }
981
+
982
+ return { headers, serializedBody };
983
+ }
646
984
  }
647
985
 
648
986
  class NativeHeaders {