jiren 1.2.5 → 1.2.7

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.
@@ -0,0 +1,655 @@
1
+ import { nativeLib as lib } from "./native-node";
2
+ import { ResponseCache } from "./cache";
3
+ import koffi from "koffi";
4
+ import zlib from "zlib";
5
+ import type {
6
+ RequestOptions,
7
+ JirenResponse,
8
+ JirenResponseBody,
9
+ WarmupUrlConfig,
10
+ UrlRequestOptions,
11
+ UrlEndpoint,
12
+ CacheConfig,
13
+ } from "./types";
14
+
15
+ const STATUS_TEXT: Record<number, string> = {
16
+ 200: "OK",
17
+ 201: "Created",
18
+ 204: "No Content",
19
+ 301: "Moved Permanently",
20
+ 302: "Found",
21
+ 400: "Bad Request",
22
+ 401: "Unauthorized",
23
+ 403: "Forbidden",
24
+ 404: "Not Found",
25
+ 500: "Internal Server Error",
26
+ 502: "Bad Gateway",
27
+ 503: "Service Unavailable",
28
+ };
29
+
30
+ /** URL configuration with optional cache */
31
+ export type UrlConfig = string | { url: string; cache?: boolean | CacheConfig };
32
+
33
+ /** Options for JirenClient constructor */
34
+ export interface JirenClientOptions<
35
+ T extends readonly WarmupUrlConfig[] | Record<string, UrlConfig> =
36
+ | readonly WarmupUrlConfig[]
37
+ | Record<string, UrlConfig>
38
+ > {
39
+ /** URLs to warmup on client creation (pre-connect + handshake) */
40
+ warmup?: string[] | T;
41
+
42
+ /** Enable benchmark mode (Force HTTP/2, disable probing) */
43
+ benchmark?: boolean;
44
+ }
45
+
46
+ /** Helper to extract keys from Warmup Config */
47
+ export type ExtractWarmupKeys<
48
+ T extends readonly WarmupUrlConfig[] | Record<string, UrlConfig>
49
+ > = T extends readonly WarmupUrlConfig[]
50
+ ? T[number]["key"]
51
+ : T extends Record<string, UrlConfig>
52
+ ? keyof T
53
+ : never;
54
+
55
+ /** Type-safe URL accessor - maps keys to UrlEndpoint objects */
56
+ export type UrlAccessor<
57
+ T extends readonly WarmupUrlConfig[] | Record<string, UrlConfig>
58
+ > = {
59
+ [K in ExtractWarmupKeys<T>]: UrlEndpoint;
60
+ };
61
+
62
+ /**
63
+ * Helper function to define warmup URLs with type inference.
64
+ */
65
+ export function defineUrls<const T extends readonly WarmupUrlConfig[]>(
66
+ urls: T
67
+ ): T {
68
+ return urls;
69
+ }
70
+
71
+ export class JirenClient<
72
+ T extends readonly WarmupUrlConfig[] | Record<string, UrlConfig> =
73
+ | readonly WarmupUrlConfig[]
74
+ | Record<string, UrlConfig>
75
+ > {
76
+ private ptr: any = null; // Koffi pointer (Buffer or External)
77
+ private urlMap: Map<string, string> = new Map();
78
+ private cacheConfig: Map<string, { enabled: boolean; ttl: number }> =
79
+ new Map();
80
+ private cache: ResponseCache;
81
+
82
+ /** Type-safe URL accessor for warmed-up URLs */
83
+ public readonly url: UrlAccessor<T>;
84
+
85
+ constructor(options?: JirenClientOptions<T>) {
86
+ this.ptr = lib.symbols.zclient_new();
87
+ if (!this.ptr) throw new Error("Failed to create native client instance");
88
+
89
+ // Initialize cache
90
+ this.cache = new ResponseCache(100);
91
+
92
+ // Enable benchmark mode if requested
93
+ if (options?.benchmark) {
94
+ lib.symbols.zclient_set_benchmark_mode(this.ptr, true);
95
+ }
96
+
97
+ // Process warmup URLs
98
+ if (options?.warmup) {
99
+ const urls: string[] = [];
100
+ const warmup = options.warmup;
101
+
102
+ if (Array.isArray(warmup)) {
103
+ for (const item of warmup) {
104
+ if (typeof item === "string") {
105
+ urls.push(item);
106
+ } else {
107
+ // WarmupUrlConfig with key and optional cache
108
+ const config = item as WarmupUrlConfig;
109
+ urls.push(config.url);
110
+ this.urlMap.set(config.key, config.url);
111
+
112
+ // Store cache config
113
+ if (config.cache) {
114
+ const cacheConfig =
115
+ typeof config.cache === "boolean"
116
+ ? { enabled: true, ttl: 60000 }
117
+ : { enabled: true, ttl: config.cache.ttl || 60000 };
118
+ this.cacheConfig.set(config.key, cacheConfig);
119
+ }
120
+ }
121
+ }
122
+ } else {
123
+ // Record<string, UrlConfig>
124
+ for (const [key, urlConfig] of Object.entries(warmup)) {
125
+ if (typeof urlConfig === "string") {
126
+ // Simple string URL
127
+ urls.push(urlConfig);
128
+ this.urlMap.set(key, urlConfig);
129
+ } else {
130
+ // URL config object with cache
131
+ urls.push(urlConfig.url);
132
+ this.urlMap.set(key, urlConfig.url);
133
+
134
+ // Store cache config
135
+ if (urlConfig.cache) {
136
+ const cacheConfig =
137
+ typeof urlConfig.cache === "boolean"
138
+ ? { enabled: true, ttl: 60000 }
139
+ : { enabled: true, ttl: urlConfig.cache.ttl || 60000 };
140
+ this.cacheConfig.set(key, cacheConfig);
141
+ }
142
+ }
143
+ }
144
+ }
145
+
146
+ if (urls.length > 0) {
147
+ this.warmup(urls);
148
+ }
149
+ }
150
+
151
+ // Create proxy for type-safe URL access
152
+ this.url = this.createUrlAccessor();
153
+ }
154
+
155
+ /**
156
+ * Creates a proxy-based URL accessor for type-safe access.
157
+ */
158
+ private createUrlAccessor(): UrlAccessor<T> {
159
+ const self = this;
160
+
161
+ return new Proxy({} as UrlAccessor<T>, {
162
+ get(_target, prop: string) {
163
+ const baseUrl = self.urlMap.get(prop);
164
+ if (!baseUrl) {
165
+ throw new Error(
166
+ `URL key "${prop}" not found. Available keys: ${Array.from(
167
+ self.urlMap.keys()
168
+ ).join(", ")}`
169
+ );
170
+ }
171
+
172
+ const buildUrl = (path?: string) =>
173
+ path
174
+ ? `${baseUrl.replace(/\/$/, "")}/${path.replace(/^\//, "")}`
175
+ : baseUrl;
176
+
177
+ return {
178
+ get: async <R = any>(
179
+ options?: UrlRequestOptions
180
+ ): Promise<JirenResponse<R> | R | string | ArrayBuffer | Blob> => {
181
+ const cacheConfig = self.cacheConfig.get(prop);
182
+
183
+ if (cacheConfig?.enabled) {
184
+ const cached = self.cache.get(baseUrl, options?.path, options);
185
+ if (cached) {
186
+ return cached as any;
187
+ }
188
+ }
189
+
190
+ const response = await self.request<R>(
191
+ "GET",
192
+ buildUrl(options?.path),
193
+ null,
194
+ {
195
+ headers: options?.headers,
196
+ maxRedirects: options?.maxRedirects,
197
+ responseType: options?.responseType,
198
+ antibot: options?.antibot,
199
+ }
200
+ );
201
+
202
+ if (
203
+ cacheConfig?.enabled &&
204
+ typeof response === "object" &&
205
+ "status" in response
206
+ ) {
207
+ self.cache.set(
208
+ baseUrl,
209
+ response as JirenResponse,
210
+ cacheConfig.ttl,
211
+ options?.path,
212
+ options
213
+ );
214
+ }
215
+
216
+ return response;
217
+ },
218
+
219
+ post: async <R = any>(
220
+ body?: string | null,
221
+ options?: UrlRequestOptions
222
+ ): Promise<JirenResponse<R> | R | string | ArrayBuffer | Blob> => {
223
+ return self.request<R>(
224
+ "POST",
225
+ buildUrl(options?.path),
226
+ body || null,
227
+ {
228
+ headers: options?.headers,
229
+ maxRedirects: options?.maxRedirects,
230
+ responseType: options?.responseType,
231
+ }
232
+ );
233
+ },
234
+
235
+ put: async <R = any>(
236
+ body?: string | null,
237
+ options?: UrlRequestOptions
238
+ ): Promise<JirenResponse<R> | R | string | ArrayBuffer | Blob> => {
239
+ return self.request<R>(
240
+ "PUT",
241
+ buildUrl(options?.path),
242
+ body || null,
243
+ {
244
+ headers: options?.headers,
245
+ maxRedirects: options?.maxRedirects,
246
+ responseType: options?.responseType,
247
+ }
248
+ );
249
+ },
250
+
251
+ patch: async <R = any>(
252
+ body?: string | null,
253
+ options?: UrlRequestOptions
254
+ ): Promise<JirenResponse<R> | R | string | ArrayBuffer | Blob> => {
255
+ return self.request<R>(
256
+ "PATCH",
257
+ buildUrl(options?.path),
258
+ body || null,
259
+ {
260
+ headers: options?.headers,
261
+ maxRedirects: options?.maxRedirects,
262
+ responseType: options?.responseType,
263
+ }
264
+ );
265
+ },
266
+
267
+ delete: async <R = any>(
268
+ body?: string | null,
269
+ options?: UrlRequestOptions
270
+ ): Promise<JirenResponse<R> | R | string | ArrayBuffer | Blob> => {
271
+ return self.request<R>(
272
+ "DELETE",
273
+ buildUrl(options?.path),
274
+ body || null,
275
+ {
276
+ headers: options?.headers,
277
+ maxRedirects: options?.maxRedirects,
278
+ responseType: options?.responseType,
279
+ }
280
+ );
281
+ },
282
+
283
+ head: async (
284
+ options?: UrlRequestOptions
285
+ ): Promise<JirenResponse<any>> => {
286
+ return self.request("HEAD", buildUrl(options?.path), null, {
287
+ headers: options?.headers,
288
+ maxRedirects: options?.maxRedirects,
289
+ antibot: options?.antibot,
290
+ });
291
+ },
292
+
293
+ options: async (
294
+ options?: UrlRequestOptions
295
+ ): Promise<JirenResponse<any>> => {
296
+ return self.request("OPTIONS", buildUrl(options?.path), null, {
297
+ headers: options?.headers,
298
+ maxRedirects: options?.maxRedirects,
299
+ antibot: options?.antibot,
300
+ });
301
+ },
302
+
303
+ prefetch: async (options?: UrlRequestOptions): Promise<void> => {
304
+ self.cache.clear(baseUrl);
305
+ const cacheConfig = self.cacheConfig.get(prop);
306
+ if (cacheConfig?.enabled) {
307
+ await self.request("GET", buildUrl(options?.path), null, {
308
+ headers: options?.headers,
309
+ maxRedirects: options?.maxRedirects,
310
+ antibot: options?.antibot,
311
+ });
312
+ }
313
+ },
314
+ } as UrlEndpoint;
315
+ },
316
+ });
317
+ }
318
+
319
+ public close(): void {
320
+ if (this.ptr) {
321
+ lib.symbols.zclient_free(this.ptr);
322
+ this.ptr = null;
323
+ }
324
+ }
325
+
326
+ public async warmup(urls: string[]): Promise<void> {
327
+ if (!this.ptr) throw new Error("Client is closed");
328
+
329
+ await Promise.all(
330
+ urls.map(
331
+ (url) =>
332
+ new Promise<void>((resolve) => {
333
+ const urlBuffer = Buffer.from(url + "\0");
334
+ lib.symbols.zclient_prefetch(this.ptr, url); // Koffi handles string auto-conversion if type is 'const char*' but Buffer is safer for null termination?
335
+ // Koffi: "const char *" expects a string or Buffer. String is null-terminated by Koffi.
336
+ // Using generic string.
337
+ resolve();
338
+ })
339
+ )
340
+ );
341
+ }
342
+
343
+ /**
344
+ * @deprecated Use warmup() instead
345
+ */
346
+ public prefetch(urls: string[]): void {
347
+ this.warmup(urls);
348
+ }
349
+
350
+ public async request<T = any>(
351
+ method: string,
352
+ url: string,
353
+ body?: string | null,
354
+ options?: RequestOptions & { responseType: "json" }
355
+ ): Promise<T>;
356
+ public async request<T = any>(
357
+ method: string,
358
+ url: string,
359
+ body?: string | null,
360
+ options?: RequestOptions & { responseType: "text" }
361
+ ): Promise<string>;
362
+ public async request<T = any>(
363
+ method: string,
364
+ url: string,
365
+ body?: string | null,
366
+ options?: RequestOptions & { responseType: "arraybuffer" }
367
+ ): Promise<ArrayBuffer>;
368
+ public async request<T = any>(
369
+ method: string,
370
+ url: string,
371
+ body?: string | null,
372
+ options?: RequestOptions & { responseType: "blob" }
373
+ ): Promise<Blob>;
374
+ public async request<T = any>(
375
+ method: string,
376
+ url: string,
377
+ body?: string | null,
378
+ options?: RequestOptions
379
+ ): Promise<JirenResponse<T>>;
380
+ public async request<T = any>(
381
+ method: string,
382
+ url: string,
383
+ body?: string | null,
384
+ options?: RequestOptions | Record<string, string> | null
385
+ ): Promise<JirenResponse<T> | T | string | ArrayBuffer | Blob> {
386
+ if (!this.ptr) throw new Error("Client is closed");
387
+
388
+ // Normalize options
389
+ let headers: Record<string, string> = {};
390
+ let maxRedirects = 5;
391
+ let responseType: RequestOptions["responseType"] | undefined;
392
+ let antibot = false;
393
+
394
+ if (options) {
395
+ if (
396
+ "maxRedirects" in options ||
397
+ "headers" in options ||
398
+ "responseType" in options ||
399
+ "method" in options ||
400
+ "timeout" in options ||
401
+ "antibot" in options
402
+ ) {
403
+ const opts = options as RequestOptions;
404
+ if (opts.headers) headers = opts.headers;
405
+ if (opts.maxRedirects !== undefined) maxRedirects = opts.maxRedirects;
406
+ if (opts.responseType) responseType = opts.responseType;
407
+ if (opts.antibot !== undefined) antibot = opts.antibot;
408
+ } else {
409
+ headers = options as Record<string, string>;
410
+ }
411
+ }
412
+
413
+ const defaultHeaders: Record<string, string> = {
414
+ "user-agent":
415
+ "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",
416
+ accept:
417
+ "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8",
418
+ "accept-encoding": "gzip",
419
+ "accept-language": "en-US,en;q=0.9",
420
+ "sec-ch-ua":
421
+ '"Not_A Brand";v="8", "Chromium";v="120", "Google Chrome";v="120"',
422
+ "sec-ch-ua-mobile": "?0",
423
+ "sec-ch-ua-platform": '"macOS"',
424
+ "sec-fetch-dest": "document",
425
+ "sec-fetch-mode": "navigate",
426
+ "sec-fetch-site": "none",
427
+ "sec-fetch-user": "?1",
428
+ "upgrade-insecure-requests": "1",
429
+ };
430
+
431
+ const finalHeaders = { ...defaultHeaders, ...headers };
432
+
433
+ // Enforce Chrome header order
434
+ const orderedHeaders: Record<string, string> = {};
435
+ const keys = [
436
+ "sec-ch-ua",
437
+ "sec-ch-ua-mobile",
438
+ "sec-ch-ua-platform",
439
+ "upgrade-insecure-requests",
440
+ "user-agent",
441
+ "accept",
442
+ "sec-fetch-site",
443
+ "sec-fetch-mode",
444
+ "sec-fetch-user",
445
+ "sec-fetch-dest",
446
+ "accept-encoding",
447
+ "accept-language",
448
+ ];
449
+
450
+ for (const key of keys) {
451
+ if (finalHeaders[key]) {
452
+ orderedHeaders[key] = finalHeaders[key];
453
+ delete finalHeaders[key];
454
+ }
455
+ }
456
+ for (const [key, value] of Object.entries(finalHeaders)) {
457
+ orderedHeaders[key] = value;
458
+ }
459
+
460
+ const headerStr = Object.entries(orderedHeaders)
461
+ .map(([k, v]) => `${k.toLowerCase()}: ${v}`)
462
+ .join("\r\n");
463
+
464
+ // Koffi auto-converts strings to const char *.
465
+ // Pass strings directly.
466
+ // For body and headers, empty string is fine if null.
467
+ // But native check might expect null pointer?
468
+ // zclient_request expects const char*. If passed null/undefined, Koffi might pass NULL?
469
+ // Let's pass null for explicit nulls.
470
+
471
+ const respPtr = lib.symbols.zclient_request(
472
+ this.ptr,
473
+ method,
474
+ url,
475
+ headerStr.length > 0 ? headerStr : null,
476
+ body || null,
477
+ maxRedirects,
478
+ antibot
479
+ );
480
+
481
+ const response = this.parseResponse<T>(respPtr, url);
482
+
483
+ if (responseType) {
484
+ if (responseType === "json") return response.body.json();
485
+ if (responseType === "text") return response.body.text();
486
+ if (responseType === "arraybuffer") return response.body.arrayBuffer();
487
+ if (responseType === "blob") return response.body.blob();
488
+ }
489
+
490
+ return response;
491
+ }
492
+
493
+ private parseResponse<T = any>(respPtr: any, url: string): JirenResponse<T> {
494
+ if (!respPtr)
495
+ throw new Error("Native request failed (returned null pointer)");
496
+
497
+ try {
498
+ const status = lib.symbols.zclient_response_status(respPtr);
499
+ const len = Number(lib.symbols.zclient_response_body_len(respPtr));
500
+ const bodyPtr = lib.symbols.zclient_response_body(respPtr);
501
+
502
+ const headersLen = Number(
503
+ lib.symbols.zclient_response_headers_len(respPtr)
504
+ );
505
+ let headersObj: Record<string, string> | NativeHeaders = {};
506
+
507
+ if (headersLen > 0) {
508
+ const rawHeadersPtr = lib.symbols.zclient_response_headers(respPtr);
509
+ if (rawHeadersPtr) {
510
+ // Copy headers to JS memory
511
+ // Koffi decode to buffer: koffi.decode(ptr, "uint8_t", len)
512
+ const raw = Buffer.from(
513
+ koffi.decode(rawHeadersPtr, "uint8_t", headersLen)
514
+ );
515
+ headersObj = new NativeHeaders(raw);
516
+ }
517
+ }
518
+
519
+ const headersProxy = new Proxy(
520
+ headersObj instanceof NativeHeaders ? headersObj : {},
521
+ {
522
+ get(target, prop) {
523
+ if (target instanceof NativeHeaders && typeof prop === "string") {
524
+ if (prop === "toJSON") return () => target.toJSON();
525
+ const val = target.get(prop);
526
+ if (val !== null) return val;
527
+ }
528
+ return Reflect.get(target, prop);
529
+ },
530
+ }
531
+ ) as unknown as Record<string, string>;
532
+
533
+ let buffer: Buffer = Buffer.alloc(0);
534
+ if (len > 0 && bodyPtr) {
535
+ // Copy body content
536
+ buffer = Buffer.from(koffi.decode(bodyPtr, "uint8_t", len));
537
+
538
+ // Handle GZIP compression
539
+ const contentEncoding = headersProxy["content-encoding"]?.toLowerCase();
540
+
541
+ // DEBUG LOGS
542
+ if (len > 0) {
543
+ console.log(`[Jiren] Body len: ${len}, Encoding: ${contentEncoding}`);
544
+ if (buffer.length > 2) {
545
+ console.log(
546
+ `[Jiren] Bytes: 0x${buffer[0]?.toString(
547
+ 16
548
+ )} 0x${buffer[1]?.toString(16)} 0x${buffer[2]?.toString(16)}`
549
+ );
550
+ }
551
+ }
552
+
553
+ if (
554
+ contentEncoding === "gzip" ||
555
+ (buffer.length > 2 && buffer[0] === 0x1f && buffer[1] === 0x8b)
556
+ ) {
557
+ try {
558
+ console.log("[Jiren] Attempting gunzip...");
559
+ buffer = zlib.gunzipSync(buffer);
560
+ console.log("[Jiren] Gunzip success!");
561
+ } catch (e) {
562
+ console.warn("Failed to gunzip response body:", e);
563
+ }
564
+ }
565
+ }
566
+
567
+ let bodyUsed = false;
568
+ const consumeBody = () => {
569
+ bodyUsed = true;
570
+ };
571
+
572
+ const bodyObj: JirenResponseBody<T> = {
573
+ bodyUsed: false,
574
+ arrayBuffer: async () => {
575
+ consumeBody();
576
+ return buffer.buffer.slice(
577
+ buffer.byteOffset,
578
+ buffer.byteOffset + buffer.byteLength
579
+ ) as ArrayBuffer;
580
+ },
581
+ blob: async () => {
582
+ consumeBody();
583
+ return new Blob([buffer as any]);
584
+ },
585
+ text: async () => {
586
+ consumeBody();
587
+ return buffer.toString("utf-8");
588
+ },
589
+ json: async <R = T>(): Promise<R> => {
590
+ consumeBody();
591
+ return JSON.parse(buffer.toString("utf-8"));
592
+ },
593
+ };
594
+
595
+ Object.defineProperty(bodyObj, "bodyUsed", {
596
+ get: () => bodyUsed,
597
+ });
598
+
599
+ return {
600
+ url,
601
+ status,
602
+ statusText: STATUS_TEXT[status] || "",
603
+ headers: headersProxy,
604
+ ok: status >= 200 && status < 300,
605
+ redirected: false,
606
+ type: "basic",
607
+ body: bodyObj,
608
+ } as JirenResponse<T>;
609
+ } finally {
610
+ lib.symbols.zclient_response_free(respPtr);
611
+ }
612
+ }
613
+ }
614
+
615
+ class NativeHeaders {
616
+ private raw: Buffer;
617
+ private len: number;
618
+ private cache = new Map<string, string>();
619
+ private parsed = false;
620
+
621
+ constructor(raw: Buffer) {
622
+ this.raw = raw;
623
+ this.len = raw.length;
624
+ }
625
+
626
+ private ensureParsed() {
627
+ if (this.parsed) return;
628
+ try {
629
+ const text = this.raw.toString("utf-8");
630
+ const lines = text.split("\r\n");
631
+ for (const line of lines) {
632
+ if (!line) continue;
633
+ const colon = line.indexOf(":");
634
+ if (colon === -1) continue;
635
+ const key = line.substring(0, colon).trim().toLowerCase();
636
+ const val = line.substring(colon + 1).trim();
637
+ this.cache.set(key, val);
638
+ }
639
+ } catch (e) {
640
+ // Ignore parsing errors
641
+ }
642
+ this.parsed = true;
643
+ }
644
+
645
+ get(name: string): string | null {
646
+ const target = name.toLowerCase();
647
+ this.ensureParsed();
648
+ return this.cache.get(target) || null;
649
+ }
650
+
651
+ toJSON(): Record<string, string> {
652
+ this.ensureParsed();
653
+ return Object.fromEntries(this.cache);
654
+ }
655
+ }
@@ -9,6 +9,7 @@ import type {
9
9
  UrlRequestOptions,
10
10
  UrlEndpoint,
11
11
  CacheConfig,
12
+ RetryConfig,
12
13
  } from "./types";
13
14
 
14
15
  const STATUS_TEXT: Record<number, string> = {
@@ -40,6 +41,9 @@ export interface JirenClientOptions<
40
41
 
41
42
  /** Enable benchmark mode (Force HTTP/2, disable probing) */
42
43
  benchmark?: boolean;
44
+
45
+ /** Global retry configuration */
46
+ retry?: number | RetryConfig;
43
47
  }
44
48
 
45
49
  /** Helper to extract keys from Warmup Config */
@@ -93,6 +97,8 @@ export class JirenClient<
93
97
  private cacheConfig: Map<string, { enabled: boolean; ttl: number }> =
94
98
  new Map();
95
99
  private cache: ResponseCache;
100
+ private inflightRequests: Map<string, Promise<any>> = new Map();
101
+ private globalRetry?: RetryConfig;
96
102
 
97
103
  /** Type-safe URL accessor for warmed-up URLs */
98
104
  public readonly url: UrlAccessor<T>;
@@ -165,6 +171,18 @@ export class JirenClient<
165
171
 
166
172
  // Create proxy for type-safe URL access
167
173
  this.url = this.createUrlAccessor();
174
+
175
+ // Store global retry config
176
+ if (options?.retry) {
177
+ this.globalRetry =
178
+ typeof options.retry === "number"
179
+ ? { count: options.retry, delay: 100, backoff: 2 }
180
+ : options.retry;
181
+ }
182
+ }
183
+
184
+ private async waitFor(ms: number) {
185
+ return new Promise((resolve) => setTimeout(resolve, ms));
168
186
  }
169
187
 
170
188
  /**
@@ -206,47 +224,74 @@ export class JirenClient<
206
224
  }
207
225
  }
208
226
 
209
- // Make the request
210
- const response = await self.request<R>(
211
- "GET",
212
- buildUrl(options?.path),
213
- null,
214
- {
215
- headers: options?.headers,
216
- maxRedirects: options?.maxRedirects,
217
- responseType: options?.responseType,
218
- antibot: options?.antibot,
219
- }
220
- );
227
+ // ** Deduplication Logic **
228
+ // Create a unique key for this request based on URL and critical options
229
+ const dedupKey = `GET:${buildUrl(options?.path)}:${JSON.stringify(
230
+ options?.headers || {}
231
+ )}`;
221
232
 
222
- // Store in cache if enabled
223
- if (
224
- cacheConfig?.enabled &&
225
- typeof response === "object" &&
226
- "status" in response
227
- ) {
228
- self.cache.set(
229
- baseUrl,
230
- response as JirenResponse,
231
- cacheConfig.ttl,
232
- options?.path,
233
- options
234
- );
233
+ // Check if there is already an identical request in flight
234
+ if (self.inflightRequests.has(dedupKey)) {
235
+ return self.inflightRequests.get(dedupKey);
235
236
  }
236
237
 
237
- return response;
238
+ // Create the request promise
239
+ const requestPromise = (async () => {
240
+ try {
241
+ // Make the request
242
+ const response = await self.request<R>(
243
+ "GET",
244
+ buildUrl(options?.path),
245
+ null,
246
+ {
247
+ headers: options?.headers,
248
+ maxRedirects: options?.maxRedirects,
249
+ responseType: options?.responseType,
250
+ antibot: options?.antibot,
251
+ }
252
+ );
253
+
254
+ // Store in cache if enabled
255
+ if (
256
+ cacheConfig?.enabled &&
257
+ typeof response === "object" &&
258
+ "status" in response
259
+ ) {
260
+ self.cache.set(
261
+ baseUrl,
262
+ response as JirenResponse,
263
+ cacheConfig.ttl,
264
+ options?.path,
265
+ options
266
+ );
267
+ }
268
+
269
+ return response;
270
+ } finally {
271
+ // Remove from inflight map when done (success or failure)
272
+ self.inflightRequests.delete(dedupKey);
273
+ }
274
+ })();
275
+
276
+ // Store the promise in the map
277
+ self.inflightRequests.set(dedupKey, requestPromise);
278
+
279
+ return requestPromise;
238
280
  },
239
281
 
240
282
  post: async <R = any>(
241
- body?: string | null,
242
283
  options?: UrlRequestOptions
243
284
  ): Promise<JirenResponse<R> | R | string | ArrayBuffer | Blob> => {
285
+ const { headers, serializedBody } = self.prepareBody(
286
+ options?.body,
287
+ options?.headers
288
+ );
244
289
  return self.request<R>(
245
290
  "POST",
246
291
  buildUrl(options?.path),
247
- body || null,
292
+ serializedBody,
248
293
  {
249
- headers: options?.headers,
294
+ headers,
250
295
  maxRedirects: options?.maxRedirects,
251
296
  responseType: options?.responseType,
252
297
  }
@@ -254,15 +299,18 @@ export class JirenClient<
254
299
  },
255
300
 
256
301
  put: async <R = any>(
257
- body?: string | null,
258
302
  options?: UrlRequestOptions
259
303
  ): Promise<JirenResponse<R> | R | string | ArrayBuffer | Blob> => {
304
+ const { headers, serializedBody } = self.prepareBody(
305
+ options?.body,
306
+ options?.headers
307
+ );
260
308
  return self.request<R>(
261
309
  "PUT",
262
310
  buildUrl(options?.path),
263
- body || null,
311
+ serializedBody,
264
312
  {
265
- headers: options?.headers,
313
+ headers,
266
314
  maxRedirects: options?.maxRedirects,
267
315
  responseType: options?.responseType,
268
316
  }
@@ -270,15 +318,18 @@ export class JirenClient<
270
318
  },
271
319
 
272
320
  patch: async <R = any>(
273
- body?: string | null,
274
321
  options?: UrlRequestOptions
275
322
  ): Promise<JirenResponse<R> | R | string | ArrayBuffer | Blob> => {
323
+ const { headers, serializedBody } = self.prepareBody(
324
+ options?.body,
325
+ options?.headers
326
+ );
276
327
  return self.request<R>(
277
328
  "PATCH",
278
329
  buildUrl(options?.path),
279
- body || null,
330
+ serializedBody,
280
331
  {
281
- headers: options?.headers,
332
+ headers,
282
333
  maxRedirects: options?.maxRedirects,
283
334
  responseType: options?.responseType,
284
335
  }
@@ -286,15 +337,18 @@ export class JirenClient<
286
337
  },
287
338
 
288
339
  delete: async <R = any>(
289
- body?: string | null,
290
340
  options?: UrlRequestOptions
291
341
  ): Promise<JirenResponse<R> | R | string | ArrayBuffer | Blob> => {
342
+ const { headers, serializedBody } = self.prepareBody(
343
+ options?.body,
344
+ options?.headers
345
+ );
292
346
  return self.request<R>(
293
347
  "DELETE",
294
348
  buildUrl(options?.path),
295
- body || null,
349
+ serializedBody,
296
350
  {
297
- headers: options?.headers,
351
+ headers,
298
352
  maxRedirects: options?.maxRedirects,
299
353
  responseType: options?.responseType,
300
354
  }
@@ -523,27 +577,74 @@ export class JirenClient<
523
577
  headersBuffer = Buffer.from(headerStr + "\0");
524
578
  }
525
579
 
526
- const respPtr = lib.symbols.zclient_request(
527
- this.ptr,
528
- methodBuffer,
529
- urlBuffer,
530
- headersBuffer,
531
- bodyBuffer,
532
- maxRedirects,
533
- antibot
534
- );
580
+ // Determine retry configuration
581
+ let retryConfig = this.globalRetry;
582
+ // Check if options is RequestOptions (has 'retry' property and is not just a header map)
583
+ // We already normalized this earlier, but let's be safe.
584
+ // If it has 'responseType', 'method', etc., it's RequestOptions.
585
+ // Simpler: Just check if 'retry' is number or object.
586
+ if (options && typeof options === "object" && "retry" in options) {
587
+ const userRetry = (options as any).retry;
588
+ if (typeof userRetry === "number") {
589
+ retryConfig = { count: userRetry, delay: 100, backoff: 2 };
590
+ } else if (typeof userRetry === "object") {
591
+ retryConfig = userRetry;
592
+ }
593
+ }
535
594
 
536
- const response = this.parseResponse<T>(respPtr, url);
595
+ let attempts = 0;
596
+ // Default to 1 attempt (0 retries) if no config
597
+ const maxAttempts = (retryConfig?.count || 0) + 1;
598
+ let currentDelay = retryConfig?.delay || 100;
599
+ const backoff = retryConfig?.backoff || 2;
600
+
601
+ let lastError: any;
602
+
603
+ while (attempts < maxAttempts) {
604
+ attempts++;
605
+ try {
606
+ const respPtr = lib.symbols.zclient_request(
607
+ this.ptr,
608
+ methodBuffer,
609
+ urlBuffer,
610
+ headersBuffer,
611
+ bodyBuffer,
612
+ maxRedirects,
613
+ antibot
614
+ );
615
+
616
+ if (!respPtr) {
617
+ throw new Error("Native request failed (returned null pointer)");
618
+ }
537
619
 
538
- // Auto-parse if requested
539
- if (responseType) {
540
- if (responseType === "json") return response.body.json();
541
- if (responseType === "text") return response.body.text();
542
- if (responseType === "arraybuffer") return response.body.arrayBuffer();
543
- if (responseType === "blob") return response.body.blob();
620
+ const response = this.parseResponse<T>(respPtr, url);
621
+
622
+ // Optional: Retry on specific status codes (e.g., 500, 502, 503, 504)
623
+ // For now, we only retry on actual exceptions/network failures (null ptr)
624
+ // or if we decide to throw on 5xx here.
625
+ // Let's stick to "Network Failure" retries for now as per plan.
626
+
627
+ // Auto-parse if requested
628
+ if (responseType) {
629
+ if (responseType === "json") return response.body.json();
630
+ if (responseType === "text") return response.body.text();
631
+ if (responseType === "arraybuffer")
632
+ return response.body.arrayBuffer();
633
+ if (responseType === "blob") return response.body.blob();
634
+ }
635
+
636
+ return response;
637
+ } catch (err) {
638
+ lastError = err;
639
+ if (attempts < maxAttempts) {
640
+ // Wait before retrying
641
+ await this.waitFor(currentDelay);
642
+ currentDelay *= backoff;
643
+ }
644
+ }
544
645
  }
545
646
 
546
- return response;
647
+ throw lastError || new Error("Request failed after retries");
547
648
  }
548
649
 
549
650
  private parseResponse<T = any>(
@@ -652,6 +753,35 @@ export class JirenClient<
652
753
  lib.symbols.zclient_response_free(respPtr);
653
754
  }
654
755
  }
756
+
757
+ /**
758
+ * Helper to prepare body and headers for requests.
759
+ * Handles JSON stringification and Content-Type header.
760
+ */
761
+ private prepareBody(
762
+ body: string | object | null | undefined,
763
+ userHeaders?: Record<string, string>
764
+ ): { headers: Record<string, string>; serializedBody: string | null } {
765
+ let serializedBody: string | null = null;
766
+ const headers = { ...userHeaders };
767
+
768
+ if (body !== null && body !== undefined) {
769
+ if (typeof body === "object") {
770
+ serializedBody = JSON.stringify(body);
771
+ // Add Content-Type if not present (case-insensitive check)
772
+ const hasContentType = Object.keys(headers).some(
773
+ (k) => k.toLowerCase() === "content-type"
774
+ );
775
+ if (!hasContentType) {
776
+ headers["Content-Type"] = "application/json";
777
+ }
778
+ } else {
779
+ serializedBody = String(body);
780
+ }
781
+ }
782
+
783
+ return { headers, serializedBody };
784
+ }
655
785
  }
656
786
 
657
787
  class NativeHeaders {
@@ -18,6 +18,8 @@ export type {
18
18
  WarmupUrlConfig,
19
19
  UrlRequestOptions,
20
20
  UrlEndpoint,
21
+ JirenResponse,
22
+ JirenResponseBody,
21
23
  } from "./types";
22
24
 
23
25
  // Remove broken exports
@@ -0,0 +1,93 @@
1
+ import koffi from "koffi";
2
+ import path from "path";
3
+ import { fileURLToPath } from "url";
4
+
5
+ const __filename = fileURLToPath(import.meta.url);
6
+ const __dirname = path.dirname(__filename);
7
+
8
+ // Determine library extension based on platform
9
+ const platform = process.platform;
10
+ let suffix = "so";
11
+ if (platform === "darwin") {
12
+ suffix = "dylib";
13
+ } else if (platform === "win32") {
14
+ suffix = "dll";
15
+ }
16
+
17
+ // Resolve library path relative to this module
18
+ // native.ts uses join(import.meta.dir, `../lib/libhttpclient.${suffix}`)
19
+ const libPath = path.join(__dirname, `../lib/libhttpclient.${suffix}`);
20
+
21
+ // Load the library
22
+ // koffi.load returns an object where we can register functions
23
+ const lib = koffi.load(libPath);
24
+
25
+ // Define types if needed
26
+ // koffi handles void* as 'void*' or 'ptr'
27
+
28
+ export const symbols = {
29
+ zclient_new: lib.func("zclient_new", "void *", []),
30
+ zclient_free: lib.func("zclient_free", "void", ["void *"]),
31
+ zclient_get: lib.func("zclient_get", "void *", ["void *", "const char *"]),
32
+ zclient_post: lib.func("zclient_post", "void *", [
33
+ "void *",
34
+ "const char *",
35
+ "const char *",
36
+ ]),
37
+ zclient_request: lib.func("zclient_request", "void *", [
38
+ "void *", // ptr
39
+ "const char *", // method
40
+ "const char *", // url
41
+ "const char *", // headers
42
+ "const char *", // body
43
+ "uint8_t", // max_redirects
44
+ "bool", // antibot
45
+ ]),
46
+ zclient_prefetch: lib.func("zclient_prefetch", "void", [
47
+ "void *",
48
+ "const char *",
49
+ ]),
50
+ zclient_response_status: lib.func("zclient_response_status", "uint16_t", [
51
+ "void *",
52
+ ]),
53
+ zclient_response_body: lib.func("zclient_response_body", "void *", [
54
+ "void *",
55
+ ]),
56
+ zclient_response_body_len: lib.func("zclient_response_body_len", "uint64_t", [
57
+ "void *",
58
+ ]),
59
+ zclient_response_headers: lib.func("zclient_response_headers", "void *", [
60
+ "void *",
61
+ ]),
62
+ zclient_response_headers_len: lib.func(
63
+ "zclient_response_headers_len",
64
+ "uint64_t",
65
+ ["void *"]
66
+ ),
67
+ zclient_response_parse_header_offsets: lib.func(
68
+ "zclient_response_parse_header_offsets",
69
+ "void *",
70
+ ["void *"]
71
+ ),
72
+ zclient_header_offsets_free: lib.func("zclient_header_offsets_free", "void", [
73
+ "void *",
74
+ ]),
75
+ z_find_header_value: lib.func("z_find_header_value", "void *", [
76
+ "void *", // raw headers ptr
77
+ "uint64_t", // len
78
+ "const char *", // key
79
+ ]),
80
+ zclient_header_value_free: lib.func("zclient_header_value_free", "void", [
81
+ "void *",
82
+ ]),
83
+ zclient_response_free: lib.func("zclient_response_free", "void", ["void *"]),
84
+ zclient_set_benchmark_mode: lib.func("zclient_set_benchmark_mode", "void", [
85
+ "void *",
86
+ "bool",
87
+ ]),
88
+ };
89
+
90
+ // Export a wrapper that matches structure of bun:ffi lib
91
+ export const nativeLib = {
92
+ symbols,
93
+ };
@@ -108,18 +108,32 @@ export interface CacheConfig {
108
108
  maxSize?: number;
109
109
  }
110
110
 
111
+ /** Configuration for automatic request retries */
112
+ export interface RetryConfig {
113
+ /** Number of retry attempts (default: 0) */
114
+ count?: number;
115
+ /** Initial delay in milliseconds before first retry (default: 100) */
116
+ delay?: number;
117
+ /** Backoff multiplier for subsequent retries (2 = double delay each time) (default: 2) */
118
+ backoff?: number;
119
+ }
120
+
111
121
  /** Options for URL endpoint requests */
112
122
  export interface UrlRequestOptions {
113
123
  /** Request headers */
114
124
  headers?: Record<string, string>;
115
125
  /** Path to append to the base URL */
116
126
  path?: string;
127
+ /** Request body (for POST, PUT, PATCH, DELETE) - Auto-stringifies objects */
128
+ body?: string | object | null;
117
129
  /** Maximum number of redirects to follow */
118
130
  maxRedirects?: number;
119
131
  /** Automatically parse response body */
120
132
  responseType?: "json" | "text" | "arraybuffer" | "blob";
121
133
  /** Enable anti-bot protection (using curl-impersonate) */
122
134
  antibot?: boolean;
135
+ /** Retry configuration for this request */
136
+ retry?: number | RetryConfig;
123
137
  }
124
138
 
125
139
  /** Interface for a URL endpoint with HTTP method helpers */
@@ -134,42 +148,26 @@ export interface UrlEndpoint {
134
148
  ): Promise<string>;
135
149
 
136
150
  /** POST request */
151
+ post<T = any>(options?: UrlRequestOptions): Promise<JirenResponse<T>>;
137
152
  post<T = any>(
138
- body?: string | null,
139
- options?: UrlRequestOptions
140
- ): Promise<JirenResponse<T>>;
141
- post<T = any>(
142
- body?: string | null,
143
153
  options?: UrlRequestOptions & { responseType: "json" }
144
154
  ): Promise<T>;
145
155
 
146
156
  /** PUT request */
157
+ put<T = any>(options?: UrlRequestOptions): Promise<JirenResponse<T>>;
147
158
  put<T = any>(
148
- body?: string | null,
149
- options?: UrlRequestOptions
150
- ): Promise<JirenResponse<T>>;
151
- put<T = any>(
152
- body?: string | null,
153
159
  options?: UrlRequestOptions & { responseType: "json" }
154
160
  ): Promise<T>;
155
161
 
156
162
  /** PATCH request */
163
+ patch<T = any>(options?: UrlRequestOptions): Promise<JirenResponse<T>>;
157
164
  patch<T = any>(
158
- body?: string | null,
159
- options?: UrlRequestOptions
160
- ): Promise<JirenResponse<T>>;
161
- patch<T = any>(
162
- body?: string | null,
163
165
  options?: UrlRequestOptions & { responseType: "json" }
164
166
  ): Promise<T>;
165
167
 
166
168
  /** DELETE request */
169
+ delete<T = any>(options?: UrlRequestOptions): Promise<JirenResponse<T>>;
167
170
  delete<T = any>(
168
- body?: string | null,
169
- options?: UrlRequestOptions
170
- ): Promise<JirenResponse<T>>;
171
- delete<T = any>(
172
- body?: string | null,
173
171
  options?: UrlRequestOptions & { responseType: "json" }
174
172
  ): Promise<T>;
175
173
 
package/index-node.ts CHANGED
@@ -12,7 +12,7 @@ export {
12
12
  JirenClient,
13
13
  type JirenClientOptions,
14
14
  type UrlAccessor,
15
- } from "./components/client-node";
15
+ } from "./components/client-node-native";
16
16
 
17
17
  // Types
18
18
  export * from "./types";
package/index.ts CHANGED
@@ -23,7 +23,11 @@
23
23
  */
24
24
 
25
25
  // Main client
26
- export { JirenClient } from "./components";
26
+ export {
27
+ JirenClient,
28
+ type JirenResponse,
29
+ type JirenResponseBody,
30
+ } from "./components";
27
31
 
28
32
  // Types
29
33
  export * from "./types";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jiren",
3
- "version": "1.2.5",
3
+ "version": "1.2.7",
4
4
  "author": "",
5
5
  "main": "index.ts",
6
6
  "module": "index.ts",
@@ -47,5 +47,8 @@
47
47
  "default": "./index-node.ts"
48
48
  },
49
49
  "./package.json": "./package.json"
50
+ },
51
+ "dependencies": {
52
+ "koffi": "^2.14.1"
50
53
  }
51
54
  }