fetchguard 1.6.2 → 2.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/dist/index.js CHANGED
@@ -18,7 +18,8 @@ var MSG = Object.freeze({
18
18
  AUTH_STATE_CHANGED: "AUTH_STATE_CHANGED",
19
19
  AUTH_CALL_RESULT: "AUTH_CALL_RESULT",
20
20
  FETCH_RESULT: "FETCH_RESULT",
21
- FETCH_ERROR: "FETCH_ERROR"
21
+ FETCH_ERROR: "FETCH_ERROR",
22
+ TOKEN_REFRESHED: "TOKEN_REFRESHED"
22
23
  });
23
24
 
24
25
  // src/constants.ts
@@ -26,65 +27,99 @@ var DEFAULT_REFRESH_EARLY_MS = 6e4;
26
27
 
27
28
  // src/errors.ts
28
29
  import { defineError, defineErrorAdvanced } from "ts-micro-result";
30
+
31
+ // src/error-codes.ts
32
+ var ERROR_CODES = {
33
+ // General
34
+ UNEXPECTED: "UNEXPECTED",
35
+ UNKNOWN_MESSAGE: "UNKNOWN_MESSAGE",
36
+ RESULT_PARSE_ERROR: "RESULT_PARSE_ERROR",
37
+ // Init
38
+ INIT_ERROR: "INIT_ERROR",
39
+ PROVIDER_INIT_FAILED: "PROVIDER_INIT_FAILED",
40
+ INIT_FAILED: "INIT_FAILED",
41
+ // Auth
42
+ TOKEN_REFRESH_FAILED: "TOKEN_REFRESH_FAILED",
43
+ LOGIN_FAILED: "LOGIN_FAILED",
44
+ LOGOUT_FAILED: "LOGOUT_FAILED",
45
+ NOT_AUTHENTICATED: "NOT_AUTHENTICATED",
46
+ // Domain
47
+ DOMAIN_NOT_ALLOWED: "DOMAIN_NOT_ALLOWED",
48
+ // Request
49
+ NETWORK_ERROR: "NETWORK_ERROR",
50
+ REQUEST_CANCELLED: "REQUEST_CANCELLED",
51
+ HTTP_ERROR: "HTTP_ERROR",
52
+ RESPONSE_PARSE_FAILED: "RESPONSE_PARSE_FAILED",
53
+ QUEUE_FULL: "QUEUE_FULL",
54
+ REQUEST_TIMEOUT: "REQUEST_TIMEOUT"
55
+ };
56
+
57
+ // src/errors.ts
29
58
  var GeneralErrors = {
30
- Unexpected: defineError("UNEXPECTED", "Unexpected error", 500),
31
- UnknownMessage: defineError("UNKNOWN_MESSAGE", "Unknown message type", 400),
32
- ResultParse: defineError("RESULT_PARSE_ERROR", "Failed to parse result", 500)
59
+ Unexpected: defineError(ERROR_CODES.UNEXPECTED, "Unexpected error"),
60
+ UnknownMessage: defineError(ERROR_CODES.UNKNOWN_MESSAGE, "Unknown message type"),
61
+ ResultParse: defineError(ERROR_CODES.RESULT_PARSE_ERROR, "Failed to parse result")
33
62
  };
34
63
  var InitErrors = {
35
- NotInitialized: defineError("INIT_ERROR", "Worker not initialized", 500),
36
- ProviderInitFailed: defineError("PROVIDER_INIT_FAILED", "Failed to initialize provider", 500),
37
- InitFailed: defineError("INIT_FAILED", "Initialization failed", 500)
64
+ NotInitialized: defineError(ERROR_CODES.INIT_ERROR, "Worker not initialized"),
65
+ ProviderInitFailed: defineError(ERROR_CODES.PROVIDER_INIT_FAILED, "Failed to initialize provider"),
66
+ InitFailed: defineError(ERROR_CODES.INIT_FAILED, "Initialization failed")
38
67
  };
39
68
  var AuthErrors = {
40
- TokenRefreshFailed: defineError("TOKEN_REFRESH_FAILED", "Token refresh failed", 401),
41
- LoginFailed: defineError("LOGIN_FAILED", "Login failed", 401),
42
- LogoutFailed: defineError("LOGOUT_FAILED", "Logout failed", 500),
43
- NotAuthenticated: defineError("NOT_AUTHENTICATED", "User is not authenticated", 401)
69
+ TokenRefreshFailed: defineError(ERROR_CODES.TOKEN_REFRESH_FAILED, "Token refresh failed"),
70
+ LoginFailed: defineError(ERROR_CODES.LOGIN_FAILED, "Login failed"),
71
+ LogoutFailed: defineError(ERROR_CODES.LOGOUT_FAILED, "Logout failed"),
72
+ NotAuthenticated: defineError(ERROR_CODES.NOT_AUTHENTICATED, "User is not authenticated")
44
73
  };
45
74
  var DomainErrors = {
46
- NotAllowed: defineErrorAdvanced("DOMAIN_NOT_ALLOWED", "Domain not allowed: {url}", 403)
75
+ NotAllowed: defineErrorAdvanced(ERROR_CODES.DOMAIN_NOT_ALLOWED, "Domain not allowed: {url}")
47
76
  };
48
77
  var RequestErrors = {
49
78
  // Network errors (connection failed, no response)
50
- NetworkError: defineError("NETWORK_ERROR", "Network error", 500),
51
- Cancelled: defineError("REQUEST_CANCELLED", "Request was cancelled", 499),
79
+ NetworkError: defineError(ERROR_CODES.NETWORK_ERROR, "Network error"),
80
+ Cancelled: defineError(ERROR_CODES.REQUEST_CANCELLED, "Request was cancelled"),
52
81
  // HTTP errors (server responded with error status)
53
- HttpError: defineErrorAdvanced("HTTP_ERROR", "HTTP {status} error", 500),
82
+ HttpError: defineErrorAdvanced(ERROR_CODES.HTTP_ERROR, "HTTP {status} error"),
54
83
  // Response parsing errors
55
- ResponseParseFailed: defineError("RESPONSE_PARSE_FAILED", "Failed to parse response body", 500)
84
+ ResponseParseFailed: defineError(ERROR_CODES.RESPONSE_PARSE_FAILED, "Failed to parse response body"),
85
+ // Queue errors
86
+ QueueFull: defineErrorAdvanced(ERROR_CODES.QUEUE_FULL, "Request queue full ({size}/{maxSize})"),
87
+ // Timeout errors
88
+ Timeout: defineError(ERROR_CODES.REQUEST_TIMEOUT, "Request timed out")
56
89
  };
57
90
 
58
91
  // src/utils/formdata.ts
59
92
  async function serializeFormData(formData) {
60
93
  const entries = [];
94
+ const transferables = [];
95
+ const orderedEntries = [];
96
+ let index = 0;
61
97
  formData.forEach((value, key) => {
62
- if (value instanceof File) {
63
- } else {
64
- entries.push([key, String(value)]);
65
- }
98
+ orderedEntries.push({ index, key, value });
99
+ index++;
66
100
  });
67
- const filePromises = [];
68
- formData.forEach((value, key) => {
69
- if (value instanceof File) {
70
- const promise = (async () => {
71
- const arrayBuffer = await value.arrayBuffer();
72
- const uint8Array = new Uint8Array(arrayBuffer);
101
+ await Promise.all(
102
+ orderedEntries.map(async ({ index: idx, key, value }) => {
103
+ if (value instanceof File) {
104
+ const buffer = await value.arrayBuffer();
73
105
  const serializedFile = {
74
106
  name: value.name,
75
107
  type: value.type,
76
- data: Array.from(uint8Array)
77
- // Convert to number array
108
+ buffer
78
109
  };
79
- entries.push([key, serializedFile]);
80
- })();
81
- filePromises.push(promise);
82
- }
83
- });
84
- await Promise.all(filePromises);
110
+ entries[idx] = [key, serializedFile];
111
+ transferables.push(buffer);
112
+ } else {
113
+ entries[idx] = [key, String(value)];
114
+ }
115
+ })
116
+ );
85
117
  return {
86
- _type: "FormData",
87
- entries
118
+ data: {
119
+ _type: "FormData",
120
+ entries
121
+ },
122
+ transferables
88
123
  };
89
124
  }
90
125
  function deserializeFormData(serialized) {
@@ -93,8 +128,7 @@ function deserializeFormData(serialized) {
93
128
  if (typeof value === "string") {
94
129
  formData.append(key, value);
95
130
  } else {
96
- const uint8Array = new Uint8Array(value.data);
97
- const file = new File([uint8Array], value.name, { type: value.type });
131
+ const file = new File([value.buffer], value.name, { type: value.type });
98
132
  formData.append(key, file);
99
133
  }
100
134
  }
@@ -108,22 +142,46 @@ function isSerializedFormData(body) {
108
142
  }
109
143
 
110
144
  // src/client.ts
145
+ var DEFAULT_MAX_CONCURRENT = 6;
146
+ var DEFAULT_MAX_QUEUE_SIZE = 1e3;
147
+ var DEFAULT_SETUP_TIMEOUT = 1e4;
148
+ var DEFAULT_REQUEST_TIMEOUT = 3e4;
111
149
  var FetchGuardClient = class {
112
150
  worker;
113
151
  messageId = 0;
114
152
  // Using unknown because different messages have different response types
115
- // (ApiResponse for FETCH, AuthResult for AUTH_CALL, etc.)
153
+ // (FetchEnvelope for FETCH, AuthResult for AUTH_CALL, etc.)
116
154
  pendingRequests = /* @__PURE__ */ new Map();
155
+ /** Track request URLs for debug hooks */
156
+ requestUrls = /* @__PURE__ */ new Map();
157
+ /** Track request timing for metrics */
158
+ requestTimings = /* @__PURE__ */ new Map();
117
159
  authListeners = /* @__PURE__ */ new Set();
118
160
  readyListeners = /* @__PURE__ */ new Set();
119
161
  isReady = false;
120
162
  requestQueue = [];
121
- isProcessingQueue = false;
122
- queueTimeout = 3e4;
123
- // 30 seconds
163
+ activeRequests = 0;
164
+ maxConcurrent;
165
+ maxQueueSize;
166
+ setupTimeout;
167
+ requestTimeout;
124
168
  setupResolve;
125
169
  setupReject;
170
+ debug;
171
+ retry;
172
+ dedupe;
173
+ /** In-flight requests for deduplication */
174
+ inFlightRequests = /* @__PURE__ */ new Map();
175
+ /** Recent completed requests for time-window deduplication */
176
+ recentResults = /* @__PURE__ */ new Map();
126
177
  constructor(options) {
178
+ this.maxConcurrent = options.maxConcurrent ?? DEFAULT_MAX_CONCURRENT;
179
+ this.maxQueueSize = options.maxQueueSize ?? DEFAULT_MAX_QUEUE_SIZE;
180
+ this.setupTimeout = options.setupTimeout ?? DEFAULT_SETUP_TIMEOUT;
181
+ this.requestTimeout = options.requestTimeout ?? DEFAULT_REQUEST_TIMEOUT;
182
+ this.debug = options.debug;
183
+ this.retry = options.retry;
184
+ this.dedupe = options.dedupe;
127
185
  this.worker = new Worker(new URL("./worker.js", import.meta.url), {
128
186
  type: "module"
129
187
  });
@@ -168,7 +226,7 @@ var FetchGuardClient = class {
168
226
  this.setupResolve = void 0;
169
227
  this.setupReject = void 0;
170
228
  }
171
- }, 1e4);
229
+ }, this.setupTimeout);
172
230
  });
173
231
  }
174
232
  /**
@@ -179,31 +237,35 @@ var FetchGuardClient = class {
179
237
  if (type === MSG.FETCH_RESULT) {
180
238
  const request = this.pendingRequests.get(id);
181
239
  if (!request) return;
240
+ const url = this.requestUrls.get(id);
241
+ const timing = this.requestTimings.get(id);
182
242
  this.pendingRequests.delete(id);
183
- const status = payload?.status;
184
- if (status >= 200 && status < 400) {
185
- request.resolve(ok(payload));
186
- } else {
187
- request.resolve(err(
188
- RequestErrors.HttpError({ status }),
189
- {
190
- body: String(payload?.body ?? ""),
191
- headers: payload?.headers ?? {}
192
- },
193
- payload?.status
194
- ));
243
+ this.requestUrls.delete(id);
244
+ this.requestTimings.delete(id);
245
+ this.onRequestComplete();
246
+ const metrics = this.calculateMetrics(timing);
247
+ if (this.debug?.onResponse && url) {
248
+ this.debug.onResponse(url, payload, metrics);
195
249
  }
250
+ request.resolve(ok(payload));
196
251
  return;
197
252
  }
198
253
  if (type === MSG.FETCH_ERROR) {
199
254
  const request = this.pendingRequests.get(id);
200
255
  if (!request) return;
256
+ const url = this.requestUrls.get(id);
257
+ const timing = this.requestTimings.get(id);
201
258
  this.pendingRequests.delete(id);
202
- const status = typeof payload?.status === "number" ? payload.status : void 0;
259
+ this.requestUrls.delete(id);
260
+ this.requestTimings.delete(id);
261
+ this.onRequestComplete();
262
+ const errorMessage = String(payload?.error || "Network error");
263
+ const metrics = this.calculateMetrics(timing);
264
+ if (this.debug?.onError && url) {
265
+ this.debug.onError(url, { code: "NETWORK_ERROR", message: errorMessage }, metrics);
266
+ }
203
267
  request.resolve(err(
204
- RequestErrors.NetworkError({ message: String(payload?.error || "Network error") }),
205
- void 0,
206
- status
268
+ RequestErrors.NetworkError({ message: errorMessage })
207
269
  ));
208
270
  return;
209
271
  }
@@ -211,7 +273,8 @@ var FetchGuardClient = class {
211
273
  const request = this.pendingRequests.get(id);
212
274
  if (!request) return;
213
275
  this.pendingRequests.delete(id);
214
- request.resolve(err(payload.errors, payload.meta, payload.status));
276
+ this.onRequestComplete();
277
+ request.resolve(err(payload.errors, payload.meta));
215
278
  return;
216
279
  }
217
280
  if (type === MSG.SETUP_ERROR) {
@@ -224,6 +287,7 @@ var FetchGuardClient = class {
224
287
  }
225
288
  if (type === MSG.READY) {
226
289
  this.isReady = true;
290
+ this.debug?.onWorkerReady?.();
227
291
  for (const listener of this.readyListeners) {
228
292
  listener();
229
293
  }
@@ -238,6 +302,7 @@ var FetchGuardClient = class {
238
302
  const request = this.pendingRequests.get(id);
239
303
  if (request) {
240
304
  this.pendingRequests.delete(id);
305
+ this.onRequestComplete();
241
306
  request.resolve(ok({ timestamp: payload?.timestamp }));
242
307
  }
243
308
  return;
@@ -250,20 +315,28 @@ var FetchGuardClient = class {
250
315
  const request = this.pendingRequests.get(id);
251
316
  if (request) {
252
317
  this.pendingRequests.delete(id);
318
+ this.onRequestComplete();
253
319
  request.resolve(ok(payload));
254
320
  }
255
321
  return;
256
322
  }
323
+ if (type === MSG.TOKEN_REFRESHED) {
324
+ this.debug?.onRefresh?.(payload?.reason);
325
+ return;
326
+ }
257
327
  }
258
328
  /**
259
329
  * Handle worker errors
260
330
  */
261
331
  handleWorkerError(error) {
262
332
  console.error("Worker error:", error);
333
+ this.debug?.onWorkerError?.(error);
263
334
  for (const [id, request] of this.pendingRequests) {
264
335
  request.reject(new Error(`Worker error: ${error.message}`));
265
336
  }
266
337
  this.pendingRequests.clear();
338
+ this.requestUrls.clear();
339
+ this.requestTimings.clear();
267
340
  }
268
341
  /**
269
342
  * Generate unique message ID
@@ -272,11 +345,207 @@ var FetchGuardClient = class {
272
345
  return `msg_${++this.messageId}_${Date.now()}`;
273
346
  }
274
347
  /**
275
- * Make API request
348
+ * Make API request with optional deduplication, retry, and AbortSignal support
349
+ *
350
+ * @param url - Full URL to fetch
351
+ * @param options - Request options including optional AbortSignal
352
+ * @returns Result with FetchEnvelope on success, error on failure
353
+ *
354
+ * @example
355
+ * // With AbortSignal
356
+ * const controller = new AbortController()
357
+ * setTimeout(() => controller.abort(), 5000)
358
+ * const result = await api.fetch('/slow', { signal: controller.signal })
276
359
  */
277
360
  async fetch(url, options = {}) {
278
- const { result } = this.fetchWithId(url, options);
279
- return result;
361
+ const { signal, ...restOptions } = options;
362
+ if (signal?.aborted) {
363
+ return err(RequestErrors.Cancelled());
364
+ }
365
+ const dedupeKey = this.getDedupeKey(url, restOptions);
366
+ if (dedupeKey) {
367
+ const inFlight = this.inFlightRequests.get(dedupeKey);
368
+ if (inFlight) {
369
+ if (signal) {
370
+ return this.wrapWithAbortSignal(inFlight, signal, null);
371
+ }
372
+ return inFlight;
373
+ }
374
+ const window = this.dedupe?.window ?? 0;
375
+ if (window > 0) {
376
+ const recent = this.recentResults.get(dedupeKey);
377
+ if (recent && Date.now() - recent.timestamp < window) {
378
+ return recent.result;
379
+ }
380
+ }
381
+ const promise = this.fetchWithRetryAndSignal(url, restOptions, signal ?? void 0);
382
+ this.inFlightRequests.set(dedupeKey, promise);
383
+ try {
384
+ const result = await promise;
385
+ if (window > 0) {
386
+ this.recentResults.set(dedupeKey, { result, timestamp: Date.now() });
387
+ setTimeout(() => this.recentResults.delete(dedupeKey), window);
388
+ }
389
+ return result;
390
+ } finally {
391
+ this.inFlightRequests.delete(dedupeKey);
392
+ }
393
+ }
394
+ return this.fetchWithRetryAndSignal(url, restOptions, signal ?? void 0);
395
+ }
396
+ /**
397
+ * Wrap a promise with AbortSignal support
398
+ */
399
+ wrapWithAbortSignal(promise, signal, requestId) {
400
+ return new Promise((resolve) => {
401
+ const abortHandler = () => {
402
+ if (requestId) {
403
+ this.cancel(requestId);
404
+ }
405
+ resolve(err(RequestErrors.Cancelled()));
406
+ };
407
+ if (signal.aborted) {
408
+ abortHandler();
409
+ return;
410
+ }
411
+ signal.addEventListener("abort", abortHandler, { once: true });
412
+ promise.then((result) => {
413
+ signal.removeEventListener("abort", abortHandler);
414
+ resolve(result);
415
+ });
416
+ });
417
+ }
418
+ /**
419
+ * Fetch with retry logic and AbortSignal support (internal)
420
+ */
421
+ async fetchWithRetryAndSignal(url, options, signal) {
422
+ const maxAttempts = this.retry?.maxAttempts ?? 0;
423
+ const delay = this.retry?.delay ?? 1e3;
424
+ const backoff = this.retry?.backoff ?? 1;
425
+ const maxDelay = this.retry?.maxDelay ?? 3e4;
426
+ const jitter = this.retry?.jitter ?? 0;
427
+ const shouldRetry = this.retry?.shouldRetry ?? this.defaultShouldRetry;
428
+ let lastResult = null;
429
+ let currentDelay = delay;
430
+ for (let attempt = 0; attempt <= maxAttempts; attempt++) {
431
+ if (signal?.aborted) {
432
+ return err(RequestErrors.Cancelled());
433
+ }
434
+ const { id, result } = this.fetchWithId(url, options);
435
+ if (signal) {
436
+ lastResult = await this.wrapWithAbortSignal(result, signal, id);
437
+ } else {
438
+ lastResult = await result;
439
+ }
440
+ if (lastResult.ok) {
441
+ return lastResult;
442
+ }
443
+ if (lastResult.errors[0]?.code === "REQUEST_CANCELLED") {
444
+ return lastResult;
445
+ }
446
+ const error = lastResult.errors[0];
447
+ const errorDetail = {
448
+ code: error?.code ?? "NETWORK_ERROR",
449
+ message: error?.message ?? "Unknown error"
450
+ };
451
+ if (attempt >= maxAttempts || !shouldRetry(errorDetail)) {
452
+ return lastResult;
453
+ }
454
+ const cappedDelay = Math.min(currentDelay, maxDelay);
455
+ const jitteredDelay = this.applyJitter(cappedDelay, jitter);
456
+ if (signal) {
457
+ const aborted = await this.sleepWithAbort(jitteredDelay, signal);
458
+ if (aborted) {
459
+ return err(RequestErrors.Cancelled());
460
+ }
461
+ } else {
462
+ await this.sleep(jitteredDelay);
463
+ }
464
+ currentDelay = currentDelay * backoff;
465
+ }
466
+ return lastResult;
467
+ }
468
+ /**
469
+ * Generate deduplication key for request
470
+ * Returns null if request should not be deduplicated
471
+ */
472
+ getDedupeKey(url, options) {
473
+ if (!this.dedupe?.enabled) {
474
+ return null;
475
+ }
476
+ if (this.dedupe.keyGenerator) {
477
+ return this.dedupe.keyGenerator(url, options);
478
+ }
479
+ const method = (options.method ?? "GET").toUpperCase();
480
+ if (method !== "GET") {
481
+ return null;
482
+ }
483
+ return `GET:${url}`;
484
+ }
485
+ /**
486
+ * Apply jitter to a delay value
487
+ * Jitter adds ±(jitter * delay) randomness to prevent thundering herd
488
+ * @param delay - Base delay in milliseconds
489
+ * @param jitter - Jitter factor (0-1)
490
+ * @returns Jittered delay
491
+ */
492
+ applyJitter(delay, jitter) {
493
+ if (jitter <= 0) return delay;
494
+ const clampedJitter = Math.min(Math.max(jitter, 0), 1);
495
+ const randomFactor = Math.random() * 2 - 1;
496
+ return Math.max(0, delay + delay * clampedJitter * randomFactor);
497
+ }
498
+ /**
499
+ * Default retry condition - only retry on NETWORK_ERROR
500
+ */
501
+ defaultShouldRetry(error) {
502
+ return error.code === "NETWORK_ERROR";
503
+ }
504
+ /**
505
+ * Calculate request metrics from timing data
506
+ */
507
+ calculateMetrics(timing) {
508
+ if (!timing) return void 0;
509
+ const endTime = Date.now();
510
+ const startTime = timing.createdAt;
511
+ const sentAt = timing.sentAt ?? startTime;
512
+ const duration = endTime - startTime;
513
+ const queueTime = sentAt - startTime;
514
+ const ipcTime = duration - queueTime;
515
+ return {
516
+ startTime,
517
+ endTime,
518
+ duration,
519
+ queueTime,
520
+ ipcTime
521
+ };
522
+ }
523
+ /**
524
+ * Sleep helper for retry delay
525
+ */
526
+ sleep(ms) {
527
+ return new Promise((resolve) => setTimeout(resolve, ms));
528
+ }
529
+ /**
530
+ * Sleep with abort signal support
531
+ * Returns true if aborted, false if completed normally
532
+ */
533
+ sleepWithAbort(ms, signal) {
534
+ return new Promise((resolve) => {
535
+ if (signal.aborted) {
536
+ resolve(true);
537
+ return;
538
+ }
539
+ const timer = setTimeout(() => {
540
+ signal.removeEventListener("abort", abortHandler);
541
+ resolve(false);
542
+ }, ms);
543
+ const abortHandler = () => {
544
+ clearTimeout(timer);
545
+ resolve(true);
546
+ };
547
+ signal.addEventListener("abort", abortHandler, { once: true });
548
+ });
280
549
  }
281
550
  /**
282
551
  * Fetch with id for external cancellation
@@ -290,11 +559,18 @@ var FetchGuardClient = class {
290
559
  resolve: (response) => resolve(response),
291
560
  reject: (error) => reject(error)
292
561
  });
562
+ this.requestUrls.set(id, url);
563
+ this.requestTimings.set(id, { createdAt: Date.now() });
564
+ this.debug?.onRequest?.(url, options);
293
565
  try {
294
566
  let serializedOptions = { ...options };
567
+ let transferables;
295
568
  if (options.body && isFormData(options.body)) {
296
- const serializedBody = await serializeFormData(options.body);
297
- serializedOptions.body = serializedBody;
569
+ const { data, transferables: formDataTransferables } = await serializeFormData(options.body);
570
+ serializedOptions.body = data;
571
+ if (formDataTransferables.length > 0) {
572
+ transferables = formDataTransferables;
573
+ }
298
574
  }
299
575
  if (options.headers) {
300
576
  if (options.headers instanceof Headers) {
@@ -306,11 +582,13 @@ var FetchGuardClient = class {
306
582
  }
307
583
  }
308
584
  const message = { id, type: MSG.FETCH, payload: { url, options: serializedOptions } };
309
- await this.sendMessageQueued(message, 3e4);
585
+ await this.sendMessageQueued(message, 3e4, transferables);
310
586
  } catch (error) {
311
587
  const request = this.pendingRequests.get(id);
312
588
  if (request) {
313
589
  this.pendingRequests.delete(id);
590
+ this.requestUrls.delete(id);
591
+ this.requestTimings.delete(id);
314
592
  request.reject(error instanceof Error ? error : new Error(String(error)));
315
593
  }
316
594
  }
@@ -324,8 +602,16 @@ var FetchGuardClient = class {
324
602
  cancel(id) {
325
603
  const request = this.pendingRequests.get(id);
326
604
  if (request) {
605
+ const url = this.requestUrls.get(id);
606
+ const timing = this.requestTimings.get(id);
327
607
  this.pendingRequests.delete(id);
608
+ this.requestUrls.delete(id);
609
+ this.requestTimings.delete(id);
328
610
  this.worker.postMessage({ id, type: MSG.CANCEL });
611
+ const metrics = this.calculateMetrics(timing);
612
+ if (this.debug?.onError && url) {
613
+ this.debug.onError(url, { code: "REQUEST_CANCELLED", message: "Request cancelled" }, metrics);
614
+ }
329
615
  request.reject(new Error("Request cancelled"));
330
616
  }
331
617
  }
@@ -511,20 +797,28 @@ var FetchGuardClient = class {
511
797
  /**
512
798
  * Send message through queue system
513
799
  * All messages go through queue for sequential processing
800
+ * @param transferables - Optional Transferable objects for zero-copy postMessage
514
801
  */
515
- sendMessageQueued(message, timeoutMs = this.queueTimeout) {
802
+ sendMessageQueued(message, timeoutMs = this.requestTimeout, transferables) {
516
803
  return new Promise((resolve, reject) => {
804
+ if (this.requestQueue.length >= this.maxQueueSize) {
805
+ reject(err(RequestErrors.QueueFull({ size: this.requestQueue.length, maxSize: this.maxQueueSize })));
806
+ return;
807
+ }
517
808
  const timeout = setTimeout(() => {
518
809
  const index = this.requestQueue.findIndex((item) => item.id === message.id);
519
810
  if (index !== -1) {
520
811
  this.requestQueue.splice(index, 1);
521
812
  }
522
813
  this.pendingRequests.delete(message.id);
523
- reject(new Error("Request timeout"));
814
+ this.requestUrls.delete(message.id);
815
+ this.requestTimings.delete(message.id);
816
+ reject(err(RequestErrors.Timeout()));
524
817
  }, timeoutMs);
525
818
  const queueItem = {
526
819
  id: message.id,
527
820
  message,
821
+ transferables,
528
822
  resolve,
529
823
  reject,
530
824
  timeout
@@ -534,29 +828,44 @@ var FetchGuardClient = class {
534
828
  });
535
829
  }
536
830
  /**
537
- * Process message queue sequentially
831
+ * Process message queue with concurrency limit
832
+ *
833
+ * Uses semaphore pattern to allow N concurrent requests.
538
834
  * Benefits:
539
- * - Sequential processing prevents worker overload
835
+ * - Higher throughput than sequential processing
836
+ * - Backpressure via maxConcurrent limit
540
837
  * - Better error isolation (one failure doesn't affect others)
541
- * - 50ms delay between requests for backpressure
542
838
  */
543
- async processQueue() {
544
- if (this.isProcessingQueue || this.requestQueue.length === 0) {
545
- return;
546
- }
547
- this.isProcessingQueue = true;
548
- while (this.requestQueue.length > 0) {
839
+ processQueue() {
840
+ while (this.requestQueue.length > 0 && this.activeRequests < this.maxConcurrent) {
549
841
  const item = this.requestQueue.shift();
550
842
  if (!item) continue;
843
+ this.activeRequests++;
844
+ const timing = this.requestTimings.get(item.id);
845
+ if (timing) {
846
+ timing.sentAt = Date.now();
847
+ }
551
848
  try {
552
- this.worker.postMessage(item.message);
553
- await new Promise((resolve) => setTimeout(resolve, 50));
849
+ if (item.transferables && item.transferables.length > 0) {
850
+ this.worker.postMessage(item.message, item.transferables);
851
+ } else {
852
+ this.worker.postMessage(item.message);
853
+ }
554
854
  } catch (error) {
855
+ this.activeRequests--;
555
856
  clearTimeout(item.timeout);
556
857
  item.reject(error instanceof Error ? error : new Error(String(error)));
858
+ this.processQueue();
557
859
  }
558
860
  }
559
- this.isProcessingQueue = false;
861
+ }
862
+ /**
863
+ * Called when a request completes (success or error)
864
+ * Decrements active count and processes next items in queue
865
+ */
866
+ onRequestComplete() {
867
+ this.activeRequests--;
868
+ this.processQueue();
560
869
  }
561
870
  /**
562
871
  * Cleanup - terminate worker
@@ -564,6 +873,8 @@ var FetchGuardClient = class {
564
873
  destroy() {
565
874
  this.worker.terminate();
566
875
  this.pendingRequests.clear();
876
+ this.requestUrls.clear();
877
+ this.requestTimings.clear();
567
878
  for (const item of this.requestQueue) {
568
879
  clearTimeout(item.timeout);
569
880
  item.reject(new Error("Client destroyed"));
@@ -619,7 +930,7 @@ function createProvider(config) {
619
930
  const response = await config.strategy.refresh(currentRefreshToken);
620
931
  if (!response.ok) {
621
932
  const body = await response.text().catch(() => "");
622
- return err2(AuthErrors.TokenRefreshFailed(), { body }, response.status);
933
+ return err2(AuthErrors.TokenRefreshFailed(), { params: { body, status: response.status } });
623
934
  }
624
935
  const tokenInfo = await config.parser.parse(response);
625
936
  if (!tokenInfo.token) {
@@ -638,7 +949,7 @@ function createProvider(config) {
638
949
  const response = await config.strategy.login(payload, url);
639
950
  if (!response.ok) {
640
951
  const body = await response.text().catch(() => "");
641
- return err2(AuthErrors.LoginFailed(), { body }, response.status);
952
+ return err2(AuthErrors.LoginFailed(), { params: { body, status: response.status } });
642
953
  }
643
954
  const tokenInfo = await config.parser.parse(response);
644
955
  if (!tokenInfo.token) {
@@ -657,7 +968,7 @@ function createProvider(config) {
657
968
  const response = await config.strategy.logout(payload);
658
969
  if (!response.ok) {
659
970
  const body = await response.text().catch(() => "");
660
- return err2(AuthErrors.LogoutFailed(), { body }, response.status);
971
+ return err2(AuthErrors.LogoutFailed(), { params: { body, status: response.status } });
661
972
  }
662
973
  if (config.refreshStorage) {
663
974
  await config.refreshStorage.set(null);
@@ -684,7 +995,13 @@ function createProvider(config) {
684
995
  }
685
996
 
686
997
  // src/provider/storage/indexeddb.ts
687
- function createIndexedDBStorage(dbName = "FetchGuardDB", refreshTokenKey = "refreshToken") {
998
+ function createIndexedDBStorage(options = "FetchGuardDB", legacyRefreshTokenKey) {
999
+ const config = typeof options === "string" ? { dbName: options, refreshTokenKey: legacyRefreshTokenKey ?? "refreshToken", onError: void 0 } : {
1000
+ dbName: options.dbName ?? "FetchGuardDB",
1001
+ refreshTokenKey: options.refreshTokenKey ?? "refreshToken",
1002
+ onError: options.onError
1003
+ };
1004
+ const { dbName, refreshTokenKey, onError } = config;
688
1005
  const storeName = "tokens";
689
1006
  const openDB = () => {
690
1007
  return new Promise((resolve, reject) => {
@@ -715,7 +1032,7 @@ function createIndexedDBStorage(dbName = "FetchGuardDB", refreshTokenKey = "refr
715
1032
  const result = await promisifyRequest(store.get(refreshTokenKey));
716
1033
  return result?.value || null;
717
1034
  } catch (error) {
718
- console.warn("Failed to get refresh token from IndexedDB:", error);
1035
+ onError?.(error, "get");
719
1036
  return null;
720
1037
  }
721
1038
  },
@@ -730,12 +1047,25 @@ function createIndexedDBStorage(dbName = "FetchGuardDB", refreshTokenKey = "refr
730
1047
  await promisifyRequest(store.delete(refreshTokenKey));
731
1048
  }
732
1049
  } catch (error) {
733
- console.warn("Failed to save refresh token to IndexedDB:", error);
1050
+ onError?.(error, token ? "set" : "delete");
734
1051
  }
735
1052
  }
736
1053
  };
737
1054
  }
738
1055
 
1056
+ // src/provider/parser/normalize.ts
1057
+ function normalizeExpiresAt(value) {
1058
+ if (value == null) return void 0;
1059
+ if (typeof value === "number") {
1060
+ return value < 1e12 ? value * 1e3 : value;
1061
+ }
1062
+ if (typeof value === "string") {
1063
+ const ts = Date.parse(value);
1064
+ return isNaN(ts) ? void 0 : ts;
1065
+ }
1066
+ return void 0;
1067
+ }
1068
+
739
1069
  // src/provider/parser/body.ts
740
1070
  var bodyParser = {
741
1071
  async parse(response) {
@@ -743,7 +1073,7 @@ var bodyParser = {
743
1073
  return {
744
1074
  token: json.data.accessToken,
745
1075
  refreshToken: json.data.refreshToken,
746
- expiresAt: json.data.expiresAt,
1076
+ expiresAt: normalizeExpiresAt(json.data.expiresAt),
747
1077
  user: json.data.user
748
1078
  };
749
1079
  }
@@ -755,7 +1085,7 @@ var cookieParser = {
755
1085
  const json = await response.clone().json();
756
1086
  return {
757
1087
  token: json.data.accessToken,
758
- expiresAt: json.data.expiresAt,
1088
+ expiresAt: normalizeExpiresAt(json.data.expiresAt),
759
1089
  user: json.data.user
760
1090
  };
761
1091
  }
@@ -878,9 +1208,76 @@ function isBinaryContentType(contentType) {
878
1208
  if (normalized.includes("html")) return false;
879
1209
  return true;
880
1210
  }
1211
+
1212
+ // src/helpers.ts
1213
+ function isNetworkError(result) {
1214
+ return !result.ok;
1215
+ }
1216
+ function isSuccess(result) {
1217
+ return result.ok && result.data.status >= 200 && result.data.status < 300;
1218
+ }
1219
+ function isClientError(result) {
1220
+ return result.ok && result.data.status >= 400 && result.data.status < 500;
1221
+ }
1222
+ function isServerError(result) {
1223
+ return result.ok && result.data.status >= 500;
1224
+ }
1225
+ function parseJson(result) {
1226
+ if (!result.ok) return null;
1227
+ try {
1228
+ return JSON.parse(result.data.body);
1229
+ } catch {
1230
+ return null;
1231
+ }
1232
+ }
1233
+ function getErrorMessage(result) {
1234
+ if (result.ok) {
1235
+ try {
1236
+ const body = JSON.parse(result.data.body);
1237
+ return body.message || body.error || `HTTP ${result.data.status}`;
1238
+ } catch {
1239
+ return `HTTP ${result.data.status}`;
1240
+ }
1241
+ }
1242
+ return result.errors[0]?.message || "Unknown error";
1243
+ }
1244
+ function getErrorBody(result) {
1245
+ if (!result.ok) return null;
1246
+ if (result.data.status >= 400) {
1247
+ try {
1248
+ return JSON.parse(result.data.body);
1249
+ } catch {
1250
+ return null;
1251
+ }
1252
+ }
1253
+ return null;
1254
+ }
1255
+ function getStatus(result) {
1256
+ return result.ok ? result.data.status : null;
1257
+ }
1258
+ function hasStatus(result, status) {
1259
+ return result.ok && result.data.status === status;
1260
+ }
1261
+ function matchResult(result, handlers) {
1262
+ if (!result.ok) {
1263
+ return handlers.networkError?.(result.errors);
1264
+ }
1265
+ const status = result.data.status;
1266
+ if (status >= 200 && status < 300) {
1267
+ return handlers.success?.(result.data);
1268
+ }
1269
+ if (status >= 400 && status < 500) {
1270
+ return handlers.clientError?.(result.data);
1271
+ }
1272
+ if (status >= 500) {
1273
+ return handlers.serverError?.(result.data);
1274
+ }
1275
+ return void 0;
1276
+ }
881
1277
  export {
882
1278
  AuthErrors,
883
1279
  DomainErrors,
1280
+ ERROR_CODES,
884
1281
  FetchGuardClient,
885
1282
  GeneralErrors,
886
1283
  InitErrors,
@@ -900,12 +1297,22 @@ export {
900
1297
  createIndexedDBStorage,
901
1298
  createProvider,
902
1299
  deserializeFormData,
1300
+ getErrorBody,
1301
+ getErrorMessage,
903
1302
  getProvider,
1303
+ getStatus,
904
1304
  hasProvider,
1305
+ hasStatus,
905
1306
  isBinaryContentType,
1307
+ isClientError,
906
1308
  isFormData,
1309
+ isNetworkError,
907
1310
  isSerializedFormData,
1311
+ isServerError,
1312
+ isSuccess,
908
1313
  listProviders,
1314
+ matchResult,
1315
+ parseJson,
909
1316
  registerProvider,
910
1317
  serializeFormData,
911
1318
  unregisterProvider