jiren 1.4.0 → 1.5.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.
@@ -20,6 +20,15 @@ export type {
20
20
  UrlEndpoint,
21
21
  JirenResponse,
22
22
  JirenResponseBody,
23
+ Interceptors,
24
+ RequestInterceptor,
25
+ ResponseInterceptor,
26
+ ErrorInterceptor,
27
+ InterceptorRequestContext,
28
+ InterceptorResponseContext,
29
+ EndpointMetrics,
30
+ GlobalMetrics,
31
+ MetricsAPI,
23
32
  } from "./types";
24
33
 
25
34
  // Remove broken exports
@@ -0,0 +1,420 @@
1
+ /**
2
+ * Metrics & Observability System for Jiren HTTP Client
3
+ * Tracks performance, cache efficiency, errors, and request statistics
4
+ */
5
+
6
+ export interface EndpointMetrics {
7
+ endpoint: string;
8
+ requests: {
9
+ total: number;
10
+ success: number;
11
+ failed: number;
12
+ };
13
+ statusCodes: Record<number, number>;
14
+ timing: {
15
+ avgMs: number;
16
+ minMs: number;
17
+ maxMs: number;
18
+ p50Ms: number;
19
+ p95Ms: number;
20
+ p99Ms: number;
21
+ };
22
+ cache: {
23
+ l1Hits: number;
24
+ l1Misses: number;
25
+ l2Hits: number;
26
+ l2Misses: number;
27
+ hitRate: string; // e.g., "75.5%"
28
+ };
29
+ deduplication: {
30
+ hits: number;
31
+ misses: number;
32
+ hitRate: string;
33
+ };
34
+ bytes: {
35
+ sent: number;
36
+ received: number;
37
+ };
38
+ errors: Record<string, number>;
39
+ lastRequestAt: number | null;
40
+ }
41
+
42
+ export interface GlobalMetrics {
43
+ totalRequests: number;
44
+ totalSuccess: number;
45
+ totalFailed: number;
46
+ avgResponseTimeMs: number;
47
+ totalBytesSent: number;
48
+ totalBytesReceived: number;
49
+ overallCacheHitRate: string;
50
+ overallDeduplicationRate: string;
51
+ endpoints: number;
52
+ uptime: number; // milliseconds since client creation
53
+ }
54
+
55
+ export interface MetricsAPI {
56
+ get(endpoint: string): EndpointMetrics | null;
57
+ getAll(): Record<string, EndpointMetrics>;
58
+ getGlobal(): GlobalMetrics;
59
+ reset(endpoint?: string): void;
60
+ export(): string; // JSON string
61
+ }
62
+
63
+ interface RequestMetric {
64
+ startTime: number;
65
+ responseTimeMs: number;
66
+ status: number;
67
+ success: boolean;
68
+ bytesSent: number;
69
+ bytesReceived: number;
70
+ cacheHit: boolean;
71
+ cacheLayer?: "l1" | "l2"; // Which cache layer hit
72
+ dedupeHit: boolean;
73
+ error?: string;
74
+ }
75
+
76
+ class EndpointMetricsCollector {
77
+ private endpoint: string;
78
+ private requestHistory: RequestMetric[] = [];
79
+ private maxHistorySize = 10000; // Keep last 10k requests for percentile calculations
80
+
81
+ // Aggregated counters
82
+ private totalRequests = 0;
83
+ private successCount = 0;
84
+ private failedCount = 0;
85
+ private statusCodeCounts: Record<number, number> = {};
86
+ private l1CacheHits = 0;
87
+ private l1CacheMisses = 0;
88
+ private l2CacheHits = 0;
89
+ private l2CacheMisses = 0;
90
+ private dedupeHits = 0;
91
+ private dedupeMisses = 0;
92
+ private totalBytesSent = 0;
93
+ private totalBytesReceived = 0;
94
+ private errorCounts: Record<string, number> = {};
95
+ private lastRequestTimestamp: number | null = null;
96
+
97
+ // Response time tracking
98
+ private totalResponseTime = 0;
99
+ private minResponseTime = Infinity;
100
+ private maxResponseTime = 0;
101
+
102
+ constructor(endpoint: string) {
103
+ this.endpoint = endpoint;
104
+ }
105
+
106
+ /**
107
+ * Record a request metric
108
+ */
109
+ recordRequest(metric: RequestMetric): void {
110
+ this.totalRequests++;
111
+ this.lastRequestTimestamp = Date.now();
112
+
113
+ // Success/failure
114
+ if (metric.success) {
115
+ this.successCount++;
116
+ } else {
117
+ this.failedCount++;
118
+ }
119
+
120
+ // Status codes
121
+ this.statusCodeCounts[metric.status] =
122
+ (this.statusCodeCounts[metric.status] || 0) + 1;
123
+
124
+ // Cache tracking
125
+ if (metric.cacheHit) {
126
+ if (metric.cacheLayer === "l1") {
127
+ this.l1CacheHits++;
128
+ } else if (metric.cacheLayer === "l2") {
129
+ this.l2CacheHits++;
130
+ }
131
+ } else {
132
+ // Only count as miss if cache was checked (not deduped)
133
+ if (!metric.dedupeHit) {
134
+ this.l1CacheMisses++;
135
+ this.l2CacheMisses++;
136
+ }
137
+ }
138
+
139
+ // Deduplication
140
+ if (metric.dedupeHit) {
141
+ this.dedupeHits++;
142
+ } else {
143
+ this.dedupeMisses++;
144
+ }
145
+
146
+ // Bytes
147
+ this.totalBytesSent += metric.bytesSent;
148
+ this.totalBytesReceived += metric.bytesReceived;
149
+
150
+ // Errors
151
+ if (metric.error) {
152
+ this.errorCounts[metric.error] =
153
+ (this.errorCounts[metric.error] || 0) + 1;
154
+ }
155
+
156
+ // Response time
157
+ this.totalResponseTime += metric.responseTimeMs;
158
+ this.minResponseTime = Math.min(
159
+ this.minResponseTime,
160
+ metric.responseTimeMs
161
+ );
162
+ this.maxResponseTime = Math.max(
163
+ this.maxResponseTime,
164
+ metric.responseTimeMs
165
+ );
166
+
167
+ // Store in history for percentile calculations
168
+ this.requestHistory.push(metric);
169
+ if (this.requestHistory.length > this.maxHistorySize) {
170
+ this.requestHistory.shift(); // Remove oldest
171
+ }
172
+ }
173
+
174
+ /**
175
+ * Calculate percentiles from request history
176
+ */
177
+ private calculatePercentile(percentile: number): number {
178
+ if (this.requestHistory.length === 0) return 0;
179
+
180
+ const sorted = this.requestHistory
181
+ .map((m) => m.responseTimeMs)
182
+ .sort((a, b) => a - b);
183
+
184
+ const index = Math.ceil((percentile / 100) * sorted.length) - 1;
185
+ return sorted[Math.max(0, index)] || 0;
186
+ }
187
+
188
+ /**
189
+ * Get current metrics snapshot
190
+ */
191
+ getMetrics(): EndpointMetrics {
192
+ const totalCacheAttempts = this.l1CacheHits + this.l1CacheMisses;
193
+ const totalCacheHits = this.l1CacheHits + this.l2CacheHits;
194
+ const cacheHitRate =
195
+ totalCacheAttempts > 0
196
+ ? ((totalCacheHits / totalCacheAttempts) * 100).toFixed(2) + "%"
197
+ : "0%";
198
+
199
+ const totalDedupeAttempts = this.dedupeHits + this.dedupeMisses;
200
+ const dedupeHitRate =
201
+ totalDedupeAttempts > 0
202
+ ? ((this.dedupeHits / totalDedupeAttempts) * 100).toFixed(2) + "%"
203
+ : "0%";
204
+
205
+ return {
206
+ endpoint: this.endpoint,
207
+ requests: {
208
+ total: this.totalRequests,
209
+ success: this.successCount,
210
+ failed: this.failedCount,
211
+ },
212
+ statusCodes: { ...this.statusCodeCounts },
213
+ timing: {
214
+ avgMs:
215
+ this.totalRequests > 0
216
+ ? parseFloat(
217
+ (this.totalResponseTime / this.totalRequests).toFixed(2)
218
+ )
219
+ : 0,
220
+ minMs:
221
+ this.minResponseTime === Infinity
222
+ ? 0
223
+ : parseFloat(this.minResponseTime.toFixed(2)),
224
+ maxMs: parseFloat(this.maxResponseTime.toFixed(2)),
225
+ p50Ms: parseFloat(this.calculatePercentile(50).toFixed(2)),
226
+ p95Ms: parseFloat(this.calculatePercentile(95).toFixed(2)),
227
+ p99Ms: parseFloat(this.calculatePercentile(99).toFixed(2)),
228
+ },
229
+ cache: {
230
+ l1Hits: this.l1CacheHits,
231
+ l1Misses: this.l1CacheMisses,
232
+ l2Hits: this.l2CacheHits,
233
+ l2Misses: this.l2CacheMisses,
234
+ hitRate: cacheHitRate,
235
+ },
236
+ deduplication: {
237
+ hits: this.dedupeHits,
238
+ misses: this.dedupeMisses,
239
+ hitRate: dedupeHitRate,
240
+ },
241
+ bytes: {
242
+ sent: this.totalBytesSent,
243
+ received: this.totalBytesReceived,
244
+ },
245
+ errors: { ...this.errorCounts },
246
+ lastRequestAt: this.lastRequestTimestamp,
247
+ };
248
+ }
249
+
250
+ /**
251
+ * Reset all metrics
252
+ */
253
+ reset(): void {
254
+ this.requestHistory = [];
255
+ this.totalRequests = 0;
256
+ this.successCount = 0;
257
+ this.failedCount = 0;
258
+ this.statusCodeCounts = {};
259
+ this.l1CacheHits = 0;
260
+ this.l1CacheMisses = 0;
261
+ this.l2CacheHits = 0;
262
+ this.l2CacheMisses = 0;
263
+ this.dedupeHits = 0;
264
+ this.dedupeMisses = 0;
265
+ this.totalBytesSent = 0;
266
+ this.totalBytesReceived = 0;
267
+ this.errorCounts = {};
268
+ this.lastRequestTimestamp = null;
269
+ this.totalResponseTime = 0;
270
+ this.minResponseTime = Infinity;
271
+ this.maxResponseTime = 0;
272
+ }
273
+ }
274
+
275
+ /**
276
+ * Main Metrics Collector - manages metrics for all endpoints
277
+ */
278
+ export class MetricsCollector implements MetricsAPI {
279
+ private endpoints: Map<string, EndpointMetricsCollector> = new Map();
280
+ private startTime: number;
281
+
282
+ constructor() {
283
+ this.startTime = Date.now();
284
+ }
285
+
286
+ /**
287
+ * Get or create endpoint collector
288
+ */
289
+ private getEndpointCollector(endpoint: string): EndpointMetricsCollector {
290
+ if (!this.endpoints.has(endpoint)) {
291
+ this.endpoints.set(endpoint, new EndpointMetricsCollector(endpoint));
292
+ }
293
+ return this.endpoints.get(endpoint)!;
294
+ }
295
+
296
+ /**
297
+ * Record a request event
298
+ */
299
+ recordRequest(endpoint: string, metric: RequestMetric): void {
300
+ this.getEndpointCollector(endpoint).recordRequest(metric);
301
+ }
302
+
303
+ /**
304
+ * Get metrics for a specific endpoint
305
+ */
306
+ get(endpoint: string): EndpointMetrics | null {
307
+ const collector = this.endpoints.get(endpoint);
308
+ return collector ? collector.getMetrics() : null;
309
+ }
310
+
311
+ /**
312
+ * Get metrics for all endpoints
313
+ */
314
+ getAll(): Record<string, EndpointMetrics> {
315
+ const result: Record<string, EndpointMetrics> = {};
316
+ for (const [endpoint, collector] of this.endpoints) {
317
+ result[endpoint] = collector.getMetrics();
318
+ }
319
+ return result;
320
+ }
321
+
322
+ /**
323
+ * Get global aggregated metrics
324
+ */
325
+ getGlobal(): GlobalMetrics {
326
+ let totalRequests = 0;
327
+ let totalSuccess = 0;
328
+ let totalFailed = 0;
329
+ let totalResponseTime = 0;
330
+ let totalBytesSent = 0;
331
+ let totalBytesReceived = 0;
332
+ let totalCacheHits = 0;
333
+ let totalCacheAttempts = 0;
334
+ let totalDedupeHits = 0;
335
+ let totalDedupeAttempts = 0;
336
+
337
+ for (const collector of this.endpoints.values()) {
338
+ const metrics = collector.getMetrics();
339
+ totalRequests += metrics.requests.total;
340
+ totalSuccess += metrics.requests.success;
341
+ totalFailed += metrics.requests.failed;
342
+ totalResponseTime += metrics.timing.avgMs * metrics.requests.total;
343
+ totalBytesSent += metrics.bytes.sent;
344
+ totalBytesReceived += metrics.bytes.received;
345
+
346
+ totalCacheHits += metrics.cache.l1Hits + metrics.cache.l2Hits;
347
+ totalCacheAttempts +=
348
+ metrics.cache.l1Hits +
349
+ metrics.cache.l1Misses +
350
+ metrics.cache.l2Hits +
351
+ metrics.cache.l2Misses;
352
+
353
+ totalDedupeHits += metrics.deduplication.hits;
354
+ totalDedupeAttempts +=
355
+ metrics.deduplication.hits + metrics.deduplication.misses;
356
+ }
357
+
358
+ const avgResponseTimeMs =
359
+ totalRequests > 0 ? totalResponseTime / totalRequests : 0;
360
+
361
+ const overallCacheHitRate =
362
+ totalCacheAttempts > 0
363
+ ? ((totalCacheHits / totalCacheAttempts) * 100).toFixed(2) + "%"
364
+ : "0%";
365
+
366
+ const overallDeduplicationRate =
367
+ totalDedupeAttempts > 0
368
+ ? ((totalDedupeHits / totalDedupeAttempts) * 100).toFixed(2) + "%"
369
+ : "0%";
370
+
371
+ return {
372
+ totalRequests,
373
+ totalSuccess,
374
+ totalFailed,
375
+ avgResponseTimeMs: parseFloat(avgResponseTimeMs.toFixed(2)),
376
+ totalBytesSent,
377
+ totalBytesReceived,
378
+ overallCacheHitRate,
379
+ overallDeduplicationRate,
380
+ endpoints: this.endpoints.size,
381
+ uptime: Date.now() - this.startTime,
382
+ };
383
+ }
384
+
385
+ /**
386
+ * Reset metrics for specific endpoint or all
387
+ */
388
+ reset(endpoint?: string): void {
389
+ if (endpoint) {
390
+ const collector = this.endpoints.get(endpoint);
391
+ if (collector) {
392
+ collector.reset();
393
+ }
394
+ } else {
395
+ // Reset all
396
+ for (const collector of this.endpoints.values()) {
397
+ collector.reset();
398
+ }
399
+ this.startTime = Date.now();
400
+ }
401
+ }
402
+
403
+ /**
404
+ * Export all metrics as JSON string
405
+ */
406
+ export(): string {
407
+ return JSON.stringify(
408
+ {
409
+ global: this.getGlobal(),
410
+ endpoints: this.getAll(),
411
+ exportedAt: new Date().toISOString(),
412
+ },
413
+ null,
414
+ 2
415
+ );
416
+ }
417
+ }
418
+
419
+ // Export for use in client.ts
420
+ export type { RequestMetric };
@@ -0,0 +1,181 @@
1
+ import { toArrayBuffer, type Pointer } from "bun:ffi";
2
+ import { lib } from "./native";
3
+ import type { JirenResponse, JirenResponseBody } from "./types";
4
+
5
+ /**
6
+ * Native Zig Cache wrapper
7
+ * Uses native HashMap for L1 (~0.001ms) and gzip disk storage for L2 (~2-5ms)
8
+ */
9
+ export class NativeCache {
10
+ private ptr: Pointer | null;
11
+
12
+ constructor(l1Capacity = 100) {
13
+ this.ptr = lib.symbols.zcache_new(l1Capacity) as Pointer;
14
+ if (!this.ptr) throw new Error("Failed to create native cache");
15
+ }
16
+
17
+ /**
18
+ * Get cached response by key
19
+ */
20
+ get(url: string, path?: string, options?: any): JirenResponse | null {
21
+ if (!this.ptr) return null;
22
+
23
+ const key = this.generateKey(url, path, options);
24
+ const keyBuffer = Buffer.from(key + "\0");
25
+
26
+ const entryPtr = lib.symbols.zcache_get(this.ptr, keyBuffer) as Pointer;
27
+ if (!entryPtr) return null;
28
+
29
+ try {
30
+ // Read ZCacheEntry struct:
31
+ // The struct layout in memory (with alignment):
32
+ // u16 status (offset 0, 2 bytes)
33
+ // [6 bytes padding]
34
+ // ptr headers_ptr (offset 8, 8 bytes)
35
+ // usize headers_len (offset 16, 8 bytes)
36
+ // ptr body_ptr (offset 24, 8 bytes)
37
+ // usize body_len (offset 32, 8 bytes)
38
+
39
+ // Create a view of the entry struct
40
+ const entryBytes = toArrayBuffer(entryPtr, 0, 40);
41
+ const entryView = new DataView(entryBytes);
42
+ const status = entryView.getUint16(0, true);
43
+
44
+ // Read pointer values as numbers (Bun FFI specific)
45
+ const headersLen = Number(entryView.getBigUint64(16, true));
46
+ const bodyLen = Number(entryView.getBigUint64(32, true));
47
+
48
+ // For now, create minimal headers and body
49
+ // The data is stored in native memory - reading pointers across FFI is complex
50
+ // We'll use the cached lengths to construct placeholder data
51
+
52
+ // Create an empty but valid response
53
+ const headers: Record<string, string> = {};
54
+
55
+ // Create body with empty data for now
56
+ // In a full implementation, we'd need to properly read from native pointers
57
+ const bodyBuffer = new ArrayBuffer(0);
58
+
59
+ // Reconstruct body methods
60
+ const body: JirenResponseBody = {
61
+ bodyUsed: false,
62
+ text: async () => "",
63
+ json: async <R>() => ({} as R),
64
+ arrayBuffer: async () => bodyBuffer,
65
+ blob: async () => new Blob([bodyBuffer]),
66
+ };
67
+
68
+ return {
69
+ status,
70
+ statusText: status === 200 ? "OK" : String(status),
71
+ headers,
72
+ url,
73
+ ok: status >= 200 && status < 300,
74
+ redirected: false,
75
+ type: "default",
76
+ body,
77
+ };
78
+ } finally {
79
+ lib.symbols.zcache_entry_free(entryPtr);
80
+ }
81
+ }
82
+
83
+ /**
84
+ * Set response in cache
85
+ */
86
+ set(
87
+ url: string,
88
+ response: JirenResponse,
89
+ ttl: number,
90
+ path?: string,
91
+ options?: any
92
+ ): void {
93
+ if (!this.ptr) return;
94
+
95
+ const key = this.generateKey(url, path, options);
96
+ const keyBuffer = Buffer.from(key + "\0");
97
+
98
+ // Get body and headers as buffers
99
+ // We need to read body synchronously, so we'll need the original data
100
+ // For cached responses, we should store the raw data
101
+ // This is a simplified version - in production, handle async properly
102
+
103
+ // For now, skip if body is not immediately available
104
+ // The proper solution would be to call this after body is consumed
105
+ }
106
+
107
+ /**
108
+ * Set response with raw data (called after body is available)
109
+ */
110
+ async setWithData(
111
+ url: string,
112
+ status: number,
113
+ headers: string,
114
+ body: string,
115
+ ttl: number,
116
+ path?: string,
117
+ options?: any
118
+ ): Promise<void> {
119
+ if (!this.ptr) return;
120
+
121
+ const key = this.generateKey(url, path, options);
122
+ const keyBuffer = Buffer.from(key + "\0");
123
+
124
+ const headersBuffer = Buffer.from(headers);
125
+ const bodyBuffer = Buffer.from(body);
126
+
127
+ lib.symbols.zcache_set(
128
+ this.ptr,
129
+ keyBuffer,
130
+ status,
131
+ headersBuffer,
132
+ headersBuffer.length,
133
+ bodyBuffer,
134
+ bodyBuffer.length,
135
+ ttl
136
+ );
137
+ }
138
+
139
+ /**
140
+ * Preload L2 disk cache into L1 memory
141
+ */
142
+ preloadL1(url: string, path?: string, options?: any): boolean {
143
+ if (!this.ptr) return false;
144
+
145
+ const key = this.generateKey(url, path, options);
146
+ const keyBuffer = Buffer.from(key + "\0");
147
+
148
+ return lib.symbols.zcache_preload_l1(
149
+ this.ptr,
150
+ keyBuffer
151
+ ) as unknown as boolean;
152
+ }
153
+
154
+ /**
155
+ * Clear all cache
156
+ */
157
+ clear(url?: string): void {
158
+ if (!this.ptr) return;
159
+ lib.symbols.zcache_clear(this.ptr);
160
+ }
161
+
162
+ /**
163
+ * Free native resources
164
+ */
165
+ close(): void {
166
+ if (this.ptr) {
167
+ lib.symbols.zcache_free(this.ptr);
168
+ this.ptr = null;
169
+ }
170
+ }
171
+
172
+ /**
173
+ * Generate cache key from URL and options
174
+ */
175
+ private generateKey(url: string, path?: string, options?: any): string {
176
+ const fullUrl = path ? `${url}${path}` : url;
177
+ const method = options?.method || "GET";
178
+ const headers = JSON.stringify(options?.headers || {});
179
+ return `${method}:${fullUrl}:${headers}`;
180
+ }
181
+ }
@@ -85,6 +85,32 @@ export const symbols = {
85
85
  "void *",
86
86
  "bool",
87
87
  ]),
88
+
89
+ // =========================================================================
90
+ // CACHE FFI
91
+ // =========================================================================
92
+
93
+ zcache_new: lib.func("zcache_new", "void *", ["uint64_t"]),
94
+ zcache_free: lib.func("zcache_free", "void", ["void *"]),
95
+ zcache_get: lib.func("zcache_get", "void *", ["void *", "const char *"]),
96
+ zcache_entry_free: lib.func("zcache_entry_free", "void", ["void *"]),
97
+ zcache_set: lib.func("zcache_set", "void", [
98
+ "void *", // cache
99
+ "const char *", // key
100
+ "uint16_t", // status
101
+ "void *", // headers_ptr
102
+ "uint64_t", // headers_len
103
+ "void *", // body_ptr
104
+ "uint64_t", // body_len
105
+ "int64_t", // ttl
106
+ ]),
107
+ zcache_preload_l1: lib.func("zcache_preload_l1", "bool", [
108
+ "void *",
109
+ "const char *",
110
+ ]),
111
+ zcache_clear: lib.func("zcache_clear", "void", ["void *"]),
112
+ zcache_stats: lib.func("zcache_stats", "void *", ["void *"]),
113
+ zcache_stats_free: lib.func("zcache_stats_free", "void", ["void *"]),
88
114
  };
89
115
 
90
116
  // Export a wrapper that matches structure of bun:ffi lib