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/README.md +235 -65
- package/dist/index.d.ts +540 -31
- package/dist/index.js +495 -88
- package/dist/index.js.map +1 -1
- package/dist/worker.js +155 -76
- package/dist/worker.js.map +1 -1
- package/package.json +8 -3
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(
|
|
31
|
-
UnknownMessage: defineError(
|
|
32
|
-
ResultParse: defineError(
|
|
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(
|
|
36
|
-
ProviderInitFailed: defineError(
|
|
37
|
-
InitFailed: defineError(
|
|
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(
|
|
41
|
-
LoginFailed: defineError(
|
|
42
|
-
LogoutFailed: defineError(
|
|
43
|
-
NotAuthenticated: defineError(
|
|
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(
|
|
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(
|
|
51
|
-
Cancelled: defineError(
|
|
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(
|
|
82
|
+
HttpError: defineErrorAdvanced(ERROR_CODES.HTTP_ERROR, "HTTP {status} error"),
|
|
54
83
|
// Response parsing errors
|
|
55
|
-
ResponseParseFailed: defineError(
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
entries.push([key, String(value)]);
|
|
65
|
-
}
|
|
98
|
+
orderedEntries.push({ index, key, value });
|
|
99
|
+
index++;
|
|
66
100
|
});
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
-
|
|
77
|
-
// Convert to number array
|
|
108
|
+
buffer
|
|
78
109
|
};
|
|
79
|
-
entries
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
110
|
+
entries[idx] = [key, serializedFile];
|
|
111
|
+
transferables.push(buffer);
|
|
112
|
+
} else {
|
|
113
|
+
entries[idx] = [key, String(value)];
|
|
114
|
+
}
|
|
115
|
+
})
|
|
116
|
+
);
|
|
85
117
|
return {
|
|
86
|
-
|
|
87
|
-
|
|
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
|
|
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
|
-
// (
|
|
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
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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
|
-
},
|
|
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
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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 {
|
|
279
|
-
|
|
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
|
|
297
|
-
serializedOptions.body =
|
|
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.
|
|
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
|
-
|
|
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
|
|
831
|
+
* Process message queue with concurrency limit
|
|
832
|
+
*
|
|
833
|
+
* Uses semaphore pattern to allow N concurrent requests.
|
|
538
834
|
* Benefits:
|
|
539
|
-
* -
|
|
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
|
-
|
|
544
|
-
|
|
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
|
-
|
|
553
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|