@angular-helpers/worker-http 0.3.0 → 0.5.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 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
- createWorkerPipeline,
372
- loggingInterceptor,
373
- retryInterceptor,
374
- cacheInterceptor,
375
- } from '@angular-helpers/worker-http/interceptors';
376
-
377
- createWorkerPipeline([
378
- loggingInterceptor(),
379
- retryInterceptor({ maxRetries: 3 }),
380
- cacheInterceptor({ ttl: 60000 }),
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)
@@ -395,6 +406,61 @@ createWorkerPipeline([
395
406
 
396
407
  ---
397
408
 
409
+ ## SSR + hydration
410
+
411
+ Worker HTTP integrates transparently with Angular SSR. The two problems SSR
412
+ creates for worker-based HTTP are handled out of the box:
413
+
414
+ **1. Workers do not exist on the server.**
415
+ During SSR, `typeof Worker === 'undefined'`. `WorkerHttpBackend` detects this
416
+ and falls back to `FetchBackend` automatically (controlled by
417
+ `withWorkerFallback()`). The request is fulfilled on the server thread
418
+ exactly like a plain `HttpClient.get()` would do.
419
+
420
+ **2. Avoiding a re-fetch after hydration.**
421
+ Add `provideClientHydration()` from `@angular/platform-browser` at the app
422
+ root (standard Angular SSR setup). That enables Angular's HTTP transfer
423
+ cache by default. The transfer cache interceptor sits in the `HttpClient`
424
+ pipeline — **before** `WorkerHttpBackend`:
425
+
426
+ - On the server: `WorkerHttpBackend` falls back to fetch → returns a
427
+ response → the interceptor captures it into `TransferState`.
428
+ - On the browser: a matching request hits the interceptor first → it replays
429
+ the cached response synchronously, **without ever reaching
430
+ `WorkerHttpBackend`**. No worker is booted for hydrated requests.
431
+
432
+ ```ts
433
+ // app.config.server.ts (or your SSR bootstrap)
434
+ export const serverConfig: ApplicationConfig = {
435
+ providers: [
436
+ provideServerRendering(),
437
+ provideWorkerHttpClient(
438
+ withWorkerConfigs([
439
+ { id: 'api', workerUrl: new URL('./workers/api.worker', import.meta.url) },
440
+ ]),
441
+ ),
442
+ ],
443
+ };
444
+
445
+ // app.config.ts (browser bootstrap)
446
+ export const appConfig: ApplicationConfig = {
447
+ providers: [
448
+ provideClientHydration(), // ← enables HTTP transfer cache automatically
449
+ provideWorkerHttpClient(
450
+ withWorkerConfigs([
451
+ { id: 'api', workerUrl: new URL('./workers/api.worker', import.meta.url) },
452
+ ]),
453
+ ),
454
+ ],
455
+ };
456
+ ```
457
+
458
+ To customise which headers are captured or to cache `POST` requests, pass
459
+ `withHttpTransferCacheOptions(...)` to `provideClientHydration()` — both are
460
+ re-exported from `@angular/platform-browser`.
461
+
462
+ ---
463
+
398
464
  ## Design principles
399
465
 
400
466
  - **Zero main-thread cost** — `fetch()` runs entirely in the worker; the main thread only handles the `postMessage` handoff
@@ -429,6 +495,48 @@ Server-Side Rendering (SSR) is supported via automatic fallback to the main thre
429
495
 
430
496
  ---
431
497
 
498
+ ## Benchmarks
499
+
500
+ A reproducible benchmark suite ships with the demo app at
501
+ [`/demo/worker-http-benchmark`](../../src/app/demo/worker-http-benchmark) and compares three
502
+ transport modes across four workloads:
503
+
504
+ | Mode | What it measures |
505
+ | --------------- | ---------------------------------------------------------- |
506
+ | `main-thread` | Baseline — the same simulated work runs on the main thread |
507
+ | `worker-pool-1` | Single worker — measures pure transport overhead |
508
+ | `worker-pool-4` | Four workers — measures the benefit of parallel dispatch |
509
+
510
+ Each scenario simulates identical "server" work (synchronous CPU burn + async delay + payload
511
+ generation), so the only variable being compared is **where** the work runs.
512
+
513
+ **Workloads**:
514
+
515
+ - 100 small sequential requests — pure transport overhead
516
+ - 1 large response (10MB) — serialization / structured clone cost
517
+ - 50 parallel requests — pool benefit
518
+ - 20 parallel requests + 500ms main-thread CPU burn — real-world jank case
519
+
520
+ **Metrics collected**:
521
+
522
+ - **Long Tasks** (`PerformanceObserver` / `longtask`) — count and total duration (Chromium-only)
523
+ - **Dropped frames** (`requestAnimationFrame` deltas > 25 ms) — visible jank proxy, works in every browser
524
+ - **Wall-clock total** for the scenario
525
+ - **Success / failure** counts
526
+
527
+ To run locally:
528
+
529
+ ```bash
530
+ npm run build:workers
531
+ npm start
532
+ # open https://localhost:4200/demo/worker-http-benchmark
533
+ ```
534
+
535
+ Numbers vary by hardware, browser, and current system load — always run a scenario several times
536
+ and watch the trend, not a single value.
537
+
538
+ ---
539
+
432
540
  ## Related documentation
433
541
 
434
542
  - [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
- * 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';
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
- * createWorkerPipeline([hmacSigningInterceptor]);
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 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
- }
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
- 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
- }
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
- return {
72
- status: response.status,
73
- statusText: response.statusText,
74
- headers: responseHeaders,
75
- body,
76
- url: response.url,
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
- function buildChain(fns, finalHandler) {
80
- return fns.reduceRight((next, interceptor) => (req) => interceptor(req, next), finalHandler);
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
- const chain = buildChain(interceptors, executeFetch);
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 === '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
- }
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 a retry interceptor with exponential backoff.
129
+ * Creates and registers a worker-side HTTP pipeline.
131
130
  *
132
- * Retries requests that fail with specific HTTP status codes.
133
- * Respects the `Retry-After` response header when present.
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
- * createWorkerPipeline([
138
- * retryInterceptor({ maxRetries: 3, initialDelay: 500 }),
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 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
- };
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$1(signatureBuffer);
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 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();
391
+ function parseRetryAfterMs(headerValue) {
392
+ const seconds = parseFloat(headerValue);
393
+ if (!isNaN(seconds)) {
394
+ return Math.max(0, seconds * 1000);
369
395
  }
370
- else {
371
- const str = typeof body === 'string' ? body : (JSON.stringify(body) ?? '');
372
- data = new TextEncoder().encode(str).buffer;
396
+ const date = new Date(headerValue).getTime();
397
+ if (!isNaN(date)) {
398
+ return Math.max(0, date - Date.now());
373
399
  }
374
- const hash = await crypto.subtle.digest(algorithm, data);
375
- return arrayBufferToHex(hash);
400
+ return 0;
376
401
  }
377
- function getHeaderValue(response, headerName) {
378
- const lower = headerName.toLowerCase();
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 content integrity interceptor that verifies response body integrity
383
- * against a hash provided in a response header.
406
+ * Creates a retry interceptor with exponential backoff.
384
407
  *
385
- * Uses WebCrypto `SubtleCrypto` (native in web workers).
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
- * contentIntegrityInterceptor({
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 contentIntegrityInterceptor(config) {
399
- const algorithm = config?.algorithm ?? 'SHA-256';
400
- const headerName = config?.headerName ?? 'X-Content-Hash';
401
- const requireHash = config?.requireHash ?? false;
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
- 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 });
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
- const actualHash = await hashBody(response.body, algorithm);
412
- if (actualHash !== expectedHash.toLowerCase()) {
413
- throw Object.assign(new Error('Content integrity check failed'), { status: 0 });
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.0",
3
+ "version": "0.5.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
- export { cacheInterceptor, composeInterceptors, contentIntegrityInterceptor, createWorkerPipeline, hmacSigningInterceptor, loggingInterceptor, rateLimitInterceptor, retryInterceptor };
244
- export type { CacheConfig, ContentIntegrityConfig, HmacInterceptorConfig, LoggingConfig, RateLimitConfig, RetryConfig, SerializableRequest, SerializableResponse, WorkerInterceptorFn };
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.