@angular-helpers/worker-http 21.2.0 → 21.2.3

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
@@ -75,7 +75,7 @@ ng add @angular-helpers/worker-http --installEsbuildPlugin=true
75
75
  ### Manual installation
76
76
 
77
77
  ```bash
78
- npm install @angular-helpers/worker-http
78
+ pnpm add @angular-helpers/worker-http
79
79
  ```
80
80
 
81
81
  Then follow the setup in the `/backend` section below.
@@ -110,6 +110,7 @@ transport.terminate();
110
110
 
111
111
  - Round-robin pool (`maxInstances`) for parallel request handling
112
112
  - Lazy worker instantiation — no worker created until first request
113
+ - **Support for SharedWorker** — set `mode: 'shared'` to share worker instances across multiple tabs, reducing memory usage and sharing interceptor state (like cache).
113
114
  - **Cancellation that actually aborts `fetch()`** — unsubscribing posts a
114
115
  cancel message; the worker-side message loop threads an `AbortSignal` all
115
116
  the way into `fetch()` so the in-flight HTTP request is truly aborted
@@ -319,7 +320,7 @@ import { structuredCloneSerializer } from '@angular-helpers/worker-http/serializ
319
320
 
320
321
  #### `createSerovalSerializer()` — Full type fidelity
321
322
 
322
- Requires `seroval` as an optional peer dependency (`npm install seroval`).
323
+ Requires `seroval` as an optional peer dependency (`pnpm add seroval`).
323
324
 
324
325
  Supports: `Date`, `Map`, `Set`, `BigInt`, `RegExp`, circular references, and more.
325
326
 
@@ -337,7 +338,7 @@ const original = serializer.deserialize(payload);
337
338
 
338
339
  #### `createToonSerializer()` — Token-Oriented Object Notation
339
340
 
340
- Requires `@toon-format/toon` as an optional peer dependency (`npm install @toon-format/toon`).
341
+ Requires `@toon-format/toon` as an optional peer dependency (`pnpm add @toon-format/toon`).
341
342
 
342
343
  [TOON](https://toonformat.dev) declares object keys once and emits values as CSV-like rows. For uniform arrays of objects (the most common API response shape — `User[]`, `Product[]`, paginated lists), it cuts payload size by **30–60%** compared to JSON, with negligible parsing overhead.
343
344
 
@@ -820,8 +821,8 @@ generation), so the only variable being compared is **where** the work runs.
820
821
  To run locally:
821
822
 
822
823
  ```bash
823
- npm run build:workers
824
- npm start
824
+ pnpm run build:workers
825
+ pnpm start
825
826
  # open https://localhost:4200/demo/worker-http-benchmark
826
827
  ```
827
828
 
@@ -245,7 +245,17 @@ class WorkerHttpBackend extends HttpBackend {
245
245
  return existing;
246
246
  const specs = this.resolveSpecsFor(config.id);
247
247
  const transport = createWorkerTransport({
248
- workerFactory: () => new Worker(config.workerUrl, { type: 'module' }),
248
+ workerFactory: () => {
249
+ if (config.mode === 'shared') {
250
+ return new SharedWorker(config.workerUrl, {
251
+ type: 'module',
252
+ name: config.name ?? config.id,
253
+ });
254
+ }
255
+ return new Worker(config.workerUrl, { type: 'module' });
256
+ },
257
+ mode: config.mode,
258
+ sharedWorkerName: config.name ?? config.id,
249
259
  maxInstances: config.maxInstances ?? 1,
250
260
  initMessage: specs.length > 0 ? { type: 'init-interceptors', specs } : undefined,
251
261
  streamsPolyfill: this.streamsPolyfill,
@@ -331,10 +341,10 @@ class WorkerHttpBackend extends HttpBackend {
331
341
  }
332
342
  }
333
343
  }
334
- static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.4", ngImport: i0, type: WorkerHttpBackend, deps: null, target: i0.ɵɵFactoryTarget.Injectable });
335
- static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.4", ngImport: i0, type: WorkerHttpBackend });
344
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.13", ngImport: i0, type: WorkerHttpBackend, deps: null, target: i0.ɵɵFactoryTarget.Injectable });
345
+ static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.13", ngImport: i0, type: WorkerHttpBackend });
336
346
  }
337
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.4", ngImport: i0, type: WorkerHttpBackend, decorators: [{
347
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.13", ngImport: i0, type: WorkerHttpBackend, decorators: [{
338
348
  type: Injectable
339
349
  }] });
340
350
 
@@ -392,10 +402,10 @@ class WorkerHttpClient {
392
402
  }
393
403
  return { ...rest, context: ctx };
394
404
  }
395
- static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.4", ngImport: i0, type: WorkerHttpClient, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
396
- static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.4", ngImport: i0, type: WorkerHttpClient });
405
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.13", ngImport: i0, type: WorkerHttpClient, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
406
+ static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.13", ngImport: i0, type: WorkerHttpClient });
397
407
  }
398
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.4", ngImport: i0, type: WorkerHttpClient, decorators: [{
408
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.13", ngImport: i0, type: WorkerHttpClient, decorators: [{
399
409
  type: Injectable
400
410
  }] });
401
411
 
@@ -78,20 +78,27 @@ async function executeFetch(req, signal) {
78
78
  }
79
79
 
80
80
  /**
81
- * Wires up the worker's `self.onmessage` handler around a built request chain.
82
- *
83
- * Owns the per-request `AbortController` map for cancellation support. Posts
84
- * `response` / `error` messages back to the main thread.
85
- *
86
- * Returns a disposer that restores `self.onmessage` to whatever it was before
87
- * (mainly useful for the configurable pipeline, which swaps the handler when
88
- * receiving the init message).
81
+ * Common loop logic for handling requests/cancellation on a port.
89
82
  */
90
- function attachRequestLoop(chain) {
83
+ function attachPortLoop(port, chain) {
91
84
  const controllers = new Map();
92
- const previous = self.onmessage;
93
- self.onmessage = async (event) => {
94
- const { type, requestId, payload } = event.data ?? {};
85
+ let responseBuffer = [];
86
+ let flushScheduled = false;
87
+ function scheduleFlush() {
88
+ if (flushScheduled)
89
+ return;
90
+ flushScheduled = true;
91
+ queueMicrotask(() => {
92
+ flushScheduled = false;
93
+ if (!responseBuffer.length)
94
+ return;
95
+ const responses = responseBuffer;
96
+ responseBuffer = [];
97
+ port.postMessage({ type: 'batch-response', responses });
98
+ });
99
+ }
100
+ const processMessage = async (msg) => {
101
+ const { type, requestId, payload } = msg;
95
102
  if (type === 'cancel') {
96
103
  controllers.get(requestId)?.abort();
97
104
  controllers.delete(requestId);
@@ -104,25 +111,41 @@ function attachRequestLoop(chain) {
104
111
  controllers.set(requestId, controller);
105
112
  try {
106
113
  const response = await chain(payload, controller.signal);
107
- self.postMessage({ type: 'response', requestId, result: response });
114
+ responseBuffer.push({ type: 'response', requestId, result: response });
115
+ scheduleFlush();
108
116
  }
109
117
  catch (error) {
110
- self.postMessage({
118
+ responseBuffer.push({
111
119
  type: 'error',
112
120
  requestId,
113
121
  error: {
114
- message: error instanceof Error ? error.message : String(error),
115
- name: error instanceof Error ? error.name : 'UnknownError',
116
- stack: error instanceof Error ? error.stack : undefined,
122
+ message: error?.message ?? String(error),
123
+ name: error?.name ?? 'UnknownError',
124
+ stack: error?.stack,
117
125
  },
118
126
  });
127
+ scheduleFlush();
119
128
  }
120
129
  finally {
121
130
  controllers.delete(requestId);
122
131
  }
123
132
  };
133
+ const messageHandler = (event) => {
134
+ const data = event.data ?? {};
135
+ if (data.type === 'batch') {
136
+ for (const msg of data.messages || []) {
137
+ // oxlint-disable-next-line no-console
138
+ processMessage(msg).catch(console.error);
139
+ }
140
+ }
141
+ else {
142
+ // oxlint-disable-next-line no-console
143
+ processMessage(data).catch(console.error);
144
+ }
145
+ };
146
+ port.addEventListener('message', messageHandler);
124
147
  return () => {
125
- self.onmessage = previous;
148
+ port.removeEventListener('message', messageHandler);
126
149
  for (const controller of controllers.values()) {
127
150
  controller.abort();
128
151
  }
@@ -130,6 +153,36 @@ function attachRequestLoop(chain) {
130
153
  };
131
154
  }
132
155
 
156
+ /**
157
+ * Wires up the worker's request handler around a built request chain.
158
+ *
159
+ * Automatically detects if running in a Dedicated Worker or Shared Worker context
160
+ * and attaches the appropriate listeners.
161
+ */
162
+ function attachRequestLoop(chain) {
163
+ const disposers = [];
164
+ // Shared Worker context
165
+ if ('onconnect' in self) {
166
+ const connectHandler = (event) => {
167
+ const port = event.ports[0];
168
+ disposers.push(attachPortLoop(port, chain));
169
+ port.start();
170
+ };
171
+ self.addEventListener('connect', connectHandler);
172
+ disposers.push(() => self.removeEventListener('connect', connectHandler));
173
+ }
174
+ else {
175
+ // Dedicated Worker context
176
+ disposers.push(attachPortLoop(self, chain));
177
+ }
178
+ return () => {
179
+ for (const dispose of disposers) {
180
+ dispose();
181
+ }
182
+ disposers.length = 0;
183
+ };
184
+ }
185
+
133
186
  /**
134
187
  * Creates and registers a worker-side HTTP pipeline.
135
188
  *
@@ -638,4 +691,4 @@ function workerCustom(name, config) {
638
691
  * Generated bundle index. Do not edit.
639
692
  */
640
693
 
641
- export { cacheInterceptor, composeInterceptors, contentIntegrityInterceptor, createConfigurableWorkerPipeline, createWorkerPipeline, hmacSigningInterceptor, loggingInterceptor, rateLimitInterceptor, registerInterceptor, resolveSpec, retryInterceptor, workerCache, workerContentIntegrity, workerCustom, workerHmacSigning, workerLogging, workerRateLimit, workerRetry };
694
+ export { attachPortLoop, attachRequestLoop, cacheInterceptor, composeInterceptors, contentIntegrityInterceptor, createConfigurableWorkerPipeline, createWorkerPipeline, hmacSigningInterceptor, loggingInterceptor, rateLimitInterceptor, registerInterceptor, resolveSpec, retryInterceptor, workerCache, workerContentIntegrity, workerCustom, workerHmacSigning, workerLogging, workerRateLimit, workerRetry };
@@ -22,12 +22,11 @@ let cachedSeroval = null;
22
22
  async function loadSeroval() {
23
23
  if (!cachedSeroval) {
24
24
  try {
25
- // Dynamic import via variable keeps seroval as optional peer dep (no static reference)
26
- const id = 'seroval';
27
- cachedSeroval = (await import(/* @vite-ignore */ id));
25
+ // Dynamic import — allows seroval to be an optional peer dep
26
+ cachedSeroval = (await import('seroval'));
28
27
  }
29
28
  catch {
30
- throw new Error('seroval is required as a peer dependency. Install it with: npm install seroval');
29
+ throw new Error('seroval is required to serialize complex types (Date, Map, Set, RegExp). Install it with: npm install seroval');
31
30
  }
32
31
  }
33
32
  return cachedSeroval;
@@ -75,9 +74,8 @@ let cachedToon = null;
75
74
  async function loadToon() {
76
75
  if (!cachedToon) {
77
76
  try {
78
- // Dynamic import via variable keeps @toon-format/toon as optional peer dep (no static reference)
79
- const id = '@toon-format/toon';
80
- cachedToon = (await import(/* @vite-ignore */ id));
77
+ // Dynamic import — allows @toon-format/toon to be an optional peer dep
78
+ cachedToon = (await import('@toon-format/toon'));
81
79
  }
82
80
  catch {
83
81
  throw new Error('@toon-format/toon is required as a peer dependency. ' +
@@ -298,7 +296,8 @@ async function createAutoSerializer(config) {
298
296
  },
299
297
  deserialize(payload) {
300
298
  let resolved = payload;
301
- if (payload.data instanceof ArrayBuffer) {
299
+ if (payload.data instanceof ArrayBuffer ||
300
+ Object.prototype.toString.call(payload.data) === '[object ArrayBuffer]') {
302
301
  const str = new TextDecoder().decode(payload.data);
303
302
  resolved = { ...payload, data: str };
304
303
  }
@@ -136,65 +136,105 @@ class WorkerHttpTimeoutError extends Error {
136
136
  }
137
137
  }
138
138
 
139
+ /**
140
+ * Wraps a Worker or SharedWorker into a unified TransportPort interface.
141
+ */
142
+ function wrapWorker(worker) {
143
+ if ('port' in worker) {
144
+ const port = worker.port;
145
+ return {
146
+ postMessage: (msg, transfer) => port.postMessage(msg, transfer),
147
+ addEventListener: (type, listener) => port.addEventListener(type, listener),
148
+ removeEventListener: (type, listener) => port.removeEventListener(type, listener),
149
+ start: () => port.start(),
150
+ terminate: () => port.close(),
151
+ };
152
+ }
153
+ return worker;
154
+ }
155
+
139
156
  const DEFAULT_REQUEST_TIMEOUT_MS = 30_000;
140
157
  /**
141
158
  * Creates a typed, Observable-based transport for communicating with a web worker.
142
- *
143
- * Features:
144
- * - Request/response correlation via `requestId`
145
- * - Cancellation on Observable unsubscribe (also aborts `fetch()` in the worker)
146
- * - Per-request timeout (default 30 s) rejecting with `WorkerHttpTimeoutError`
147
- * - Optional worker pool with round-robin dispatch
148
- * - Lazy worker creation (default)
149
- * - Opt-in transferable detection (`transferDetection: 'auto'`) for zero-copy
150
- * `ArrayBuffer` / stream payloads
151
- *
152
- * @example
153
- * ```typescript
154
- * const transport = createWorkerTransport<MyRequest, MyResponse>({
155
- * workerFactory: () => new Worker(new URL('./my.worker.ts', import.meta.url), { type: 'module' }),
156
- * maxInstances: 2,
157
- * });
158
- *
159
- * transport.execute(request).subscribe({
160
- * next: (response) => console.log(response),
161
- * error: (err) => console.error(err),
162
- * });
163
- * ```
164
159
  */
165
160
  function createWorkerTransport(config) {
166
- const workers = [];
161
+ const instances = [];
167
162
  let roundRobinIndex = 0;
168
163
  let terminated = false;
164
+ const batchBuffer = new Map();
165
+ const batchTransferables = new Map();
166
+ let flushScheduled = false;
167
+ function scheduleFlush() {
168
+ if (flushScheduled)
169
+ return;
170
+ flushScheduled = true;
171
+ queueMicrotask(() => {
172
+ flushScheduled = false;
173
+ for (const [instance, messages] of batchBuffer.entries()) {
174
+ const transferables = batchTransferables.get(instance) || [];
175
+ instance.postMessage({ type: 'batch', messages }, transferables);
176
+ }
177
+ batchBuffer.clear();
178
+ batchTransferables.clear();
179
+ });
180
+ }
181
+ function dispatchToWorker(instance, msg, transferables) {
182
+ if (!batchBuffer.has(instance)) {
183
+ batchBuffer.set(instance, []);
184
+ }
185
+ batchBuffer.get(instance).push(msg);
186
+ if (transferables?.length) {
187
+ if (!batchTransferables.has(instance)) {
188
+ batchTransferables.set(instance, []);
189
+ }
190
+ batchTransferables.get(instance).push(...transferables);
191
+ }
192
+ scheduleFlush();
193
+ }
194
+ const mode = config.mode ?? 'worker';
195
+ const sharedWorkerName = config.sharedWorkerName ?? 'worker-http';
169
196
  const maxInstances = Math.min(config.maxInstances ?? 1, typeof navigator !== 'undefined' ? (navigator.hardwareConcurrency ?? 4) : 1);
170
197
  const requestTimeout = config.requestTimeout ?? DEFAULT_REQUEST_TIMEOUT_MS;
171
198
  const transferDetection = config.transferDetection ?? 'none';
172
199
  const streamsPolyfill = config.streamsPolyfill ?? false;
173
200
  let polyfillLoaded = false;
174
- function createWorker() {
201
+ function createInstance(index) {
202
+ let worker;
175
203
  if (config.workerFactory) {
176
- return config.workerFactory();
204
+ worker = config.workerFactory();
177
205
  }
178
- if (config.workerUrl) {
206
+ else if (config.workerUrl) {
179
207
  const url = typeof config.workerUrl === 'string'
180
208
  ? new URL(config.workerUrl, document.baseURI)
181
209
  : config.workerUrl;
182
- return new Worker(url, { type: 'module' });
210
+ if (mode === 'shared') {
211
+ const name = maxInstances > 1 ? `${sharedWorkerName}-${index}` : sharedWorkerName;
212
+ worker = new SharedWorker(url, { type: 'module', name });
213
+ }
214
+ else {
215
+ worker = new Worker(url, { type: 'module' });
216
+ }
217
+ }
218
+ else {
219
+ throw new Error('Either workerFactory or workerUrl must be provided');
183
220
  }
184
- throw new Error('Either workerFactory or workerUrl must be provided');
221
+ return wrapWorker(worker);
185
222
  }
186
- function getOrCreateWorker() {
187
- if (workers.length < maxInstances) {
188
- const worker = createWorker();
223
+ function getOrCreateInstance() {
224
+ if (instances.length < maxInstances) {
225
+ const instance = createInstance(instances.length);
226
+ if (instance.start) {
227
+ instance.start();
228
+ }
189
229
  if (config.initMessage) {
190
- worker.postMessage(config.initMessage);
230
+ instance.postMessage(config.initMessage);
191
231
  }
192
- workers.push(worker);
193
- return worker;
232
+ instances.push(instance);
233
+ return instance;
194
234
  }
195
- const worker = workers[roundRobinIndex % workers.length];
235
+ const instance = instances[roundRobinIndex % instances.length];
196
236
  roundRobinIndex++;
197
- return worker;
237
+ return instance;
198
238
  }
199
239
  function execute(request, options) {
200
240
  if (terminated) {
@@ -206,25 +246,23 @@ function createWorkerTransport(config) {
206
246
  const externalSignal = options?.signal;
207
247
  const effectiveTimeout = options?.timeout ?? requestTimeout;
208
248
  return new Observable((subscriber) => {
209
- // Fail-fast: if the caller's signal was already aborted before we even
210
- // touched a worker, surface it immediately with no postMessage roundtrip.
211
249
  if (externalSignal?.aborted) {
212
250
  subscriber.error(new WorkerHttpAbortError(externalSignal.reason));
213
251
  return () => undefined;
214
252
  }
215
- // Lazy-load polyfill on first request if enabled
216
253
  if (streamsPolyfill && !polyfillLoaded) {
217
254
  loadStreamsPolyfill().catch((err) => {
255
+ // oxlint-disable-next-line no-console
218
256
  console.warn('[worker-http] Streams polyfill failed to load:', err);
219
257
  });
220
258
  }
221
- const worker = getOrCreateWorker();
259
+ const instance = getOrCreateInstance();
222
260
  let settled = false;
223
261
  let timeoutHandle;
224
262
  let abortListener;
225
263
  const cleanup = () => {
226
- worker.removeEventListener('message', messageHandler);
227
- worker.removeEventListener('error', errorHandler);
264
+ instance.removeEventListener('message', messageHandler);
265
+ instance.removeEventListener('error', errorHandler);
228
266
  if (timeoutHandle !== undefined) {
229
267
  clearTimeout(timeoutHandle);
230
268
  timeoutHandle = undefined;
@@ -234,8 +272,7 @@ function createWorkerTransport(config) {
234
272
  abortListener = undefined;
235
273
  }
236
274
  };
237
- const messageHandler = (event) => {
238
- const data = event.data;
275
+ const handleResponse = (data) => {
239
276
  if (data.requestId !== requestId)
240
277
  return;
241
278
  if (settled)
@@ -243,14 +280,24 @@ function createWorkerTransport(config) {
243
280
  settled = true;
244
281
  cleanup();
245
282
  if (data.type === 'error') {
246
- const err = data.error;
247
- subscriber.error(new Error(err.message));
283
+ subscriber.error(new Error(data.error.message));
248
284
  }
249
285
  else {
250
286
  subscriber.next(data.result);
251
287
  subscriber.complete();
252
288
  }
253
289
  };
290
+ const messageHandler = (event) => {
291
+ const data = event.data;
292
+ if (data.type === 'batch-response') {
293
+ const match = data.responses.find((r) => r.requestId === requestId);
294
+ if (match)
295
+ handleResponse(match);
296
+ }
297
+ else {
298
+ handleResponse(data);
299
+ }
300
+ };
254
301
  const errorHandler = (event) => {
255
302
  if (settled)
256
303
  return;
@@ -258,14 +305,14 @@ function createWorkerTransport(config) {
258
305
  cleanup();
259
306
  subscriber.error(new Error(event.message ?? 'Worker error'));
260
307
  };
261
- worker.addEventListener('message', messageHandler);
262
- worker.addEventListener('error', errorHandler);
308
+ instance.addEventListener('message', messageHandler);
309
+ instance.addEventListener('error', errorHandler);
263
310
  if (transferDetection === 'auto') {
264
311
  const transferables = detectTransferables(request);
265
- worker.postMessage({ type: 'request', requestId, payload: request }, transferables);
312
+ dispatchToWorker(instance, { type: 'request', requestId, payload: request }, transferables);
266
313
  }
267
314
  else {
268
- worker.postMessage({ type: 'request', requestId, payload: request });
315
+ dispatchToWorker(instance, { type: 'request', requestId, payload: request });
269
316
  }
270
317
  if (effectiveTimeout > 0 && Number.isFinite(effectiveTimeout)) {
271
318
  timeoutHandle = setTimeout(() => {
@@ -273,15 +320,10 @@ function createWorkerTransport(config) {
273
320
  return;
274
321
  settled = true;
275
322
  cleanup();
276
- // Ask the worker to abort any in-flight work for this id. The
277
- // cancellation fix wires this through to `fetch()`.
278
- worker.postMessage({ type: 'cancel', requestId });
323
+ dispatchToWorker(instance, { type: 'cancel', requestId });
279
324
  subscriber.error(new WorkerHttpTimeoutError(effectiveTimeout));
280
325
  }, effectiveTimeout);
281
326
  }
282
- // External AbortSignal: surface a typed abort error and cancel the
283
- // worker-side fetch. Distinct from a silent unsubscribe (no error) and
284
- // from a timeout (different error type).
285
327
  if (externalSignal) {
286
328
  abortListener = () => {
287
329
  if (settled)
@@ -289,25 +331,20 @@ function createWorkerTransport(config) {
289
331
  settled = true;
290
332
  const reason = externalSignal.reason;
291
333
  cleanup();
292
- worker.postMessage({ type: 'cancel', requestId });
334
+ dispatchToWorker(instance, { type: 'cancel', requestId });
293
335
  subscriber.error(new WorkerHttpAbortError(reason));
294
336
  };
295
337
  externalSignal.addEventListener('abort', abortListener, { once: true });
296
338
  }
297
- // Teardown: send cancel message on unsubscribe
298
339
  return () => {
299
340
  if (settled)
300
341
  return;
301
342
  settled = true;
302
343
  cleanup();
303
- worker.postMessage({ type: 'cancel', requestId });
344
+ dispatchToWorker(instance, { type: 'cancel', requestId });
304
345
  };
305
346
  });
306
347
  }
307
- /**
308
- * Lazy-loads the streams polyfill if enabled and not already loaded.
309
- * Called before operations that might involve stream transfer.
310
- */
311
348
  async function loadStreamsPolyfill() {
312
349
  if (!streamsPolyfill || polyfillLoaded) {
313
350
  return;
@@ -320,25 +357,27 @@ function createWorkerTransport(config) {
320
357
  polyfillLoaded = true;
321
358
  }
322
359
  catch (err) {
323
- // Log warning but don't block request — streams will fail naturally
360
+ // oxlint-disable-next-line no-console
324
361
  console.warn('[worker-http] Failed to load streams polyfill:', err);
325
362
  }
326
363
  }
327
364
  function terminate() {
328
365
  terminated = true;
329
- for (const worker of workers) {
330
- worker.terminate();
366
+ for (const instance of instances) {
367
+ if (instance.terminate) {
368
+ instance.terminate();
369
+ }
331
370
  }
332
- workers.length = 0;
371
+ instances.length = 0;
333
372
  }
334
373
  return {
335
374
  execute,
336
375
  terminate,
337
376
  get isActive() {
338
- return !terminated && workers.length > 0;
377
+ return !terminated && instances.length > 0;
339
378
  },
340
379
  get activeInstances() {
341
- return workers.length;
380
+ return instances.length;
342
381
  },
343
382
  };
344
383
  }
@@ -347,4 +386,4 @@ function createWorkerTransport(config) {
347
386
  * Generated bundle index. Do not edit.
348
387
  */
349
388
 
350
- export { WorkerHttpAbortError, WorkerHttpTimeoutError, createWorkerTransport, detectTransferables };
389
+ export { WorkerHttpAbortError, WorkerHttpTimeoutError, createWorkerTransport, detectTransferables, wrapWorker };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@angular-helpers/worker-http",
3
- "version": "21.2.0",
3
+ "version": "21.2.3",
4
4
  "description": "Angular HTTP over Web Workers — off-main-thread HTTP pipelines with configurable interceptors, WebCrypto security, and pluggable serialization",
5
5
  "schematics": "./schematics/collection.json",
6
6
  "exports": {
@@ -102,7 +102,8 @@
102
102
  "module": "fesm2022/angular-helpers-worker-http.mjs",
103
103
  "typings": "types/angular-helpers-worker-http.d.ts",
104
104
  "sideEffects": false,
105
+ "type": "module",
105
106
  "dependencies": {
106
107
  "tslib": "^2.3.0"
107
108
  }
108
- }
109
+ }
@@ -27,6 +27,19 @@ interface WorkerConfig {
27
27
  workerUrl: URL;
28
28
  /** Maximum worker instances in the pool (default: 1) */
29
29
  maxInstances?: number;
30
+ /**
31
+ * Execution mode for the worker.
32
+ *
33
+ * - `'worker'` (default) — Dedicated Web Worker. Each tab has its own worker instance.
34
+ * - `'shared'` — Shared Web Worker. Multiple tabs share the same worker instances.
35
+ */
36
+ mode?: 'worker' | 'shared';
37
+ /**
38
+ * Name for the SharedWorker. Required when `mode: 'shared'` to ensure multiple
39
+ * tabs connect to the same worker instance. If `maxInstances > 1`, names are
40
+ * suffixed with the instance index (e.g. `api-1`, `api-2`).
41
+ */
42
+ name?: string;
30
43
  }
31
44
  /**
32
45
  * URL-pattern to worker auto-routing rule.
@@ -223,6 +223,21 @@ declare function resolveSpec(spec: WorkerInterceptorSpec): WorkerInterceptorFn;
223
223
  */
224
224
  declare function createConfigurableWorkerPipeline(): void;
225
225
 
226
+ type RequestHandler = (req: SerializableRequest, signal?: AbortSignal) => Promise<SerializableResponse>;
227
+
228
+ /**
229
+ * Wires up the worker's request handler around a built request chain.
230
+ *
231
+ * Automatically detects if running in a Dedicated Worker or Shared Worker context
232
+ * and attaches the appropriate listeners.
233
+ */
234
+ declare function attachRequestLoop(chain: RequestHandler): () => void;
235
+
236
+ /**
237
+ * Common loop logic for handling requests/cancellation on a port.
238
+ */
239
+ declare function attachPortLoop(port: MessagePort | any, chain: RequestHandler): () => void;
240
+
226
241
  /**
227
242
  * Creates a retry interceptor with exponential backoff.
228
243
  *
@@ -362,5 +377,5 @@ declare function workerContentIntegrity(config?: ContentIntegrityConfig): Worker
362
377
  */
363
378
  declare function workerCustom(name: string, config?: unknown): WorkerInterceptorSpec;
364
379
 
365
- export { cacheInterceptor, composeInterceptors, contentIntegrityInterceptor, createConfigurableWorkerPipeline, createWorkerPipeline, hmacSigningInterceptor, loggingInterceptor, rateLimitInterceptor, registerInterceptor, resolveSpec, retryInterceptor, workerCache, workerContentIntegrity, workerCustom, workerHmacSigning, workerLogging, workerRateLimit, workerRetry };
380
+ export { attachPortLoop, attachRequestLoop, cacheInterceptor, composeInterceptors, contentIntegrityInterceptor, createConfigurableWorkerPipeline, createWorkerPipeline, hmacSigningInterceptor, loggingInterceptor, rateLimitInterceptor, registerInterceptor, resolveSpec, retryInterceptor, workerCache, workerContentIntegrity, workerCustom, workerHmacSigning, workerLogging, workerRateLimit, workerRetry };
366
381
  export type { CacheConfig, ContentIntegrityConfig, HmacInterceptorConfig, LoggingConfig, RateLimitConfig, RetryConfig, SerializableHmacConfig, SerializableLoggingConfig, SerializableRequest, SerializableResponse, WorkerInterceptorFn, WorkerInterceptorInitMessage, WorkerInterceptorSpec };
@@ -9,16 +9,16 @@ import { Observable } from 'rxjs';
9
9
  */
10
10
  interface WorkerTransportConfig {
11
11
  /**
12
- * Factory function that creates a new Worker instance.
13
- * The `new Worker(new URL(...))` call MUST be in your app code (not a library)
14
- * for Angular CLI to bundle the worker correctly.
12
+ * Factory function that creates a new Worker or SharedWorker instance.
13
+ * The `new Worker(...)` or `new SharedWorker(...)` call MUST be in your app code
14
+ * (not a library) for Angular CLI to bundle the worker correctly.
15
15
  *
16
16
  * @example
17
17
  * ```typescript
18
18
  * workerFactory: () => new Worker(new URL('./echo.worker.ts', import.meta.url), { type: 'module' })
19
19
  * ```
20
20
  */
21
- workerFactory?: () => Worker;
21
+ workerFactory?: () => Worker | SharedWorker;
22
22
  /**
23
23
  * URL to a pre-transpiled worker file.
24
24
  * Use this when workers are built separately (e.g., with Vite) and distributed
@@ -32,6 +32,19 @@ interface WorkerTransportConfig {
32
32
  * ```
33
33
  */
34
34
  workerUrl?: string | URL;
35
+ /**
36
+ * Execution mode for the worker.
37
+ *
38
+ * - `'worker'` (default) — Dedicated Web Worker. Each tab has its own worker instance.
39
+ * - `'shared'` — Shared Web Worker. Multiple tabs share the same worker instances.
40
+ */
41
+ mode?: 'worker' | 'shared';
42
+ /**
43
+ * Name for the SharedWorker. Required when `mode: 'shared'` to ensure multiple
44
+ * tabs connect to the same worker instance. If `maxInstances > 1`, names are
45
+ * suffixed with the instance index (e.g. `api-1`, `api-2`).
46
+ */
47
+ sharedWorkerName?: string;
35
48
  /** Maximum number of worker instances in the pool (default: 1) */
36
49
  maxInstances?: number;
37
50
  /**
@@ -139,28 +152,6 @@ interface WorkerTransport<TRequest = unknown, TResponse = unknown> {
139
152
 
140
153
  /**
141
154
  * Creates a typed, Observable-based transport for communicating with a web worker.
142
- *
143
- * Features:
144
- * - Request/response correlation via `requestId`
145
- * - Cancellation on Observable unsubscribe (also aborts `fetch()` in the worker)
146
- * - Per-request timeout (default 30 s) rejecting with `WorkerHttpTimeoutError`
147
- * - Optional worker pool with round-robin dispatch
148
- * - Lazy worker creation (default)
149
- * - Opt-in transferable detection (`transferDetection: 'auto'`) for zero-copy
150
- * `ArrayBuffer` / stream payloads
151
- *
152
- * @example
153
- * ```typescript
154
- * const transport = createWorkerTransport<MyRequest, MyResponse>({
155
- * workerFactory: () => new Worker(new URL('./my.worker.ts', import.meta.url), { type: 'module' }),
156
- * maxInstances: 2,
157
- * });
158
- *
159
- * transport.execute(request).subscribe({
160
- * next: (response) => console.log(response),
161
- * error: (err) => console.error(err),
162
- * });
163
- * ```
164
155
  */
165
156
  declare function createWorkerTransport<TRequest = unknown, TResponse = unknown>(config: WorkerTransportConfig): WorkerTransport<TRequest, TResponse>;
166
157
 
@@ -235,5 +226,22 @@ declare class WorkerHttpAbortError extends Error {
235
226
  */
236
227
  declare function detectTransferables(payload: unknown): Transferable[];
237
228
 
238
- export { WorkerHttpAbortError, WorkerHttpTimeoutError, createWorkerTransport, detectTransferables };
239
- export type { WorkerErrorResponse, WorkerExecuteOptions, WorkerMessage, WorkerResponse, WorkerTransport, WorkerTransportConfig };
229
+ /**
230
+ * Common interface for communication ports.
231
+ * Unifies Worker and SharedWorker (MessagePort).
232
+ */
233
+ interface TransportPort {
234
+ postMessage(message: any, transfer?: Transferable[]): void;
235
+ addEventListener(type: string, listener: any): void;
236
+ removeEventListener(type: string, listener: any): void;
237
+ terminate?(): void;
238
+ start?(): void;
239
+ }
240
+
241
+ /**
242
+ * Wraps a Worker or SharedWorker into a unified TransportPort interface.
243
+ */
244
+ declare function wrapWorker(worker: Worker | SharedWorker): TransportPort;
245
+
246
+ export { WorkerHttpAbortError, WorkerHttpTimeoutError, createWorkerTransport, detectTransferables, wrapWorker };
247
+ export type { TransportPort, WorkerErrorResponse, WorkerExecuteOptions, WorkerMessage, WorkerResponse, WorkerTransport, WorkerTransportConfig };