@angular-helpers/worker-http 0.6.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
 
@@ -285,6 +285,7 @@ class WorkerHttpBackend extends HttpBackend {
285
285
  }
286
286
  catch (telemetryError) {
287
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
288
289
  console.error('[WorkerHttpBackend] telemetry subscriber threw:', telemetryError);
289
290
  }
290
291
  }
@@ -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.6.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",
@@ -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 { };