@angular-helpers/worker-http 0.5.0 → 0.7.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
@@ -67,9 +67,40 @@ transport.terminate();
67
67
  **Features:**
68
68
 
69
69
  - Round-robin pool (`maxInstances`) for parallel request handling
70
- - Request cancellation via `AbortController` in the worker
71
- - Automatic `Transferable` detection for zero-copy `ArrayBuffer` transfer
72
70
  - Lazy worker instantiation — no worker created until first request
71
+ - **Cancellation that actually aborts `fetch()`** — unsubscribing posts a
72
+ cancel message; the worker-side message loop threads an `AbortSignal` all
73
+ the way into `fetch()` so the in-flight HTTP request is truly aborted
74
+ - **Per-request timeout** (default `30_000` ms) via `requestTimeout`; errors
75
+ with `WorkerHttpTimeoutError` and sends a cancel message to the worker.
76
+ Set to `0` to disable.
77
+ - **Opt-in transferable detection** via `transferDetection: 'auto'` — passes
78
+ detected `ArrayBuffer` / `MessagePort` / `ImageBitmap` /
79
+ `OffscreenCanvas` / streams as the transfer list of `postMessage`, enabling
80
+ zero-copy transfer of large buffers. Default is `'none'` to preserve the
81
+ caller's access to the original data after post.
82
+
83
+ ```typescript
84
+ import {
85
+ createWorkerTransport,
86
+ WorkerHttpTimeoutError,
87
+ } from '@angular-helpers/worker-http/transport';
88
+
89
+ const transport = createWorkerTransport({
90
+ workerUrl: new URL('./workers/api.worker', import.meta.url),
91
+ maxInstances: 2,
92
+ requestTimeout: 10_000, // override default 30 s
93
+ transferDetection: 'auto', // zero-copy ArrayBuffer at postMessage
94
+ });
95
+
96
+ transport.execute(request).subscribe({
97
+ error: (err) => {
98
+ if (err instanceof WorkerHttpTimeoutError) {
99
+ // dedicated timeout handling
100
+ }
101
+ },
102
+ });
103
+ ```
73
104
 
74
105
  ---
75
106
 
@@ -406,6 +437,79 @@ manual control.
406
437
 
407
438
  ---
408
439
 
440
+ ## Telemetry
441
+
442
+ Main-thread extension point for APM / metrics. `withTelemetry(...)` registers
443
+ a subscriber that fires synchronously at three lifecycle points of every
444
+ request handled by `WorkerHttpBackend`:
445
+
446
+ - **`onRequest`** — after worker resolution, before dispatch
447
+ - **`onResponse`** — when a successful response is emitted
448
+ - **`onError`** — when the request fails (transport or non-2xx)
449
+
450
+ Events share a `requestId` so you can correlate the three emissions for one
451
+ request. `transport` is `'worker'` or `'fallback-fetch'` (SSR /
452
+ no-route). Errors in your subscriber are caught and logged — they **never**
453
+ affect the actual HTTP request.
454
+
455
+ Call `withTelemetry(...)` multiple times to attach independent subscribers
456
+ (e.g. one for Sentry, one for custom metrics). All subscribers receive every
457
+ event in registration order.
458
+
459
+ ```ts
460
+ provideWorkerHttpClient(
461
+ withWorkerConfigs([{ id: 'api', workerUrl: new URL('./workers/api.worker', import.meta.url) }]),
462
+ withWorkerRoutes([{ pattern: /\/api\//, worker: 'api' }]),
463
+
464
+ // Latency histogram
465
+ withTelemetry({
466
+ onResponse: (e) =>
467
+ histogram.record(e.durationMs, {
468
+ workerId: e.workerId,
469
+ status: e.status,
470
+ transport: e.transport,
471
+ }),
472
+ onError: (e) =>
473
+ histogram.record(e.durationMs, {
474
+ workerId: e.workerId,
475
+ status: 'error',
476
+ }),
477
+ }),
478
+
479
+ // Sentry breadcrumbs
480
+ withTelemetry({
481
+ onRequest: (e) =>
482
+ Sentry.addBreadcrumb({
483
+ category: 'worker-http',
484
+ message: `${e.method} ${e.url}`,
485
+ data: { requestId: e.requestId, workerId: e.workerId },
486
+ }),
487
+ onError: (e) =>
488
+ Sentry.captureException(e.error, {
489
+ tags: { workerId: e.workerId ?? 'fallback' },
490
+ }),
491
+ }),
492
+ );
493
+ ```
494
+
495
+ Event interface:
496
+
497
+ ```ts
498
+ interface WorkerHttpTelemetryEventBase {
499
+ readonly requestId: string; // unique per request, correlates events
500
+ readonly method: string;
501
+ readonly url: string;
502
+ readonly urlWithParams: string;
503
+ readonly workerId: string | null; // null = fallback fetch
504
+ readonly transport: 'worker' | 'fallback-fetch';
505
+ readonly timestamp: number; // performance.now() at emission
506
+ }
507
+ // onResponse adds: kind: 'response', status, durationMs
508
+ // onError adds: kind: 'error', error, durationMs
509
+ ```
510
+
511
+ ---
512
+
409
513
  ## SSR + hydration
410
514
 
411
515
  Worker HTTP integrates transparently with Angular SSR. The two problems SSR
@@ -2,7 +2,7 @@ import * as i0 from '@angular/core';
2
2
  import { InjectionToken, inject, Injectable, makeEnvironmentProviders } from '@angular/core';
3
3
  import { HttpContextToken, HttpHeaders, HttpResponse, HttpBackend, FetchBackend, HttpErrorResponse, HttpClient, HttpContext, provideHttpClient, withFetch } from '@angular/common/http';
4
4
  import { throwError } from 'rxjs';
5
- import { map, catchError } from 'rxjs/operators';
5
+ import { map, tap, catchError } from 'rxjs/operators';
6
6
  import { createWorkerTransport } from '@angular-helpers/worker-http/transport';
7
7
 
8
8
  /**
@@ -53,6 +53,15 @@ const WORKER_HTTP_SERIALIZER_TOKEN = new InjectionToken('WorkerHttpSerializer',
53
53
  * Defaults to an empty map (no interceptors).
54
54
  */
55
55
  const WORKER_HTTP_INTERCEPTORS_TOKEN = new InjectionToken('WorkerHttpInterceptors', { factory: () => ({}) });
56
+ /**
57
+ * Telemetry subscribers registered via `withTelemetry()`.
58
+ *
59
+ * Multi-provider token — each `withTelemetry(...)` call appends one
60
+ * subscriber. All subscribers are invoked on every emission; a throwing
61
+ * subscriber is isolated (the backend swallows the error so it never
62
+ * affects the HTTP request).
63
+ */
64
+ const WORKER_HTTP_TELEMETRY_TOKEN = new InjectionToken('WorkerHttpTelemetry', { factory: () => [] });
56
65
 
57
66
  /**
58
67
  * Converts an Angular `HttpRequest` into a structured-clone-safe POJO
@@ -117,6 +126,16 @@ function matchWorkerRoute(url, routes) {
117
126
  return null;
118
127
  }
119
128
 
129
+ let telemetryRequestCounter = 0;
130
+ function nextTelemetryRequestId() {
131
+ telemetryRequestCounter = (telemetryRequestCounter + 1) >>> 0;
132
+ return `whttp-${telemetryRequestCounter.toString(36)}`;
133
+ }
134
+ function now() {
135
+ return typeof performance !== 'undefined' && typeof performance.now === 'function'
136
+ ? performance.now()
137
+ : Date.now();
138
+ }
120
139
  /**
121
140
  * Angular `HttpBackend` replacement that routes HTTP requests to web workers.
122
141
  *
@@ -136,14 +155,15 @@ class WorkerHttpBackend extends HttpBackend {
136
155
  serializer = inject(WORKER_HTTP_SERIALIZER_TOKEN);
137
156
  interceptorSpecs = inject(WORKER_HTTP_INTERCEPTORS_TOKEN);
138
157
  fetchBackend = inject(FetchBackend, { optional: true });
158
+ telemetry = inject(WORKER_HTTP_TELEMETRY_TOKEN);
139
159
  transports = new Map();
140
160
  handle(req) {
141
161
  if (typeof Worker === 'undefined') {
142
- return this.handleFallback(req, 'Web Workers are not available in this environment (SSR)');
162
+ return this.handleFallback(req, null, 'Web Workers are not available in this environment (SSR)');
143
163
  }
144
164
  const workerId = req.context.get(WORKER_TARGET) ?? matchWorkerRoute(req.url, this.routes);
145
165
  if (!workerId) {
146
- return this.handleFallback(req, `No worker route matched for URL: ${req.url}`);
166
+ return this.handleFallback(req, null, `No worker route matched for URL: ${req.url}`);
147
167
  }
148
168
  const config = this.configs.find((c) => c.id === workerId);
149
169
  if (!config) {
@@ -156,12 +176,22 @@ class WorkerHttpBackend extends HttpBackend {
156
176
  ? this.serializer.serialize(serializable.body).data
157
177
  : serializable.body;
158
178
  const payload = body !== serializable.body ? { ...serializable, body } : serializable;
159
- return transport.execute(payload).pipe(map((res) => toHttpResponse(res, req)), catchError((err) => throwError(() => new HttpErrorResponse({
160
- error: err,
161
- status: 0,
162
- statusText: 'Worker Error',
163
- url: req.urlWithParams,
164
- }))));
179
+ const base = this.buildEventBase(req, workerId, 'worker');
180
+ this.emitRequest(base);
181
+ return transport.execute(payload).pipe(map((res) => toHttpResponse(res, req)), tap((event) => {
182
+ if (event instanceof HttpResponse) {
183
+ this.emitResponse(base, event.status);
184
+ }
185
+ }), catchError((err) => {
186
+ const httpError = new HttpErrorResponse({
187
+ error: err,
188
+ status: 0,
189
+ statusText: 'Worker Error',
190
+ url: req.urlWithParams,
191
+ });
192
+ this.emitError(base, httpError);
193
+ return throwError(() => httpError);
194
+ }));
165
195
  }
166
196
  ngOnDestroy() {
167
197
  for (const transport of this.transports.values()) {
@@ -191,11 +221,74 @@ class WorkerHttpBackend extends HttpBackend {
191
221
  return wildcard;
192
222
  return [...wildcard, ...specific];
193
223
  }
194
- handleFallback(req, reason) {
224
+ handleFallback(req, workerId, reason) {
195
225
  if (this.fallback === 'error' || !this.fetchBackend) {
196
226
  return throwError(() => new Error(`[WorkerHttpBackend] ${reason}`));
197
227
  }
198
- return this.fetchBackend.handle(req);
228
+ const base = this.buildEventBase(req, workerId, 'fallback-fetch');
229
+ this.emitRequest(base);
230
+ return this.fetchBackend.handle(req).pipe(tap((event) => {
231
+ if (event instanceof HttpResponse) {
232
+ this.emitResponse(base, event.status);
233
+ }
234
+ }), catchError((err) => {
235
+ this.emitError(base, err);
236
+ return throwError(() => err);
237
+ }));
238
+ }
239
+ // --- Telemetry ---------------------------------------------------------
240
+ buildEventBase(req, workerId, transport) {
241
+ return {
242
+ requestId: nextTelemetryRequestId(),
243
+ method: req.method,
244
+ url: req.url,
245
+ urlWithParams: req.urlWithParams,
246
+ workerId,
247
+ transport,
248
+ timestamp: now(),
249
+ };
250
+ }
251
+ emitRequest(base) {
252
+ if (this.telemetry.length === 0)
253
+ return;
254
+ const event = { ...base, kind: 'request' };
255
+ this.dispatch((sub) => sub.onRequest?.(event));
256
+ }
257
+ emitResponse(base, status) {
258
+ if (this.telemetry.length === 0)
259
+ return;
260
+ const event = {
261
+ ...base,
262
+ kind: 'response',
263
+ status,
264
+ durationMs: now() - base.timestamp,
265
+ timestamp: now(),
266
+ };
267
+ this.dispatch((sub) => sub.onResponse?.(event));
268
+ }
269
+ emitError(base, error) {
270
+ if (this.telemetry.length === 0)
271
+ return;
272
+ const event = {
273
+ ...base,
274
+ kind: 'error',
275
+ error,
276
+ durationMs: now() - base.timestamp,
277
+ timestamp: now(),
278
+ };
279
+ this.dispatch((sub) => sub.onError?.(event));
280
+ }
281
+ dispatch(invoke) {
282
+ for (const subscriber of this.telemetry) {
283
+ try {
284
+ invoke(subscriber);
285
+ }
286
+ catch (telemetryError) {
287
+ // A throwing telemetry subscriber must never affect the HTTP request.
288
+ // oxlint-disable-next-line no-console -- defensive log when user-provided telemetry throws
289
+ console.error('[WorkerHttpBackend] telemetry subscriber threw:', telemetryError);
290
+ }
291
+ }
199
292
  }
200
293
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.4", ngImport: i0, type: WorkerHttpBackend, deps: null, target: i0.ɵɵFactoryTarget.Injectable });
201
294
  static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.4", ngImport: i0, type: WorkerHttpBackend });
@@ -423,9 +516,51 @@ function withWorkerInterceptors(specs) {
423
516
  providers: [{ provide: WORKER_HTTP_INTERCEPTORS_TOKEN, useValue: map }],
424
517
  };
425
518
  }
519
+ /**
520
+ * Registers a telemetry subscriber for `WorkerHttpBackend`.
521
+ *
522
+ * Telemetry hooks are a main-thread extension point for APM integrations
523
+ * (Sentry, Datadog, OpenTelemetry, ad-hoc logging). They fire synchronously
524
+ * at three lifecycle points of every HTTP request handled by the backend:
525
+ *
526
+ * - `onRequest` — after worker resolution, before dispatch
527
+ * - `onResponse` — when a successful response is emitted
528
+ * - `onError` — when the request fails
529
+ *
530
+ * The feature is repeatable — call `withTelemetry(...)` multiple times to
531
+ * attach independent subscribers. All of them receive every event, in
532
+ * registration order. A throwing subscriber is isolated: the backend
533
+ * catches and logs the error so telemetry bugs never break the request.
534
+ *
535
+ * @example Basic counter
536
+ * ```typescript
537
+ * const counters = { requests: 0, errors: 0 };
538
+ * provideWorkerHttpClient(
539
+ * withWorkerConfigs([...]),
540
+ * withTelemetry({
541
+ * onRequest: () => counters.requests++,
542
+ * onError: () => counters.errors++,
543
+ * }),
544
+ * );
545
+ * ```
546
+ *
547
+ * @example Latency histogram
548
+ * ```typescript
549
+ * withTelemetry({
550
+ * onResponse: (e) => histogram.record(e.durationMs, { workerId: e.workerId }),
551
+ * onError: (e) => histogram.record(e.durationMs, { workerId: e.workerId, error: true }),
552
+ * });
553
+ * ```
554
+ */
555
+ function withTelemetry(telemetry) {
556
+ return {
557
+ kind: 'Telemetry',
558
+ providers: [{ provide: WORKER_HTTP_TELEMETRY_TOKEN, useValue: telemetry, multi: true }],
559
+ };
560
+ }
426
561
 
427
562
  /**
428
563
  * Generated bundle index. Do not edit.
429
564
  */
430
565
 
431
- export { WORKER_HTTP_INTERCEPTORS_TOKEN, WORKER_HTTP_SERIALIZER_TOKEN, WORKER_TARGET, WorkerHttpBackend, WorkerHttpClient, matchWorkerRoute, provideWorkerHttpClient, toHttpResponse, toSerializableRequest, withWorkerConfigs, withWorkerFallback, withWorkerInterceptors, withWorkerRoutes, withWorkerSerialization };
566
+ export { WORKER_HTTP_INTERCEPTORS_TOKEN, WORKER_HTTP_SERIALIZER_TOKEN, WORKER_TARGET, WorkerHttpBackend, WorkerHttpClient, matchWorkerRoute, provideWorkerHttpClient, toHttpResponse, toSerializableRequest, withTelemetry, withWorkerConfigs, withWorkerFallback, withWorkerInterceptors, withWorkerRoutes, withWorkerSerialization };
@@ -80,9 +80,13 @@ async function createAesEncryptor(config) {
80
80
  ? new Uint8Array(config.keyMaterial.buffer.slice(0))
81
81
  : new Uint8Array(config.keyMaterial);
82
82
  const cryptoKey = await crypto.subtle.importKey('raw', keyMaterial, { name: algorithm, length: keyLength }, false, ['encrypt', 'decrypt']);
83
+ // AES-GCM uses a 96-bit (12-byte) IV per NIST SP 800-38D recommendation.
84
+ // AES-CBC and AES-CTR require exactly one block (16 bytes) for their IV /
85
+ // counter respectively; passing 12 bytes causes `OperationError`.
86
+ const ivLength = algorithm === 'AES-GCM' ? 12 : 16;
83
87
  return {
84
88
  async encrypt(data) {
85
- const iv = crypto.getRandomValues(new Uint8Array(12));
89
+ const iv = crypto.getRandomValues(new Uint8Array(ivLength));
86
90
  const params = algorithm === 'AES-GCM'
87
91
  ? { name: algorithm, iv }
88
92
  : algorithm === 'AES-CBC'
@@ -1,9 +1,14 @@
1
1
  /**
2
2
  * Composes interceptor functions around a final handler, producing a single
3
- * `(req) => Promise<resp>` chain. Pure — no side effects.
3
+ * `(req, signal?) => Promise<resp>` chain. Pure — no side effects.
4
+ *
5
+ * The optional `signal` is threaded through interceptor boundaries automatically
6
+ * via a wrapper around `next`, so legacy `WorkerInterceptorFn` implementations
7
+ * (which take only `req, next`) keep working and still propagate cancellation
8
+ * when they invoke `next(req)`.
4
9
  */
5
10
  function buildChain(fns, finalHandler) {
6
- return fns.reduceRight((next, interceptor) => (req) => interceptor(req, next), finalHandler);
11
+ return fns.reduceRight((next, interceptor) => (req, signal) => interceptor(req, (r) => next(r, signal)), finalHandler);
7
12
  }
8
13
  /**
9
14
  * Performs the actual `fetch()` call inside the worker, translating the
@@ -98,7 +103,7 @@ function attachRequestLoop(chain) {
98
103
  const controller = new AbortController();
99
104
  controllers.set(requestId, controller);
100
105
  try {
101
- const response = await chain(payload);
106
+ const response = await chain(payload, controller.signal);
102
107
  self.postMessage({ type: 'response', requestId, result: response });
103
108
  }
104
109
  catch (error) {
@@ -145,7 +150,7 @@ function attachRequestLoop(chain) {
145
150
  * ```
146
151
  */
147
152
  function createWorkerPipeline(interceptors) {
148
- const chain = buildChain(interceptors, (req) => executeFetch(req));
153
+ const chain = buildChain(interceptors, (req, signal) => executeFetch(req, signal));
149
154
  attachRequestLoop(chain);
150
155
  }
151
156
 
@@ -325,7 +330,9 @@ function hmacSigningInterceptor(config) {
325
330
  * ```
326
331
  */
327
332
  function loggingInterceptor(config) {
328
- const logger = config?.logger ?? ((msg, data) => console.log(msg, data));
333
+ const logger = config?.logger ??
334
+ // oxlint-disable-next-line no-console -- default logger of a logging interceptor; consumers inject their own via config.logger
335
+ ((msg, data) => console.log(msg, data));
329
336
  const includeHeaders = config?.includeHeaders ?? false;
330
337
  function safeLog(message, data) {
331
338
  try {
@@ -538,7 +545,7 @@ function createConfigurableWorkerPipeline() {
538
545
  if (data.type === INIT_MESSAGE_TYPE) {
539
546
  const specs = data.specs ?? [];
540
547
  const fns = specs.map((spec) => resolveSpec(spec));
541
- const chain = buildChain(fns, (req) => executeFetch(req));
548
+ const chain = buildChain(fns, (req, signal) => executeFetch(req, signal));
542
549
  // Swap to the regular request loop and replay any buffered messages.
543
550
  attachRequestLoop(chain);
544
551
  const handler = self.onmessage;
@@ -1,14 +1,118 @@
1
1
  import { Observable } from 'rxjs';
2
2
 
3
+ /**
4
+ * Scans a payload one level deep and collects every `Transferable` instance
5
+ * (ArrayBuffer, MessagePort, ImageBitmap, OffscreenCanvas, ReadableStream,
6
+ * WritableStream, TransformStream) found in its own enumerable properties.
7
+ *
8
+ * Used by `createWorkerTransport` when `transferDetection === 'auto'` to build
9
+ * the second argument of `worker.postMessage(data, transfer)` so large buffers
10
+ * move zero-copy instead of being structured-cloned.
11
+ *
12
+ * Design notes:
13
+ * - Only one level deep by design: deep traversal has quadratic cost on heavy
14
+ * graphs and makes the transfer list surprising. Real payloads that care
15
+ * about zero-copy put the buffer at the top level.
16
+ * - Duplicates are filtered — the same buffer referenced twice is transferred
17
+ * only once (required by the structured-clone algorithm).
18
+ * - Returns an empty array for primitives, plain serializable values, or when
19
+ * no transferable is found; `postMessage` accepts `[]` safely.
20
+ */
21
+ function detectTransferables(payload) {
22
+ if (payload === null || payload === undefined)
23
+ return [];
24
+ if (typeof payload !== 'object')
25
+ return [];
26
+ const found = [];
27
+ const seen = new Set();
28
+ const collect = (value) => {
29
+ if (value === null || value === undefined)
30
+ return;
31
+ if (isTransferable(value)) {
32
+ if (!seen.has(value)) {
33
+ seen.add(value);
34
+ found.push(value);
35
+ }
36
+ }
37
+ };
38
+ if (isTransferable(payload)) {
39
+ collect(payload);
40
+ return found;
41
+ }
42
+ if (Array.isArray(payload)) {
43
+ for (const item of payload)
44
+ collect(item);
45
+ return found;
46
+ }
47
+ for (const key of Object.keys(payload)) {
48
+ collect(payload[key]);
49
+ }
50
+ return found;
51
+ }
52
+ function isTransferable(value) {
53
+ if (value === null || value === undefined)
54
+ return false;
55
+ if (typeof value !== 'object')
56
+ return false;
57
+ // ArrayBuffer is the common case; check first.
58
+ if (typeof ArrayBuffer !== 'undefined' && value instanceof ArrayBuffer)
59
+ return true;
60
+ // Typed-array views carry an underlying buffer but are NOT Transferable —
61
+ // only the buffer is. Caller must pass `.buffer` explicitly if they want it.
62
+ if (typeof MessagePort !== 'undefined' && value instanceof MessagePort)
63
+ return true;
64
+ if (typeof ImageBitmap !== 'undefined' && value instanceof ImageBitmap)
65
+ return true;
66
+ if (typeof OffscreenCanvas !== 'undefined' && value instanceof OffscreenCanvas)
67
+ return true;
68
+ if (typeof ReadableStream !== 'undefined' && value instanceof ReadableStream)
69
+ return true;
70
+ if (typeof WritableStream !== 'undefined' && value instanceof WritableStream)
71
+ return true;
72
+ if (typeof TransformStream !== 'undefined' && value instanceof TransformStream)
73
+ return true;
74
+ return false;
75
+ }
76
+
77
+ /**
78
+ * Thrown by `createWorkerTransport` when a request exceeds its configured
79
+ * `requestTimeout`. Consumers can `instanceof`-check this error to distinguish
80
+ * timeout rejections from transport/worker errors.
81
+ *
82
+ * @example
83
+ * ```typescript
84
+ * transport.execute(req).subscribe({
85
+ * error: (err) => {
86
+ * if (err instanceof WorkerHttpTimeoutError) {
87
+ * // dedicated timeout handling
88
+ * }
89
+ * },
90
+ * });
91
+ * ```
92
+ */
93
+ class WorkerHttpTimeoutError extends Error {
94
+ name = 'WorkerHttpTimeoutError';
95
+ timeoutMs;
96
+ constructor(timeoutMs) {
97
+ super(`Worker request timed out after ${timeoutMs} ms`);
98
+ this.timeoutMs = timeoutMs;
99
+ // Maintain a proper prototype chain across TS transpile targets.
100
+ Object.setPrototypeOf(this, new.target.prototype);
101
+ }
102
+ }
103
+
104
+ const DEFAULT_REQUEST_TIMEOUT_MS = 30_000;
3
105
  /**
4
106
  * Creates a typed, Observable-based transport for communicating with a web worker.
5
107
  *
6
108
  * Features:
7
109
  * - Request/response correlation via `requestId`
8
- * - Automatic cancellation on Observable unsubscribe
110
+ * - Cancellation on Observable unsubscribe (also aborts `fetch()` in the worker)
111
+ * - Per-request timeout (default 30 s) rejecting with `WorkerHttpTimeoutError`
9
112
  * - Optional worker pool with round-robin dispatch
10
113
  * - Lazy worker creation (default)
11
- * - Transferable auto-detection for ArrayBuffer payloads
114
+ * - Opt-in transferable detection (`transferDetection: 'auto'`) for zero-copy
115
+ * `ArrayBuffer` / stream payloads
12
116
  *
13
117
  * @example
14
118
  * ```typescript
@@ -28,6 +132,8 @@ function createWorkerTransport(config) {
28
132
  let roundRobinIndex = 0;
29
133
  let terminated = false;
30
134
  const maxInstances = Math.min(config.maxInstances ?? 1, typeof navigator !== 'undefined' ? (navigator.hardwareConcurrency ?? 4) : 1);
135
+ const requestTimeout = config.requestTimeout ?? DEFAULT_REQUEST_TIMEOUT_MS;
136
+ const transferDetection = config.transferDetection ?? 'none';
31
137
  function createWorker() {
32
138
  if (config.workerFactory) {
33
139
  return config.workerFactory();
@@ -62,12 +168,24 @@ function createWorkerTransport(config) {
62
168
  const requestId = crypto.randomUUID();
63
169
  return new Observable((subscriber) => {
64
170
  const worker = getOrCreateWorker();
171
+ let settled = false;
172
+ let timeoutHandle;
173
+ const cleanup = () => {
174
+ worker.removeEventListener('message', messageHandler);
175
+ worker.removeEventListener('error', errorHandler);
176
+ if (timeoutHandle !== undefined) {
177
+ clearTimeout(timeoutHandle);
178
+ timeoutHandle = undefined;
179
+ }
180
+ };
65
181
  const messageHandler = (event) => {
66
182
  const data = event.data;
67
183
  if (data.requestId !== requestId)
68
184
  return;
69
- worker.removeEventListener('message', messageHandler);
70
- worker.removeEventListener('error', errorHandler);
185
+ if (settled)
186
+ return;
187
+ settled = true;
188
+ cleanup();
71
189
  if (data.type === 'error') {
72
190
  const err = data.error;
73
191
  subscriber.error(new Error(err.message));
@@ -78,17 +196,39 @@ function createWorkerTransport(config) {
78
196
  }
79
197
  };
80
198
  const errorHandler = (event) => {
81
- worker.removeEventListener('message', messageHandler);
82
- worker.removeEventListener('error', errorHandler);
199
+ if (settled)
200
+ return;
201
+ settled = true;
202
+ cleanup();
83
203
  subscriber.error(new Error(event.message ?? 'Worker error'));
84
204
  };
85
205
  worker.addEventListener('message', messageHandler);
86
206
  worker.addEventListener('error', errorHandler);
87
- worker.postMessage({ type: 'request', requestId, payload: request });
207
+ if (transferDetection === 'auto') {
208
+ const transferables = detectTransferables(request);
209
+ worker.postMessage({ type: 'request', requestId, payload: request }, transferables);
210
+ }
211
+ else {
212
+ worker.postMessage({ type: 'request', requestId, payload: request });
213
+ }
214
+ if (requestTimeout > 0 && Number.isFinite(requestTimeout)) {
215
+ timeoutHandle = setTimeout(() => {
216
+ if (settled)
217
+ return;
218
+ settled = true;
219
+ cleanup();
220
+ // Ask the worker to abort any in-flight work for this id. The
221
+ // cancellation fix wires this through to `fetch()`.
222
+ worker.postMessage({ type: 'cancel', requestId });
223
+ subscriber.error(new WorkerHttpTimeoutError(requestTimeout));
224
+ }, requestTimeout);
225
+ }
88
226
  // Teardown: send cancel message on unsubscribe
89
227
  return () => {
90
- worker.removeEventListener('message', messageHandler);
91
- worker.removeEventListener('error', errorHandler);
228
+ if (settled)
229
+ return;
230
+ settled = true;
231
+ cleanup();
92
232
  worker.postMessage({ type: 'cancel', requestId });
93
233
  };
94
234
  });
@@ -116,4 +256,4 @@ function createWorkerTransport(config) {
116
256
  * Generated bundle index. Do not edit.
117
257
  */
118
258
 
119
- export { createWorkerTransport };
259
+ export { WorkerHttpTimeoutError, createWorkerTransport, detectTransferables };
@@ -1,20 +1,3 @@
1
- /**
2
- * @angular-helpers/worker-http
3
- *
4
- * Angular HTTP over Web Workers — off-main-thread HTTP pipelines
5
- * with configurable interceptors, WebCrypto security, and pluggable serialization.
6
- *
7
- * Sub-entry points:
8
- * - @angular-helpers/worker-http/transport (P1: typed RPC bridge)
9
- * - @angular-helpers/worker-http/serializer (P2: TOON, seroval, auto-detect)
10
- * - @angular-helpers/worker-http/backend (P3: Angular HttpBackend replacement)
11
- * - @angular-helpers/worker-http/interceptors (P4: pure-fn interceptors for workers)
12
- * - @angular-helpers/worker-http/crypto (P5: WebCrypto primitives)
13
- */
14
- const WORKER_HTTP_VERSION = '0.0.1';
15
-
16
1
  /**
17
2
  * Generated bundle index. Do not edit.
18
3
  */
19
-
20
- export { WORKER_HTTP_VERSION };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@angular-helpers/worker-http",
3
- "version": "0.5.0",
3
+ "version": "0.7.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",
@@ -9,7 +9,7 @@ import { Observable } from 'rxjs';
9
9
  * Discriminated union for worker HTTP feature kinds.
10
10
  * Mirrors Angular's HttpFeatureKind pattern.
11
11
  */
12
- type WorkerHttpFeatureKind = 'WorkerConfigs' | 'WorkerRoutes' | 'WorkerFallback' | 'WorkerSerialization' | 'WorkerInterceptors';
12
+ type WorkerHttpFeatureKind = 'WorkerConfigs' | 'WorkerRoutes' | 'WorkerFallback' | 'WorkerSerialization' | 'WorkerInterceptors' | 'Telemetry';
13
13
  /**
14
14
  * Feature object — mirrors Angular's HttpFeature<K> shape.
15
15
  */
@@ -68,6 +68,82 @@ interface SerializableResponse {
68
68
  url: string;
69
69
  }
70
70
 
71
+ /**
72
+ * Telemetry hooks for `WorkerHttpBackend`.
73
+ *
74
+ * Extension point for APM integrations (Sentry, Datadog, OpenTelemetry,
75
+ * ad-hoc metrics). Fires on the main thread, synchronously, at three
76
+ * lifecycle points of every HTTP request that reaches
77
+ * `WorkerHttpBackend`:
78
+ *
79
+ * - `onRequest` — after worker resolution / fallback decision, BEFORE
80
+ * dispatch.
81
+ * - `onResponse` — when a successful response is returned from the worker
82
+ * (or the fallback `FetchBackend`).
83
+ * - `onError` — when the request fails in transport or surfaces as a
84
+ * non-2xx `HttpErrorResponse`.
85
+ *
86
+ * Subscribers are invoked inside a try/catch — a throwing or misbehaving
87
+ * subscriber is isolated from the HTTP request and from every other
88
+ * subscriber. Telemetry errors are logged via `console.error` and
89
+ * swallowed.
90
+ *
91
+ * Multiple subscribers are supported via a multi-provider DI token.
92
+ * Register with `withTelemetry(...)`; every call appends one subscriber.
93
+ */
94
+ /**
95
+ * How a request was dispatched.
96
+ *
97
+ * - `'worker'` — dispatched to a worker from the pool.
98
+ * - `'fallback-fetch'` — dispatched to the main-thread `FetchBackend`
99
+ * (SSR context, no matching route, unknown worker with `'main-thread'`
100
+ * fallback strategy).
101
+ */
102
+ type WorkerHttpTransportKind = 'worker' | 'fallback-fetch';
103
+ /**
104
+ * Base shape shared by every telemetry event.
105
+ */
106
+ interface WorkerHttpTelemetryEventBase {
107
+ /** Stable id unique to this request within the process. Correlates all three events. */
108
+ readonly requestId: string;
109
+ /** HTTP method (`'GET'`, `'POST'`, ...). */
110
+ readonly method: string;
111
+ /** URL without query params, as Angular sees it. */
112
+ readonly url: string;
113
+ /** URL with query params baked in. */
114
+ readonly urlWithParams: string;
115
+ /** Worker id that served the request. `null` when routed to fallback fetch. */
116
+ readonly workerId: string | null;
117
+ /** How the request was actually dispatched. */
118
+ readonly transport: WorkerHttpTransportKind;
119
+ /** `performance.now()` value at emission time. */
120
+ readonly timestamp: number;
121
+ }
122
+ /** Fires before the request is dispatched. */
123
+ interface WorkerHttpRequestEvent extends WorkerHttpTelemetryEventBase {
124
+ readonly kind: 'request';
125
+ }
126
+ /** Fires when a successful response is returned. */
127
+ interface WorkerHttpResponseEvent extends WorkerHttpTelemetryEventBase {
128
+ readonly kind: 'response';
129
+ readonly status: number;
130
+ readonly durationMs: number;
131
+ }
132
+ /** Fires when the request fails. */
133
+ interface WorkerHttpErrorEvent extends WorkerHttpTelemetryEventBase {
134
+ readonly kind: 'error';
135
+ readonly error: unknown;
136
+ readonly durationMs: number;
137
+ }
138
+ /**
139
+ * Telemetry subscriber — all callbacks are optional.
140
+ */
141
+ interface WorkerHttpTelemetry {
142
+ onRequest?(event: WorkerHttpRequestEvent): void;
143
+ onResponse?(event: WorkerHttpResponseEvent): void;
144
+ onError?(event: WorkerHttpErrorEvent): void;
145
+ }
146
+
71
147
  /**
72
148
  * Per-worker interceptor specs map. Key is the worker id from
73
149
  * `WorkerConfig.id`, plus the special `'*'` wildcard that applies to every
@@ -232,6 +308,43 @@ declare function withWorkerSerialization(serializer: WorkerSerializer): WorkerHt
232
308
  * ```
233
309
  */
234
310
  declare function withWorkerInterceptors(specs: readonly WorkerInterceptorSpec[] | WorkerInterceptorSpecsMap): WorkerHttpFeature<'WorkerInterceptors'>;
311
+ /**
312
+ * Registers a telemetry subscriber for `WorkerHttpBackend`.
313
+ *
314
+ * Telemetry hooks are a main-thread extension point for APM integrations
315
+ * (Sentry, Datadog, OpenTelemetry, ad-hoc logging). They fire synchronously
316
+ * at three lifecycle points of every HTTP request handled by the backend:
317
+ *
318
+ * - `onRequest` — after worker resolution, before dispatch
319
+ * - `onResponse` — when a successful response is emitted
320
+ * - `onError` — when the request fails
321
+ *
322
+ * The feature is repeatable — call `withTelemetry(...)` multiple times to
323
+ * attach independent subscribers. All of them receive every event, in
324
+ * registration order. A throwing subscriber is isolated: the backend
325
+ * catches and logs the error so telemetry bugs never break the request.
326
+ *
327
+ * @example Basic counter
328
+ * ```typescript
329
+ * const counters = { requests: 0, errors: 0 };
330
+ * provideWorkerHttpClient(
331
+ * withWorkerConfigs([...]),
332
+ * withTelemetry({
333
+ * onRequest: () => counters.requests++,
334
+ * onError: () => counters.errors++,
335
+ * }),
336
+ * );
337
+ * ```
338
+ *
339
+ * @example Latency histogram
340
+ * ```typescript
341
+ * withTelemetry({
342
+ * onResponse: (e) => histogram.record(e.durationMs, { workerId: e.workerId }),
343
+ * onError: (e) => histogram.record(e.durationMs, { workerId: e.workerId, error: true }),
344
+ * });
345
+ * ```
346
+ */
347
+ declare function withTelemetry(telemetry: WorkerHttpTelemetry): WorkerHttpFeature<'Telemetry'>;
235
348
 
236
349
  /**
237
350
  * Angular `HttpBackend` replacement that routes HTTP requests to web workers.
@@ -252,12 +365,18 @@ declare class WorkerHttpBackend extends HttpBackend implements OnDestroy {
252
365
  private readonly serializer;
253
366
  private readonly interceptorSpecs;
254
367
  private readonly fetchBackend;
368
+ private readonly telemetry;
255
369
  private readonly transports;
256
370
  handle(req: HttpRequest<unknown>): Observable<HttpEvent<unknown>>;
257
371
  ngOnDestroy(): void;
258
372
  private getOrCreateTransport;
259
373
  private resolveSpecsFor;
260
374
  private handleFallback;
375
+ private buildEventBase;
376
+ private emitRequest;
377
+ private emitResponse;
378
+ private emitError;
379
+ private dispatch;
261
380
  static ɵfac: i0.ɵɵFactoryDeclaration<WorkerHttpBackend, never>;
262
381
  static ɵprov: i0.ɵɵInjectableDeclaration<WorkerHttpBackend>;
263
382
  }
@@ -341,5 +460,5 @@ declare function matchWorkerRoute(url: string, routes: Array<{
341
460
  priority?: number;
342
461
  }>): string | null;
343
462
 
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 };
463
+ export { WORKER_HTTP_INTERCEPTORS_TOKEN, WORKER_HTTP_SERIALIZER_TOKEN, WORKER_TARGET, WorkerHttpBackend, WorkerHttpClient, matchWorkerRoute, provideWorkerHttpClient, toHttpResponse, toSerializableRequest, withTelemetry, withWorkerConfigs, withWorkerFallback, withWorkerInterceptors, withWorkerRoutes, withWorkerSerialization };
464
+ export type { SerializableRequest, SerializableResponse, WorkerConfig, WorkerFallbackStrategy, WorkerHttpErrorEvent, WorkerHttpFeature, WorkerHttpFeatureKind, WorkerHttpRequestEvent, WorkerHttpResponseEvent, WorkerHttpTelemetry, WorkerHttpTelemetryEventBase, WorkerHttpTransportKind, WorkerInterceptorSpecsMap, WorkerRequestOptions, WorkerRoute };
@@ -34,9 +34,27 @@ interface WorkerTransportConfig {
34
34
  workerUrl?: string | URL;
35
35
  /** Maximum number of worker instances in the pool (default: 1) */
36
36
  maxInstances?: number;
37
- /** Transfer strategy for large payloads */
37
+ /**
38
+ * Transfer strategy for `postMessage` payloads.
39
+ *
40
+ * - `'none'` (default) — payloads are always structured-cloned, preserving
41
+ * the caller's access to the original data after post.
42
+ * - `'auto'` — shallowly walks the payload and passes every detected
43
+ * `Transferable` (ArrayBuffer, MessagePort, ImageBitmap, OffscreenCanvas,
44
+ * ReadableStream, WritableStream, TransformStream) in the transfer list
45
+ * of `postMessage`. Large buffers move zero-copy; their `byteLength`
46
+ * becomes `0` in the main thread after post.
47
+ *
48
+ * The `'manual'` value is reserved for a future API where callers supply
49
+ * their own transfer list per request. It currently behaves like `'none'`.
50
+ */
38
51
  transferDetection?: 'auto' | 'manual' | 'none';
39
- /** Timeout in ms for a single request (default: 30000) */
52
+ /**
53
+ * Per-request timeout in milliseconds. If the worker does not respond
54
+ * within this window, `execute()` errors with `WorkerHttpTimeoutError`
55
+ * and a cancel message is posted to the worker. Set to `0` or
56
+ * non-finite to disable the timeout entirely. Default: `30000` (30 s).
57
+ */
40
58
  requestTimeout?: number;
41
59
  /**
42
60
  * Optional handshake message posted to every worker as soon as it is
@@ -103,10 +121,12 @@ interface WorkerTransport<TRequest = unknown, TResponse = unknown> {
103
121
  *
104
122
  * Features:
105
123
  * - Request/response correlation via `requestId`
106
- * - Automatic cancellation on Observable unsubscribe
124
+ * - Cancellation on Observable unsubscribe (also aborts `fetch()` in the worker)
125
+ * - Per-request timeout (default 30 s) rejecting with `WorkerHttpTimeoutError`
107
126
  * - Optional worker pool with round-robin dispatch
108
127
  * - Lazy worker creation (default)
109
- * - Transferable auto-detection for ArrayBuffer payloads
128
+ * - Opt-in transferable detection (`transferDetection: 'auto'`) for zero-copy
129
+ * `ArrayBuffer` / stream payloads
110
130
  *
111
131
  * @example
112
132
  * ```typescript
@@ -123,5 +143,47 @@ interface WorkerTransport<TRequest = unknown, TResponse = unknown> {
123
143
  */
124
144
  declare function createWorkerTransport<TRequest = unknown, TResponse = unknown>(config: WorkerTransportConfig): WorkerTransport<TRequest, TResponse>;
125
145
 
126
- export { createWorkerTransport };
146
+ /**
147
+ * Thrown by `createWorkerTransport` when a request exceeds its configured
148
+ * `requestTimeout`. Consumers can `instanceof`-check this error to distinguish
149
+ * timeout rejections from transport/worker errors.
150
+ *
151
+ * @example
152
+ * ```typescript
153
+ * transport.execute(req).subscribe({
154
+ * error: (err) => {
155
+ * if (err instanceof WorkerHttpTimeoutError) {
156
+ * // dedicated timeout handling
157
+ * }
158
+ * },
159
+ * });
160
+ * ```
161
+ */
162
+ declare class WorkerHttpTimeoutError extends Error {
163
+ readonly name = "WorkerHttpTimeoutError";
164
+ readonly timeoutMs: number;
165
+ constructor(timeoutMs: number);
166
+ }
167
+
168
+ /**
169
+ * Scans a payload one level deep and collects every `Transferable` instance
170
+ * (ArrayBuffer, MessagePort, ImageBitmap, OffscreenCanvas, ReadableStream,
171
+ * WritableStream, TransformStream) found in its own enumerable properties.
172
+ *
173
+ * Used by `createWorkerTransport` when `transferDetection === 'auto'` to build
174
+ * the second argument of `worker.postMessage(data, transfer)` so large buffers
175
+ * move zero-copy instead of being structured-cloned.
176
+ *
177
+ * Design notes:
178
+ * - Only one level deep by design: deep traversal has quadratic cost on heavy
179
+ * graphs and makes the transfer list surprising. Real payloads that care
180
+ * about zero-copy put the buffer at the top level.
181
+ * - Duplicates are filtered — the same buffer referenced twice is transferred
182
+ * only once (required by the structured-clone algorithm).
183
+ * - Returns an empty array for primitives, plain serializable values, or when
184
+ * no transferable is found; `postMessage` accepts `[]` safely.
185
+ */
186
+ declare function detectTransferables(payload: unknown): Transferable[];
187
+
188
+ export { WorkerHttpTimeoutError, createWorkerTransport, detectTransferables };
127
189
  export type { WorkerErrorResponse, WorkerMessage, WorkerResponse, WorkerTransport, WorkerTransportConfig };
@@ -1,16 +1,2 @@
1
- /**
2
- * @angular-helpers/worker-http
3
- *
4
- * Angular HTTP over Web Workers — off-main-thread HTTP pipelines
5
- * with configurable interceptors, WebCrypto security, and pluggable serialization.
6
- *
7
- * Sub-entry points:
8
- * - @angular-helpers/worker-http/transport (P1: typed RPC bridge)
9
- * - @angular-helpers/worker-http/serializer (P2: TOON, seroval, auto-detect)
10
- * - @angular-helpers/worker-http/backend (P3: Angular HttpBackend replacement)
11
- * - @angular-helpers/worker-http/interceptors (P4: pure-fn interceptors for workers)
12
- * - @angular-helpers/worker-http/crypto (P5: WebCrypto primitives)
13
- */
14
- declare const WORKER_HTTP_VERSION = "0.0.1";
15
1
 
16
- export { WORKER_HTTP_VERSION };
2
+ export { };