@angular-helpers/worker-http 21.1.0 → 21.2.2

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
@@ -88,6 +88,9 @@ Then follow the setup in the `/backend` section below.
88
88
 
89
89
  A framework-agnostic, type-safe bridge between the main thread and a Web Worker. Wraps `postMessage` with request/response correlation, Observable API, and automatic cancellation on unsubscribe.
90
90
 
91
+ <details>
92
+ <summary><strong>API and examples</strong></summary>
93
+
91
94
  ```typescript
92
95
  import { createWorkerTransport } from '@angular-helpers/worker-http/transport';
93
96
 
@@ -107,6 +110,7 @@ transport.terminate();
107
110
 
108
111
  - Round-robin pool (`maxInstances`) for parallel request handling
109
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).
110
114
  - **Cancellation that actually aborts `fetch()`** — unsubscribing posts a
111
115
  cancel message; the worker-side message loop threads an `AbortSignal` all
112
116
  the way into `fetch()` so the in-flight HTTP request is truly aborted
@@ -146,12 +150,17 @@ transport.execute(request).subscribe({
146
150
  });
147
151
  ```
148
152
 
153
+ </details>
154
+
149
155
  ---
150
156
 
151
157
  ### `/interceptors` — Worker-side pipeline
152
158
 
153
159
  Pure-function interceptors that run inside the worker. No Angular DI, no DOM access — just `(req, next) => Promise<response>`.
154
160
 
161
+ <details>
162
+ <summary><strong>Setup, built-in interceptors, and custom interceptors</strong></summary>
163
+
155
164
  #### Setup in your worker file
156
165
 
157
166
  ```typescript
@@ -283,11 +292,22 @@ export const authTokenInterceptor: WorkerInterceptorFn = (req, next) => {
283
292
  };
284
293
  ```
285
294
 
295
+ </details>
296
+
286
297
  ---
287
298
 
288
299
  ### `/serializer` — Pluggable serialization
289
300
 
290
- Handles the `postMessage` serialization boundary. Structured clone is zero-overhead but loses `Date`, `Map`, `Set` fidelity. `seroval` preserves full type fidelity. The auto-serializer picks the best strategy per payload.
301
+ Handles the `postMessage` serialization boundary. Three strategies, each with a clear sweet spot:
302
+
303
+ - `structuredCloneSerializer` — zero overhead, default
304
+ - `createToonSerializer()` — 30–60% smaller for uniform arrays of objects
305
+ - `createSerovalSerializer()` — full type fidelity (`Date`, `Map`, `Set`, circular refs)
306
+
307
+ The auto-serializer picks the best strategy per payload.
308
+
309
+ <details>
310
+ <summary><strong>Per-strategy API and examples</strong></summary>
291
311
 
292
312
  #### `structuredCloneSerializer` (default)
293
313
 
@@ -316,14 +336,49 @@ const original = serializer.deserialize(payload);
316
336
  // original.tags instanceof Set → true
317
337
  ```
318
338
 
339
+ #### `createToonSerializer()` — Token-Oriented Object Notation
340
+
341
+ Requires `@toon-format/toon` as an optional peer dependency (`npm install @toon-format/toon`).
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.
344
+
345
+ ```typescript
346
+ import { createToonSerializer } from '@angular-helpers/worker-http/serializer';
347
+
348
+ const serializer = await createToonSerializer();
349
+
350
+ const payload = serializer.serialize([
351
+ { id: 1, name: 'Alice', role: 'admin' },
352
+ { id: 2, name: 'Bob', role: 'member' },
353
+ { id: 3, name: 'Carol', role: 'member' },
354
+ { id: 4, name: 'Dave', role: 'guest' },
355
+ { id: 5, name: 'Eve', role: 'admin' },
356
+ ]);
357
+
358
+ // payload.data is a TOON string:
359
+ // [5]{id,name,role}:
360
+ // 1,Alice,admin
361
+ // 2,Bob,member
362
+ // 3,Carol,member
363
+ // 4,Dave,guest
364
+ // 5,Eve,admin
365
+ ```
366
+
367
+ **When TOON shines**: uniform arrays of objects with primitive values (numbers, strings, booleans, nulls) at depth-1.
368
+
369
+ **When TOON does NOT help**: payloads with `Date`, `Map`, `Set`, nested objects, or single objects — use `seroval` or structured clone instead.
370
+
319
371
  #### `createAutoSerializer()` — Smart auto-detection
320
372
 
321
- Automatically picks the best strategy per payload. The factory is async (pre-loads `seroval` during initialization), but the returned serializer is fully synchronous.
373
+ Automatically picks the best strategy per payload. The factory is async (pre-loads `seroval` and `@toon-format/toon` during initialization, both optional), but the returned serializer is fully synchronous.
322
374
 
323
- **Detection logic (depth-1):**
375
+ **Detection logic (depth-1, top-down, first match wins):**
324
376
 
325
- - Contains `Date`, `Map`, `Set`, or `RegExp` at the top level or as direct array/object values → `seroval`
326
- - Otherwise structured clone (zero overhead)
377
+ 1. Contains `Date`, `Map`, `Set`, or `RegExp` at the top level or as direct array/object values → `seroval`
378
+ 2. Uniform array of plain objects with primitive values, length ≥ 5 → `toon`
379
+ 3. Otherwise → structured clone (zero overhead)
380
+
381
+ The TOON threshold is conservative (length ≥ 5). Smaller arrays don't justify the encoding overhead.
327
382
 
328
383
  Payloads larger than `transferThreshold` (default: 100 KiB) are encoded to `ArrayBuffer` and transferred zero-copy.
329
384
 
@@ -346,12 +401,17 @@ auto.serialize(hugeDataset); // transferables: [ArrayBuffer]
346
401
 
347
402
  > **Depth-1 limitation**: `[{ createdAt: new Date() }]` — the `Date` is inside a nested object; not detected at depth-1. For deeply nested complex types, use `createSerovalSerializer()` directly.
348
403
 
404
+ </details>
405
+
349
406
  ---
350
407
 
351
408
  ### `/crypto` — WebCrypto primitives
352
409
 
353
410
  Standalone WebCrypto utilities. Useful in both workers and the main thread, but workers provide memory isolation for key material.
354
411
 
412
+ <details>
413
+ <summary><strong>HMAC, AES, hashing examples</strong></summary>
414
+
355
415
  #### `createHmacSigner(config)`
356
416
 
357
417
  ```typescript
@@ -386,12 +446,17 @@ const hasher = createContentHasher();
386
446
  const hash = await hasher.hash('SHA-256', data); // → hex string
387
447
  ```
388
448
 
449
+ </details>
450
+
389
451
  ---
390
452
 
391
453
  ### `/backend` — Angular `HttpBackend` replacement
392
454
 
393
455
  Drop-in replacement for Angular's `HttpBackend` that transparently routes `HttpClient` requests to Web Workers. Use `WorkerHttpClient` exactly like `HttpClient` — the routing is invisible to application code.
394
456
 
457
+ <details>
458
+ <summary><strong>Configuration, providers, and consumer code</strong></summary>
459
+
395
460
  ```typescript
396
461
  // app.config.ts
397
462
  import {
@@ -480,13 +545,18 @@ manual control.
480
545
  - `matchWorkerRoute(url, routes)` — pure utility to test routing rules
481
546
  - `WORKER_HTTP_SIGNAL`, `WORKER_HTTP_TIMEOUT` — `HttpContextToken`s used internally by `WorkerHttpClient`; set them directly on the `HttpContext` if you're using `HttpClient` rather than the wrapper.
482
547
 
548
+ </details>
549
+
483
550
  ---
484
551
 
485
552
  ## Telemetry
486
553
 
487
- Main-thread extension point for APM / metrics. `withTelemetry(...)` registers
488
- a subscriber that fires synchronously at three lifecycle points of every
489
- request handled by `WorkerHttpBackend`:
554
+ Main-thread extension point for APM / metrics. `withTelemetry(...)` registers a subscriber that fires synchronously at three lifecycle points of every request handled by `WorkerHttpBackend` (`onRequest`, `onResponse`, `onError`).
555
+
556
+ <details>
557
+ <summary><strong>Subscriber semantics, examples, and event interface</strong></summary>
558
+
559
+ Lifecycle points:
490
560
 
491
561
  - **`onRequest`** — after worker resolution, before dispatch
492
562
  - **`onResponse`** — when a successful response is emitted
@@ -553,12 +623,17 @@ interface WorkerHttpTelemetryEventBase {
553
623
  // onError adds: kind: 'error', error, durationMs
554
624
  ```
555
625
 
626
+ </details>
627
+
556
628
  ---
557
629
 
558
630
  ### `/esbuild-plugin` — Interceptor auto-bundling
559
631
 
560
632
  An esbuild plugin that automatically discovers and bundles interceptor files into your worker builds. When using Angular with a custom webpack/esbuild configuration, this ensures your interceptors are included in the worker bundle without manual imports.
561
633
 
634
+ <details>
635
+ <summary><strong>Plugin options and example</strong></summary>
636
+
562
637
  ```typescript
563
638
  // esbuild.config.ts
564
639
  import { workerHttpPlugin } from '@angular-helpers/worker-http/esbuild-plugin';
@@ -585,12 +660,17 @@ export default {
585
660
 
586
661
  Discovered interceptors are merged with explicit ones. Test files (`.spec.ts`, `.test.ts`) are automatically excluded.
587
662
 
663
+ </details>
664
+
588
665
  ---
589
666
 
590
667
  ### `/streams-polyfill` — Safari transferable streams
591
668
 
592
669
  Safari 16-17 lack native transferable `ReadableStream`/`TransformStream` support. This ponyfill enables stream transfer in workers for those browsers, loaded lazily only when needed.
593
670
 
671
+ <details>
672
+ <summary><strong>Setup and bundle impact</strong></summary>
673
+
594
674
  ```typescript
595
675
  // Enable in your app config (main thread)
596
676
  import { withWorkerStreamsPolyfill } from '@angular-helpers/worker-http/backend';
@@ -608,12 +688,16 @@ provideWorkerHttpClient(
608
688
 
609
689
  **Bundle impact:** Zero for modern browsers. The polyfill is lazy-loaded only on affected Safari versions when streams are actually used.
610
690
 
691
+ </details>
692
+
611
693
  ---
612
694
 
613
695
  ## SSR + hydration
614
696
 
615
- Worker HTTP integrates transparently with Angular SSR. The two problems SSR
616
- creates for worker-based HTTP are handled out of the box:
697
+ Worker HTTP integrates transparently with Angular SSR. SSR's two problems for worker-based HTTP — missing `Worker` global on the server and the post-hydration re-fetch — are both handled out of the box.
698
+
699
+ <details>
700
+ <summary><strong>How SSR fallback and the transfer cache work</strong></summary>
617
701
 
618
702
  **1. Workers do not exist on the server.**
619
703
  During SSR, `typeof Worker === 'undefined'`. `WorkerHttpBackend` detects this
@@ -663,6 +747,8 @@ To customise which headers are captured or to cache `POST` requests, pass
663
747
  `withHttpTransferCacheOptions(...)` to `provideClientHydration()` — both are
664
748
  re-exported from `@angular/platform-browser`.
665
749
 
750
+ </details>
751
+
666
752
  ---
667
753
 
668
754
  ## Design principles
@@ -677,13 +763,14 @@ re-exported from `@angular/platform-browser`.
677
763
 
678
764
  ## Serialization strategy decision guide
679
765
 
680
- | Payload type | Recommended serializer | Reason |
681
- | ------------------------------------ | -------------------------------------- | --------------------------- |
682
- | Simple objects, arrays of primitives | `structuredCloneSerializer` (default) | Zero overhead |
683
- | Objects with `Date`, `Map`, `Set` | `createSerovalSerializer()` | Full type fidelity |
684
- | Unknown payload shape | `createAutoSerializer()` | Depth-1 auto-detect |
685
- | Large arrays (> 100 KiB) | `createAutoSerializer()` | Auto ArrayBuffer transfer |
686
- | Deeply nested complex types | `createSerovalSerializer()` explicitly | Auto-detect is depth-1 only |
766
+ | Payload type | Recommended serializer | Reason |
767
+ | ------------------------------------------ | -------------------------------------- | --------------------------- |
768
+ | Simple objects, arrays of primitives | `structuredCloneSerializer` (default) | Zero overhead |
769
+ | Uniform array of plain objects (≥ 5 items) | `createToonSerializer()` | 30–60% size reduction |
770
+ | Objects with `Date`, `Map`, `Set` | `createSerovalSerializer()` | Full type fidelity |
771
+ | Unknown payload shape | `createAutoSerializer()` | Depth-1 auto-detect |
772
+ | Large arrays (> 100 KiB) | `createAutoSerializer()` | Auto ArrayBuffer transfer |
773
+ | Deeply nested complex types | `createSerovalSerializer()` explicitly | Auto-detect is depth-1 only |
687
774
 
688
775
  ---
689
776
 
@@ -701,9 +788,12 @@ Server-Side Rendering (SSR) is supported via automatic fallback to the main thre
701
788
 
702
789
  ## Benchmarks
703
790
 
704
- A reproducible benchmark suite ships with the demo app at
705
- [`/demo/worker-http-benchmark`](../../src/app/demo/worker-http-benchmark) and compares three
706
- transport modes across four workloads:
791
+ A reproducible benchmark suite ships with the demo app at [`/demo/worker-http-benchmark`](../../src/app/demo/worker-http-benchmark).
792
+
793
+ <details>
794
+ <summary><strong>Modes, workloads, metrics, and how to run</strong></summary>
795
+
796
+ It compares three transport modes across four workloads:
707
797
 
708
798
  | Mode | What it measures |
709
799
  | --------------- | ---------------------------------------------------------- |
@@ -739,6 +829,8 @@ npm start
739
829
  Numbers vary by hardware, browser, and current system load — always run a scenario several times
740
830
  and watch the trend, not a single value.
741
831
 
832
+ </details>
833
+
742
834
  ---
743
835
 
744
836
  ## Related documentation
@@ -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,
@@ -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,39 @@ 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
+ processMessage(msg).catch(console.error);
138
+ }
139
+ }
140
+ else {
141
+ processMessage(data).catch(console.error);
142
+ }
143
+ };
144
+ port.addEventListener('message', messageHandler);
124
145
  return () => {
125
- self.onmessage = previous;
146
+ port.removeEventListener('message', messageHandler);
126
147
  for (const controller of controllers.values()) {
127
148
  controller.abort();
128
149
  }
@@ -130,6 +151,36 @@ function attachRequestLoop(chain) {
130
151
  };
131
152
  }
132
153
 
154
+ /**
155
+ * Wires up the worker's request handler around a built request chain.
156
+ *
157
+ * Automatically detects if running in a Dedicated Worker or Shared Worker context
158
+ * and attaches the appropriate listeners.
159
+ */
160
+ function attachRequestLoop(chain) {
161
+ const disposers = [];
162
+ // Shared Worker context
163
+ if ('onconnect' in self) {
164
+ const connectHandler = (event) => {
165
+ const port = event.ports[0];
166
+ disposers.push(attachPortLoop(port, chain));
167
+ port.start();
168
+ };
169
+ self.addEventListener('connect', connectHandler);
170
+ disposers.push(() => self.removeEventListener('connect', connectHandler));
171
+ }
172
+ else {
173
+ // Dedicated Worker context
174
+ disposers.push(attachPortLoop(self, chain));
175
+ }
176
+ return () => {
177
+ for (const dispose of disposers) {
178
+ dispose();
179
+ }
180
+ disposers.length = 0;
181
+ };
182
+ }
183
+
133
184
  /**
134
185
  * Creates and registers a worker-side HTTP pipeline.
135
186
  *
@@ -638,4 +689,4 @@ function workerCustom(name, config) {
638
689
  * Generated bundle index. Do not edit.
639
690
  */
640
691
 
641
- export { cacheInterceptor, composeInterceptors, contentIntegrityInterceptor, createConfigurableWorkerPipeline, createWorkerPipeline, hmacSigningInterceptor, loggingInterceptor, rateLimitInterceptor, registerInterceptor, resolveSpec, retryInterceptor, workerCache, workerContentIntegrity, workerCustom, workerHmacSigning, workerLogging, workerRateLimit, workerRetry };
692
+ export { attachPortLoop, attachRequestLoop, cacheInterceptor, composeInterceptors, contentIntegrityInterceptor, createConfigurableWorkerPipeline, createWorkerPipeline, hmacSigningInterceptor, loggingInterceptor, rateLimitInterceptor, registerInterceptor, resolveSpec, retryInterceptor, workerCache, workerContentIntegrity, workerCustom, workerHmacSigning, workerLogging, workerRateLimit, workerRetry };
@@ -71,6 +71,138 @@ async function createSerovalSerializer() {
71
71
  };
72
72
  }
73
73
 
74
+ let cachedToon = null;
75
+ async function loadToon() {
76
+ if (!cachedToon) {
77
+ 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));
81
+ }
82
+ catch {
83
+ throw new Error('@toon-format/toon is required as a peer dependency. ' +
84
+ 'Install it with: npm install @toon-format/toon');
85
+ }
86
+ }
87
+ return cachedToon;
88
+ }
89
+ /**
90
+ * Creates a `WorkerSerializer` backed by `@toon-format/toon` (Token-Oriented Object Notation).
91
+ *
92
+ * TOON is a compact, schema-aware encoding of the JSON data model that declares object
93
+ * keys once and emits values as CSV-like rows. It typically reduces size by **30–60%**
94
+ * for uniform arrays of objects (e.g. `User[]`, `Product[]`, paginated lists), with
95
+ * negligible parsing overhead.
96
+ *
97
+ * **When to use it**:
98
+ * - Worker↔main `postMessage` payloads dominated by uniform arrays of objects
99
+ * - Cases where `structuredClone` cost is dominated by repeated key strings
100
+ *
101
+ * **When NOT to use it**:
102
+ * - Payloads containing `Date`, `Map`, `Set`, `RegExp` (use `seroval` instead)
103
+ * - Small / single-object payloads (overhead not justified)
104
+ *
105
+ * The factory is async because it dynamically imports the optional `@toon-format/toon` peer.
106
+ *
107
+ * `@toon-format/toon` must be installed separately:
108
+ * ```
109
+ * npm install @toon-format/toon
110
+ * ```
111
+ *
112
+ * @example
113
+ * ```typescript
114
+ * const serializer = await createToonSerializer();
115
+ * const payload = serializer.serialize([
116
+ * { id: 1, name: 'Alice' },
117
+ * { id: 2, name: 'Bob' },
118
+ * { id: 3, name: 'Carol' },
119
+ * { id: 4, name: 'Dave' },
120
+ * { id: 5, name: 'Eve' },
121
+ * ]);
122
+ * worker.postMessage({ payload }, payload.transferables);
123
+ * ```
124
+ *
125
+ * @see https://toonformat.dev
126
+ */
127
+ async function createToonSerializer() {
128
+ const { encode, decode } = await loadToon();
129
+ return {
130
+ serialize(data) {
131
+ return {
132
+ data: encode(data),
133
+ transferables: [],
134
+ format: 'toon',
135
+ };
136
+ },
137
+ deserialize(payload) {
138
+ if (payload.format !== 'toon') {
139
+ throw new Error(`Expected format 'toon', got '${payload.format}'`);
140
+ }
141
+ return decode(payload.data);
142
+ },
143
+ };
144
+ }
145
+ /**
146
+ * Conservative threshold below which TOON's overhead outweighs its size benefit.
147
+ * Auto-serializer keeps shorter arrays on `structured-clone`.
148
+ *
149
+ * Exported for testing and for consumers building custom routing logic.
150
+ */
151
+ const MIN_UNIFORM_ARRAY_LENGTH = 5;
152
+ /**
153
+ * Detects whether a value is a depth-1 uniform array of plain objects with primitive values.
154
+ *
155
+ * Pure function — no side effects. Used by `createAutoSerializer()` to decide
156
+ * whether to route a payload through TOON.
157
+ *
158
+ * Conditions checked (all must pass):
159
+ * 1. Value is an array with `length >= MIN_UNIFORM_ARRAY_LENGTH`
160
+ * 2. Every item is a non-null, non-array plain object
161
+ * 3. Every item has the same set of keys as the first item
162
+ * 4. Every value across all items is a primitive (string, number, boolean, null)
163
+ *
164
+ * @example
165
+ * ```typescript
166
+ * isUniformObjectArray([{a:1},{a:2},{a:3},{a:4},{a:5}]); // true
167
+ * isUniformObjectArray([{a:1},{b:2}]); // false (heterogeneous keys)
168
+ * isUniformObjectArray([{a:[1,2]},{a:[3,4]}]); // false (nested array value)
169
+ * isUniformObjectArray([{a:1}]); // false (length < 5)
170
+ * ```
171
+ */
172
+ function isUniformObjectArray(value) {
173
+ if (!Array.isArray(value) || value.length < MIN_UNIFORM_ARRAY_LENGTH) {
174
+ return false;
175
+ }
176
+ const first = value[0];
177
+ if (first === null || typeof first !== 'object' || Array.isArray(first)) {
178
+ return false;
179
+ }
180
+ const expectedKeys = Object.keys(first)
181
+ .sort()
182
+ .join('\u0000');
183
+ if (expectedKeys === '') {
184
+ return false;
185
+ }
186
+ for (const item of value) {
187
+ if (item === null || typeof item !== 'object' || Array.isArray(item)) {
188
+ return false;
189
+ }
190
+ const record = item;
191
+ if (Object.keys(record).sort().join('\u0000') !== expectedKeys) {
192
+ return false;
193
+ }
194
+ for (const v of Object.values(record)) {
195
+ if (v === null)
196
+ continue;
197
+ const t = typeof v;
198
+ if (t !== 'string' && t !== 'number' && t !== 'boolean') {
199
+ return false;
200
+ }
201
+ }
202
+ }
203
+ return true;
204
+ }
205
+
74
206
  /**
75
207
  * Shallow check for complex types at depth-1 that structured-clone cannot preserve.
76
208
  * Depth-1 is intentional: fast and predictable. For deeply nested complex types,
@@ -107,9 +239,11 @@ function encodeToTransferable(str) {
107
239
  * The factory is async because it pre-loads `seroval` during initialization
108
240
  * so the returned serializer methods are fully synchronous (no await in hot path).
109
241
  *
110
- * Strategy selection per `serialize()` call:
111
- * - Contains `Date`, `Map`, `Set`, or `RegExp` at depth-1 → `seroval` (full fidelity)
112
- * - Otherwise structured clone (native, zero overhead)
242
+ * Strategy selection per `serialize()` call (top-down, first match wins):
243
+ * 1. Contains `Date`, `Map`, `Set`, or `RegExp` at depth-1 → `seroval` (full fidelity)
244
+ * 2. Uniform array of plain objects (length ≥ 5, primitive values, identical key set)
245
+ * → `toon` (30–60% size reduction)
246
+ * 3. Otherwise → structured clone (native, zero overhead)
113
247
  *
114
248
  * Large payloads (> `transferThreshold`, default 100 KiB) are encoded to
115
249
  * `ArrayBuffer` and added to `transferables` for zero-copy `postMessage` transfer.
@@ -130,6 +264,13 @@ async function createAutoSerializer(config) {
130
264
  catch {
131
265
  // seroval not installed — complex types will throw at serialize time with a clear message
132
266
  }
267
+ let toon = null;
268
+ try {
269
+ toon = await createToonSerializer();
270
+ }
271
+ catch {
272
+ // @toon-format/toon not installed — uniform arrays will fall back to structured-clone
273
+ }
133
274
  return {
134
275
  serialize(data) {
135
276
  let payload;
@@ -140,6 +281,9 @@ async function createAutoSerializer(config) {
140
281
  }
141
282
  payload = sv.serialize(data);
142
283
  }
284
+ else if (toon && isUniformObjectArray(data)) {
285
+ payload = toon.serialize(data);
286
+ }
143
287
  else {
144
288
  payload = structuredCloneSerializer.serialize(data);
145
289
  }
@@ -154,7 +298,8 @@ async function createAutoSerializer(config) {
154
298
  },
155
299
  deserialize(payload) {
156
300
  let resolved = payload;
157
- if (payload.data instanceof ArrayBuffer) {
301
+ if (payload.data instanceof ArrayBuffer ||
302
+ Object.prototype.toString.call(payload.data) === '[object ArrayBuffer]') {
158
303
  const str = new TextDecoder().decode(payload.data);
159
304
  resolved = { ...payload, data: str };
160
305
  }
@@ -168,6 +313,13 @@ async function createAutoSerializer(config) {
168
313
  }
169
314
  return sv.deserialize(resolved);
170
315
  }
316
+ if (resolved.format === 'toon') {
317
+ if (!toon) {
318
+ throw new Error('@toon-format/toon is required to deserialize this payload. ' +
319
+ 'Install it with: npm install @toon-format/toon');
320
+ }
321
+ return toon.deserialize(resolved);
322
+ }
171
323
  throw new Error(`Unknown serialization format: '${resolved.format}'`);
172
324
  },
173
325
  };
@@ -177,4 +329,4 @@ async function createAutoSerializer(config) {
177
329
  * Generated bundle index. Do not edit.
178
330
  */
179
331
 
180
- export { createAutoSerializer, createSerovalSerializer, structuredCloneSerializer };
332
+ export { MIN_UNIFORM_ARRAY_LENGTH, createAutoSerializer, createSerovalSerializer, createToonSerializer, isUniformObjectArray, structuredCloneSerializer };
@@ -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,22 @@ 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) => {
218
255
  console.warn('[worker-http] Streams polyfill failed to load:', err);
219
256
  });
220
257
  }
221
- const worker = getOrCreateWorker();
258
+ const instance = getOrCreateInstance();
222
259
  let settled = false;
223
260
  let timeoutHandle;
224
261
  let abortListener;
225
262
  const cleanup = () => {
226
- worker.removeEventListener('message', messageHandler);
227
- worker.removeEventListener('error', errorHandler);
263
+ instance.removeEventListener('message', messageHandler);
264
+ instance.removeEventListener('error', errorHandler);
228
265
  if (timeoutHandle !== undefined) {
229
266
  clearTimeout(timeoutHandle);
230
267
  timeoutHandle = undefined;
@@ -234,8 +271,7 @@ function createWorkerTransport(config) {
234
271
  abortListener = undefined;
235
272
  }
236
273
  };
237
- const messageHandler = (event) => {
238
- const data = event.data;
274
+ const handleResponse = (data) => {
239
275
  if (data.requestId !== requestId)
240
276
  return;
241
277
  if (settled)
@@ -243,14 +279,24 @@ function createWorkerTransport(config) {
243
279
  settled = true;
244
280
  cleanup();
245
281
  if (data.type === 'error') {
246
- const err = data.error;
247
- subscriber.error(new Error(err.message));
282
+ subscriber.error(new Error(data.error.message));
248
283
  }
249
284
  else {
250
285
  subscriber.next(data.result);
251
286
  subscriber.complete();
252
287
  }
253
288
  };
289
+ const messageHandler = (event) => {
290
+ const data = event.data;
291
+ if (data.type === 'batch-response') {
292
+ const match = data.responses.find((r) => r.requestId === requestId);
293
+ if (match)
294
+ handleResponse(match);
295
+ }
296
+ else {
297
+ handleResponse(data);
298
+ }
299
+ };
254
300
  const errorHandler = (event) => {
255
301
  if (settled)
256
302
  return;
@@ -258,14 +304,14 @@ function createWorkerTransport(config) {
258
304
  cleanup();
259
305
  subscriber.error(new Error(event.message ?? 'Worker error'));
260
306
  };
261
- worker.addEventListener('message', messageHandler);
262
- worker.addEventListener('error', errorHandler);
307
+ instance.addEventListener('message', messageHandler);
308
+ instance.addEventListener('error', errorHandler);
263
309
  if (transferDetection === 'auto') {
264
310
  const transferables = detectTransferables(request);
265
- worker.postMessage({ type: 'request', requestId, payload: request }, transferables);
311
+ dispatchToWorker(instance, { type: 'request', requestId, payload: request }, transferables);
266
312
  }
267
313
  else {
268
- worker.postMessage({ type: 'request', requestId, payload: request });
314
+ dispatchToWorker(instance, { type: 'request', requestId, payload: request });
269
315
  }
270
316
  if (effectiveTimeout > 0 && Number.isFinite(effectiveTimeout)) {
271
317
  timeoutHandle = setTimeout(() => {
@@ -273,15 +319,10 @@ function createWorkerTransport(config) {
273
319
  return;
274
320
  settled = true;
275
321
  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 });
322
+ dispatchToWorker(instance, { type: 'cancel', requestId });
279
323
  subscriber.error(new WorkerHttpTimeoutError(effectiveTimeout));
280
324
  }, effectiveTimeout);
281
325
  }
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
326
  if (externalSignal) {
286
327
  abortListener = () => {
287
328
  if (settled)
@@ -289,25 +330,20 @@ function createWorkerTransport(config) {
289
330
  settled = true;
290
331
  const reason = externalSignal.reason;
291
332
  cleanup();
292
- worker.postMessage({ type: 'cancel', requestId });
333
+ dispatchToWorker(instance, { type: 'cancel', requestId });
293
334
  subscriber.error(new WorkerHttpAbortError(reason));
294
335
  };
295
336
  externalSignal.addEventListener('abort', abortListener, { once: true });
296
337
  }
297
- // Teardown: send cancel message on unsubscribe
298
338
  return () => {
299
339
  if (settled)
300
340
  return;
301
341
  settled = true;
302
342
  cleanup();
303
- worker.postMessage({ type: 'cancel', requestId });
343
+ dispatchToWorker(instance, { type: 'cancel', requestId });
304
344
  };
305
345
  });
306
346
  }
307
- /**
308
- * Lazy-loads the streams polyfill if enabled and not already loaded.
309
- * Called before operations that might involve stream transfer.
310
- */
311
347
  async function loadStreamsPolyfill() {
312
348
  if (!streamsPolyfill || polyfillLoaded) {
313
349
  return;
@@ -320,25 +356,26 @@ function createWorkerTransport(config) {
320
356
  polyfillLoaded = true;
321
357
  }
322
358
  catch (err) {
323
- // Log warning but don't block request — streams will fail naturally
324
359
  console.warn('[worker-http] Failed to load streams polyfill:', err);
325
360
  }
326
361
  }
327
362
  function terminate() {
328
363
  terminated = true;
329
- for (const worker of workers) {
330
- worker.terminate();
364
+ for (const instance of instances) {
365
+ if (instance.terminate) {
366
+ instance.terminate();
367
+ }
331
368
  }
332
- workers.length = 0;
369
+ instances.length = 0;
333
370
  }
334
371
  return {
335
372
  execute,
336
373
  terminate,
337
374
  get isActive() {
338
- return !terminated && workers.length > 0;
375
+ return !terminated && instances.length > 0;
339
376
  },
340
377
  get activeInstances() {
341
- return workers.length;
378
+ return instances.length;
342
379
  },
343
380
  };
344
381
  }
@@ -347,4 +384,4 @@ function createWorkerTransport(config) {
347
384
  * Generated bundle index. Do not edit.
348
385
  */
349
386
 
350
- export { WorkerHttpAbortError, WorkerHttpTimeoutError, createWorkerTransport, detectTransferables };
387
+ 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.1.0",
3
+ "version": "21.2.2",
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": {
@@ -76,7 +76,8 @@
76
76
  "@angular/common": "^21.0.0",
77
77
  "@angular/core": "^21.0.0",
78
78
  "rxjs": "^7.0.0",
79
- "seroval": "^1.0.0"
79
+ "seroval": "^1.0.0",
80
+ "@toon-format/toon": "^2.0.0"
80
81
  },
81
82
  "peerDependenciesMeta": {
82
83
  "@angular/common": {
@@ -88,6 +89,9 @@
88
89
  "seroval": {
89
90
  "optional": true
90
91
  },
92
+ "@toon-format/toon": {
93
+ "optional": true
94
+ },
91
95
  "esbuild": {
92
96
  "optional": true
93
97
  },
@@ -98,6 +102,7 @@
98
102
  "module": "fesm2022/angular-helpers-worker-http.mjs",
99
103
  "typings": "types/angular-helpers-worker-http.d.ts",
100
104
  "sideEffects": false,
105
+ "type": "module",
101
106
  "dependencies": {
102
107
  "tslib": "^2.3.0"
103
108
  }
@@ -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 };
@@ -63,15 +63,85 @@ declare const structuredCloneSerializer: WorkerSerializer;
63
63
  */
64
64
  declare function createSerovalSerializer(): Promise<WorkerSerializer>;
65
65
 
66
+ /**
67
+ * Creates a `WorkerSerializer` backed by `@toon-format/toon` (Token-Oriented Object Notation).
68
+ *
69
+ * TOON is a compact, schema-aware encoding of the JSON data model that declares object
70
+ * keys once and emits values as CSV-like rows. It typically reduces size by **30–60%**
71
+ * for uniform arrays of objects (e.g. `User[]`, `Product[]`, paginated lists), with
72
+ * negligible parsing overhead.
73
+ *
74
+ * **When to use it**:
75
+ * - Worker↔main `postMessage` payloads dominated by uniform arrays of objects
76
+ * - Cases where `structuredClone` cost is dominated by repeated key strings
77
+ *
78
+ * **When NOT to use it**:
79
+ * - Payloads containing `Date`, `Map`, `Set`, `RegExp` (use `seroval` instead)
80
+ * - Small / single-object payloads (overhead not justified)
81
+ *
82
+ * The factory is async because it dynamically imports the optional `@toon-format/toon` peer.
83
+ *
84
+ * `@toon-format/toon` must be installed separately:
85
+ * ```
86
+ * npm install @toon-format/toon
87
+ * ```
88
+ *
89
+ * @example
90
+ * ```typescript
91
+ * const serializer = await createToonSerializer();
92
+ * const payload = serializer.serialize([
93
+ * { id: 1, name: 'Alice' },
94
+ * { id: 2, name: 'Bob' },
95
+ * { id: 3, name: 'Carol' },
96
+ * { id: 4, name: 'Dave' },
97
+ * { id: 5, name: 'Eve' },
98
+ * ]);
99
+ * worker.postMessage({ payload }, payload.transferables);
100
+ * ```
101
+ *
102
+ * @see https://toonformat.dev
103
+ */
104
+ declare function createToonSerializer(): Promise<WorkerSerializer>;
105
+ /**
106
+ * Conservative threshold below which TOON's overhead outweighs its size benefit.
107
+ * Auto-serializer keeps shorter arrays on `structured-clone`.
108
+ *
109
+ * Exported for testing and for consumers building custom routing logic.
110
+ */
111
+ declare const MIN_UNIFORM_ARRAY_LENGTH = 5;
112
+ /**
113
+ * Detects whether a value is a depth-1 uniform array of plain objects with primitive values.
114
+ *
115
+ * Pure function — no side effects. Used by `createAutoSerializer()` to decide
116
+ * whether to route a payload through TOON.
117
+ *
118
+ * Conditions checked (all must pass):
119
+ * 1. Value is an array with `length >= MIN_UNIFORM_ARRAY_LENGTH`
120
+ * 2. Every item is a non-null, non-array plain object
121
+ * 3. Every item has the same set of keys as the first item
122
+ * 4. Every value across all items is a primitive (string, number, boolean, null)
123
+ *
124
+ * @example
125
+ * ```typescript
126
+ * isUniformObjectArray([{a:1},{a:2},{a:3},{a:4},{a:5}]); // true
127
+ * isUniformObjectArray([{a:1},{b:2}]); // false (heterogeneous keys)
128
+ * isUniformObjectArray([{a:[1,2]},{a:[3,4]}]); // false (nested array value)
129
+ * isUniformObjectArray([{a:1}]); // false (length < 5)
130
+ * ```
131
+ */
132
+ declare function isUniformObjectArray(value: unknown): boolean;
133
+
66
134
  /**
67
135
  * Creates an auto-detecting `WorkerSerializer` that picks the best strategy per payload.
68
136
  *
69
137
  * The factory is async because it pre-loads `seroval` during initialization
70
138
  * so the returned serializer methods are fully synchronous (no await in hot path).
71
139
  *
72
- * Strategy selection per `serialize()` call:
73
- * - Contains `Date`, `Map`, `Set`, or `RegExp` at depth-1 → `seroval` (full fidelity)
74
- * - Otherwise structured clone (native, zero overhead)
140
+ * Strategy selection per `serialize()` call (top-down, first match wins):
141
+ * 1. Contains `Date`, `Map`, `Set`, or `RegExp` at depth-1 → `seroval` (full fidelity)
142
+ * 2. Uniform array of plain objects (length ≥ 5, primitive values, identical key set)
143
+ * → `toon` (30–60% size reduction)
144
+ * 3. Otherwise → structured clone (native, zero overhead)
75
145
  *
76
146
  * Large payloads (> `transferThreshold`, default 100 KiB) are encoded to
77
147
  * `ArrayBuffer` and added to `transferables` for zero-copy `postMessage` transfer.
@@ -85,5 +155,5 @@ declare function createSerovalSerializer(): Promise<WorkerSerializer>;
85
155
  */
86
156
  declare function createAutoSerializer(config?: AutoSerializerConfig): Promise<WorkerSerializer>;
87
157
 
88
- export { createAutoSerializer, createSerovalSerializer, structuredCloneSerializer };
158
+ export { MIN_UNIFORM_ARRAY_LENGTH, createAutoSerializer, createSerovalSerializer, createToonSerializer, isUniformObjectArray, structuredCloneSerializer };
89
159
  export type { AutoSerializerConfig, SerializedPayload, SerializerStrategy, WorkerSerializer };
@@ -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 };