jiren 1.1.0 → 1.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,639 @@
1
+ import { CString, toArrayBuffer, type Pointer } from "bun:ffi";
2
+ import { lib } from "./native";
3
+ import type {
4
+ RequestOptions,
5
+ JirenResponse,
6
+ JirenResponseBody,
7
+ WarmupUrlConfig,
8
+ UrlRequestOptions,
9
+ UrlEndpoint,
10
+ } from "./types";
11
+
12
+ const STATUS_TEXT: Record<number, string> = {
13
+ 200: "OK",
14
+ 201: "Created",
15
+ 204: "No Content",
16
+ 301: "Moved Permanently",
17
+ 302: "Found",
18
+ 400: "Bad Request",
19
+ 401: "Unauthorized",
20
+ 403: "Forbidden",
21
+ 404: "Not Found",
22
+ 500: "Internal Server Error",
23
+ 502: "Bad Gateway",
24
+ 503: "Service Unavailable",
25
+ };
26
+
27
+ /** Options for JirenClient constructor */
28
+ export interface JirenClientOptions<
29
+ T extends readonly WarmupUrlConfig[] | Record<string, string> =
30
+ | readonly WarmupUrlConfig[]
31
+ | Record<string, string>
32
+ > {
33
+ /** URLs to warmup on client creation (pre-connect + handshake) */
34
+ warmup?: string[] | T;
35
+
36
+ /** Enable benchmark mode (Force HTTP/2, disable probing) */
37
+ benchmark?: boolean;
38
+ }
39
+
40
+ /** Helper to extract keys from Warmup Config */
41
+ export type ExtractWarmupKeys<
42
+ T extends readonly WarmupUrlConfig[] | Record<string, string>
43
+ > = T extends readonly WarmupUrlConfig[]
44
+ ? T[number]["key"]
45
+ : T extends Record<string, string>
46
+ ? keyof T
47
+ : never;
48
+
49
+ /** Type-safe URL accessor - maps keys to UrlEndpoint objects */
50
+ export type UrlAccessor<
51
+ T extends readonly WarmupUrlConfig[] | Record<string, string>
52
+ > = {
53
+ [K in ExtractWarmupKeys<T>]: UrlEndpoint;
54
+ };
55
+
56
+ /**
57
+ * Helper function to define warmup URLs with type inference.
58
+ * This eliminates the need for 'as const'.
59
+ *
60
+ * @example
61
+ * ```typescript
62
+ * const client = new JirenClient({
63
+ * warmup: defineUrls([
64
+ * { key: "google", url: "https://google.com" },
65
+ * ])
66
+ * });
67
+ * // OR
68
+ * const client = new JirenClient({
69
+ * warmup: {
70
+ * google: "https://google.com"
71
+ * }
72
+ * });
73
+ * ```
74
+ */
75
+ export function defineUrls<const T extends readonly WarmupUrlConfig[]>(
76
+ urls: T
77
+ ): T {
78
+ return urls;
79
+ }
80
+
81
+ export class JirenClient<
82
+ T extends readonly WarmupUrlConfig[] | Record<string, string> =
83
+ | readonly WarmupUrlConfig[]
84
+ | Record<string, string>
85
+ > {
86
+ private ptr: Pointer | null;
87
+ private urlMap: Map<string, string> = new Map();
88
+
89
+ /** Type-safe URL accessor for warmed-up URLs */
90
+ public readonly url: UrlAccessor<T>;
91
+
92
+ constructor(options?: JirenClientOptions<T>) {
93
+ this.ptr = lib.symbols.zclient_new();
94
+ if (!this.ptr) throw new Error("Failed to create native client instance");
95
+
96
+ // Enable benchmark mode if requested
97
+ if (options?.benchmark) {
98
+ lib.symbols.zclient_set_benchmark_mode(this.ptr, true);
99
+ }
100
+
101
+ // Process warmup URLs
102
+ if (options?.warmup) {
103
+ const urls: string[] = [];
104
+ const warmup = options.warmup;
105
+
106
+ if (Array.isArray(warmup)) {
107
+ for (const item of warmup) {
108
+ if (typeof item === "string") {
109
+ urls.push(item);
110
+ } else {
111
+ // WarmupUrlConfig with key
112
+ const config = item as WarmupUrlConfig;
113
+ urls.push(config.url);
114
+ this.urlMap.set(config.key, config.url);
115
+ }
116
+ }
117
+ } else {
118
+ // Record<string, string>
119
+ for (const [key, url] of Object.entries(warmup)) {
120
+ urls.push(url as string);
121
+ this.urlMap.set(key, url as string);
122
+ }
123
+ }
124
+
125
+ if (urls.length > 0) {
126
+ this.warmup(urls);
127
+ }
128
+ }
129
+
130
+ // Create proxy for type-safe URL access
131
+ this.url = this.createUrlAccessor();
132
+ }
133
+
134
+ /**
135
+ * Creates a proxy-based URL accessor for type-safe access to warmed-up URLs.
136
+ */
137
+ private createUrlAccessor(): UrlAccessor<T> {
138
+ const self = this;
139
+
140
+ return new Proxy({} as UrlAccessor<T>, {
141
+ get(_target, prop: string) {
142
+ const baseUrl = self.urlMap.get(prop);
143
+ if (!baseUrl) {
144
+ throw new Error(
145
+ `URL key "${prop}" not found. Available keys: ${Array.from(
146
+ self.urlMap.keys()
147
+ ).join(", ")}`
148
+ );
149
+ }
150
+
151
+ // Helper to build full URL with optional path
152
+ const buildUrl = (path?: string) =>
153
+ path
154
+ ? `${baseUrl.replace(/\/$/, "")}/${path.replace(/^\//, "")}`
155
+ : baseUrl;
156
+
157
+ // Return a UrlEndpoint object with all HTTP methods
158
+ return {
159
+ get: async <R = any>(
160
+ options?: UrlRequestOptions
161
+ ): Promise<JirenResponse<R> | R | string | ArrayBuffer | Blob> => {
162
+ return self.request<R>("GET", buildUrl(options?.path), null, {
163
+ headers: options?.headers,
164
+ maxRedirects: options?.maxRedirects,
165
+ responseType: options?.responseType,
166
+ antibot: options?.antibot,
167
+ });
168
+ },
169
+
170
+ post: async <R = any>(
171
+ body?: string | null,
172
+ options?: UrlRequestOptions
173
+ ): Promise<JirenResponse<R> | R | string | ArrayBuffer | Blob> => {
174
+ return self.request<R>(
175
+ "POST",
176
+ buildUrl(options?.path),
177
+ body || null,
178
+ {
179
+ headers: options?.headers,
180
+ maxRedirects: options?.maxRedirects,
181
+ responseType: options?.responseType,
182
+ }
183
+ );
184
+ },
185
+
186
+ put: async <R = any>(
187
+ body?: string | null,
188
+ options?: UrlRequestOptions
189
+ ): Promise<JirenResponse<R> | R | string | ArrayBuffer | Blob> => {
190
+ return self.request<R>(
191
+ "PUT",
192
+ buildUrl(options?.path),
193
+ body || null,
194
+ {
195
+ headers: options?.headers,
196
+ maxRedirects: options?.maxRedirects,
197
+ responseType: options?.responseType,
198
+ }
199
+ );
200
+ },
201
+
202
+ patch: async <R = any>(
203
+ body?: string | null,
204
+ options?: UrlRequestOptions
205
+ ): Promise<JirenResponse<R> | R | string | ArrayBuffer | Blob> => {
206
+ return self.request<R>(
207
+ "PATCH",
208
+ buildUrl(options?.path),
209
+ body || null,
210
+ {
211
+ headers: options?.headers,
212
+ maxRedirects: options?.maxRedirects,
213
+ responseType: options?.responseType,
214
+ }
215
+ );
216
+ },
217
+
218
+ delete: async <R = any>(
219
+ body?: string | null,
220
+ options?: UrlRequestOptions
221
+ ): Promise<JirenResponse<R> | R | string | ArrayBuffer | Blob> => {
222
+ return self.request<R>(
223
+ "DELETE",
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
+ head: async (
235
+ options?: UrlRequestOptions
236
+ ): Promise<JirenResponse<any>> => {
237
+ return self.request("HEAD", buildUrl(options?.path), null, {
238
+ headers: options?.headers,
239
+ maxRedirects: options?.maxRedirects,
240
+ antibot: options?.antibot,
241
+ });
242
+ },
243
+
244
+ options: async (
245
+ options?: UrlRequestOptions
246
+ ): Promise<JirenResponse<any>> => {
247
+ return self.request("OPTIONS", buildUrl(options?.path), null, {
248
+ headers: options?.headers,
249
+ maxRedirects: options?.maxRedirects,
250
+ antibot: options?.antibot,
251
+ });
252
+ },
253
+ } as UrlEndpoint;
254
+ },
255
+ });
256
+ }
257
+
258
+ /**
259
+ * Free the native client resources.
260
+ * Must be called when the client is no longer needed.
261
+ */
262
+ public close(): void {
263
+ if (this.ptr) {
264
+ lib.symbols.zclient_free(this.ptr);
265
+ this.ptr = null;
266
+ }
267
+ }
268
+
269
+ /**
270
+ * Warm up connections to URLs (DNS resolve + QUIC handshake).
271
+ * Call this early (e.g., at app startup) so subsequent requests are fast.
272
+ * @param urls - List of URLs to warm up
273
+ */
274
+ public warmup(urls: string[]): void {
275
+ if (!this.ptr) throw new Error("Client is closed");
276
+
277
+ for (const url of urls) {
278
+ const urlBuffer = Buffer.from(url + "\0");
279
+ lib.symbols.zclient_prefetch(this.ptr, urlBuffer);
280
+ }
281
+ }
282
+
283
+ /**
284
+ * @deprecated Use warmup() instead
285
+ */
286
+ public prefetch(urls: string[]): void {
287
+ this.warmup(urls);
288
+ }
289
+
290
+ /**
291
+ * Perform a generic HTTP request.
292
+ * @param method - HTTP method (GET, POST, PUT, DELETE, etc.)
293
+ * @param url - The URL to request
294
+ * @param body - The body content string (optional)
295
+ * @param options - Request options (headers, maxRedirects, etc.) or just headers map
296
+ * @returns Promise resolving to Response object or parsed body
297
+ */
298
+ public async request<T = any>(
299
+ method: string,
300
+ url: string,
301
+ body?: string | null,
302
+ options?: RequestOptions & { responseType: "json" }
303
+ ): Promise<T>;
304
+ public async request<T = any>(
305
+ method: string,
306
+ url: string,
307
+ body?: string | null,
308
+ options?: RequestOptions & { responseType: "text" }
309
+ ): Promise<string>;
310
+ public async request<T = any>(
311
+ method: string,
312
+ url: string,
313
+ body?: string | null,
314
+ options?: RequestOptions & { responseType: "arraybuffer" }
315
+ ): Promise<ArrayBuffer>;
316
+ public async request<T = any>(
317
+ method: string,
318
+ url: string,
319
+ body?: string | null,
320
+ options?: RequestOptions & { responseType: "blob" }
321
+ ): Promise<Blob>;
322
+ public async request<T = any>(
323
+ method: string,
324
+ url: string,
325
+ body?: string | null,
326
+ options?: RequestOptions
327
+ ): Promise<JirenResponse<T>>;
328
+ public async request<T = any>(
329
+ method: string,
330
+ url: string,
331
+ body?: string | null,
332
+ options?: RequestOptions | Record<string, string> | null
333
+ ): Promise<JirenResponse<T> | T | string | ArrayBuffer | Blob> {
334
+ if (!this.ptr) throw new Error("Client is closed");
335
+
336
+ // Normalize options
337
+ let headers: Record<string, string> = {};
338
+ let maxRedirects = 5; // Default
339
+ let responseType: RequestOptions["responseType"] | undefined;
340
+ let antibot = false; // Default
341
+
342
+ if (options) {
343
+ if (
344
+ "maxRedirects" in options ||
345
+ "headers" in options ||
346
+ "responseType" in options ||
347
+ "method" in options || // Check for any RequestOptions specific key
348
+ "timeout" in options ||
349
+ "antibot" in options
350
+ ) {
351
+ // It is RequestOptions
352
+ const opts = options as RequestOptions;
353
+ if (opts.headers) headers = opts.headers;
354
+ if (opts.maxRedirects !== undefined) maxRedirects = opts.maxRedirects;
355
+ if (opts.responseType) responseType = opts.responseType;
356
+ if (opts.antibot !== undefined) antibot = opts.antibot;
357
+ } else {
358
+ // Assume it's just headers Record<string, string> for backward compatibility
359
+ headers = options as Record<string, string>;
360
+ }
361
+ }
362
+
363
+ const methodBuffer = Buffer.from(method + "\0");
364
+ const urlBuffer = Buffer.from(url + "\0");
365
+
366
+ let bodyBuffer: Buffer | null = null;
367
+ if (body) {
368
+ bodyBuffer = Buffer.from(body + "\0");
369
+ }
370
+
371
+ let headersBuffer: Buffer | null = null;
372
+ const defaultHeaders: Record<string, string> = {
373
+ "user-agent":
374
+ "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",
375
+ accept:
376
+ "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8",
377
+ "accept-encoding": "gzip",
378
+ "accept-language": "en-US,en;q=0.9",
379
+ "sec-ch-ua":
380
+ '"Not_A Brand";v="8", "Chromium";v="120", "Google Chrome";v="120"',
381
+ "sec-ch-ua-mobile": "?0",
382
+ "sec-ch-ua-platform": '"macOS"',
383
+ "sec-fetch-dest": "document",
384
+ "sec-fetch-mode": "navigate",
385
+ "sec-fetch-site": "none",
386
+ "sec-fetch-user": "?1",
387
+ "upgrade-insecure-requests": "1",
388
+ };
389
+
390
+ const finalHeaders = { ...defaultHeaders, ...headers };
391
+
392
+ // Enforce Chrome header order
393
+ const orderedHeaders: Record<string, string> = {};
394
+ const keys = [
395
+ "sec-ch-ua",
396
+ "sec-ch-ua-mobile",
397
+ "sec-ch-ua-platform",
398
+ "upgrade-insecure-requests",
399
+ "user-agent",
400
+ "accept",
401
+ "sec-fetch-site",
402
+ "sec-fetch-mode",
403
+ "sec-fetch-user",
404
+ "sec-fetch-dest",
405
+ "accept-encoding",
406
+ "accept-language",
407
+ ];
408
+
409
+ // Add priority headers in order
410
+ for (const key of keys) {
411
+ if (finalHeaders[key]) {
412
+ orderedHeaders[key] = finalHeaders[key];
413
+ delete finalHeaders[key];
414
+ }
415
+ }
416
+
417
+ // Add remaining custom headers
418
+ for (const [key, value] of Object.entries(finalHeaders)) {
419
+ orderedHeaders[key] = value;
420
+ }
421
+
422
+ const headerStr = Object.entries(orderedHeaders)
423
+ .map(([k, v]) => `${k.toLowerCase()}: ${v}`)
424
+ .join("\r\n");
425
+
426
+ if (headerStr.length > 0) {
427
+ headersBuffer = Buffer.from(headerStr + "\0");
428
+ }
429
+
430
+ const respPtr = lib.symbols.zclient_request(
431
+ this.ptr,
432
+ methodBuffer,
433
+ urlBuffer,
434
+ headersBuffer,
435
+ bodyBuffer,
436
+ maxRedirects,
437
+ antibot
438
+ );
439
+
440
+ const response = this.parseResponse<T>(respPtr, url);
441
+
442
+ // Auto-parse if requested
443
+ if (responseType) {
444
+ if (responseType === "json") return response.body.json();
445
+ if (responseType === "text") return response.body.text();
446
+ if (responseType === "arraybuffer") return response.body.arrayBuffer();
447
+ if (responseType === "blob") return response.body.blob();
448
+ }
449
+
450
+ return response;
451
+ }
452
+
453
+ private parseResponse<T = any>(
454
+ respPtr: Pointer | null,
455
+ url: string
456
+ ): JirenResponse<T> {
457
+ if (!respPtr)
458
+ throw new Error("Native request failed (returned null pointer)");
459
+
460
+ try {
461
+ const status = lib.symbols.zclient_response_status(respPtr);
462
+ const len = Number(lib.symbols.zclient_response_body_len(respPtr));
463
+ const bodyPtr = lib.symbols.zclient_response_body(respPtr);
464
+
465
+ const headersLen = Number(
466
+ lib.symbols.zclient_response_headers_len(respPtr)
467
+ );
468
+ let headersObj: Record<string, string> | NativeHeaders = {};
469
+
470
+ if (headersLen > 0) {
471
+ const rawHeadersPtr = lib.symbols.zclient_response_headers(respPtr);
472
+ if (rawHeadersPtr) {
473
+ // Copy headers to JS memory
474
+ // We need to copy because respPtr will be freed
475
+ const rawSrc = toArrayBuffer(rawHeadersPtr, 0, headersLen);
476
+ const raw = new Uint8Array(rawSrc.slice(0)); // Explicit copy
477
+
478
+ headersObj = new NativeHeaders(raw);
479
+ }
480
+ }
481
+
482
+ // Proxy for backward compatibility
483
+ const headersProxy = new Proxy(
484
+ headersObj instanceof NativeHeaders ? headersObj : {},
485
+ {
486
+ get(target, prop) {
487
+ if (target instanceof NativeHeaders && typeof prop === "string") {
488
+ if (prop === "toJSON") return () => target.toJSON();
489
+
490
+ // Try to get from native headers
491
+ const val = target.get(prop);
492
+ if (val !== null) return val;
493
+ }
494
+ return Reflect.get(target, prop);
495
+ },
496
+ }
497
+ ) as unknown as Record<string, string>; // Lie to TS
498
+
499
+ let buffer: ArrayBuffer | Buffer = new ArrayBuffer(0);
500
+ if (len > 0 && bodyPtr) {
501
+ // Create a copy of the buffer because the native response is freed immediately after
502
+ buffer = toArrayBuffer(bodyPtr, 0, len).slice(0);
503
+ }
504
+
505
+ let bodyUsed = false;
506
+ const consumeBody = () => {
507
+ if (bodyUsed) {
508
+ }
509
+ bodyUsed = true;
510
+ };
511
+
512
+ const bodyObj: JirenResponseBody<T> = {
513
+ bodyUsed: false,
514
+ arrayBuffer: async () => {
515
+ consumeBody();
516
+ if (Buffer.isBuffer(buffer)) {
517
+ const buf = buffer as Buffer;
518
+ return buf.buffer.slice(
519
+ buf.byteOffset,
520
+ buf.byteOffset + buf.byteLength
521
+ ) as ArrayBuffer;
522
+ }
523
+ return buffer as ArrayBuffer;
524
+ },
525
+ blob: async () => {
526
+ consumeBody();
527
+ return new Blob([buffer]);
528
+ },
529
+ text: async () => {
530
+ consumeBody();
531
+ return new TextDecoder().decode(buffer);
532
+ },
533
+ json: async <R = T>(): Promise<R> => {
534
+ consumeBody();
535
+ const text = new TextDecoder().decode(buffer);
536
+ return JSON.parse(text);
537
+ },
538
+ };
539
+
540
+ // Update bodyUsed getter to reflect local variable
541
+ Object.defineProperty(bodyObj, "bodyUsed", {
542
+ get: () => bodyUsed,
543
+ });
544
+
545
+ return {
546
+ url,
547
+ status,
548
+ statusText: STATUS_TEXT[status] || "",
549
+ headers: headersProxy,
550
+ ok: status >= 200 && status < 300,
551
+ redirected: false,
552
+ type: "basic",
553
+ body: bodyObj,
554
+ } as JirenResponse<T>;
555
+ } finally {
556
+ lib.symbols.zclient_response_free(respPtr);
557
+ }
558
+ }
559
+ }
560
+
561
+ class NativeHeaders {
562
+ private raw: Uint8Array;
563
+ private len: number;
564
+ private decoder = new TextDecoder();
565
+ private cache = new Map<string, string>();
566
+ // We need a pointer to the raw buffer for FFI calls.
567
+ // Since we can't easily rely on ptr(this.raw) being stable if we stored it,
568
+ // we will pass this.raw to the FFI call directly each time.
569
+
570
+ constructor(raw: Uint8Array) {
571
+ this.raw = raw;
572
+ this.len = raw.byteLength;
573
+ }
574
+
575
+ get(name: string): string | null {
576
+ const target = name.toLowerCase();
577
+ if (this.cache.has(target)) return this.cache.get(target)!;
578
+
579
+ const keyBuf = Buffer.from(target + "\0");
580
+
581
+ // Debug log
582
+ // Pass the raw buffer directly. Bun handles the pointer.
583
+ const resPtr = lib.symbols.z_find_header_value(
584
+ this.raw as any,
585
+ this.len,
586
+ keyBuf
587
+ );
588
+
589
+ if (!resPtr) return null;
590
+
591
+ try {
592
+ // ZHeaderValue: { value_ptr: pointer, value_len: size_t }
593
+ // Assuming 64-bit architecture, pointers and size_t are 8 bytes.
594
+ // Struct size = 16 bytes.
595
+ const view = new DataView(toArrayBuffer(resPtr, 0, 16));
596
+ const valPtr = view.getBigUint64(0, true);
597
+ const valLen = Number(view.getBigUint64(8, true));
598
+
599
+ if (valLen === 0) {
600
+ this.cache.set(target, "");
601
+ return "";
602
+ }
603
+
604
+ // Convert valPtr to ArrayBuffer
605
+ // Note: valPtr points inside this.raw, but toArrayBuffer(ptr) creates a view on that memory.
606
+ const valBytes = toArrayBuffer(Number(valPtr) as any, 0, valLen);
607
+ const val = this.decoder.decode(valBytes);
608
+
609
+ this.cache.set(target, val);
610
+ return val;
611
+ } finally {
612
+ lib.symbols.zclient_header_value_free(resPtr);
613
+ }
614
+ }
615
+
616
+ // Fallback for when full object is needed (e.g. debugging)
617
+ // This is expensive as it reparses everything using the old offset method
618
+ // BUT we don't have the offset method easily available on the raw buffer unless we expose a new one?
619
+ // Wait, `zclient_response_parse_header_offsets` takes `Response*`.
620
+ // We don't have Response* anymore.
621
+ // We need `z_parse_header_offsets_from_raw(ptr, len)`.
622
+ // Or just parse in JS since we have the full buffer?
623
+ // Actually, we can just do a JS parser since we have the buffer.
624
+ // It's a fallback anyway.
625
+ toJSON(): Record<string, string> {
626
+ const obj: Record<string, string> = {};
627
+ const text = this.decoder.decode(this.raw);
628
+ const lines = text.split("\r\n");
629
+ for (const line of lines) {
630
+ if (!line) continue;
631
+ const colon = line.indexOf(":");
632
+ if (colon === -1) continue;
633
+ const key = line.substring(0, colon).trim().toLowerCase();
634
+ const val = line.substring(colon + 1).trim();
635
+ obj[key] = val;
636
+ }
637
+ return obj;
638
+ }
639
+ }
@@ -1,13 +1,23 @@
1
- import { isBun } from "./runtime.js";
1
+ /**
2
+ * Jiren - Ultra-fast HTTP/3 client powered by native Zig + QUIC
3
+ *
4
+ * @packageDocumentation
5
+ */
2
6
 
3
- // Conditionally export the right client
4
- export const JirenClient = isBun
5
- ? (await import("./client-bun.js")).JirenClient
6
- : (await import("./client-node.js")).NodeJirenClient;
7
+ // Main client
8
+ export {
9
+ JirenClient,
10
+ type JirenClientOptions,
11
+ type UrlAccessor,
12
+ } from "./client";
7
13
 
14
+ // Types
8
15
  export type {
9
- HttpResponse,
10
- RequestOptions,
11
- GetRequestOptions,
12
- PostRequestOptions,
13
- } from "../types/index.js";
16
+ JirenHttpConfig,
17
+ ParsedUrl,
18
+ WarmupUrlConfig,
19
+ UrlRequestOptions,
20
+ UrlEndpoint,
21
+ } from "./types";
22
+
23
+ // Remove broken exports