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