@angular-helpers/worker-http 0.2.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 +399 -0
- package/fesm2022/angular-helpers-worker-http-backend.mjs +3 -0
- package/fesm2022/angular-helpers-worker-http-crypto.mjs +146 -0
- package/fesm2022/angular-helpers-worker-http-interceptors.mjs +453 -0
- package/fesm2022/angular-helpers-worker-http-serializer.mjs +180 -0
- package/fesm2022/angular-helpers-worker-http-transport.mjs +116 -0
- package/fesm2022/angular-helpers-worker-http.mjs +20 -0
- package/package.json +81 -0
- package/types/angular-helpers-worker-http-backend.d.ts +42 -0
- package/types/angular-helpers-worker-http-crypto.d.ts +127 -0
- package/types/angular-helpers-worker-http-interceptors.d.ts +244 -0
- package/types/angular-helpers-worker-http-serializer.d.ts +89 -0
- package/types/angular-helpers-worker-http-transport.d.ts +115 -0
- package/types/angular-helpers-worker-http.d.ts +16 -0
|
@@ -0,0 +1,453 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Creates and registers a worker-side HTTP pipeline.
|
|
3
|
+
*
|
|
4
|
+
* Call this inside a worker file to set up the interceptor chain.
|
|
5
|
+
* The pipeline listens for incoming requests via `postMessage`,
|
|
6
|
+
* runs them through the interceptor chain, executes `fetch()`,
|
|
7
|
+
* and sends the response back.
|
|
8
|
+
*
|
|
9
|
+
* @example
|
|
10
|
+
* ```typescript
|
|
11
|
+
* // workers/secure.worker.ts
|
|
12
|
+
* import { createWorkerPipeline } from '@angular-helpers/worker-http/interceptors';
|
|
13
|
+
* import { hmacSigningInterceptor } from './my-interceptors';
|
|
14
|
+
*
|
|
15
|
+
* createWorkerPipeline([hmacSigningInterceptor]);
|
|
16
|
+
* ```
|
|
17
|
+
*/
|
|
18
|
+
function createWorkerPipeline(interceptors) {
|
|
19
|
+
const controllers = new Map();
|
|
20
|
+
async function executeFetch(req) {
|
|
21
|
+
const controller = controllers.get(req.url) ?? new AbortController();
|
|
22
|
+
const headers = new Headers();
|
|
23
|
+
for (const [key, values] of Object.entries(req.headers)) {
|
|
24
|
+
for (const value of values) {
|
|
25
|
+
headers.append(key, value);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
let url = req.url;
|
|
29
|
+
const paramEntries = Object.entries(req.params);
|
|
30
|
+
if (paramEntries.length > 0) {
|
|
31
|
+
const searchParams = new URLSearchParams();
|
|
32
|
+
for (const [key, values] of paramEntries) {
|
|
33
|
+
for (const value of values) {
|
|
34
|
+
searchParams.append(key, value);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
url += (url.includes('?') ? '&' : '?') + searchParams.toString();
|
|
38
|
+
}
|
|
39
|
+
const fetchInit = {
|
|
40
|
+
method: req.method,
|
|
41
|
+
headers,
|
|
42
|
+
credentials: req.withCredentials ? 'include' : 'same-origin',
|
|
43
|
+
signal: controller.signal,
|
|
44
|
+
};
|
|
45
|
+
if (req.body !== null &&
|
|
46
|
+
req.body !== undefined &&
|
|
47
|
+
req.method !== 'GET' &&
|
|
48
|
+
req.method !== 'HEAD') {
|
|
49
|
+
fetchInit.body = typeof req.body === 'string' ? req.body : JSON.stringify(req.body);
|
|
50
|
+
}
|
|
51
|
+
const response = await fetch(url, fetchInit);
|
|
52
|
+
const responseHeaders = {};
|
|
53
|
+
response.headers.forEach((value, key) => {
|
|
54
|
+
responseHeaders[key] = responseHeaders[key] ?? [];
|
|
55
|
+
responseHeaders[key].push(value);
|
|
56
|
+
});
|
|
57
|
+
let body;
|
|
58
|
+
switch (req.responseType) {
|
|
59
|
+
case 'text':
|
|
60
|
+
body = await response.text();
|
|
61
|
+
break;
|
|
62
|
+
case 'arraybuffer':
|
|
63
|
+
body = await response.arrayBuffer();
|
|
64
|
+
break;
|
|
65
|
+
case 'blob':
|
|
66
|
+
body = await response.blob();
|
|
67
|
+
break;
|
|
68
|
+
default:
|
|
69
|
+
body = await response.json();
|
|
70
|
+
}
|
|
71
|
+
return {
|
|
72
|
+
status: response.status,
|
|
73
|
+
statusText: response.statusText,
|
|
74
|
+
headers: responseHeaders,
|
|
75
|
+
body,
|
|
76
|
+
url: response.url,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
function buildChain(fns, finalHandler) {
|
|
80
|
+
return fns.reduceRight((next, interceptor) => (req) => interceptor(req, next), finalHandler);
|
|
81
|
+
}
|
|
82
|
+
const chain = buildChain(interceptors, executeFetch);
|
|
83
|
+
self.onmessage = async (event) => {
|
|
84
|
+
const { type, requestId, payload } = event.data;
|
|
85
|
+
if (type === 'cancel') {
|
|
86
|
+
controllers.get(requestId)?.abort();
|
|
87
|
+
controllers.delete(requestId);
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
if (type === 'request') {
|
|
91
|
+
const controller = new AbortController();
|
|
92
|
+
controllers.set(requestId, controller);
|
|
93
|
+
try {
|
|
94
|
+
const response = await chain(payload);
|
|
95
|
+
self.postMessage({ type: 'response', requestId, result: response });
|
|
96
|
+
}
|
|
97
|
+
catch (error) {
|
|
98
|
+
self.postMessage({
|
|
99
|
+
type: 'error',
|
|
100
|
+
requestId,
|
|
101
|
+
error: {
|
|
102
|
+
message: error instanceof Error ? error.message : String(error),
|
|
103
|
+
name: error instanceof Error ? error.name : 'UnknownError',
|
|
104
|
+
stack: error instanceof Error ? error.stack : undefined,
|
|
105
|
+
},
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
finally {
|
|
109
|
+
controllers.delete(requestId);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function parseRetryAfterMs(headerValue) {
|
|
116
|
+
const seconds = parseFloat(headerValue);
|
|
117
|
+
if (!isNaN(seconds)) {
|
|
118
|
+
return Math.max(0, seconds * 1000);
|
|
119
|
+
}
|
|
120
|
+
const date = new Date(headerValue).getTime();
|
|
121
|
+
if (!isNaN(date)) {
|
|
122
|
+
return Math.max(0, date - Date.now());
|
|
123
|
+
}
|
|
124
|
+
return 0;
|
|
125
|
+
}
|
|
126
|
+
function delay(ms) {
|
|
127
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Creates a retry interceptor with exponential backoff.
|
|
131
|
+
*
|
|
132
|
+
* Retries requests that fail with specific HTTP status codes.
|
|
133
|
+
* Respects the `Retry-After` response header when present.
|
|
134
|
+
*
|
|
135
|
+
* @example
|
|
136
|
+
* ```typescript
|
|
137
|
+
* createWorkerPipeline([
|
|
138
|
+
* retryInterceptor({ maxRetries: 3, initialDelay: 500 }),
|
|
139
|
+
* ]);
|
|
140
|
+
* ```
|
|
141
|
+
*/
|
|
142
|
+
function retryInterceptor(config) {
|
|
143
|
+
const maxRetries = config?.maxRetries ?? 3;
|
|
144
|
+
const initialDelay = config?.initialDelay ?? 1000;
|
|
145
|
+
const backoffMultiplier = config?.backoffMultiplier ?? 2;
|
|
146
|
+
const retryStatusCodes = config?.retryStatusCodes ?? [408, 429, 500, 502, 503, 504];
|
|
147
|
+
const retryOnNetworkError = config?.retryOnNetworkError ?? true;
|
|
148
|
+
return async (req, next) => {
|
|
149
|
+
let attempt = 0;
|
|
150
|
+
while (true) {
|
|
151
|
+
try {
|
|
152
|
+
const response = await next(req);
|
|
153
|
+
if (maxRetries > 0 && retryStatusCodes.includes(response.status)) {
|
|
154
|
+
if (attempt < maxRetries) {
|
|
155
|
+
const retryAfterHeader = response.headers['retry-after']?.[0] ?? response.headers['Retry-After']?.[0];
|
|
156
|
+
const waitMs = retryAfterHeader
|
|
157
|
+
? parseRetryAfterMs(retryAfterHeader)
|
|
158
|
+
: initialDelay * Math.pow(backoffMultiplier, attempt);
|
|
159
|
+
await delay(waitMs);
|
|
160
|
+
attempt++;
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
throw Object.assign(new Error(`Max retries exceeded after ${maxRetries} attempt(s) (status: ${response.status})`), { status: response.status });
|
|
164
|
+
}
|
|
165
|
+
return response;
|
|
166
|
+
}
|
|
167
|
+
catch (error) {
|
|
168
|
+
if (retryOnNetworkError && attempt < maxRetries) {
|
|
169
|
+
const waitMs = initialDelay * Math.pow(backoffMultiplier, attempt);
|
|
170
|
+
await delay(waitMs);
|
|
171
|
+
attempt++;
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
throw error;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Creates a cache interceptor that stores responses in worker memory.
|
|
182
|
+
*
|
|
183
|
+
* Caches GET responses by default (configurable). Evicts oldest entry
|
|
184
|
+
* when `maxEntries` is reached (insertion-order eviction).
|
|
185
|
+
* Each factory call creates an independent cache instance.
|
|
186
|
+
*
|
|
187
|
+
* @example
|
|
188
|
+
* ```typescript
|
|
189
|
+
* createWorkerPipeline([
|
|
190
|
+
* cacheInterceptor({ ttl: 30000, maxEntries: 50 }),
|
|
191
|
+
* ]);
|
|
192
|
+
* ```
|
|
193
|
+
*/
|
|
194
|
+
function cacheInterceptor(config) {
|
|
195
|
+
const ttl = config?.ttl ?? 60_000;
|
|
196
|
+
const maxEntries = config?.maxEntries ?? 100;
|
|
197
|
+
const methods = config?.methods ?? ['GET'];
|
|
198
|
+
const cache = new Map();
|
|
199
|
+
return async (req, next) => {
|
|
200
|
+
if (!methods.includes(req.method.toUpperCase())) {
|
|
201
|
+
return next(req);
|
|
202
|
+
}
|
|
203
|
+
if (ttl === 0) {
|
|
204
|
+
return next(req);
|
|
205
|
+
}
|
|
206
|
+
const cacheKey = `${req.method.toUpperCase()}:${req.url}`;
|
|
207
|
+
const now = Date.now();
|
|
208
|
+
const cached = cache.get(cacheKey);
|
|
209
|
+
if (cached && cached.expiresAt > now) {
|
|
210
|
+
return cached.response;
|
|
211
|
+
}
|
|
212
|
+
if (cached) {
|
|
213
|
+
cache.delete(cacheKey);
|
|
214
|
+
}
|
|
215
|
+
const response = await next(req);
|
|
216
|
+
if (cache.size >= maxEntries) {
|
|
217
|
+
const firstKey = cache.keys().next().value;
|
|
218
|
+
if (firstKey !== undefined) {
|
|
219
|
+
cache.delete(firstKey);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
cache.set(cacheKey, { response, expiresAt: now + ttl });
|
|
223
|
+
return response;
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function arrayBufferToHex$1(buffer) {
|
|
228
|
+
return [...new Uint8Array(buffer)].map((b) => b.toString(16).padStart(2, '0')).join('');
|
|
229
|
+
}
|
|
230
|
+
function defaultPayloadBuilder(req) {
|
|
231
|
+
const body = req.body != null ? JSON.stringify(req.body) : '';
|
|
232
|
+
return `${req.method}:${req.url}:${body}`;
|
|
233
|
+
}
|
|
234
|
+
/**
|
|
235
|
+
* Creates an HMAC signing interceptor that adds a signature header to outgoing requests.
|
|
236
|
+
*
|
|
237
|
+
* Uses WebCrypto `SubtleCrypto` (native in web workers). The `CryptoKey` is
|
|
238
|
+
* imported lazily on the first request and reused for all subsequent requests
|
|
239
|
+
* in the same factory instance.
|
|
240
|
+
*
|
|
241
|
+
* @example
|
|
242
|
+
* ```typescript
|
|
243
|
+
* createWorkerPipeline([
|
|
244
|
+
* hmacSigningInterceptor({
|
|
245
|
+
* keyMaterial: new TextEncoder().encode('my-secret-key'),
|
|
246
|
+
* algorithm: 'SHA-256',
|
|
247
|
+
* headerName: 'X-HMAC-Signature',
|
|
248
|
+
* }),
|
|
249
|
+
* ]);
|
|
250
|
+
* ```
|
|
251
|
+
*/
|
|
252
|
+
function hmacSigningInterceptor(config) {
|
|
253
|
+
const algorithm = config.algorithm ?? 'SHA-256';
|
|
254
|
+
const headerName = config.headerName ?? 'X-HMAC-Signature';
|
|
255
|
+
const payloadBuilder = config.payloadBuilder ?? defaultPayloadBuilder;
|
|
256
|
+
let cryptoKeyPromise = null;
|
|
257
|
+
function getCryptoKey() {
|
|
258
|
+
if (!cryptoKeyPromise) {
|
|
259
|
+
const keyMaterial = config.keyMaterial instanceof Uint8Array
|
|
260
|
+
? config.keyMaterial
|
|
261
|
+
: new Uint8Array(config.keyMaterial);
|
|
262
|
+
cryptoKeyPromise = crypto.subtle.importKey('raw', keyMaterial, { name: 'HMAC', hash: algorithm }, false, ['sign']);
|
|
263
|
+
}
|
|
264
|
+
return cryptoKeyPromise;
|
|
265
|
+
}
|
|
266
|
+
return async (req, next) => {
|
|
267
|
+
const key = await getCryptoKey();
|
|
268
|
+
const payload = payloadBuilder(req);
|
|
269
|
+
const signatureBuffer = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(payload));
|
|
270
|
+
const signatureHex = arrayBufferToHex$1(signatureBuffer);
|
|
271
|
+
const signedReq = {
|
|
272
|
+
...req,
|
|
273
|
+
headers: {
|
|
274
|
+
...req.headers,
|
|
275
|
+
[headerName]: [signatureHex],
|
|
276
|
+
},
|
|
277
|
+
};
|
|
278
|
+
return next(signedReq);
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Creates a logging interceptor that logs request and response details.
|
|
284
|
+
*
|
|
285
|
+
* Uses `console.log` by default. A custom logger can be provided via config.
|
|
286
|
+
* Logger exceptions are swallowed — a logging failure never interrupts the pipeline.
|
|
287
|
+
*
|
|
288
|
+
* @example
|
|
289
|
+
* ```typescript
|
|
290
|
+
* createWorkerPipeline([
|
|
291
|
+
* loggingInterceptor({ includeHeaders: true }),
|
|
292
|
+
* ]);
|
|
293
|
+
* ```
|
|
294
|
+
*/
|
|
295
|
+
function loggingInterceptor(config) {
|
|
296
|
+
const logger = config?.logger ?? ((msg, data) => console.log(msg, data));
|
|
297
|
+
const includeHeaders = config?.includeHeaders ?? false;
|
|
298
|
+
function safeLog(message, data) {
|
|
299
|
+
try {
|
|
300
|
+
logger(message, data);
|
|
301
|
+
}
|
|
302
|
+
catch {
|
|
303
|
+
// swallow — logger must never break the pipeline
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
return async (req, next) => {
|
|
307
|
+
const startMs = Date.now();
|
|
308
|
+
safeLog(`[worker] → ${req.method} ${req.url}`, includeHeaders ? { headers: req.headers } : undefined);
|
|
309
|
+
try {
|
|
310
|
+
const response = await next(req);
|
|
311
|
+
const elapsedMs = Date.now() - startMs;
|
|
312
|
+
safeLog(`[worker] ← ${response.status} ${req.url} (${elapsedMs}ms)`, includeHeaders ? { headers: response.headers } : undefined);
|
|
313
|
+
return response;
|
|
314
|
+
}
|
|
315
|
+
catch (error) {
|
|
316
|
+
const elapsedMs = Date.now() - startMs;
|
|
317
|
+
const status = error.status;
|
|
318
|
+
const label = status != null ? String(status) : 'NETWORK_ERROR';
|
|
319
|
+
safeLog(`[worker] ✕ ${label} ${req.url} (${elapsedMs}ms)`, error);
|
|
320
|
+
throw error;
|
|
321
|
+
}
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* Creates a client-side rate limiting interceptor using a sliding window algorithm.
|
|
327
|
+
*
|
|
328
|
+
* Tracks request timestamps within the configured window. When the limit is
|
|
329
|
+
* exceeded, throws an error with status 429. State is per-factory-instance
|
|
330
|
+
* (resets when the worker is terminated).
|
|
331
|
+
*
|
|
332
|
+
* @example
|
|
333
|
+
* ```typescript
|
|
334
|
+
* createWorkerPipeline([
|
|
335
|
+
* rateLimitInterceptor({ maxRequests: 10, windowMs: 5000 }),
|
|
336
|
+
* ]);
|
|
337
|
+
* ```
|
|
338
|
+
*/
|
|
339
|
+
function rateLimitInterceptor(config) {
|
|
340
|
+
const maxRequests = config?.maxRequests ?? 100;
|
|
341
|
+
const windowMs = config?.windowMs ?? 60_000;
|
|
342
|
+
const timestamps = [];
|
|
343
|
+
return async (req, next) => {
|
|
344
|
+
const now = Date.now();
|
|
345
|
+
const windowStart = now - windowMs;
|
|
346
|
+
let i = 0;
|
|
347
|
+
while (i < timestamps.length && timestamps[i] < windowStart) {
|
|
348
|
+
i++;
|
|
349
|
+
}
|
|
350
|
+
timestamps.splice(0, i);
|
|
351
|
+
if (timestamps.length >= maxRequests) {
|
|
352
|
+
throw Object.assign(new Error('Rate limit exceeded'), { status: 429 });
|
|
353
|
+
}
|
|
354
|
+
timestamps.push(now);
|
|
355
|
+
return next(req);
|
|
356
|
+
};
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function arrayBufferToHex(buffer) {
|
|
360
|
+
return [...new Uint8Array(buffer)].map((b) => b.toString(16).padStart(2, '0')).join('');
|
|
361
|
+
}
|
|
362
|
+
async function hashBody(body, algorithm) {
|
|
363
|
+
let data;
|
|
364
|
+
if (body instanceof ArrayBuffer) {
|
|
365
|
+
data = body;
|
|
366
|
+
}
|
|
367
|
+
else if (body instanceof Blob) {
|
|
368
|
+
data = await body.arrayBuffer();
|
|
369
|
+
}
|
|
370
|
+
else {
|
|
371
|
+
const str = typeof body === 'string' ? body : (JSON.stringify(body) ?? '');
|
|
372
|
+
data = new TextEncoder().encode(str).buffer;
|
|
373
|
+
}
|
|
374
|
+
const hash = await crypto.subtle.digest(algorithm, data);
|
|
375
|
+
return arrayBufferToHex(hash);
|
|
376
|
+
}
|
|
377
|
+
function getHeaderValue(response, headerName) {
|
|
378
|
+
const lower = headerName.toLowerCase();
|
|
379
|
+
return response.headers[lower]?.[0] ?? response.headers[headerName]?.[0];
|
|
380
|
+
}
|
|
381
|
+
/**
|
|
382
|
+
* Creates a content integrity interceptor that verifies response body integrity
|
|
383
|
+
* against a hash provided in a response header.
|
|
384
|
+
*
|
|
385
|
+
* Uses WebCrypto `SubtleCrypto` (native in web workers).
|
|
386
|
+
*
|
|
387
|
+
* @example
|
|
388
|
+
* ```typescript
|
|
389
|
+
* createWorkerPipeline([
|
|
390
|
+
* contentIntegrityInterceptor({
|
|
391
|
+
* algorithm: 'SHA-256',
|
|
392
|
+
* headerName: 'X-Content-Hash',
|
|
393
|
+
* requireHash: true,
|
|
394
|
+
* }),
|
|
395
|
+
* ]);
|
|
396
|
+
* ```
|
|
397
|
+
*/
|
|
398
|
+
function contentIntegrityInterceptor(config) {
|
|
399
|
+
const algorithm = config?.algorithm ?? 'SHA-256';
|
|
400
|
+
const headerName = config?.headerName ?? 'X-Content-Hash';
|
|
401
|
+
const requireHash = config?.requireHash ?? false;
|
|
402
|
+
return async (req, next) => {
|
|
403
|
+
const response = await next(req);
|
|
404
|
+
const expectedHash = getHeaderValue(response, headerName);
|
|
405
|
+
if (!expectedHash) {
|
|
406
|
+
if (requireHash) {
|
|
407
|
+
throw Object.assign(new Error(`Content integrity header '${headerName}' is required but missing`), { status: 0 });
|
|
408
|
+
}
|
|
409
|
+
return response;
|
|
410
|
+
}
|
|
411
|
+
const actualHash = await hashBody(response.body, algorithm);
|
|
412
|
+
if (actualHash !== expectedHash.toLowerCase()) {
|
|
413
|
+
throw Object.assign(new Error('Content integrity check failed'), { status: 0 });
|
|
414
|
+
}
|
|
415
|
+
return response;
|
|
416
|
+
};
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/**
|
|
420
|
+
* Composes multiple worker interceptors into a single `WorkerInterceptorFn`.
|
|
421
|
+
*
|
|
422
|
+
* Interceptors are executed left-to-right, matching Angular's interceptor chain convention.
|
|
423
|
+
* Each interceptor calls `next()` to pass control to the next one.
|
|
424
|
+
*
|
|
425
|
+
* @example
|
|
426
|
+
* ```typescript
|
|
427
|
+
* const pipeline = composeInterceptors(
|
|
428
|
+
* loggingInterceptor(),
|
|
429
|
+
* retryInterceptor({ maxRetries: 2 }),
|
|
430
|
+
* hmacSigningInterceptor({ keyMaterial: key }),
|
|
431
|
+
* );
|
|
432
|
+
*
|
|
433
|
+
* createWorkerPipeline([pipeline]);
|
|
434
|
+
* ```
|
|
435
|
+
*/
|
|
436
|
+
function composeInterceptors(...fns) {
|
|
437
|
+
if (fns.length === 0) {
|
|
438
|
+
return (_req, next) => next(_req);
|
|
439
|
+
}
|
|
440
|
+
if (fns.length === 1) {
|
|
441
|
+
return fns[0];
|
|
442
|
+
}
|
|
443
|
+
return (req, finalNext) => {
|
|
444
|
+
const chain = fns.reduceRight((next, interceptor) => (r) => interceptor(r, next), (r) => finalNext(r));
|
|
445
|
+
return chain(req);
|
|
446
|
+
};
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/**
|
|
450
|
+
* Generated bundle index. Do not edit.
|
|
451
|
+
*/
|
|
452
|
+
|
|
453
|
+
export { cacheInterceptor, composeInterceptors, contentIntegrityInterceptor, createWorkerPipeline, hmacSigningInterceptor, loggingInterceptor, rateLimitInterceptor, retryInterceptor };
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Default serializer that relies on the browser's native structured clone algorithm.
|
|
3
|
+
* Zero overhead — data is passed directly to postMessage without transformation.
|
|
4
|
+
*
|
|
5
|
+
* Best for: small payloads (< 100 KiB), simple types.
|
|
6
|
+
* Limitations: cannot handle functions, DOM nodes, or class instances with prototype chains.
|
|
7
|
+
*/
|
|
8
|
+
const structuredCloneSerializer = {
|
|
9
|
+
serialize(data) {
|
|
10
|
+
return {
|
|
11
|
+
data,
|
|
12
|
+
transferables: [],
|
|
13
|
+
format: 'structured-clone',
|
|
14
|
+
};
|
|
15
|
+
},
|
|
16
|
+
deserialize(payload) {
|
|
17
|
+
return payload.data;
|
|
18
|
+
},
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
let cachedSeroval = null;
|
|
22
|
+
async function loadSeroval() {
|
|
23
|
+
if (!cachedSeroval) {
|
|
24
|
+
try {
|
|
25
|
+
// Dynamic import via variable — keeps seroval as optional peer dep (no static reference)
|
|
26
|
+
const id = 'seroval';
|
|
27
|
+
cachedSeroval = (await import(/* @vite-ignore */ id));
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
throw new Error('seroval is required as a peer dependency. Install it with: npm install seroval');
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return cachedSeroval;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Creates a `WorkerSerializer` backed by `seroval` for full type fidelity.
|
|
37
|
+
*
|
|
38
|
+
* Supports `Date`, `Map`, `Set`, `BigInt`, `RegExp`, circular references, and more.
|
|
39
|
+
* The factory is async because it dynamically imports the optional `seroval` peer.
|
|
40
|
+
*
|
|
41
|
+
* `seroval` must be installed separately:
|
|
42
|
+
* ```
|
|
43
|
+
* npm install seroval
|
|
44
|
+
* ```
|
|
45
|
+
*
|
|
46
|
+
* @example
|
|
47
|
+
* ```typescript
|
|
48
|
+
* const serializer = await createSerovalSerializer();
|
|
49
|
+
* const payload = serializer.serialize({ date: new Date(), map: new Map() });
|
|
50
|
+
* const original = serializer.deserialize(payload);
|
|
51
|
+
* ```
|
|
52
|
+
*
|
|
53
|
+
* @see https://github.com/lxsmnsyc/seroval
|
|
54
|
+
*/
|
|
55
|
+
async function createSerovalSerializer() {
|
|
56
|
+
const { serialize, deserialize } = await loadSeroval();
|
|
57
|
+
return {
|
|
58
|
+
serialize(data) {
|
|
59
|
+
return {
|
|
60
|
+
data: serialize(data),
|
|
61
|
+
transferables: [],
|
|
62
|
+
format: 'seroval',
|
|
63
|
+
};
|
|
64
|
+
},
|
|
65
|
+
deserialize(payload) {
|
|
66
|
+
if (payload.format !== 'seroval') {
|
|
67
|
+
throw new Error(`Expected format 'seroval', got '${payload.format}'`);
|
|
68
|
+
}
|
|
69
|
+
return deserialize(payload.data);
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Shallow check for complex types at depth-1 that structured-clone cannot preserve.
|
|
76
|
+
* Depth-1 is intentional: fast and predictable. For deeply nested complex types,
|
|
77
|
+
* use `withWorkerSerialization(await createSerovalSerializer())` explicitly.
|
|
78
|
+
*/
|
|
79
|
+
function hasComplexType(value) {
|
|
80
|
+
if (value === null || typeof value !== 'object') {
|
|
81
|
+
return false;
|
|
82
|
+
}
|
|
83
|
+
if (value instanceof Date ||
|
|
84
|
+
value instanceof Map ||
|
|
85
|
+
value instanceof Set ||
|
|
86
|
+
value instanceof RegExp) {
|
|
87
|
+
return true;
|
|
88
|
+
}
|
|
89
|
+
const items = Array.isArray(value) ? value : Object.values(value);
|
|
90
|
+
for (const item of items) {
|
|
91
|
+
if (item instanceof Date ||
|
|
92
|
+
item instanceof Map ||
|
|
93
|
+
item instanceof Set ||
|
|
94
|
+
item instanceof RegExp) {
|
|
95
|
+
return true;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return false;
|
|
99
|
+
}
|
|
100
|
+
function encodeToTransferable(str) {
|
|
101
|
+
const buffer = new TextEncoder().encode(str).buffer;
|
|
102
|
+
return { data: buffer, transferables: [buffer] };
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Creates an auto-detecting `WorkerSerializer` that picks the best strategy per payload.
|
|
106
|
+
*
|
|
107
|
+
* The factory is async because it pre-loads `seroval` during initialization
|
|
108
|
+
* so the returned serializer methods are fully synchronous (no await in hot path).
|
|
109
|
+
*
|
|
110
|
+
* Strategy selection per `serialize()` call:
|
|
111
|
+
* - Contains `Date`, `Map`, `Set`, or `RegExp` at depth-1 → `seroval` (full fidelity)
|
|
112
|
+
* - Otherwise → structured clone (native, zero overhead)
|
|
113
|
+
*
|
|
114
|
+
* Large payloads (> `transferThreshold`, default 100 KiB) are encoded to
|
|
115
|
+
* `ArrayBuffer` and added to `transferables` for zero-copy `postMessage` transfer.
|
|
116
|
+
*
|
|
117
|
+
* @example
|
|
118
|
+
* ```typescript
|
|
119
|
+
* const auto = await createAutoSerializer();
|
|
120
|
+
* const payload = auto.serialize({ users, fetchedAt: new Date() });
|
|
121
|
+
* worker.postMessage({ payload }, payload.transferables);
|
|
122
|
+
* ```
|
|
123
|
+
*/
|
|
124
|
+
async function createAutoSerializer(config) {
|
|
125
|
+
const transferThreshold = config?.transferThreshold ?? 102_400;
|
|
126
|
+
let sv = null;
|
|
127
|
+
try {
|
|
128
|
+
sv = await createSerovalSerializer();
|
|
129
|
+
}
|
|
130
|
+
catch {
|
|
131
|
+
// seroval not installed — complex types will throw at serialize time with a clear message
|
|
132
|
+
}
|
|
133
|
+
return {
|
|
134
|
+
serialize(data) {
|
|
135
|
+
let payload;
|
|
136
|
+
if (hasComplexType(data)) {
|
|
137
|
+
if (!sv) {
|
|
138
|
+
throw new Error('seroval is required to serialize complex types (Date, Map, Set, RegExp). ' +
|
|
139
|
+
'Install it with: npm install seroval');
|
|
140
|
+
}
|
|
141
|
+
payload = sv.serialize(data);
|
|
142
|
+
}
|
|
143
|
+
else {
|
|
144
|
+
payload = structuredCloneSerializer.serialize(data);
|
|
145
|
+
}
|
|
146
|
+
if (payload.transferables.length === 0 && typeof payload.data === 'string') {
|
|
147
|
+
const approxBytes = payload.data.length * 2;
|
|
148
|
+
if (approxBytes > transferThreshold) {
|
|
149
|
+
const { data: buffer, transferables } = encodeToTransferable(payload.data);
|
|
150
|
+
return { data: buffer, transferables, format: payload.format };
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
return payload;
|
|
154
|
+
},
|
|
155
|
+
deserialize(payload) {
|
|
156
|
+
let resolved = payload;
|
|
157
|
+
if (payload.data instanceof ArrayBuffer) {
|
|
158
|
+
const str = new TextDecoder().decode(payload.data);
|
|
159
|
+
resolved = { ...payload, data: str };
|
|
160
|
+
}
|
|
161
|
+
if (resolved.format === 'structured-clone') {
|
|
162
|
+
return structuredCloneSerializer.deserialize(resolved);
|
|
163
|
+
}
|
|
164
|
+
if (resolved.format === 'seroval') {
|
|
165
|
+
if (!sv) {
|
|
166
|
+
throw new Error('seroval is required to deserialize this payload. ' +
|
|
167
|
+
'Install it with: npm install seroval');
|
|
168
|
+
}
|
|
169
|
+
return sv.deserialize(resolved);
|
|
170
|
+
}
|
|
171
|
+
throw new Error(`Unknown serialization format: '${resolved.format}'`);
|
|
172
|
+
},
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Generated bundle index. Do not edit.
|
|
178
|
+
*/
|
|
179
|
+
|
|
180
|
+
export { createAutoSerializer, createSerovalSerializer, structuredCloneSerializer };
|