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.
- package/README.md +220 -3
- package/components/cache.ts +398 -41
- package/components/client-node-native.ts +45 -12
- package/components/client.ts +524 -99
- package/components/index.ts +9 -0
- package/components/metrics.ts +420 -0
- package/components/native-cache.ts +181 -0
- package/components/native-node.ts +26 -0
- package/components/native.ts +92 -0
- package/components/types.ts +105 -5
- package/lib/libhttpclient.dylib +0 -0
- package/package.json +1 -1
- package/components/client-node.ts +0 -440
package/components/client.ts
CHANGED
|
@@ -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
|
-
|
|
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 =
|
|
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
|
|
36
|
-
| readonly
|
|
45
|
+
T extends readonly TargetUrlConfig[] | Record<string, UrlConfig> =
|
|
46
|
+
| readonly TargetUrlConfig[]
|
|
37
47
|
| Record<string, UrlConfig>
|
|
38
48
|
> {
|
|
39
|
-
/** URLs to
|
|
40
|
-
|
|
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
|
|
50
|
-
export type
|
|
51
|
-
T extends readonly
|
|
52
|
-
> = 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[]
|
|
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
|
|
76
|
+
T extends readonly TargetUrlConfig[] | Record<string, UrlConfig>
|
|
61
77
|
> = {
|
|
62
|
-
[K in
|
|
78
|
+
[K in ExtractTargetKeys<T>]: UrlEndpoint;
|
|
63
79
|
};
|
|
64
80
|
|
|
65
81
|
/**
|
|
66
|
-
* Helper function to define
|
|
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
|
-
*
|
|
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
|
-
*
|
|
94
|
+
* targets: {
|
|
79
95
|
* google: "https://google.com"
|
|
80
96
|
* }
|
|
81
97
|
* });
|
|
82
98
|
* ```
|
|
83
99
|
*/
|
|
84
|
-
export function defineUrls<const T extends readonly
|
|
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
|
|
92
|
-
| readonly
|
|
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
|
|
119
|
-
if (options?.
|
|
209
|
+
// Process target URLs
|
|
210
|
+
if (options?.targets) {
|
|
120
211
|
const urls: string[] = [];
|
|
121
|
-
const
|
|
212
|
+
const targets = options.targets;
|
|
122
213
|
|
|
123
|
-
if (Array.isArray(
|
|
124
|
-
for (const item of
|
|
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
|
-
//
|
|
129
|
-
const config = item as
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
*
|
|
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
|
|
676
|
+
* @param urls - List of URLs to pre-connect to
|
|
416
677
|
*/
|
|
417
|
-
public async
|
|
678
|
+
public async preconnect(urls: string[]): Promise<void> {
|
|
418
679
|
if (!this.ptr) throw new Error("Client is closed");
|
|
419
680
|
|
|
420
|
-
//
|
|
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
|
|
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.
|
|
705
|
+
this.preconnect(urls);
|
|
438
706
|
}
|
|
439
707
|
|
|
440
708
|
/**
|
|
@@ -510,7 +778,22 @@ export class JirenClient<
|
|
|
510
778
|
}
|
|
511
779
|
}
|
|
512
780
|
|
|
513
|
-
|
|
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
|
|
522
|
-
const
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
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
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
836
|
+
// Add remaining custom headers
|
|
837
|
+
for (const [key, value] of Object.entries(finalHeaders)) {
|
|
838
|
+
orderedHeaders[key] = value;
|
|
839
|
+
}
|
|
571
840
|
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
630
|
-
if (responseType === "text") return
|
|
914
|
+
if (responseType === "json") return finalResponse.body.json();
|
|
915
|
+
if (responseType === "text") return finalResponse.body.text();
|
|
631
916
|
if (responseType === "arraybuffer")
|
|
632
|
-
return
|
|
633
|
-
if (responseType === "blob") return
|
|
917
|
+
return finalResponse.body.arrayBuffer();
|
|
918
|
+
if (responseType === "blob") return finalResponse.body.blob();
|
|
634
919
|
}
|
|
635
920
|
|
|
636
|
-
return
|
|
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
|
|
1040
|
+
return this.decoder.decode(buffer);
|
|
729
1041
|
},
|
|
730
1042
|
json: async <R = T>(): Promise<R> => {
|
|
731
1043
|
consumeBody();
|
|
732
|
-
const text =
|
|
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.
|