jiren 1.4.5 → 1.5.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.
@@ -1,11 +1,12 @@
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,
@@ -16,6 +17,7 @@ import type {
16
17
  ErrorInterceptor,
17
18
  InterceptorRequestContext,
18
19
  InterceptorResponseContext,
20
+ MetricsAPI,
19
21
  } from "./types";
20
22
 
21
23
  const STATUS_TEXT: Record<number, string> = {
@@ -40,12 +42,12 @@ export type UrlConfig =
40
42
 
41
43
  /** Options for JirenClient constructor */
42
44
  export interface JirenClientOptions<
43
- T extends readonly WarmupUrlConfig[] | Record<string, UrlConfig> =
44
- | readonly WarmupUrlConfig[]
45
+ T extends readonly TargetUrlConfig[] | Record<string, UrlConfig> =
46
+ | readonly TargetUrlConfig[]
45
47
  | Record<string, UrlConfig>
46
48
  > {
47
- /** URLs to warmup on client creation (pre-connect + handshake) */
48
- warmup?: string[] | T;
49
+ /** Target URLs to pre-connect on client creation */
50
+ targets?: string[] | T;
49
51
 
50
52
  /** Enable benchmark mode (Force HTTP/2, disable probing) */
51
53
  benchmark?: boolean;
@@ -55,12 +57,15 @@ export interface JirenClientOptions<
55
57
 
56
58
  /** Request/response interceptors */
57
59
  interceptors?: Interceptors;
60
+
61
+ /** Performance mode: disable metrics, skip cache checks for non-cached endpoints (default: true) */
62
+ performanceMode?: boolean;
58
63
  }
59
64
 
60
- /** Helper to extract keys from Warmup Config */
61
- export type ExtractWarmupKeys<
62
- T extends readonly WarmupUrlConfig[] | Record<string, UrlConfig>
63
- > = 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[]
64
69
  ? T[number]["key"]
65
70
  : T extends Record<string, UrlConfig>
66
71
  ? keyof T
@@ -68,39 +73,39 @@ export type ExtractWarmupKeys<
68
73
 
69
74
  /** Type-safe URL accessor - maps keys to UrlEndpoint objects */
70
75
  export type UrlAccessor<
71
- T extends readonly WarmupUrlConfig[] | Record<string, UrlConfig>
76
+ T extends readonly TargetUrlConfig[] | Record<string, UrlConfig>
72
77
  > = {
73
- [K in ExtractWarmupKeys<T>]: UrlEndpoint;
78
+ [K in ExtractTargetKeys<T>]: UrlEndpoint;
74
79
  };
75
80
 
76
81
  /**
77
- * Helper function to define warmup URLs with type inference.
82
+ * Helper function to define target URLs with type inference.
78
83
  * This eliminates the need for 'as const'.
79
84
  *
80
85
  * @example
81
86
  * ```typescript
82
87
  * const client = new JirenClient({
83
- * warmup: defineUrls([
88
+ * targets: defineUrls([
84
89
  * { key: "google", url: "https://google.com" },
85
90
  * ])
86
91
  * });
87
92
  * // OR
88
93
  * const client = new JirenClient({
89
- * warmup: {
94
+ * targets: {
90
95
  * google: "https://google.com"
91
96
  * }
92
97
  * });
93
98
  * ```
94
99
  */
95
- export function defineUrls<const T extends readonly WarmupUrlConfig[]>(
100
+ export function defineUrls<const T extends readonly TargetUrlConfig[]>(
96
101
  urls: T
97
102
  ): T {
98
103
  return urls;
99
104
  }
100
105
 
101
106
  export class JirenClient<
102
- T extends readonly WarmupUrlConfig[] | Record<string, UrlConfig> =
103
- | readonly WarmupUrlConfig[]
107
+ T extends readonly TargetUrlConfig[] | Record<string, UrlConfig> =
108
+ | readonly TargetUrlConfig[]
104
109
  | Record<string, UrlConfig>
105
110
  > {
106
111
  private ptr: Pointer | null;
@@ -114,36 +119,105 @@ export class JirenClient<
114
119
  private requestInterceptors: RequestInterceptor[] = [];
115
120
  private responseInterceptors: ResponseInterceptor[] = [];
116
121
  private errorInterceptors: ErrorInterceptor[] = [];
117
- private warmupPromise: Promise<void> | null = null;
118
- private warmupComplete: Set<string> = new Set();
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();
119
160
 
120
161
  /** Type-safe URL accessor for warmed-up URLs */
121
162
  public readonly url: UrlAccessor<T>;
122
163
 
164
+ // Metrics collector
165
+ private metricsCollector: MetricsCollector;
166
+ /** Public metrics API */
167
+ public readonly metrics: MetricsAPI;
168
+
123
169
  constructor(options?: JirenClientOptions<T>) {
124
170
  this.ptr = lib.symbols.zclient_new();
125
171
  if (!this.ptr) throw new Error("Failed to create native client instance");
126
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
+
127
194
  // Initialize cache
128
195
  this.cache = new ResponseCache(100);
129
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
+
130
204
  // Enable benchmark mode if requested
131
205
  if (options?.benchmark) {
132
206
  lib.symbols.zclient_set_benchmark_mode(this.ptr, true);
133
207
  }
134
208
 
135
- // Process warmup URLs
136
- if (options?.warmup) {
209
+ // Process target URLs
210
+ if (options?.targets) {
137
211
  const urls: string[] = [];
138
- const warmup = options.warmup;
212
+ const targets = options.targets;
139
213
 
140
- if (Array.isArray(warmup)) {
141
- for (const item of warmup) {
214
+ if (Array.isArray(targets)) {
215
+ for (const item of targets) {
142
216
  if (typeof item === "string") {
143
217
  urls.push(item);
144
218
  } else {
145
- // WarmupUrlConfig with key and optional cache
146
- const config = item as WarmupUrlConfig;
219
+ // TargetUrlConfig with key and optional cache
220
+ const config = item as TargetUrlConfig;
147
221
  urls.push(config.url);
148
222
  this.urlMap.set(config.key, config.url);
149
223
 
@@ -164,7 +238,10 @@ export class JirenClient<
164
238
  }
165
239
  } else {
166
240
  // Record<string, UrlConfig>
167
- for (const [key, urlConfig] of Object.entries(warmup)) {
241
+ for (const [key, urlConfig] of Object.entries(targets) as [
242
+ string,
243
+ UrlConfig
244
+ ][]) {
168
245
  if (typeof urlConfig === "string") {
169
246
  // Simple string URL
170
247
  urls.push(urlConfig);
@@ -184,7 +261,7 @@ export class JirenClient<
184
261
  }
185
262
 
186
263
  // Store antibot config
187
- if ((urlConfig as { antibot?: boolean }).antibot) {
264
+ if (urlConfig.antibot) {
188
265
  this.antibotConfig.set(key, true);
189
266
  }
190
267
  }
@@ -192,9 +269,10 @@ export class JirenClient<
192
269
  }
193
270
 
194
271
  if (urls.length > 0) {
195
- // Lazy warmup in background (always - it's faster)
196
- this.warmupPromise = this.warmup(urls).then(() => {
197
- urls.forEach((url) => this.warmupComplete.add(url));
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
198
276
  });
199
277
  }
200
278
 
@@ -234,11 +312,18 @@ export class JirenClient<
234
312
  }
235
313
 
236
314
  /**
237
- * Wait for lazy warmup to complete.
238
- * Only needed if using lazyWarmup: true and want to ensure warmup is done.
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
239
324
  */
240
325
  public async waitForWarmup(): Promise<void> {
241
- if (this.warmupPromise) await this.warmupPromise;
326
+ return this.waitForTargets();
242
327
  }
243
328
 
244
329
  /**
@@ -269,22 +354,55 @@ export class JirenClient<
269
354
  get: async <R = any>(
270
355
  options?: UrlRequestOptions
271
356
  ): Promise<JirenResponse<R> | R | string | ArrayBuffer | Blob> => {
272
- // Wait for warmup to complete if not yet done
273
- if (self.warmupPromise && !self.warmupComplete.has(baseUrl)) {
274
- await self.warmupPromise;
357
+ // Wait for targets to complete if still pending
358
+ if (self.targetsPromise) {
359
+ await self.targetsPromise;
275
360
  }
276
361
 
277
362
  // Check if caching is enabled for this URL
278
363
  const cacheConfig = self.cacheConfig.get(prop);
279
364
 
280
- // Check if antibot is enabled for this URL (from warmup config or per-request)
365
+ // Check if antibot is enabled for this URL (from targets config or per-request)
281
366
  const useAntibot =
282
367
  options?.antibot ?? self.antibotConfig.get(prop) ?? false;
283
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
284
383
  if (cacheConfig?.enabled) {
285
- // Try to get from cache
286
384
  const cached = self.cache.get(baseUrl, options?.path, options);
287
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
+
288
406
  return cached as any;
289
407
  }
290
408
  }
@@ -297,7 +415,28 @@ export class JirenClient<
297
415
 
298
416
  // Check if there is already an identical request in flight
299
417
  if (self.inflightRequests.has(dedupKey)) {
300
- 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;
301
440
  }
302
441
 
303
442
  // Create the request promise
@@ -331,7 +470,50 @@ export class JirenClient<
331
470
  );
332
471
  }
333
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
+
334
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;
335
517
  } finally {
336
518
  // Remove from inflight map when done (success or failure)
337
519
  self.inflightRequests.delete(dedupKey);
@@ -489,14 +671,14 @@ export class JirenClient<
489
671
  }
490
672
 
491
673
  /**
492
- * Warm up connections to URLs (DNS resolve + QUIC handshake) in parallel.
674
+ * Pre-connect to URLs (DNS resolve + QUIC handshake) in parallel.
493
675
  * Call this early (e.g., at app startup) so subsequent requests are fast.
494
- * @param urls - List of URLs to warm up
676
+ * @param urls - List of URLs to pre-connect to
495
677
  */
496
- public async warmup(urls: string[]): Promise<void> {
678
+ public async preconnect(urls: string[]): Promise<void> {
497
679
  if (!this.ptr) throw new Error("Client is closed");
498
680
 
499
- // Warm up all URLs in parallel for faster startup
681
+ // Pre-connect to all URLs in parallel for faster startup
500
682
  await Promise.all(
501
683
  urls.map(
502
684
  (url) =>
@@ -510,10 +692,17 @@ export class JirenClient<
510
692
  }
511
693
 
512
694
  /**
513
- * @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
514
703
  */
515
704
  public prefetch(urls: string[]): void {
516
- this.warmup(urls);
705
+ this.preconnect(urls);
517
706
  }
518
707
 
519
708
  /**
@@ -589,21 +778,22 @@ export class JirenClient<
589
778
  }
590
779
  }
591
780
 
592
- // Build interceptor request context
781
+ // Run interceptors only if any are registered
593
782
  let ctx: InterceptorRequestContext = { method, url, headers, body };
594
-
595
- // Run request interceptors
596
- for (const interceptor of this.requestInterceptors) {
597
- ctx = await interceptor(ctx);
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;
598
792
  }
599
793
 
600
- // Apply interceptor modifications
601
- method = ctx.method;
602
- url = ctx.url;
603
- headers = ctx.headers;
604
- body = ctx.body ?? null;
605
-
606
- const methodBuffer = Buffer.from(method + "\0");
794
+ // Use pre-computed method buffer or create one for custom methods
795
+ const methodBuffer =
796
+ this.methodBuffers[method] || Buffer.from(method + "\0");
607
797
  const urlBuffer = Buffer.from(url + "\0");
608
798
 
609
799
  let bodyBuffer: Buffer | null = null;
@@ -611,63 +801,51 @@ export class JirenClient<
611
801
  bodyBuffer = Buffer.from(body + "\0");
612
802
  }
613
803
 
614
- let headersBuffer: Buffer | null = null;
615
- const defaultHeaders: Record<string, string> = {
616
- "user-agent":
617
- "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",
618
- accept:
619
- "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8",
620
- "accept-encoding": "gzip",
621
- "accept-language": "en-US,en;q=0.9",
622
- "sec-ch-ua":
623
- '"Not_A Brand";v="8", "Chromium";v="120", "Google Chrome";v="120"',
624
- "sec-ch-ua-mobile": "?0",
625
- "sec-ch-ua-platform": '"macOS"',
626
- "sec-fetch-dest": "document",
627
- "sec-fetch-mode": "navigate",
628
- "sec-fetch-site": "none",
629
- "sec-fetch-user": "?1",
630
- "upgrade-insecure-requests": "1",
631
- };
632
-
633
- const finalHeaders = { ...defaultHeaders, ...headers };
634
-
635
- // Enforce Chrome header order
636
- const orderedHeaders: Record<string, string> = {};
637
- const keys = [
638
- "sec-ch-ua",
639
- "sec-ch-ua-mobile",
640
- "sec-ch-ua-platform",
641
- "upgrade-insecure-requests",
642
- "user-agent",
643
- "accept",
644
- "sec-fetch-site",
645
- "sec-fetch-mode",
646
- "sec-fetch-user",
647
- "sec-fetch-dest",
648
- "accept-encoding",
649
- "accept-language",
650
- ];
651
-
652
- // Add priority headers in order
653
- for (const key of keys) {
654
- if (finalHeaders[key]) {
655
- orderedHeaders[key] = finalHeaders[key];
656
- 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
+ }
657
834
  }
658
- }
659
835
 
660
- // Add remaining custom headers
661
- for (const [key, value] of Object.entries(finalHeaders)) {
662
- orderedHeaders[key] = value;
663
- }
836
+ // Add remaining custom headers
837
+ for (const [key, value] of Object.entries(finalHeaders)) {
838
+ orderedHeaders[key] = value;
839
+ }
664
840
 
665
- const headerStr = Object.entries(orderedHeaders)
666
- .map(([k, v]) => `${k.toLowerCase()}: ${v}`)
667
- .join("\r\n");
841
+ const headerStr = Object.entries(orderedHeaders)
842
+ .map(([k, v]) => `${k.toLowerCase()}: ${v}`)
843
+ .join("\r\n");
668
844
 
669
- if (headerStr.length > 0) {
670
845
  headersBuffer = Buffer.from(headerStr + "\0");
846
+ } else {
847
+ // Fast path: use pre-computed headers buffer (no allocation!)
848
+ headersBuffer = this.defaultHeadersBuffer;
671
849
  }
672
850
 
673
851
  // Determine retry configuration
@@ -696,7 +874,8 @@ export class JirenClient<
696
874
  while (attempts < maxAttempts) {
697
875
  attempts++;
698
876
  try {
699
- 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(
700
879
  this.ptr,
701
880
  methodBuffer,
702
881
  urlBuffer,
@@ -710,17 +889,20 @@ export class JirenClient<
710
889
  throw new Error("Native request failed (returned null pointer)");
711
890
  }
712
891
 
713
- const response = this.parseResponse<T>(respPtr, url);
714
-
715
- // Run response interceptors
716
- let responseCtx: InterceptorResponseContext<T> = {
717
- request: ctx,
718
- response,
719
- };
720
- for (const interceptor of this.responseInterceptors) {
721
- responseCtx = await interceptor(responseCtx);
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;
722
905
  }
723
- const finalResponse = responseCtx.response;
724
906
 
725
907
  // Optional: Retry on specific status codes (e.g., 500, 502, 503, 504)
726
908
  // For now, we only retry on actual exceptions/network failures (null ptr)
@@ -738,9 +920,11 @@ export class JirenClient<
738
920
 
739
921
  return finalResponse;
740
922
  } catch (err) {
741
- // Run error interceptors
742
- for (const interceptor of this.errorInterceptors) {
743
- await interceptor(err as Error, ctx);
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
+ }
744
928
  }
745
929
  lastError = err;
746
930
  if (attempts < maxAttempts) {
@@ -853,11 +1037,11 @@ export class JirenClient<
853
1037
  },
854
1038
  text: async () => {
855
1039
  consumeBody();
856
- return new TextDecoder().decode(buffer);
1040
+ return this.decoder.decode(buffer);
857
1041
  },
858
1042
  json: async <R = T>(): Promise<R> => {
859
1043
  consumeBody();
860
- const text = new TextDecoder().decode(buffer);
1044
+ const text = this.decoder.decode(buffer);
861
1045
  return JSON.parse(text);
862
1046
  },
863
1047
  };
@@ -882,6 +1066,119 @@ export class JirenClient<
882
1066
  }
883
1067
  }
884
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
+
885
1182
  /**
886
1183
  * Helper to prepare body and headers for requests.
887
1184
  * Handles JSON stringification and Content-Type header.