jiren 1.3.1 → 1.4.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.
package/README.md CHANGED
@@ -46,6 +46,7 @@ console.log(users);
46
46
  - [Basic Usage](#basic-usage)
47
47
  - [Response Caching](#response-caching)
48
48
  - [Anti-Bot Protection](#anti-bot-protection)
49
+ - [Request Interceptors](#request-interceptors)
49
50
  - [Advanced Features](#advanced-features)
50
51
  - [Performance](#performance)
51
52
  - [API Reference](#api-reference)
@@ -242,6 +243,57 @@ const response = await client.url.protected.get({
242
243
 
243
244
  ---
244
245
 
246
+ ## Request Interceptors
247
+
248
+ Add middleware to intercept requests and responses for logging, auth injection, and more:
249
+
250
+ ### Basic Interceptors
251
+
252
+ ```typescript
253
+ const client = new JirenClient({
254
+ warmup: { api: "https://api.example.com" },
255
+ interceptors: {
256
+ // Modify requests before sending
257
+ request: [
258
+ (ctx) => ({
259
+ ...ctx,
260
+ headers: { ...ctx.headers, Authorization: `Bearer ${getToken()}` },
261
+ }),
262
+ ],
263
+ // Transform responses after receiving
264
+ response: [
265
+ (ctx) => {
266
+ console.log(`[${ctx.response.status}] ${ctx.request.url}`);
267
+ return ctx;
268
+ },
269
+ ],
270
+ // Handle errors
271
+ error: [(err, ctx) => console.error(`Failed: ${ctx.url}`, err)],
272
+ },
273
+ });
274
+ ```
275
+
276
+ ### Dynamic Interceptors with `use()`
277
+
278
+ ```typescript
279
+ // Add interceptors after client creation
280
+ client.use({
281
+ request: [
282
+ (ctx) => ({ ...ctx, headers: { ...ctx.headers, "X-Custom": "value" } }),
283
+ ],
284
+ });
285
+ ```
286
+
287
+ ### Interceptor Types
288
+
289
+ | Type | Purpose | Context |
290
+ | ---------- | ------------------------------------------------ | -------------------------------- |
291
+ | `request` | Modify method, URL, headers, body before sending | `{ method, url, headers, body }` |
292
+ | `response` | Transform response after receiving | `{ request, response }` |
293
+ | `error` | Handle errors centrally | `(error, requestContext)` |
294
+
295
+ ---
296
+
245
297
  ## Advanced Features
246
298
 
247
299
  ### TypeScript Generics
@@ -1,8 +1,36 @@
1
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
1
+ import {
2
+ existsSync,
3
+ mkdirSync,
4
+ readFileSync,
5
+ writeFileSync,
6
+ unlinkSync,
7
+ readdirSync,
8
+ statSync,
9
+ } from "fs";
2
10
  import { gzipSync, gunzipSync } from "zlib";
3
11
  import { createHash } from "crypto";
4
12
  import { join } from "path";
5
- import type { JirenResponse } from "./types";
13
+ import type { JirenResponse, JirenResponseBody } from "./types";
14
+
15
+ /**
16
+ * Serializable cache entry - stores raw body data for reconstruction
17
+ */
18
+ interface SerializableCacheEntry {
19
+ // Store serializable parts of response
20
+ status: number;
21
+ statusText: string;
22
+ headers: Record<string, string>;
23
+ url: string;
24
+ ok: boolean;
25
+ redirected: boolean;
26
+ type: "basic" | "cors" | "default" | "error" | "opaque" | "opaqueredirect";
27
+ // Store raw body as base64 string (for binary compatibility)
28
+ bodyData: string;
29
+ bodyIsText: boolean;
30
+ // Cache metadata
31
+ timestamp: number;
32
+ ttl: number;
33
+ }
6
34
 
7
35
  interface CacheEntry {
8
36
  response: JirenResponse;
@@ -10,12 +38,224 @@ interface CacheEntry {
10
38
  ttl: number;
11
39
  }
12
40
 
41
+ /**
42
+ * Reconstruct body methods from raw data
43
+ */
44
+ function reconstructBody(bodyData: string, isText: boolean): JirenResponseBody {
45
+ let buffer: ArrayBuffer;
46
+
47
+ if (isText) {
48
+ // Text data stored directly
49
+ const encoder = new TextEncoder();
50
+ buffer = encoder.encode(bodyData).buffer as ArrayBuffer;
51
+ } else {
52
+ // Binary data stored as base64
53
+ const binaryString = atob(bodyData);
54
+ const bytes = new Uint8Array(binaryString.length);
55
+ for (let i = 0; i < binaryString.length; i++) {
56
+ bytes[i] = binaryString.charCodeAt(i);
57
+ }
58
+ buffer = bytes.buffer as ArrayBuffer;
59
+ }
60
+
61
+ return {
62
+ bodyUsed: false,
63
+ text: async () => {
64
+ if (isText) return bodyData;
65
+ const decoder = new TextDecoder();
66
+ return decoder.decode(buffer);
67
+ },
68
+ json: async () => {
69
+ const text = isText ? bodyData : new TextDecoder().decode(buffer);
70
+ return JSON.parse(text);
71
+ },
72
+ arrayBuffer: async () => buffer,
73
+ blob: async () => new Blob([buffer]),
74
+ };
75
+ }
76
+
77
+ /**
78
+ * L1 In-Memory LRU Cache Node
79
+ */
80
+ interface LRUNode {
81
+ key: string;
82
+ entry: CacheEntry;
83
+ prev: LRUNode | null;
84
+ next: LRUNode | null;
85
+ }
86
+
87
+ /**
88
+ * L1 In-Memory LRU Cache
89
+ * Provides ~0.001ms access time (vs ~5ms for disk)
90
+ */
91
+ class L1MemoryCache {
92
+ private capacity: number;
93
+ private cache: Map<string, LRUNode> = new Map();
94
+ private head: LRUNode | null = null; // Most recently used
95
+ private tail: LRUNode | null = null; // Least recently used
96
+
97
+ // Stats
98
+ public hits = 0;
99
+ public misses = 0;
100
+
101
+ constructor(capacity = 100) {
102
+ this.capacity = capacity;
103
+ }
104
+
105
+ /**
106
+ * Get from L1 cache (O(1) lookup + LRU update)
107
+ */
108
+ get(key: string): CacheEntry | null {
109
+ const node = this.cache.get(key);
110
+ if (!node) {
111
+ this.misses++;
112
+ return null;
113
+ }
114
+
115
+ // Check if expired
116
+ const now = Date.now();
117
+ if (now - node.entry.timestamp > node.entry.ttl) {
118
+ this.delete(key);
119
+ this.misses++;
120
+ return null;
121
+ }
122
+
123
+ // Move to front (most recently used)
124
+ this.moveToFront(node);
125
+ this.hits++;
126
+ return node.entry;
127
+ }
128
+
129
+ /**
130
+ * Set in L1 cache with LRU eviction
131
+ */
132
+ set(key: string, entry: CacheEntry): void {
133
+ // If key exists, update and move to front
134
+ const existing = this.cache.get(key);
135
+ if (existing) {
136
+ existing.entry = entry;
137
+ this.moveToFront(existing);
138
+ return;
139
+ }
140
+
141
+ // Create new node
142
+ const node: LRUNode = {
143
+ key,
144
+ entry,
145
+ prev: null,
146
+ next: this.head,
147
+ };
148
+
149
+ // Add to front
150
+ if (this.head) {
151
+ this.head.prev = node;
152
+ }
153
+ this.head = node;
154
+ if (!this.tail) {
155
+ this.tail = node;
156
+ }
157
+
158
+ this.cache.set(key, node);
159
+
160
+ // Evict LRU if over capacity
161
+ if (this.cache.size > this.capacity) {
162
+ this.evictLRU();
163
+ }
164
+ }
165
+
166
+ /**
167
+ * Delete from L1 cache
168
+ */
169
+ delete(key: string): void {
170
+ const node = this.cache.get(key);
171
+ if (!node) return;
172
+
173
+ this.removeNode(node);
174
+ this.cache.delete(key);
175
+ }
176
+
177
+ /**
178
+ * Clear all L1 cache
179
+ */
180
+ clear(): void {
181
+ this.cache.clear();
182
+ this.head = null;
183
+ this.tail = null;
184
+ }
185
+
186
+ /**
187
+ * Get L1 cache stats
188
+ */
189
+ stats() {
190
+ return {
191
+ size: this.cache.size,
192
+ capacity: this.capacity,
193
+ hits: this.hits,
194
+ misses: this.misses,
195
+ hitRate:
196
+ this.hits + this.misses > 0
197
+ ? ((this.hits / (this.hits + this.misses)) * 100).toFixed(2) + "%"
198
+ : "0%",
199
+ };
200
+ }
201
+
202
+ private moveToFront(node: LRUNode): void {
203
+ if (node === this.head) return; // Already at front
204
+
205
+ this.removeNode(node);
206
+
207
+ // Add to front
208
+ node.prev = null;
209
+ node.next = this.head;
210
+ if (this.head) {
211
+ this.head.prev = node;
212
+ }
213
+ this.head = node;
214
+ if (!this.tail) {
215
+ this.tail = node;
216
+ }
217
+ }
218
+
219
+ private removeNode(node: LRUNode): void {
220
+ if (node.prev) {
221
+ node.prev.next = node.next;
222
+ } else {
223
+ this.head = node.next;
224
+ }
225
+
226
+ if (node.next) {
227
+ node.next.prev = node.prev;
228
+ } else {
229
+ this.tail = node.prev;
230
+ }
231
+ }
232
+
233
+ private evictLRU(): void {
234
+ if (!this.tail) return;
235
+
236
+ const key = this.tail.key;
237
+ this.removeNode(this.tail);
238
+ this.cache.delete(key);
239
+ }
240
+ }
241
+
242
+ /**
243
+ * Two-Tier Response Cache
244
+ *
245
+ * L1: In-Memory LRU Cache (~0.001ms access) - hot data
246
+ * L2: Disk Cache with gzip (~5ms access) - persistence
247
+ *
248
+ * Read path: L1 → L2 → Network
249
+ * Write path: L1 + L2 (write-through)
250
+ */
13
251
  export class ResponseCache {
252
+ private l1: L1MemoryCache;
14
253
  private cacheDir: string;
15
- private maxSize: number;
254
+ private maxDiskSize: number;
16
255
 
17
- constructor(maxSize = 100, cacheDir = ".cache/jiren") {
18
- this.maxSize = maxSize;
256
+ constructor(l1Capacity = 100, cacheDir = ".cache/jiren", maxDiskSize = 100) {
257
+ this.l1 = new L1MemoryCache(l1Capacity);
258
+ this.maxDiskSize = maxDiskSize;
19
259
  this.cacheDir = cacheDir;
20
260
 
21
261
  // Create cache directory if it doesn't exist
@@ -45,10 +285,58 @@ export class ResponseCache {
45
285
  }
46
286
 
47
287
  /**
48
- * Get cached response if valid
288
+ * Preload L2 disk cache entry into L1 memory cache.
289
+ * Call this during initialization to ensure first request hits L1.
290
+ * @param url - Base URL to preload
291
+ * @param path - Optional path
292
+ * @param options - Optional request options
293
+ * @returns true if entry was preloaded, false if not found/expired
294
+ */
295
+ preloadL1(url: string, path?: string, options?: any): boolean {
296
+ const key = this.generateKey(url, path, options);
297
+
298
+ // Check if already in L1
299
+ if (this.l1.get(key)) {
300
+ return true;
301
+ }
302
+
303
+ // Try to load from L2 disk
304
+ const l2Entry = this.getFromDisk(key);
305
+ if (l2Entry) {
306
+ this.l1.set(key, l2Entry);
307
+ return true;
308
+ }
309
+
310
+ return false;
311
+ }
312
+
313
+ /**
314
+ * Get cached response - checks L1 first, then L2
49
315
  */
50
316
  get(url: string, path?: string, options?: any): JirenResponse | null {
51
317
  const key = this.generateKey(url, path, options);
318
+
319
+ // L1: Check in-memory cache first (~0.001ms)
320
+ const l1Entry = this.l1.get(key);
321
+ if (l1Entry) {
322
+ return l1Entry.response;
323
+ }
324
+
325
+ // L2: Check disk cache (~5ms)
326
+ const l2Entry = this.getFromDisk(key);
327
+ if (l2Entry) {
328
+ // Promote to L1 for faster future access
329
+ this.l1.set(key, l2Entry);
330
+ return l2Entry.response;
331
+ }
332
+
333
+ return null;
334
+ }
335
+
336
+ /**
337
+ * Get from L2 disk cache
338
+ */
339
+ private getFromDisk(key: string): CacheEntry | null {
52
340
  const filePath = this.getCacheFilePath(key);
53
341
 
54
342
  if (!existsSync(filePath)) return null;
@@ -60,30 +348,46 @@ export class ResponseCache {
60
348
  // Decompress
61
349
  const decompressed = gunzipSync(compressed);
62
350
  const data = decompressed.toString("utf-8");
63
- const entry: CacheEntry = JSON.parse(data);
351
+ const serialized: SerializableCacheEntry = JSON.parse(data);
64
352
 
65
353
  // Check if expired
66
354
  const now = Date.now();
67
- if (now - entry.timestamp > entry.ttl) {
355
+ if (now - serialized.timestamp > serialized.ttl) {
68
356
  // Delete expired cache file
69
357
  try {
70
- require("fs").unlinkSync(filePath);
358
+ unlinkSync(filePath);
71
359
  } catch {}
72
360
  return null;
73
361
  }
74
362
 
75
- return entry.response;
363
+ // Reconstruct the response with working body methods
364
+ const response: JirenResponse = {
365
+ status: serialized.status,
366
+ statusText: serialized.statusText,
367
+ headers: serialized.headers,
368
+ url: serialized.url,
369
+ ok: serialized.ok,
370
+ redirected: serialized.redirected,
371
+ type: serialized.type || "default",
372
+ body: reconstructBody(serialized.bodyData, serialized.bodyIsText),
373
+ };
374
+
375
+ return {
376
+ response,
377
+ timestamp: serialized.timestamp,
378
+ ttl: serialized.ttl,
379
+ };
76
380
  } catch (error) {
77
381
  // Invalid cache file, delete it
78
382
  try {
79
- require("fs").unlinkSync(filePath);
383
+ unlinkSync(filePath);
80
384
  } catch {}
81
385
  return null;
82
386
  }
83
387
  }
84
388
 
85
389
  /**
86
- * Store response in cache as compressed JSON file
390
+ * Store response in both L1 and L2 (write-through)
87
391
  */
88
392
  set(
89
393
  url: string,
@@ -93,7 +397,6 @@ export class ResponseCache {
93
397
  options?: any
94
398
  ): void {
95
399
  const key = this.generateKey(url, path, options);
96
- const filePath = this.getCacheFilePath(key);
97
400
 
98
401
  const entry: CacheEntry = {
99
402
  response,
@@ -101,9 +404,59 @@ export class ResponseCache {
101
404
  ttl,
102
405
  };
103
406
 
407
+ // L1: Store in memory (~0.001ms)
408
+ this.l1.set(key, entry);
409
+
410
+ // L2: Store on disk (async-ish, for persistence)
411
+ this.setToDisk(key, entry);
412
+ }
413
+
414
+ /**
415
+ * Write to L2 disk cache
416
+ */
417
+ private async setToDisk(key: string, entry: CacheEntry): Promise<void> {
418
+ const filePath = this.getCacheFilePath(key);
419
+
104
420
  try {
421
+ // Extract body text for serialization
422
+ let bodyData: string;
423
+ let bodyIsText = true;
424
+
425
+ try {
426
+ bodyData = await entry.response.body.text();
427
+ } catch {
428
+ // If text fails, try arrayBuffer and convert to base64
429
+ try {
430
+ const buffer = await entry.response.body.arrayBuffer();
431
+ const bytes = new Uint8Array(buffer);
432
+ let binary = "";
433
+ for (let i = 0; i < bytes.length; i++) {
434
+ binary += String.fromCharCode(bytes[i]!);
435
+ }
436
+ bodyData = btoa(binary);
437
+ bodyIsText = false;
438
+ } catch {
439
+ bodyData = "";
440
+ }
441
+ }
442
+
443
+ // Create serializable entry
444
+ const serialized: SerializableCacheEntry = {
445
+ status: entry.response.status,
446
+ statusText: entry.response.statusText,
447
+ headers: entry.response.headers as Record<string, string>,
448
+ url: entry.response.url,
449
+ ok: entry.response.ok,
450
+ redirected: entry.response.redirected,
451
+ type: entry.response.type || "default",
452
+ bodyData,
453
+ bodyIsText,
454
+ timestamp: entry.timestamp,
455
+ ttl: entry.ttl,
456
+ };
457
+
105
458
  // Convert to JSON
106
- const json = JSON.stringify(entry);
459
+ const json = JSON.stringify(serialized);
107
460
 
108
461
  // Compress with gzip
109
462
  const compressed = gzipSync(json);
@@ -120,26 +473,29 @@ export class ResponseCache {
120
473
  * Clear cache for a specific URL or all
121
474
  */
122
475
  clear(url?: string): void {
476
+ // Clear L1
477
+ this.l1.clear();
478
+
479
+ // Clear L2
123
480
  if (url) {
124
481
  // Clear all cache files for this URL
125
482
  // This is approximate since we hash the keys
126
483
  // For now, just clear all to be safe
127
- this.clearAll();
484
+ this.clearAllDisk();
128
485
  } else {
129
- this.clearAll();
486
+ this.clearAllDisk();
130
487
  }
131
488
  }
132
489
 
133
490
  /**
134
- * Clear all cache files
491
+ * Clear all L2 disk cache files
135
492
  */
136
- private clearAll(): void {
493
+ private clearAllDisk(): void {
137
494
  try {
138
- const fs = require("fs");
139
- const files = fs.readdirSync(this.cacheDir);
495
+ const files = readdirSync(this.cacheDir);
140
496
  for (const file of files) {
141
497
  if (file.endsWith(".json.gz")) {
142
- fs.unlinkSync(join(this.cacheDir, file));
498
+ unlinkSync(join(this.cacheDir, file));
143
499
  }
144
500
  }
145
501
  } catch (error) {
@@ -148,34 +504,35 @@ export class ResponseCache {
148
504
  }
149
505
 
150
506
  /**
151
- * Get cache statistics
507
+ * Get combined cache statistics
152
508
  */
153
509
  stats() {
510
+ const l1Stats = this.l1.stats();
511
+
512
+ // L2 disk stats
513
+ let diskSize = 0;
514
+ let diskFiles = 0;
515
+ let totalDiskBytes = 0;
516
+
154
517
  try {
155
- const fs = require("fs");
156
- const files = fs.readdirSync(this.cacheDir);
518
+ const files = readdirSync(this.cacheDir);
157
519
  const cacheFiles = files.filter((f: string) => f.endsWith(".json.gz"));
520
+ diskFiles = cacheFiles.length;
158
521
 
159
- // Calculate total size
160
- let totalSize = 0;
161
522
  for (const file of cacheFiles) {
162
- const stats = fs.statSync(join(this.cacheDir, file));
163
- totalSize += stats.size;
523
+ const stats = statSync(join(this.cacheDir, file));
524
+ totalDiskBytes += stats.size;
164
525
  }
526
+ } catch {}
165
527
 
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,
528
+ return {
529
+ l1: l1Stats,
530
+ l2: {
531
+ size: diskFiles,
532
+ maxSize: this.maxDiskSize,
176
533
  cacheDir: this.cacheDir,
177
- totalSizeKB: "0",
178
- };
179
- }
534
+ totalSizeKB: (totalDiskBytes / 1024).toFixed(2),
535
+ },
536
+ };
180
537
  }
181
538
  }