jiren 1.1.1 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,23 +1,347 @@
1
1
  import { CString, toArrayBuffer, type Pointer } from "bun:ffi";
2
2
  import { lib } from "./native";
3
- import type { RequestOptions } from "./types";
3
+ import { ResponseCache } from "./cache";
4
+ import type {
5
+ RequestOptions,
6
+ JirenResponse,
7
+ JirenResponseBody,
8
+ WarmupUrlConfig,
9
+ UrlRequestOptions,
10
+ UrlEndpoint,
11
+ CacheConfig,
12
+ } from "./types";
4
13
 
5
- export interface JirenClientOptions {
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
+ > {
6
38
  /** URLs to warmup on client creation (pre-connect + handshake) */
7
- warmup?: string[];
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
+ * This eliminates the need for 'as const'.
64
+ *
65
+ * @example
66
+ * ```typescript
67
+ * const client = new JirenClient({
68
+ * warmup: defineUrls([
69
+ * { key: "google", url: "https://google.com" },
70
+ * ])
71
+ * });
72
+ * // OR
73
+ * const client = new JirenClient({
74
+ * warmup: {
75
+ * google: "https://google.com"
76
+ * }
77
+ * });
78
+ * ```
79
+ */
80
+ export function defineUrls<const T extends readonly WarmupUrlConfig[]>(
81
+ urls: T
82
+ ): T {
83
+ return urls;
8
84
  }
9
85
 
10
- export class JirenClient {
86
+ export class JirenClient<
87
+ T extends readonly WarmupUrlConfig[] | Record<string, UrlConfig> =
88
+ | readonly WarmupUrlConfig[]
89
+ | Record<string, UrlConfig>
90
+ > {
11
91
  private ptr: Pointer | null;
92
+ private urlMap: Map<string, string> = new Map();
93
+ private cacheConfig: Map<string, { enabled: boolean; ttl: number }> =
94
+ new Map();
95
+ private cache: ResponseCache;
96
+
97
+ /** Type-safe URL accessor for warmed-up URLs */
98
+ public readonly url: UrlAccessor<T>;
12
99
 
13
- constructor(options?: JirenClientOptions) {
100
+ constructor(options?: JirenClientOptions<T>) {
14
101
  this.ptr = lib.symbols.zclient_new();
15
102
  if (!this.ptr) throw new Error("Failed to create native client instance");
16
103
 
17
- // Warmup connections immediately if URLs provided
18
- if (options?.warmup && options.warmup.length > 0) {
19
- this.warmup(options.warmup);
104
+ // Initialize cache
105
+ this.cache = new ResponseCache(100);
106
+
107
+ // Enable benchmark mode if requested
108
+ if (options?.benchmark) {
109
+ lib.symbols.zclient_set_benchmark_mode(this.ptr, true);
110
+ }
111
+
112
+ // Process warmup URLs
113
+ if (options?.warmup) {
114
+ const urls: string[] = [];
115
+ const warmup = options.warmup;
116
+
117
+ if (Array.isArray(warmup)) {
118
+ for (const item of warmup) {
119
+ if (typeof item === "string") {
120
+ urls.push(item);
121
+ } else {
122
+ // WarmupUrlConfig with key and optional cache
123
+ const config = item as WarmupUrlConfig;
124
+ urls.push(config.url);
125
+ this.urlMap.set(config.key, config.url);
126
+
127
+ // Store cache config
128
+ if (config.cache) {
129
+ const cacheConfig =
130
+ typeof config.cache === "boolean"
131
+ ? { enabled: true, ttl: 60000 }
132
+ : { enabled: true, ttl: config.cache.ttl || 60000 };
133
+ this.cacheConfig.set(config.key, cacheConfig);
134
+ }
135
+ }
136
+ }
137
+ } else {
138
+ // Record<string, UrlConfig>
139
+ for (const [key, urlConfig] of Object.entries(warmup)) {
140
+ if (typeof urlConfig === "string") {
141
+ // Simple string URL
142
+ urls.push(urlConfig);
143
+ this.urlMap.set(key, urlConfig);
144
+ } else {
145
+ // URL config object with cache
146
+ urls.push(urlConfig.url);
147
+ this.urlMap.set(key, urlConfig.url);
148
+
149
+ // Store cache config
150
+ if (urlConfig.cache) {
151
+ const cacheConfig =
152
+ typeof urlConfig.cache === "boolean"
153
+ ? { enabled: true, ttl: 60000 }
154
+ : { enabled: true, ttl: urlConfig.cache.ttl || 60000 };
155
+ this.cacheConfig.set(key, cacheConfig);
156
+ }
157
+ }
158
+ }
159
+ }
160
+
161
+ if (urls.length > 0) {
162
+ this.warmup(urls);
163
+ }
20
164
  }
165
+
166
+ // Create proxy for type-safe URL access
167
+ this.url = this.createUrlAccessor();
168
+ }
169
+
170
+ /**
171
+ * Creates a proxy-based URL accessor for type-safe access to warmed-up URLs.
172
+ */
173
+ private createUrlAccessor(): UrlAccessor<T> {
174
+ const self = this;
175
+
176
+ return new Proxy({} as UrlAccessor<T>, {
177
+ get(_target, prop: string) {
178
+ const baseUrl = self.urlMap.get(prop);
179
+ if (!baseUrl) {
180
+ throw new Error(
181
+ `URL key "${prop}" not found. Available keys: ${Array.from(
182
+ self.urlMap.keys()
183
+ ).join(", ")}`
184
+ );
185
+ }
186
+
187
+ // Helper to build full URL with optional path
188
+ const buildUrl = (path?: string) =>
189
+ path
190
+ ? `${baseUrl.replace(/\/$/, "")}/${path.replace(/^\//, "")}`
191
+ : baseUrl;
192
+
193
+ // Return a UrlEndpoint object with all HTTP methods
194
+ return {
195
+ get: async <R = any>(
196
+ options?: UrlRequestOptions
197
+ ): Promise<JirenResponse<R> | R | string | ArrayBuffer | Blob> => {
198
+ // Check if caching is enabled for this URL
199
+ const cacheConfig = self.cacheConfig.get(prop);
200
+
201
+ if (cacheConfig?.enabled) {
202
+ // Try to get from cache
203
+ const cached = self.cache.get(baseUrl, options?.path, options);
204
+ if (cached) {
205
+ return cached as any;
206
+ }
207
+ }
208
+
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
+ );
221
+
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
+ );
235
+ }
236
+
237
+ return response;
238
+ },
239
+
240
+ post: async <R = any>(
241
+ body?: string | null,
242
+ options?: UrlRequestOptions
243
+ ): Promise<JirenResponse<R> | R | string | ArrayBuffer | Blob> => {
244
+ return self.request<R>(
245
+ "POST",
246
+ buildUrl(options?.path),
247
+ body || null,
248
+ {
249
+ headers: options?.headers,
250
+ maxRedirects: options?.maxRedirects,
251
+ responseType: options?.responseType,
252
+ }
253
+ );
254
+ },
255
+
256
+ put: async <R = any>(
257
+ body?: string | null,
258
+ options?: UrlRequestOptions
259
+ ): Promise<JirenResponse<R> | R | string | ArrayBuffer | Blob> => {
260
+ return self.request<R>(
261
+ "PUT",
262
+ buildUrl(options?.path),
263
+ body || null,
264
+ {
265
+ headers: options?.headers,
266
+ maxRedirects: options?.maxRedirects,
267
+ responseType: options?.responseType,
268
+ }
269
+ );
270
+ },
271
+
272
+ patch: async <R = any>(
273
+ body?: string | null,
274
+ options?: UrlRequestOptions
275
+ ): Promise<JirenResponse<R> | R | string | ArrayBuffer | Blob> => {
276
+ return self.request<R>(
277
+ "PATCH",
278
+ buildUrl(options?.path),
279
+ body || null,
280
+ {
281
+ headers: options?.headers,
282
+ maxRedirects: options?.maxRedirects,
283
+ responseType: options?.responseType,
284
+ }
285
+ );
286
+ },
287
+
288
+ delete: async <R = any>(
289
+ body?: string | null,
290
+ options?: UrlRequestOptions
291
+ ): Promise<JirenResponse<R> | R | string | ArrayBuffer | Blob> => {
292
+ return self.request<R>(
293
+ "DELETE",
294
+ buildUrl(options?.path),
295
+ body || null,
296
+ {
297
+ headers: options?.headers,
298
+ maxRedirects: options?.maxRedirects,
299
+ responseType: options?.responseType,
300
+ }
301
+ );
302
+ },
303
+
304
+ head: async (
305
+ options?: UrlRequestOptions
306
+ ): Promise<JirenResponse<any>> => {
307
+ return self.request("HEAD", buildUrl(options?.path), null, {
308
+ headers: options?.headers,
309
+ maxRedirects: options?.maxRedirects,
310
+ antibot: options?.antibot,
311
+ });
312
+ },
313
+
314
+ options: async (
315
+ options?: UrlRequestOptions
316
+ ): Promise<JirenResponse<any>> => {
317
+ return self.request("OPTIONS", buildUrl(options?.path), null, {
318
+ headers: options?.headers,
319
+ maxRedirects: options?.maxRedirects,
320
+ antibot: options?.antibot,
321
+ });
322
+ },
323
+
324
+ /**
325
+ * Prefetch/refresh cache for this URL
326
+ * Clears existing cache and makes a fresh request
327
+ */
328
+ prefetch: async (options?: UrlRequestOptions): Promise<void> => {
329
+ // Clear cache for this URL
330
+ self.cache.clear(baseUrl);
331
+
332
+ // Make fresh request to populate cache
333
+ const cacheConfig = self.cacheConfig.get(prop);
334
+ if (cacheConfig?.enabled) {
335
+ await self.request("GET", buildUrl(options?.path), null, {
336
+ headers: options?.headers,
337
+ maxRedirects: options?.maxRedirects,
338
+ antibot: options?.antibot,
339
+ });
340
+ }
341
+ },
342
+ } as UrlEndpoint;
343
+ },
344
+ });
21
345
  }
22
346
 
23
347
  /**
@@ -32,17 +356,24 @@ export class JirenClient {
32
356
  }
33
357
 
34
358
  /**
35
- * Warm up connections to URLs (DNS resolve + QUIC handshake).
359
+ * Warm up connections to URLs (DNS resolve + QUIC handshake) in parallel.
36
360
  * Call this early (e.g., at app startup) so subsequent requests are fast.
37
361
  * @param urls - List of URLs to warm up
38
362
  */
39
- public warmup(urls: string[]): void {
363
+ public async warmup(urls: string[]): Promise<void> {
40
364
  if (!this.ptr) throw new Error("Client is closed");
41
365
 
42
- for (const url of urls) {
43
- const urlBuffer = Buffer.from(url + "\0");
44
- lib.symbols.zclient_prefetch(this.ptr, urlBuffer);
45
- }
366
+ // Warm up all URLs in parallel for faster startup
367
+ await Promise.all(
368
+ urls.map(
369
+ (url) =>
370
+ new Promise<void>((resolve) => {
371
+ const urlBuffer = Buffer.from(url + "\0");
372
+ lib.symbols.zclient_prefetch(this.ptr, urlBuffer);
373
+ resolve();
374
+ })
375
+ )
376
+ );
46
377
  }
47
378
 
48
379
  /**
@@ -58,27 +389,67 @@ export class JirenClient {
58
389
  * @param url - The URL to request
59
390
  * @param body - The body content string (optional)
60
391
  * @param options - Request options (headers, maxRedirects, etc.) or just headers map
61
- * @returns Promise resolving to Response object
392
+ * @returns Promise resolving to Response object or parsed body
62
393
  */
394
+ public async request<T = any>(
395
+ method: string,
396
+ url: string,
397
+ body?: string | null,
398
+ options?: RequestOptions & { responseType: "json" }
399
+ ): Promise<T>;
400
+ public async request<T = any>(
401
+ method: string,
402
+ url: string,
403
+ body?: string | null,
404
+ options?: RequestOptions & { responseType: "text" }
405
+ ): Promise<string>;
406
+ public async request<T = any>(
407
+ method: string,
408
+ url: string,
409
+ body?: string | null,
410
+ options?: RequestOptions & { responseType: "arraybuffer" }
411
+ ): Promise<ArrayBuffer>;
412
+ public async request<T = any>(
413
+ method: string,
414
+ url: string,
415
+ body?: string | null,
416
+ options?: RequestOptions & { responseType: "blob" }
417
+ ): Promise<Blob>;
418
+ public async request<T = any>(
419
+ method: string,
420
+ url: string,
421
+ body?: string | null,
422
+ options?: RequestOptions
423
+ ): Promise<JirenResponse<T>>;
63
424
  public async request<T = any>(
64
425
  method: string,
65
426
  url: string,
66
427
  body?: string | null,
67
428
  options?: RequestOptions | Record<string, string> | null
68
- ) {
429
+ ): Promise<JirenResponse<T> | T | string | ArrayBuffer | Blob> {
69
430
  if (!this.ptr) throw new Error("Client is closed");
70
431
 
71
432
  // Normalize options
72
433
  let headers: Record<string, string> = {};
73
434
  let maxRedirects = 5; // Default
435
+ let responseType: RequestOptions["responseType"] | undefined;
436
+ let antibot = false; // Default
74
437
 
75
438
  if (options) {
76
- if ("maxRedirects" in options || "headers" in options) {
439
+ if (
440
+ "maxRedirects" in options ||
441
+ "headers" in options ||
442
+ "responseType" in options ||
443
+ "method" in options || // Check for any RequestOptions specific key
444
+ "timeout" in options ||
445
+ "antibot" in options
446
+ ) {
77
447
  // It is RequestOptions
78
448
  const opts = options as RequestOptions;
79
449
  if (opts.headers) headers = opts.headers;
80
450
  if (opts.maxRedirects !== undefined) maxRedirects = opts.maxRedirects;
81
- // Merge top-level unknown keys as headers if lenient? No, strict to types.
451
+ if (opts.responseType) responseType = opts.responseType;
452
+ if (opts.antibot !== undefined) antibot = opts.antibot;
82
453
  } else {
83
454
  // Assume it's just headers Record<string, string> for backward compatibility
84
455
  headers = options as Record<string, string>;
@@ -99,13 +470,52 @@ export class JirenClient {
99
470
  "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",
100
471
  accept:
101
472
  "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8",
102
- "accept-encoding": "gzip, deflate, br",
473
+ "accept-encoding": "gzip",
103
474
  "accept-language": "en-US,en;q=0.9",
475
+ "sec-ch-ua":
476
+ '"Not_A Brand";v="8", "Chromium";v="120", "Google Chrome";v="120"',
477
+ "sec-ch-ua-mobile": "?0",
478
+ "sec-ch-ua-platform": '"macOS"',
479
+ "sec-fetch-dest": "document",
480
+ "sec-fetch-mode": "navigate",
481
+ "sec-fetch-site": "none",
482
+ "sec-fetch-user": "?1",
483
+ "upgrade-insecure-requests": "1",
104
484
  };
105
485
 
106
486
  const finalHeaders = { ...defaultHeaders, ...headers };
107
487
 
108
- const headerStr = Object.entries(finalHeaders)
488
+ // Enforce Chrome header order
489
+ const orderedHeaders: Record<string, string> = {};
490
+ const keys = [
491
+ "sec-ch-ua",
492
+ "sec-ch-ua-mobile",
493
+ "sec-ch-ua-platform",
494
+ "upgrade-insecure-requests",
495
+ "user-agent",
496
+ "accept",
497
+ "sec-fetch-site",
498
+ "sec-fetch-mode",
499
+ "sec-fetch-user",
500
+ "sec-fetch-dest",
501
+ "accept-encoding",
502
+ "accept-language",
503
+ ];
504
+
505
+ // Add priority headers in order
506
+ for (const key of keys) {
507
+ if (finalHeaders[key]) {
508
+ orderedHeaders[key] = finalHeaders[key];
509
+ delete finalHeaders[key];
510
+ }
511
+ }
512
+
513
+ // Add remaining custom headers
514
+ for (const [key, value] of Object.entries(finalHeaders)) {
515
+ orderedHeaders[key] = value;
516
+ }
517
+
518
+ const headerStr = Object.entries(orderedHeaders)
109
519
  .map(([k, v]) => `${k.toLowerCase()}: ${v}`)
110
520
  .join("\r\n");
111
521
 
@@ -119,60 +529,27 @@ export class JirenClient {
119
529
  urlBuffer,
120
530
  headersBuffer,
121
531
  bodyBuffer,
122
- maxRedirects
532
+ maxRedirects,
533
+ antibot
123
534
  );
124
535
 
125
- return this.parseResponse<T>(respPtr);
126
- }
536
+ const response = this.parseResponse<T>(respPtr, url);
127
537
 
128
- public async get<T = any>(
129
- url: string,
130
- options?: RequestOptions | Record<string, string>
131
- ) {
132
- return this.request<T>("GET", url, null, options);
133
- }
134
-
135
- public async post<T = any>(
136
- url: string,
137
- body: string,
138
- options?: RequestOptions | Record<string, string>
139
- ) {
140
- return this.request<T>("POST", url, body, options);
141
- }
142
-
143
- public async put<T = any>(
144
- url: string,
145
- body: string,
146
- options?: RequestOptions | Record<string, string>
147
- ) {
148
- return this.request<T>("PUT", url, body, options);
149
- }
150
-
151
- public async patch<T = any>(
152
- url: string,
153
- body: string,
154
- options?: RequestOptions | Record<string, string>
155
- ) {
156
- return this.request<T>("PATCH", url, body, options);
157
- }
158
-
159
- public async delete<T = any>(
160
- url: string,
161
- body?: string,
162
- options?: RequestOptions | Record<string, string>
163
- ) {
164
- return this.request<T>("DELETE", url, body || null, options);
165
- }
166
-
167
- public async head(url: string, headers?: Record<string, string>) {
168
- return this.request("HEAD", url, null, headers);
169
- }
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();
544
+ }
170
545
 
171
- public async options(url: string, headers?: Record<string, string>) {
172
- return this.request("OPTIONS", url, null, headers);
546
+ return response;
173
547
  }
174
548
 
175
- private parseResponse<T = any>(respPtr: Pointer | null) {
549
+ private parseResponse<T = any>(
550
+ respPtr: Pointer | null,
551
+ url: string
552
+ ): JirenResponse<T> {
176
553
  if (!respPtr)
177
554
  throw new Error("Native request failed (returned null pointer)");
178
555
 
@@ -181,20 +558,178 @@ export class JirenClient {
181
558
  const len = Number(lib.symbols.zclient_response_body_len(respPtr));
182
559
  const bodyPtr = lib.symbols.zclient_response_body(respPtr);
183
560
 
184
- let bodyString = "";
561
+ const headersLen = Number(
562
+ lib.symbols.zclient_response_headers_len(respPtr)
563
+ );
564
+ let headersObj: Record<string, string> | NativeHeaders = {};
565
+
566
+ if (headersLen > 0) {
567
+ const rawHeadersPtr = lib.symbols.zclient_response_headers(respPtr);
568
+ if (rawHeadersPtr) {
569
+ // Copy headers to JS memory
570
+ // We need to copy because respPtr will be freed
571
+ const rawSrc = toArrayBuffer(rawHeadersPtr, 0, headersLen);
572
+ const raw = new Uint8Array(rawSrc.slice(0)); // Explicit copy
573
+
574
+ headersObj = new NativeHeaders(raw);
575
+ }
576
+ }
577
+
578
+ // Proxy for backward compatibility
579
+ const headersProxy = new Proxy(
580
+ headersObj instanceof NativeHeaders ? headersObj : {},
581
+ {
582
+ get(target, prop) {
583
+ if (target instanceof NativeHeaders && typeof prop === "string") {
584
+ if (prop === "toJSON") return () => target.toJSON();
585
+
586
+ // Try to get from native headers
587
+ const val = target.get(prop);
588
+ if (val !== null) return val;
589
+ }
590
+ return Reflect.get(target, prop);
591
+ },
592
+ }
593
+ ) as unknown as Record<string, string>; // Lie to TS
594
+
595
+ let buffer: ArrayBuffer | Buffer = new ArrayBuffer(0);
185
596
  if (len > 0 && bodyPtr) {
186
- const buffer = toArrayBuffer(bodyPtr, 0, len);
187
- bodyString = new TextDecoder().decode(buffer);
597
+ // Create a copy of the buffer because the native response is freed immediately after
598
+ buffer = toArrayBuffer(bodyPtr, 0, len).slice(0);
188
599
  }
189
600
 
601
+ let bodyUsed = false;
602
+ const consumeBody = () => {
603
+ if (bodyUsed) {
604
+ }
605
+ bodyUsed = true;
606
+ };
607
+
608
+ const bodyObj: JirenResponseBody<T> = {
609
+ bodyUsed: false,
610
+ arrayBuffer: async () => {
611
+ consumeBody();
612
+ if (Buffer.isBuffer(buffer)) {
613
+ const buf = buffer as Buffer;
614
+ return buf.buffer.slice(
615
+ buf.byteOffset,
616
+ buf.byteOffset + buf.byteLength
617
+ ) as ArrayBuffer;
618
+ }
619
+ return buffer as ArrayBuffer;
620
+ },
621
+ blob: async () => {
622
+ consumeBody();
623
+ return new Blob([buffer]);
624
+ },
625
+ text: async () => {
626
+ consumeBody();
627
+ return new TextDecoder().decode(buffer);
628
+ },
629
+ json: async <R = T>(): Promise<R> => {
630
+ consumeBody();
631
+ const text = new TextDecoder().decode(buffer);
632
+ return JSON.parse(text);
633
+ },
634
+ };
635
+
636
+ // Update bodyUsed getter to reflect local variable
637
+ Object.defineProperty(bodyObj, "bodyUsed", {
638
+ get: () => bodyUsed,
639
+ });
640
+
190
641
  return {
642
+ url,
191
643
  status,
192
- body: bodyString,
193
- text: async () => bodyString,
194
- json: async (): Promise<T> => JSON.parse(bodyString),
195
- };
644
+ statusText: STATUS_TEXT[status] || "",
645
+ headers: headersProxy,
646
+ ok: status >= 200 && status < 300,
647
+ redirected: false,
648
+ type: "basic",
649
+ body: bodyObj,
650
+ } as JirenResponse<T>;
196
651
  } finally {
197
652
  lib.symbols.zclient_response_free(respPtr);
198
653
  }
199
654
  }
200
655
  }
656
+
657
+ class NativeHeaders {
658
+ private raw: Uint8Array;
659
+ private len: number;
660
+ private decoder = new TextDecoder();
661
+ private cache = new Map<string, string>();
662
+ // We need a pointer to the raw buffer for FFI calls.
663
+ // Since we can't easily rely on ptr(this.raw) being stable if we stored it,
664
+ // we will pass this.raw to the FFI call directly each time.
665
+
666
+ constructor(raw: Uint8Array) {
667
+ this.raw = raw;
668
+ this.len = raw.byteLength;
669
+ }
670
+
671
+ get(name: string): string | null {
672
+ const target = name.toLowerCase();
673
+ if (this.cache.has(target)) return this.cache.get(target)!;
674
+
675
+ const keyBuf = Buffer.from(target + "\0");
676
+
677
+ // Debug log
678
+ // Pass the raw buffer directly. Bun handles the pointer.
679
+ const resPtr = lib.symbols.z_find_header_value(
680
+ this.raw as any,
681
+ this.len,
682
+ keyBuf
683
+ );
684
+
685
+ if (!resPtr) return null;
686
+
687
+ try {
688
+ // ZHeaderValue: { value_ptr: pointer, value_len: size_t }
689
+ // Assuming 64-bit architecture, pointers and size_t are 8 bytes.
690
+ // Struct size = 16 bytes.
691
+ const view = new DataView(toArrayBuffer(resPtr, 0, 16));
692
+ const valPtr = view.getBigUint64(0, true);
693
+ const valLen = Number(view.getBigUint64(8, true));
694
+
695
+ if (valLen === 0) {
696
+ this.cache.set(target, "");
697
+ return "";
698
+ }
699
+
700
+ // Convert valPtr to ArrayBuffer
701
+ // Note: valPtr points inside this.raw, but toArrayBuffer(ptr) creates a view on that memory.
702
+ const valBytes = toArrayBuffer(Number(valPtr) as any, 0, valLen);
703
+ const val = this.decoder.decode(valBytes);
704
+
705
+ this.cache.set(target, val);
706
+ return val;
707
+ } finally {
708
+ lib.symbols.zclient_header_value_free(resPtr);
709
+ }
710
+ }
711
+
712
+ // Fallback for when full object is needed (e.g. debugging)
713
+ // This is expensive as it reparses everything using the old offset method
714
+ // BUT we don't have the offset method easily available on the raw buffer unless we expose a new one?
715
+ // Wait, `zclient_response_parse_header_offsets` takes `Response*`.
716
+ // We don't have Response* anymore.
717
+ // We need `z_parse_header_offsets_from_raw(ptr, len)`.
718
+ // Or just parse in JS since we have the full buffer?
719
+ // Actually, we can just do a JS parser since we have the buffer.
720
+ // It's a fallback anyway.
721
+ toJSON(): Record<string, string> {
722
+ const obj: Record<string, string> = {};
723
+ const text = this.decoder.decode(this.raw);
724
+ const lines = text.split("\r\n");
725
+ for (const line of lines) {
726
+ if (!line) continue;
727
+ const colon = line.indexOf(":");
728
+ if (colon === -1) continue;
729
+ const key = line.substring(0, colon).trim().toLowerCase();
730
+ const val = line.substring(colon + 1).trim();
731
+ obj[key] = val;
732
+ }
733
+ return obj;
734
+ }
735
+ }