@angular-helpers/worker-http 0.3.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 +65 -12
- package/fesm2022/angular-helpers-worker-http-backend.mjs +60 -1
- package/fesm2022/angular-helpers-worker-http-interceptors.mjs +375 -194
- package/fesm2022/angular-helpers-worker-http-transport.mjs +3 -0
- package/package.json +1 -1
- package/types/angular-helpers-worker-http-backend.d.ts +52 -3
- package/types/angular-helpers-worker-http-interceptors.d.ts +127 -5
- package/types/angular-helpers-worker-http-transport.d.ts +12 -0
package/README.md
CHANGED
|
@@ -325,8 +325,10 @@ import {
|
|
|
325
325
|
withWorkerRoutes,
|
|
326
326
|
withWorkerFallback,
|
|
327
327
|
withWorkerSerialization,
|
|
328
|
+
withWorkerInterceptors,
|
|
328
329
|
} from '@angular-helpers/worker-http/backend';
|
|
329
330
|
import { createSerovalSerializer } from '@angular-helpers/worker-http/serializer';
|
|
331
|
+
import { workerLogging, workerRetry, workerCache } from '@angular-helpers/worker-http/interceptors';
|
|
330
332
|
|
|
331
333
|
export const appConfig: ApplicationConfig = {
|
|
332
334
|
providers: [
|
|
@@ -348,6 +350,11 @@ export const appConfig: ApplicationConfig = {
|
|
|
348
350
|
]),
|
|
349
351
|
withWorkerFallback('main-thread'), // SSR-safe
|
|
350
352
|
withWorkerSerialization(createSerovalSerializer()), // optional: complex bodies
|
|
353
|
+
withWorkerInterceptors([
|
|
354
|
+
workerLogging(),
|
|
355
|
+
workerRetry({ maxRetries: 3 }),
|
|
356
|
+
workerCache({ ttl: 60000 }),
|
|
357
|
+
]),
|
|
351
358
|
),
|
|
352
359
|
],
|
|
353
360
|
};
|
|
@@ -367,20 +374,23 @@ export class DataService {
|
|
|
367
374
|
}
|
|
368
375
|
|
|
369
376
|
// workers/api.worker.ts — runs on a separate OS thread
|
|
370
|
-
import {
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
]);
|
|
377
|
+
import { createConfigurableWorkerPipeline } from '@angular-helpers/worker-http/interceptors';
|
|
378
|
+
|
|
379
|
+
// The pipeline is built at runtime from the specs configured via
|
|
380
|
+
// `withWorkerInterceptors([...])` in `app.config.ts`. Custom interceptors
|
|
381
|
+
// not covered by the built-in catalogue can be wired in here:
|
|
382
|
+
//
|
|
383
|
+
// import { registerInterceptor } from '@angular-helpers/worker-http/interceptors';
|
|
384
|
+
// registerInterceptor('auth-token', (config) => async (req, next) => { ... });
|
|
385
|
+
//
|
|
386
|
+
// then referenced from the main thread with `workerCustom('auth-token', config)`.
|
|
387
|
+
createConfigurableWorkerPipeline();
|
|
382
388
|
```
|
|
383
389
|
|
|
390
|
+
If you prefer to keep the pipeline composition inside the worker file, use
|
|
391
|
+
`createWorkerPipeline([interceptors])` instead — it stays available for full
|
|
392
|
+
manual control.
|
|
393
|
+
|
|
384
394
|
**Features:**
|
|
385
395
|
|
|
386
396
|
- `provideWorkerHttpClient(...features)` — replaces `provideHttpClient()`; do not use both
|
|
@@ -388,6 +398,7 @@ createWorkerPipeline([
|
|
|
388
398
|
- `withWorkerRoutes(routes)` — URL-pattern routing with priority ordering
|
|
389
399
|
- `withWorkerFallback(strategy)` — `'main-thread'` (SSR-safe) or `'error'`
|
|
390
400
|
- `withWorkerSerialization(serializer)` — plug in `createSerovalSerializer()` for complex request bodies (`Date`, `Map`, `Set`)
|
|
401
|
+
- `withWorkerInterceptors(specs | specsByWorker)` — configure the worker-side pipeline from Angular DI; pairs with `createConfigurableWorkerPipeline()` in the worker file
|
|
391
402
|
- `WORKER_TARGET` — `HttpContextToken<string | null>` for per-request worker routing via `HttpContext`
|
|
392
403
|
- `WorkerHttpClient` — `HttpClient` wrapper with optional `{ worker: string }` routing field
|
|
393
404
|
- `WorkerHttpBackend` — the `HttpBackend` implementation (injectable for advanced use)
|
|
@@ -429,6 +440,48 @@ Server-Side Rendering (SSR) is supported via automatic fallback to the main thre
|
|
|
429
440
|
|
|
430
441
|
---
|
|
431
442
|
|
|
443
|
+
## Benchmarks
|
|
444
|
+
|
|
445
|
+
A reproducible benchmark suite ships with the demo app at
|
|
446
|
+
[`/demo/worker-http-benchmark`](../../src/app/demo/worker-http-benchmark) and compares three
|
|
447
|
+
transport modes across four workloads:
|
|
448
|
+
|
|
449
|
+
| Mode | What it measures |
|
|
450
|
+
| --------------- | ---------------------------------------------------------- |
|
|
451
|
+
| `main-thread` | Baseline — the same simulated work runs on the main thread |
|
|
452
|
+
| `worker-pool-1` | Single worker — measures pure transport overhead |
|
|
453
|
+
| `worker-pool-4` | Four workers — measures the benefit of parallel dispatch |
|
|
454
|
+
|
|
455
|
+
Each scenario simulates identical "server" work (synchronous CPU burn + async delay + payload
|
|
456
|
+
generation), so the only variable being compared is **where** the work runs.
|
|
457
|
+
|
|
458
|
+
**Workloads**:
|
|
459
|
+
|
|
460
|
+
- 100 small sequential requests — pure transport overhead
|
|
461
|
+
- 1 large response (10MB) — serialization / structured clone cost
|
|
462
|
+
- 50 parallel requests — pool benefit
|
|
463
|
+
- 20 parallel requests + 500ms main-thread CPU burn — real-world jank case
|
|
464
|
+
|
|
465
|
+
**Metrics collected**:
|
|
466
|
+
|
|
467
|
+
- **Long Tasks** (`PerformanceObserver` / `longtask`) — count and total duration (Chromium-only)
|
|
468
|
+
- **Dropped frames** (`requestAnimationFrame` deltas > 25 ms) — visible jank proxy, works in every browser
|
|
469
|
+
- **Wall-clock total** for the scenario
|
|
470
|
+
- **Success / failure** counts
|
|
471
|
+
|
|
472
|
+
To run locally:
|
|
473
|
+
|
|
474
|
+
```bash
|
|
475
|
+
npm run build:workers
|
|
476
|
+
npm start
|
|
477
|
+
# open https://localhost:4200/demo/worker-http-benchmark
|
|
478
|
+
```
|
|
479
|
+
|
|
480
|
+
Numbers vary by hardware, browser, and current system load — always run a scenario several times
|
|
481
|
+
and watch the trend, not a single value.
|
|
482
|
+
|
|
483
|
+
---
|
|
484
|
+
|
|
432
485
|
## Related documentation
|
|
433
486
|
|
|
434
487
|
- [Architecture & Feasibility Study](../../docs/sdd-angular-http-web-workers.md)
|
|
@@ -48,6 +48,11 @@ const WORKER_HTTP_FALLBACK_TOKEN = new InjectionToken('WorkerHttpFallback', { fa
|
|
|
48
48
|
* serialized form — add a worker interceptor to deserialize it if needed.
|
|
49
49
|
*/
|
|
50
50
|
const WORKER_HTTP_SERIALIZER_TOKEN = new InjectionToken('WorkerHttpSerializer', { factory: () => null });
|
|
51
|
+
/**
|
|
52
|
+
* Per-worker interceptor specs provided via `withWorkerInterceptors()`.
|
|
53
|
+
* Defaults to an empty map (no interceptors).
|
|
54
|
+
*/
|
|
55
|
+
const WORKER_HTTP_INTERCEPTORS_TOKEN = new InjectionToken('WorkerHttpInterceptors', { factory: () => ({}) });
|
|
51
56
|
|
|
52
57
|
/**
|
|
53
58
|
* Converts an Angular `HttpRequest` into a structured-clone-safe POJO
|
|
@@ -129,6 +134,7 @@ class WorkerHttpBackend extends HttpBackend {
|
|
|
129
134
|
routes = inject(WORKER_HTTP_ROUTES_TOKEN);
|
|
130
135
|
fallback = inject(WORKER_HTTP_FALLBACK_TOKEN);
|
|
131
136
|
serializer = inject(WORKER_HTTP_SERIALIZER_TOKEN);
|
|
137
|
+
interceptorSpecs = inject(WORKER_HTTP_INTERCEPTORS_TOKEN);
|
|
132
138
|
fetchBackend = inject(FetchBackend, { optional: true });
|
|
133
139
|
transports = new Map();
|
|
134
140
|
handle(req) {
|
|
@@ -167,13 +173,24 @@ class WorkerHttpBackend extends HttpBackend {
|
|
|
167
173
|
const existing = this.transports.get(config.id);
|
|
168
174
|
if (existing)
|
|
169
175
|
return existing;
|
|
176
|
+
const specs = this.resolveSpecsFor(config.id);
|
|
170
177
|
const transport = createWorkerTransport({
|
|
171
178
|
workerFactory: () => new Worker(config.workerUrl, { type: 'module' }),
|
|
172
179
|
maxInstances: config.maxInstances ?? 1,
|
|
180
|
+
initMessage: specs.length > 0 ? { type: 'init-interceptors', specs } : undefined,
|
|
173
181
|
});
|
|
174
182
|
this.transports.set(config.id, transport);
|
|
175
183
|
return transport;
|
|
176
184
|
}
|
|
185
|
+
resolveSpecsFor(workerId) {
|
|
186
|
+
const wildcard = this.interceptorSpecs['*'] ?? [];
|
|
187
|
+
const specific = this.interceptorSpecs[workerId] ?? [];
|
|
188
|
+
if (wildcard.length === 0)
|
|
189
|
+
return specific;
|
|
190
|
+
if (specific.length === 0)
|
|
191
|
+
return wildcard;
|
|
192
|
+
return [...wildcard, ...specific];
|
|
193
|
+
}
|
|
177
194
|
handleFallback(req, reason) {
|
|
178
195
|
if (this.fallback === 'error' || !this.fetchBackend) {
|
|
179
196
|
return throwError(() => new Error(`[WorkerHttpBackend] ${reason}`));
|
|
@@ -364,9 +381,51 @@ function withWorkerSerialization(serializer) {
|
|
|
364
381
|
providers: [{ provide: WORKER_HTTP_SERIALIZER_TOKEN, useValue: serializer }],
|
|
365
382
|
};
|
|
366
383
|
}
|
|
384
|
+
/**
|
|
385
|
+
* Configures the worker-side interceptor pipeline from Angular DI.
|
|
386
|
+
*
|
|
387
|
+
* Specs are forwarded to each worker via an `init-interceptors` handshake
|
|
388
|
+
* message posted before any HTTP request. Workers must call
|
|
389
|
+
* `createConfigurableWorkerPipeline()` to receive and act on the handshake.
|
|
390
|
+
*
|
|
391
|
+
* Two shapes are accepted:
|
|
392
|
+
* - `WorkerInterceptorSpec[]` — applied to every registered worker
|
|
393
|
+
* - `Record<workerId, WorkerInterceptorSpec[]>` — per-worker, with the
|
|
394
|
+
* special `'*'` key applied to all workers in addition to the
|
|
395
|
+
* worker-specific specs
|
|
396
|
+
*
|
|
397
|
+
* @example
|
|
398
|
+
* ```typescript
|
|
399
|
+
* provideWorkerHttpClient(
|
|
400
|
+
* withWorkerConfigs([{ id: 'api', workerUrl: ... }]),
|
|
401
|
+
* withWorkerInterceptors([
|
|
402
|
+
* workerLogging(),
|
|
403
|
+
* workerRetry({ maxRetries: 3 }),
|
|
404
|
+
* workerCache({ ttl: 30_000 }),
|
|
405
|
+
* ]),
|
|
406
|
+
* );
|
|
407
|
+
* ```
|
|
408
|
+
*
|
|
409
|
+
* @example Per-worker specs
|
|
410
|
+
* ```typescript
|
|
411
|
+
* withWorkerInterceptors({
|
|
412
|
+
* '*': [workerLogging()],
|
|
413
|
+
* 'secure': [workerHmacSigning({ keyMaterial })],
|
|
414
|
+
* });
|
|
415
|
+
* ```
|
|
416
|
+
*/
|
|
417
|
+
function withWorkerInterceptors(specs) {
|
|
418
|
+
const map = Array.isArray(specs)
|
|
419
|
+
? { '*': [...specs] }
|
|
420
|
+
: specs;
|
|
421
|
+
return {
|
|
422
|
+
kind: 'WorkerInterceptors',
|
|
423
|
+
providers: [{ provide: WORKER_HTTP_INTERCEPTORS_TOKEN, useValue: map }],
|
|
424
|
+
};
|
|
425
|
+
}
|
|
367
426
|
|
|
368
427
|
/**
|
|
369
428
|
* Generated bundle index. Do not edit.
|
|
370
429
|
*/
|
|
371
430
|
|
|
372
|
-
export { WORKER_HTTP_SERIALIZER_TOKEN, WORKER_TARGET, WorkerHttpBackend, WorkerHttpClient, matchWorkerRoute, provideWorkerHttpClient, toHttpResponse, toSerializableRequest, withWorkerConfigs, withWorkerFallback, withWorkerRoutes, withWorkerSerialization };
|
|
431
|
+
export { WORKER_HTTP_INTERCEPTORS_TOKEN, WORKER_HTTP_SERIALIZER_TOKEN, WORKER_TARGET, WorkerHttpBackend, WorkerHttpClient, matchWorkerRoute, provideWorkerHttpClient, toHttpResponse, toSerializableRequest, withWorkerConfigs, withWorkerFallback, withWorkerInterceptors, withWorkerRoutes, withWorkerSerialization };
|
|
@@ -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,6 +199,66 @@ 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
263
|
const body = req.body !== null && req.body !== undefined ? JSON.stringify(req.body) : '';
|
|
232
264
|
return `${req.method}:${req.url}:${body}`;
|
|
@@ -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: {
|
|
@@ -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",
|
|
@@ -2,13 +2,14 @@ import * as i0 from '@angular/core';
|
|
|
2
2
|
import { Provider, InjectionToken, EnvironmentProviders, OnDestroy } from '@angular/core';
|
|
3
3
|
import { HttpContextToken, HttpBackend, HttpRequest, HttpEvent, HttpContext, HttpResponse } from '@angular/common/http';
|
|
4
4
|
import { WorkerSerializer } from '@angular-helpers/worker-http/serializer';
|
|
5
|
+
import { WorkerInterceptorSpec } from '@angular-helpers/worker-http/interceptors';
|
|
5
6
|
import { Observable } from 'rxjs';
|
|
6
7
|
|
|
7
8
|
/**
|
|
8
9
|
* Discriminated union for worker HTTP feature kinds.
|
|
9
10
|
* Mirrors Angular's HttpFeatureKind pattern.
|
|
10
11
|
*/
|
|
11
|
-
type WorkerHttpFeatureKind = 'WorkerConfigs' | 'WorkerRoutes' | 'WorkerFallback' | 'WorkerSerialization';
|
|
12
|
+
type WorkerHttpFeatureKind = 'WorkerConfigs' | 'WorkerRoutes' | 'WorkerFallback' | 'WorkerSerialization' | 'WorkerInterceptors';
|
|
12
13
|
/**
|
|
13
14
|
* Feature object — mirrors Angular's HttpFeature<K> shape.
|
|
14
15
|
*/
|
|
@@ -67,6 +68,13 @@ interface SerializableResponse {
|
|
|
67
68
|
url: string;
|
|
68
69
|
}
|
|
69
70
|
|
|
71
|
+
/**
|
|
72
|
+
* Per-worker interceptor specs map. Key is the worker id from
|
|
73
|
+
* `WorkerConfig.id`, plus the special `'*'` wildcard that applies to every
|
|
74
|
+
* worker. Specs from `'*'` are prepended to the worker-specific specs at
|
|
75
|
+
* resolve time.
|
|
76
|
+
*/
|
|
77
|
+
type WorkerInterceptorSpecsMap = Readonly<Record<string, readonly WorkerInterceptorSpec[]>>;
|
|
70
78
|
/**
|
|
71
79
|
* Per-request HttpContextToken that carries the target worker ID.
|
|
72
80
|
*
|
|
@@ -93,6 +101,11 @@ declare const WORKER_TARGET: HttpContextToken<string>;
|
|
|
93
101
|
* serialized form — add a worker interceptor to deserialize it if needed.
|
|
94
102
|
*/
|
|
95
103
|
declare const WORKER_HTTP_SERIALIZER_TOKEN: InjectionToken<WorkerSerializer>;
|
|
104
|
+
/**
|
|
105
|
+
* Per-worker interceptor specs provided via `withWorkerInterceptors()`.
|
|
106
|
+
* Defaults to an empty map (no interceptors).
|
|
107
|
+
*/
|
|
108
|
+
declare const WORKER_HTTP_INTERCEPTORS_TOKEN: InjectionToken<Readonly<Record<string, readonly WorkerInterceptorSpec[]>>>;
|
|
96
109
|
|
|
97
110
|
/**
|
|
98
111
|
* Sets up the worker HTTP infrastructure and replaces Angular's `HttpBackend`
|
|
@@ -185,6 +198,40 @@ declare function withWorkerFallback(strategy: WorkerFallbackStrategy): WorkerHtt
|
|
|
185
198
|
* ```
|
|
186
199
|
*/
|
|
187
200
|
declare function withWorkerSerialization(serializer: WorkerSerializer): WorkerHttpFeature<'WorkerSerialization'>;
|
|
201
|
+
/**
|
|
202
|
+
* Configures the worker-side interceptor pipeline from Angular DI.
|
|
203
|
+
*
|
|
204
|
+
* Specs are forwarded to each worker via an `init-interceptors` handshake
|
|
205
|
+
* message posted before any HTTP request. Workers must call
|
|
206
|
+
* `createConfigurableWorkerPipeline()` to receive and act on the handshake.
|
|
207
|
+
*
|
|
208
|
+
* Two shapes are accepted:
|
|
209
|
+
* - `WorkerInterceptorSpec[]` — applied to every registered worker
|
|
210
|
+
* - `Record<workerId, WorkerInterceptorSpec[]>` — per-worker, with the
|
|
211
|
+
* special `'*'` key applied to all workers in addition to the
|
|
212
|
+
* worker-specific specs
|
|
213
|
+
*
|
|
214
|
+
* @example
|
|
215
|
+
* ```typescript
|
|
216
|
+
* provideWorkerHttpClient(
|
|
217
|
+
* withWorkerConfigs([{ id: 'api', workerUrl: ... }]),
|
|
218
|
+
* withWorkerInterceptors([
|
|
219
|
+
* workerLogging(),
|
|
220
|
+
* workerRetry({ maxRetries: 3 }),
|
|
221
|
+
* workerCache({ ttl: 30_000 }),
|
|
222
|
+
* ]),
|
|
223
|
+
* );
|
|
224
|
+
* ```
|
|
225
|
+
*
|
|
226
|
+
* @example Per-worker specs
|
|
227
|
+
* ```typescript
|
|
228
|
+
* withWorkerInterceptors({
|
|
229
|
+
* '*': [workerLogging()],
|
|
230
|
+
* 'secure': [workerHmacSigning({ keyMaterial })],
|
|
231
|
+
* });
|
|
232
|
+
* ```
|
|
233
|
+
*/
|
|
234
|
+
declare function withWorkerInterceptors(specs: readonly WorkerInterceptorSpec[] | WorkerInterceptorSpecsMap): WorkerHttpFeature<'WorkerInterceptors'>;
|
|
188
235
|
|
|
189
236
|
/**
|
|
190
237
|
* Angular `HttpBackend` replacement that routes HTTP requests to web workers.
|
|
@@ -203,11 +250,13 @@ declare class WorkerHttpBackend extends HttpBackend implements OnDestroy {
|
|
|
203
250
|
private readonly routes;
|
|
204
251
|
private readonly fallback;
|
|
205
252
|
private readonly serializer;
|
|
253
|
+
private readonly interceptorSpecs;
|
|
206
254
|
private readonly fetchBackend;
|
|
207
255
|
private readonly transports;
|
|
208
256
|
handle(req: HttpRequest<unknown>): Observable<HttpEvent<unknown>>;
|
|
209
257
|
ngOnDestroy(): void;
|
|
210
258
|
private getOrCreateTransport;
|
|
259
|
+
private resolveSpecsFor;
|
|
211
260
|
private handleFallback;
|
|
212
261
|
static ɵfac: i0.ɵɵFactoryDeclaration<WorkerHttpBackend, never>;
|
|
213
262
|
static ɵprov: i0.ɵɵInjectableDeclaration<WorkerHttpBackend>;
|
|
@@ -292,5 +341,5 @@ declare function matchWorkerRoute(url: string, routes: Array<{
|
|
|
292
341
|
priority?: number;
|
|
293
342
|
}>): string | null;
|
|
294
343
|
|
|
295
|
-
export { WORKER_HTTP_SERIALIZER_TOKEN, WORKER_TARGET, WorkerHttpBackend, WorkerHttpClient, matchWorkerRoute, provideWorkerHttpClient, toHttpResponse, toSerializableRequest, withWorkerConfigs, withWorkerFallback, withWorkerRoutes, withWorkerSerialization };
|
|
296
|
-
export type { SerializableRequest, SerializableResponse, WorkerConfig, WorkerFallbackStrategy, WorkerHttpFeature, WorkerHttpFeatureKind, WorkerRequestOptions, WorkerRoute };
|
|
344
|
+
export { WORKER_HTTP_INTERCEPTORS_TOKEN, WORKER_HTTP_SERIALIZER_TOKEN, WORKER_TARGET, WorkerHttpBackend, WorkerHttpClient, matchWorkerRoute, provideWorkerHttpClient, toHttpResponse, toSerializableRequest, withWorkerConfigs, withWorkerFallback, withWorkerInterceptors, withWorkerRoutes, withWorkerSerialization };
|
|
345
|
+
export type { SerializableRequest, SerializableResponse, WorkerConfig, WorkerFallbackStrategy, WorkerHttpFeature, WorkerHttpFeatureKind, WorkerInterceptorSpecsMap, WorkerRequestOptions, WorkerRoute };
|
|
@@ -109,17 +109,120 @@ interface ContentIntegrityConfig {
|
|
|
109
109
|
* runs them through the interceptor chain, executes `fetch()`,
|
|
110
110
|
* and sends the response back.
|
|
111
111
|
*
|
|
112
|
+
* For a runtime-configurable variant whose chain is built from specs sent
|
|
113
|
+
* from the main thread, use `createConfigurableWorkerPipeline()` instead.
|
|
114
|
+
*
|
|
112
115
|
* @example
|
|
113
116
|
* ```typescript
|
|
114
117
|
* // workers/secure.worker.ts
|
|
115
|
-
* import { createWorkerPipeline } from '@angular-helpers/worker-http/interceptors';
|
|
116
|
-
* import { hmacSigningInterceptor } from './my-interceptors';
|
|
118
|
+
* import { createWorkerPipeline, hmacSigningInterceptor } from '@angular-helpers/worker-http/interceptors';
|
|
117
119
|
*
|
|
118
|
-
* createWorkerPipeline([hmacSigningInterceptor]);
|
|
120
|
+
* createWorkerPipeline([hmacSigningInterceptor({ keyMaterial })]);
|
|
119
121
|
* ```
|
|
120
122
|
*/
|
|
121
123
|
declare function createWorkerPipeline(interceptors: WorkerInterceptorFn[]): void;
|
|
122
124
|
|
|
125
|
+
/**
|
|
126
|
+
* Serializable subset of LoggingConfig — `logger` is a function and cannot
|
|
127
|
+
* cross the worker boundary, so it is dropped here. The configurable pipeline
|
|
128
|
+
* uses `console.log` inside the worker for built-in logging.
|
|
129
|
+
*/
|
|
130
|
+
type SerializableLoggingConfig = Omit<LoggingConfig, 'logger'>;
|
|
131
|
+
/**
|
|
132
|
+
* Serializable subset of HmacInterceptorConfig — `payloadBuilder` is a function
|
|
133
|
+
* and cannot cross the worker boundary. The default payload builder is used.
|
|
134
|
+
*
|
|
135
|
+
* `keyMaterial` is `ArrayBuffer | Uint8Array`, both of which ARE supported by
|
|
136
|
+
* the structured clone algorithm.
|
|
137
|
+
*/
|
|
138
|
+
type SerializableHmacConfig = Omit<HmacInterceptorConfig, 'payloadBuilder'>;
|
|
139
|
+
/**
|
|
140
|
+
* Discriminated union of interceptor specifications that can be configured
|
|
141
|
+
* from Angular DI via `withWorkerInterceptors([...])` and forwarded to the
|
|
142
|
+
* worker over `postMessage`.
|
|
143
|
+
*
|
|
144
|
+
* For interceptors with custom function fields (loggers, payload builders),
|
|
145
|
+
* register the interceptor in the worker file via `registerInterceptor()` and
|
|
146
|
+
* reference it here with `kind: 'custom'`.
|
|
147
|
+
*/
|
|
148
|
+
type WorkerInterceptorSpec = {
|
|
149
|
+
readonly kind: 'logging';
|
|
150
|
+
readonly config?: SerializableLoggingConfig;
|
|
151
|
+
} | {
|
|
152
|
+
readonly kind: 'retry';
|
|
153
|
+
readonly config?: RetryConfig;
|
|
154
|
+
} | {
|
|
155
|
+
readonly kind: 'cache';
|
|
156
|
+
readonly config?: CacheConfig;
|
|
157
|
+
} | {
|
|
158
|
+
readonly kind: 'hmac-signing';
|
|
159
|
+
readonly config: SerializableHmacConfig;
|
|
160
|
+
} | {
|
|
161
|
+
readonly kind: 'rate-limit';
|
|
162
|
+
readonly config?: RateLimitConfig;
|
|
163
|
+
} | {
|
|
164
|
+
readonly kind: 'content-integrity';
|
|
165
|
+
readonly config?: ContentIntegrityConfig;
|
|
166
|
+
} | {
|
|
167
|
+
readonly kind: 'custom';
|
|
168
|
+
readonly name: string;
|
|
169
|
+
readonly config?: unknown;
|
|
170
|
+
};
|
|
171
|
+
/**
|
|
172
|
+
* Wire format for the init handshake message sent from the main thread to the
|
|
173
|
+
* worker. Posted exactly once per worker, before any HTTP request.
|
|
174
|
+
*/
|
|
175
|
+
interface WorkerInterceptorInitMessage {
|
|
176
|
+
readonly type: 'init-interceptors';
|
|
177
|
+
readonly specs: readonly WorkerInterceptorSpec[];
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
type CustomFactory = (config?: unknown) => WorkerInterceptorFn;
|
|
181
|
+
/**
|
|
182
|
+
* Registers a custom interceptor factory that can be referenced from
|
|
183
|
+
* `withWorkerInterceptors([workerCustom('my-name', config)])`.
|
|
184
|
+
*
|
|
185
|
+
* Must be called inside the worker file BEFORE `createConfigurableWorkerPipeline()`.
|
|
186
|
+
*
|
|
187
|
+
* @example
|
|
188
|
+
* ```typescript
|
|
189
|
+
* // app.worker.ts
|
|
190
|
+
* import { createConfigurableWorkerPipeline, registerInterceptor } from '@angular-helpers/worker-http/interceptors';
|
|
191
|
+
*
|
|
192
|
+
* registerInterceptor('auth-token', (config: { token: string }) => async (req, next) => {
|
|
193
|
+
* const headers = { ...req.headers, authorization: [`Bearer ${config.token}`] };
|
|
194
|
+
* return next({ ...req, headers });
|
|
195
|
+
* });
|
|
196
|
+
*
|
|
197
|
+
* createConfigurableWorkerPipeline();
|
|
198
|
+
* ```
|
|
199
|
+
*/
|
|
200
|
+
declare function registerInterceptor(name: string, factory: CustomFactory): void;
|
|
201
|
+
/**
|
|
202
|
+
* Resolves a single spec to its concrete `WorkerInterceptorFn`.
|
|
203
|
+
*
|
|
204
|
+
* Exported for test use. Throws on unknown `kind` or unregistered custom name
|
|
205
|
+
* so misconfiguration fails loudly at init time, not on the first request.
|
|
206
|
+
*/
|
|
207
|
+
declare function resolveSpec(spec: WorkerInterceptorSpec): WorkerInterceptorFn;
|
|
208
|
+
/**
|
|
209
|
+
* Creates a worker-side pipeline whose interceptor chain is supplied at
|
|
210
|
+
* runtime via the init handshake message sent by `WorkerHttpBackend`.
|
|
211
|
+
*
|
|
212
|
+
* Behavior:
|
|
213
|
+
* - Listens for the first `init-interceptors` message and builds the chain
|
|
214
|
+
* from the received specs.
|
|
215
|
+
* - Until init arrives, incoming `request` messages are buffered (they will
|
|
216
|
+
* be flushed once the chain is ready).
|
|
217
|
+
* - If no init arrives (e.g. worker used standalone without
|
|
218
|
+
* `withWorkerInterceptors`), the pipeline runs with an empty chain so
|
|
219
|
+
* every request goes straight to `fetch()`.
|
|
220
|
+
*
|
|
221
|
+
* Custom interceptor factories must be registered with
|
|
222
|
+
* `registerInterceptor(name, factory)` before this call.
|
|
223
|
+
*/
|
|
224
|
+
declare function createConfigurableWorkerPipeline(): void;
|
|
225
|
+
|
|
123
226
|
/**
|
|
124
227
|
* Creates a retry interceptor with exponential backoff.
|
|
125
228
|
*
|
|
@@ -240,5 +343,24 @@ declare function contentIntegrityInterceptor(config?: ContentIntegrityConfig): W
|
|
|
240
343
|
*/
|
|
241
344
|
declare function composeInterceptors(...fns: WorkerInterceptorFn[]): WorkerInterceptorFn;
|
|
242
345
|
|
|
243
|
-
|
|
244
|
-
|
|
346
|
+
/**
|
|
347
|
+
* Spec builders — pure factories that return POJO `WorkerInterceptorSpec`
|
|
348
|
+
* objects suitable for `withWorkerInterceptors([...])`.
|
|
349
|
+
*
|
|
350
|
+
* Each builder mirrors the corresponding worker-side interceptor factory but
|
|
351
|
+
* accepts only serializable config (no function fields).
|
|
352
|
+
*/
|
|
353
|
+
declare function workerLogging(config?: SerializableLoggingConfig): WorkerInterceptorSpec;
|
|
354
|
+
declare function workerRetry(config?: RetryConfig): WorkerInterceptorSpec;
|
|
355
|
+
declare function workerCache(config?: CacheConfig): WorkerInterceptorSpec;
|
|
356
|
+
declare function workerHmacSigning(config: SerializableHmacConfig): WorkerInterceptorSpec;
|
|
357
|
+
declare function workerRateLimit(config?: RateLimitConfig): WorkerInterceptorSpec;
|
|
358
|
+
declare function workerContentIntegrity(config?: ContentIntegrityConfig): WorkerInterceptorSpec;
|
|
359
|
+
/**
|
|
360
|
+
* Reference a custom interceptor that has been registered on the worker side
|
|
361
|
+
* via `registerInterceptor(name, factory)`.
|
|
362
|
+
*/
|
|
363
|
+
declare function workerCustom(name: string, config?: unknown): WorkerInterceptorSpec;
|
|
364
|
+
|
|
365
|
+
export { cacheInterceptor, composeInterceptors, contentIntegrityInterceptor, createConfigurableWorkerPipeline, createWorkerPipeline, hmacSigningInterceptor, loggingInterceptor, rateLimitInterceptor, registerInterceptor, resolveSpec, retryInterceptor, workerCache, workerContentIntegrity, workerCustom, workerHmacSigning, workerLogging, workerRateLimit, workerRetry };
|
|
366
|
+
export type { CacheConfig, ContentIntegrityConfig, HmacInterceptorConfig, LoggingConfig, RateLimitConfig, RetryConfig, SerializableHmacConfig, SerializableLoggingConfig, SerializableRequest, SerializableResponse, WorkerInterceptorFn, WorkerInterceptorInitMessage, WorkerInterceptorSpec };
|
|
@@ -38,6 +38,18 @@ interface WorkerTransportConfig {
|
|
|
38
38
|
transferDetection?: 'auto' | 'manual' | 'none';
|
|
39
39
|
/** Timeout in ms for a single request (default: 30000) */
|
|
40
40
|
requestTimeout?: number;
|
|
41
|
+
/**
|
|
42
|
+
* Optional handshake message posted to every worker as soon as it is
|
|
43
|
+
* created, BEFORE any request. Useful to ship runtime configuration
|
|
44
|
+
* (e.g. interceptor specs) that the worker uses to build its pipeline.
|
|
45
|
+
*
|
|
46
|
+
* The shape is opaque to the transport — the worker is responsible for
|
|
47
|
+
* recognising and acting on it.
|
|
48
|
+
*/
|
|
49
|
+
initMessage?: {
|
|
50
|
+
type: string;
|
|
51
|
+
[key: string]: unknown;
|
|
52
|
+
};
|
|
41
53
|
}
|
|
42
54
|
/**
|
|
43
55
|
* Message sent from main thread to worker.
|