jiren 1.4.0 → 1.5.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.
@@ -1,15 +1,23 @@
1
1
  import { CString, toArrayBuffer, type Pointer } from "bun:ffi";
2
2
  import { lib } from "./native";
3
3
  import { ResponseCache } from "./cache";
4
+ import { MetricsCollector, type RequestMetric } from "./metrics";
4
5
  import type {
5
6
  RequestOptions,
6
7
  JirenResponse,
7
8
  JirenResponseBody,
8
- WarmupUrlConfig,
9
+ TargetUrlConfig,
9
10
  UrlRequestOptions,
10
11
  UrlEndpoint,
11
12
  CacheConfig,
12
13
  RetryConfig,
14
+ Interceptors,
15
+ RequestInterceptor,
16
+ ResponseInterceptor,
17
+ ErrorInterceptor,
18
+ InterceptorRequestContext,
19
+ InterceptorResponseContext,
20
+ MetricsAPI,
13
21
  } from "./types";
14
22
 
15
23
  const STATUS_TEXT: Record<number, string> = {
@@ -27,29 +35,37 @@ const STATUS_TEXT: Record<number, string> = {
27
35
  503: "Service Unavailable",
28
36
  };
29
37
 
30
- /** URL configuration with optional cache */
31
- export type UrlConfig = string | { url: string; cache?: boolean | CacheConfig };
38
+ /** URL configuration with optional cache and antibot */
39
+ export type UrlConfig =
40
+ | string
41
+ | { url: string; cache?: boolean | CacheConfig; antibot?: boolean };
32
42
 
33
43
  /** Options for JirenClient constructor */
34
44
  export interface JirenClientOptions<
35
- T extends readonly WarmupUrlConfig[] | Record<string, UrlConfig> =
36
- | readonly WarmupUrlConfig[]
45
+ T extends readonly TargetUrlConfig[] | Record<string, UrlConfig> =
46
+ | readonly TargetUrlConfig[]
37
47
  | Record<string, UrlConfig>
38
48
  > {
39
- /** URLs to warmup on client creation (pre-connect + handshake) */
40
- warmup?: string[] | T;
49
+ /** Target URLs to pre-connect on client creation */
50
+ targets?: string[] | T;
41
51
 
42
52
  /** Enable benchmark mode (Force HTTP/2, disable probing) */
43
53
  benchmark?: boolean;
44
54
 
45
55
  /** Global retry configuration */
46
56
  retry?: number | RetryConfig;
57
+
58
+ /** Request/response interceptors */
59
+ interceptors?: Interceptors;
60
+
61
+ /** Performance mode: disable metrics, skip cache checks for non-cached endpoints (default: true) */
62
+ performanceMode?: boolean;
47
63
  }
48
64
 
49
- /** Helper to extract keys from Warmup Config */
50
- export type ExtractWarmupKeys<
51
- T extends readonly WarmupUrlConfig[] | Record<string, UrlConfig>
52
- > = T extends readonly WarmupUrlConfig[]
65
+ /** Helper to extract keys from Target Config */
66
+ export type ExtractTargetKeys<
67
+ T extends readonly TargetUrlConfig[] | Record<string, UrlConfig>
68
+ > = T extends readonly TargetUrlConfig[]
53
69
  ? T[number]["key"]
54
70
  : T extends Record<string, UrlConfig>
55
71
  ? keyof T
@@ -57,76 +73,151 @@ export type ExtractWarmupKeys<
57
73
 
58
74
  /** Type-safe URL accessor - maps keys to UrlEndpoint objects */
59
75
  export type UrlAccessor<
60
- T extends readonly WarmupUrlConfig[] | Record<string, UrlConfig>
76
+ T extends readonly TargetUrlConfig[] | Record<string, UrlConfig>
61
77
  > = {
62
- [K in ExtractWarmupKeys<T>]: UrlEndpoint;
78
+ [K in ExtractTargetKeys<T>]: UrlEndpoint;
63
79
  };
64
80
 
65
81
  /**
66
- * Helper function to define warmup URLs with type inference.
82
+ * Helper function to define target URLs with type inference.
67
83
  * This eliminates the need for 'as const'.
68
84
  *
69
85
  * @example
70
86
  * ```typescript
71
87
  * const client = new JirenClient({
72
- * warmup: defineUrls([
88
+ * targets: defineUrls([
73
89
  * { key: "google", url: "https://google.com" },
74
90
  * ])
75
91
  * });
76
92
  * // OR
77
93
  * const client = new JirenClient({
78
- * warmup: {
94
+ * targets: {
79
95
  * google: "https://google.com"
80
96
  * }
81
97
  * });
82
98
  * ```
83
99
  */
84
- export function defineUrls<const T extends readonly WarmupUrlConfig[]>(
100
+ export function defineUrls<const T extends readonly TargetUrlConfig[]>(
85
101
  urls: T
86
102
  ): T {
87
103
  return urls;
88
104
  }
89
105
 
90
106
  export class JirenClient<
91
- T extends readonly WarmupUrlConfig[] | Record<string, UrlConfig> =
92
- | readonly WarmupUrlConfig[]
107
+ T extends readonly TargetUrlConfig[] | Record<string, UrlConfig> =
108
+ | readonly TargetUrlConfig[]
93
109
  | Record<string, UrlConfig>
94
110
  > {
95
111
  private ptr: Pointer | null;
96
112
  private urlMap: Map<string, string> = new Map();
97
113
  private cacheConfig: Map<string, { enabled: boolean; ttl: number }> =
98
114
  new Map();
115
+ private antibotConfig: Map<string, boolean> = new Map();
99
116
  private cache: ResponseCache;
100
117
  private inflightRequests: Map<string, Promise<any>> = new Map();
101
118
  private globalRetry?: RetryConfig;
119
+ private requestInterceptors: RequestInterceptor[] = [];
120
+ private responseInterceptors: ResponseInterceptor[] = [];
121
+ private errorInterceptors: ErrorInterceptor[] = [];
122
+ private targetsPromise: Promise<void> | null = null;
123
+ private targetsComplete: Set<string> = new Set();
124
+ private performanceMode: boolean = false;
125
+
126
+ // Pre-computed headers (avoid per-request overhead)
127
+ private readonly defaultHeadersStr: string;
128
+ private readonly defaultHeadersBuffer: Buffer; // Cached buffer
129
+ private readonly defaultHeaders: Record<string, string> = {
130
+ "user-agent":
131
+ "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",
132
+ accept:
133
+ "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8",
134
+ "accept-encoding": "gzip",
135
+ "accept-language": "en-US,en;q=0.9",
136
+ "sec-ch-ua":
137
+ '"Not_A Brand";v="8", "Chromium";v="120", "Google Chrome";v="120"',
138
+ "sec-ch-ua-mobile": "?0",
139
+ "sec-ch-ua-platform": '"macOS"',
140
+ "sec-fetch-dest": "document",
141
+ "sec-fetch-mode": "navigate",
142
+ "sec-fetch-site": "none",
143
+ "sec-fetch-user": "?1",
144
+ "upgrade-insecure-requests": "1",
145
+ };
146
+
147
+ // Pre-computed method buffers (avoid per-request allocation)
148
+ private readonly methodBuffers: Record<string, Buffer> = {
149
+ GET: Buffer.from("GET\0"),
150
+ POST: Buffer.from("POST\0"),
151
+ PUT: Buffer.from("PUT\0"),
152
+ PATCH: Buffer.from("PATCH\0"),
153
+ DELETE: Buffer.from("DELETE\0"),
154
+ HEAD: Buffer.from("HEAD\0"),
155
+ OPTIONS: Buffer.from("OPTIONS\0"),
156
+ };
157
+
158
+ // Reusable TextDecoder (avoid per-response allocation)
159
+ private readonly decoder = new TextDecoder();
102
160
 
103
161
  /** Type-safe URL accessor for warmed-up URLs */
104
162
  public readonly url: UrlAccessor<T>;
105
163
 
164
+ // Metrics collector
165
+ private metricsCollector: MetricsCollector;
166
+ /** Public metrics API */
167
+ public readonly metrics: MetricsAPI;
168
+
106
169
  constructor(options?: JirenClientOptions<T>) {
107
170
  this.ptr = lib.symbols.zclient_new();
108
171
  if (!this.ptr) throw new Error("Failed to create native client instance");
109
172
 
173
+ // Pre-compute default headers string (avoid per-request overhead)
174
+ const orderedKeys = [
175
+ "sec-ch-ua",
176
+ "sec-ch-ua-mobile",
177
+ "sec-ch-ua-platform",
178
+ "upgrade-insecure-requests",
179
+ "user-agent",
180
+ "accept",
181
+ "sec-fetch-site",
182
+ "sec-fetch-mode",
183
+ "sec-fetch-user",
184
+ "sec-fetch-dest",
185
+ "accept-encoding",
186
+ "accept-language",
187
+ ];
188
+ this.defaultHeadersStr = orderedKeys
189
+ .map((k) => `${k}: ${this.defaultHeaders[k]}`)
190
+ .join("\r\n");
191
+ // Cache the Buffer to avoid per-request allocation
192
+ this.defaultHeadersBuffer = Buffer.from(this.defaultHeadersStr + "\0");
193
+
110
194
  // Initialize cache
111
195
  this.cache = new ResponseCache(100);
112
196
 
197
+ // Initialize metrics
198
+ this.metricsCollector = new MetricsCollector();
199
+ this.metrics = this.metricsCollector;
200
+
201
+ // Performance mode (default: true for maximum speed, set false to enable metrics)
202
+ this.performanceMode = options?.performanceMode ?? true;
203
+
113
204
  // Enable benchmark mode if requested
114
205
  if (options?.benchmark) {
115
206
  lib.symbols.zclient_set_benchmark_mode(this.ptr, true);
116
207
  }
117
208
 
118
- // Process warmup URLs
119
- if (options?.warmup) {
209
+ // Process target URLs
210
+ if (options?.targets) {
120
211
  const urls: string[] = [];
121
- const warmup = options.warmup;
212
+ const targets = options.targets;
122
213
 
123
- if (Array.isArray(warmup)) {
124
- for (const item of warmup) {
214
+ if (Array.isArray(targets)) {
215
+ for (const item of targets) {
125
216
  if (typeof item === "string") {
126
217
  urls.push(item);
127
218
  } else {
128
- // WarmupUrlConfig with key and optional cache
129
- const config = item as WarmupUrlConfig;
219
+ // TargetUrlConfig with key and optional cache
220
+ const config = item as TargetUrlConfig;
130
221
  urls.push(config.url);
131
222
  this.urlMap.set(config.key, config.url);
132
223
 
@@ -138,11 +229,19 @@ export class JirenClient<
138
229
  : { enabled: true, ttl: config.cache.ttl || 60000 };
139
230
  this.cacheConfig.set(config.key, cacheConfig);
140
231
  }
232
+
233
+ // Store antibot config
234
+ if (config.antibot) {
235
+ this.antibotConfig.set(config.key, true);
236
+ }
141
237
  }
142
238
  }
143
239
  } else {
144
240
  // Record<string, UrlConfig>
145
- for (const [key, urlConfig] of Object.entries(warmup)) {
241
+ for (const [key, urlConfig] of Object.entries(targets) as [
242
+ string,
243
+ UrlConfig
244
+ ][]) {
146
245
  if (typeof urlConfig === "string") {
147
246
  // Simple string URL
148
247
  urls.push(urlConfig);
@@ -160,12 +259,32 @@ export class JirenClient<
160
259
  : { enabled: true, ttl: urlConfig.cache.ttl || 60000 };
161
260
  this.cacheConfig.set(key, cacheConfig);
162
261
  }
262
+
263
+ // Store antibot config
264
+ if (urlConfig.antibot) {
265
+ this.antibotConfig.set(key, true);
266
+ }
163
267
  }
164
268
  }
165
269
  }
166
270
 
167
271
  if (urls.length > 0) {
168
- this.warmup(urls);
272
+ // Lazy pre-connect in background (always - it's faster)
273
+ this.targetsPromise = this.preconnect(urls).then(() => {
274
+ urls.forEach((url) => this.targetsComplete.add(url));
275
+ this.targetsPromise = null; // Clear when done to skip future checks
276
+ });
277
+ }
278
+
279
+ // Preload L2 disk cache entries into L1 memory for cached endpoints
280
+ // This ensures user's first request hits L1 (~0.001ms) instead of L2 (~5ms)
281
+ for (const [key, config] of this.cacheConfig.entries()) {
282
+ if (config.enabled) {
283
+ const url = this.urlMap.get(key);
284
+ if (url) {
285
+ this.cache.preloadL1(url);
286
+ }
287
+ }
169
288
  }
170
289
  }
171
290
 
@@ -179,12 +298,34 @@ export class JirenClient<
179
298
  ? { count: options.retry, delay: 100, backoff: 2 }
180
299
  : options.retry;
181
300
  }
301
+
302
+ // Initialize interceptors
303
+ if (options?.interceptors) {
304
+ this.requestInterceptors = options.interceptors.request || [];
305
+ this.responseInterceptors = options.interceptors.response || [];
306
+ this.errorInterceptors = options.interceptors.error || [];
307
+ }
182
308
  }
183
309
 
184
310
  private async waitFor(ms: number) {
185
311
  return new Promise((resolve) => setTimeout(resolve, ms));
186
312
  }
187
313
 
314
+ /**
315
+ * Wait for lazy pre-connection to complete.
316
+ * Only needed if you want to ensure targets are ready before making requests.
317
+ */
318
+ public async waitForTargets(): Promise<void> {
319
+ if (this.targetsPromise) await this.targetsPromise;
320
+ }
321
+
322
+ /**
323
+ * @deprecated Use waitForTargets() instead
324
+ */
325
+ public async waitForWarmup(): Promise<void> {
326
+ return this.waitForTargets();
327
+ }
328
+
188
329
  /**
189
330
  * Creates a proxy-based URL accessor for type-safe access to warmed-up URLs.
190
331
  */
@@ -213,13 +354,55 @@ export class JirenClient<
213
354
  get: async <R = any>(
214
355
  options?: UrlRequestOptions
215
356
  ): Promise<JirenResponse<R> | R | string | ArrayBuffer | Blob> => {
357
+ // Wait for targets to complete if still pending
358
+ if (self.targetsPromise) {
359
+ await self.targetsPromise;
360
+ }
361
+
216
362
  // Check if caching is enabled for this URL
217
363
  const cacheConfig = self.cacheConfig.get(prop);
218
364
 
365
+ // Check if antibot is enabled for this URL (from targets config or per-request)
366
+ const useAntibot =
367
+ options?.antibot ?? self.antibotConfig.get(prop) ?? false;
368
+
369
+ // === FAST PATH: Skip overhead when no cache and performance mode ===
370
+ if (!cacheConfig?.enabled && self.performanceMode) {
371
+ return self.request<R>("GET", buildUrl(options?.path), null, {
372
+ headers: options?.headers,
373
+ maxRedirects: options?.maxRedirects,
374
+ responseType: options?.responseType,
375
+ antibot: useAntibot,
376
+ });
377
+ }
378
+
379
+ // === SLOW PATH: Full features (cache, metrics, dedupe) ===
380
+ const startTime = performance.now();
381
+
382
+ // Try L1 cache first
219
383
  if (cacheConfig?.enabled) {
220
- // Try to get from cache
221
384
  const cached = self.cache.get(baseUrl, options?.path, options);
222
385
  if (cached) {
386
+ const responseTimeMs = performance.now() - startTime;
387
+
388
+ // Check which cache layer hit (L1 in memory is very fast ~0.001-0.1ms, L2 disk is ~1-5ms)
389
+ const cacheLayer = responseTimeMs < 0.5 ? "l1" : "l2";
390
+
391
+ // Record cache hit metric (skip in performance mode)
392
+ if (!self.performanceMode) {
393
+ self.metricsCollector.recordRequest(prop, {
394
+ startTime,
395
+ responseTimeMs,
396
+ status: cached.status,
397
+ success: cached.ok,
398
+ bytesSent: 0,
399
+ bytesReceived: 0,
400
+ cacheHit: true,
401
+ cacheLayer,
402
+ dedupeHit: false,
403
+ });
404
+ }
405
+
223
406
  return cached as any;
224
407
  }
225
408
  }
@@ -232,7 +415,28 @@ export class JirenClient<
232
415
 
233
416
  // Check if there is already an identical request in flight
234
417
  if (self.inflightRequests.has(dedupKey)) {
235
- return self.inflightRequests.get(dedupKey);
418
+ const dedupeStart = performance.now();
419
+ const result = await self.inflightRequests.get(dedupKey);
420
+ const responseTimeMs = performance.now() - dedupeStart;
421
+
422
+ // Record deduplication hit (skip in performance mode)
423
+ if (!self.performanceMode) {
424
+ self.metricsCollector.recordRequest(prop, {
425
+ startTime: dedupeStart,
426
+ responseTimeMs,
427
+ status:
428
+ typeof result === "object" && "status" in result
429
+ ? result.status
430
+ : 200,
431
+ success: true,
432
+ bytesSent: 0,
433
+ bytesReceived: 0,
434
+ cacheHit: false,
435
+ dedupeHit: true,
436
+ });
437
+ }
438
+
439
+ return result;
236
440
  }
237
441
 
238
442
  // Create the request promise
@@ -247,7 +451,7 @@ export class JirenClient<
247
451
  headers: options?.headers,
248
452
  maxRedirects: options?.maxRedirects,
249
453
  responseType: options?.responseType,
250
- antibot: options?.antibot,
454
+ antibot: useAntibot,
251
455
  }
252
456
  );
253
457
 
@@ -266,7 +470,50 @@ export class JirenClient<
266
470
  );
267
471
  }
268
472
 
473
+ const responseTimeMs = performance.now() - startTime;
474
+
475
+ // Record request metric (skip in performance mode)
476
+ if (!self.performanceMode) {
477
+ self.metricsCollector.recordRequest(prop, {
478
+ startTime,
479
+ responseTimeMs,
480
+ status:
481
+ typeof response === "object" && "status" in response
482
+ ? response.status
483
+ : 200,
484
+ success:
485
+ typeof response === "object" && "ok" in response
486
+ ? response.ok
487
+ : true,
488
+ bytesSent: options?.body
489
+ ? JSON.stringify(options.body).length
490
+ : 0,
491
+ bytesReceived: 0,
492
+ cacheHit: false,
493
+ dedupeHit: false,
494
+ });
495
+ }
496
+
269
497
  return response;
498
+ } catch (error) {
499
+ // Record failed request (skip in performance mode)
500
+ if (!self.performanceMode) {
501
+ const responseTimeMs = performance.now() - startTime;
502
+ self.metricsCollector.recordRequest(prop, {
503
+ startTime,
504
+ responseTimeMs,
505
+ status: 0,
506
+ success: false,
507
+ bytesSent: 0,
508
+ bytesReceived: 0,
509
+ cacheHit: false,
510
+ dedupeHit: false,
511
+ error:
512
+ error instanceof Error ? error.message : String(error),
513
+ });
514
+ }
515
+
516
+ throw error;
270
517
  } finally {
271
518
  // Remove from inflight map when done (success or failure)
272
519
  self.inflightRequests.delete(dedupKey);
@@ -410,14 +657,28 @@ export class JirenClient<
410
657
  }
411
658
 
412
659
  /**
413
- * Warm up connections to URLs (DNS resolve + QUIC handshake) in parallel.
660
+ * Register interceptors dynamically.
661
+ * @param interceptors - Interceptor configuration to add
662
+ * @returns this for chaining
663
+ */
664
+ public use(interceptors: Interceptors): this {
665
+ if (interceptors.request)
666
+ this.requestInterceptors.push(...interceptors.request);
667
+ if (interceptors.response)
668
+ this.responseInterceptors.push(...interceptors.response);
669
+ if (interceptors.error) this.errorInterceptors.push(...interceptors.error);
670
+ return this;
671
+ }
672
+
673
+ /**
674
+ * Pre-connect to URLs (DNS resolve + QUIC handshake) in parallel.
414
675
  * Call this early (e.g., at app startup) so subsequent requests are fast.
415
- * @param urls - List of URLs to warm up
676
+ * @param urls - List of URLs to pre-connect to
416
677
  */
417
- public async warmup(urls: string[]): Promise<void> {
678
+ public async preconnect(urls: string[]): Promise<void> {
418
679
  if (!this.ptr) throw new Error("Client is closed");
419
680
 
420
- // Warm up all URLs in parallel for faster startup
681
+ // Pre-connect to all URLs in parallel for faster startup
421
682
  await Promise.all(
422
683
  urls.map(
423
684
  (url) =>
@@ -431,10 +692,17 @@ export class JirenClient<
431
692
  }
432
693
 
433
694
  /**
434
- * @deprecated Use warmup() instead
695
+ * @deprecated Use preconnect() instead
696
+ */
697
+ public async warmup(urls: string[]): Promise<void> {
698
+ return this.preconnect(urls);
699
+ }
700
+
701
+ /**
702
+ * @deprecated Use preconnect() instead
435
703
  */
436
704
  public prefetch(urls: string[]): void {
437
- this.warmup(urls);
705
+ this.preconnect(urls);
438
706
  }
439
707
 
440
708
  /**
@@ -510,7 +778,22 @@ export class JirenClient<
510
778
  }
511
779
  }
512
780
 
513
- const methodBuffer = Buffer.from(method + "\0");
781
+ // Run interceptors only if any are registered
782
+ let ctx: InterceptorRequestContext = { method, url, headers, body };
783
+ if (this.requestInterceptors.length > 0) {
784
+ for (const interceptor of this.requestInterceptors) {
785
+ ctx = await interceptor(ctx);
786
+ }
787
+ // Apply interceptor modifications
788
+ method = ctx.method;
789
+ url = ctx.url;
790
+ headers = ctx.headers;
791
+ body = ctx.body ?? null;
792
+ }
793
+
794
+ // Use pre-computed method buffer or create one for custom methods
795
+ const methodBuffer =
796
+ this.methodBuffers[method] || Buffer.from(method + "\0");
514
797
  const urlBuffer = Buffer.from(url + "\0");
515
798
 
516
799
  let bodyBuffer: Buffer | null = null;
@@ -518,63 +801,51 @@ export class JirenClient<
518
801
  bodyBuffer = Buffer.from(body + "\0");
519
802
  }
520
803
 
521
- let headersBuffer: Buffer | null = null;
522
- const defaultHeaders: Record<string, string> = {
523
- "user-agent":
524
- "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",
525
- accept:
526
- "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8",
527
- "accept-encoding": "gzip",
528
- "accept-language": "en-US,en;q=0.9",
529
- "sec-ch-ua":
530
- '"Not_A Brand";v="8", "Chromium";v="120", "Google Chrome";v="120"',
531
- "sec-ch-ua-mobile": "?0",
532
- "sec-ch-ua-platform": '"macOS"',
533
- "sec-fetch-dest": "document",
534
- "sec-fetch-mode": "navigate",
535
- "sec-fetch-site": "none",
536
- "sec-fetch-user": "?1",
537
- "upgrade-insecure-requests": "1",
538
- };
539
-
540
- const finalHeaders = { ...defaultHeaders, ...headers };
541
-
542
- // Enforce Chrome header order
543
- const orderedHeaders: Record<string, string> = {};
544
- const keys = [
545
- "sec-ch-ua",
546
- "sec-ch-ua-mobile",
547
- "sec-ch-ua-platform",
548
- "upgrade-insecure-requests",
549
- "user-agent",
550
- "accept",
551
- "sec-fetch-site",
552
- "sec-fetch-mode",
553
- "sec-fetch-user",
554
- "sec-fetch-dest",
555
- "accept-encoding",
556
- "accept-language",
557
- ];
558
-
559
- // Add priority headers in order
560
- for (const key of keys) {
561
- if (finalHeaders[key]) {
562
- orderedHeaders[key] = finalHeaders[key];
563
- delete finalHeaders[key];
804
+ let headersBuffer: Buffer;
805
+ const hasCustomHeaders = Object.keys(headers).length > 0;
806
+
807
+ if (hasCustomHeaders) {
808
+ // Merge custom headers with defaults (slow path)
809
+ const finalHeaders = { ...this.defaultHeaders, ...headers };
810
+
811
+ // Enforce Chrome header order
812
+ const orderedHeaders: Record<string, string> = {};
813
+ const keys = [
814
+ "sec-ch-ua",
815
+ "sec-ch-ua-mobile",
816
+ "sec-ch-ua-platform",
817
+ "upgrade-insecure-requests",
818
+ "user-agent",
819
+ "accept",
820
+ "sec-fetch-site",
821
+ "sec-fetch-mode",
822
+ "sec-fetch-user",
823
+ "sec-fetch-dest",
824
+ "accept-encoding",
825
+ "accept-language",
826
+ ];
827
+
828
+ // Add priority headers in order
829
+ for (const key of keys) {
830
+ if (finalHeaders[key]) {
831
+ orderedHeaders[key] = finalHeaders[key];
832
+ delete finalHeaders[key];
833
+ }
564
834
  }
565
- }
566
835
 
567
- // Add remaining custom headers
568
- for (const [key, value] of Object.entries(finalHeaders)) {
569
- orderedHeaders[key] = value;
570
- }
836
+ // Add remaining custom headers
837
+ for (const [key, value] of Object.entries(finalHeaders)) {
838
+ orderedHeaders[key] = value;
839
+ }
571
840
 
572
- const headerStr = Object.entries(orderedHeaders)
573
- .map(([k, v]) => `${k.toLowerCase()}: ${v}`)
574
- .join("\r\n");
841
+ const headerStr = Object.entries(orderedHeaders)
842
+ .map(([k, v]) => `${k.toLowerCase()}: ${v}`)
843
+ .join("\r\n");
575
844
 
576
- if (headerStr.length > 0) {
577
845
  headersBuffer = Buffer.from(headerStr + "\0");
846
+ } else {
847
+ // Fast path: use pre-computed headers buffer (no allocation!)
848
+ headersBuffer = this.defaultHeadersBuffer;
578
849
  }
579
850
 
580
851
  // Determine retry configuration
@@ -603,7 +874,8 @@ export class JirenClient<
603
874
  while (attempts < maxAttempts) {
604
875
  attempts++;
605
876
  try {
606
- const respPtr = lib.symbols.zclient_request(
877
+ // Use optimized single-call API (reduces 5 FFI calls to 1)
878
+ const respPtr = lib.symbols.zclient_request_full(
607
879
  this.ptr,
608
880
  methodBuffer,
609
881
  urlBuffer,
@@ -617,7 +889,20 @@ export class JirenClient<
617
889
  throw new Error("Native request failed (returned null pointer)");
618
890
  }
619
891
 
620
- const response = this.parseResponse<T>(respPtr, url);
892
+ const response = this.parseResponseFull<T>(respPtr, url);
893
+
894
+ // Run response interceptors only if any registered
895
+ let finalResponse = response;
896
+ if (this.responseInterceptors.length > 0) {
897
+ let responseCtx: InterceptorResponseContext<T> = {
898
+ request: ctx,
899
+ response,
900
+ };
901
+ for (const interceptor of this.responseInterceptors) {
902
+ responseCtx = await interceptor(responseCtx);
903
+ }
904
+ finalResponse = responseCtx.response;
905
+ }
621
906
 
622
907
  // Optional: Retry on specific status codes (e.g., 500, 502, 503, 504)
623
908
  // For now, we only retry on actual exceptions/network failures (null ptr)
@@ -626,15 +911,21 @@ export class JirenClient<
626
911
 
627
912
  // Auto-parse if requested
628
913
  if (responseType) {
629
- if (responseType === "json") return response.body.json();
630
- if (responseType === "text") return response.body.text();
914
+ if (responseType === "json") return finalResponse.body.json();
915
+ if (responseType === "text") return finalResponse.body.text();
631
916
  if (responseType === "arraybuffer")
632
- return response.body.arrayBuffer();
633
- if (responseType === "blob") return response.body.blob();
917
+ return finalResponse.body.arrayBuffer();
918
+ if (responseType === "blob") return finalResponse.body.blob();
634
919
  }
635
920
 
636
- return response;
921
+ return finalResponse;
637
922
  } catch (err) {
923
+ // Run error interceptors only if any registered
924
+ if (this.errorInterceptors.length > 0) {
925
+ for (const interceptor of this.errorInterceptors) {
926
+ await interceptor(err as Error, ctx);
927
+ }
928
+ }
638
929
  lastError = err;
639
930
  if (attempts < maxAttempts) {
640
931
  // Wait before retrying
@@ -697,6 +988,27 @@ export class JirenClient<
697
988
  if (len > 0 && bodyPtr) {
698
989
  // Create a copy of the buffer because the native response is freed immediately after
699
990
  buffer = toArrayBuffer(bodyPtr, 0, len).slice(0);
991
+
992
+ // Handle GZIP decompression if needed
993
+ const bufferView = new Uint8Array(buffer);
994
+ // Check for gzip magic bytes (0x1f 0x8b)
995
+ if (
996
+ bufferView.length >= 2 &&
997
+ bufferView[0] === 0x1f &&
998
+ bufferView[1] === 0x8b
999
+ ) {
1000
+ try {
1001
+ // Use Bun's built-in gzip decompression
1002
+ const decompressed = Bun.gunzipSync(bufferView);
1003
+ buffer = decompressed.buffer.slice(
1004
+ decompressed.byteOffset,
1005
+ decompressed.byteOffset + decompressed.byteLength
1006
+ );
1007
+ } catch (e) {
1008
+ // Decompression failed, keep original buffer
1009
+ console.warn("[Jiren] gzip decompression failed:", e);
1010
+ }
1011
+ }
700
1012
  }
701
1013
 
702
1014
  let bodyUsed = false;
@@ -725,11 +1037,11 @@ export class JirenClient<
725
1037
  },
726
1038
  text: async () => {
727
1039
  consumeBody();
728
- return new TextDecoder().decode(buffer);
1040
+ return this.decoder.decode(buffer);
729
1041
  },
730
1042
  json: async <R = T>(): Promise<R> => {
731
1043
  consumeBody();
732
- const text = new TextDecoder().decode(buffer);
1044
+ const text = this.decoder.decode(buffer);
733
1045
  return JSON.parse(text);
734
1046
  },
735
1047
  };
@@ -754,6 +1066,119 @@ export class JirenClient<
754
1066
  }
755
1067
  }
756
1068
 
1069
+ /**
1070
+ * Optimized response parser using ZFullResponse struct (single FFI call got all data)
1071
+ */
1072
+ private parseResponseFull<T = any>(
1073
+ respPtr: Pointer | null,
1074
+ url: string
1075
+ ): JirenResponse<T> {
1076
+ if (!respPtr)
1077
+ throw new Error("Native request failed (returned null pointer)");
1078
+
1079
+ try {
1080
+ // Use FFI accessor functions (avoids BigInt-to-Pointer conversion issues)
1081
+ const status = lib.symbols.zfull_response_status(respPtr);
1082
+ const bodyPtr = lib.symbols.zfull_response_body(respPtr);
1083
+ const bodyLen = Number(lib.symbols.zfull_response_body_len(respPtr));
1084
+ const headersPtr = lib.symbols.zfull_response_headers(respPtr);
1085
+ const headersLen = Number(
1086
+ lib.symbols.zfull_response_headers_len(respPtr)
1087
+ );
1088
+
1089
+ let headersObj: Record<string, string> | NativeHeaders = {};
1090
+ if (headersLen > 0 && headersPtr) {
1091
+ const rawSrc = toArrayBuffer(headersPtr, 0, headersLen);
1092
+ const raw = new Uint8Array(rawSrc.slice(0));
1093
+ headersObj = new NativeHeaders(raw);
1094
+ }
1095
+
1096
+ // Simplified proxy for performance mode
1097
+ const headersProxy = this.performanceMode
1098
+ ? (headersObj as Record<string, string>)
1099
+ : (new Proxy(headersObj instanceof NativeHeaders ? headersObj : {}, {
1100
+ get(target, prop) {
1101
+ if (target instanceof NativeHeaders && typeof prop === "string") {
1102
+ if (prop === "toJSON") return () => target.toJSON();
1103
+ const val = target.get(prop);
1104
+ if (val !== null) return val;
1105
+ }
1106
+ return Reflect.get(target, prop);
1107
+ },
1108
+ }) as unknown as Record<string, string>);
1109
+
1110
+ let buffer: ArrayBuffer | Buffer = new ArrayBuffer(0);
1111
+ if (bodyLen > 0 && bodyPtr) {
1112
+ buffer = toArrayBuffer(bodyPtr, 0, bodyLen).slice(0);
1113
+
1114
+ // Handle GZIP decompression
1115
+ const bufferView = new Uint8Array(buffer);
1116
+ if (
1117
+ bufferView.length >= 2 &&
1118
+ bufferView[0] === 0x1f &&
1119
+ bufferView[1] === 0x8b
1120
+ ) {
1121
+ try {
1122
+ const decompressed = Bun.gunzipSync(bufferView);
1123
+ buffer = decompressed.buffer.slice(
1124
+ decompressed.byteOffset,
1125
+ decompressed.byteOffset + decompressed.byteLength
1126
+ );
1127
+ } catch (e) {
1128
+ console.warn("[Jiren] gzip decompression failed:", e);
1129
+ }
1130
+ }
1131
+ }
1132
+
1133
+ let bodyUsed = false;
1134
+ const consumeBody = () => {
1135
+ bodyUsed = true;
1136
+ };
1137
+
1138
+ const bodyObj: JirenResponseBody<T> = {
1139
+ bodyUsed: false,
1140
+ arrayBuffer: async () => {
1141
+ consumeBody();
1142
+ if (Buffer.isBuffer(buffer)) {
1143
+ const buf = buffer as Buffer;
1144
+ return buf.buffer.slice(
1145
+ buf.byteOffset,
1146
+ buf.byteOffset + buf.byteLength
1147
+ ) as ArrayBuffer;
1148
+ }
1149
+ return buffer as ArrayBuffer;
1150
+ },
1151
+ blob: async () => {
1152
+ consumeBody();
1153
+ return new Blob([buffer]);
1154
+ },
1155
+ text: async () => {
1156
+ consumeBody();
1157
+ return this.decoder.decode(buffer);
1158
+ },
1159
+ json: async <R = T>(): Promise<R> => {
1160
+ consumeBody();
1161
+ return JSON.parse(this.decoder.decode(buffer));
1162
+ },
1163
+ };
1164
+
1165
+ Object.defineProperty(bodyObj, "bodyUsed", { get: () => bodyUsed });
1166
+
1167
+ return {
1168
+ url,
1169
+ status,
1170
+ statusText: STATUS_TEXT[status] || "",
1171
+ headers: headersProxy,
1172
+ ok: status >= 200 && status < 300,
1173
+ redirected: false,
1174
+ type: "basic",
1175
+ body: bodyObj,
1176
+ } as JirenResponse<T>;
1177
+ } finally {
1178
+ lib.symbols.zclient_response_full_free(respPtr);
1179
+ }
1180
+ }
1181
+
757
1182
  /**
758
1183
  * Helper to prepare body and headers for requests.
759
1184
  * Handles JSON stringification and Content-Type header.