@auth-craft/client 0.1.0 → 0.1.2
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 +144 -8
- package/dist/core/core.d.ts +72 -0
- package/dist/core/core.d.ts.map +1 -0
- package/dist/core/core.js +105 -0
- package/dist/core/core.js.map +1 -0
- package/dist/core/proxy.d.ts +79 -0
- package/dist/core/proxy.d.ts.map +1 -0
- package/dist/core/proxy.js +99 -0
- package/dist/core/proxy.js.map +1 -0
- package/dist/index.d.ts +15 -28
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +23 -55
- package/dist/index.js.map +1 -1
- package/dist/transport/client.d.ts +250 -0
- package/dist/transport/client.d.ts.map +1 -0
- package/dist/transport/client.js +940 -0
- package/dist/transport/client.js.map +1 -0
- package/dist/transport/constants.d.ts +9 -0
- package/dist/transport/constants.d.ts.map +1 -0
- package/dist/transport/constants.js +9 -0
- package/dist/transport/constants.js.map +1 -0
- package/dist/transport/error-codes.d.ts +41 -0
- package/dist/transport/error-codes.d.ts.map +1 -0
- package/dist/transport/error-codes.js +38 -0
- package/dist/transport/error-codes.js.map +1 -0
- package/dist/transport/errors.d.ts +55 -0
- package/dist/transport/errors.d.ts.map +1 -0
- package/dist/transport/errors.js +55 -0
- package/dist/transport/errors.js.map +1 -0
- package/dist/transport/helpers.d.ts +135 -0
- package/dist/transport/helpers.d.ts.map +1 -0
- package/dist/transport/helpers.js +185 -0
- package/dist/transport/helpers.js.map +1 -0
- package/dist/transport/index.d.ts +22 -0
- package/dist/transport/index.d.ts.map +1 -0
- package/dist/transport/index.js +32 -0
- package/dist/transport/index.js.map +1 -0
- package/dist/transport/messages.d.ts +104 -0
- package/dist/transport/messages.d.ts.map +1 -0
- package/dist/transport/messages.js +24 -0
- package/dist/transport/messages.js.map +1 -0
- package/dist/transport/provider/create-provider.d.ts +38 -0
- package/dist/transport/provider/create-provider.d.ts.map +1 -0
- package/dist/transport/provider/create-provider.js +127 -0
- package/dist/transport/provider/create-provider.js.map +1 -0
- package/dist/transport/provider/parser/body.d.ts +7 -0
- package/dist/transport/provider/parser/body.d.ts.map +1 -0
- package/dist/transport/provider/parser/body.js +17 -0
- package/dist/transport/provider/parser/body.js.map +1 -0
- package/dist/transport/provider/parser/cookie.d.ts +8 -0
- package/dist/transport/provider/parser/cookie.d.ts.map +1 -0
- package/dist/transport/provider/parser/cookie.js +17 -0
- package/dist/transport/provider/parser/cookie.js.map +1 -0
- package/dist/transport/provider/parser/normalize.d.ts +6 -0
- package/dist/transport/provider/parser/normalize.d.ts.map +1 -0
- package/dist/transport/provider/parser/normalize.js +21 -0
- package/dist/transport/provider/parser/normalize.js.map +1 -0
- package/dist/transport/provider/presets.d.ts +31 -0
- package/dist/transport/provider/presets.d.ts.map +1 -0
- package/dist/transport/provider/presets.js +35 -0
- package/dist/transport/provider/presets.js.map +1 -0
- package/dist/transport/provider/register-presets.d.ts +10 -0
- package/dist/transport/provider/register-presets.d.ts.map +1 -0
- package/dist/transport/provider/register-presets.js +32 -0
- package/dist/transport/provider/register-presets.js.map +1 -0
- package/dist/transport/provider/storage/indexeddb.d.ts +26 -0
- package/dist/transport/provider/storage/indexeddb.d.ts.map +1 -0
- package/dist/transport/provider/storage/indexeddb.js +72 -0
- package/dist/transport/provider/storage/indexeddb.js.map +1 -0
- package/dist/transport/provider/strategy/body.d.ts +28 -0
- package/dist/transport/provider/strategy/body.d.ts.map +1 -0
- package/dist/transport/provider/strategy/body.js +73 -0
- package/dist/transport/provider/strategy/body.js.map +1 -0
- package/dist/transport/provider/strategy/cookie.d.ts +29 -0
- package/dist/transport/provider/strategy/cookie.d.ts.map +1 -0
- package/dist/transport/provider/strategy/cookie.js +70 -0
- package/dist/transport/provider/strategy/cookie.js.map +1 -0
- package/dist/transport/result.d.ts +28 -0
- package/dist/transport/result.d.ts.map +1 -0
- package/dist/transport/result.js +87 -0
- package/dist/transport/result.js.map +1 -0
- package/dist/transport/types.d.ts +488 -0
- package/dist/transport/types.d.ts.map +1 -0
- package/dist/transport/types.js +3 -0
- package/dist/transport/types.js.map +1 -0
- package/dist/transport/utils/binary.d.ts +20 -0
- package/dist/transport/utils/binary.d.ts.map +1 -0
- package/dist/transport/utils/binary.js +51 -0
- package/dist/transport/utils/binary.js.map +1 -0
- package/dist/transport/utils/formdata.d.ts +26 -0
- package/dist/transport/utils/formdata.d.ts.map +1 -0
- package/dist/transport/utils/formdata.js +77 -0
- package/dist/transport/utils/formdata.js.map +1 -0
- package/dist/transport/utils/registry.d.ts +26 -0
- package/dist/transport/utils/registry.d.ts.map +1 -0
- package/dist/transport/utils/registry.js +51 -0
- package/dist/transport/utils/registry.js.map +1 -0
- package/dist/transport/worker-post.d.ts +44 -0
- package/dist/transport/worker-post.d.ts.map +1 -0
- package/dist/transport/worker-post.js +104 -0
- package/dist/transport/worker-post.js.map +1 -0
- package/dist/transport/worker.d.ts +10 -0
- package/dist/transport/worker.d.ts.map +1 -0
- package/dist/transport/worker.js +421 -0
- package/dist/transport/worker.js.map +1 -0
- package/dist/types.test-d.js +3 -9
- package/dist/types.test-d.js.map +1 -1
- package/eslint-isolation.mjs +82 -0
- package/package.json +30 -16
|
@@ -0,0 +1,940 @@
|
|
|
1
|
+
import { err, ok } from 'ts-micro-result';
|
|
2
|
+
import { DEFAULT_REFRESH_EARLY_MS } from './constants';
|
|
3
|
+
import { RequestErrors } from './errors';
|
|
4
|
+
import { MSG } from './messages';
|
|
5
|
+
import { toResult } from './result';
|
|
6
|
+
import { isFormData, serializeFormData } from './utils/formdata';
|
|
7
|
+
/** Default max concurrent requests */
|
|
8
|
+
const DEFAULT_MAX_CONCURRENT = 6;
|
|
9
|
+
/** Default max queue size */
|
|
10
|
+
const DEFAULT_MAX_QUEUE_SIZE = 1000;
|
|
11
|
+
/** Default setup timeout (ms) */
|
|
12
|
+
const DEFAULT_SETUP_TIMEOUT = 10000;
|
|
13
|
+
/** Default request timeout (ms) */
|
|
14
|
+
const DEFAULT_REQUEST_TIMEOUT = 30000;
|
|
15
|
+
/**
|
|
16
|
+
* FetchGuard Client - main interface cho việc gọi API thông qua Web Worker
|
|
17
|
+
*/
|
|
18
|
+
export class FetchGuardClient {
|
|
19
|
+
worker;
|
|
20
|
+
messageId = 0;
|
|
21
|
+
// Using unknown because different messages have different response types
|
|
22
|
+
// (FetchEnvelope for FETCH, AuthResult for AUTH_CALL, etc.)
|
|
23
|
+
pendingRequests = new Map();
|
|
24
|
+
/** Track request URLs for debug hooks */
|
|
25
|
+
requestUrls = new Map();
|
|
26
|
+
/** Track request timing for metrics */
|
|
27
|
+
requestTimings = new Map();
|
|
28
|
+
authListeners = new Set();
|
|
29
|
+
readyListeners = new Set();
|
|
30
|
+
isReady = false;
|
|
31
|
+
requestQueue = [];
|
|
32
|
+
activeRequests = 0;
|
|
33
|
+
maxConcurrent;
|
|
34
|
+
maxQueueSize;
|
|
35
|
+
setupTimeout;
|
|
36
|
+
requestTimeout;
|
|
37
|
+
setupResolve;
|
|
38
|
+
setupReject;
|
|
39
|
+
debug;
|
|
40
|
+
retry;
|
|
41
|
+
dedupe;
|
|
42
|
+
/** In-flight requests for deduplication */
|
|
43
|
+
inFlightRequests = new Map();
|
|
44
|
+
/** Recent completed requests for time-window deduplication */
|
|
45
|
+
recentResults = new Map();
|
|
46
|
+
constructor(options) {
|
|
47
|
+
this.maxConcurrent = options.maxConcurrent ?? DEFAULT_MAX_CONCURRENT;
|
|
48
|
+
this.maxQueueSize = options.maxQueueSize ?? DEFAULT_MAX_QUEUE_SIZE;
|
|
49
|
+
this.setupTimeout = options.setupTimeout ?? DEFAULT_SETUP_TIMEOUT;
|
|
50
|
+
this.requestTimeout = options.requestTimeout ?? DEFAULT_REQUEST_TIMEOUT;
|
|
51
|
+
this.debug = options.debug;
|
|
52
|
+
this.retry = options.retry;
|
|
53
|
+
this.dedupe = options.dedupe;
|
|
54
|
+
// Use custom worker factory if provided, otherwise use built-in worker
|
|
55
|
+
if (options.workerFactory) {
|
|
56
|
+
this.worker = options.workerFactory();
|
|
57
|
+
}
|
|
58
|
+
else {
|
|
59
|
+
this.worker = new Worker(new URL('./worker.js', import.meta.url), {
|
|
60
|
+
type: 'module',
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
this.worker.onmessage = this.handleWorkerMessage.bind(this);
|
|
64
|
+
this.worker.onerror = this.handleWorkerError.bind(this);
|
|
65
|
+
this.initializeWorker(options);
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Initialize worker with config and provider
|
|
69
|
+
*/
|
|
70
|
+
async initializeWorker(options) {
|
|
71
|
+
const config = {
|
|
72
|
+
allowedDomains: options.allowedDomains || [],
|
|
73
|
+
refreshEarlyMs: options.refreshEarlyMs ?? DEFAULT_REFRESH_EARLY_MS,
|
|
74
|
+
defaultHeaders: options.defaultHeaders || {},
|
|
75
|
+
};
|
|
76
|
+
// Serialize provider config based on type
|
|
77
|
+
let providerConfig = null;
|
|
78
|
+
if (typeof options.provider === 'string') {
|
|
79
|
+
// String = registry lookup (advanced usage)
|
|
80
|
+
providerConfig = options.provider;
|
|
81
|
+
}
|
|
82
|
+
else if ('type' in options.provider && options.provider.type) {
|
|
83
|
+
// ProviderPresetConfig object (recommended)
|
|
84
|
+
providerConfig = options.provider;
|
|
85
|
+
}
|
|
86
|
+
else {
|
|
87
|
+
// TokenProvider instance - NOT SUPPORTED
|
|
88
|
+
throw new Error('Direct TokenProvider instance is not supported. Use ProviderPresetConfig instead:\n' +
|
|
89
|
+
' { type: "cookie-auth", refreshUrl: "...", loginUrl: "...", logoutUrl: "..." }\n' +
|
|
90
|
+
'Or for custom providers, register in worker code and use string name.');
|
|
91
|
+
}
|
|
92
|
+
const message = {
|
|
93
|
+
id: this.generateMessageId(),
|
|
94
|
+
type: MSG.SETUP,
|
|
95
|
+
payload: {
|
|
96
|
+
config,
|
|
97
|
+
providerConfig,
|
|
98
|
+
},
|
|
99
|
+
};
|
|
100
|
+
return new Promise((resolve, reject) => {
|
|
101
|
+
// Setup will respond with READY (no id, so we track separately)
|
|
102
|
+
this.setupResolve = resolve;
|
|
103
|
+
this.setupReject = reject;
|
|
104
|
+
this.worker.postMessage(message);
|
|
105
|
+
setTimeout(() => {
|
|
106
|
+
if (this.setupReject) {
|
|
107
|
+
this.setupReject(new Error('Worker setup timeout'));
|
|
108
|
+
this.setupResolve = undefined;
|
|
109
|
+
this.setupReject = undefined;
|
|
110
|
+
}
|
|
111
|
+
}, this.setupTimeout);
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Handle worker messages
|
|
116
|
+
*/
|
|
117
|
+
handleWorkerMessage(event) {
|
|
118
|
+
const { id, type, payload } = event.data;
|
|
119
|
+
if (type === MSG.FETCH_RESULT) {
|
|
120
|
+
// FETCH_RESULT contains FetchEnvelope (raw HTTP response)
|
|
121
|
+
// Worker doesn't judge HTTP status - client receives envelope as-is
|
|
122
|
+
const request = this.pendingRequests.get(id);
|
|
123
|
+
if (!request)
|
|
124
|
+
return;
|
|
125
|
+
const url = this.requestUrls.get(id);
|
|
126
|
+
const timing = this.requestTimings.get(id);
|
|
127
|
+
this.pendingRequests.delete(id);
|
|
128
|
+
this.requestUrls.delete(id);
|
|
129
|
+
this.requestTimings.delete(id);
|
|
130
|
+
this.onRequestComplete();
|
|
131
|
+
// Calculate metrics
|
|
132
|
+
const metrics = this.calculateMetrics(timing);
|
|
133
|
+
// Debug hook: onResponse
|
|
134
|
+
if (this.debug?.onResponse && url) {
|
|
135
|
+
this.debug.onResponse(url, payload, metrics);
|
|
136
|
+
}
|
|
137
|
+
request.resolve(ok(payload));
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
if (type === MSG.FETCH_ERROR) {
|
|
141
|
+
// Network/timeout/cancel errors (no HTTP response)
|
|
142
|
+
const request = this.pendingRequests.get(id);
|
|
143
|
+
if (!request)
|
|
144
|
+
return;
|
|
145
|
+
const url = this.requestUrls.get(id);
|
|
146
|
+
const timing = this.requestTimings.get(id);
|
|
147
|
+
this.pendingRequests.delete(id);
|
|
148
|
+
this.requestUrls.delete(id);
|
|
149
|
+
this.requestTimings.delete(id);
|
|
150
|
+
this.onRequestComplete();
|
|
151
|
+
const errorMessage = String(payload?.error || 'Network error');
|
|
152
|
+
// Calculate metrics
|
|
153
|
+
const metrics = this.calculateMetrics(timing);
|
|
154
|
+
// Debug hook: onError
|
|
155
|
+
if (this.debug?.onError && url) {
|
|
156
|
+
this.debug.onError(url, { code: 'NETWORK_ERROR', message: errorMessage }, metrics);
|
|
157
|
+
}
|
|
158
|
+
request.resolve(err(RequestErrors.NetworkError({ message: errorMessage })));
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
if (type === MSG.ERROR) {
|
|
162
|
+
const request = this.pendingRequests.get(id);
|
|
163
|
+
if (!request)
|
|
164
|
+
return;
|
|
165
|
+
this.pendingRequests.delete(id);
|
|
166
|
+
this.onRequestComplete();
|
|
167
|
+
request.resolve(err(payload.errors, payload.meta));
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
if (type === MSG.SETUP_ERROR) {
|
|
171
|
+
// Setup failed - reject setup promise
|
|
172
|
+
if (this.setupReject) {
|
|
173
|
+
this.setupReject(new Error(`Worker setup failed: ${payload?.error || 'Unknown error'}`));
|
|
174
|
+
this.setupResolve = undefined;
|
|
175
|
+
this.setupReject = undefined;
|
|
176
|
+
}
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
if (type === MSG.READY) {
|
|
180
|
+
this.isReady = true;
|
|
181
|
+
// Debug hook: onWorkerReady
|
|
182
|
+
this.debug?.onWorkerReady?.();
|
|
183
|
+
// Notify ready listeners
|
|
184
|
+
for (const listener of this.readyListeners) {
|
|
185
|
+
listener();
|
|
186
|
+
}
|
|
187
|
+
if (this.setupResolve) {
|
|
188
|
+
this.setupResolve();
|
|
189
|
+
this.setupResolve = undefined;
|
|
190
|
+
this.setupReject = undefined;
|
|
191
|
+
}
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
if (type === MSG.PONG) {
|
|
195
|
+
const request = this.pendingRequests.get(id);
|
|
196
|
+
if (request) {
|
|
197
|
+
this.pendingRequests.delete(id);
|
|
198
|
+
this.onRequestComplete();
|
|
199
|
+
request.resolve(ok({ timestamp: payload?.timestamp }));
|
|
200
|
+
}
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
if (type === MSG.AUTH_STATE_CHANGED) {
|
|
204
|
+
for (const cb of this.authListeners)
|
|
205
|
+
cb(payload);
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
if (type === MSG.AUTH_CALL_RESULT) {
|
|
209
|
+
const request = this.pendingRequests.get(id);
|
|
210
|
+
if (request) {
|
|
211
|
+
this.pendingRequests.delete(id);
|
|
212
|
+
this.onRequestComplete();
|
|
213
|
+
request.resolve(ok(payload)); // payload is AuthResult
|
|
214
|
+
}
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
if (type === MSG.TOKEN_REFRESHED) {
|
|
218
|
+
// Debug hook: onRefresh
|
|
219
|
+
this.debug?.onRefresh?.(payload?.reason);
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
/**
|
|
224
|
+
* Handle worker errors
|
|
225
|
+
*/
|
|
226
|
+
handleWorkerError(error) {
|
|
227
|
+
console.error('Worker error:', error);
|
|
228
|
+
// Debug hook: onWorkerError
|
|
229
|
+
this.debug?.onWorkerError?.(error);
|
|
230
|
+
// Reject all pending requests
|
|
231
|
+
for (const [_id, request] of this.pendingRequests) {
|
|
232
|
+
request.reject(new Error(`Worker error: ${error.message}`));
|
|
233
|
+
}
|
|
234
|
+
this.pendingRequests.clear();
|
|
235
|
+
this.requestUrls.clear();
|
|
236
|
+
this.requestTimings.clear();
|
|
237
|
+
}
|
|
238
|
+
/**
|
|
239
|
+
* Generate unique message ID
|
|
240
|
+
*/
|
|
241
|
+
generateMessageId() {
|
|
242
|
+
return `msg_${++this.messageId}_${Date.now()}`;
|
|
243
|
+
}
|
|
244
|
+
/**
|
|
245
|
+
* Make API request with optional deduplication, retry, and AbortSignal support
|
|
246
|
+
*
|
|
247
|
+
* @param url - Full URL to fetch
|
|
248
|
+
* @param options - Request options including optional AbortSignal
|
|
249
|
+
* @returns Result with FetchEnvelope on success, error on failure
|
|
250
|
+
*
|
|
251
|
+
* @example
|
|
252
|
+
* // With AbortSignal
|
|
253
|
+
* const controller = new AbortController()
|
|
254
|
+
* setTimeout(() => controller.abort(), 5000)
|
|
255
|
+
* const result = await api.fetch('/slow', { signal: controller.signal })
|
|
256
|
+
*/
|
|
257
|
+
async fetch(url, options = {}) {
|
|
258
|
+
// Extract signal from options (not passed to worker - handled client-side)
|
|
259
|
+
const { signal, ...restOptions } = options;
|
|
260
|
+
// Check if already aborted
|
|
261
|
+
if (signal?.aborted) {
|
|
262
|
+
return err(RequestErrors.Cancelled());
|
|
263
|
+
}
|
|
264
|
+
// Check for deduplication
|
|
265
|
+
const dedupeKey = this.getDedupeKey(url, restOptions);
|
|
266
|
+
if (dedupeKey) {
|
|
267
|
+
// Check for in-flight request
|
|
268
|
+
const inFlight = this.inFlightRequests.get(dedupeKey);
|
|
269
|
+
if (inFlight) {
|
|
270
|
+
// If we have a signal, wrap the in-flight promise to support cancellation
|
|
271
|
+
if (signal) {
|
|
272
|
+
return this.wrapWithAbortSignal(inFlight, signal, null);
|
|
273
|
+
}
|
|
274
|
+
return inFlight;
|
|
275
|
+
}
|
|
276
|
+
// Check for recent result within time window
|
|
277
|
+
const window = this.dedupe?.window ?? 0;
|
|
278
|
+
if (window > 0) {
|
|
279
|
+
const recent = this.recentResults.get(dedupeKey);
|
|
280
|
+
if (recent && Date.now() - recent.timestamp < window) {
|
|
281
|
+
return recent.result;
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
// Create deduped request
|
|
285
|
+
const promise = this.fetchWithRetryAndSignal(url, restOptions, signal ?? undefined);
|
|
286
|
+
this.inFlightRequests.set(dedupeKey, promise);
|
|
287
|
+
try {
|
|
288
|
+
const result = await promise;
|
|
289
|
+
// Store result for time-window deduplication
|
|
290
|
+
if (window > 0) {
|
|
291
|
+
this.recentResults.set(dedupeKey, { result, timestamp: Date.now() });
|
|
292
|
+
// Clean up old results after window expires
|
|
293
|
+
setTimeout(() => this.recentResults.delete(dedupeKey), window);
|
|
294
|
+
}
|
|
295
|
+
return result;
|
|
296
|
+
}
|
|
297
|
+
finally {
|
|
298
|
+
this.inFlightRequests.delete(dedupeKey);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
// No deduplication - just fetch with retry and signal
|
|
302
|
+
return this.fetchWithRetryAndSignal(url, restOptions, signal ?? undefined);
|
|
303
|
+
}
|
|
304
|
+
/**
|
|
305
|
+
* Wrap a promise with AbortSignal support
|
|
306
|
+
*/
|
|
307
|
+
wrapWithAbortSignal(promise, signal, requestId) {
|
|
308
|
+
return new Promise((resolve) => {
|
|
309
|
+
// Handle abort
|
|
310
|
+
const abortHandler = () => {
|
|
311
|
+
if (requestId) {
|
|
312
|
+
this.cancel(requestId);
|
|
313
|
+
}
|
|
314
|
+
resolve(err(RequestErrors.Cancelled()));
|
|
315
|
+
};
|
|
316
|
+
if (signal.aborted) {
|
|
317
|
+
abortHandler();
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
signal.addEventListener('abort', abortHandler, { once: true });
|
|
321
|
+
promise.then((result) => {
|
|
322
|
+
signal.removeEventListener('abort', abortHandler);
|
|
323
|
+
resolve(result);
|
|
324
|
+
});
|
|
325
|
+
});
|
|
326
|
+
}
|
|
327
|
+
/**
|
|
328
|
+
* Fetch with retry logic and AbortSignal support (internal)
|
|
329
|
+
*/
|
|
330
|
+
async fetchWithRetryAndSignal(url, options, signal) {
|
|
331
|
+
const maxAttempts = this.retry?.maxAttempts ?? 0;
|
|
332
|
+
const delay = this.retry?.delay ?? 1000;
|
|
333
|
+
const backoff = this.retry?.backoff ?? 1;
|
|
334
|
+
const maxDelay = this.retry?.maxDelay ?? 30000;
|
|
335
|
+
const jitter = this.retry?.jitter ?? 0;
|
|
336
|
+
const shouldRetry = this.retry?.shouldRetry ?? this.defaultShouldRetry;
|
|
337
|
+
let lastResult = null;
|
|
338
|
+
let currentDelay = delay;
|
|
339
|
+
// Initial attempt + retries
|
|
340
|
+
for (let attempt = 0; attempt <= maxAttempts; attempt++) {
|
|
341
|
+
// Check if aborted before each attempt
|
|
342
|
+
if (signal?.aborted) {
|
|
343
|
+
return err(RequestErrors.Cancelled());
|
|
344
|
+
}
|
|
345
|
+
const { id, result } = this.fetchWithId(url, options);
|
|
346
|
+
// If we have a signal, wrap result with abort support
|
|
347
|
+
if (signal) {
|
|
348
|
+
lastResult = await this.wrapWithAbortSignal(result, signal, id);
|
|
349
|
+
}
|
|
350
|
+
else {
|
|
351
|
+
lastResult = await result;
|
|
352
|
+
}
|
|
353
|
+
// Success or HTTP error (4xx/5xx) - don't retry
|
|
354
|
+
if (lastResult.ok) {
|
|
355
|
+
return lastResult;
|
|
356
|
+
}
|
|
357
|
+
// Check if cancelled
|
|
358
|
+
if (lastResult.errors[0]?.code === 'REQUEST_CANCELLED') {
|
|
359
|
+
return lastResult;
|
|
360
|
+
}
|
|
361
|
+
// Check if we should retry this error
|
|
362
|
+
const error = lastResult.errors[0];
|
|
363
|
+
const errorDetail = {
|
|
364
|
+
code: error?.code ?? 'NETWORK_ERROR',
|
|
365
|
+
message: error?.message ?? 'Unknown error',
|
|
366
|
+
};
|
|
367
|
+
// Don't retry if:
|
|
368
|
+
// - This was the last attempt
|
|
369
|
+
// - Error is not retryable (e.g., cancelled)
|
|
370
|
+
if (attempt >= maxAttempts || !shouldRetry(errorDetail)) {
|
|
371
|
+
return lastResult;
|
|
372
|
+
}
|
|
373
|
+
// Wait before retry (with exponential backoff and optional jitter)
|
|
374
|
+
// Jitter only applies when shouldRetry=true (we're actually retrying)
|
|
375
|
+
const cappedDelay = Math.min(currentDelay, maxDelay);
|
|
376
|
+
const jitteredDelay = this.applyJitter(cappedDelay, jitter);
|
|
377
|
+
// Check abort during delay
|
|
378
|
+
if (signal) {
|
|
379
|
+
const aborted = await this.sleepWithAbort(jitteredDelay, signal);
|
|
380
|
+
if (aborted) {
|
|
381
|
+
return err(RequestErrors.Cancelled());
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
else {
|
|
385
|
+
await this.sleep(jitteredDelay);
|
|
386
|
+
}
|
|
387
|
+
currentDelay = currentDelay * backoff;
|
|
388
|
+
}
|
|
389
|
+
return lastResult;
|
|
390
|
+
}
|
|
391
|
+
/**
|
|
392
|
+
* Generate deduplication key for request
|
|
393
|
+
* Returns null if request should not be deduplicated
|
|
394
|
+
*/
|
|
395
|
+
getDedupeKey(url, options) {
|
|
396
|
+
if (!this.dedupe?.enabled) {
|
|
397
|
+
return null;
|
|
398
|
+
}
|
|
399
|
+
// Use custom key generator if provided
|
|
400
|
+
if (this.dedupe.keyGenerator) {
|
|
401
|
+
return this.dedupe.keyGenerator(url, options);
|
|
402
|
+
}
|
|
403
|
+
// Default: only dedupe GET requests by URL
|
|
404
|
+
const method = (options.method ?? 'GET').toUpperCase();
|
|
405
|
+
if (method !== 'GET') {
|
|
406
|
+
return null;
|
|
407
|
+
}
|
|
408
|
+
return `GET:${url}`;
|
|
409
|
+
}
|
|
410
|
+
/**
|
|
411
|
+
* Apply jitter to a delay value
|
|
412
|
+
* Jitter adds ±(jitter * delay) randomness to prevent thundering herd
|
|
413
|
+
* @param delay - Base delay in milliseconds
|
|
414
|
+
* @param jitter - Jitter factor (0-1)
|
|
415
|
+
* @returns Jittered delay
|
|
416
|
+
*/
|
|
417
|
+
applyJitter(delay, jitter) {
|
|
418
|
+
if (jitter <= 0)
|
|
419
|
+
return delay;
|
|
420
|
+
// Clamp jitter to valid range [0, 1]
|
|
421
|
+
const clampedJitter = Math.min(Math.max(jitter, 0), 1);
|
|
422
|
+
// Random value between -1 and 1
|
|
423
|
+
const randomFactor = Math.random() * 2 - 1;
|
|
424
|
+
// Apply jitter: delay ± (delay * jitter * random)
|
|
425
|
+
return Math.max(0, delay + delay * clampedJitter * randomFactor);
|
|
426
|
+
}
|
|
427
|
+
/**
|
|
428
|
+
* Default retry condition - only retry on NETWORK_ERROR
|
|
429
|
+
*/
|
|
430
|
+
defaultShouldRetry(error) {
|
|
431
|
+
// Don't retry cancelled requests or parse errors
|
|
432
|
+
return error.code === 'NETWORK_ERROR';
|
|
433
|
+
}
|
|
434
|
+
/**
|
|
435
|
+
* Calculate request metrics from timing data
|
|
436
|
+
*/
|
|
437
|
+
calculateMetrics(timing) {
|
|
438
|
+
if (!timing)
|
|
439
|
+
return undefined;
|
|
440
|
+
const endTime = Date.now();
|
|
441
|
+
const startTime = timing.createdAt;
|
|
442
|
+
const sentAt = timing.sentAt ?? startTime;
|
|
443
|
+
const duration = endTime - startTime;
|
|
444
|
+
const queueTime = sentAt - startTime;
|
|
445
|
+
const ipcTime = duration - queueTime; // Approximate: total - queue = IPC + server
|
|
446
|
+
return {
|
|
447
|
+
startTime,
|
|
448
|
+
endTime,
|
|
449
|
+
duration,
|
|
450
|
+
queueTime,
|
|
451
|
+
ipcTime,
|
|
452
|
+
};
|
|
453
|
+
}
|
|
454
|
+
/**
|
|
455
|
+
* Sleep helper for retry delay
|
|
456
|
+
*/
|
|
457
|
+
sleep(ms) {
|
|
458
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
459
|
+
}
|
|
460
|
+
/**
|
|
461
|
+
* Sleep with abort signal support
|
|
462
|
+
* Returns true if aborted, false if completed normally
|
|
463
|
+
*/
|
|
464
|
+
sleepWithAbort(ms, signal) {
|
|
465
|
+
return new Promise((resolve) => {
|
|
466
|
+
if (signal.aborted) {
|
|
467
|
+
resolve(true);
|
|
468
|
+
return;
|
|
469
|
+
}
|
|
470
|
+
const timer = setTimeout(() => {
|
|
471
|
+
signal.removeEventListener('abort', abortHandler);
|
|
472
|
+
resolve(false);
|
|
473
|
+
}, ms);
|
|
474
|
+
const abortHandler = () => {
|
|
475
|
+
clearTimeout(timer);
|
|
476
|
+
resolve(true);
|
|
477
|
+
};
|
|
478
|
+
signal.addEventListener('abort', abortHandler, { once: true });
|
|
479
|
+
});
|
|
480
|
+
}
|
|
481
|
+
/**
|
|
482
|
+
* Fetch with id for external cancellation
|
|
483
|
+
* Returns { id, result, cancel }
|
|
484
|
+
* Now uses queue system for sequential processing
|
|
485
|
+
*/
|
|
486
|
+
fetchWithId(url, options = {}) {
|
|
487
|
+
const id = this.generateMessageId();
|
|
488
|
+
// The Promise is resolved/rejected via pendingRequests (worker callback).
|
|
489
|
+
// The executor stays synchronous (no async); the serialize-and-send work
|
|
490
|
+
// runs separately and routes failures through the same reject path.
|
|
491
|
+
const result = new Promise((resolve, reject) => {
|
|
492
|
+
this.pendingRequests.set(id, {
|
|
493
|
+
resolve: (response) => resolve(response),
|
|
494
|
+
reject: (error) => reject(error),
|
|
495
|
+
});
|
|
496
|
+
// Track URL for debug hooks
|
|
497
|
+
this.requestUrls.set(id, url);
|
|
498
|
+
// Track timing for metrics
|
|
499
|
+
this.requestTimings.set(id, { createdAt: Date.now() });
|
|
500
|
+
// Debug hook: onRequest
|
|
501
|
+
this.debug?.onRequest?.(url, options);
|
|
502
|
+
});
|
|
503
|
+
// Serialize (FormData/Headers) then send — async, but kept out of the
|
|
504
|
+
// Promise executor (noAsyncPromiseExecutor). Failures reject via the
|
|
505
|
+
// pending request registered above.
|
|
506
|
+
void (async () => {
|
|
507
|
+
try {
|
|
508
|
+
const serializedOptions = { ...options };
|
|
509
|
+
let transferables;
|
|
510
|
+
// Serialize FormData body before sending to worker
|
|
511
|
+
if (options.body && isFormData(options.body)) {
|
|
512
|
+
const { data, transferables: formDataTransferables } = await serializeFormData(options.body);
|
|
513
|
+
// SerializedFormData will be deserialized back to FormData in worker
|
|
514
|
+
serializedOptions.body = data;
|
|
515
|
+
// ArrayBuffers for zero-copy transfer
|
|
516
|
+
if (formDataTransferables.length > 0) {
|
|
517
|
+
transferables = formDataTransferables;
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
// Serialize Headers object to plain object (Headers cannot be cloned)
|
|
521
|
+
if (options.headers instanceof Headers) {
|
|
522
|
+
const plainHeaders = {};
|
|
523
|
+
options.headers.forEach((value, key) => {
|
|
524
|
+
plainHeaders[key] = value;
|
|
525
|
+
});
|
|
526
|
+
serializedOptions.headers = plainHeaders;
|
|
527
|
+
}
|
|
528
|
+
const message = { id, type: MSG.FETCH, payload: { url, options: serializedOptions } };
|
|
529
|
+
await this.sendMessageQueued(message, 30000, transferables);
|
|
530
|
+
}
|
|
531
|
+
catch (error) {
|
|
532
|
+
const request = this.pendingRequests.get(id);
|
|
533
|
+
if (request) {
|
|
534
|
+
this.pendingRequests.delete(id);
|
|
535
|
+
this.requestUrls.delete(id);
|
|
536
|
+
this.requestTimings.delete(id);
|
|
537
|
+
request.reject(error instanceof Error ? error : new Error(String(error)));
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
})();
|
|
541
|
+
const cancel = () => this.cancel(id);
|
|
542
|
+
return { id, result, cancel };
|
|
543
|
+
}
|
|
544
|
+
/**
|
|
545
|
+
* Cancel a pending request by ID
|
|
546
|
+
*/
|
|
547
|
+
cancel(id) {
|
|
548
|
+
const request = this.pendingRequests.get(id);
|
|
549
|
+
if (request) {
|
|
550
|
+
const url = this.requestUrls.get(id);
|
|
551
|
+
const timing = this.requestTimings.get(id);
|
|
552
|
+
this.pendingRequests.delete(id);
|
|
553
|
+
this.requestUrls.delete(id);
|
|
554
|
+
this.requestTimings.delete(id);
|
|
555
|
+
this.worker.postMessage({ id, type: MSG.CANCEL });
|
|
556
|
+
// Calculate metrics
|
|
557
|
+
const metrics = this.calculateMetrics(timing);
|
|
558
|
+
// Debug hook: onError for cancelled request
|
|
559
|
+
if (this.debug?.onError && url) {
|
|
560
|
+
this.debug.onError(url, { code: 'REQUEST_CANCELLED', message: 'Request cancelled' }, metrics);
|
|
561
|
+
}
|
|
562
|
+
request.reject(new Error('Request cancelled'));
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
/**
|
|
566
|
+
* Convenience methods
|
|
567
|
+
*/
|
|
568
|
+
async get(url, options = {}) {
|
|
569
|
+
return this.fetch(url, { ...options, method: 'GET' });
|
|
570
|
+
}
|
|
571
|
+
async post(url, body, options = {}) {
|
|
572
|
+
// If body is FormData, use fetch directly (no JSON.stringify)
|
|
573
|
+
if (body && isFormData(body)) {
|
|
574
|
+
return this.fetch(url, {
|
|
575
|
+
...options,
|
|
576
|
+
method: 'POST',
|
|
577
|
+
body,
|
|
578
|
+
});
|
|
579
|
+
}
|
|
580
|
+
// For non-FormData body, use JSON
|
|
581
|
+
const headers = new Headers(options.headers);
|
|
582
|
+
// Set Content-Type if not already set and body is being stringified
|
|
583
|
+
if (body && !headers.has('Content-Type')) {
|
|
584
|
+
headers.set('Content-Type', 'application/json');
|
|
585
|
+
}
|
|
586
|
+
return this.fetch(url, {
|
|
587
|
+
...options,
|
|
588
|
+
headers,
|
|
589
|
+
method: 'POST',
|
|
590
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
591
|
+
});
|
|
592
|
+
}
|
|
593
|
+
async put(url, body, options = {}) {
|
|
594
|
+
// If body is FormData, use fetch directly (no JSON.stringify)
|
|
595
|
+
if (body && isFormData(body)) {
|
|
596
|
+
return this.fetch(url, {
|
|
597
|
+
...options,
|
|
598
|
+
method: 'PUT',
|
|
599
|
+
body,
|
|
600
|
+
});
|
|
601
|
+
}
|
|
602
|
+
// For non-FormData body, use JSON
|
|
603
|
+
const headers = new Headers(options.headers);
|
|
604
|
+
// Set Content-Type if not already set and body is being stringified
|
|
605
|
+
if (body && !headers.has('Content-Type')) {
|
|
606
|
+
headers.set('Content-Type', 'application/json');
|
|
607
|
+
}
|
|
608
|
+
return this.fetch(url, {
|
|
609
|
+
...options,
|
|
610
|
+
headers,
|
|
611
|
+
method: 'PUT',
|
|
612
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
613
|
+
});
|
|
614
|
+
}
|
|
615
|
+
async delete(url, options = {}) {
|
|
616
|
+
return this.fetch(url, { ...options, method: 'DELETE' });
|
|
617
|
+
}
|
|
618
|
+
async patch(url, body, options = {}) {
|
|
619
|
+
// If body is FormData, use fetch directly (no JSON.stringify)
|
|
620
|
+
if (body && isFormData(body)) {
|
|
621
|
+
return this.fetch(url, {
|
|
622
|
+
...options,
|
|
623
|
+
method: 'PATCH',
|
|
624
|
+
body,
|
|
625
|
+
});
|
|
626
|
+
}
|
|
627
|
+
// For non-FormData body, use JSON
|
|
628
|
+
const headers = new Headers(options.headers);
|
|
629
|
+
// Set Content-Type if not already set and body is being stringified
|
|
630
|
+
if (body && !headers.has('Content-Type')) {
|
|
631
|
+
headers.set('Content-Type', 'application/json');
|
|
632
|
+
}
|
|
633
|
+
return this.fetch(url, {
|
|
634
|
+
...options,
|
|
635
|
+
headers,
|
|
636
|
+
method: 'PATCH',
|
|
637
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
638
|
+
});
|
|
639
|
+
}
|
|
640
|
+
/**
|
|
641
|
+
* Typed data-API call.
|
|
642
|
+
*
|
|
643
|
+
* Issues the HTTP request through the Worker (tokens stay isolated) and
|
|
644
|
+
* deserializes the backend's ts-micro-result envelope into a domain
|
|
645
|
+
* `Result<T>` — so callers no longer hand-write "judge status + parse JSON +
|
|
646
|
+
* map errors". This is the entry point the contract-driven typed client uses
|
|
647
|
+
* for `kind: 'data'` endpoints.
|
|
648
|
+
*
|
|
649
|
+
* Auth-lifecycle endpoints (login/refresh/exchange/logout) must NOT use this —
|
|
650
|
+
* they go through login()/exchangeToken()/logout() because they touch the
|
|
651
|
+
* Worker's refresh-token storage.
|
|
652
|
+
*/
|
|
653
|
+
async callJson(method, url, body, options = {}) {
|
|
654
|
+
let envelope;
|
|
655
|
+
switch (method) {
|
|
656
|
+
case 'GET':
|
|
657
|
+
envelope = await this.get(url, options);
|
|
658
|
+
break;
|
|
659
|
+
case 'DELETE':
|
|
660
|
+
if (body !== undefined && !isFormData(body)) {
|
|
661
|
+
const headers = new Headers(options.headers);
|
|
662
|
+
if (!headers.has('Content-Type'))
|
|
663
|
+
headers.set('Content-Type', 'application/json');
|
|
664
|
+
envelope = await this.fetch(url, { ...options, headers, method: 'DELETE', body: JSON.stringify(body) });
|
|
665
|
+
}
|
|
666
|
+
else {
|
|
667
|
+
envelope = await this.delete(url, options);
|
|
668
|
+
}
|
|
669
|
+
break;
|
|
670
|
+
case 'POST':
|
|
671
|
+
envelope = await this.post(url, body, options);
|
|
672
|
+
break;
|
|
673
|
+
case 'PUT':
|
|
674
|
+
envelope = await this.put(url, body, options);
|
|
675
|
+
break;
|
|
676
|
+
case 'PATCH':
|
|
677
|
+
envelope = await this.patch(url, body, options);
|
|
678
|
+
break;
|
|
679
|
+
}
|
|
680
|
+
return toResult(envelope);
|
|
681
|
+
}
|
|
682
|
+
/**
|
|
683
|
+
* Generic method to call any auth method on provider
|
|
684
|
+
* @param method - Method name (login, logout, loginWithPhone, etc.)
|
|
685
|
+
* @param emitEvent - Whether to emit AUTH_STATE_CHANGED event (default: true)
|
|
686
|
+
* @param args - Arguments to pass to the method
|
|
687
|
+
* @returns Promise<Result<AuthResult>> - Always returns AuthResult
|
|
688
|
+
*/
|
|
689
|
+
async call(method, emitEvent, ...args) {
|
|
690
|
+
const id = this.generateMessageId();
|
|
691
|
+
const message = { id, type: MSG.AUTH_CALL, payload: { method, args, emitEvent } };
|
|
692
|
+
return new Promise((resolve, reject) => {
|
|
693
|
+
this.pendingRequests.set(id, {
|
|
694
|
+
resolve: (r) => resolve(r),
|
|
695
|
+
reject: (e) => reject(e),
|
|
696
|
+
});
|
|
697
|
+
this.sendMessageQueued(message, 15000).catch((error) => {
|
|
698
|
+
const request = this.pendingRequests.get(id);
|
|
699
|
+
if (request) {
|
|
700
|
+
this.pendingRequests.delete(id);
|
|
701
|
+
request.reject(error);
|
|
702
|
+
}
|
|
703
|
+
});
|
|
704
|
+
});
|
|
705
|
+
}
|
|
706
|
+
/**
|
|
707
|
+
* Convenience wrapper for login
|
|
708
|
+
* @param payload - Login credentials
|
|
709
|
+
* @param url - Optional URL override
|
|
710
|
+
* @param emitEvent - Whether to emit AUTH_STATE_CHANGED event (default: true)
|
|
711
|
+
*/
|
|
712
|
+
async login(payload, url, emitEvent = true) {
|
|
713
|
+
const args = [];
|
|
714
|
+
if (typeof payload !== 'undefined') {
|
|
715
|
+
args.push(payload);
|
|
716
|
+
}
|
|
717
|
+
if (typeof url !== 'undefined') {
|
|
718
|
+
// If payload is undefined but url is provided, need to pass undefined explicitly
|
|
719
|
+
if (args.length === 0) {
|
|
720
|
+
args.push(undefined);
|
|
721
|
+
}
|
|
722
|
+
args.push(url);
|
|
723
|
+
}
|
|
724
|
+
return this.call('login', emitEvent, ...args);
|
|
725
|
+
}
|
|
726
|
+
/**
|
|
727
|
+
* Convenience wrapper for logout
|
|
728
|
+
* @param payload - Optional logout payload
|
|
729
|
+
* @param emitEvent - Whether to emit AUTH_STATE_CHANGED event (default: true)
|
|
730
|
+
*/
|
|
731
|
+
async logout(payload, emitEvent = true) {
|
|
732
|
+
const args = typeof payload === 'undefined' ? [] : [payload];
|
|
733
|
+
return this.call('logout', emitEvent, ...args);
|
|
734
|
+
}
|
|
735
|
+
/**
|
|
736
|
+
* Convenience wrapper for refreshToken
|
|
737
|
+
* @param emitEvent - Whether to emit AUTH_STATE_CHANGED event (default: true)
|
|
738
|
+
*/
|
|
739
|
+
async refreshToken(emitEvent = true) {
|
|
740
|
+
return this.call('refreshToken', emitEvent);
|
|
741
|
+
}
|
|
742
|
+
/**
|
|
743
|
+
* Exchange current token for a new one with different context
|
|
744
|
+
*
|
|
745
|
+
* Useful for:
|
|
746
|
+
* - Switching tenants in multi-tenant apps
|
|
747
|
+
* - Changing authorization scope
|
|
748
|
+
* - Impersonating users (admin feature)
|
|
749
|
+
*
|
|
750
|
+
* @param url - URL to call for token exchange
|
|
751
|
+
* @param options - Exchange options (method, payload, headers)
|
|
752
|
+
* @param emitEvent - Whether to emit AUTH_STATE_CHANGED event (default: true)
|
|
753
|
+
*
|
|
754
|
+
* @example
|
|
755
|
+
* // Switch tenant
|
|
756
|
+
* await api.exchangeToken('https://auth.example.com/auth/select-tenant', {
|
|
757
|
+
* payload: { tenantId: 'tenant_123' }
|
|
758
|
+
* })
|
|
759
|
+
*
|
|
760
|
+
* // Change scope with PUT method
|
|
761
|
+
* await api.exchangeToken('https://auth.example.com/auth/switch-context', {
|
|
762
|
+
* method: 'PUT',
|
|
763
|
+
* payload: { scope: 'admin' }
|
|
764
|
+
* })
|
|
765
|
+
*
|
|
766
|
+
* // With custom headers (overrides defaultHeaders)
|
|
767
|
+
* await api.exchangeToken('https://auth.example.com/auth/impersonate', {
|
|
768
|
+
* payload: { userId: 'user_456' },
|
|
769
|
+
* headers: { 'X-Impersonate-Reason': 'support-ticket-123' }
|
|
770
|
+
* })
|
|
771
|
+
*/
|
|
772
|
+
async exchangeToken(url, options, emitEvent = true) {
|
|
773
|
+
const args = [url];
|
|
774
|
+
if (options) {
|
|
775
|
+
args.push(options);
|
|
776
|
+
}
|
|
777
|
+
return this.call('exchangeToken', emitEvent, ...args);
|
|
778
|
+
}
|
|
779
|
+
/**
|
|
780
|
+
* Check if worker is ready
|
|
781
|
+
*/
|
|
782
|
+
ready() {
|
|
783
|
+
return this.isReady;
|
|
784
|
+
}
|
|
785
|
+
/**
|
|
786
|
+
* Wait for worker to be ready
|
|
787
|
+
* Returns immediately if already ready
|
|
788
|
+
*/
|
|
789
|
+
async whenReady() {
|
|
790
|
+
if (this.isReady)
|
|
791
|
+
return Promise.resolve();
|
|
792
|
+
return new Promise((resolve) => {
|
|
793
|
+
this.readyListeners.add(resolve);
|
|
794
|
+
});
|
|
795
|
+
}
|
|
796
|
+
/**
|
|
797
|
+
* Subscribe to ready event
|
|
798
|
+
* Callback is called immediately if already ready
|
|
799
|
+
*/
|
|
800
|
+
onReady(callback) {
|
|
801
|
+
if (this.isReady) {
|
|
802
|
+
// Already ready - call immediately
|
|
803
|
+
callback();
|
|
804
|
+
}
|
|
805
|
+
this.readyListeners.add(callback);
|
|
806
|
+
// Return unsubscribe function
|
|
807
|
+
return () => {
|
|
808
|
+
this.readyListeners.delete(callback);
|
|
809
|
+
};
|
|
810
|
+
}
|
|
811
|
+
/**
|
|
812
|
+
* Subscribe to auth state changes
|
|
813
|
+
*/
|
|
814
|
+
onAuthStateChanged(cb) {
|
|
815
|
+
this.authListeners.add(cb);
|
|
816
|
+
return () => this.authListeners.delete(cb);
|
|
817
|
+
}
|
|
818
|
+
/** Send PING and await PONG */
|
|
819
|
+
async ping() {
|
|
820
|
+
const id = this.generateMessageId();
|
|
821
|
+
const message = { id, type: MSG.PING, payload: { timestamp: Date.now() } };
|
|
822
|
+
return new Promise((resolve, reject) => {
|
|
823
|
+
this.pendingRequests.set(id, {
|
|
824
|
+
resolve: (r) => resolve(r),
|
|
825
|
+
reject: (e) => reject(e),
|
|
826
|
+
});
|
|
827
|
+
this.sendMessageQueued(message, 5000).catch((error) => {
|
|
828
|
+
const request = this.pendingRequests.get(id);
|
|
829
|
+
if (request) {
|
|
830
|
+
this.pendingRequests.delete(id);
|
|
831
|
+
request.reject(error);
|
|
832
|
+
}
|
|
833
|
+
});
|
|
834
|
+
});
|
|
835
|
+
}
|
|
836
|
+
/**
|
|
837
|
+
* Send message through queue system
|
|
838
|
+
* All messages go through queue for sequential processing
|
|
839
|
+
* @param transferables - Optional Transferable objects for zero-copy postMessage
|
|
840
|
+
*/
|
|
841
|
+
sendMessageQueued(message, timeoutMs = this.requestTimeout, transferables) {
|
|
842
|
+
return new Promise((resolve, reject) => {
|
|
843
|
+
// Check queue size limit to prevent memory leak
|
|
844
|
+
if (this.requestQueue.length >= this.maxQueueSize) {
|
|
845
|
+
reject(err(RequestErrors.QueueFull({ size: this.requestQueue.length, maxSize: this.maxQueueSize })));
|
|
846
|
+
return;
|
|
847
|
+
}
|
|
848
|
+
const timeout = setTimeout(() => {
|
|
849
|
+
const index = this.requestQueue.findIndex((item) => item.id === message.id);
|
|
850
|
+
if (index !== -1) {
|
|
851
|
+
this.requestQueue.splice(index, 1);
|
|
852
|
+
}
|
|
853
|
+
this.pendingRequests.delete(message.id);
|
|
854
|
+
this.requestUrls.delete(message.id);
|
|
855
|
+
this.requestTimings.delete(message.id);
|
|
856
|
+
reject(err(RequestErrors.Timeout()));
|
|
857
|
+
}, timeoutMs);
|
|
858
|
+
const queueItem = {
|
|
859
|
+
id: message.id,
|
|
860
|
+
message,
|
|
861
|
+
transferables,
|
|
862
|
+
resolve: resolve,
|
|
863
|
+
reject,
|
|
864
|
+
timeout,
|
|
865
|
+
};
|
|
866
|
+
this.requestQueue.push(queueItem);
|
|
867
|
+
this.processQueue();
|
|
868
|
+
});
|
|
869
|
+
}
|
|
870
|
+
/**
|
|
871
|
+
* Process message queue with concurrency limit
|
|
872
|
+
*
|
|
873
|
+
* Uses semaphore pattern to allow N concurrent requests.
|
|
874
|
+
* Benefits:
|
|
875
|
+
* - Higher throughput than sequential processing
|
|
876
|
+
* - Backpressure via maxConcurrent limit
|
|
877
|
+
* - Better error isolation (one failure doesn't affect others)
|
|
878
|
+
*/
|
|
879
|
+
processQueue() {
|
|
880
|
+
// Process as many items as we can within concurrency limit
|
|
881
|
+
while (this.requestQueue.length > 0 && this.activeRequests < this.maxConcurrent) {
|
|
882
|
+
const item = this.requestQueue.shift();
|
|
883
|
+
if (!item)
|
|
884
|
+
continue;
|
|
885
|
+
this.activeRequests++;
|
|
886
|
+
// Update timing: mark when request is actually sent to worker
|
|
887
|
+
const timing = this.requestTimings.get(item.id);
|
|
888
|
+
if (timing) {
|
|
889
|
+
timing.sentAt = Date.now();
|
|
890
|
+
}
|
|
891
|
+
try {
|
|
892
|
+
// Use transferables for zero-copy transfer when available (e.g., FormData with files)
|
|
893
|
+
if (item.transferables && item.transferables.length > 0) {
|
|
894
|
+
this.worker.postMessage(item.message, item.transferables);
|
|
895
|
+
}
|
|
896
|
+
else {
|
|
897
|
+
this.worker.postMessage(item.message);
|
|
898
|
+
}
|
|
899
|
+
// Note: activeRequests is decremented when response is received
|
|
900
|
+
// in handleWorkerMessage, not here
|
|
901
|
+
}
|
|
902
|
+
catch (error) {
|
|
903
|
+
this.activeRequests--;
|
|
904
|
+
clearTimeout(item.timeout);
|
|
905
|
+
item.reject(error instanceof Error ? error : new Error(String(error)));
|
|
906
|
+
// Continue processing queue after error
|
|
907
|
+
this.processQueue();
|
|
908
|
+
}
|
|
909
|
+
}
|
|
910
|
+
}
|
|
911
|
+
/**
|
|
912
|
+
* Called when a request completes (success or error)
|
|
913
|
+
* Decrements active count and processes next items in queue
|
|
914
|
+
*/
|
|
915
|
+
onRequestComplete() {
|
|
916
|
+
this.activeRequests--;
|
|
917
|
+
this.processQueue();
|
|
918
|
+
}
|
|
919
|
+
/**
|
|
920
|
+
* Cleanup - terminate worker
|
|
921
|
+
*/
|
|
922
|
+
destroy() {
|
|
923
|
+
this.worker.terminate();
|
|
924
|
+
this.pendingRequests.clear();
|
|
925
|
+
this.requestUrls.clear();
|
|
926
|
+
this.requestTimings.clear();
|
|
927
|
+
for (const item of this.requestQueue) {
|
|
928
|
+
clearTimeout(item.timeout);
|
|
929
|
+
item.reject(new Error('Client destroyed'));
|
|
930
|
+
}
|
|
931
|
+
this.requestQueue = [];
|
|
932
|
+
}
|
|
933
|
+
}
|
|
934
|
+
/**
|
|
935
|
+
* Factory function to create FetchGuard client
|
|
936
|
+
*/
|
|
937
|
+
export function createClient(options) {
|
|
938
|
+
return new FetchGuardClient(options);
|
|
939
|
+
}
|
|
940
|
+
//# sourceMappingURL=client.js.map
|