@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.
Files changed (109) hide show
  1. package/README.md +144 -8
  2. package/dist/core/core.d.ts +72 -0
  3. package/dist/core/core.d.ts.map +1 -0
  4. package/dist/core/core.js +105 -0
  5. package/dist/core/core.js.map +1 -0
  6. package/dist/core/proxy.d.ts +79 -0
  7. package/dist/core/proxy.d.ts.map +1 -0
  8. package/dist/core/proxy.js +99 -0
  9. package/dist/core/proxy.js.map +1 -0
  10. package/dist/index.d.ts +15 -28
  11. package/dist/index.d.ts.map +1 -1
  12. package/dist/index.js +23 -55
  13. package/dist/index.js.map +1 -1
  14. package/dist/transport/client.d.ts +250 -0
  15. package/dist/transport/client.d.ts.map +1 -0
  16. package/dist/transport/client.js +940 -0
  17. package/dist/transport/client.js.map +1 -0
  18. package/dist/transport/constants.d.ts +9 -0
  19. package/dist/transport/constants.d.ts.map +1 -0
  20. package/dist/transport/constants.js +9 -0
  21. package/dist/transport/constants.js.map +1 -0
  22. package/dist/transport/error-codes.d.ts +41 -0
  23. package/dist/transport/error-codes.d.ts.map +1 -0
  24. package/dist/transport/error-codes.js +38 -0
  25. package/dist/transport/error-codes.js.map +1 -0
  26. package/dist/transport/errors.d.ts +55 -0
  27. package/dist/transport/errors.d.ts.map +1 -0
  28. package/dist/transport/errors.js +55 -0
  29. package/dist/transport/errors.js.map +1 -0
  30. package/dist/transport/helpers.d.ts +135 -0
  31. package/dist/transport/helpers.d.ts.map +1 -0
  32. package/dist/transport/helpers.js +185 -0
  33. package/dist/transport/helpers.js.map +1 -0
  34. package/dist/transport/index.d.ts +22 -0
  35. package/dist/transport/index.d.ts.map +1 -0
  36. package/dist/transport/index.js +32 -0
  37. package/dist/transport/index.js.map +1 -0
  38. package/dist/transport/messages.d.ts +104 -0
  39. package/dist/transport/messages.d.ts.map +1 -0
  40. package/dist/transport/messages.js +24 -0
  41. package/dist/transport/messages.js.map +1 -0
  42. package/dist/transport/provider/create-provider.d.ts +38 -0
  43. package/dist/transport/provider/create-provider.d.ts.map +1 -0
  44. package/dist/transport/provider/create-provider.js +127 -0
  45. package/dist/transport/provider/create-provider.js.map +1 -0
  46. package/dist/transport/provider/parser/body.d.ts +7 -0
  47. package/dist/transport/provider/parser/body.d.ts.map +1 -0
  48. package/dist/transport/provider/parser/body.js +17 -0
  49. package/dist/transport/provider/parser/body.js.map +1 -0
  50. package/dist/transport/provider/parser/cookie.d.ts +8 -0
  51. package/dist/transport/provider/parser/cookie.d.ts.map +1 -0
  52. package/dist/transport/provider/parser/cookie.js +17 -0
  53. package/dist/transport/provider/parser/cookie.js.map +1 -0
  54. package/dist/transport/provider/parser/normalize.d.ts +6 -0
  55. package/dist/transport/provider/parser/normalize.d.ts.map +1 -0
  56. package/dist/transport/provider/parser/normalize.js +21 -0
  57. package/dist/transport/provider/parser/normalize.js.map +1 -0
  58. package/dist/transport/provider/presets.d.ts +31 -0
  59. package/dist/transport/provider/presets.d.ts.map +1 -0
  60. package/dist/transport/provider/presets.js +35 -0
  61. package/dist/transport/provider/presets.js.map +1 -0
  62. package/dist/transport/provider/register-presets.d.ts +10 -0
  63. package/dist/transport/provider/register-presets.d.ts.map +1 -0
  64. package/dist/transport/provider/register-presets.js +32 -0
  65. package/dist/transport/provider/register-presets.js.map +1 -0
  66. package/dist/transport/provider/storage/indexeddb.d.ts +26 -0
  67. package/dist/transport/provider/storage/indexeddb.d.ts.map +1 -0
  68. package/dist/transport/provider/storage/indexeddb.js +72 -0
  69. package/dist/transport/provider/storage/indexeddb.js.map +1 -0
  70. package/dist/transport/provider/strategy/body.d.ts +28 -0
  71. package/dist/transport/provider/strategy/body.d.ts.map +1 -0
  72. package/dist/transport/provider/strategy/body.js +73 -0
  73. package/dist/transport/provider/strategy/body.js.map +1 -0
  74. package/dist/transport/provider/strategy/cookie.d.ts +29 -0
  75. package/dist/transport/provider/strategy/cookie.d.ts.map +1 -0
  76. package/dist/transport/provider/strategy/cookie.js +70 -0
  77. package/dist/transport/provider/strategy/cookie.js.map +1 -0
  78. package/dist/transport/result.d.ts +28 -0
  79. package/dist/transport/result.d.ts.map +1 -0
  80. package/dist/transport/result.js +87 -0
  81. package/dist/transport/result.js.map +1 -0
  82. package/dist/transport/types.d.ts +488 -0
  83. package/dist/transport/types.d.ts.map +1 -0
  84. package/dist/transport/types.js +3 -0
  85. package/dist/transport/types.js.map +1 -0
  86. package/dist/transport/utils/binary.d.ts +20 -0
  87. package/dist/transport/utils/binary.d.ts.map +1 -0
  88. package/dist/transport/utils/binary.js +51 -0
  89. package/dist/transport/utils/binary.js.map +1 -0
  90. package/dist/transport/utils/formdata.d.ts +26 -0
  91. package/dist/transport/utils/formdata.d.ts.map +1 -0
  92. package/dist/transport/utils/formdata.js +77 -0
  93. package/dist/transport/utils/formdata.js.map +1 -0
  94. package/dist/transport/utils/registry.d.ts +26 -0
  95. package/dist/transport/utils/registry.d.ts.map +1 -0
  96. package/dist/transport/utils/registry.js +51 -0
  97. package/dist/transport/utils/registry.js.map +1 -0
  98. package/dist/transport/worker-post.d.ts +44 -0
  99. package/dist/transport/worker-post.d.ts.map +1 -0
  100. package/dist/transport/worker-post.js +104 -0
  101. package/dist/transport/worker-post.js.map +1 -0
  102. package/dist/transport/worker.d.ts +10 -0
  103. package/dist/transport/worker.d.ts.map +1 -0
  104. package/dist/transport/worker.js +421 -0
  105. package/dist/transport/worker.js.map +1 -0
  106. package/dist/types.test-d.js +3 -9
  107. package/dist/types.test-d.js.map +1 -1
  108. package/eslint-isolation.mjs +82 -0
  109. 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