fetchguard 1.6.3 → 2.1.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,101 @@ 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
+ TOKEN_EXCHANGE_FAILED: "TOKEN_EXCHANGE_FAILED",
44
+ LOGIN_FAILED: "LOGIN_FAILED",
45
+ LOGOUT_FAILED: "LOGOUT_FAILED",
46
+ NOT_AUTHENTICATED: "NOT_AUTHENTICATED",
47
+ // Domain
48
+ DOMAIN_NOT_ALLOWED: "DOMAIN_NOT_ALLOWED",
49
+ // Request
50
+ NETWORK_ERROR: "NETWORK_ERROR",
51
+ REQUEST_CANCELLED: "REQUEST_CANCELLED",
52
+ HTTP_ERROR: "HTTP_ERROR",
53
+ RESPONSE_PARSE_FAILED: "RESPONSE_PARSE_FAILED",
54
+ QUEUE_FULL: "QUEUE_FULL",
55
+ REQUEST_TIMEOUT: "REQUEST_TIMEOUT"
56
+ };
57
+
58
+ // src/errors.ts
29
59
  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)
60
+ Unexpected: defineError(ERROR_CODES.UNEXPECTED, "Unexpected error"),
61
+ UnknownMessage: defineError(ERROR_CODES.UNKNOWN_MESSAGE, "Unknown message type"),
62
+ ResultParse: defineError(ERROR_CODES.RESULT_PARSE_ERROR, "Failed to parse result")
33
63
  };
34
64
  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)
65
+ NotInitialized: defineError(ERROR_CODES.INIT_ERROR, "Worker not initialized"),
66
+ ProviderInitFailed: defineError(ERROR_CODES.PROVIDER_INIT_FAILED, "Failed to initialize provider"),
67
+ InitFailed: defineError(ERROR_CODES.INIT_FAILED, "Initialization failed")
38
68
  };
39
69
  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)
70
+ TokenRefreshFailed: defineError(ERROR_CODES.TOKEN_REFRESH_FAILED, "Token refresh failed"),
71
+ TokenExchangeFailed: defineError(ERROR_CODES.TOKEN_EXCHANGE_FAILED, "Token exchange failed"),
72
+ LoginFailed: defineError(ERROR_CODES.LOGIN_FAILED, "Login failed"),
73
+ LogoutFailed: defineError(ERROR_CODES.LOGOUT_FAILED, "Logout failed"),
74
+ NotAuthenticated: defineError(ERROR_CODES.NOT_AUTHENTICATED, "User is not authenticated")
44
75
  };
45
76
  var DomainErrors = {
46
- NotAllowed: defineErrorAdvanced("DOMAIN_NOT_ALLOWED", "Domain not allowed: {url}", 403)
77
+ NotAllowed: defineErrorAdvanced(ERROR_CODES.DOMAIN_NOT_ALLOWED, "Domain not allowed: {url}")
47
78
  };
48
79
  var RequestErrors = {
49
80
  // Network errors (connection failed, no response)
50
- NetworkError: defineError("NETWORK_ERROR", "Network error", 500),
51
- Cancelled: defineError("REQUEST_CANCELLED", "Request was cancelled", 499),
81
+ NetworkError: defineError(ERROR_CODES.NETWORK_ERROR, "Network error"),
82
+ Cancelled: defineError(ERROR_CODES.REQUEST_CANCELLED, "Request was cancelled"),
52
83
  // HTTP errors (server responded with error status)
53
- HttpError: defineErrorAdvanced("HTTP_ERROR", "HTTP {status} error", 500),
84
+ HttpError: defineErrorAdvanced(ERROR_CODES.HTTP_ERROR, "HTTP {status} error"),
54
85
  // Response parsing errors
55
- ResponseParseFailed: defineError("RESPONSE_PARSE_FAILED", "Failed to parse response body", 500)
86
+ ResponseParseFailed: defineError(ERROR_CODES.RESPONSE_PARSE_FAILED, "Failed to parse response body"),
87
+ // Queue errors
88
+ QueueFull: defineErrorAdvanced(ERROR_CODES.QUEUE_FULL, "Request queue full ({size}/{maxSize})"),
89
+ // Timeout errors
90
+ Timeout: defineError(ERROR_CODES.REQUEST_TIMEOUT, "Request timed out")
56
91
  };
57
92
 
58
93
  // src/utils/formdata.ts
59
94
  async function serializeFormData(formData) {
60
95
  const entries = [];
96
+ const transferables = [];
97
+ const orderedEntries = [];
98
+ let index = 0;
61
99
  formData.forEach((value, key) => {
62
- if (value instanceof File) {
63
- } else {
64
- entries.push([key, String(value)]);
65
- }
100
+ orderedEntries.push({ index, key, value });
101
+ index++;
66
102
  });
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);
103
+ await Promise.all(
104
+ orderedEntries.map(async ({ index: idx, key, value }) => {
105
+ if (value instanceof File) {
106
+ const buffer = await value.arrayBuffer();
73
107
  const serializedFile = {
74
108
  name: value.name,
75
109
  type: value.type,
76
- data: Array.from(uint8Array)
77
- // Convert to number array
110
+ buffer
78
111
  };
79
- entries.push([key, serializedFile]);
80
- })();
81
- filePromises.push(promise);
82
- }
83
- });
84
- await Promise.all(filePromises);
112
+ entries[idx] = [key, serializedFile];
113
+ transferables.push(buffer);
114
+ } else {
115
+ entries[idx] = [key, String(value)];
116
+ }
117
+ })
118
+ );
85
119
  return {
86
- _type: "FormData",
87
- entries
120
+ data: {
121
+ _type: "FormData",
122
+ entries
123
+ },
124
+ transferables
88
125
  };
89
126
  }
90
127
  function deserializeFormData(serialized) {
@@ -93,8 +130,7 @@ function deserializeFormData(serialized) {
93
130
  if (typeof value === "string") {
94
131
  formData.append(key, value);
95
132
  } else {
96
- const uint8Array = new Uint8Array(value.data);
97
- const file = new File([uint8Array], value.name, { type: value.type });
133
+ const file = new File([value.buffer], value.name, { type: value.type });
98
134
  formData.append(key, file);
99
135
  }
100
136
  }
@@ -108,22 +144,46 @@ function isSerializedFormData(body) {
108
144
  }
109
145
 
110
146
  // src/client.ts
147
+ var DEFAULT_MAX_CONCURRENT = 6;
148
+ var DEFAULT_MAX_QUEUE_SIZE = 1e3;
149
+ var DEFAULT_SETUP_TIMEOUT = 1e4;
150
+ var DEFAULT_REQUEST_TIMEOUT = 3e4;
111
151
  var FetchGuardClient = class {
112
152
  worker;
113
153
  messageId = 0;
114
154
  // Using unknown because different messages have different response types
115
- // (ApiResponse for FETCH, AuthResult for AUTH_CALL, etc.)
155
+ // (FetchEnvelope for FETCH, AuthResult for AUTH_CALL, etc.)
116
156
  pendingRequests = /* @__PURE__ */ new Map();
157
+ /** Track request URLs for debug hooks */
158
+ requestUrls = /* @__PURE__ */ new Map();
159
+ /** Track request timing for metrics */
160
+ requestTimings = /* @__PURE__ */ new Map();
117
161
  authListeners = /* @__PURE__ */ new Set();
118
162
  readyListeners = /* @__PURE__ */ new Set();
119
163
  isReady = false;
120
164
  requestQueue = [];
121
- isProcessingQueue = false;
122
- queueTimeout = 3e4;
123
- // 30 seconds
165
+ activeRequests = 0;
166
+ maxConcurrent;
167
+ maxQueueSize;
168
+ setupTimeout;
169
+ requestTimeout;
124
170
  setupResolve;
125
171
  setupReject;
172
+ debug;
173
+ retry;
174
+ dedupe;
175
+ /** In-flight requests for deduplication */
176
+ inFlightRequests = /* @__PURE__ */ new Map();
177
+ /** Recent completed requests for time-window deduplication */
178
+ recentResults = /* @__PURE__ */ new Map();
126
179
  constructor(options) {
180
+ this.maxConcurrent = options.maxConcurrent ?? DEFAULT_MAX_CONCURRENT;
181
+ this.maxQueueSize = options.maxQueueSize ?? DEFAULT_MAX_QUEUE_SIZE;
182
+ this.setupTimeout = options.setupTimeout ?? DEFAULT_SETUP_TIMEOUT;
183
+ this.requestTimeout = options.requestTimeout ?? DEFAULT_REQUEST_TIMEOUT;
184
+ this.debug = options.debug;
185
+ this.retry = options.retry;
186
+ this.dedupe = options.dedupe;
127
187
  this.worker = new Worker(new URL("./worker.js", import.meta.url), {
128
188
  type: "module"
129
189
  });
@@ -168,7 +228,7 @@ var FetchGuardClient = class {
168
228
  this.setupResolve = void 0;
169
229
  this.setupReject = void 0;
170
230
  }
171
- }, 1e4);
231
+ }, this.setupTimeout);
172
232
  });
173
233
  }
174
234
  /**
@@ -179,31 +239,35 @@ var FetchGuardClient = class {
179
239
  if (type === MSG.FETCH_RESULT) {
180
240
  const request = this.pendingRequests.get(id);
181
241
  if (!request) return;
242
+ const url = this.requestUrls.get(id);
243
+ const timing = this.requestTimings.get(id);
182
244
  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
- ));
245
+ this.requestUrls.delete(id);
246
+ this.requestTimings.delete(id);
247
+ this.onRequestComplete();
248
+ const metrics = this.calculateMetrics(timing);
249
+ if (this.debug?.onResponse && url) {
250
+ this.debug.onResponse(url, payload, metrics);
195
251
  }
252
+ request.resolve(ok(payload));
196
253
  return;
197
254
  }
198
255
  if (type === MSG.FETCH_ERROR) {
199
256
  const request = this.pendingRequests.get(id);
200
257
  if (!request) return;
258
+ const url = this.requestUrls.get(id);
259
+ const timing = this.requestTimings.get(id);
201
260
  this.pendingRequests.delete(id);
202
- const status = typeof payload?.status === "number" ? payload.status : void 0;
261
+ this.requestUrls.delete(id);
262
+ this.requestTimings.delete(id);
263
+ this.onRequestComplete();
264
+ const errorMessage = String(payload?.error || "Network error");
265
+ const metrics = this.calculateMetrics(timing);
266
+ if (this.debug?.onError && url) {
267
+ this.debug.onError(url, { code: "NETWORK_ERROR", message: errorMessage }, metrics);
268
+ }
203
269
  request.resolve(err(
204
- RequestErrors.NetworkError({ message: String(payload?.error || "Network error") }),
205
- void 0,
206
- status
270
+ RequestErrors.NetworkError({ message: errorMessage })
207
271
  ));
208
272
  return;
209
273
  }
@@ -211,7 +275,8 @@ var FetchGuardClient = class {
211
275
  const request = this.pendingRequests.get(id);
212
276
  if (!request) return;
213
277
  this.pendingRequests.delete(id);
214
- request.resolve(err(payload.errors, payload.meta, payload.status));
278
+ this.onRequestComplete();
279
+ request.resolve(err(payload.errors, payload.meta));
215
280
  return;
216
281
  }
217
282
  if (type === MSG.SETUP_ERROR) {
@@ -224,6 +289,7 @@ var FetchGuardClient = class {
224
289
  }
225
290
  if (type === MSG.READY) {
226
291
  this.isReady = true;
292
+ this.debug?.onWorkerReady?.();
227
293
  for (const listener of this.readyListeners) {
228
294
  listener();
229
295
  }
@@ -238,6 +304,7 @@ var FetchGuardClient = class {
238
304
  const request = this.pendingRequests.get(id);
239
305
  if (request) {
240
306
  this.pendingRequests.delete(id);
307
+ this.onRequestComplete();
241
308
  request.resolve(ok({ timestamp: payload?.timestamp }));
242
309
  }
243
310
  return;
@@ -250,20 +317,28 @@ var FetchGuardClient = class {
250
317
  const request = this.pendingRequests.get(id);
251
318
  if (request) {
252
319
  this.pendingRequests.delete(id);
320
+ this.onRequestComplete();
253
321
  request.resolve(ok(payload));
254
322
  }
255
323
  return;
256
324
  }
325
+ if (type === MSG.TOKEN_REFRESHED) {
326
+ this.debug?.onRefresh?.(payload?.reason);
327
+ return;
328
+ }
257
329
  }
258
330
  /**
259
331
  * Handle worker errors
260
332
  */
261
333
  handleWorkerError(error) {
262
334
  console.error("Worker error:", error);
335
+ this.debug?.onWorkerError?.(error);
263
336
  for (const [id, request] of this.pendingRequests) {
264
337
  request.reject(new Error(`Worker error: ${error.message}`));
265
338
  }
266
339
  this.pendingRequests.clear();
340
+ this.requestUrls.clear();
341
+ this.requestTimings.clear();
267
342
  }
268
343
  /**
269
344
  * Generate unique message ID
@@ -272,11 +347,207 @@ var FetchGuardClient = class {
272
347
  return `msg_${++this.messageId}_${Date.now()}`;
273
348
  }
274
349
  /**
275
- * Make API request
350
+ * Make API request with optional deduplication, retry, and AbortSignal support
351
+ *
352
+ * @param url - Full URL to fetch
353
+ * @param options - Request options including optional AbortSignal
354
+ * @returns Result with FetchEnvelope on success, error on failure
355
+ *
356
+ * @example
357
+ * // With AbortSignal
358
+ * const controller = new AbortController()
359
+ * setTimeout(() => controller.abort(), 5000)
360
+ * const result = await api.fetch('/slow', { signal: controller.signal })
276
361
  */
277
362
  async fetch(url, options = {}) {
278
- const { result } = this.fetchWithId(url, options);
279
- return result;
363
+ const { signal, ...restOptions } = options;
364
+ if (signal?.aborted) {
365
+ return err(RequestErrors.Cancelled());
366
+ }
367
+ const dedupeKey = this.getDedupeKey(url, restOptions);
368
+ if (dedupeKey) {
369
+ const inFlight = this.inFlightRequests.get(dedupeKey);
370
+ if (inFlight) {
371
+ if (signal) {
372
+ return this.wrapWithAbortSignal(inFlight, signal, null);
373
+ }
374
+ return inFlight;
375
+ }
376
+ const window = this.dedupe?.window ?? 0;
377
+ if (window > 0) {
378
+ const recent = this.recentResults.get(dedupeKey);
379
+ if (recent && Date.now() - recent.timestamp < window) {
380
+ return recent.result;
381
+ }
382
+ }
383
+ const promise = this.fetchWithRetryAndSignal(url, restOptions, signal ?? void 0);
384
+ this.inFlightRequests.set(dedupeKey, promise);
385
+ try {
386
+ const result = await promise;
387
+ if (window > 0) {
388
+ this.recentResults.set(dedupeKey, { result, timestamp: Date.now() });
389
+ setTimeout(() => this.recentResults.delete(dedupeKey), window);
390
+ }
391
+ return result;
392
+ } finally {
393
+ this.inFlightRequests.delete(dedupeKey);
394
+ }
395
+ }
396
+ return this.fetchWithRetryAndSignal(url, restOptions, signal ?? void 0);
397
+ }
398
+ /**
399
+ * Wrap a promise with AbortSignal support
400
+ */
401
+ wrapWithAbortSignal(promise, signal, requestId) {
402
+ return new Promise((resolve) => {
403
+ const abortHandler = () => {
404
+ if (requestId) {
405
+ this.cancel(requestId);
406
+ }
407
+ resolve(err(RequestErrors.Cancelled()));
408
+ };
409
+ if (signal.aborted) {
410
+ abortHandler();
411
+ return;
412
+ }
413
+ signal.addEventListener("abort", abortHandler, { once: true });
414
+ promise.then((result) => {
415
+ signal.removeEventListener("abort", abortHandler);
416
+ resolve(result);
417
+ });
418
+ });
419
+ }
420
+ /**
421
+ * Fetch with retry logic and AbortSignal support (internal)
422
+ */
423
+ async fetchWithRetryAndSignal(url, options, signal) {
424
+ const maxAttempts = this.retry?.maxAttempts ?? 0;
425
+ const delay = this.retry?.delay ?? 1e3;
426
+ const backoff = this.retry?.backoff ?? 1;
427
+ const maxDelay = this.retry?.maxDelay ?? 3e4;
428
+ const jitter = this.retry?.jitter ?? 0;
429
+ const shouldRetry = this.retry?.shouldRetry ?? this.defaultShouldRetry;
430
+ let lastResult = null;
431
+ let currentDelay = delay;
432
+ for (let attempt = 0; attempt <= maxAttempts; attempt++) {
433
+ if (signal?.aborted) {
434
+ return err(RequestErrors.Cancelled());
435
+ }
436
+ const { id, result } = this.fetchWithId(url, options);
437
+ if (signal) {
438
+ lastResult = await this.wrapWithAbortSignal(result, signal, id);
439
+ } else {
440
+ lastResult = await result;
441
+ }
442
+ if (lastResult.ok) {
443
+ return lastResult;
444
+ }
445
+ if (lastResult.errors[0]?.code === "REQUEST_CANCELLED") {
446
+ return lastResult;
447
+ }
448
+ const error = lastResult.errors[0];
449
+ const errorDetail = {
450
+ code: error?.code ?? "NETWORK_ERROR",
451
+ message: error?.message ?? "Unknown error"
452
+ };
453
+ if (attempt >= maxAttempts || !shouldRetry(errorDetail)) {
454
+ return lastResult;
455
+ }
456
+ const cappedDelay = Math.min(currentDelay, maxDelay);
457
+ const jitteredDelay = this.applyJitter(cappedDelay, jitter);
458
+ if (signal) {
459
+ const aborted = await this.sleepWithAbort(jitteredDelay, signal);
460
+ if (aborted) {
461
+ return err(RequestErrors.Cancelled());
462
+ }
463
+ } else {
464
+ await this.sleep(jitteredDelay);
465
+ }
466
+ currentDelay = currentDelay * backoff;
467
+ }
468
+ return lastResult;
469
+ }
470
+ /**
471
+ * Generate deduplication key for request
472
+ * Returns null if request should not be deduplicated
473
+ */
474
+ getDedupeKey(url, options) {
475
+ if (!this.dedupe?.enabled) {
476
+ return null;
477
+ }
478
+ if (this.dedupe.keyGenerator) {
479
+ return this.dedupe.keyGenerator(url, options);
480
+ }
481
+ const method = (options.method ?? "GET").toUpperCase();
482
+ if (method !== "GET") {
483
+ return null;
484
+ }
485
+ return `GET:${url}`;
486
+ }
487
+ /**
488
+ * Apply jitter to a delay value
489
+ * Jitter adds ±(jitter * delay) randomness to prevent thundering herd
490
+ * @param delay - Base delay in milliseconds
491
+ * @param jitter - Jitter factor (0-1)
492
+ * @returns Jittered delay
493
+ */
494
+ applyJitter(delay, jitter) {
495
+ if (jitter <= 0) return delay;
496
+ const clampedJitter = Math.min(Math.max(jitter, 0), 1);
497
+ const randomFactor = Math.random() * 2 - 1;
498
+ return Math.max(0, delay + delay * clampedJitter * randomFactor);
499
+ }
500
+ /**
501
+ * Default retry condition - only retry on NETWORK_ERROR
502
+ */
503
+ defaultShouldRetry(error) {
504
+ return error.code === "NETWORK_ERROR";
505
+ }
506
+ /**
507
+ * Calculate request metrics from timing data
508
+ */
509
+ calculateMetrics(timing) {
510
+ if (!timing) return void 0;
511
+ const endTime = Date.now();
512
+ const startTime = timing.createdAt;
513
+ const sentAt = timing.sentAt ?? startTime;
514
+ const duration = endTime - startTime;
515
+ const queueTime = sentAt - startTime;
516
+ const ipcTime = duration - queueTime;
517
+ return {
518
+ startTime,
519
+ endTime,
520
+ duration,
521
+ queueTime,
522
+ ipcTime
523
+ };
524
+ }
525
+ /**
526
+ * Sleep helper for retry delay
527
+ */
528
+ sleep(ms) {
529
+ return new Promise((resolve) => setTimeout(resolve, ms));
530
+ }
531
+ /**
532
+ * Sleep with abort signal support
533
+ * Returns true if aborted, false if completed normally
534
+ */
535
+ sleepWithAbort(ms, signal) {
536
+ return new Promise((resolve) => {
537
+ if (signal.aborted) {
538
+ resolve(true);
539
+ return;
540
+ }
541
+ const timer = setTimeout(() => {
542
+ signal.removeEventListener("abort", abortHandler);
543
+ resolve(false);
544
+ }, ms);
545
+ const abortHandler = () => {
546
+ clearTimeout(timer);
547
+ resolve(true);
548
+ };
549
+ signal.addEventListener("abort", abortHandler, { once: true });
550
+ });
280
551
  }
281
552
  /**
282
553
  * Fetch with id for external cancellation
@@ -290,11 +561,18 @@ var FetchGuardClient = class {
290
561
  resolve: (response) => resolve(response),
291
562
  reject: (error) => reject(error)
292
563
  });
564
+ this.requestUrls.set(id, url);
565
+ this.requestTimings.set(id, { createdAt: Date.now() });
566
+ this.debug?.onRequest?.(url, options);
293
567
  try {
294
568
  let serializedOptions = { ...options };
569
+ let transferables;
295
570
  if (options.body && isFormData(options.body)) {
296
- const serializedBody = await serializeFormData(options.body);
297
- serializedOptions.body = serializedBody;
571
+ const { data, transferables: formDataTransferables } = await serializeFormData(options.body);
572
+ serializedOptions.body = data;
573
+ if (formDataTransferables.length > 0) {
574
+ transferables = formDataTransferables;
575
+ }
298
576
  }
299
577
  if (options.headers) {
300
578
  if (options.headers instanceof Headers) {
@@ -306,11 +584,13 @@ var FetchGuardClient = class {
306
584
  }
307
585
  }
308
586
  const message = { id, type: MSG.FETCH, payload: { url, options: serializedOptions } };
309
- await this.sendMessageQueued(message, 3e4);
587
+ await this.sendMessageQueued(message, 3e4, transferables);
310
588
  } catch (error) {
311
589
  const request = this.pendingRequests.get(id);
312
590
  if (request) {
313
591
  this.pendingRequests.delete(id);
592
+ this.requestUrls.delete(id);
593
+ this.requestTimings.delete(id);
314
594
  request.reject(error instanceof Error ? error : new Error(String(error)));
315
595
  }
316
596
  }
@@ -324,8 +604,16 @@ var FetchGuardClient = class {
324
604
  cancel(id) {
325
605
  const request = this.pendingRequests.get(id);
326
606
  if (request) {
607
+ const url = this.requestUrls.get(id);
608
+ const timing = this.requestTimings.get(id);
327
609
  this.pendingRequests.delete(id);
610
+ this.requestUrls.delete(id);
611
+ this.requestTimings.delete(id);
328
612
  this.worker.postMessage({ id, type: MSG.CANCEL });
613
+ const metrics = this.calculateMetrics(timing);
614
+ if (this.debug?.onError && url) {
615
+ this.debug.onError(url, { code: "REQUEST_CANCELLED", message: "Request cancelled" }, metrics);
616
+ }
329
617
  request.reject(new Error("Request cancelled"));
330
618
  }
331
619
  }
@@ -454,6 +742,37 @@ var FetchGuardClient = class {
454
742
  async refreshToken(emitEvent = true) {
455
743
  return this.call("refreshToken", emitEvent);
456
744
  }
745
+ /**
746
+ * Exchange current token for a new one with different context
747
+ *
748
+ * Useful for:
749
+ * - Switching tenants in multi-tenant apps
750
+ * - Changing authorization scope
751
+ * - Impersonating users (admin feature)
752
+ *
753
+ * @param url - URL to call for token exchange
754
+ * @param options - Exchange options (method, payload)
755
+ * @param emitEvent - Whether to emit AUTH_STATE_CHANGED event (default: true)
756
+ *
757
+ * @example
758
+ * // Switch tenant
759
+ * await api.exchangeToken('https://auth.example.com/auth/select-tenant', {
760
+ * payload: { tenantId: 'tenant_123' }
761
+ * })
762
+ *
763
+ * // Change scope with PUT method
764
+ * await api.exchangeToken('https://auth.example.com/auth/switch-context', {
765
+ * method: 'PUT',
766
+ * payload: { scope: 'admin' }
767
+ * })
768
+ */
769
+ async exchangeToken(url, options, emitEvent = true) {
770
+ const args = [url];
771
+ if (options) {
772
+ args.push(options);
773
+ }
774
+ return this.call("exchangeToken", emitEvent, ...args);
775
+ }
457
776
  /**
458
777
  * Check if worker is ready
459
778
  */
@@ -511,20 +830,28 @@ var FetchGuardClient = class {
511
830
  /**
512
831
  * Send message through queue system
513
832
  * All messages go through queue for sequential processing
833
+ * @param transferables - Optional Transferable objects for zero-copy postMessage
514
834
  */
515
- sendMessageQueued(message, timeoutMs = this.queueTimeout) {
835
+ sendMessageQueued(message, timeoutMs = this.requestTimeout, transferables) {
516
836
  return new Promise((resolve, reject) => {
837
+ if (this.requestQueue.length >= this.maxQueueSize) {
838
+ reject(err(RequestErrors.QueueFull({ size: this.requestQueue.length, maxSize: this.maxQueueSize })));
839
+ return;
840
+ }
517
841
  const timeout = setTimeout(() => {
518
842
  const index = this.requestQueue.findIndex((item) => item.id === message.id);
519
843
  if (index !== -1) {
520
844
  this.requestQueue.splice(index, 1);
521
845
  }
522
846
  this.pendingRequests.delete(message.id);
523
- reject(new Error("Request timeout"));
847
+ this.requestUrls.delete(message.id);
848
+ this.requestTimings.delete(message.id);
849
+ reject(err(RequestErrors.Timeout()));
524
850
  }, timeoutMs);
525
851
  const queueItem = {
526
852
  id: message.id,
527
853
  message,
854
+ transferables,
528
855
  resolve,
529
856
  reject,
530
857
  timeout
@@ -534,29 +861,44 @@ var FetchGuardClient = class {
534
861
  });
535
862
  }
536
863
  /**
537
- * Process message queue sequentially
864
+ * Process message queue with concurrency limit
865
+ *
866
+ * Uses semaphore pattern to allow N concurrent requests.
538
867
  * Benefits:
539
- * - Sequential processing prevents worker overload
868
+ * - Higher throughput than sequential processing
869
+ * - Backpressure via maxConcurrent limit
540
870
  * - Better error isolation (one failure doesn't affect others)
541
- * - 50ms delay between requests for backpressure
542
871
  */
543
- async processQueue() {
544
- if (this.isProcessingQueue || this.requestQueue.length === 0) {
545
- return;
546
- }
547
- this.isProcessingQueue = true;
548
- while (this.requestQueue.length > 0) {
872
+ processQueue() {
873
+ while (this.requestQueue.length > 0 && this.activeRequests < this.maxConcurrent) {
549
874
  const item = this.requestQueue.shift();
550
875
  if (!item) continue;
876
+ this.activeRequests++;
877
+ const timing = this.requestTimings.get(item.id);
878
+ if (timing) {
879
+ timing.sentAt = Date.now();
880
+ }
551
881
  try {
552
- this.worker.postMessage(item.message);
553
- await new Promise((resolve) => setTimeout(resolve, 50));
882
+ if (item.transferables && item.transferables.length > 0) {
883
+ this.worker.postMessage(item.message, item.transferables);
884
+ } else {
885
+ this.worker.postMessage(item.message);
886
+ }
554
887
  } catch (error) {
888
+ this.activeRequests--;
555
889
  clearTimeout(item.timeout);
556
890
  item.reject(error instanceof Error ? error : new Error(String(error)));
891
+ this.processQueue();
557
892
  }
558
893
  }
559
- this.isProcessingQueue = false;
894
+ }
895
+ /**
896
+ * Called when a request completes (success or error)
897
+ * Decrements active count and processes next items in queue
898
+ */
899
+ onRequestComplete() {
900
+ this.activeRequests--;
901
+ this.processQueue();
560
902
  }
561
903
  /**
562
904
  * Cleanup - terminate worker
@@ -564,6 +906,8 @@ var FetchGuardClient = class {
564
906
  destroy() {
565
907
  this.worker.terminate();
566
908
  this.pendingRequests.clear();
909
+ this.requestUrls.clear();
910
+ this.requestTimings.clear();
567
911
  for (const item of this.requestQueue) {
568
912
  clearTimeout(item.timeout);
569
913
  item.reject(new Error("Client destroyed"));
@@ -619,7 +963,7 @@ function createProvider(config) {
619
963
  const response = await config.strategy.refresh(currentRefreshToken);
620
964
  if (!response.ok) {
621
965
  const body = await response.text().catch(() => "");
622
- return err2(AuthErrors.TokenRefreshFailed(), { body }, response.status);
966
+ return err2(AuthErrors.TokenRefreshFailed(), { params: { body, status: response.status } });
623
967
  }
624
968
  const tokenInfo = await config.parser.parse(response);
625
969
  if (!tokenInfo.token) {
@@ -638,7 +982,7 @@ function createProvider(config) {
638
982
  const response = await config.strategy.login(payload, url);
639
983
  if (!response.ok) {
640
984
  const body = await response.text().catch(() => "");
641
- return err2(AuthErrors.LoginFailed(), { body }, response.status);
985
+ return err2(AuthErrors.LoginFailed(), { params: { body, status: response.status } });
642
986
  }
643
987
  const tokenInfo = await config.parser.parse(response);
644
988
  if (!tokenInfo.token) {
@@ -657,7 +1001,7 @@ function createProvider(config) {
657
1001
  const response = await config.strategy.logout(payload);
658
1002
  if (!response.ok) {
659
1003
  const body = await response.text().catch(() => "");
660
- return err2(AuthErrors.LogoutFailed(), { body }, response.status);
1004
+ return err2(AuthErrors.LogoutFailed(), { params: { body, status: response.status } });
661
1005
  }
662
1006
  if (config.refreshStorage) {
663
1007
  await config.refreshStorage.set(null);
@@ -672,6 +1016,37 @@ function createProvider(config) {
672
1016
  } catch (error) {
673
1017
  return err2(RequestErrors.NetworkError({ message: String(error) }));
674
1018
  }
1019
+ },
1020
+ async exchangeToken(accessToken, url, options = {}) {
1021
+ const { method = "POST", payload } = options;
1022
+ if (!accessToken) {
1023
+ return err2(AuthErrors.NotAuthenticated());
1024
+ }
1025
+ try {
1026
+ const response = await fetch(url, {
1027
+ method,
1028
+ headers: {
1029
+ "Content-Type": "application/json",
1030
+ "Authorization": `Bearer ${accessToken}`
1031
+ },
1032
+ body: payload ? JSON.stringify(payload) : void 0,
1033
+ credentials: "include"
1034
+ });
1035
+ if (!response.ok) {
1036
+ const body = await response.text().catch(() => "");
1037
+ return err2(AuthErrors.TokenExchangeFailed(), { params: { body, status: response.status } });
1038
+ }
1039
+ const tokenInfo = await config.parser.parse(response);
1040
+ if (!tokenInfo.token) {
1041
+ return err2(AuthErrors.TokenExchangeFailed({ message: "No access token in response" }));
1042
+ }
1043
+ if (config.refreshStorage && tokenInfo.refreshToken) {
1044
+ await config.refreshStorage.set(tokenInfo.refreshToken);
1045
+ }
1046
+ return ok2(tokenInfo);
1047
+ } catch (error) {
1048
+ return err2(RequestErrors.NetworkError({ message: String(error) }));
1049
+ }
675
1050
  }
676
1051
  };
677
1052
  if (config.customMethods) {
@@ -684,7 +1059,13 @@ function createProvider(config) {
684
1059
  }
685
1060
 
686
1061
  // src/provider/storage/indexeddb.ts
687
- function createIndexedDBStorage(dbName = "FetchGuardDB", refreshTokenKey = "refreshToken") {
1062
+ function createIndexedDBStorage(options = "FetchGuardDB", legacyRefreshTokenKey) {
1063
+ const config = typeof options === "string" ? { dbName: options, refreshTokenKey: legacyRefreshTokenKey ?? "refreshToken", onError: void 0 } : {
1064
+ dbName: options.dbName ?? "FetchGuardDB",
1065
+ refreshTokenKey: options.refreshTokenKey ?? "refreshToken",
1066
+ onError: options.onError
1067
+ };
1068
+ const { dbName, refreshTokenKey, onError } = config;
688
1069
  const storeName = "tokens";
689
1070
  const openDB = () => {
690
1071
  return new Promise((resolve, reject) => {
@@ -715,7 +1096,7 @@ function createIndexedDBStorage(dbName = "FetchGuardDB", refreshTokenKey = "refr
715
1096
  const result = await promisifyRequest(store.get(refreshTokenKey));
716
1097
  return result?.value || null;
717
1098
  } catch (error) {
718
- console.warn("Failed to get refresh token from IndexedDB:", error);
1099
+ onError?.(error, "get");
719
1100
  return null;
720
1101
  }
721
1102
  },
@@ -730,7 +1111,7 @@ function createIndexedDBStorage(dbName = "FetchGuardDB", refreshTokenKey = "refr
730
1111
  await promisifyRequest(store.delete(refreshTokenKey));
731
1112
  }
732
1113
  } catch (error) {
733
- console.warn("Failed to save refresh token to IndexedDB:", error);
1114
+ onError?.(error, token ? "set" : "delete");
734
1115
  }
735
1116
  }
736
1117
  };
@@ -891,9 +1272,76 @@ function isBinaryContentType(contentType) {
891
1272
  if (normalized.includes("html")) return false;
892
1273
  return true;
893
1274
  }
1275
+
1276
+ // src/helpers.ts
1277
+ function isNetworkError(result) {
1278
+ return !result.ok;
1279
+ }
1280
+ function isSuccess(result) {
1281
+ return result.ok && result.data.status >= 200 && result.data.status < 300;
1282
+ }
1283
+ function isClientError(result) {
1284
+ return result.ok && result.data.status >= 400 && result.data.status < 500;
1285
+ }
1286
+ function isServerError(result) {
1287
+ return result.ok && result.data.status >= 500;
1288
+ }
1289
+ function parseJson(result) {
1290
+ if (!result.ok) return null;
1291
+ try {
1292
+ return JSON.parse(result.data.body);
1293
+ } catch {
1294
+ return null;
1295
+ }
1296
+ }
1297
+ function getErrorMessage(result) {
1298
+ if (result.ok) {
1299
+ try {
1300
+ const body = JSON.parse(result.data.body);
1301
+ return body.message || body.error || `HTTP ${result.data.status}`;
1302
+ } catch {
1303
+ return `HTTP ${result.data.status}`;
1304
+ }
1305
+ }
1306
+ return result.errors[0]?.message || "Unknown error";
1307
+ }
1308
+ function getErrorBody(result) {
1309
+ if (!result.ok) return null;
1310
+ if (result.data.status >= 400) {
1311
+ try {
1312
+ return JSON.parse(result.data.body);
1313
+ } catch {
1314
+ return null;
1315
+ }
1316
+ }
1317
+ return null;
1318
+ }
1319
+ function getStatus(result) {
1320
+ return result.ok ? result.data.status : null;
1321
+ }
1322
+ function hasStatus(result, status) {
1323
+ return result.ok && result.data.status === status;
1324
+ }
1325
+ function matchResult(result, handlers) {
1326
+ if (!result.ok) {
1327
+ return handlers.networkError?.(result.errors);
1328
+ }
1329
+ const status = result.data.status;
1330
+ if (status >= 200 && status < 300) {
1331
+ return handlers.success?.(result.data);
1332
+ }
1333
+ if (status >= 400 && status < 500) {
1334
+ return handlers.clientError?.(result.data);
1335
+ }
1336
+ if (status >= 500) {
1337
+ return handlers.serverError?.(result.data);
1338
+ }
1339
+ return void 0;
1340
+ }
894
1341
  export {
895
1342
  AuthErrors,
896
1343
  DomainErrors,
1344
+ ERROR_CODES,
897
1345
  FetchGuardClient,
898
1346
  GeneralErrors,
899
1347
  InitErrors,
@@ -913,12 +1361,22 @@ export {
913
1361
  createIndexedDBStorage,
914
1362
  createProvider,
915
1363
  deserializeFormData,
1364
+ getErrorBody,
1365
+ getErrorMessage,
916
1366
  getProvider,
1367
+ getStatus,
917
1368
  hasProvider,
1369
+ hasStatus,
918
1370
  isBinaryContentType,
1371
+ isClientError,
919
1372
  isFormData,
1373
+ isNetworkError,
920
1374
  isSerializedFormData,
1375
+ isServerError,
1376
+ isSuccess,
921
1377
  listProviders,
1378
+ matchResult,
1379
+ parseJson,
922
1380
  registerProvider,
923
1381
  serializeFormData,
924
1382
  unregisterProvider