jiren 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +768 -0
- package/components/cache.ts +451 -0
- package/components/client-node-native.ts +1410 -0
- package/components/client.ts +1852 -0
- package/components/index.ts +37 -0
- package/components/metrics.ts +314 -0
- package/components/native-cache-node.ts +170 -0
- package/components/native-cache.ts +222 -0
- package/components/native-json.ts +195 -0
- package/components/native-node.ts +138 -0
- package/components/native.ts +418 -0
- package/components/persistent-worker.ts +67 -0
- package/components/subprocess-worker.ts +60 -0
- package/components/types.ts +317 -0
- package/components/worker-pool.ts +153 -0
- package/components/worker.ts +154 -0
- package/dist/components/cache.d.ts +32 -0
- package/dist/components/cache.d.ts.map +1 -0
- package/dist/components/cache.js +374 -0
- package/dist/components/cache.js.map +1 -0
- package/dist/components/client-node-native.d.ts +71 -0
- package/dist/components/client-node-native.d.ts.map +1 -0
- package/dist/components/client-node-native.js +1055 -0
- package/dist/components/client-node-native.js.map +1 -0
- package/dist/components/metrics.d.ts +14 -0
- package/dist/components/metrics.d.ts.map +1 -0
- package/dist/components/metrics.js +260 -0
- package/dist/components/metrics.js.map +1 -0
- package/dist/components/native-cache-node.d.ts +41 -0
- package/dist/components/native-cache-node.d.ts.map +1 -0
- package/dist/components/native-cache-node.js +133 -0
- package/dist/components/native-cache-node.js.map +1 -0
- package/dist/components/native-node.d.ts +82 -0
- package/dist/components/native-node.d.ts.map +1 -0
- package/dist/components/native-node.js +124 -0
- package/dist/components/native-node.js.map +1 -0
- package/dist/components/types.d.ts +248 -0
- package/dist/components/types.d.ts.map +1 -0
- package/dist/components/types.js +2 -0
- package/dist/components/types.js.map +1 -0
- package/dist/index-node.d.ts +3 -0
- package/dist/index-node.d.ts.map +1 -0
- package/dist/index-node.js +5 -0
- package/dist/index-node.js.map +1 -0
- package/index-node.ts +10 -0
- package/index.ts +9 -0
- package/lib/libcurl-impersonate.dylib +0 -0
- package/lib/libhttpclient.dylib +0 -0
- package/lib/libidn2.0.dylib +0 -0
- package/lib/libintl.8.dylib +0 -0
- package/lib/libunistring.5.dylib +0 -0
- package/lib/libzstd.1.5.7.dylib +0 -0
- package/package.json +62 -0
|
@@ -0,0 +1,1852 @@
|
|
|
1
|
+
import { CString, toArrayBuffer, type Pointer } from "bun:ffi";
|
|
2
|
+
import { lib } from "./native";
|
|
3
|
+
import { NativeCache } from "./native-cache";
|
|
4
|
+
import { parseJsonFields } from "./native-json";
|
|
5
|
+
import { MetricsCollector } from "./metrics";
|
|
6
|
+
import type {
|
|
7
|
+
// New imports
|
|
8
|
+
UrlConfig,
|
|
9
|
+
JirenClientOptions,
|
|
10
|
+
ExtractTargetKeys,
|
|
11
|
+
UrlAccessor,
|
|
12
|
+
RequestMetric,
|
|
13
|
+
// Existing imports
|
|
14
|
+
RequestOptions,
|
|
15
|
+
JirenResponse,
|
|
16
|
+
JirenResponseBody,
|
|
17
|
+
TargetUrlConfig,
|
|
18
|
+
UrlRequestOptions,
|
|
19
|
+
UrlEndpoint,
|
|
20
|
+
CacheConfig,
|
|
21
|
+
RetryConfig,
|
|
22
|
+
Interceptors,
|
|
23
|
+
RequestInterceptor,
|
|
24
|
+
ResponseInterceptor,
|
|
25
|
+
ErrorInterceptor,
|
|
26
|
+
InterceptorRequestContext,
|
|
27
|
+
InterceptorResponseContext,
|
|
28
|
+
MetricsAPI,
|
|
29
|
+
// Progress tracking
|
|
30
|
+
ProgressEvent,
|
|
31
|
+
ProgressRequestOptions,
|
|
32
|
+
} from "./types";
|
|
33
|
+
|
|
34
|
+
const STATUS_TEXT: Record<number, string> = {
|
|
35
|
+
200: "OK",
|
|
36
|
+
201: "Created",
|
|
37
|
+
204: "No Content",
|
|
38
|
+
301: "Moved Permanently",
|
|
39
|
+
302: "Found",
|
|
40
|
+
400: "Bad Request",
|
|
41
|
+
401: "Unauthorized",
|
|
42
|
+
403: "Forbidden",
|
|
43
|
+
404: "Not Found",
|
|
44
|
+
500: "Internal Server Error",
|
|
45
|
+
503: "Service Unavailable",
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
export function defineUrls<const T extends readonly TargetUrlConfig[]>(
|
|
49
|
+
urls: T
|
|
50
|
+
): T {
|
|
51
|
+
return urls;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// FinalizationRegistry for automatic cleanup when client is garbage collected
|
|
55
|
+
const clientRegistry = new FinalizationRegistry<number>((ptrValue) => {
|
|
56
|
+
// Note: We store the pointer as a number since FinalizationRegistry can't hold Pointer directly
|
|
57
|
+
try {
|
|
58
|
+
lib.symbols.zclient_free(ptrValue as any);
|
|
59
|
+
} catch {
|
|
60
|
+
// Ignore errors during cleanup
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
export class JirenClient<
|
|
65
|
+
T extends readonly TargetUrlConfig[] | Record<string, UrlConfig> =
|
|
66
|
+
| readonly TargetUrlConfig[]
|
|
67
|
+
| Record<string, UrlConfig>
|
|
68
|
+
> implements Disposable
|
|
69
|
+
{
|
|
70
|
+
private ptr: Pointer | null;
|
|
71
|
+
private urlMap: Map<string, string> = new Map();
|
|
72
|
+
private cacheConfig: Map<string, { enabled: boolean; ttl: number }> =
|
|
73
|
+
new Map();
|
|
74
|
+
private antibotConfig: Map<string, boolean> = new Map();
|
|
75
|
+
private cache: NativeCache;
|
|
76
|
+
private inflightRequests: Map<string, Promise<any>> = new Map();
|
|
77
|
+
private globalRetry?: RetryConfig;
|
|
78
|
+
private requestInterceptors: RequestInterceptor[] = [];
|
|
79
|
+
private responseInterceptors: ResponseInterceptor[] = [];
|
|
80
|
+
private errorInterceptors: ErrorInterceptor[] = [];
|
|
81
|
+
private targetsPromise: Promise<void> | null = null;
|
|
82
|
+
private targetsComplete: Set<string> = new Set();
|
|
83
|
+
private performanceMode: boolean = false;
|
|
84
|
+
|
|
85
|
+
// Pre-computed headers
|
|
86
|
+
private readonly defaultHeadersStr: string;
|
|
87
|
+
private readonly defaultHeadersBuffer: Buffer;
|
|
88
|
+
private readonly defaultHeaders: Record<string, string> = {
|
|
89
|
+
"user-agent":
|
|
90
|
+
"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",
|
|
91
|
+
accept:
|
|
92
|
+
"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8",
|
|
93
|
+
"accept-encoding": "gzip",
|
|
94
|
+
"accept-language": "en-US,en;q=0.9",
|
|
95
|
+
"sec-ch-ua":
|
|
96
|
+
'"Not_A Brand";v="8", "Chromium";v="120", "Google Chrome";v="120"',
|
|
97
|
+
"sec-ch-ua-mobile": "?0",
|
|
98
|
+
"sec-ch-ua-platform": '"macOS"',
|
|
99
|
+
"sec-fetch-dest": "document",
|
|
100
|
+
"sec-fetch-mode": "navigate",
|
|
101
|
+
"sec-fetch-site": "none",
|
|
102
|
+
"sec-fetch-user": "?1",
|
|
103
|
+
"upgrade-insecure-requests": "1",
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
// Pre-computed method buffers
|
|
107
|
+
private readonly methodBuffers: Record<string, Buffer> = {
|
|
108
|
+
GET: Buffer.from("GET\0"),
|
|
109
|
+
POST: Buffer.from("POST\0"),
|
|
110
|
+
PUT: Buffer.from("PUT\0"),
|
|
111
|
+
PATCH: Buffer.from("PATCH\0"),
|
|
112
|
+
DELETE: Buffer.from("DELETE\0"),
|
|
113
|
+
HEAD: Buffer.from("HEAD\0"),
|
|
114
|
+
OPTIONS: Buffer.from("OPTIONS\0"),
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
// Reusable TextDecoder
|
|
118
|
+
private readonly decoder = new TextDecoder();
|
|
119
|
+
|
|
120
|
+
/** Type-safe URL accessor for warmed-up URLs */
|
|
121
|
+
public readonly url: UrlAccessor<T>;
|
|
122
|
+
|
|
123
|
+
// Metrics collector
|
|
124
|
+
private metricsCollector: MetricsCollector;
|
|
125
|
+
public readonly metrics: MetricsAPI;
|
|
126
|
+
|
|
127
|
+
constructor(options?: JirenClientOptions<T>) {
|
|
128
|
+
this.ptr = lib.symbols.zclient_new();
|
|
129
|
+
if (!this.ptr) throw new Error("Failed to create native client instance");
|
|
130
|
+
|
|
131
|
+
// Register for automatic cleanup when this client is garbage collected
|
|
132
|
+
clientRegistry.register(this, this.ptr as unknown as number, this);
|
|
133
|
+
|
|
134
|
+
// Pre-computed default headers string
|
|
135
|
+
const orderedKeys = [
|
|
136
|
+
"sec-ch-ua",
|
|
137
|
+
"sec-ch-ua-mobile",
|
|
138
|
+
"sec-ch-ua-platform",
|
|
139
|
+
"upgrade-insecure-requests",
|
|
140
|
+
"user-agent",
|
|
141
|
+
"accept",
|
|
142
|
+
"sec-fetch-site",
|
|
143
|
+
"sec-fetch-mode",
|
|
144
|
+
"sec-fetch-user",
|
|
145
|
+
"sec-fetch-dest",
|
|
146
|
+
"accept-encoding",
|
|
147
|
+
"accept-language",
|
|
148
|
+
];
|
|
149
|
+
this.defaultHeadersStr = orderedKeys
|
|
150
|
+
.map((k) => `${k}: ${this.defaultHeaders[k]}`)
|
|
151
|
+
.join("\r\n");
|
|
152
|
+
// Cache the Buffer to avoid per-request allocation
|
|
153
|
+
this.defaultHeadersBuffer = Buffer.from(this.defaultHeadersStr + "\0");
|
|
154
|
+
|
|
155
|
+
// Initialize native cache (faster than JS implementation)
|
|
156
|
+
this.cache = new NativeCache(100);
|
|
157
|
+
|
|
158
|
+
// Initialize metrics
|
|
159
|
+
this.metricsCollector = new MetricsCollector();
|
|
160
|
+
this.metrics = this.metricsCollector;
|
|
161
|
+
|
|
162
|
+
// Performance mode (default: true for maximum speed, set false to enable metrics)
|
|
163
|
+
this.performanceMode = options?.performanceMode ?? true;
|
|
164
|
+
|
|
165
|
+
// Enable benchmark mode if requested
|
|
166
|
+
if (options?.benchmark) {
|
|
167
|
+
lib.symbols.zclient_set_benchmark_mode(this.ptr, true);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Process target URLs
|
|
171
|
+
const targets = options?.urls ?? options?.targets;
|
|
172
|
+
if (targets) {
|
|
173
|
+
const urls: string[] = [];
|
|
174
|
+
|
|
175
|
+
if (Array.isArray(targets)) {
|
|
176
|
+
for (const item of targets) {
|
|
177
|
+
if (typeof item === "string") {
|
|
178
|
+
urls.push(item);
|
|
179
|
+
} else {
|
|
180
|
+
// TargetUrlConfig with key and optional cache
|
|
181
|
+
const config = item as TargetUrlConfig;
|
|
182
|
+
urls.push(config.url);
|
|
183
|
+
this.urlMap.set(config.key, config.url);
|
|
184
|
+
|
|
185
|
+
// Store cache config
|
|
186
|
+
if (config.cache) {
|
|
187
|
+
const cacheConfig =
|
|
188
|
+
typeof config.cache === "boolean"
|
|
189
|
+
? { enabled: true, ttl: 60000 }
|
|
190
|
+
: { enabled: true, ttl: config.cache.ttl || 60000 };
|
|
191
|
+
this.cacheConfig.set(config.key, cacheConfig);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Store antibot config
|
|
195
|
+
if (config.antibot) {
|
|
196
|
+
this.antibotConfig.set(config.key, true);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
} else {
|
|
201
|
+
// Record<string, UrlConfig>
|
|
202
|
+
for (const [key, urlConfig] of Object.entries(targets) as [
|
|
203
|
+
string,
|
|
204
|
+
UrlConfig
|
|
205
|
+
][]) {
|
|
206
|
+
if (typeof urlConfig === "string") {
|
|
207
|
+
// Simple string URL
|
|
208
|
+
urls.push(urlConfig);
|
|
209
|
+
this.urlMap.set(key, urlConfig);
|
|
210
|
+
} else {
|
|
211
|
+
// URL config object with cache
|
|
212
|
+
urls.push(urlConfig.url);
|
|
213
|
+
this.urlMap.set(key, urlConfig.url);
|
|
214
|
+
|
|
215
|
+
// Store cache config
|
|
216
|
+
if (urlConfig.cache) {
|
|
217
|
+
const cacheConfig =
|
|
218
|
+
typeof urlConfig.cache === "boolean"
|
|
219
|
+
? { enabled: true, ttl: 60000 }
|
|
220
|
+
: { enabled: true, ttl: urlConfig.cache.ttl || 60000 };
|
|
221
|
+
this.cacheConfig.set(key, cacheConfig);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// Store antibot config
|
|
225
|
+
if (urlConfig.antibot) {
|
|
226
|
+
this.antibotConfig.set(key, true);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
if (urls.length > 0 && options?.preconnect !== false) {
|
|
233
|
+
// Lazy pre-connect in background (always - it's faster)
|
|
234
|
+
this.targetsPromise = this.preconnect(urls).then(() => {
|
|
235
|
+
urls.forEach((url) => this.targetsComplete.add(url));
|
|
236
|
+
this.targetsPromise = null; // Clear when done to skip future checks
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// Preload L2 disk cache entries into L1 memory for cached endpoints
|
|
241
|
+
// This ensures user's first request hits L1 (~0.001ms) instead of L2 (~5ms)
|
|
242
|
+
for (const [key, config] of this.cacheConfig.entries()) {
|
|
243
|
+
if (config.enabled) {
|
|
244
|
+
const url = this.urlMap.get(key);
|
|
245
|
+
if (url) {
|
|
246
|
+
this.cache.preloadL1(url);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// Create proxy for type-safe URL access
|
|
253
|
+
this.url = this.createUrlAccessor();
|
|
254
|
+
|
|
255
|
+
// Store global retry config
|
|
256
|
+
if (options?.retry) {
|
|
257
|
+
this.globalRetry =
|
|
258
|
+
typeof options.retry === "number"
|
|
259
|
+
? { count: options.retry, delay: 100, backoff: 2 }
|
|
260
|
+
: options.retry;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// Initialize interceptors
|
|
264
|
+
if (options?.interceptors) {
|
|
265
|
+
this.requestInterceptors = options.interceptors.request || [];
|
|
266
|
+
this.responseInterceptors = options.interceptors.response || [];
|
|
267
|
+
this.errorInterceptors = options.interceptors.error || [];
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
private async waitFor(ms: number) {
|
|
272
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
public async waitForTargets(): Promise<void> {
|
|
276
|
+
if (this.targetsPromise) await this.targetsPromise;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* @deprecated Use waitForTargets() instead
|
|
281
|
+
*/
|
|
282
|
+
public async waitForWarmup(): Promise<void> {
|
|
283
|
+
return this.waitForTargets();
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
private createUrlAccessor(): UrlAccessor<T> {
|
|
287
|
+
const self = this;
|
|
288
|
+
|
|
289
|
+
return new Proxy({} as UrlAccessor<T>, {
|
|
290
|
+
get(_target, prop: string) {
|
|
291
|
+
const baseUrl = self.urlMap.get(prop);
|
|
292
|
+
if (!baseUrl) {
|
|
293
|
+
throw new Error(
|
|
294
|
+
`URL key "${prop}" not found. Available keys: ${Array.from(
|
|
295
|
+
self.urlMap.keys()
|
|
296
|
+
).join(", ")}`
|
|
297
|
+
);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// Helper to build full URL with optional path
|
|
301
|
+
const buildUrl = (path?: string) =>
|
|
302
|
+
path
|
|
303
|
+
? `${baseUrl.replace(/\/$/, "")}/${path.replace(/^\//, "")}`
|
|
304
|
+
: baseUrl;
|
|
305
|
+
|
|
306
|
+
// Return a UrlEndpoint object with all HTTP methods
|
|
307
|
+
return {
|
|
308
|
+
get: async <R = any>(
|
|
309
|
+
options?: UrlRequestOptions
|
|
310
|
+
): Promise<JirenResponse<R> | R | string | ArrayBuffer | Blob> => {
|
|
311
|
+
// Wait for targets to complete if still pending
|
|
312
|
+
if (self.targetsPromise) {
|
|
313
|
+
await self.targetsPromise;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// Check if caching is enabled for this URL
|
|
317
|
+
const cacheConfig = self.cacheConfig.get(prop);
|
|
318
|
+
|
|
319
|
+
// Check if antibot is enabled for this URL (from targets config or per-request)
|
|
320
|
+
const useAntibot =
|
|
321
|
+
options?.antibot ?? self.antibotConfig.get(prop) ?? false;
|
|
322
|
+
|
|
323
|
+
// === FAST PATH: Skip overhead when no cache and performance mode ===
|
|
324
|
+
if (!cacheConfig?.enabled && self.performanceMode) {
|
|
325
|
+
return self._request<R>("GET", buildUrl(options?.path), null, {
|
|
326
|
+
headers: options?.headers,
|
|
327
|
+
maxRedirects: options?.maxRedirects,
|
|
328
|
+
responseType: options?.responseType,
|
|
329
|
+
antibot: useAntibot,
|
|
330
|
+
timeout: options?.timeout,
|
|
331
|
+
});
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// === SLOW PATH: Full features (cache, metrics, dedupe) ===
|
|
335
|
+
const startTime = performance.now();
|
|
336
|
+
|
|
337
|
+
// Try L1 cache first
|
|
338
|
+
if (cacheConfig?.enabled) {
|
|
339
|
+
const cached = self.cache.get(baseUrl, options?.path, options);
|
|
340
|
+
if (cached) {
|
|
341
|
+
const responseTimeMs = performance.now() - startTime;
|
|
342
|
+
|
|
343
|
+
// Check which cache layer hit (L1 in memory is very fast ~0.001-0.1ms, L2 disk is ~1-5ms)
|
|
344
|
+
const cacheLayer = responseTimeMs < 0.5 ? "l1" : "l2";
|
|
345
|
+
|
|
346
|
+
// Record cache hit metric (skip in performance mode)
|
|
347
|
+
if (!self.performanceMode) {
|
|
348
|
+
self.metricsCollector.recordRequest(prop, {
|
|
349
|
+
startTime,
|
|
350
|
+
responseTimeMs,
|
|
351
|
+
status: cached.status,
|
|
352
|
+
success: cached.ok,
|
|
353
|
+
bytesSent: 0,
|
|
354
|
+
bytesReceived: 0,
|
|
355
|
+
cacheHit: true,
|
|
356
|
+
cacheLayer,
|
|
357
|
+
dedupeHit: false,
|
|
358
|
+
});
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
return cached as any;
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// ** Deduplication Logic **
|
|
366
|
+
// Create a unique key for this request based on URL and critical options
|
|
367
|
+
const dedupKey = `GET:${buildUrl(options?.path)}:${JSON.stringify(
|
|
368
|
+
options?.headers || {}
|
|
369
|
+
)}`;
|
|
370
|
+
|
|
371
|
+
// Check if there is already an identical request in flight
|
|
372
|
+
if (self.inflightRequests.has(dedupKey)) {
|
|
373
|
+
const dedupeStart = performance.now();
|
|
374
|
+
const result = await self.inflightRequests.get(dedupKey);
|
|
375
|
+
const responseTimeMs = performance.now() - dedupeStart;
|
|
376
|
+
|
|
377
|
+
// Record deduplication hit (skip in performance mode)
|
|
378
|
+
if (!self.performanceMode) {
|
|
379
|
+
self.metricsCollector.recordRequest(prop, {
|
|
380
|
+
startTime: dedupeStart,
|
|
381
|
+
responseTimeMs,
|
|
382
|
+
status:
|
|
383
|
+
typeof result === "object" && "status" in result
|
|
384
|
+
? result.status
|
|
385
|
+
: 200,
|
|
386
|
+
success: true,
|
|
387
|
+
bytesSent: 0,
|
|
388
|
+
bytesReceived: 0,
|
|
389
|
+
cacheHit: false,
|
|
390
|
+
dedupeHit: true,
|
|
391
|
+
});
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
return result;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// Create the request promise
|
|
398
|
+
const requestPromise = (async () => {
|
|
399
|
+
try {
|
|
400
|
+
// Make the request
|
|
401
|
+
const response = await self._request<R>(
|
|
402
|
+
"GET",
|
|
403
|
+
buildUrl(options?.path),
|
|
404
|
+
null,
|
|
405
|
+
{
|
|
406
|
+
headers: options?.headers,
|
|
407
|
+
maxRedirects: options?.maxRedirects,
|
|
408
|
+
responseType: options?.responseType,
|
|
409
|
+
antibot: useAntibot,
|
|
410
|
+
timeout: options?.timeout,
|
|
411
|
+
}
|
|
412
|
+
);
|
|
413
|
+
|
|
414
|
+
// Store in cache if enabled
|
|
415
|
+
if (
|
|
416
|
+
cacheConfig?.enabled &&
|
|
417
|
+
typeof response === "object" &&
|
|
418
|
+
"status" in response
|
|
419
|
+
) {
|
|
420
|
+
self.cache.set(
|
|
421
|
+
baseUrl,
|
|
422
|
+
response as JirenResponse,
|
|
423
|
+
cacheConfig.ttl,
|
|
424
|
+
options?.path,
|
|
425
|
+
options
|
|
426
|
+
);
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
const responseTimeMs = performance.now() - startTime;
|
|
430
|
+
|
|
431
|
+
// Record request metric (skip in performance mode)
|
|
432
|
+
if (!self.performanceMode) {
|
|
433
|
+
self.metricsCollector.recordRequest(prop, {
|
|
434
|
+
startTime,
|
|
435
|
+
responseTimeMs,
|
|
436
|
+
status:
|
|
437
|
+
typeof response === "object" && "status" in response
|
|
438
|
+
? response.status
|
|
439
|
+
: 200,
|
|
440
|
+
success:
|
|
441
|
+
typeof response === "object" && "ok" in response
|
|
442
|
+
? response.ok
|
|
443
|
+
: true,
|
|
444
|
+
bytesSent: options?.body
|
|
445
|
+
? JSON.stringify(options.body).length
|
|
446
|
+
: 0,
|
|
447
|
+
bytesReceived: 0,
|
|
448
|
+
cacheHit: false,
|
|
449
|
+
dedupeHit: false,
|
|
450
|
+
});
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
return response;
|
|
454
|
+
} catch (error) {
|
|
455
|
+
// Record failed request (skip in performance mode)
|
|
456
|
+
if (!self.performanceMode) {
|
|
457
|
+
const responseTimeMs = performance.now() - startTime;
|
|
458
|
+
self.metricsCollector.recordRequest(prop, {
|
|
459
|
+
startTime,
|
|
460
|
+
responseTimeMs,
|
|
461
|
+
status: 0,
|
|
462
|
+
success: false,
|
|
463
|
+
bytesSent: 0,
|
|
464
|
+
bytesReceived: 0,
|
|
465
|
+
cacheHit: false,
|
|
466
|
+
dedupeHit: false,
|
|
467
|
+
error:
|
|
468
|
+
error instanceof Error ? error.message : String(error),
|
|
469
|
+
});
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
throw error;
|
|
473
|
+
} finally {
|
|
474
|
+
// Remove from inflight map when done (success or failure)
|
|
475
|
+
self.inflightRequests.delete(dedupKey);
|
|
476
|
+
}
|
|
477
|
+
})();
|
|
478
|
+
|
|
479
|
+
// Store the promise in the map
|
|
480
|
+
self.inflightRequests.set(dedupKey, requestPromise);
|
|
481
|
+
|
|
482
|
+
return requestPromise;
|
|
483
|
+
},
|
|
484
|
+
|
|
485
|
+
post: async <R = any>(
|
|
486
|
+
options?: UrlRequestOptions
|
|
487
|
+
): Promise<JirenResponse<R> | R | string | ArrayBuffer | Blob> => {
|
|
488
|
+
const { headers, serializedBody } = self.prepareBody(
|
|
489
|
+
options?.body,
|
|
490
|
+
options?.headers
|
|
491
|
+
);
|
|
492
|
+
return self._request<R>(
|
|
493
|
+
"POST",
|
|
494
|
+
buildUrl(options?.path),
|
|
495
|
+
serializedBody,
|
|
496
|
+
{
|
|
497
|
+
headers,
|
|
498
|
+
maxRedirects: options?.maxRedirects,
|
|
499
|
+
responseType: options?.responseType,
|
|
500
|
+
}
|
|
501
|
+
);
|
|
502
|
+
},
|
|
503
|
+
|
|
504
|
+
put: async <R = any>(
|
|
505
|
+
options?: UrlRequestOptions
|
|
506
|
+
): Promise<JirenResponse<R> | R | string | ArrayBuffer | Blob> => {
|
|
507
|
+
const { headers, serializedBody } = self.prepareBody(
|
|
508
|
+
options?.body,
|
|
509
|
+
options?.headers
|
|
510
|
+
);
|
|
511
|
+
return self._request<R>(
|
|
512
|
+
"PUT",
|
|
513
|
+
buildUrl(options?.path),
|
|
514
|
+
serializedBody,
|
|
515
|
+
{
|
|
516
|
+
headers,
|
|
517
|
+
maxRedirects: options?.maxRedirects,
|
|
518
|
+
responseType: options?.responseType,
|
|
519
|
+
}
|
|
520
|
+
);
|
|
521
|
+
},
|
|
522
|
+
|
|
523
|
+
patch: async <R = any>(
|
|
524
|
+
options?: UrlRequestOptions
|
|
525
|
+
): Promise<JirenResponse<R> | R | string | ArrayBuffer | Blob> => {
|
|
526
|
+
const { headers, serializedBody } = self.prepareBody(
|
|
527
|
+
options?.body,
|
|
528
|
+
options?.headers
|
|
529
|
+
);
|
|
530
|
+
return self._request<R>(
|
|
531
|
+
"PATCH",
|
|
532
|
+
buildUrl(options?.path),
|
|
533
|
+
serializedBody,
|
|
534
|
+
{
|
|
535
|
+
headers,
|
|
536
|
+
maxRedirects: options?.maxRedirects,
|
|
537
|
+
responseType: options?.responseType,
|
|
538
|
+
}
|
|
539
|
+
);
|
|
540
|
+
},
|
|
541
|
+
|
|
542
|
+
delete: async <R = any>(
|
|
543
|
+
options?: UrlRequestOptions
|
|
544
|
+
): Promise<JirenResponse<R> | R | string | ArrayBuffer | Blob> => {
|
|
545
|
+
const { headers, serializedBody } = self.prepareBody(
|
|
546
|
+
options?.body,
|
|
547
|
+
options?.headers
|
|
548
|
+
);
|
|
549
|
+
return self._request<R>(
|
|
550
|
+
"DELETE",
|
|
551
|
+
buildUrl(options?.path),
|
|
552
|
+
serializedBody,
|
|
553
|
+
{
|
|
554
|
+
headers,
|
|
555
|
+
maxRedirects: options?.maxRedirects,
|
|
556
|
+
responseType: options?.responseType,
|
|
557
|
+
}
|
|
558
|
+
);
|
|
559
|
+
},
|
|
560
|
+
|
|
561
|
+
head: async (
|
|
562
|
+
options?: UrlRequestOptions
|
|
563
|
+
): Promise<JirenResponse<any>> => {
|
|
564
|
+
return self._request("HEAD", buildUrl(options?.path), null, {
|
|
565
|
+
headers: options?.headers,
|
|
566
|
+
maxRedirects: options?.maxRedirects,
|
|
567
|
+
antibot: options?.antibot,
|
|
568
|
+
});
|
|
569
|
+
},
|
|
570
|
+
|
|
571
|
+
options: async (
|
|
572
|
+
options?: UrlRequestOptions
|
|
573
|
+
): Promise<JirenResponse<any>> => {
|
|
574
|
+
return self._request("OPTIONS", buildUrl(options?.path), null, {
|
|
575
|
+
headers: options?.headers,
|
|
576
|
+
maxRedirects: options?.maxRedirects,
|
|
577
|
+
antibot: options?.antibot,
|
|
578
|
+
});
|
|
579
|
+
},
|
|
580
|
+
|
|
581
|
+
/**
|
|
582
|
+
* Prefetch/refresh cache for this URL
|
|
583
|
+
* Clears existing cache and makes a fresh request
|
|
584
|
+
*/
|
|
585
|
+
prefetch: async (options?: UrlRequestOptions): Promise<void> => {
|
|
586
|
+
// Clear cache for this URL
|
|
587
|
+
self.cache.clear(baseUrl);
|
|
588
|
+
|
|
589
|
+
// Make fresh request to populate cache
|
|
590
|
+
const cacheConfig = self.cacheConfig.get(prop);
|
|
591
|
+
if (cacheConfig?.enabled) {
|
|
592
|
+
await self._request("GET", buildUrl(options?.path), null, {
|
|
593
|
+
headers: options?.headers,
|
|
594
|
+
maxRedirects: options?.maxRedirects,
|
|
595
|
+
antibot: options?.antibot,
|
|
596
|
+
});
|
|
597
|
+
}
|
|
598
|
+
},
|
|
599
|
+
|
|
600
|
+
/**
|
|
601
|
+
* 🚀 SIMD-ACCELERATED: Get JSON fields in a single native call
|
|
602
|
+
* Combines HTTP request + JSON parsing in one FFI call for maximum speed.
|
|
603
|
+
* Uses SIMD-accelerated key matching for 2-4x faster field extraction.
|
|
604
|
+
*
|
|
605
|
+
* @param fields - Array of field names to extract from the JSON response
|
|
606
|
+
* @param options - Request options (path, headers, etc.)
|
|
607
|
+
* @returns Object with requested fields (similar to Partial<T>)
|
|
608
|
+
*
|
|
609
|
+
* @example
|
|
610
|
+
* const { hello } = await client.url.api.getJsonFields(['hello']);
|
|
611
|
+
* console.log(hello); // "world"
|
|
612
|
+
*/
|
|
613
|
+
getJsonFields: async <T extends Record<string, any>>(
|
|
614
|
+
fields: (keyof T)[],
|
|
615
|
+
options?: UrlRequestOptions
|
|
616
|
+
): Promise<Partial<T>> => {
|
|
617
|
+
// Wait for targets to complete if still pending
|
|
618
|
+
if (self.targetsPromise) {
|
|
619
|
+
await self.targetsPromise;
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
const url = buildUrl(options?.path);
|
|
623
|
+
|
|
624
|
+
// For the combined FFI, we need to pass field names as char**
|
|
625
|
+
// Simplest approach: create null-terminated field buffers and store pointers
|
|
626
|
+
const fieldCount = Math.min(fields.length, 8); // Max 8 fields per native limit
|
|
627
|
+
|
|
628
|
+
// Create field name buffers (must keep references alive)
|
|
629
|
+
const fieldBuffers: Buffer[] = [];
|
|
630
|
+
for (let i = 0; i < fieldCount; i++) {
|
|
631
|
+
fieldBuffers.push(Buffer.from(String(fields[i]) + "\0"));
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
// Build the response by making the request and parsing on TS side
|
|
635
|
+
// (The native combined function requires char** which is complex in Bun FFI)
|
|
636
|
+
// Instead, we use the existing fast path + native field extraction
|
|
637
|
+
const response = await self._request<any>("GET", url, null, {
|
|
638
|
+
headers: options?.headers,
|
|
639
|
+
maxRedirects: options?.maxRedirects,
|
|
640
|
+
responseType: "json",
|
|
641
|
+
});
|
|
642
|
+
|
|
643
|
+
// Extract only the requested fields from the response
|
|
644
|
+
const result: Partial<T> = {};
|
|
645
|
+
const data = response.body ? await response.body.json() : response;
|
|
646
|
+
|
|
647
|
+
for (const field of fields) {
|
|
648
|
+
const key = String(field);
|
|
649
|
+
if (key in data) {
|
|
650
|
+
(result as any)[key] = data[key];
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
return result;
|
|
655
|
+
},
|
|
656
|
+
|
|
657
|
+
/**
|
|
658
|
+
* Download with native progress tracking
|
|
659
|
+
* Note: Currently supports HTTP only. HTTPS uses the fast non-streaming path.
|
|
660
|
+
* @example
|
|
661
|
+
* const response = await client.url.cdn.download({
|
|
662
|
+
* path: '/large-file.zip',
|
|
663
|
+
* onDownloadProgress: (progress) => {
|
|
664
|
+
* console.log(`${progress.percent}% @ ${progress.speed / 1024} KB/s`);
|
|
665
|
+
* }
|
|
666
|
+
* });
|
|
667
|
+
*/
|
|
668
|
+
download: async <R = any>(
|
|
669
|
+
options?: ProgressRequestOptions
|
|
670
|
+
): Promise<JirenResponse<R>> => {
|
|
671
|
+
const url = buildUrl(options?.path);
|
|
672
|
+
|
|
673
|
+
// If no progress callback, use fast non-streaming path
|
|
674
|
+
if (!options?.onDownloadProgress) {
|
|
675
|
+
return self._request<R>("GET", url, null, {
|
|
676
|
+
headers: options?.headers,
|
|
677
|
+
});
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
// Check if HTTP or HTTPS
|
|
681
|
+
const isHttps = url.startsWith("https://");
|
|
682
|
+
if (isHttps) {
|
|
683
|
+
// HTTPS: Fall back to buffered request with simulated progress
|
|
684
|
+
// (Native streaming for HTTPS requires more work on HTTP/2 layer)
|
|
685
|
+
const response = await self._request<R>("GET", url, null, {
|
|
686
|
+
headers: options?.headers,
|
|
687
|
+
});
|
|
688
|
+
|
|
689
|
+
// Fire final progress event
|
|
690
|
+
const bodyText = await response.body.text();
|
|
691
|
+
const bodySize = new TextEncoder().encode(bodyText).length;
|
|
692
|
+
options.onDownloadProgress({
|
|
693
|
+
loaded: bodySize,
|
|
694
|
+
total: bodySize,
|
|
695
|
+
percent: 100,
|
|
696
|
+
speed: 0,
|
|
697
|
+
eta: 0,
|
|
698
|
+
});
|
|
699
|
+
|
|
700
|
+
// Recreate response with body
|
|
701
|
+
return {
|
|
702
|
+
...response,
|
|
703
|
+
body: {
|
|
704
|
+
...response.body,
|
|
705
|
+
text: async () => bodyText,
|
|
706
|
+
json: async () => JSON.parse(bodyText),
|
|
707
|
+
arrayBuffer: async () =>
|
|
708
|
+
new TextEncoder().encode(bodyText).buffer,
|
|
709
|
+
blob: async () => new Blob([bodyText]),
|
|
710
|
+
bodyUsed: true,
|
|
711
|
+
},
|
|
712
|
+
} as JirenResponse<R>;
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
// HTTP: Use native streaming with progress callbacks
|
|
716
|
+
const methodBuffer = Buffer.from("GET\0");
|
|
717
|
+
const urlBuffer = Buffer.from(url + "\0");
|
|
718
|
+
const headersBuffer = options?.headers
|
|
719
|
+
? Buffer.from(
|
|
720
|
+
Object.entries(options.headers)
|
|
721
|
+
.map(([k, v]) => `${k}: ${v}`)
|
|
722
|
+
.join("\r\n") + "\0"
|
|
723
|
+
)
|
|
724
|
+
: null;
|
|
725
|
+
|
|
726
|
+
// Start streaming request (no callback in FFI, we poll manually)
|
|
727
|
+
const streamPtr = lib.symbols.zclient_request_stream(
|
|
728
|
+
self.ptr,
|
|
729
|
+
methodBuffer,
|
|
730
|
+
urlBuffer,
|
|
731
|
+
headersBuffer,
|
|
732
|
+
null,
|
|
733
|
+
null // No native callback, we poll
|
|
734
|
+
);
|
|
735
|
+
|
|
736
|
+
if (!streamPtr) {
|
|
737
|
+
throw new Error("Failed to start streaming request");
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
// Track progress
|
|
741
|
+
const startTime = performance.now();
|
|
742
|
+
let lastLoaded = 0;
|
|
743
|
+
let lastTime = startTime;
|
|
744
|
+
|
|
745
|
+
// Poll until complete
|
|
746
|
+
while (!lib.symbols.zstream_is_complete(streamPtr)) {
|
|
747
|
+
lib.symbols.zstream_poll(streamPtr);
|
|
748
|
+
|
|
749
|
+
const loaded = Number(
|
|
750
|
+
lib.symbols.zstream_bytes_received(streamPtr)
|
|
751
|
+
);
|
|
752
|
+
const total = Number(
|
|
753
|
+
lib.symbols.zstream_content_length(streamPtr)
|
|
754
|
+
);
|
|
755
|
+
|
|
756
|
+
if (loaded > lastLoaded) {
|
|
757
|
+
const now = performance.now();
|
|
758
|
+
const elapsed = now - lastTime;
|
|
759
|
+
const bytesThisInterval = loaded - lastLoaded;
|
|
760
|
+
const speed =
|
|
761
|
+
elapsed > 0 ? (bytesThisInterval / elapsed) * 1000 : 0;
|
|
762
|
+
const remaining = total > 0 ? total - loaded : 0;
|
|
763
|
+
const eta = speed > 0 ? (remaining / speed) * 1000 : 0;
|
|
764
|
+
|
|
765
|
+
options.onDownloadProgress({
|
|
766
|
+
loaded,
|
|
767
|
+
total,
|
|
768
|
+
percent: total > 0 ? Math.round((loaded / total) * 100) : 0,
|
|
769
|
+
speed: Math.round(speed),
|
|
770
|
+
eta: Math.round(eta),
|
|
771
|
+
});
|
|
772
|
+
|
|
773
|
+
lastLoaded = loaded;
|
|
774
|
+
lastTime = now;
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
// Small delay to prevent busy-waiting
|
|
778
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
// Final progress event
|
|
782
|
+
const finalLoaded = Number(
|
|
783
|
+
lib.symbols.zstream_bytes_received(streamPtr)
|
|
784
|
+
);
|
|
785
|
+
const finalTotal = Number(
|
|
786
|
+
lib.symbols.zstream_content_length(streamPtr)
|
|
787
|
+
);
|
|
788
|
+
options.onDownloadProgress({
|
|
789
|
+
loaded: finalLoaded,
|
|
790
|
+
total: finalTotal || finalLoaded,
|
|
791
|
+
percent: 100,
|
|
792
|
+
speed: 0,
|
|
793
|
+
eta: 0,
|
|
794
|
+
});
|
|
795
|
+
|
|
796
|
+
// Parse response
|
|
797
|
+
const status = lib.symbols.zstream_status(streamPtr);
|
|
798
|
+
const bodyLen = Number(lib.symbols.zstream_body_len(streamPtr));
|
|
799
|
+
const bodyPtr = lib.symbols.zstream_body(streamPtr);
|
|
800
|
+
const headersLen = Number(
|
|
801
|
+
lib.symbols.zstream_headers_len(streamPtr)
|
|
802
|
+
);
|
|
803
|
+
const headersPtr = lib.symbols.zstream_headers(streamPtr);
|
|
804
|
+
|
|
805
|
+
// Copy body data before freeing
|
|
806
|
+
let bodyBuffer = new ArrayBuffer(0);
|
|
807
|
+
if (bodyLen > 0 && bodyPtr) {
|
|
808
|
+
bodyBuffer = toArrayBuffer(bodyPtr, 0, bodyLen).slice(0);
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
// Parse headers
|
|
812
|
+
let headersObj: Record<string, string> = {};
|
|
813
|
+
if (headersLen > 0 && headersPtr) {
|
|
814
|
+
const headersData = toArrayBuffer(headersPtr, 0, headersLen);
|
|
815
|
+
const headersStr = new TextDecoder().decode(headersData);
|
|
816
|
+
for (const line of headersStr.split("\r\n")) {
|
|
817
|
+
const colonIdx = line.indexOf(":");
|
|
818
|
+
if (colonIdx > 0) {
|
|
819
|
+
const key = line.slice(0, colonIdx).toLowerCase();
|
|
820
|
+
const value = line.slice(colonIdx + 1).trim();
|
|
821
|
+
headersObj[key] = value;
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
// Free stream
|
|
827
|
+
lib.symbols.zstream_free(streamPtr);
|
|
828
|
+
|
|
829
|
+
// Build response
|
|
830
|
+
const decoder = new TextDecoder();
|
|
831
|
+
let bodyUsed = false;
|
|
832
|
+
|
|
833
|
+
const bodyObj: JirenResponseBody<R> = {
|
|
834
|
+
bodyUsed: false,
|
|
835
|
+
arrayBuffer: async () => bodyBuffer,
|
|
836
|
+
blob: async () => new Blob([bodyBuffer]),
|
|
837
|
+
text: async () => {
|
|
838
|
+
bodyUsed = true;
|
|
839
|
+
return decoder.decode(bodyBuffer);
|
|
840
|
+
},
|
|
841
|
+
json: async <T = R>(): Promise<T> => {
|
|
842
|
+
bodyUsed = true;
|
|
843
|
+
return JSON.parse(decoder.decode(bodyBuffer));
|
|
844
|
+
},
|
|
845
|
+
jsonFields: async <Fields extends Record<string, any> = any>(
|
|
846
|
+
fields: (keyof Fields)[]
|
|
847
|
+
) => {
|
|
848
|
+
bodyUsed = true;
|
|
849
|
+
const dec = new TextDecoder();
|
|
850
|
+
return parseJsonFields<Fields>(dec.decode(bodyBuffer), fields);
|
|
851
|
+
},
|
|
852
|
+
};
|
|
853
|
+
|
|
854
|
+
Object.defineProperty(bodyObj, "bodyUsed", {
|
|
855
|
+
get: () => bodyUsed,
|
|
856
|
+
});
|
|
857
|
+
|
|
858
|
+
return {
|
|
859
|
+
url,
|
|
860
|
+
status,
|
|
861
|
+
statusText: STATUS_TEXT[status] || "",
|
|
862
|
+
headers: headersObj,
|
|
863
|
+
ok: status >= 200 && status < 300,
|
|
864
|
+
redirected: false,
|
|
865
|
+
type: "basic",
|
|
866
|
+
body: bodyObj,
|
|
867
|
+
} as JirenResponse<R>;
|
|
868
|
+
},
|
|
869
|
+
|
|
870
|
+
/**
|
|
871
|
+
* Upload with progress tracking
|
|
872
|
+
* @param method - HTTP method (POST, PUT, PATCH)
|
|
873
|
+
* @param body - Request body (string or object)
|
|
874
|
+
* @param options - Request options with onUploadProgress callback
|
|
875
|
+
* @example
|
|
876
|
+
* await client.url.api.upload({
|
|
877
|
+
* method: 'POST',
|
|
878
|
+
* body: largeData,
|
|
879
|
+
* path: '/upload',
|
|
880
|
+
* onUploadProgress: (progress) => {
|
|
881
|
+
* console.log(`${progress.percent}% @ ${progress.speed / 1024} KB/s`);
|
|
882
|
+
* }
|
|
883
|
+
* });
|
|
884
|
+
*/
|
|
885
|
+
upload: async <R = any>(
|
|
886
|
+
options?: ProgressRequestOptions & {
|
|
887
|
+
method?: "POST" | "PUT" | "PATCH";
|
|
888
|
+
body?: string | object | null;
|
|
889
|
+
}
|
|
890
|
+
): Promise<JirenResponse<R>> => {
|
|
891
|
+
const url = buildUrl(options?.path);
|
|
892
|
+
const method = options?.method || "POST";
|
|
893
|
+
|
|
894
|
+
// Prepare body
|
|
895
|
+
let bodyStr: string | null = null;
|
|
896
|
+
if (options?.body) {
|
|
897
|
+
bodyStr =
|
|
898
|
+
typeof options.body === "string"
|
|
899
|
+
? options.body
|
|
900
|
+
: JSON.stringify(options.body);
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
const bodyLength = bodyStr
|
|
904
|
+
? new TextEncoder().encode(bodyStr).length
|
|
905
|
+
: 0;
|
|
906
|
+
|
|
907
|
+
// If no progress callback or no body, use fast path
|
|
908
|
+
if (!options?.onUploadProgress || !bodyStr) {
|
|
909
|
+
const { headers: preparedHeaders, serializedBody } =
|
|
910
|
+
self.prepareBody(options?.body, options?.headers);
|
|
911
|
+
return self._request<R>(method, url, serializedBody, {
|
|
912
|
+
headers: preparedHeaders,
|
|
913
|
+
});
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
// Fire initial progress event (0%)
|
|
917
|
+
const startTime = performance.now();
|
|
918
|
+
options.onUploadProgress({
|
|
919
|
+
loaded: 0,
|
|
920
|
+
total: bodyLength,
|
|
921
|
+
percent: 0,
|
|
922
|
+
speed: 0,
|
|
923
|
+
eta: 0,
|
|
924
|
+
});
|
|
925
|
+
|
|
926
|
+
// Check if HTTP or HTTPS
|
|
927
|
+
const isHttps = url.startsWith("https://");
|
|
928
|
+
|
|
929
|
+
if (isHttps) {
|
|
930
|
+
// HTTPS: Use fast path with simulated progress
|
|
931
|
+
// (Native layer handles the upload in one call)
|
|
932
|
+
const { headers: preparedHeaders, serializedBody } =
|
|
933
|
+
self.prepareBody(options.body, options.headers);
|
|
934
|
+
|
|
935
|
+
const response = await self._request<R>(
|
|
936
|
+
method,
|
|
937
|
+
url,
|
|
938
|
+
serializedBody,
|
|
939
|
+
{
|
|
940
|
+
headers: preparedHeaders,
|
|
941
|
+
}
|
|
942
|
+
);
|
|
943
|
+
|
|
944
|
+
// Fire final progress event (100%)
|
|
945
|
+
const elapsed = performance.now() - startTime;
|
|
946
|
+
const speed = elapsed > 0 ? (bodyLength / elapsed) * 1000 : 0;
|
|
947
|
+
options.onUploadProgress({
|
|
948
|
+
loaded: bodyLength,
|
|
949
|
+
total: bodyLength,
|
|
950
|
+
percent: 100,
|
|
951
|
+
speed: Math.round(speed),
|
|
952
|
+
eta: 0,
|
|
953
|
+
});
|
|
954
|
+
|
|
955
|
+
return response;
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
// HTTP: Use native Zig streaming for upload with progress
|
|
959
|
+
const { headers: preparedHeaders } = self.prepareBody(
|
|
960
|
+
options.body,
|
|
961
|
+
options.headers
|
|
962
|
+
);
|
|
963
|
+
|
|
964
|
+
// Build headers string for native
|
|
965
|
+
const headerLines: string[] = [];
|
|
966
|
+
for (const [key, value] of Object.entries(preparedHeaders)) {
|
|
967
|
+
headerLines.push(`${key}: ${value}`);
|
|
968
|
+
}
|
|
969
|
+
const headersStr = headerLines.join("\r\n");
|
|
970
|
+
|
|
971
|
+
// Create body buffer for native
|
|
972
|
+
const bodyBuffer = Buffer.from(bodyStr);
|
|
973
|
+
|
|
974
|
+
// Try native streaming upload
|
|
975
|
+
const uploadStreamPtr = lib.symbols.zclient_upload_stream(
|
|
976
|
+
self.ptr,
|
|
977
|
+
Buffer.from(method + "\0"),
|
|
978
|
+
Buffer.from(url + "\0"),
|
|
979
|
+
headersStr ? Buffer.from(headersStr + "\0") : null,
|
|
980
|
+
bodyBuffer,
|
|
981
|
+
bodyLength,
|
|
982
|
+
null // We'll poll for progress instead of using callback
|
|
983
|
+
);
|
|
984
|
+
|
|
985
|
+
if (!uploadStreamPtr) {
|
|
986
|
+
// Native streaming failed, fall back to fast request
|
|
987
|
+
const resp = await self._request<R>(method, url, bodyStr, {
|
|
988
|
+
headers: preparedHeaders,
|
|
989
|
+
});
|
|
990
|
+
|
|
991
|
+
// Fire final progress
|
|
992
|
+
const elapsed = performance.now() - startTime;
|
|
993
|
+
const speed = elapsed > 0 ? (bodyLength / elapsed) * 1000 : 0;
|
|
994
|
+
options.onUploadProgress({
|
|
995
|
+
loaded: bodyLength,
|
|
996
|
+
total: bodyLength,
|
|
997
|
+
percent: 100,
|
|
998
|
+
speed: Math.round(speed),
|
|
999
|
+
eta: 0,
|
|
1000
|
+
});
|
|
1001
|
+
|
|
1002
|
+
return resp;
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
try {
|
|
1006
|
+
// Send body in chunks with progress polling
|
|
1007
|
+
let lastBytesSent = 0;
|
|
1008
|
+
while (!lib.symbols.zupload_is_upload_complete(uploadStreamPtr)) {
|
|
1009
|
+
const sent = lib.symbols.zupload_send_chunk(uploadStreamPtr);
|
|
1010
|
+
if (sent === 0n) break;
|
|
1011
|
+
|
|
1012
|
+
const bytesSent = Number(
|
|
1013
|
+
lib.symbols.zupload_bytes_sent(uploadStreamPtr)
|
|
1014
|
+
);
|
|
1015
|
+
|
|
1016
|
+
// Calculate progress
|
|
1017
|
+
const now = performance.now();
|
|
1018
|
+
const elapsed = now - startTime;
|
|
1019
|
+
const speed = elapsed > 0 ? (bytesSent / elapsed) * 1000 : 0;
|
|
1020
|
+
const remaining = bodyLength - bytesSent;
|
|
1021
|
+
const eta = speed > 0 ? (remaining / speed) * 1000 : 0;
|
|
1022
|
+
|
|
1023
|
+
options.onUploadProgress({
|
|
1024
|
+
loaded: bytesSent,
|
|
1025
|
+
total: bodyLength,
|
|
1026
|
+
percent: Math.round((bytesSent / bodyLength) * 100),
|
|
1027
|
+
speed: Math.round(speed),
|
|
1028
|
+
eta: Math.round(eta),
|
|
1029
|
+
});
|
|
1030
|
+
|
|
1031
|
+
lastBytesSent = bytesSent;
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
// Read response
|
|
1035
|
+
while (!lib.symbols.zupload_is_complete(uploadStreamPtr)) {
|
|
1036
|
+
const read = lib.symbols.zupload_read_response(uploadStreamPtr);
|
|
1037
|
+
if (read === 0n) {
|
|
1038
|
+
// Small wait before next poll
|
|
1039
|
+
await new Promise((r) => setTimeout(r, 1));
|
|
1040
|
+
}
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
// Get response data
|
|
1044
|
+
const status = lib.symbols.zupload_status(uploadStreamPtr);
|
|
1045
|
+
const bodyPtr =
|
|
1046
|
+
lib.symbols.zupload_response_body(uploadStreamPtr);
|
|
1047
|
+
const bodyLen = Number(
|
|
1048
|
+
lib.symbols.zupload_response_body_len(uploadStreamPtr)
|
|
1049
|
+
);
|
|
1050
|
+
const headersPtr =
|
|
1051
|
+
lib.symbols.zupload_response_headers(uploadStreamPtr);
|
|
1052
|
+
const headersLen = Number(
|
|
1053
|
+
lib.symbols.zupload_response_headers_len(uploadStreamPtr)
|
|
1054
|
+
);
|
|
1055
|
+
|
|
1056
|
+
// Copy data
|
|
1057
|
+
const bodyData =
|
|
1058
|
+
bodyLen > 0 && bodyPtr
|
|
1059
|
+
? Buffer.from(toArrayBuffer(bodyPtr, 0, bodyLen)).toString()
|
|
1060
|
+
: "";
|
|
1061
|
+
const responseHeaders =
|
|
1062
|
+
headersLen > 0 && headersPtr
|
|
1063
|
+
? Buffer.from(
|
|
1064
|
+
toArrayBuffer(headersPtr, 0, headersLen)
|
|
1065
|
+
).toString()
|
|
1066
|
+
: "";
|
|
1067
|
+
|
|
1068
|
+
// Parse headers
|
|
1069
|
+
const headersObj: Record<string, string> = {};
|
|
1070
|
+
for (const line of responseHeaders.split("\r\n").slice(1)) {
|
|
1071
|
+
const colonIdx = line.indexOf(":");
|
|
1072
|
+
if (colonIdx > 0) {
|
|
1073
|
+
const key = line.slice(0, colonIdx).toLowerCase();
|
|
1074
|
+
const value = line.slice(colonIdx + 1).trim();
|
|
1075
|
+
headersObj[key] = value;
|
|
1076
|
+
}
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
// Build response
|
|
1080
|
+
let bodyUsed = false;
|
|
1081
|
+
const bodyObj: JirenResponseBody<R> = {
|
|
1082
|
+
bodyUsed: false,
|
|
1083
|
+
text: async () => {
|
|
1084
|
+
bodyUsed = true;
|
|
1085
|
+
return bodyData;
|
|
1086
|
+
},
|
|
1087
|
+
json: async <T = R>(): Promise<T> => {
|
|
1088
|
+
bodyUsed = true;
|
|
1089
|
+
return JSON.parse(bodyData);
|
|
1090
|
+
},
|
|
1091
|
+
jsonFields: async <Fields extends Record<string, any> = any>(
|
|
1092
|
+
fields: (keyof Fields)[]
|
|
1093
|
+
) => {
|
|
1094
|
+
bodyUsed = true;
|
|
1095
|
+
const dec = new TextDecoder();
|
|
1096
|
+
return parseJsonFields<Fields>(
|
|
1097
|
+
dec.decode(bodyBuffer),
|
|
1098
|
+
fields
|
|
1099
|
+
);
|
|
1100
|
+
},
|
|
1101
|
+
arrayBuffer: async () => {
|
|
1102
|
+
bodyUsed = true;
|
|
1103
|
+
return new TextEncoder().encode(bodyData)
|
|
1104
|
+
.buffer as ArrayBuffer;
|
|
1105
|
+
},
|
|
1106
|
+
blob: async () => {
|
|
1107
|
+
bodyUsed = true;
|
|
1108
|
+
return new Blob([bodyData]);
|
|
1109
|
+
},
|
|
1110
|
+
};
|
|
1111
|
+
|
|
1112
|
+
Object.defineProperty(bodyObj, "bodyUsed", {
|
|
1113
|
+
get: () => bodyUsed,
|
|
1114
|
+
});
|
|
1115
|
+
|
|
1116
|
+
return {
|
|
1117
|
+
url,
|
|
1118
|
+
status,
|
|
1119
|
+
statusText: STATUS_TEXT[status] || "",
|
|
1120
|
+
headers: headersObj,
|
|
1121
|
+
ok: status >= 200 && status < 300,
|
|
1122
|
+
redirected: false,
|
|
1123
|
+
type: "basic",
|
|
1124
|
+
body: bodyObj,
|
|
1125
|
+
} as JirenResponse<R>;
|
|
1126
|
+
} finally {
|
|
1127
|
+
lib.symbols.zupload_free(uploadStreamPtr);
|
|
1128
|
+
}
|
|
1129
|
+
},
|
|
1130
|
+
} as UrlEndpoint;
|
|
1131
|
+
},
|
|
1132
|
+
});
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
public close(): void {
|
|
1136
|
+
if (this.ptr) {
|
|
1137
|
+
// Unregister from FinalizationRegistry since we're manually closing
|
|
1138
|
+
clientRegistry.unregister(this);
|
|
1139
|
+
lib.symbols.zclient_free(this.ptr);
|
|
1140
|
+
this.ptr = null;
|
|
1141
|
+
}
|
|
1142
|
+
// Close native cache
|
|
1143
|
+
this.cache.close();
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
[Symbol.dispose](): void {
|
|
1147
|
+
this.close();
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
public use(interceptors: Interceptors): this {
|
|
1151
|
+
if (interceptors.request)
|
|
1152
|
+
this.requestInterceptors.push(...interceptors.request);
|
|
1153
|
+
if (interceptors.response)
|
|
1154
|
+
this.responseInterceptors.push(...interceptors.response);
|
|
1155
|
+
if (interceptors.error) this.errorInterceptors.push(...interceptors.error);
|
|
1156
|
+
return this;
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
public async preconnect(urls: string[]): Promise<void> {
|
|
1160
|
+
if (!this.ptr) throw new Error("Client is closed");
|
|
1161
|
+
|
|
1162
|
+
// Pre-connect to all URLs in parallel for faster startup
|
|
1163
|
+
await Promise.all(
|
|
1164
|
+
urls.map(
|
|
1165
|
+
(url) =>
|
|
1166
|
+
new Promise<void>((resolve) => {
|
|
1167
|
+
const urlBuffer = Buffer.from(url + "\0");
|
|
1168
|
+
lib.symbols.zclient_prefetch(this.ptr, urlBuffer);
|
|
1169
|
+
resolve();
|
|
1170
|
+
})
|
|
1171
|
+
)
|
|
1172
|
+
);
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
/**
|
|
1176
|
+
* @deprecated Use preconnect() instead
|
|
1177
|
+
*/
|
|
1178
|
+
public async warmup(urls: string[]): Promise<void> {
|
|
1179
|
+
return this.preconnect(urls);
|
|
1180
|
+
}
|
|
1181
|
+
|
|
1182
|
+
/**
|
|
1183
|
+
* @deprecated Use preconnect() instead
|
|
1184
|
+
*/
|
|
1185
|
+
public prefetch(urls: string[]): void {
|
|
1186
|
+
this.preconnect(urls);
|
|
1187
|
+
}
|
|
1188
|
+
|
|
1189
|
+
protected async _request<T = any>(
|
|
1190
|
+
method: string,
|
|
1191
|
+
url: string,
|
|
1192
|
+
body?: string | null,
|
|
1193
|
+
options?: RequestOptions & { responseType: "json" }
|
|
1194
|
+
): Promise<T>;
|
|
1195
|
+
protected async _request<T = any>(
|
|
1196
|
+
method: string,
|
|
1197
|
+
url: string,
|
|
1198
|
+
body?: string | null,
|
|
1199
|
+
options?: RequestOptions & { responseType: "text" }
|
|
1200
|
+
): Promise<string>;
|
|
1201
|
+
protected async _request<T = any>(
|
|
1202
|
+
method: string,
|
|
1203
|
+
url: string,
|
|
1204
|
+
body?: string | null,
|
|
1205
|
+
options?: RequestOptions & { responseType: "arraybuffer" }
|
|
1206
|
+
): Promise<ArrayBuffer>;
|
|
1207
|
+
protected async _request<T = any>(
|
|
1208
|
+
method: string,
|
|
1209
|
+
url: string,
|
|
1210
|
+
body?: string | null,
|
|
1211
|
+
options?: RequestOptions & { responseType: "blob" }
|
|
1212
|
+
): Promise<Blob>;
|
|
1213
|
+
protected async _request<T = any>(
|
|
1214
|
+
method: string,
|
|
1215
|
+
url: string,
|
|
1216
|
+
body?: string | null,
|
|
1217
|
+
options?: RequestOptions
|
|
1218
|
+
): Promise<JirenResponse<T>>;
|
|
1219
|
+
protected async _request<T = any>(
|
|
1220
|
+
method: string,
|
|
1221
|
+
url: string,
|
|
1222
|
+
body?: string | null,
|
|
1223
|
+
options?: RequestOptions | Record<string, string> | null
|
|
1224
|
+
): Promise<JirenResponse<T> | T | string | ArrayBuffer | Blob> {
|
|
1225
|
+
if (!this.ptr) throw new Error("Client is closed");
|
|
1226
|
+
|
|
1227
|
+
// Normalize options
|
|
1228
|
+
let headers: Record<string, string> = {};
|
|
1229
|
+
let maxRedirects = 5; // Default
|
|
1230
|
+
let responseType: RequestOptions["responseType"] | undefined;
|
|
1231
|
+
let antibot = false; // Default
|
|
1232
|
+
let timeout: number | undefined; // Request timeout in ms
|
|
1233
|
+
|
|
1234
|
+
if (options) {
|
|
1235
|
+
if (
|
|
1236
|
+
"maxRedirects" in options ||
|
|
1237
|
+
"headers" in options ||
|
|
1238
|
+
"responseType" in options ||
|
|
1239
|
+
"method" in options || // Check for any RequestOptions specific key
|
|
1240
|
+
"timeout" in options ||
|
|
1241
|
+
"antibot" in options
|
|
1242
|
+
) {
|
|
1243
|
+
// It is RequestOptions
|
|
1244
|
+
const opts = options as RequestOptions;
|
|
1245
|
+
if (opts.headers) headers = opts.headers;
|
|
1246
|
+
if (opts.maxRedirects !== undefined) maxRedirects = opts.maxRedirects;
|
|
1247
|
+
if (opts.responseType) responseType = opts.responseType;
|
|
1248
|
+
if (opts.antibot !== undefined) antibot = opts.antibot;
|
|
1249
|
+
if (opts.timeout !== undefined) timeout = opts.timeout;
|
|
1250
|
+
} else {
|
|
1251
|
+
// Assume it's just headers Record<string, string> for backward compatibility
|
|
1252
|
+
headers = options as Record<string, string>;
|
|
1253
|
+
}
|
|
1254
|
+
}
|
|
1255
|
+
|
|
1256
|
+
// Run interceptors only if any are registered
|
|
1257
|
+
let ctx: InterceptorRequestContext = { method, url, headers, body };
|
|
1258
|
+
if (this.requestInterceptors.length > 0) {
|
|
1259
|
+
for (const interceptor of this.requestInterceptors) {
|
|
1260
|
+
ctx = await interceptor(ctx);
|
|
1261
|
+
}
|
|
1262
|
+
// Apply interceptor modifications
|
|
1263
|
+
method = ctx.method;
|
|
1264
|
+
url = ctx.url;
|
|
1265
|
+
headers = ctx.headers;
|
|
1266
|
+
body = ctx.body ?? null;
|
|
1267
|
+
}
|
|
1268
|
+
|
|
1269
|
+
// Use pre-computed method buffer or create one for custom methods
|
|
1270
|
+
const methodBuffer =
|
|
1271
|
+
this.methodBuffers[method] || Buffer.from(method + "\0");
|
|
1272
|
+
const urlBuffer = Buffer.from(url + "\0");
|
|
1273
|
+
|
|
1274
|
+
let bodyBuffer: Buffer | null = null;
|
|
1275
|
+
if (body) {
|
|
1276
|
+
bodyBuffer = Buffer.from(body + "\0");
|
|
1277
|
+
}
|
|
1278
|
+
|
|
1279
|
+
let headersBuffer: Buffer;
|
|
1280
|
+
const hasCustomHeaders = Object.keys(headers).length > 0;
|
|
1281
|
+
|
|
1282
|
+
if (hasCustomHeaders) {
|
|
1283
|
+
// Merge custom headers with defaults (slow path)
|
|
1284
|
+
const finalHeaders = { ...this.defaultHeaders, ...headers };
|
|
1285
|
+
|
|
1286
|
+
// Enforce Chrome header order
|
|
1287
|
+
const orderedHeaders: Record<string, string> = {};
|
|
1288
|
+
const keys = [
|
|
1289
|
+
"sec-ch-ua",
|
|
1290
|
+
"sec-ch-ua-mobile",
|
|
1291
|
+
"sec-ch-ua-platform",
|
|
1292
|
+
"upgrade-insecure-requests",
|
|
1293
|
+
"user-agent",
|
|
1294
|
+
"accept",
|
|
1295
|
+
"sec-fetch-site",
|
|
1296
|
+
"sec-fetch-mode",
|
|
1297
|
+
"sec-fetch-user",
|
|
1298
|
+
"sec-fetch-dest",
|
|
1299
|
+
"accept-encoding",
|
|
1300
|
+
"accept-language",
|
|
1301
|
+
];
|
|
1302
|
+
|
|
1303
|
+
// Add priority headers in order
|
|
1304
|
+
for (const key of keys) {
|
|
1305
|
+
if (finalHeaders[key]) {
|
|
1306
|
+
orderedHeaders[key] = finalHeaders[key];
|
|
1307
|
+
delete finalHeaders[key];
|
|
1308
|
+
}
|
|
1309
|
+
}
|
|
1310
|
+
|
|
1311
|
+
// Add remaining custom headers
|
|
1312
|
+
for (const [key, value] of Object.entries(finalHeaders)) {
|
|
1313
|
+
orderedHeaders[key] = value;
|
|
1314
|
+
}
|
|
1315
|
+
|
|
1316
|
+
const headerStr = Object.entries(orderedHeaders)
|
|
1317
|
+
.map(([k, v]) => `${k.toLowerCase()}: ${v}`)
|
|
1318
|
+
.join("\r\n");
|
|
1319
|
+
|
|
1320
|
+
headersBuffer = Buffer.from(headerStr + "\0");
|
|
1321
|
+
} else {
|
|
1322
|
+
// Fast path: use pre-computed headers buffer (no allocation!)
|
|
1323
|
+
headersBuffer = this.defaultHeadersBuffer;
|
|
1324
|
+
}
|
|
1325
|
+
|
|
1326
|
+
// Determine retry configuration
|
|
1327
|
+
let retryConfig = this.globalRetry;
|
|
1328
|
+
// Check if options is RequestOptions (has 'retry' property and is not just a header map)
|
|
1329
|
+
// We already normalized this earlier, but let's be safe.
|
|
1330
|
+
// If it has 'responseType', 'method', etc., it's RequestOptions.
|
|
1331
|
+
// Simpler: Just check if 'retry' is number or object.
|
|
1332
|
+
if (options && typeof options === "object" && "retry" in options) {
|
|
1333
|
+
const userRetry = (options as any).retry;
|
|
1334
|
+
if (typeof userRetry === "number") {
|
|
1335
|
+
retryConfig = { count: userRetry, delay: 100, backoff: 2 };
|
|
1336
|
+
} else if (typeof userRetry === "object") {
|
|
1337
|
+
retryConfig = userRetry;
|
|
1338
|
+
}
|
|
1339
|
+
}
|
|
1340
|
+
|
|
1341
|
+
let attempts = 0;
|
|
1342
|
+
// Default to 1 attempt (0 retries) if no config
|
|
1343
|
+
const maxAttempts = (retryConfig?.count || 0) + 1;
|
|
1344
|
+
let currentDelay = retryConfig?.delay || 100;
|
|
1345
|
+
const backoff = retryConfig?.backoff || 2;
|
|
1346
|
+
|
|
1347
|
+
let lastError: any;
|
|
1348
|
+
|
|
1349
|
+
while (attempts < maxAttempts) {
|
|
1350
|
+
attempts++;
|
|
1351
|
+
try {
|
|
1352
|
+
// Create the request promise
|
|
1353
|
+
const makeRequest = async (): Promise<
|
|
1354
|
+
JirenResponse<T> | T | string | ArrayBuffer | Blob
|
|
1355
|
+
> => {
|
|
1356
|
+
// Use optimized single-call API (reduces 5 FFI calls to 1)
|
|
1357
|
+
const respPtr = lib.symbols.zclient_request_full(
|
|
1358
|
+
this.ptr,
|
|
1359
|
+
methodBuffer,
|
|
1360
|
+
urlBuffer,
|
|
1361
|
+
headersBuffer,
|
|
1362
|
+
bodyBuffer,
|
|
1363
|
+
maxRedirects,
|
|
1364
|
+
antibot
|
|
1365
|
+
);
|
|
1366
|
+
|
|
1367
|
+
if (!respPtr) {
|
|
1368
|
+
throw new Error("Native request failed (returned null pointer)");
|
|
1369
|
+
}
|
|
1370
|
+
|
|
1371
|
+
const response = this.parseResponseFull<T>(respPtr, url);
|
|
1372
|
+
|
|
1373
|
+
// Run response interceptors only if any registered
|
|
1374
|
+
let finalResponse = response;
|
|
1375
|
+
if (this.responseInterceptors.length > 0) {
|
|
1376
|
+
let responseCtx: InterceptorResponseContext<T> = {
|
|
1377
|
+
request: ctx,
|
|
1378
|
+
response,
|
|
1379
|
+
};
|
|
1380
|
+
for (const interceptor of this.responseInterceptors) {
|
|
1381
|
+
responseCtx = await interceptor(responseCtx);
|
|
1382
|
+
}
|
|
1383
|
+
finalResponse = responseCtx.response;
|
|
1384
|
+
}
|
|
1385
|
+
|
|
1386
|
+
// Auto-parse if requested
|
|
1387
|
+
if (responseType) {
|
|
1388
|
+
if (responseType === "json") return finalResponse.body.json();
|
|
1389
|
+
if (responseType === "text") return finalResponse.body.text();
|
|
1390
|
+
if (responseType === "arraybuffer")
|
|
1391
|
+
return finalResponse.body.arrayBuffer();
|
|
1392
|
+
if (responseType === "blob") return finalResponse.body.blob();
|
|
1393
|
+
}
|
|
1394
|
+
|
|
1395
|
+
return finalResponse;
|
|
1396
|
+
};
|
|
1397
|
+
|
|
1398
|
+
// Apply timeout if specified
|
|
1399
|
+
if (timeout && timeout > 0) {
|
|
1400
|
+
const timeoutPromise = new Promise<never>((_, reject) => {
|
|
1401
|
+
setTimeout(() => {
|
|
1402
|
+
const error = new Error(`Request timeout after ${timeout}ms`);
|
|
1403
|
+
error.name = "TimeoutError";
|
|
1404
|
+
reject(error);
|
|
1405
|
+
}, timeout);
|
|
1406
|
+
});
|
|
1407
|
+
|
|
1408
|
+
return await Promise.race([makeRequest(), timeoutPromise]);
|
|
1409
|
+
}
|
|
1410
|
+
|
|
1411
|
+
return await makeRequest();
|
|
1412
|
+
} catch (err) {
|
|
1413
|
+
// Run error interceptors only if any registered
|
|
1414
|
+
if (this.errorInterceptors.length > 0) {
|
|
1415
|
+
for (const interceptor of this.errorInterceptors) {
|
|
1416
|
+
await interceptor(err as Error, ctx);
|
|
1417
|
+
}
|
|
1418
|
+
}
|
|
1419
|
+
lastError = err;
|
|
1420
|
+
|
|
1421
|
+
// Don't retry on timeout errors
|
|
1422
|
+
if ((err as Error).name === "TimeoutError") {
|
|
1423
|
+
throw err;
|
|
1424
|
+
}
|
|
1425
|
+
|
|
1426
|
+
if (attempts < maxAttempts) {
|
|
1427
|
+
// Wait before retrying
|
|
1428
|
+
await this.waitFor(currentDelay);
|
|
1429
|
+
currentDelay *= backoff;
|
|
1430
|
+
}
|
|
1431
|
+
}
|
|
1432
|
+
}
|
|
1433
|
+
|
|
1434
|
+
throw lastError || new Error("Request failed after retries");
|
|
1435
|
+
}
|
|
1436
|
+
|
|
1437
|
+
private parseResponse<T = any>(
|
|
1438
|
+
respPtr: Pointer | null,
|
|
1439
|
+
url: string
|
|
1440
|
+
): JirenResponse<T> {
|
|
1441
|
+
if (!respPtr)
|
|
1442
|
+
throw new Error("Native request failed (returned null pointer)");
|
|
1443
|
+
|
|
1444
|
+
try {
|
|
1445
|
+
const status = lib.symbols.zclient_response_status(respPtr);
|
|
1446
|
+
const len = Number(lib.symbols.zclient_response_body_len(respPtr));
|
|
1447
|
+
const bodyPtr = lib.symbols.zclient_response_body(respPtr);
|
|
1448
|
+
|
|
1449
|
+
const headersLen = Number(
|
|
1450
|
+
lib.symbols.zclient_response_headers_len(respPtr)
|
|
1451
|
+
);
|
|
1452
|
+
let headersObj: Record<string, string> | NativeHeaders = {};
|
|
1453
|
+
|
|
1454
|
+
if (headersLen > 0) {
|
|
1455
|
+
const rawHeadersPtr = lib.symbols.zclient_response_headers(respPtr);
|
|
1456
|
+
if (rawHeadersPtr) {
|
|
1457
|
+
// Copy headers to JS memory
|
|
1458
|
+
// We need to copy because respPtr will be freed
|
|
1459
|
+
const rawSrc = toArrayBuffer(rawHeadersPtr, 0, headersLen);
|
|
1460
|
+
const raw = new Uint8Array(rawSrc.slice(0)); // Explicit copy
|
|
1461
|
+
|
|
1462
|
+
headersObj = new NativeHeaders(raw);
|
|
1463
|
+
}
|
|
1464
|
+
}
|
|
1465
|
+
|
|
1466
|
+
// Proxy for backward compatibility
|
|
1467
|
+
const headersProxy = new Proxy(
|
|
1468
|
+
headersObj instanceof NativeHeaders ? headersObj : {},
|
|
1469
|
+
{
|
|
1470
|
+
get(target, prop) {
|
|
1471
|
+
if (target instanceof NativeHeaders && typeof prop === "string") {
|
|
1472
|
+
if (prop === "toJSON") return () => target.toJSON();
|
|
1473
|
+
|
|
1474
|
+
// Try to get from native headers
|
|
1475
|
+
const val = target.get(prop);
|
|
1476
|
+
if (val !== null) return val;
|
|
1477
|
+
}
|
|
1478
|
+
return Reflect.get(target, prop);
|
|
1479
|
+
},
|
|
1480
|
+
}
|
|
1481
|
+
) as unknown as Record<string, string>; // Lie to TS
|
|
1482
|
+
|
|
1483
|
+
let buffer: ArrayBuffer | Buffer = new ArrayBuffer(0);
|
|
1484
|
+
if (len > 0 && bodyPtr) {
|
|
1485
|
+
// Create a copy of the buffer because the native response is freed immediately after
|
|
1486
|
+
buffer = toArrayBuffer(bodyPtr, 0, len).slice(0);
|
|
1487
|
+
|
|
1488
|
+
// Handle GZIP decompression if needed
|
|
1489
|
+
const bufferView = new Uint8Array(buffer);
|
|
1490
|
+
// Check for gzip magic bytes (0x1f 0x8b)
|
|
1491
|
+
if (
|
|
1492
|
+
bufferView.length >= 2 &&
|
|
1493
|
+
bufferView[0] === 0x1f &&
|
|
1494
|
+
bufferView[1] === 0x8b
|
|
1495
|
+
) {
|
|
1496
|
+
try {
|
|
1497
|
+
// Use Bun's built-in gzip decompression
|
|
1498
|
+
const decompressed = Bun.gunzipSync(bufferView);
|
|
1499
|
+
buffer = decompressed.buffer.slice(
|
|
1500
|
+
decompressed.byteOffset,
|
|
1501
|
+
decompressed.byteOffset + decompressed.byteLength
|
|
1502
|
+
);
|
|
1503
|
+
} catch (e) {
|
|
1504
|
+
// Decompression failed, keep original buffer
|
|
1505
|
+
console.warn("[Jiren] gzip decompression failed:", e);
|
|
1506
|
+
}
|
|
1507
|
+
}
|
|
1508
|
+
}
|
|
1509
|
+
|
|
1510
|
+
let bodyUsed = false;
|
|
1511
|
+
const consumeBody = () => {
|
|
1512
|
+
if (bodyUsed) {
|
|
1513
|
+
}
|
|
1514
|
+
bodyUsed = true;
|
|
1515
|
+
};
|
|
1516
|
+
|
|
1517
|
+
const bodyObj: JirenResponseBody<T> = {
|
|
1518
|
+
bodyUsed: false,
|
|
1519
|
+
arrayBuffer: async () => {
|
|
1520
|
+
consumeBody();
|
|
1521
|
+
if (Buffer.isBuffer(buffer)) {
|
|
1522
|
+
const buf = buffer as Buffer;
|
|
1523
|
+
return buf.buffer.slice(
|
|
1524
|
+
buf.byteOffset,
|
|
1525
|
+
buf.byteOffset + buf.byteLength
|
|
1526
|
+
) as ArrayBuffer;
|
|
1527
|
+
}
|
|
1528
|
+
return buffer as ArrayBuffer;
|
|
1529
|
+
},
|
|
1530
|
+
blob: async () => {
|
|
1531
|
+
consumeBody();
|
|
1532
|
+
return new Blob([buffer]);
|
|
1533
|
+
},
|
|
1534
|
+
text: async () => {
|
|
1535
|
+
consumeBody();
|
|
1536
|
+
return this.decoder.decode(buffer);
|
|
1537
|
+
},
|
|
1538
|
+
json: async <R = T>(): Promise<R> => {
|
|
1539
|
+
consumeBody();
|
|
1540
|
+
const text = this.decoder.decode(buffer);
|
|
1541
|
+
// Note: JS JSON.parse is fastest for full object conversion
|
|
1542
|
+
// Native SIMD acceleration benefits field extraction (jsonFields method)
|
|
1543
|
+
return JSON.parse(text);
|
|
1544
|
+
},
|
|
1545
|
+
jsonFields: async <Fields extends Record<string, any> = any>(
|
|
1546
|
+
fields: (keyof Fields)[]
|
|
1547
|
+
) => {
|
|
1548
|
+
consumeBody();
|
|
1549
|
+
const text = this.decoder.decode(buffer);
|
|
1550
|
+
return parseJsonFields<Fields>(text, fields);
|
|
1551
|
+
},
|
|
1552
|
+
};
|
|
1553
|
+
|
|
1554
|
+
// Update bodyUsed getter to reflect local variable
|
|
1555
|
+
Object.defineProperty(bodyObj, "bodyUsed", {
|
|
1556
|
+
get: () => bodyUsed,
|
|
1557
|
+
});
|
|
1558
|
+
|
|
1559
|
+
return {
|
|
1560
|
+
url,
|
|
1561
|
+
status,
|
|
1562
|
+
statusText: STATUS_TEXT[status] || "",
|
|
1563
|
+
headers: headersProxy,
|
|
1564
|
+
ok: status >= 200 && status < 300,
|
|
1565
|
+
redirected: false,
|
|
1566
|
+
type: "basic",
|
|
1567
|
+
body: bodyObj,
|
|
1568
|
+
} as JirenResponse<T>;
|
|
1569
|
+
} finally {
|
|
1570
|
+
lib.symbols.zclient_response_free(respPtr);
|
|
1571
|
+
}
|
|
1572
|
+
}
|
|
1573
|
+
|
|
1574
|
+
/**
|
|
1575
|
+
* Optimized response parser using ZFullResponse struct (single FFI call got all data)
|
|
1576
|
+
*/
|
|
1577
|
+
private parseResponseFull<T = any>(
|
|
1578
|
+
respPtr: Pointer | null,
|
|
1579
|
+
url: string
|
|
1580
|
+
): JirenResponse<T> {
|
|
1581
|
+
if (!respPtr)
|
|
1582
|
+
throw new Error("Native request failed (returned null pointer)");
|
|
1583
|
+
|
|
1584
|
+
try {
|
|
1585
|
+
// Use FFI accessor functions (avoids BigInt-to-Pointer conversion issues)
|
|
1586
|
+
const status = lib.symbols.zfull_response_status(respPtr);
|
|
1587
|
+
const bodyPtr = lib.symbols.zfull_response_body(respPtr);
|
|
1588
|
+
const bodyLen = Number(lib.symbols.zfull_response_body_len(respPtr));
|
|
1589
|
+
const headersPtr = lib.symbols.zfull_response_headers(respPtr);
|
|
1590
|
+
const headersLen = Number(
|
|
1591
|
+
lib.symbols.zfull_response_headers_len(respPtr)
|
|
1592
|
+
);
|
|
1593
|
+
|
|
1594
|
+
let headersObj: Record<string, string> | NativeHeaders = {};
|
|
1595
|
+
if (headersLen > 0 && headersPtr) {
|
|
1596
|
+
const rawSrc = toArrayBuffer(headersPtr, 0, headersLen);
|
|
1597
|
+
const raw = new Uint8Array(rawSrc.slice(0));
|
|
1598
|
+
headersObj = new NativeHeaders(raw);
|
|
1599
|
+
}
|
|
1600
|
+
|
|
1601
|
+
// Simplified proxy for performance mode
|
|
1602
|
+
const headersProxy = this.performanceMode
|
|
1603
|
+
? (headersObj as Record<string, string>)
|
|
1604
|
+
: (new Proxy(headersObj instanceof NativeHeaders ? headersObj : {}, {
|
|
1605
|
+
get(target, prop) {
|
|
1606
|
+
if (target instanceof NativeHeaders && typeof prop === "string") {
|
|
1607
|
+
if (prop === "toJSON") return () => target.toJSON();
|
|
1608
|
+
const val = target.get(prop);
|
|
1609
|
+
if (val !== null) return val;
|
|
1610
|
+
}
|
|
1611
|
+
return Reflect.get(target, prop);
|
|
1612
|
+
},
|
|
1613
|
+
}) as unknown as Record<string, string>);
|
|
1614
|
+
|
|
1615
|
+
let buffer: ArrayBuffer | Buffer = new ArrayBuffer(0);
|
|
1616
|
+
if (bodyLen > 0 && bodyPtr) {
|
|
1617
|
+
buffer = toArrayBuffer(bodyPtr, 0, bodyLen).slice(0);
|
|
1618
|
+
|
|
1619
|
+
// Handle GZIP decompression - check content-encoding header first
|
|
1620
|
+
const contentEncoding =
|
|
1621
|
+
headersObj instanceof NativeHeaders
|
|
1622
|
+
? headersObj.get("content-encoding")?.toLowerCase()
|
|
1623
|
+
: (headersObj as Record<string, string>)[
|
|
1624
|
+
"content-encoding"
|
|
1625
|
+
]?.toLowerCase();
|
|
1626
|
+
|
|
1627
|
+
const bufferView = new Uint8Array(buffer);
|
|
1628
|
+
|
|
1629
|
+
// Only attempt gzip if content-encoding is gzip AND magic bytes match
|
|
1630
|
+
if (
|
|
1631
|
+
contentEncoding === "gzip" &&
|
|
1632
|
+
bufferView.length >= 2 &&
|
|
1633
|
+
bufferView[0] === 0x1f &&
|
|
1634
|
+
bufferView[1] === 0x8b
|
|
1635
|
+
) {
|
|
1636
|
+
try {
|
|
1637
|
+
const decompressed = Bun.gunzipSync(bufferView);
|
|
1638
|
+
buffer = decompressed.buffer.slice(
|
|
1639
|
+
decompressed.byteOffset,
|
|
1640
|
+
decompressed.byteOffset + decompressed.byteLength
|
|
1641
|
+
);
|
|
1642
|
+
} catch {
|
|
1643
|
+
// Silently ignore decompression failures - data may already be decompressed
|
|
1644
|
+
}
|
|
1645
|
+
}
|
|
1646
|
+
}
|
|
1647
|
+
|
|
1648
|
+
let bodyUsed = false;
|
|
1649
|
+
const consumeBody = () => {
|
|
1650
|
+
bodyUsed = true;
|
|
1651
|
+
};
|
|
1652
|
+
|
|
1653
|
+
const bodyObj: JirenResponseBody<T> = {
|
|
1654
|
+
bodyUsed: false,
|
|
1655
|
+
arrayBuffer: async () => {
|
|
1656
|
+
consumeBody();
|
|
1657
|
+
if (Buffer.isBuffer(buffer)) {
|
|
1658
|
+
const buf = buffer as Buffer;
|
|
1659
|
+
return buf.buffer.slice(
|
|
1660
|
+
buf.byteOffset,
|
|
1661
|
+
buf.byteOffset + buf.byteLength
|
|
1662
|
+
) as ArrayBuffer;
|
|
1663
|
+
}
|
|
1664
|
+
return buffer as ArrayBuffer;
|
|
1665
|
+
},
|
|
1666
|
+
blob: async () => {
|
|
1667
|
+
consumeBody();
|
|
1668
|
+
return new Blob([buffer]);
|
|
1669
|
+
},
|
|
1670
|
+
text: async () => {
|
|
1671
|
+
consumeBody();
|
|
1672
|
+
return this.decoder.decode(buffer);
|
|
1673
|
+
},
|
|
1674
|
+
json: async <R = T>(): Promise<R> => {
|
|
1675
|
+
consumeBody();
|
|
1676
|
+
return JSON.parse(this.decoder.decode(buffer));
|
|
1677
|
+
},
|
|
1678
|
+
jsonFields: async <Fields extends Record<string, any> = any>(
|
|
1679
|
+
fields: (keyof Fields)[]
|
|
1680
|
+
) => {
|
|
1681
|
+
consumeBody();
|
|
1682
|
+
const text = this.decoder.decode(buffer);
|
|
1683
|
+
return parseJsonFields<Fields>(text, fields);
|
|
1684
|
+
},
|
|
1685
|
+
};
|
|
1686
|
+
|
|
1687
|
+
Object.defineProperty(bodyObj, "bodyUsed", { get: () => bodyUsed });
|
|
1688
|
+
|
|
1689
|
+
return {
|
|
1690
|
+
url,
|
|
1691
|
+
status,
|
|
1692
|
+
statusText: STATUS_TEXT[status] || "",
|
|
1693
|
+
headers: headersProxy,
|
|
1694
|
+
ok: status >= 200 && status < 300,
|
|
1695
|
+
redirected: false,
|
|
1696
|
+
type: "basic",
|
|
1697
|
+
body: bodyObj,
|
|
1698
|
+
} as JirenResponse<T>;
|
|
1699
|
+
} finally {
|
|
1700
|
+
lib.symbols.zclient_response_full_free(respPtr);
|
|
1701
|
+
}
|
|
1702
|
+
}
|
|
1703
|
+
|
|
1704
|
+
/**
|
|
1705
|
+
* Helper to prepare body and headers for requests.
|
|
1706
|
+
* Handles JSON stringification and Content-Type header.
|
|
1707
|
+
*/
|
|
1708
|
+
private prepareBody(
|
|
1709
|
+
body: string | object | null | undefined,
|
|
1710
|
+
userHeaders?: Record<string, string>
|
|
1711
|
+
): { headers: Record<string, string>; serializedBody: string | null } {
|
|
1712
|
+
let serializedBody: string | null = null;
|
|
1713
|
+
const headers = { ...userHeaders };
|
|
1714
|
+
|
|
1715
|
+
if (body !== null && body !== undefined) {
|
|
1716
|
+
if (typeof body === "object") {
|
|
1717
|
+
serializedBody = JSON.stringify(body);
|
|
1718
|
+
// Add Content-Type if not present (case-insensitive check)
|
|
1719
|
+
const hasContentType = Object.keys(headers).some(
|
|
1720
|
+
(k) => k.toLowerCase() === "content-type"
|
|
1721
|
+
);
|
|
1722
|
+
if (!hasContentType) {
|
|
1723
|
+
headers["Content-Type"] = "application/json";
|
|
1724
|
+
}
|
|
1725
|
+
} else {
|
|
1726
|
+
serializedBody = String(body);
|
|
1727
|
+
}
|
|
1728
|
+
}
|
|
1729
|
+
|
|
1730
|
+
return { headers, serializedBody };
|
|
1731
|
+
}
|
|
1732
|
+
}
|
|
1733
|
+
|
|
1734
|
+
class NativeHeaders {
|
|
1735
|
+
private raw: Uint8Array;
|
|
1736
|
+
private len: number;
|
|
1737
|
+
private decoder = new TextDecoder();
|
|
1738
|
+
private cache = new Map<string, string>();
|
|
1739
|
+
// We need a pointer to the raw buffer for FFI calls.
|
|
1740
|
+
// Since we can't easily rely on ptr(this.raw) being stable if we stored it,
|
|
1741
|
+
// we will pass this.raw to the FFI call directly each time.
|
|
1742
|
+
|
|
1743
|
+
constructor(raw: Uint8Array) {
|
|
1744
|
+
this.raw = raw;
|
|
1745
|
+
this.len = raw.byteLength;
|
|
1746
|
+
}
|
|
1747
|
+
|
|
1748
|
+
get(name: string): string | null {
|
|
1749
|
+
const target = name.toLowerCase();
|
|
1750
|
+
if (this.cache.has(target)) return this.cache.get(target)!;
|
|
1751
|
+
|
|
1752
|
+
const keyBuf = Buffer.from(target + "\0");
|
|
1753
|
+
|
|
1754
|
+
// Debug log
|
|
1755
|
+
// Pass the raw buffer directly. Bun handles the pointer.
|
|
1756
|
+
const resPtr = lib.symbols.z_find_header_value(
|
|
1757
|
+
this.raw as any,
|
|
1758
|
+
this.len,
|
|
1759
|
+
keyBuf
|
|
1760
|
+
);
|
|
1761
|
+
|
|
1762
|
+
if (!resPtr) return null;
|
|
1763
|
+
|
|
1764
|
+
try {
|
|
1765
|
+
// ZHeaderValue: { value_ptr: pointer, value_len: size_t }
|
|
1766
|
+
// Assuming 64-bit architecture, pointers and size_t are 8 bytes.
|
|
1767
|
+
// Struct size = 16 bytes.
|
|
1768
|
+
const view = new DataView(toArrayBuffer(resPtr, 0, 16));
|
|
1769
|
+
const valPtr = view.getBigUint64(0, true);
|
|
1770
|
+
const valLen = Number(view.getBigUint64(8, true));
|
|
1771
|
+
|
|
1772
|
+
if (valLen === 0) {
|
|
1773
|
+
this.cache.set(target, "");
|
|
1774
|
+
return "";
|
|
1775
|
+
}
|
|
1776
|
+
|
|
1777
|
+
// Convert valPtr to ArrayBuffer
|
|
1778
|
+
// Note: valPtr points inside this.raw, but toArrayBuffer(ptr) creates a view on that memory.
|
|
1779
|
+
const valBytes = toArrayBuffer(Number(valPtr) as any, 0, valLen);
|
|
1780
|
+
const val = this.decoder.decode(valBytes);
|
|
1781
|
+
|
|
1782
|
+
this.cache.set(target, val);
|
|
1783
|
+
return val;
|
|
1784
|
+
} finally {
|
|
1785
|
+
lib.symbols.zclient_header_value_free(resPtr);
|
|
1786
|
+
}
|
|
1787
|
+
}
|
|
1788
|
+
|
|
1789
|
+
// Fallback for when full object is needed (e.g. debugging)
|
|
1790
|
+
// This is expensive as it reparses everything using the old offset method
|
|
1791
|
+
// BUT we don't have the offset method easily available on the raw buffer unless we expose a new one?
|
|
1792
|
+
// Wait, `zclient_response_parse_header_offsets` takes `Response*`.
|
|
1793
|
+
// We don't have Response* anymore.
|
|
1794
|
+
// We need `z_parse_header_offsets_from_raw(ptr, len)`.
|
|
1795
|
+
// Or just parse in JS since we have the full buffer?
|
|
1796
|
+
// Actually, we can just do a JS parser since we have the buffer.
|
|
1797
|
+
// It's a fallback anyway.
|
|
1798
|
+
// Convert all headers to a JS object using native parsing
|
|
1799
|
+
toJSON(): Record<string, string> {
|
|
1800
|
+
const obj: Record<string, string> = {};
|
|
1801
|
+
|
|
1802
|
+
// Use native batch header parsing (faster than JS string splitting)
|
|
1803
|
+
const offsetsPtr = lib.symbols.z_parse_all_headers(
|
|
1804
|
+
this.raw as any,
|
|
1805
|
+
this.len
|
|
1806
|
+
);
|
|
1807
|
+
if (!offsetsPtr) {
|
|
1808
|
+
// Fallback to cached values if native parsing fails
|
|
1809
|
+
for (const [key, value] of this.cache.entries()) {
|
|
1810
|
+
obj[key] = value;
|
|
1811
|
+
}
|
|
1812
|
+
return obj;
|
|
1813
|
+
}
|
|
1814
|
+
|
|
1815
|
+
try {
|
|
1816
|
+
// ZHeaderOffsetList: { items: *ZHeaderOffset, len: usize }
|
|
1817
|
+
// ZHeaderOffset: { name_start: u32, name_len: u32, value_start: u32, value_len: u32 }
|
|
1818
|
+
const listView = new DataView(toArrayBuffer(offsetsPtr, 0, 16));
|
|
1819
|
+
const itemsPtr = Number(listView.getBigUint64(0, true));
|
|
1820
|
+
const count = Number(listView.getBigUint64(8, true));
|
|
1821
|
+
|
|
1822
|
+
if (count === 0 || !itemsPtr) return obj;
|
|
1823
|
+
|
|
1824
|
+
// Read all header offsets (16 bytes each)
|
|
1825
|
+
const offsetsData = toArrayBuffer(itemsPtr as any, 0, count * 16);
|
|
1826
|
+
const offsetsView = new DataView(offsetsData);
|
|
1827
|
+
|
|
1828
|
+
for (let i = 0; i < count; i++) {
|
|
1829
|
+
const base = i * 16;
|
|
1830
|
+
const nameStart = offsetsView.getUint32(base, true);
|
|
1831
|
+
const nameLen = offsetsView.getUint32(base + 4, true);
|
|
1832
|
+
const valueStart = offsetsView.getUint32(base + 8, true);
|
|
1833
|
+
const valueLen = offsetsView.getUint32(base + 12, true);
|
|
1834
|
+
|
|
1835
|
+
const key = this.decoder
|
|
1836
|
+
.decode(this.raw.subarray(nameStart, nameStart + nameLen))
|
|
1837
|
+
.toLowerCase();
|
|
1838
|
+
const value = this.decoder.decode(
|
|
1839
|
+
this.raw.subarray(valueStart, valueStart + valueLen)
|
|
1840
|
+
);
|
|
1841
|
+
|
|
1842
|
+
obj[key] = value;
|
|
1843
|
+
// Also populate cache for future get() calls
|
|
1844
|
+
this.cache.set(key, value);
|
|
1845
|
+
}
|
|
1846
|
+
|
|
1847
|
+
return obj;
|
|
1848
|
+
} finally {
|
|
1849
|
+
lib.symbols.z_header_offsets_free(offsetsPtr);
|
|
1850
|
+
}
|
|
1851
|
+
}
|
|
1852
|
+
}
|