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.
- package/README.md +284 -337
- package/components/client-node-native.ts +11 -11
- package/components/client.ts +423 -126
- package/components/index.ts +3 -0
- package/components/metrics.ts +420 -0
- package/components/native.ts +42 -0
- package/components/types.ts +65 -5
- package/lib/libhttpclient.dylib +0 -0
- package/package.json +1 -1
- package/components/client-node.ts +0 -473
package/components/client.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
44
|
-
| readonly
|
|
45
|
+
T extends readonly TargetUrlConfig[] | Record<string, UrlConfig> =
|
|
46
|
+
| readonly TargetUrlConfig[]
|
|
45
47
|
| Record<string, UrlConfig>
|
|
46
48
|
> {
|
|
47
|
-
/** URLs to
|
|
48
|
-
|
|
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
|
|
61
|
-
export type
|
|
62
|
-
T extends readonly
|
|
63
|
-
> = T extends readonly
|
|
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
|
|
76
|
+
T extends readonly TargetUrlConfig[] | Record<string, UrlConfig>
|
|
72
77
|
> = {
|
|
73
|
-
[K in
|
|
78
|
+
[K in ExtractTargetKeys<T>]: UrlEndpoint;
|
|
74
79
|
};
|
|
75
80
|
|
|
76
81
|
/**
|
|
77
|
-
* Helper function to define
|
|
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
|
-
*
|
|
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
|
-
*
|
|
94
|
+
* targets: {
|
|
90
95
|
* google: "https://google.com"
|
|
91
96
|
* }
|
|
92
97
|
* });
|
|
93
98
|
* ```
|
|
94
99
|
*/
|
|
95
|
-
export function defineUrls<const T extends readonly
|
|
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
|
|
103
|
-
| readonly
|
|
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
|
|
118
|
-
private
|
|
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
|
|
136
|
-
if (options?.
|
|
209
|
+
// Process target URLs
|
|
210
|
+
if (options?.targets) {
|
|
137
211
|
const urls: string[] = [];
|
|
138
|
-
const
|
|
212
|
+
const targets = options.targets;
|
|
139
213
|
|
|
140
|
-
if (Array.isArray(
|
|
141
|
-
for (const item of
|
|
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
|
-
//
|
|
146
|
-
const config = item as
|
|
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(
|
|
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 (
|
|
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
|
|
196
|
-
this.
|
|
197
|
-
urls.forEach((url) => this.
|
|
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
|
|
238
|
-
* Only needed if
|
|
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
|
-
|
|
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
|
|
273
|
-
if (self.
|
|
274
|
-
await self.
|
|
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
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
|
676
|
+
* @param urls - List of URLs to pre-connect to
|
|
495
677
|
*/
|
|
496
|
-
public async
|
|
678
|
+
public async preconnect(urls: string[]): Promise<void> {
|
|
497
679
|
if (!this.ptr) throw new Error("Client is closed");
|
|
498
680
|
|
|
499
|
-
//
|
|
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
|
|
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.
|
|
705
|
+
this.preconnect(urls);
|
|
517
706
|
}
|
|
518
707
|
|
|
519
708
|
/**
|
|
@@ -589,21 +778,22 @@ export class JirenClient<
|
|
|
589
778
|
}
|
|
590
779
|
}
|
|
591
780
|
|
|
592
|
-
//
|
|
781
|
+
// Run interceptors only if any are registered
|
|
593
782
|
let ctx: InterceptorRequestContext = { method, url, headers, body };
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
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
|
-
//
|
|
601
|
-
|
|
602
|
-
|
|
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
|
|
615
|
-
const
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
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
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
836
|
+
// Add remaining custom headers
|
|
837
|
+
for (const [key, value] of Object.entries(finalHeaders)) {
|
|
838
|
+
orderedHeaders[key] = value;
|
|
839
|
+
}
|
|
664
840
|
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
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
|
-
|
|
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.
|
|
714
|
-
|
|
715
|
-
// Run response interceptors
|
|
716
|
-
let
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
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
|
-
|
|
743
|
-
|
|
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
|
|
1040
|
+
return this.decoder.decode(buffer);
|
|
857
1041
|
},
|
|
858
1042
|
json: async <R = T>(): Promise<R> => {
|
|
859
1043
|
consumeBody();
|
|
860
|
-
const text =
|
|
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.
|