jiren 1.1.5 → 1.2.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,181 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
2
+ import { gzipSync, gunzipSync } from "zlib";
3
+ import { createHash } from "crypto";
4
+ import { join } from "path";
5
+ import type { JirenResponse } from "./types";
6
+
7
+ interface CacheEntry {
8
+ response: JirenResponse;
9
+ timestamp: number;
10
+ ttl: number;
11
+ }
12
+
13
+ export class ResponseCache {
14
+ private cacheDir: string;
15
+ private maxSize: number;
16
+
17
+ constructor(maxSize = 100, cacheDir = ".cache/jiren") {
18
+ this.maxSize = maxSize;
19
+ this.cacheDir = cacheDir;
20
+
21
+ // Create cache directory if it doesn't exist
22
+ if (!existsSync(this.cacheDir)) {
23
+ mkdirSync(this.cacheDir, { recursive: true });
24
+ }
25
+ }
26
+
27
+ /**
28
+ * Generate cache key from URL and options
29
+ */
30
+ private generateKey(url: string, path?: string, options?: any): string {
31
+ const fullUrl = path ? `${url}${path}` : url;
32
+ const method = options?.method || "GET";
33
+ const headers = JSON.stringify(options?.headers || {});
34
+ const key = `${method}:${fullUrl}:${headers}`;
35
+
36
+ // Hash the key to create a valid filename
37
+ return createHash("md5").update(key).digest("hex");
38
+ }
39
+
40
+ /**
41
+ * Get cache file path (compressed .gz file)
42
+ */
43
+ private getCacheFilePath(key: string): string {
44
+ return join(this.cacheDir, `${key}.json.gz`);
45
+ }
46
+
47
+ /**
48
+ * Get cached response if valid
49
+ */
50
+ get(url: string, path?: string, options?: any): JirenResponse | null {
51
+ const key = this.generateKey(url, path, options);
52
+ const filePath = this.getCacheFilePath(key);
53
+
54
+ if (!existsSync(filePath)) return null;
55
+
56
+ try {
57
+ // Read compressed file
58
+ const compressed = readFileSync(filePath);
59
+
60
+ // Decompress
61
+ const decompressed = gunzipSync(compressed);
62
+ const data = decompressed.toString("utf-8");
63
+ const entry: CacheEntry = JSON.parse(data);
64
+
65
+ // Check if expired
66
+ const now = Date.now();
67
+ if (now - entry.timestamp > entry.ttl) {
68
+ // Delete expired cache file
69
+ try {
70
+ require("fs").unlinkSync(filePath);
71
+ } catch {}
72
+ return null;
73
+ }
74
+
75
+ return entry.response;
76
+ } catch (error) {
77
+ // Invalid cache file, delete it
78
+ try {
79
+ require("fs").unlinkSync(filePath);
80
+ } catch {}
81
+ return null;
82
+ }
83
+ }
84
+
85
+ /**
86
+ * Store response in cache as compressed JSON file
87
+ */
88
+ set(
89
+ url: string,
90
+ response: JirenResponse,
91
+ ttl: number,
92
+ path?: string,
93
+ options?: any
94
+ ): void {
95
+ const key = this.generateKey(url, path, options);
96
+ const filePath = this.getCacheFilePath(key);
97
+
98
+ const entry: CacheEntry = {
99
+ response,
100
+ timestamp: Date.now(),
101
+ ttl,
102
+ };
103
+
104
+ try {
105
+ // Convert to JSON
106
+ const json = JSON.stringify(entry);
107
+
108
+ // Compress with gzip
109
+ const compressed = gzipSync(json);
110
+
111
+ // Write compressed file
112
+ writeFileSync(filePath, compressed);
113
+ } catch (error) {
114
+ // Silently fail if can't write cache
115
+ console.warn("Failed to write cache:", error);
116
+ }
117
+ }
118
+
119
+ /**
120
+ * Clear cache for a specific URL or all
121
+ */
122
+ clear(url?: string): void {
123
+ if (url) {
124
+ // Clear all cache files for this URL
125
+ // This is approximate since we hash the keys
126
+ // For now, just clear all to be safe
127
+ this.clearAll();
128
+ } else {
129
+ this.clearAll();
130
+ }
131
+ }
132
+
133
+ /**
134
+ * Clear all cache files
135
+ */
136
+ private clearAll(): void {
137
+ try {
138
+ const fs = require("fs");
139
+ const files = fs.readdirSync(this.cacheDir);
140
+ for (const file of files) {
141
+ if (file.endsWith(".json.gz")) {
142
+ fs.unlinkSync(join(this.cacheDir, file));
143
+ }
144
+ }
145
+ } catch (error) {
146
+ // Silently fail
147
+ }
148
+ }
149
+
150
+ /**
151
+ * Get cache statistics
152
+ */
153
+ stats() {
154
+ try {
155
+ const fs = require("fs");
156
+ const files = fs.readdirSync(this.cacheDir);
157
+ const cacheFiles = files.filter((f: string) => f.endsWith(".json.gz"));
158
+
159
+ // Calculate total size
160
+ let totalSize = 0;
161
+ for (const file of cacheFiles) {
162
+ const stats = fs.statSync(join(this.cacheDir, file));
163
+ totalSize += stats.size;
164
+ }
165
+
166
+ return {
167
+ size: cacheFiles.length,
168
+ maxSize: this.maxSize,
169
+ cacheDir: this.cacheDir,
170
+ totalSizeKB: (totalSize / 1024).toFixed(2),
171
+ };
172
+ } catch {
173
+ return {
174
+ size: 0,
175
+ maxSize: this.maxSize,
176
+ cacheDir: this.cacheDir,
177
+ totalSizeKB: "0",
178
+ };
179
+ }
180
+ }
181
+ }
@@ -0,0 +1,440 @@
1
+ import { ResponseCache } from "./cache";
2
+ import type {
3
+ RequestOptions,
4
+ JirenResponse,
5
+ JirenResponseBody,
6
+ WarmupUrlConfig,
7
+ UrlRequestOptions,
8
+ UrlEndpoint,
9
+ CacheConfig,
10
+ } from "./types";
11
+
12
+ /** URL configuration with optional cache */
13
+ export type UrlConfig = string | { url: string; cache?: boolean | CacheConfig };
14
+
15
+ /** Options for JirenClient constructor */
16
+ export interface JirenClientOptions<
17
+ T extends readonly WarmupUrlConfig[] | Record<string, UrlConfig> =
18
+ | readonly WarmupUrlConfig[]
19
+ | Record<string, UrlConfig>
20
+ > {
21
+ /** URLs to warmup (pre-connect) */
22
+ warmup?: string[] | T;
23
+
24
+ /** Benchmark mode (Ignored in Node.js fallback) */
25
+ benchmark?: boolean;
26
+ }
27
+
28
+ /** Helper to extract keys from Warmup Config */
29
+ export type ExtractWarmupKeys<
30
+ T extends readonly WarmupUrlConfig[] | Record<string, UrlConfig>
31
+ > = T extends readonly WarmupUrlConfig[]
32
+ ? T[number]["key"]
33
+ : T extends Record<string, UrlConfig>
34
+ ? keyof T
35
+ : never;
36
+
37
+ /** Type-safe URL accessor - maps keys to UrlEndpoint objects */
38
+ export type UrlAccessor<
39
+ T extends readonly WarmupUrlConfig[] | Record<string, UrlConfig>
40
+ > = {
41
+ [K in ExtractWarmupKeys<T>]: UrlEndpoint;
42
+ };
43
+
44
+ /**
45
+ * Helper function to define warmup URLs with type inference.
46
+ */
47
+ export function defineUrls<const T extends readonly WarmupUrlConfig[]>(
48
+ urls: T
49
+ ): T {
50
+ return urls;
51
+ }
52
+
53
+ export class JirenClient<
54
+ T extends readonly WarmupUrlConfig[] | Record<string, UrlConfig> =
55
+ | readonly WarmupUrlConfig[]
56
+ | Record<string, UrlConfig>
57
+ > {
58
+ private urlMap: Map<string, string> = new Map();
59
+ private cacheConfig: Map<string, { enabled: boolean; ttl: number }> =
60
+ new Map();
61
+ private cache: ResponseCache;
62
+
63
+ /** Type-safe URL accessor for warmed-up URLs */
64
+ public readonly url: UrlAccessor<T>;
65
+
66
+ constructor(options?: JirenClientOptions<T>) {
67
+ // Initialize cache
68
+ this.cache = new ResponseCache(100);
69
+
70
+ // Process warmup URLs
71
+ if (options?.warmup) {
72
+ const urls: string[] = [];
73
+ const warmup = options.warmup;
74
+
75
+ if (Array.isArray(warmup)) {
76
+ for (const item of warmup) {
77
+ if (typeof item === "string") {
78
+ urls.push(item);
79
+ } else {
80
+ // WarmupUrlConfig with key and optional cache
81
+ const config = item as WarmupUrlConfig;
82
+ urls.push(config.url);
83
+ this.urlMap.set(config.key, config.url);
84
+
85
+ // Store cache config
86
+ if (config.cache) {
87
+ const cacheConfig =
88
+ typeof config.cache === "boolean"
89
+ ? { enabled: true, ttl: 60000 }
90
+ : { enabled: true, ttl: config.cache.ttl || 60000 };
91
+ this.cacheConfig.set(config.key, cacheConfig);
92
+ }
93
+ }
94
+ }
95
+ } else {
96
+ // Record<string, UrlConfig>
97
+ for (const [key, urlConfig] of Object.entries(warmup)) {
98
+ if (typeof urlConfig === "string") {
99
+ // Simple string URL
100
+ urls.push(urlConfig);
101
+ this.urlMap.set(key, urlConfig);
102
+ } else {
103
+ // URL config object with cache
104
+ urls.push(urlConfig.url);
105
+ this.urlMap.set(key, urlConfig.url);
106
+
107
+ // Store cache config
108
+ if (urlConfig.cache) {
109
+ const cacheConfig =
110
+ typeof urlConfig.cache === "boolean"
111
+ ? { enabled: true, ttl: 60000 }
112
+ : { enabled: true, ttl: urlConfig.cache.ttl || 60000 };
113
+ this.cacheConfig.set(key, cacheConfig);
114
+ }
115
+ }
116
+ }
117
+ }
118
+
119
+ if (urls.length > 0) {
120
+ this.warmup(urls);
121
+ }
122
+ }
123
+
124
+ // Create proxy for type-safe URL access
125
+ this.url = this.createUrlAccessor();
126
+ }
127
+
128
+ /**
129
+ * Creates a proxy-based URL accessor for type-safe access.
130
+ */
131
+ private createUrlAccessor(): UrlAccessor<T> {
132
+ const self = this;
133
+
134
+ return new Proxy({} as UrlAccessor<T>, {
135
+ get(_target, prop: string) {
136
+ const baseUrl = self.urlMap.get(prop);
137
+ if (!baseUrl) {
138
+ throw new Error(
139
+ `URL key "${prop}" not found. Available keys: ${Array.from(
140
+ self.urlMap.keys()
141
+ ).join(", ")}`
142
+ );
143
+ }
144
+
145
+ const buildUrl = (path?: string) =>
146
+ path
147
+ ? `${baseUrl.replace(/\/$/, "")}/${path.replace(/^\//, "")}`
148
+ : baseUrl;
149
+
150
+ return {
151
+ get: async <R = any>(
152
+ options?: UrlRequestOptions
153
+ ): Promise<JirenResponse<R> | R | string | ArrayBuffer | Blob> => {
154
+ const cacheConfig = self.cacheConfig.get(prop);
155
+
156
+ if (cacheConfig?.enabled) {
157
+ const cached = self.cache.get(baseUrl, options?.path, options);
158
+ if (cached) return cached as any;
159
+ }
160
+
161
+ const response = await self.request<R>(
162
+ "GET",
163
+ buildUrl(options?.path),
164
+ null,
165
+ {
166
+ headers: options?.headers,
167
+ maxRedirects: options?.maxRedirects,
168
+ responseType: options?.responseType,
169
+ antibot: options?.antibot,
170
+ }
171
+ );
172
+
173
+ if (
174
+ cacheConfig?.enabled &&
175
+ typeof response === "object" &&
176
+ "status" in response
177
+ ) {
178
+ self.cache.set(
179
+ baseUrl,
180
+ response as JirenResponse,
181
+ cacheConfig.ttl,
182
+ options?.path,
183
+ options
184
+ );
185
+ }
186
+
187
+ return response;
188
+ },
189
+
190
+ post: async <R = any>(
191
+ body?: string | null,
192
+ options?: UrlRequestOptions
193
+ ): Promise<JirenResponse<R> | R | string | ArrayBuffer | Blob> => {
194
+ return self.request<R>(
195
+ "POST",
196
+ buildUrl(options?.path),
197
+ body || null,
198
+ {
199
+ headers: options?.headers,
200
+ maxRedirects: options?.maxRedirects,
201
+ responseType: options?.responseType,
202
+ }
203
+ );
204
+ },
205
+
206
+ put: async <R = any>(
207
+ body?: string | null,
208
+ options?: UrlRequestOptions
209
+ ): Promise<JirenResponse<R> | R | string | ArrayBuffer | Blob> => {
210
+ return self.request<R>(
211
+ "PUT",
212
+ buildUrl(options?.path),
213
+ body || null,
214
+ {
215
+ headers: options?.headers,
216
+ maxRedirects: options?.maxRedirects,
217
+ responseType: options?.responseType,
218
+ }
219
+ );
220
+ },
221
+
222
+ patch: async <R = any>(
223
+ body?: string | null,
224
+ options?: UrlRequestOptions
225
+ ): Promise<JirenResponse<R> | R | string | ArrayBuffer | Blob> => {
226
+ return self.request<R>(
227
+ "PATCH",
228
+ buildUrl(options?.path),
229
+ body || null,
230
+ {
231
+ headers: options?.headers,
232
+ maxRedirects: options?.maxRedirects,
233
+ responseType: options?.responseType,
234
+ }
235
+ );
236
+ },
237
+
238
+ delete: async <R = any>(
239
+ body?: string | null,
240
+ options?: UrlRequestOptions
241
+ ): Promise<JirenResponse<R> | R | string | ArrayBuffer | Blob> => {
242
+ return self.request<R>(
243
+ "DELETE",
244
+ buildUrl(options?.path),
245
+ body || null,
246
+ {
247
+ headers: options?.headers,
248
+ maxRedirects: options?.maxRedirects,
249
+ responseType: options?.responseType,
250
+ }
251
+ );
252
+ },
253
+
254
+ head: async (
255
+ options?: UrlRequestOptions
256
+ ): Promise<JirenResponse<any>> => {
257
+ return self.request("HEAD", buildUrl(options?.path), null, {
258
+ headers: options?.headers,
259
+ maxRedirects: options?.maxRedirects,
260
+ antibot: options?.antibot,
261
+ });
262
+ },
263
+
264
+ options: async (
265
+ options?: UrlRequestOptions
266
+ ): Promise<JirenResponse<any>> => {
267
+ return self.request("OPTIONS", buildUrl(options?.path), null, {
268
+ headers: options?.headers,
269
+ maxRedirects: options?.maxRedirects,
270
+ antibot: options?.antibot,
271
+ });
272
+ },
273
+
274
+ prefetch: async (options?: UrlRequestOptions): Promise<void> => {
275
+ self.cache.clear(baseUrl);
276
+ const cacheConfig = self.cacheConfig.get(prop);
277
+ if (cacheConfig?.enabled) {
278
+ await self.request("GET", buildUrl(options?.path), null, {
279
+ headers: options?.headers,
280
+ maxRedirects: options?.maxRedirects,
281
+ antibot: options?.antibot,
282
+ });
283
+ }
284
+ },
285
+ } as UrlEndpoint;
286
+ },
287
+ });
288
+ }
289
+
290
+ public close(): void {
291
+ // No-op for fetch client
292
+ }
293
+
294
+ public async warmup(urls: string[]): Promise<void> {
295
+ // Basic prefetch for Node.js fallback (HEAD request)
296
+ await Promise.allSettled(
297
+ urls.map((url) =>
298
+ fetch(url, { method: "HEAD", signal: AbortSignal.timeout(2000) }).catch(
299
+ () => {}
300
+ )
301
+ )
302
+ );
303
+ }
304
+
305
+ /**
306
+ * @deprecated Use warmup() instead
307
+ */
308
+ public prefetch(urls: string[]): void {
309
+ this.warmup(urls);
310
+ }
311
+
312
+ public async request<T = any>(
313
+ method: string,
314
+ url: string,
315
+ body?: string | null,
316
+ options?: RequestOptions & { responseType: "json" }
317
+ ): Promise<T>;
318
+ public async request<T = any>(
319
+ method: string,
320
+ url: string,
321
+ body?: string | null,
322
+ options?: RequestOptions & { responseType: "text" }
323
+ ): Promise<string>;
324
+ public async request<T = any>(
325
+ method: string,
326
+ url: string,
327
+ body?: string | null,
328
+ options?: RequestOptions & { responseType: "arraybuffer" }
329
+ ): Promise<ArrayBuffer>;
330
+ public async request<T = any>(
331
+ method: string,
332
+ url: string,
333
+ body?: string | null,
334
+ options?: RequestOptions & { responseType: "blob" }
335
+ ): Promise<Blob>;
336
+ public async request<T = any>(
337
+ method: string,
338
+ url: string,
339
+ body?: string | null,
340
+ options?: RequestOptions
341
+ ): Promise<JirenResponse<T>>;
342
+ public async request<T = any>(
343
+ method: string,
344
+ url: string,
345
+ body?: string | null,
346
+ options?: RequestOptions | Record<string, string> | null
347
+ ): Promise<JirenResponse<T> | T | string | ArrayBuffer | Blob> {
348
+ // Normalize options
349
+ let headers: Record<string, string> = {};
350
+ let responseType: RequestOptions["responseType"] | undefined;
351
+
352
+ if (options) {
353
+ if (
354
+ "maxRedirects" in options ||
355
+ "headers" in options ||
356
+ "responseType" in options ||
357
+ "method" in options ||
358
+ "timeout" in options ||
359
+ "antibot" in options
360
+ ) {
361
+ const opts = options as RequestOptions;
362
+ if (opts.headers) headers = opts.headers;
363
+ if (opts.responseType) responseType = opts.responseType;
364
+ } else {
365
+ headers = options as Record<string, string>;
366
+ }
367
+ }
368
+
369
+ const fetchOptions: RequestInit = {
370
+ method,
371
+ headers: headers,
372
+ body: body || undefined,
373
+ redirect: "follow", // Native client follows redirects
374
+ };
375
+
376
+ const response = await fetch(url, fetchOptions);
377
+
378
+ // Auto-parse if requested
379
+ if (responseType) {
380
+ if (responseType === "json") return response.json() as Promise<T>;
381
+ if (responseType === "text") return response.text();
382
+ if (responseType === "arraybuffer") return response.arrayBuffer();
383
+ if (responseType === "blob") return response.blob();
384
+ }
385
+
386
+ // Construct JirenResponse
387
+ const responseHeaders: Record<string, string> = {};
388
+ response.headers.forEach((val, key) => {
389
+ responseHeaders[key] = val;
390
+ });
391
+
392
+ // Create a clone for each reading method to avoid "body used" error if user reads multiple times
393
+ // However, fetch body can only be read once. We replicate the behavior of the native client
394
+ // where we can read it. To do that we might need to buffer it OR just expose the raw response.
395
+ // The native client implementation buffers the whole body. Let's match that.
396
+
397
+ const buffer = await response.arrayBuffer();
398
+
399
+ let bodyUsed = false;
400
+ const consumeBody = () => {
401
+ bodyUsed = true;
402
+ };
403
+
404
+ const bodyObj: JirenResponseBody<T> = {
405
+ bodyUsed: false,
406
+ arrayBuffer: async () => {
407
+ consumeBody();
408
+ return buffer.slice(0);
409
+ },
410
+ blob: async () => {
411
+ consumeBody();
412
+ return new Blob([buffer]);
413
+ },
414
+ text: async () => {
415
+ consumeBody();
416
+ return new TextDecoder().decode(buffer);
417
+ },
418
+ json: async <R = T>(): Promise<R> => {
419
+ consumeBody();
420
+ const text = new TextDecoder().decode(buffer);
421
+ return JSON.parse(text);
422
+ },
423
+ };
424
+
425
+ Object.defineProperty(bodyObj, "bodyUsed", {
426
+ get: () => bodyUsed,
427
+ });
428
+
429
+ return {
430
+ url: response.url,
431
+ status: response.status,
432
+ statusText: response.statusText,
433
+ headers: responseHeaders,
434
+ ok: response.ok,
435
+ redirected: response.redirected,
436
+ type: response.type,
437
+ body: bodyObj,
438
+ } as JirenResponse<T>;
439
+ }
440
+ }