jiren 1.2.0 → 1.2.6

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,626 @@
1
+ import { nativeLib as lib } from "./native-node";
2
+ import { ResponseCache } from "./cache";
3
+ import koffi from "koffi";
4
+ import type {
5
+ RequestOptions,
6
+ JirenResponse,
7
+ JirenResponseBody,
8
+ WarmupUrlConfig,
9
+ UrlRequestOptions,
10
+ UrlEndpoint,
11
+ CacheConfig,
12
+ } from "./types";
13
+
14
+ const STATUS_TEXT: Record<number, string> = {
15
+ 200: "OK",
16
+ 201: "Created",
17
+ 204: "No Content",
18
+ 301: "Moved Permanently",
19
+ 302: "Found",
20
+ 400: "Bad Request",
21
+ 401: "Unauthorized",
22
+ 403: "Forbidden",
23
+ 404: "Not Found",
24
+ 500: "Internal Server Error",
25
+ 502: "Bad Gateway",
26
+ 503: "Service Unavailable",
27
+ };
28
+
29
+ /** URL configuration with optional cache */
30
+ export type UrlConfig = string | { url: string; cache?: boolean | CacheConfig };
31
+
32
+ /** Options for JirenClient constructor */
33
+ export interface JirenClientOptions<
34
+ T extends readonly WarmupUrlConfig[] | Record<string, UrlConfig> =
35
+ | readonly WarmupUrlConfig[]
36
+ | Record<string, UrlConfig>
37
+ > {
38
+ /** URLs to warmup on client creation (pre-connect + handshake) */
39
+ warmup?: string[] | T;
40
+
41
+ /** Enable benchmark mode (Force HTTP/2, disable probing) */
42
+ benchmark?: boolean;
43
+ }
44
+
45
+ /** Helper to extract keys from Warmup Config */
46
+ export type ExtractWarmupKeys<
47
+ T extends readonly WarmupUrlConfig[] | Record<string, UrlConfig>
48
+ > = T extends readonly WarmupUrlConfig[]
49
+ ? T[number]["key"]
50
+ : T extends Record<string, UrlConfig>
51
+ ? keyof T
52
+ : never;
53
+
54
+ /** Type-safe URL accessor - maps keys to UrlEndpoint objects */
55
+ export type UrlAccessor<
56
+ T extends readonly WarmupUrlConfig[] | Record<string, UrlConfig>
57
+ > = {
58
+ [K in ExtractWarmupKeys<T>]: UrlEndpoint;
59
+ };
60
+
61
+ /**
62
+ * Helper function to define warmup URLs with type inference.
63
+ */
64
+ export function defineUrls<const T extends readonly WarmupUrlConfig[]>(
65
+ urls: T
66
+ ): T {
67
+ return urls;
68
+ }
69
+
70
+ export class JirenClient<
71
+ T extends readonly WarmupUrlConfig[] | Record<string, UrlConfig> =
72
+ | readonly WarmupUrlConfig[]
73
+ | Record<string, UrlConfig>
74
+ > {
75
+ private ptr: any = null; // Koffi pointer (Buffer or External)
76
+ private urlMap: Map<string, string> = new Map();
77
+ private cacheConfig: Map<string, { enabled: boolean; ttl: number }> =
78
+ new Map();
79
+ private cache: ResponseCache;
80
+
81
+ /** Type-safe URL accessor for warmed-up URLs */
82
+ public readonly url: UrlAccessor<T>;
83
+
84
+ constructor(options?: JirenClientOptions<T>) {
85
+ this.ptr = lib.symbols.zclient_new();
86
+ if (!this.ptr) throw new Error("Failed to create native client instance");
87
+
88
+ // Initialize cache
89
+ this.cache = new ResponseCache(100);
90
+
91
+ // Enable benchmark mode if requested
92
+ if (options?.benchmark) {
93
+ lib.symbols.zclient_set_benchmark_mode(this.ptr, true);
94
+ }
95
+
96
+ // Process warmup URLs
97
+ if (options?.warmup) {
98
+ const urls: string[] = [];
99
+ const warmup = options.warmup;
100
+
101
+ if (Array.isArray(warmup)) {
102
+ for (const item of warmup) {
103
+ if (typeof item === "string") {
104
+ urls.push(item);
105
+ } else {
106
+ // WarmupUrlConfig with key and optional cache
107
+ const config = item as WarmupUrlConfig;
108
+ urls.push(config.url);
109
+ this.urlMap.set(config.key, config.url);
110
+
111
+ // Store cache config
112
+ if (config.cache) {
113
+ const cacheConfig =
114
+ typeof config.cache === "boolean"
115
+ ? { enabled: true, ttl: 60000 }
116
+ : { enabled: true, ttl: config.cache.ttl || 60000 };
117
+ this.cacheConfig.set(config.key, cacheConfig);
118
+ }
119
+ }
120
+ }
121
+ } else {
122
+ // Record<string, UrlConfig>
123
+ for (const [key, urlConfig] of Object.entries(warmup)) {
124
+ if (typeof urlConfig === "string") {
125
+ // Simple string URL
126
+ urls.push(urlConfig);
127
+ this.urlMap.set(key, urlConfig);
128
+ } else {
129
+ // URL config object with cache
130
+ urls.push(urlConfig.url);
131
+ this.urlMap.set(key, urlConfig.url);
132
+
133
+ // Store cache config
134
+ if (urlConfig.cache) {
135
+ const cacheConfig =
136
+ typeof urlConfig.cache === "boolean"
137
+ ? { enabled: true, ttl: 60000 }
138
+ : { enabled: true, ttl: urlConfig.cache.ttl || 60000 };
139
+ this.cacheConfig.set(key, cacheConfig);
140
+ }
141
+ }
142
+ }
143
+ }
144
+
145
+ if (urls.length > 0) {
146
+ this.warmup(urls);
147
+ }
148
+ }
149
+
150
+ // Create proxy for type-safe URL access
151
+ this.url = this.createUrlAccessor();
152
+ }
153
+
154
+ /**
155
+ * Creates a proxy-based URL accessor for type-safe access.
156
+ */
157
+ private createUrlAccessor(): UrlAccessor<T> {
158
+ const self = this;
159
+
160
+ return new Proxy({} as UrlAccessor<T>, {
161
+ get(_target, prop: string) {
162
+ const baseUrl = self.urlMap.get(prop);
163
+ if (!baseUrl) {
164
+ throw new Error(
165
+ `URL key "${prop}" not found. Available keys: ${Array.from(
166
+ self.urlMap.keys()
167
+ ).join(", ")}`
168
+ );
169
+ }
170
+
171
+ const buildUrl = (path?: string) =>
172
+ path
173
+ ? `${baseUrl.replace(/\/$/, "")}/${path.replace(/^\//, "")}`
174
+ : baseUrl;
175
+
176
+ return {
177
+ get: async <R = any>(
178
+ options?: UrlRequestOptions
179
+ ): Promise<JirenResponse<R> | R | string | ArrayBuffer | Blob> => {
180
+ const cacheConfig = self.cacheConfig.get(prop);
181
+
182
+ if (cacheConfig?.enabled) {
183
+ const cached = self.cache.get(baseUrl, options?.path, options);
184
+ if (cached) {
185
+ return cached as any;
186
+ }
187
+ }
188
+
189
+ const response = await self.request<R>(
190
+ "GET",
191
+ buildUrl(options?.path),
192
+ null,
193
+ {
194
+ headers: options?.headers,
195
+ maxRedirects: options?.maxRedirects,
196
+ responseType: options?.responseType,
197
+ antibot: options?.antibot,
198
+ }
199
+ );
200
+
201
+ if (
202
+ cacheConfig?.enabled &&
203
+ typeof response === "object" &&
204
+ "status" in response
205
+ ) {
206
+ self.cache.set(
207
+ baseUrl,
208
+ response as JirenResponse,
209
+ cacheConfig.ttl,
210
+ options?.path,
211
+ options
212
+ );
213
+ }
214
+
215
+ return response;
216
+ },
217
+
218
+ post: async <R = any>(
219
+ body?: string | null,
220
+ options?: UrlRequestOptions
221
+ ): Promise<JirenResponse<R> | R | string | ArrayBuffer | Blob> => {
222
+ return self.request<R>(
223
+ "POST",
224
+ buildUrl(options?.path),
225
+ body || null,
226
+ {
227
+ headers: options?.headers,
228
+ maxRedirects: options?.maxRedirects,
229
+ responseType: options?.responseType,
230
+ }
231
+ );
232
+ },
233
+
234
+ put: async <R = any>(
235
+ body?: string | null,
236
+ options?: UrlRequestOptions
237
+ ): Promise<JirenResponse<R> | R | string | ArrayBuffer | Blob> => {
238
+ return self.request<R>(
239
+ "PUT",
240
+ buildUrl(options?.path),
241
+ body || null,
242
+ {
243
+ headers: options?.headers,
244
+ maxRedirects: options?.maxRedirects,
245
+ responseType: options?.responseType,
246
+ }
247
+ );
248
+ },
249
+
250
+ patch: async <R = any>(
251
+ body?: string | null,
252
+ options?: UrlRequestOptions
253
+ ): Promise<JirenResponse<R> | R | string | ArrayBuffer | Blob> => {
254
+ return self.request<R>(
255
+ "PATCH",
256
+ buildUrl(options?.path),
257
+ body || null,
258
+ {
259
+ headers: options?.headers,
260
+ maxRedirects: options?.maxRedirects,
261
+ responseType: options?.responseType,
262
+ }
263
+ );
264
+ },
265
+
266
+ delete: async <R = any>(
267
+ body?: string | null,
268
+ options?: UrlRequestOptions
269
+ ): Promise<JirenResponse<R> | R | string | ArrayBuffer | Blob> => {
270
+ return self.request<R>(
271
+ "DELETE",
272
+ buildUrl(options?.path),
273
+ body || null,
274
+ {
275
+ headers: options?.headers,
276
+ maxRedirects: options?.maxRedirects,
277
+ responseType: options?.responseType,
278
+ }
279
+ );
280
+ },
281
+
282
+ head: async (
283
+ options?: UrlRequestOptions
284
+ ): Promise<JirenResponse<any>> => {
285
+ return self.request("HEAD", buildUrl(options?.path), null, {
286
+ headers: options?.headers,
287
+ maxRedirects: options?.maxRedirects,
288
+ antibot: options?.antibot,
289
+ });
290
+ },
291
+
292
+ options: async (
293
+ options?: UrlRequestOptions
294
+ ): Promise<JirenResponse<any>> => {
295
+ return self.request("OPTIONS", buildUrl(options?.path), null, {
296
+ headers: options?.headers,
297
+ maxRedirects: options?.maxRedirects,
298
+ antibot: options?.antibot,
299
+ });
300
+ },
301
+
302
+ prefetch: async (options?: UrlRequestOptions): Promise<void> => {
303
+ self.cache.clear(baseUrl);
304
+ const cacheConfig = self.cacheConfig.get(prop);
305
+ if (cacheConfig?.enabled) {
306
+ await self.request("GET", buildUrl(options?.path), null, {
307
+ headers: options?.headers,
308
+ maxRedirects: options?.maxRedirects,
309
+ antibot: options?.antibot,
310
+ });
311
+ }
312
+ },
313
+ } as UrlEndpoint;
314
+ },
315
+ });
316
+ }
317
+
318
+ public close(): void {
319
+ if (this.ptr) {
320
+ lib.symbols.zclient_free(this.ptr);
321
+ this.ptr = null;
322
+ }
323
+ }
324
+
325
+ public async warmup(urls: string[]): Promise<void> {
326
+ if (!this.ptr) throw new Error("Client is closed");
327
+
328
+ await Promise.all(
329
+ urls.map(
330
+ (url) =>
331
+ new Promise<void>((resolve) => {
332
+ const urlBuffer = Buffer.from(url + "\0");
333
+ lib.symbols.zclient_prefetch(this.ptr, url); // Koffi handles string auto-conversion if type is 'const char*' but Buffer is safer for null termination?
334
+ // Koffi: "const char *" expects a string or Buffer. String is null-terminated by Koffi.
335
+ // Using generic string.
336
+ resolve();
337
+ })
338
+ )
339
+ );
340
+ }
341
+
342
+ /**
343
+ * @deprecated Use warmup() instead
344
+ */
345
+ public prefetch(urls: string[]): void {
346
+ this.warmup(urls);
347
+ }
348
+
349
+ public async request<T = any>(
350
+ method: string,
351
+ url: string,
352
+ body?: string | null,
353
+ options?: RequestOptions & { responseType: "json" }
354
+ ): Promise<T>;
355
+ public async request<T = any>(
356
+ method: string,
357
+ url: string,
358
+ body?: string | null,
359
+ options?: RequestOptions & { responseType: "text" }
360
+ ): Promise<string>;
361
+ public async request<T = any>(
362
+ method: string,
363
+ url: string,
364
+ body?: string | null,
365
+ options?: RequestOptions & { responseType: "arraybuffer" }
366
+ ): Promise<ArrayBuffer>;
367
+ public async request<T = any>(
368
+ method: string,
369
+ url: string,
370
+ body?: string | null,
371
+ options?: RequestOptions & { responseType: "blob" }
372
+ ): Promise<Blob>;
373
+ public async request<T = any>(
374
+ method: string,
375
+ url: string,
376
+ body?: string | null,
377
+ options?: RequestOptions
378
+ ): Promise<JirenResponse<T>>;
379
+ public async request<T = any>(
380
+ method: string,
381
+ url: string,
382
+ body?: string | null,
383
+ options?: RequestOptions | Record<string, string> | null
384
+ ): Promise<JirenResponse<T> | T | string | ArrayBuffer | Blob> {
385
+ if (!this.ptr) throw new Error("Client is closed");
386
+
387
+ // Normalize options
388
+ let headers: Record<string, string> = {};
389
+ let maxRedirects = 5;
390
+ let responseType: RequestOptions["responseType"] | undefined;
391
+ let antibot = false;
392
+
393
+ if (options) {
394
+ if (
395
+ "maxRedirects" in options ||
396
+ "headers" in options ||
397
+ "responseType" in options ||
398
+ "method" in options ||
399
+ "timeout" in options ||
400
+ "antibot" in options
401
+ ) {
402
+ const opts = options as RequestOptions;
403
+ if (opts.headers) headers = opts.headers;
404
+ if (opts.maxRedirects !== undefined) maxRedirects = opts.maxRedirects;
405
+ if (opts.responseType) responseType = opts.responseType;
406
+ if (opts.antibot !== undefined) antibot = opts.antibot;
407
+ } else {
408
+ headers = options as Record<string, string>;
409
+ }
410
+ }
411
+
412
+ const defaultHeaders: Record<string, string> = {
413
+ "user-agent":
414
+ "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",
415
+ accept:
416
+ "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8",
417
+ "accept-encoding": "gzip",
418
+ "accept-language": "en-US,en;q=0.9",
419
+ "sec-ch-ua":
420
+ '"Not_A Brand";v="8", "Chromium";v="120", "Google Chrome";v="120"',
421
+ "sec-ch-ua-mobile": "?0",
422
+ "sec-ch-ua-platform": '"macOS"',
423
+ "sec-fetch-dest": "document",
424
+ "sec-fetch-mode": "navigate",
425
+ "sec-fetch-site": "none",
426
+ "sec-fetch-user": "?1",
427
+ "upgrade-insecure-requests": "1",
428
+ };
429
+
430
+ const finalHeaders = { ...defaultHeaders, ...headers };
431
+
432
+ // Enforce Chrome header order
433
+ const orderedHeaders: Record<string, string> = {};
434
+ const keys = [
435
+ "sec-ch-ua",
436
+ "sec-ch-ua-mobile",
437
+ "sec-ch-ua-platform",
438
+ "upgrade-insecure-requests",
439
+ "user-agent",
440
+ "accept",
441
+ "sec-fetch-site",
442
+ "sec-fetch-mode",
443
+ "sec-fetch-user",
444
+ "sec-fetch-dest",
445
+ "accept-encoding",
446
+ "accept-language",
447
+ ];
448
+
449
+ for (const key of keys) {
450
+ if (finalHeaders[key]) {
451
+ orderedHeaders[key] = finalHeaders[key];
452
+ delete finalHeaders[key];
453
+ }
454
+ }
455
+ for (const [key, value] of Object.entries(finalHeaders)) {
456
+ orderedHeaders[key] = value;
457
+ }
458
+
459
+ const headerStr = Object.entries(orderedHeaders)
460
+ .map(([k, v]) => `${k.toLowerCase()}: ${v}`)
461
+ .join("\r\n");
462
+
463
+ // Koffi auto-converts strings to const char *.
464
+ // Pass strings directly.
465
+ // For body and headers, empty string is fine if null.
466
+ // But native check might expect null pointer?
467
+ // zclient_request expects const char*. If passed null/undefined, Koffi might pass NULL?
468
+ // Let's pass null for explicit nulls.
469
+
470
+ const respPtr = lib.symbols.zclient_request(
471
+ this.ptr,
472
+ method,
473
+ url,
474
+ headerStr.length > 0 ? headerStr : null,
475
+ body || null,
476
+ maxRedirects,
477
+ antibot
478
+ );
479
+
480
+ const response = this.parseResponse<T>(respPtr, url);
481
+
482
+ if (responseType) {
483
+ if (responseType === "json") return response.body.json();
484
+ if (responseType === "text") return response.body.text();
485
+ if (responseType === "arraybuffer") return response.body.arrayBuffer();
486
+ if (responseType === "blob") return response.body.blob();
487
+ }
488
+
489
+ return response;
490
+ }
491
+
492
+ private parseResponse<T = any>(respPtr: any, url: string): JirenResponse<T> {
493
+ if (!respPtr)
494
+ throw new Error("Native request failed (returned null pointer)");
495
+
496
+ try {
497
+ const status = lib.symbols.zclient_response_status(respPtr);
498
+ const len = Number(lib.symbols.zclient_response_body_len(respPtr));
499
+ const bodyPtr = lib.symbols.zclient_response_body(respPtr);
500
+
501
+ const headersLen = Number(
502
+ lib.symbols.zclient_response_headers_len(respPtr)
503
+ );
504
+ let headersObj: Record<string, string> | NativeHeaders = {};
505
+
506
+ if (headersLen > 0) {
507
+ const rawHeadersPtr = lib.symbols.zclient_response_headers(respPtr);
508
+ if (rawHeadersPtr) {
509
+ // Copy headers to JS memory
510
+ // Koffi decode to buffer: koffi.decode(ptr, "uint8_t", len)
511
+ const raw = Buffer.from(
512
+ koffi.decode(rawHeadersPtr, "uint8_t", headersLen)
513
+ );
514
+ headersObj = new NativeHeaders(raw);
515
+ }
516
+ }
517
+
518
+ const headersProxy = new Proxy(
519
+ headersObj instanceof NativeHeaders ? headersObj : {},
520
+ {
521
+ get(target, prop) {
522
+ if (target instanceof NativeHeaders && typeof prop === "string") {
523
+ if (prop === "toJSON") return () => target.toJSON();
524
+ const val = target.get(prop);
525
+ if (val !== null) return val;
526
+ }
527
+ return Reflect.get(target, prop);
528
+ },
529
+ }
530
+ ) as unknown as Record<string, string>;
531
+
532
+ let buffer: Buffer = Buffer.alloc(0);
533
+ if (len > 0 && bodyPtr) {
534
+ // Copy body content
535
+ buffer = Buffer.from(koffi.decode(bodyPtr, "uint8_t", len));
536
+ }
537
+
538
+ let bodyUsed = false;
539
+ const consumeBody = () => {
540
+ bodyUsed = true;
541
+ };
542
+
543
+ const bodyObj: JirenResponseBody<T> = {
544
+ bodyUsed: false,
545
+ arrayBuffer: async () => {
546
+ consumeBody();
547
+ return buffer.buffer.slice(
548
+ buffer.byteOffset,
549
+ buffer.byteOffset + buffer.byteLength
550
+ ) as ArrayBuffer;
551
+ },
552
+ blob: async () => {
553
+ consumeBody();
554
+ return new Blob([buffer as any]);
555
+ },
556
+ text: async () => {
557
+ consumeBody();
558
+ return buffer.toString("utf-8");
559
+ },
560
+ json: async <R = T>(): Promise<R> => {
561
+ consumeBody();
562
+ return JSON.parse(buffer.toString("utf-8"));
563
+ },
564
+ };
565
+
566
+ Object.defineProperty(bodyObj, "bodyUsed", {
567
+ get: () => bodyUsed,
568
+ });
569
+
570
+ return {
571
+ url,
572
+ status,
573
+ statusText: STATUS_TEXT[status] || "",
574
+ headers: headersProxy,
575
+ ok: status >= 200 && status < 300,
576
+ redirected: false,
577
+ type: "basic",
578
+ body: bodyObj,
579
+ } as JirenResponse<T>;
580
+ } finally {
581
+ lib.symbols.zclient_response_free(respPtr);
582
+ }
583
+ }
584
+ }
585
+
586
+ class NativeHeaders {
587
+ private raw: Buffer;
588
+ private len: number;
589
+ private cache = new Map<string, string>();
590
+ private parsed = false;
591
+
592
+ constructor(raw: Buffer) {
593
+ this.raw = raw;
594
+ this.len = raw.length;
595
+ }
596
+
597
+ private ensureParsed() {
598
+ if (this.parsed) return;
599
+ try {
600
+ const text = this.raw.toString("utf-8");
601
+ const lines = text.split("\r\n");
602
+ for (const line of lines) {
603
+ if (!line) continue;
604
+ const colon = line.indexOf(":");
605
+ if (colon === -1) continue;
606
+ const key = line.substring(0, colon).trim().toLowerCase();
607
+ const val = line.substring(colon + 1).trim();
608
+ this.cache.set(key, val);
609
+ }
610
+ } catch (e) {
611
+ // Ignore parsing errors
612
+ }
613
+ this.parsed = true;
614
+ }
615
+
616
+ get(name: string): string | null {
617
+ const target = name.toLowerCase();
618
+ this.ensureParsed();
619
+ return this.cache.get(target) || null;
620
+ }
621
+
622
+ toJSON(): Record<string, string> {
623
+ this.ensureParsed();
624
+ return Object.fromEntries(this.cache);
625
+ }
626
+ }
@@ -0,0 +1,440 @@
1
+ import { ResponseCache } from "./cache";
2
+ import type {
3
+ RequestOptions,
4
+ JirenResponse,
5
+ JirenResponseBody,
6
+ WarmupUrlConfig,
7
+ UrlRequestOptions,
8
+ UrlEndpoint,
9
+ CacheConfig,
10
+ } from "./types";
11
+
12
+ /** URL configuration with optional cache */
13
+ export type UrlConfig = string | { url: string; cache?: boolean | CacheConfig };
14
+
15
+ /** Options for JirenClient constructor */
16
+ export interface JirenClientOptions<
17
+ T extends readonly WarmupUrlConfig[] | Record<string, UrlConfig> =
18
+ | readonly WarmupUrlConfig[]
19
+ | Record<string, UrlConfig>
20
+ > {
21
+ /** URLs to warmup (pre-connect) */
22
+ warmup?: string[] | T;
23
+
24
+ /** Benchmark mode (Ignored in Node.js fallback) */
25
+ benchmark?: boolean;
26
+ }
27
+
28
+ /** Helper to extract keys from Warmup Config */
29
+ export type ExtractWarmupKeys<
30
+ T extends readonly WarmupUrlConfig[] | Record<string, UrlConfig>
31
+ > = T extends readonly WarmupUrlConfig[]
32
+ ? T[number]["key"]
33
+ : T extends Record<string, UrlConfig>
34
+ ? keyof T
35
+ : never;
36
+
37
+ /** Type-safe URL accessor - maps keys to UrlEndpoint objects */
38
+ export type UrlAccessor<
39
+ T extends readonly WarmupUrlConfig[] | Record<string, UrlConfig>
40
+ > = {
41
+ [K in ExtractWarmupKeys<T>]: UrlEndpoint;
42
+ };
43
+
44
+ /**
45
+ * Helper function to define warmup URLs with type inference.
46
+ */
47
+ export function defineUrls<const T extends readonly WarmupUrlConfig[]>(
48
+ urls: T
49
+ ): T {
50
+ return urls;
51
+ }
52
+
53
+ export class JirenClient<
54
+ T extends readonly WarmupUrlConfig[] | Record<string, UrlConfig> =
55
+ | readonly WarmupUrlConfig[]
56
+ | Record<string, UrlConfig>
57
+ > {
58
+ private urlMap: Map<string, string> = new Map();
59
+ private cacheConfig: Map<string, { enabled: boolean; ttl: number }> =
60
+ new Map();
61
+ private cache: ResponseCache;
62
+
63
+ /** Type-safe URL accessor for warmed-up URLs */
64
+ public readonly url: UrlAccessor<T>;
65
+
66
+ constructor(options?: JirenClientOptions<T>) {
67
+ // Initialize cache
68
+ this.cache = new ResponseCache(100);
69
+
70
+ // Process warmup URLs
71
+ if (options?.warmup) {
72
+ const urls: string[] = [];
73
+ const warmup = options.warmup;
74
+
75
+ if (Array.isArray(warmup)) {
76
+ for (const item of warmup) {
77
+ if (typeof item === "string") {
78
+ urls.push(item);
79
+ } else {
80
+ // WarmupUrlConfig with key and optional cache
81
+ const config = item as WarmupUrlConfig;
82
+ urls.push(config.url);
83
+ this.urlMap.set(config.key, config.url);
84
+
85
+ // Store cache config
86
+ if (config.cache) {
87
+ const cacheConfig =
88
+ typeof config.cache === "boolean"
89
+ ? { enabled: true, ttl: 60000 }
90
+ : { enabled: true, ttl: config.cache.ttl || 60000 };
91
+ this.cacheConfig.set(config.key, cacheConfig);
92
+ }
93
+ }
94
+ }
95
+ } else {
96
+ // Record<string, UrlConfig>
97
+ for (const [key, urlConfig] of Object.entries(warmup)) {
98
+ if (typeof urlConfig === "string") {
99
+ // Simple string URL
100
+ urls.push(urlConfig);
101
+ this.urlMap.set(key, urlConfig);
102
+ } else {
103
+ // URL config object with cache
104
+ urls.push(urlConfig.url);
105
+ this.urlMap.set(key, urlConfig.url);
106
+
107
+ // Store cache config
108
+ if (urlConfig.cache) {
109
+ const cacheConfig =
110
+ typeof urlConfig.cache === "boolean"
111
+ ? { enabled: true, ttl: 60000 }
112
+ : { enabled: true, ttl: urlConfig.cache.ttl || 60000 };
113
+ this.cacheConfig.set(key, cacheConfig);
114
+ }
115
+ }
116
+ }
117
+ }
118
+
119
+ if (urls.length > 0) {
120
+ this.warmup(urls);
121
+ }
122
+ }
123
+
124
+ // Create proxy for type-safe URL access
125
+ this.url = this.createUrlAccessor();
126
+ }
127
+
128
+ /**
129
+ * Creates a proxy-based URL accessor for type-safe access.
130
+ */
131
+ private createUrlAccessor(): UrlAccessor<T> {
132
+ const self = this;
133
+
134
+ return new Proxy({} as UrlAccessor<T>, {
135
+ get(_target, prop: string) {
136
+ const baseUrl = self.urlMap.get(prop);
137
+ if (!baseUrl) {
138
+ throw new Error(
139
+ `URL key "${prop}" not found. Available keys: ${Array.from(
140
+ self.urlMap.keys()
141
+ ).join(", ")}`
142
+ );
143
+ }
144
+
145
+ const buildUrl = (path?: string) =>
146
+ path
147
+ ? `${baseUrl.replace(/\/$/, "")}/${path.replace(/^\//, "")}`
148
+ : baseUrl;
149
+
150
+ return {
151
+ get: async <R = any>(
152
+ options?: UrlRequestOptions
153
+ ): Promise<JirenResponse<R> | R | string | ArrayBuffer | Blob> => {
154
+ const cacheConfig = self.cacheConfig.get(prop);
155
+
156
+ if (cacheConfig?.enabled) {
157
+ const cached = self.cache.get(baseUrl, options?.path, options);
158
+ if (cached) return cached as any;
159
+ }
160
+
161
+ const response = await self.request<R>(
162
+ "GET",
163
+ buildUrl(options?.path),
164
+ null,
165
+ {
166
+ headers: options?.headers,
167
+ maxRedirects: options?.maxRedirects,
168
+ responseType: options?.responseType,
169
+ antibot: options?.antibot,
170
+ }
171
+ );
172
+
173
+ if (
174
+ cacheConfig?.enabled &&
175
+ typeof response === "object" &&
176
+ "status" in response
177
+ ) {
178
+ self.cache.set(
179
+ baseUrl,
180
+ response as JirenResponse,
181
+ cacheConfig.ttl,
182
+ options?.path,
183
+ options
184
+ );
185
+ }
186
+
187
+ return response;
188
+ },
189
+
190
+ post: async <R = any>(
191
+ body?: string | null,
192
+ options?: UrlRequestOptions
193
+ ): Promise<JirenResponse<R> | R | string | ArrayBuffer | Blob> => {
194
+ return self.request<R>(
195
+ "POST",
196
+ buildUrl(options?.path),
197
+ body || null,
198
+ {
199
+ headers: options?.headers,
200
+ maxRedirects: options?.maxRedirects,
201
+ responseType: options?.responseType,
202
+ }
203
+ );
204
+ },
205
+
206
+ put: async <R = any>(
207
+ body?: string | null,
208
+ options?: UrlRequestOptions
209
+ ): Promise<JirenResponse<R> | R | string | ArrayBuffer | Blob> => {
210
+ return self.request<R>(
211
+ "PUT",
212
+ buildUrl(options?.path),
213
+ body || null,
214
+ {
215
+ headers: options?.headers,
216
+ maxRedirects: options?.maxRedirects,
217
+ responseType: options?.responseType,
218
+ }
219
+ );
220
+ },
221
+
222
+ patch: async <R = any>(
223
+ body?: string | null,
224
+ options?: UrlRequestOptions
225
+ ): Promise<JirenResponse<R> | R | string | ArrayBuffer | Blob> => {
226
+ return self.request<R>(
227
+ "PATCH",
228
+ buildUrl(options?.path),
229
+ body || null,
230
+ {
231
+ headers: options?.headers,
232
+ maxRedirects: options?.maxRedirects,
233
+ responseType: options?.responseType,
234
+ }
235
+ );
236
+ },
237
+
238
+ delete: async <R = any>(
239
+ body?: string | null,
240
+ options?: UrlRequestOptions
241
+ ): Promise<JirenResponse<R> | R | string | ArrayBuffer | Blob> => {
242
+ return self.request<R>(
243
+ "DELETE",
244
+ buildUrl(options?.path),
245
+ body || null,
246
+ {
247
+ headers: options?.headers,
248
+ maxRedirects: options?.maxRedirects,
249
+ responseType: options?.responseType,
250
+ }
251
+ );
252
+ },
253
+
254
+ head: async (
255
+ options?: UrlRequestOptions
256
+ ): Promise<JirenResponse<any>> => {
257
+ return self.request("HEAD", buildUrl(options?.path), null, {
258
+ headers: options?.headers,
259
+ maxRedirects: options?.maxRedirects,
260
+ antibot: options?.antibot,
261
+ });
262
+ },
263
+
264
+ options: async (
265
+ options?: UrlRequestOptions
266
+ ): Promise<JirenResponse<any>> => {
267
+ return self.request("OPTIONS", buildUrl(options?.path), null, {
268
+ headers: options?.headers,
269
+ maxRedirects: options?.maxRedirects,
270
+ antibot: options?.antibot,
271
+ });
272
+ },
273
+
274
+ prefetch: async (options?: UrlRequestOptions): Promise<void> => {
275
+ self.cache.clear(baseUrl);
276
+ const cacheConfig = self.cacheConfig.get(prop);
277
+ if (cacheConfig?.enabled) {
278
+ await self.request("GET", buildUrl(options?.path), null, {
279
+ headers: options?.headers,
280
+ maxRedirects: options?.maxRedirects,
281
+ antibot: options?.antibot,
282
+ });
283
+ }
284
+ },
285
+ } as UrlEndpoint;
286
+ },
287
+ });
288
+ }
289
+
290
+ public close(): void {
291
+ // No-op for fetch client
292
+ }
293
+
294
+ public async warmup(urls: string[]): Promise<void> {
295
+ // Basic prefetch for Node.js fallback (HEAD request)
296
+ await Promise.allSettled(
297
+ urls.map((url) =>
298
+ fetch(url, { method: "HEAD", signal: AbortSignal.timeout(2000) }).catch(
299
+ () => {}
300
+ )
301
+ )
302
+ );
303
+ }
304
+
305
+ /**
306
+ * @deprecated Use warmup() instead
307
+ */
308
+ public prefetch(urls: string[]): void {
309
+ this.warmup(urls);
310
+ }
311
+
312
+ public async request<T = any>(
313
+ method: string,
314
+ url: string,
315
+ body?: string | null,
316
+ options?: RequestOptions & { responseType: "json" }
317
+ ): Promise<T>;
318
+ public async request<T = any>(
319
+ method: string,
320
+ url: string,
321
+ body?: string | null,
322
+ options?: RequestOptions & { responseType: "text" }
323
+ ): Promise<string>;
324
+ public async request<T = any>(
325
+ method: string,
326
+ url: string,
327
+ body?: string | null,
328
+ options?: RequestOptions & { responseType: "arraybuffer" }
329
+ ): Promise<ArrayBuffer>;
330
+ public async request<T = any>(
331
+ method: string,
332
+ url: string,
333
+ body?: string | null,
334
+ options?: RequestOptions & { responseType: "blob" }
335
+ ): Promise<Blob>;
336
+ public async request<T = any>(
337
+ method: string,
338
+ url: string,
339
+ body?: string | null,
340
+ options?: RequestOptions
341
+ ): Promise<JirenResponse<T>>;
342
+ public async request<T = any>(
343
+ method: string,
344
+ url: string,
345
+ body?: string | null,
346
+ options?: RequestOptions | Record<string, string> | null
347
+ ): Promise<JirenResponse<T> | T | string | ArrayBuffer | Blob> {
348
+ // Normalize options
349
+ let headers: Record<string, string> = {};
350
+ let responseType: RequestOptions["responseType"] | undefined;
351
+
352
+ if (options) {
353
+ if (
354
+ "maxRedirects" in options ||
355
+ "headers" in options ||
356
+ "responseType" in options ||
357
+ "method" in options ||
358
+ "timeout" in options ||
359
+ "antibot" in options
360
+ ) {
361
+ const opts = options as RequestOptions;
362
+ if (opts.headers) headers = opts.headers;
363
+ if (opts.responseType) responseType = opts.responseType;
364
+ } else {
365
+ headers = options as Record<string, string>;
366
+ }
367
+ }
368
+
369
+ const fetchOptions: RequestInit = {
370
+ method,
371
+ headers: headers,
372
+ body: body || undefined,
373
+ redirect: "follow", // Native client follows redirects
374
+ };
375
+
376
+ const response = await fetch(url, fetchOptions);
377
+
378
+ // Auto-parse if requested
379
+ if (responseType) {
380
+ if (responseType === "json") return response.json() as Promise<T>;
381
+ if (responseType === "text") return response.text();
382
+ if (responseType === "arraybuffer") return response.arrayBuffer();
383
+ if (responseType === "blob") return response.blob();
384
+ }
385
+
386
+ // Construct JirenResponse
387
+ const responseHeaders: Record<string, string> = {};
388
+ response.headers.forEach((val, key) => {
389
+ responseHeaders[key] = val;
390
+ });
391
+
392
+ // Create a clone for each reading method to avoid "body used" error if user reads multiple times
393
+ // However, fetch body can only be read once. We replicate the behavior of the native client
394
+ // where we can read it. To do that we might need to buffer it OR just expose the raw response.
395
+ // The native client implementation buffers the whole body. Let's match that.
396
+
397
+ const buffer = await response.arrayBuffer();
398
+
399
+ let bodyUsed = false;
400
+ const consumeBody = () => {
401
+ bodyUsed = true;
402
+ };
403
+
404
+ const bodyObj: JirenResponseBody<T> = {
405
+ bodyUsed: false,
406
+ arrayBuffer: async () => {
407
+ consumeBody();
408
+ return buffer.slice(0);
409
+ },
410
+ blob: async () => {
411
+ consumeBody();
412
+ return new Blob([buffer]);
413
+ },
414
+ text: async () => {
415
+ consumeBody();
416
+ return new TextDecoder().decode(buffer);
417
+ },
418
+ json: async <R = T>(): Promise<R> => {
419
+ consumeBody();
420
+ const text = new TextDecoder().decode(buffer);
421
+ return JSON.parse(text);
422
+ },
423
+ };
424
+
425
+ Object.defineProperty(bodyObj, "bodyUsed", {
426
+ get: () => bodyUsed,
427
+ });
428
+
429
+ return {
430
+ url: response.url,
431
+ status: response.status,
432
+ statusText: response.statusText,
433
+ headers: responseHeaders,
434
+ ok: response.ok,
435
+ redirected: response.redirected,
436
+ type: response.type,
437
+ body: bodyObj,
438
+ } as JirenResponse<T>;
439
+ }
440
+ }
@@ -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
+ };
package/index-node.ts ADDED
@@ -0,0 +1,18 @@
1
+ /**
2
+ * jiren - Ultra-fast HTTP/HTTPS client (Node.js/Next.js Fallback)
3
+ *
4
+ * NOTE: This is the Node.js compatible version using standard fetch.
5
+ * For maximum performance with native Zig bindings, use Bun.
6
+ *
7
+ * @packageDocumentation
8
+ */
9
+
10
+ // Main client (Node.js fallback)
11
+ export {
12
+ JirenClient,
13
+ type JirenClientOptions,
14
+ type UrlAccessor,
15
+ } from "./components/client-node-native";
16
+
17
+ // Types
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.0",
3
+ "version": "1.2.6",
4
4
  "author": "",
5
5
  "main": "index.ts",
6
6
  "module": "index.ts",
@@ -14,6 +14,7 @@
14
14
  "description": "Jiren is a high-performance HTTP/HTTPS client, Faster than any other HTTP/HTTPS client.",
15
15
  "files": [
16
16
  "index.ts",
17
+ "index-node.ts",
17
18
  "types",
18
19
  "lib",
19
20
  "components"
@@ -39,5 +40,15 @@
39
40
  "node": ">=18.0.0",
40
41
  "bun": ">=1.1.0"
41
42
  },
42
- "type": "module"
43
+ "type": "module",
44
+ "exports": {
45
+ ".": {
46
+ "bun": "./index.ts",
47
+ "default": "./index-node.ts"
48
+ },
49
+ "./package.json": "./package.json"
50
+ },
51
+ "dependencies": {
52
+ "koffi": "^2.14.1"
53
+ }
43
54
  }