@angular-helpers/worker-http 0.2.0 → 0.4.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 +112 -22
- package/fesm2022/angular-helpers-worker-http-backend.mjs +428 -0
- package/fesm2022/angular-helpers-worker-http-interceptors.mjs +377 -196
- package/fesm2022/angular-helpers-worker-http-transport.mjs +3 -0
- package/package.json +1 -1
- package/types/angular-helpers-worker-http-backend.d.ts +306 -3
- package/types/angular-helpers-worker-http-interceptors.d.ts +127 -5
- package/types/angular-helpers-worker-http-transport.d.ts +12 -0
|
@@ -1,180 +1,152 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
* import { createWorkerPipeline } from '@angular-helpers/worker-http/interceptors';
|
|
13
|
-
* import { hmacSigningInterceptor } from './my-interceptors';
|
|
2
|
+
* Composes interceptor functions around a final handler, producing a single
|
|
3
|
+
* `(req) => Promise<resp>` chain. Pure — no side effects.
|
|
4
|
+
*/
|
|
5
|
+
function buildChain(fns, finalHandler) {
|
|
6
|
+
return fns.reduceRight((next, interceptor) => (req) => interceptor(req, next), finalHandler);
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Performs the actual `fetch()` call inside the worker, translating the
|
|
10
|
+
* structured-clone-safe `SerializableRequest` into a `Request` and the
|
|
11
|
+
* `Response` back into a `SerializableResponse`.
|
|
14
12
|
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
13
|
+
* The optional `signal` allows the caller to wire up cancellation via an
|
|
14
|
+
* `AbortController` owned outside of this function.
|
|
17
15
|
*/
|
|
18
|
-
function
|
|
19
|
-
const
|
|
20
|
-
|
|
21
|
-
const
|
|
22
|
-
|
|
23
|
-
for (const [key, values] of Object.entries(req.headers)) {
|
|
24
|
-
for (const value of values) {
|
|
25
|
-
headers.append(key, value);
|
|
26
|
-
}
|
|
16
|
+
async function executeFetch(req, signal) {
|
|
17
|
+
const headers = new Headers();
|
|
18
|
+
for (const [key, values] of Object.entries(req.headers)) {
|
|
19
|
+
for (const value of values) {
|
|
20
|
+
headers.append(key, value);
|
|
27
21
|
}
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
22
|
+
}
|
|
23
|
+
let url = req.url;
|
|
24
|
+
const paramEntries = Object.entries(req.params);
|
|
25
|
+
if (paramEntries.length > 0) {
|
|
26
|
+
const searchParams = new URLSearchParams();
|
|
27
|
+
for (const [key, values] of paramEntries) {
|
|
28
|
+
for (const value of values) {
|
|
29
|
+
searchParams.append(key, value);
|
|
36
30
|
}
|
|
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
31
|
}
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
32
|
+
url += (url.includes('?') ? '&' : '?') + searchParams.toString();
|
|
33
|
+
}
|
|
34
|
+
const fetchInit = {
|
|
35
|
+
method: req.method,
|
|
36
|
+
headers,
|
|
37
|
+
credentials: req.withCredentials ? 'include' : 'same-origin',
|
|
38
|
+
signal,
|
|
39
|
+
};
|
|
40
|
+
if (req.body !== null &&
|
|
41
|
+
req.body !== undefined &&
|
|
42
|
+
req.method !== 'GET' &&
|
|
43
|
+
req.method !== 'HEAD') {
|
|
44
|
+
fetchInit.body = typeof req.body === 'string' ? req.body : JSON.stringify(req.body);
|
|
78
45
|
}
|
|
79
|
-
|
|
80
|
-
|
|
46
|
+
const response = await fetch(url, fetchInit);
|
|
47
|
+
const responseHeaders = {};
|
|
48
|
+
response.headers.forEach((value, key) => {
|
|
49
|
+
responseHeaders[key] = responseHeaders[key] ?? [];
|
|
50
|
+
responseHeaders[key].push(value);
|
|
51
|
+
});
|
|
52
|
+
let body;
|
|
53
|
+
switch (req.responseType) {
|
|
54
|
+
case 'text':
|
|
55
|
+
body = await response.text();
|
|
56
|
+
break;
|
|
57
|
+
case 'arraybuffer':
|
|
58
|
+
body = await response.arrayBuffer();
|
|
59
|
+
break;
|
|
60
|
+
case 'blob':
|
|
61
|
+
body = await response.blob();
|
|
62
|
+
break;
|
|
63
|
+
default:
|
|
64
|
+
body = await response.json();
|
|
81
65
|
}
|
|
82
|
-
|
|
66
|
+
return {
|
|
67
|
+
status: response.status,
|
|
68
|
+
statusText: response.statusText,
|
|
69
|
+
headers: responseHeaders,
|
|
70
|
+
body,
|
|
71
|
+
url: response.url,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Wires up the worker's `self.onmessage` handler around a built request chain.
|
|
77
|
+
*
|
|
78
|
+
* Owns the per-request `AbortController` map for cancellation support. Posts
|
|
79
|
+
* `response` / `error` messages back to the main thread.
|
|
80
|
+
*
|
|
81
|
+
* Returns a disposer that restores `self.onmessage` to whatever it was before
|
|
82
|
+
* (mainly useful for the configurable pipeline, which swaps the handler when
|
|
83
|
+
* receiving the init message).
|
|
84
|
+
*/
|
|
85
|
+
function attachRequestLoop(chain) {
|
|
86
|
+
const controllers = new Map();
|
|
87
|
+
const previous = self.onmessage;
|
|
83
88
|
self.onmessage = async (event) => {
|
|
84
|
-
const { type, requestId, payload } = event.data;
|
|
89
|
+
const { type, requestId, payload } = event.data ?? {};
|
|
85
90
|
if (type === 'cancel') {
|
|
86
91
|
controllers.get(requestId)?.abort();
|
|
87
92
|
controllers.delete(requestId);
|
|
88
93
|
return;
|
|
89
94
|
}
|
|
90
|
-
if (type
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
95
|
+
if (type !== 'request') {
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
const controller = new AbortController();
|
|
99
|
+
controllers.set(requestId, controller);
|
|
100
|
+
try {
|
|
101
|
+
const response = await chain(payload);
|
|
102
|
+
self.postMessage({ type: 'response', requestId, result: response });
|
|
103
|
+
}
|
|
104
|
+
catch (error) {
|
|
105
|
+
self.postMessage({
|
|
106
|
+
type: 'error',
|
|
107
|
+
requestId,
|
|
108
|
+
error: {
|
|
109
|
+
message: error instanceof Error ? error.message : String(error),
|
|
110
|
+
name: error instanceof Error ? error.name : 'UnknownError',
|
|
111
|
+
stack: error instanceof Error ? error.stack : undefined,
|
|
112
|
+
},
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
finally {
|
|
116
|
+
controllers.delete(requestId);
|
|
111
117
|
}
|
|
112
118
|
};
|
|
119
|
+
return () => {
|
|
120
|
+
self.onmessage = previous;
|
|
121
|
+
for (const controller of controllers.values()) {
|
|
122
|
+
controller.abort();
|
|
123
|
+
}
|
|
124
|
+
controllers.clear();
|
|
125
|
+
};
|
|
113
126
|
}
|
|
114
127
|
|
|
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
128
|
/**
|
|
130
|
-
* Creates
|
|
129
|
+
* Creates and registers a worker-side HTTP pipeline.
|
|
131
130
|
*
|
|
132
|
-
*
|
|
133
|
-
*
|
|
131
|
+
* Call this inside a worker file to set up the interceptor chain.
|
|
132
|
+
* The pipeline listens for incoming requests via `postMessage`,
|
|
133
|
+
* runs them through the interceptor chain, executes `fetch()`,
|
|
134
|
+
* and sends the response back.
|
|
135
|
+
*
|
|
136
|
+
* For a runtime-configurable variant whose chain is built from specs sent
|
|
137
|
+
* from the main thread, use `createConfigurableWorkerPipeline()` instead.
|
|
134
138
|
*
|
|
135
139
|
* @example
|
|
136
140
|
* ```typescript
|
|
137
|
-
*
|
|
138
|
-
*
|
|
139
|
-
*
|
|
141
|
+
* // workers/secure.worker.ts
|
|
142
|
+
* import { createWorkerPipeline, hmacSigningInterceptor } from '@angular-helpers/worker-http/interceptors';
|
|
143
|
+
*
|
|
144
|
+
* createWorkerPipeline([hmacSigningInterceptor({ keyMaterial })]);
|
|
140
145
|
* ```
|
|
141
146
|
*/
|
|
142
|
-
function
|
|
143
|
-
const
|
|
144
|
-
|
|
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
|
-
};
|
|
147
|
+
function createWorkerPipeline(interceptors) {
|
|
148
|
+
const chain = buildChain(interceptors, (req) => executeFetch(req));
|
|
149
|
+
attachRequestLoop(chain);
|
|
178
150
|
}
|
|
179
151
|
|
|
180
152
|
/**
|
|
@@ -227,8 +199,68 @@ function cacheInterceptor(config) {
|
|
|
227
199
|
function arrayBufferToHex$1(buffer) {
|
|
228
200
|
return [...new Uint8Array(buffer)].map((b) => b.toString(16).padStart(2, '0')).join('');
|
|
229
201
|
}
|
|
202
|
+
async function hashBody(body, algorithm) {
|
|
203
|
+
let data;
|
|
204
|
+
if (body instanceof ArrayBuffer) {
|
|
205
|
+
data = body;
|
|
206
|
+
}
|
|
207
|
+
else if (body instanceof Blob) {
|
|
208
|
+
data = await body.arrayBuffer();
|
|
209
|
+
}
|
|
210
|
+
else {
|
|
211
|
+
const str = typeof body === 'string' ? body : (JSON.stringify(body) ?? '');
|
|
212
|
+
data = new TextEncoder().encode(str).buffer;
|
|
213
|
+
}
|
|
214
|
+
const hash = await crypto.subtle.digest(algorithm, data);
|
|
215
|
+
return arrayBufferToHex$1(hash);
|
|
216
|
+
}
|
|
217
|
+
function getHeaderValue(response, headerName) {
|
|
218
|
+
const lower = headerName.toLowerCase();
|
|
219
|
+
return response.headers[lower]?.[0] ?? response.headers[headerName]?.[0];
|
|
220
|
+
}
|
|
221
|
+
/**
|
|
222
|
+
* Creates a content integrity interceptor that verifies response body integrity
|
|
223
|
+
* against a hash provided in a response header.
|
|
224
|
+
*
|
|
225
|
+
* Uses WebCrypto `SubtleCrypto` (native in web workers).
|
|
226
|
+
*
|
|
227
|
+
* @example
|
|
228
|
+
* ```typescript
|
|
229
|
+
* createWorkerPipeline([
|
|
230
|
+
* contentIntegrityInterceptor({
|
|
231
|
+
* algorithm: 'SHA-256',
|
|
232
|
+
* headerName: 'X-Content-Hash',
|
|
233
|
+
* requireHash: true,
|
|
234
|
+
* }),
|
|
235
|
+
* ]);
|
|
236
|
+
* ```
|
|
237
|
+
*/
|
|
238
|
+
function contentIntegrityInterceptor(config) {
|
|
239
|
+
const algorithm = config?.algorithm ?? 'SHA-256';
|
|
240
|
+
const headerName = config?.headerName ?? 'X-Content-Hash';
|
|
241
|
+
const requireHash = config?.requireHash ?? false;
|
|
242
|
+
return async (req, next) => {
|
|
243
|
+
const response = await next(req);
|
|
244
|
+
const expectedHash = getHeaderValue(response, headerName);
|
|
245
|
+
if (!expectedHash) {
|
|
246
|
+
if (requireHash) {
|
|
247
|
+
throw Object.assign(new Error(`Content integrity header '${headerName}' is required but missing`), { status: 0 });
|
|
248
|
+
}
|
|
249
|
+
return response;
|
|
250
|
+
}
|
|
251
|
+
const actualHash = await hashBody(response.body, algorithm);
|
|
252
|
+
if (actualHash !== expectedHash.toLowerCase()) {
|
|
253
|
+
throw Object.assign(new Error('Content integrity check failed'), { status: 0 });
|
|
254
|
+
}
|
|
255
|
+
return response;
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function arrayBufferToHex(buffer) {
|
|
260
|
+
return [...new Uint8Array(buffer)].map((b) => b.toString(16).padStart(2, '0')).join('');
|
|
261
|
+
}
|
|
230
262
|
function defaultPayloadBuilder(req) {
|
|
231
|
-
const body = req.body
|
|
263
|
+
const body = req.body !== null && req.body !== undefined ? JSON.stringify(req.body) : '';
|
|
232
264
|
return `${req.method}:${req.url}:${body}`;
|
|
233
265
|
}
|
|
234
266
|
/**
|
|
@@ -267,7 +299,7 @@ function hmacSigningInterceptor(config) {
|
|
|
267
299
|
const key = await getCryptoKey();
|
|
268
300
|
const payload = payloadBuilder(req);
|
|
269
301
|
const signatureBuffer = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(payload));
|
|
270
|
-
const signatureHex = arrayBufferToHex
|
|
302
|
+
const signatureHex = arrayBufferToHex(signatureBuffer);
|
|
271
303
|
const signedReq = {
|
|
272
304
|
...req,
|
|
273
305
|
headers: {
|
|
@@ -315,7 +347,7 @@ function loggingInterceptor(config) {
|
|
|
315
347
|
catch (error) {
|
|
316
348
|
const elapsedMs = Date.now() - startMs;
|
|
317
349
|
const status = error.status;
|
|
318
|
-
const label = status
|
|
350
|
+
const label = status !== null && status !== undefined ? String(status) : 'NETWORK_ERROR';
|
|
319
351
|
safeLog(`[worker] ✕ ${label} ${req.url} (${elapsedMs}ms)`, error);
|
|
320
352
|
throw error;
|
|
321
353
|
}
|
|
@@ -356,64 +388,180 @@ function rateLimitInterceptor(config) {
|
|
|
356
388
|
};
|
|
357
389
|
}
|
|
358
390
|
|
|
359
|
-
function
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
let data;
|
|
364
|
-
if (body instanceof ArrayBuffer) {
|
|
365
|
-
data = body;
|
|
366
|
-
}
|
|
367
|
-
else if (body instanceof Blob) {
|
|
368
|
-
data = await body.arrayBuffer();
|
|
391
|
+
function parseRetryAfterMs(headerValue) {
|
|
392
|
+
const seconds = parseFloat(headerValue);
|
|
393
|
+
if (!isNaN(seconds)) {
|
|
394
|
+
return Math.max(0, seconds * 1000);
|
|
369
395
|
}
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
396
|
+
const date = new Date(headerValue).getTime();
|
|
397
|
+
if (!isNaN(date)) {
|
|
398
|
+
return Math.max(0, date - Date.now());
|
|
373
399
|
}
|
|
374
|
-
|
|
375
|
-
return arrayBufferToHex(hash);
|
|
400
|
+
return 0;
|
|
376
401
|
}
|
|
377
|
-
function
|
|
378
|
-
|
|
379
|
-
return response.headers[lower]?.[0] ?? response.headers[headerName]?.[0];
|
|
402
|
+
function delay(ms) {
|
|
403
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
380
404
|
}
|
|
381
405
|
/**
|
|
382
|
-
* Creates a
|
|
383
|
-
* against a hash provided in a response header.
|
|
406
|
+
* Creates a retry interceptor with exponential backoff.
|
|
384
407
|
*
|
|
385
|
-
*
|
|
408
|
+
* Retries requests that fail with specific HTTP status codes.
|
|
409
|
+
* Respects the `Retry-After` response header when present.
|
|
386
410
|
*
|
|
387
411
|
* @example
|
|
388
412
|
* ```typescript
|
|
389
413
|
* createWorkerPipeline([
|
|
390
|
-
*
|
|
391
|
-
* algorithm: 'SHA-256',
|
|
392
|
-
* headerName: 'X-Content-Hash',
|
|
393
|
-
* requireHash: true,
|
|
394
|
-
* }),
|
|
414
|
+
* retryInterceptor({ maxRetries: 3, initialDelay: 500 }),
|
|
395
415
|
* ]);
|
|
396
416
|
* ```
|
|
397
417
|
*/
|
|
398
|
-
function
|
|
399
|
-
const
|
|
400
|
-
const
|
|
401
|
-
const
|
|
418
|
+
function retryInterceptor(config) {
|
|
419
|
+
const maxRetries = config?.maxRetries ?? 3;
|
|
420
|
+
const initialDelay = config?.initialDelay ?? 1000;
|
|
421
|
+
const backoffMultiplier = config?.backoffMultiplier ?? 2;
|
|
422
|
+
const retryStatusCodes = config?.retryStatusCodes ?? [408, 429, 500, 502, 503, 504];
|
|
423
|
+
const retryOnNetworkError = config?.retryOnNetworkError ?? true;
|
|
402
424
|
return async (req, next) => {
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
425
|
+
let attempt = 0;
|
|
426
|
+
while (true) {
|
|
427
|
+
try {
|
|
428
|
+
const response = await next(req);
|
|
429
|
+
if (maxRetries > 0 && retryStatusCodes.includes(response.status)) {
|
|
430
|
+
if (attempt < maxRetries) {
|
|
431
|
+
const retryAfterHeader = response.headers['retry-after']?.[0] ?? response.headers['Retry-After']?.[0];
|
|
432
|
+
const waitMs = retryAfterHeader
|
|
433
|
+
? parseRetryAfterMs(retryAfterHeader)
|
|
434
|
+
: initialDelay * Math.pow(backoffMultiplier, attempt);
|
|
435
|
+
await delay(waitMs);
|
|
436
|
+
attempt++;
|
|
437
|
+
continue;
|
|
438
|
+
}
|
|
439
|
+
throw Object.assign(new Error(`Max retries exceeded after ${maxRetries} attempt(s) (status: ${response.status})`), { status: response.status });
|
|
440
|
+
}
|
|
441
|
+
return response;
|
|
442
|
+
}
|
|
443
|
+
catch (error) {
|
|
444
|
+
if (retryOnNetworkError && attempt < maxRetries) {
|
|
445
|
+
const waitMs = initialDelay * Math.pow(backoffMultiplier, attempt);
|
|
446
|
+
await delay(waitMs);
|
|
447
|
+
attempt++;
|
|
448
|
+
continue;
|
|
449
|
+
}
|
|
450
|
+
throw error;
|
|
408
451
|
}
|
|
409
|
-
return response;
|
|
410
452
|
}
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
453
|
+
};
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
const INIT_MESSAGE_TYPE = 'init-interceptors';
|
|
457
|
+
const customRegistry = new Map();
|
|
458
|
+
/**
|
|
459
|
+
* Registers a custom interceptor factory that can be referenced from
|
|
460
|
+
* `withWorkerInterceptors([workerCustom('my-name', config)])`.
|
|
461
|
+
*
|
|
462
|
+
* Must be called inside the worker file BEFORE `createConfigurableWorkerPipeline()`.
|
|
463
|
+
*
|
|
464
|
+
* @example
|
|
465
|
+
* ```typescript
|
|
466
|
+
* // app.worker.ts
|
|
467
|
+
* import { createConfigurableWorkerPipeline, registerInterceptor } from '@angular-helpers/worker-http/interceptors';
|
|
468
|
+
*
|
|
469
|
+
* registerInterceptor('auth-token', (config: { token: string }) => async (req, next) => {
|
|
470
|
+
* const headers = { ...req.headers, authorization: [`Bearer ${config.token}`] };
|
|
471
|
+
* return next({ ...req, headers });
|
|
472
|
+
* });
|
|
473
|
+
*
|
|
474
|
+
* createConfigurableWorkerPipeline();
|
|
475
|
+
* ```
|
|
476
|
+
*/
|
|
477
|
+
function registerInterceptor(name, factory) {
|
|
478
|
+
customRegistry.set(name, factory);
|
|
479
|
+
}
|
|
480
|
+
/** Test-only: clears the custom registry. */
|
|
481
|
+
function __clearCustomRegistry() {
|
|
482
|
+
customRegistry.clear();
|
|
483
|
+
}
|
|
484
|
+
/**
|
|
485
|
+
* Resolves a single spec to its concrete `WorkerInterceptorFn`.
|
|
486
|
+
*
|
|
487
|
+
* Exported for test use. Throws on unknown `kind` or unregistered custom name
|
|
488
|
+
* so misconfiguration fails loudly at init time, not on the first request.
|
|
489
|
+
*/
|
|
490
|
+
function resolveSpec(spec) {
|
|
491
|
+
switch (spec.kind) {
|
|
492
|
+
case 'logging':
|
|
493
|
+
return loggingInterceptor(spec.config);
|
|
494
|
+
case 'retry':
|
|
495
|
+
return retryInterceptor(spec.config);
|
|
496
|
+
case 'cache':
|
|
497
|
+
return cacheInterceptor(spec.config);
|
|
498
|
+
case 'hmac-signing':
|
|
499
|
+
return hmacSigningInterceptor(spec.config);
|
|
500
|
+
case 'rate-limit':
|
|
501
|
+
return rateLimitInterceptor(spec.config);
|
|
502
|
+
case 'content-integrity':
|
|
503
|
+
return contentIntegrityInterceptor(spec.config);
|
|
504
|
+
case 'custom': {
|
|
505
|
+
const factory = customRegistry.get(spec.name);
|
|
506
|
+
if (!factory) {
|
|
507
|
+
throw new Error(`[worker-http] Custom interceptor "${spec.name}" was not registered. ` +
|
|
508
|
+
`Call registerInterceptor("${spec.name}", factory) in your worker file before createConfigurableWorkerPipeline().`);
|
|
509
|
+
}
|
|
510
|
+
return factory(spec.config);
|
|
511
|
+
}
|
|
512
|
+
default: {
|
|
513
|
+
const exhaustive = spec;
|
|
514
|
+
throw new Error(`[worker-http] Unknown interceptor spec kind: ${JSON.stringify(exhaustive)}`);
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
/**
|
|
519
|
+
* Creates a worker-side pipeline whose interceptor chain is supplied at
|
|
520
|
+
* runtime via the init handshake message sent by `WorkerHttpBackend`.
|
|
521
|
+
*
|
|
522
|
+
* Behavior:
|
|
523
|
+
* - Listens for the first `init-interceptors` message and builds the chain
|
|
524
|
+
* from the received specs.
|
|
525
|
+
* - Until init arrives, incoming `request` messages are buffered (they will
|
|
526
|
+
* be flushed once the chain is ready).
|
|
527
|
+
* - If no init arrives (e.g. worker used standalone without
|
|
528
|
+
* `withWorkerInterceptors`), the pipeline runs with an empty chain so
|
|
529
|
+
* every request goes straight to `fetch()`.
|
|
530
|
+
*
|
|
531
|
+
* Custom interceptor factories must be registered with
|
|
532
|
+
* `registerInterceptor(name, factory)` before this call.
|
|
533
|
+
*/
|
|
534
|
+
function createConfigurableWorkerPipeline() {
|
|
535
|
+
const pending = [];
|
|
536
|
+
const initialHandler = (event) => {
|
|
537
|
+
const data = event.data ?? {};
|
|
538
|
+
if (data.type === INIT_MESSAGE_TYPE) {
|
|
539
|
+
const specs = data.specs ?? [];
|
|
540
|
+
const fns = specs.map((spec) => resolveSpec(spec));
|
|
541
|
+
const chain = buildChain(fns, (req) => executeFetch(req));
|
|
542
|
+
// Swap to the regular request loop and replay any buffered messages.
|
|
543
|
+
attachRequestLoop(chain);
|
|
544
|
+
const handler = self.onmessage;
|
|
545
|
+
if (handler) {
|
|
546
|
+
for (const buffered of pending) {
|
|
547
|
+
handler.call(self, buffered);
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
pending.length = 0;
|
|
551
|
+
return;
|
|
552
|
+
}
|
|
553
|
+
if (data.type === 'request' || data.type === 'cancel') {
|
|
554
|
+
pending.push(event);
|
|
414
555
|
}
|
|
415
|
-
return response;
|
|
416
556
|
};
|
|
557
|
+
self.onmessage = initialHandler;
|
|
558
|
+
// Safety net: if the main thread never sends init within a microtask,
|
|
559
|
+
// the worker still works as a no-interceptor pipeline. We DO NOT trigger
|
|
560
|
+
// this immediately — the init arrives via postMessage which is queued, so
|
|
561
|
+
// we let the buffered messages accumulate until init or the first non-init
|
|
562
|
+
// message after a tick. The transport guarantees init is posted first, so
|
|
563
|
+
// in practice the empty-chain path is only hit when the worker is used
|
|
564
|
+
// without withWorkerInterceptors.
|
|
417
565
|
}
|
|
418
566
|
|
|
419
567
|
/**
|
|
@@ -446,8 +594,41 @@ function composeInterceptors(...fns) {
|
|
|
446
594
|
};
|
|
447
595
|
}
|
|
448
596
|
|
|
597
|
+
/**
|
|
598
|
+
* Spec builders — pure factories that return POJO `WorkerInterceptorSpec`
|
|
599
|
+
* objects suitable for `withWorkerInterceptors([...])`.
|
|
600
|
+
*
|
|
601
|
+
* Each builder mirrors the corresponding worker-side interceptor factory but
|
|
602
|
+
* accepts only serializable config (no function fields).
|
|
603
|
+
*/
|
|
604
|
+
function workerLogging(config) {
|
|
605
|
+
return { kind: 'logging', config };
|
|
606
|
+
}
|
|
607
|
+
function workerRetry(config) {
|
|
608
|
+
return { kind: 'retry', config };
|
|
609
|
+
}
|
|
610
|
+
function workerCache(config) {
|
|
611
|
+
return { kind: 'cache', config };
|
|
612
|
+
}
|
|
613
|
+
function workerHmacSigning(config) {
|
|
614
|
+
return { kind: 'hmac-signing', config };
|
|
615
|
+
}
|
|
616
|
+
function workerRateLimit(config) {
|
|
617
|
+
return { kind: 'rate-limit', config };
|
|
618
|
+
}
|
|
619
|
+
function workerContentIntegrity(config) {
|
|
620
|
+
return { kind: 'content-integrity', config };
|
|
621
|
+
}
|
|
622
|
+
/**
|
|
623
|
+
* Reference a custom interceptor that has been registered on the worker side
|
|
624
|
+
* via `registerInterceptor(name, factory)`.
|
|
625
|
+
*/
|
|
626
|
+
function workerCustom(name, config) {
|
|
627
|
+
return { kind: 'custom', name, config };
|
|
628
|
+
}
|
|
629
|
+
|
|
449
630
|
/**
|
|
450
631
|
* Generated bundle index. Do not edit.
|
|
451
632
|
*/
|
|
452
633
|
|
|
453
|
-
export { cacheInterceptor, composeInterceptors, contentIntegrityInterceptor, createWorkerPipeline, hmacSigningInterceptor, loggingInterceptor, rateLimitInterceptor, retryInterceptor };
|
|
634
|
+
export { cacheInterceptor, composeInterceptors, contentIntegrityInterceptor, createConfigurableWorkerPipeline, createWorkerPipeline, hmacSigningInterceptor, loggingInterceptor, rateLimitInterceptor, registerInterceptor, resolveSpec, retryInterceptor, workerCache, workerContentIntegrity, workerCustom, workerHmacSigning, workerLogging, workerRateLimit, workerRetry };
|
|
@@ -43,6 +43,9 @@ function createWorkerTransport(config) {
|
|
|
43
43
|
function getOrCreateWorker() {
|
|
44
44
|
if (workers.length < maxInstances) {
|
|
45
45
|
const worker = createWorker();
|
|
46
|
+
if (config.initMessage) {
|
|
47
|
+
worker.postMessage(config.initMessage);
|
|
48
|
+
}
|
|
46
49
|
workers.push(worker);
|
|
47
50
|
return worker;
|
|
48
51
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@angular-helpers/worker-http",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Angular HTTP over Web Workers — off-main-thread HTTP pipelines with configurable interceptors, WebCrypto security, and pluggable serialization",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"angular",
|