@angular-helpers/worker-http 21.0.0 → 21.2.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
@@ -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
 
@@ -113,6 +116,11 @@ transport.terminate();
113
116
  - **Per-request timeout** (default `30_000` ms) via `requestTimeout`; errors
114
117
  with `WorkerHttpTimeoutError` and sends a cancel message to the worker.
115
118
  Set to `0` to disable.
119
+ - **Per-call `AbortSignal` and `timeout` overrides** via the second argument
120
+ to `execute(request, { signal, timeout })`. The signal triggers a typed
121
+ `WorkerHttpAbortError`; an aborted-at-call-time signal fails fast with no
122
+ postMessage round-trip. Distinct error class from `WorkerHttpTimeoutError`
123
+ so consumers can branch on `instanceof`.
116
124
  - **Opt-in transferable detection** via `transferDetection: 'auto'` — passes
117
125
  detected `ArrayBuffer` / `MessagePort` / `ImageBitmap` /
118
126
  `OffscreenCanvas` / streams as the transfer list of `postMessage`, enabling
@@ -141,12 +149,17 @@ transport.execute(request).subscribe({
141
149
  });
142
150
  ```
143
151
 
152
+ </details>
153
+
144
154
  ---
145
155
 
146
156
  ### `/interceptors` — Worker-side pipeline
147
157
 
148
158
  Pure-function interceptors that run inside the worker. No Angular DI, no DOM access — just `(req, next) => Promise<response>`.
149
159
 
160
+ <details>
161
+ <summary><strong>Setup, built-in interceptors, and custom interceptors</strong></summary>
162
+
150
163
  #### Setup in your worker file
151
164
 
152
165
  ```typescript
@@ -278,11 +291,22 @@ export const authTokenInterceptor: WorkerInterceptorFn = (req, next) => {
278
291
  };
279
292
  ```
280
293
 
294
+ </details>
295
+
281
296
  ---
282
297
 
283
298
  ### `/serializer` — Pluggable serialization
284
299
 
285
- 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.
300
+ Handles the `postMessage` serialization boundary. Three strategies, each with a clear sweet spot:
301
+
302
+ - `structuredCloneSerializer` — zero overhead, default
303
+ - `createToonSerializer()` — 30–60% smaller for uniform arrays of objects
304
+ - `createSerovalSerializer()` — full type fidelity (`Date`, `Map`, `Set`, circular refs)
305
+
306
+ The auto-serializer picks the best strategy per payload.
307
+
308
+ <details>
309
+ <summary><strong>Per-strategy API and examples</strong></summary>
286
310
 
287
311
  #### `structuredCloneSerializer` (default)
288
312
 
@@ -311,14 +335,49 @@ const original = serializer.deserialize(payload);
311
335
  // original.tags instanceof Set → true
312
336
  ```
313
337
 
338
+ #### `createToonSerializer()` — Token-Oriented Object Notation
339
+
340
+ Requires `@toon-format/toon` as an optional peer dependency (`npm install @toon-format/toon`).
341
+
342
+ [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
+ ```typescript
345
+ import { createToonSerializer } from '@angular-helpers/worker-http/serializer';
346
+
347
+ const serializer = await createToonSerializer();
348
+
349
+ const payload = serializer.serialize([
350
+ { id: 1, name: 'Alice', role: 'admin' },
351
+ { id: 2, name: 'Bob', role: 'member' },
352
+ { id: 3, name: 'Carol', role: 'member' },
353
+ { id: 4, name: 'Dave', role: 'guest' },
354
+ { id: 5, name: 'Eve', role: 'admin' },
355
+ ]);
356
+
357
+ // payload.data is a TOON string:
358
+ // [5]{id,name,role}:
359
+ // 1,Alice,admin
360
+ // 2,Bob,member
361
+ // 3,Carol,member
362
+ // 4,Dave,guest
363
+ // 5,Eve,admin
364
+ ```
365
+
366
+ **When TOON shines**: uniform arrays of objects with primitive values (numbers, strings, booleans, nulls) at depth-1.
367
+
368
+ **When TOON does NOT help**: payloads with `Date`, `Map`, `Set`, nested objects, or single objects — use `seroval` or structured clone instead.
369
+
314
370
  #### `createAutoSerializer()` — Smart auto-detection
315
371
 
316
- Automatically picks the best strategy per payload. The factory is async (pre-loads `seroval` during initialization), but the returned serializer is fully synchronous.
372
+ 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.
317
373
 
318
- **Detection logic (depth-1):**
374
+ **Detection logic (depth-1, top-down, first match wins):**
319
375
 
320
- - Contains `Date`, `Map`, `Set`, or `RegExp` at the top level or as direct array/object values → `seroval`
321
- - Otherwise structured clone (zero overhead)
376
+ 1. Contains `Date`, `Map`, `Set`, or `RegExp` at the top level or as direct array/object values → `seroval`
377
+ 2. Uniform array of plain objects with primitive values, length ≥ 5 → `toon`
378
+ 3. Otherwise → structured clone (zero overhead)
379
+
380
+ The TOON threshold is conservative (length ≥ 5). Smaller arrays don't justify the encoding overhead.
322
381
 
323
382
  Payloads larger than `transferThreshold` (default: 100 KiB) are encoded to `ArrayBuffer` and transferred zero-copy.
324
383
 
@@ -341,12 +400,17 @@ auto.serialize(hugeDataset); // transferables: [ArrayBuffer]
341
400
 
342
401
  > **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.
343
402
 
403
+ </details>
404
+
344
405
  ---
345
406
 
346
407
  ### `/crypto` — WebCrypto primitives
347
408
 
348
409
  Standalone WebCrypto utilities. Useful in both workers and the main thread, but workers provide memory isolation for key material.
349
410
 
411
+ <details>
412
+ <summary><strong>HMAC, AES, hashing examples</strong></summary>
413
+
350
414
  #### `createHmacSigner(config)`
351
415
 
352
416
  ```typescript
@@ -381,12 +445,17 @@ const hasher = createContentHasher();
381
445
  const hash = await hasher.hash('SHA-256', data); // → hex string
382
446
  ```
383
447
 
448
+ </details>
449
+
384
450
  ---
385
451
 
386
452
  ### `/backend` — Angular `HttpBackend` replacement
387
453
 
388
454
  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.
389
455
 
456
+ <details>
457
+ <summary><strong>Configuration, providers, and consumer code</strong></summary>
458
+
390
459
  ```typescript
391
460
  // app.config.ts
392
461
  import {
@@ -470,17 +539,23 @@ manual control.
470
539
  - `withWorkerSerialization(serializer)` — plug in `createSerovalSerializer()` for complex request bodies (`Date`, `Map`, `Set`)
471
540
  - `withWorkerInterceptors(specs | specsByWorker)` — configure the worker-side pipeline from Angular DI; pairs with `createConfigurableWorkerPipeline()` in the worker file
472
541
  - `WORKER_TARGET` — `HttpContextToken<string | null>` for per-request worker routing via `HttpContext`
473
- - `WorkerHttpClient` — `HttpClient` wrapper with optional `{ worker: string }` routing field
542
+ - `WorkerHttpClient` — `HttpClient` wrapper with optional `{ worker: string }` routing field, plus per-request `signal?: AbortSignal` and `timeout?: number` for cancellation. Aborted requests error with `WorkerHttpAbortError`; expired timeouts with `WorkerHttpTimeoutError`.
474
543
  - `WorkerHttpBackend` — the `HttpBackend` implementation (injectable for advanced use)
475
544
  - `matchWorkerRoute(url, routes)` — pure utility to test routing rules
545
+ - `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.
546
+
547
+ </details>
476
548
 
477
549
  ---
478
550
 
479
551
  ## Telemetry
480
552
 
481
- Main-thread extension point for APM / metrics. `withTelemetry(...)` registers
482
- a subscriber that fires synchronously at three lifecycle points of every
483
- request handled by `WorkerHttpBackend`:
553
+ 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`).
554
+
555
+ <details>
556
+ <summary><strong>Subscriber semantics, examples, and event interface</strong></summary>
557
+
558
+ Lifecycle points:
484
559
 
485
560
  - **`onRequest`** — after worker resolution, before dispatch
486
561
  - **`onResponse`** — when a successful response is emitted
@@ -547,12 +622,17 @@ interface WorkerHttpTelemetryEventBase {
547
622
  // onError adds: kind: 'error', error, durationMs
548
623
  ```
549
624
 
625
+ </details>
626
+
550
627
  ---
551
628
 
552
629
  ### `/esbuild-plugin` — Interceptor auto-bundling
553
630
 
554
631
  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.
555
632
 
633
+ <details>
634
+ <summary><strong>Plugin options and example</strong></summary>
635
+
556
636
  ```typescript
557
637
  // esbuild.config.ts
558
638
  import { workerHttpPlugin } from '@angular-helpers/worker-http/esbuild-plugin';
@@ -579,12 +659,17 @@ export default {
579
659
 
580
660
  Discovered interceptors are merged with explicit ones. Test files (`.spec.ts`, `.test.ts`) are automatically excluded.
581
661
 
662
+ </details>
663
+
582
664
  ---
583
665
 
584
666
  ### `/streams-polyfill` — Safari transferable streams
585
667
 
586
668
  Safari 16-17 lack native transferable `ReadableStream`/`TransformStream` support. This ponyfill enables stream transfer in workers for those browsers, loaded lazily only when needed.
587
669
 
670
+ <details>
671
+ <summary><strong>Setup and bundle impact</strong></summary>
672
+
588
673
  ```typescript
589
674
  // Enable in your app config (main thread)
590
675
  import { withWorkerStreamsPolyfill } from '@angular-helpers/worker-http/backend';
@@ -602,12 +687,16 @@ provideWorkerHttpClient(
602
687
 
603
688
  **Bundle impact:** Zero for modern browsers. The polyfill is lazy-loaded only on affected Safari versions when streams are actually used.
604
689
 
690
+ </details>
691
+
605
692
  ---
606
693
 
607
694
  ## SSR + hydration
608
695
 
609
- Worker HTTP integrates transparently with Angular SSR. The two problems SSR
610
- creates for worker-based HTTP are handled out of the box:
696
+ 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.
697
+
698
+ <details>
699
+ <summary><strong>How SSR fallback and the transfer cache work</strong></summary>
611
700
 
612
701
  **1. Workers do not exist on the server.**
613
702
  During SSR, `typeof Worker === 'undefined'`. `WorkerHttpBackend` detects this
@@ -657,6 +746,8 @@ To customise which headers are captured or to cache `POST` requests, pass
657
746
  `withHttpTransferCacheOptions(...)` to `provideClientHydration()` — both are
658
747
  re-exported from `@angular/platform-browser`.
659
748
 
749
+ </details>
750
+
660
751
  ---
661
752
 
662
753
  ## Design principles
@@ -671,13 +762,14 @@ re-exported from `@angular/platform-browser`.
671
762
 
672
763
  ## Serialization strategy decision guide
673
764
 
674
- | Payload type | Recommended serializer | Reason |
675
- | ------------------------------------ | -------------------------------------- | --------------------------- |
676
- | Simple objects, arrays of primitives | `structuredCloneSerializer` (default) | Zero overhead |
677
- | Objects with `Date`, `Map`, `Set` | `createSerovalSerializer()` | Full type fidelity |
678
- | Unknown payload shape | `createAutoSerializer()` | Depth-1 auto-detect |
679
- | Large arrays (> 100 KiB) | `createAutoSerializer()` | Auto ArrayBuffer transfer |
680
- | Deeply nested complex types | `createSerovalSerializer()` explicitly | Auto-detect is depth-1 only |
765
+ | Payload type | Recommended serializer | Reason |
766
+ | ------------------------------------------ | -------------------------------------- | --------------------------- |
767
+ | Simple objects, arrays of primitives | `structuredCloneSerializer` (default) | Zero overhead |
768
+ | Uniform array of plain objects (≥ 5 items) | `createToonSerializer()` | 30–60% size reduction |
769
+ | Objects with `Date`, `Map`, `Set` | `createSerovalSerializer()` | Full type fidelity |
770
+ | Unknown payload shape | `createAutoSerializer()` | Depth-1 auto-detect |
771
+ | Large arrays (> 100 KiB) | `createAutoSerializer()` | Auto ArrayBuffer transfer |
772
+ | Deeply nested complex types | `createSerovalSerializer()` explicitly | Auto-detect is depth-1 only |
681
773
 
682
774
  ---
683
775
 
@@ -695,9 +787,12 @@ Server-Side Rendering (SSR) is supported via automatic fallback to the main thre
695
787
 
696
788
  ## Benchmarks
697
789
 
698
- A reproducible benchmark suite ships with the demo app at
699
- [`/demo/worker-http-benchmark`](../../src/app/demo/worker-http-benchmark) and compares three
700
- transport modes across four workloads:
790
+ A reproducible benchmark suite ships with the demo app at [`/demo/worker-http-benchmark`](../../src/app/demo/worker-http-benchmark).
791
+
792
+ <details>
793
+ <summary><strong>Modes, workloads, metrics, and how to run</strong></summary>
794
+
795
+ It compares three transport modes across four workloads:
701
796
 
702
797
  | Mode | What it measures |
703
798
  | --------------- | ---------------------------------------------------------- |
@@ -733,6 +828,8 @@ npm start
733
828
  Numbers vary by hardware, browser, and current system load — always run a scenario several times
734
829
  and watch the trend, not a single value.
735
830
 
831
+ </details>
832
+
736
833
  ---
737
834
 
738
835
  ## Related documentation
@@ -22,6 +22,31 @@ import { createWorkerTransport } from '@angular-helpers/worker-http/transport';
22
22
  * ```
23
23
  */
24
24
  const WORKER_TARGET = new HttpContextToken(() => null);
25
+ /**
26
+ * Per-request HttpContextToken carrying an external `AbortSignal`.
27
+ *
28
+ * When the signal fires, the backend posts a `cancel` message to the worker
29
+ * and the request errors with `WorkerHttpAbortError` (wrapped by Angular into
30
+ * `HttpErrorResponse`).
31
+ *
32
+ * @example
33
+ * ```typescript
34
+ * const ac = new AbortController();
35
+ * this.http.get('/api/users', { signal: ac.signal });
36
+ * // later
37
+ * ac.abort();
38
+ * ```
39
+ */
40
+ const WORKER_HTTP_SIGNAL = new HttpContextToken(() => null);
41
+ /**
42
+ * Per-request HttpContextToken carrying a timeout in milliseconds.
43
+ *
44
+ * When set, overrides the transport-level `requestTimeout` for this single
45
+ * call. `0` or non-finite disables the timeout for this request only.
46
+ *
47
+ * On expiry the request errors with `WorkerHttpTimeoutError`.
48
+ */
49
+ const WORKER_HTTP_TIMEOUT = new HttpContextToken(() => null);
25
50
  /**
26
51
  * Registered worker definitions provided via `withWorkerConfigs()`.
27
52
  */
@@ -188,7 +213,12 @@ class WorkerHttpBackend extends HttpBackend {
188
213
  const payload = body !== serializable.body ? { ...serializable, body } : serializable;
189
214
  const base = this.buildEventBase(req, workerId, 'worker');
190
215
  this.emitRequest(base);
191
- return transport.execute(payload).pipe(map((res) => toHttpResponse(res, req)), tap((event) => {
216
+ const signal = req.context.get(WORKER_HTTP_SIGNAL) ?? undefined;
217
+ const timeout = req.context.get(WORKER_HTTP_TIMEOUT);
218
+ const executeOptions = signal !== undefined || timeout !== null
219
+ ? { signal, timeout: timeout ?? undefined }
220
+ : undefined;
221
+ return transport.execute(payload, executeOptions).pipe(map((res) => toHttpResponse(res, req)), tap((event) => {
192
222
  if (event instanceof HttpResponse) {
193
223
  this.emitResponse(base, event.status);
194
224
  }
@@ -352,11 +382,15 @@ class WorkerHttpClient {
352
382
  return this.http.head(url, this.withWorker(options));
353
383
  }
354
384
  withWorker(options) {
355
- const { worker = null, context, ...rest } = options ?? {};
356
- return {
357
- ...rest,
358
- context: (context ?? new HttpContext()).set(WORKER_TARGET, worker),
359
- };
385
+ const { worker = null, signal, timeout, context, ...rest } = options ?? {};
386
+ let ctx = (context ?? new HttpContext()).set(WORKER_TARGET, worker);
387
+ if (signal !== undefined) {
388
+ ctx = ctx.set(WORKER_HTTP_SIGNAL, signal);
389
+ }
390
+ if (timeout !== undefined) {
391
+ ctx = ctx.set(WORKER_HTTP_TIMEOUT, timeout);
392
+ }
393
+ return { ...rest, context: ctx };
360
394
  }
361
395
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.4", ngImport: i0, type: WorkerHttpClient, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
362
396
  static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.4", ngImport: i0, type: WorkerHttpClient });
@@ -603,4 +637,4 @@ function withWorkerStreamsPolyfill() {
603
637
  * Generated bundle index. Do not edit.
604
638
  */
605
639
 
606
- export { WORKER_HTTP_INTERCEPTORS_TOKEN, WORKER_HTTP_SERIALIZER_TOKEN, WORKER_HTTP_STREAMS_POLYFILL_TOKEN, WORKER_TARGET, WorkerHttpBackend, WorkerHttpClient, matchWorkerRoute, provideWorkerHttpClient, toHttpResponse, toSerializableRequest, withTelemetry, withWorkerConfigs, withWorkerFallback, withWorkerInterceptors, withWorkerRoutes, withWorkerSerialization, withWorkerStreamsPolyfill };
640
+ export { WORKER_HTTP_INTERCEPTORS_TOKEN, WORKER_HTTP_SERIALIZER_TOKEN, WORKER_HTTP_SIGNAL, WORKER_HTTP_STREAMS_POLYFILL_TOKEN, WORKER_HTTP_TIMEOUT, WORKER_TARGET, WorkerHttpBackend, WorkerHttpClient, matchWorkerRoute, provideWorkerHttpClient, toHttpResponse, toSerializableRequest, withTelemetry, withWorkerConfigs, withWorkerFallback, withWorkerInterceptors, withWorkerRoutes, withWorkerSerialization, withWorkerStreamsPolyfill };
@@ -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
  }
@@ -168,6 +312,13 @@ async function createAutoSerializer(config) {
168
312
  }
169
313
  return sv.deserialize(resolved);
170
314
  }
315
+ if (resolved.format === 'toon') {
316
+ if (!toon) {
317
+ throw new Error('@toon-format/toon is required to deserialize this payload. ' +
318
+ 'Install it with: npm install @toon-format/toon');
319
+ }
320
+ return toon.deserialize(resolved);
321
+ }
171
322
  throw new Error(`Unknown serialization format: '${resolved.format}'`);
172
323
  },
173
324
  };
@@ -177,4 +328,4 @@ async function createAutoSerializer(config) {
177
328
  * Generated bundle index. Do not edit.
178
329
  */
179
330
 
180
- export { createAutoSerializer, createSerovalSerializer, structuredCloneSerializer };
331
+ export { MIN_UNIFORM_ARRAY_LENGTH, createAutoSerializer, createSerovalSerializer, createToonSerializer, isUniformObjectArray, structuredCloneSerializer };
@@ -74,6 +74,41 @@ function isTransferable(value) {
74
74
  return false;
75
75
  }
76
76
 
77
+ /**
78
+ * Thrown by `createWorkerTransport` when a request is aborted via an external
79
+ * `AbortSignal` passed to `execute(request, { signal })`.
80
+ *
81
+ * Distinct from `WorkerHttpTimeoutError` (which fires when the per-request
82
+ * `timeout` elapses) and from a silent unsubscribe (which sends a `cancel`
83
+ * message but does not surface an error to the subscriber, since RxJS already
84
+ * tore down the stream).
85
+ *
86
+ * @example
87
+ * ```typescript
88
+ * const ac = new AbortController();
89
+ * transport.execute(req, { signal: ac.signal }).subscribe({
90
+ * error: (err) => {
91
+ * if (err instanceof WorkerHttpAbortError) {
92
+ * // user-driven cancellation; usually safe to ignore in UI
93
+ * }
94
+ * },
95
+ * });
96
+ * ac.abort('user navigated away');
97
+ * ```
98
+ */
99
+ class WorkerHttpAbortError extends Error {
100
+ name = 'WorkerHttpAbortError';
101
+ /** The reason passed to `AbortController.abort(reason)`, if any. */
102
+ reason;
103
+ constructor(reason) {
104
+ super(reason === undefined
105
+ ? 'Worker request aborted'
106
+ : `Worker request aborted: ${reason instanceof Error ? reason.message : String(reason)}`);
107
+ this.reason = reason;
108
+ Object.setPrototypeOf(this, new.target.prototype);
109
+ }
110
+ }
111
+
77
112
  /**
78
113
  * Thrown by `createWorkerTransport` when a request exceeds its configured
79
114
  * `requestTimeout`. Consumers can `instanceof`-check this error to distinguish
@@ -161,14 +196,22 @@ function createWorkerTransport(config) {
161
196
  roundRobinIndex++;
162
197
  return worker;
163
198
  }
164
- function execute(request) {
199
+ function execute(request, options) {
165
200
  if (terminated) {
166
201
  return new Observable((subscriber) => {
167
202
  subscriber.error(new Error('WorkerTransport has been terminated'));
168
203
  });
169
204
  }
170
205
  const requestId = crypto.randomUUID();
206
+ const externalSignal = options?.signal;
207
+ const effectiveTimeout = options?.timeout ?? requestTimeout;
171
208
  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
+ if (externalSignal?.aborted) {
212
+ subscriber.error(new WorkerHttpAbortError(externalSignal.reason));
213
+ return () => undefined;
214
+ }
172
215
  // Lazy-load polyfill on first request if enabled
173
216
  if (streamsPolyfill && !polyfillLoaded) {
174
217
  loadStreamsPolyfill().catch((err) => {
@@ -178,6 +221,7 @@ function createWorkerTransport(config) {
178
221
  const worker = getOrCreateWorker();
179
222
  let settled = false;
180
223
  let timeoutHandle;
224
+ let abortListener;
181
225
  const cleanup = () => {
182
226
  worker.removeEventListener('message', messageHandler);
183
227
  worker.removeEventListener('error', errorHandler);
@@ -185,6 +229,10 @@ function createWorkerTransport(config) {
185
229
  clearTimeout(timeoutHandle);
186
230
  timeoutHandle = undefined;
187
231
  }
232
+ if (abortListener && externalSignal) {
233
+ externalSignal.removeEventListener('abort', abortListener);
234
+ abortListener = undefined;
235
+ }
188
236
  };
189
237
  const messageHandler = (event) => {
190
238
  const data = event.data;
@@ -219,7 +267,7 @@ function createWorkerTransport(config) {
219
267
  else {
220
268
  worker.postMessage({ type: 'request', requestId, payload: request });
221
269
  }
222
- if (requestTimeout > 0 && Number.isFinite(requestTimeout)) {
270
+ if (effectiveTimeout > 0 && Number.isFinite(effectiveTimeout)) {
223
271
  timeoutHandle = setTimeout(() => {
224
272
  if (settled)
225
273
  return;
@@ -228,8 +276,23 @@ function createWorkerTransport(config) {
228
276
  // Ask the worker to abort any in-flight work for this id. The
229
277
  // cancellation fix wires this through to `fetch()`.
230
278
  worker.postMessage({ type: 'cancel', requestId });
231
- subscriber.error(new WorkerHttpTimeoutError(requestTimeout));
232
- }, requestTimeout);
279
+ subscriber.error(new WorkerHttpTimeoutError(effectiveTimeout));
280
+ }, effectiveTimeout);
281
+ }
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
+ if (externalSignal) {
286
+ abortListener = () => {
287
+ if (settled)
288
+ return;
289
+ settled = true;
290
+ const reason = externalSignal.reason;
291
+ cleanup();
292
+ worker.postMessage({ type: 'cancel', requestId });
293
+ subscriber.error(new WorkerHttpAbortError(reason));
294
+ };
295
+ externalSignal.addEventListener('abort', abortListener, { once: true });
233
296
  }
234
297
  // Teardown: send cancel message on unsubscribe
235
298
  return () => {
@@ -284,4 +347,4 @@ function createWorkerTransport(config) {
284
347
  * Generated bundle index. Do not edit.
285
348
  */
286
349
 
287
- export { WorkerHttpTimeoutError, createWorkerTransport, detectTransferables };
350
+ export { WorkerHttpAbortError, WorkerHttpTimeoutError, createWorkerTransport, detectTransferables };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@angular-helpers/worker-http",
3
- "version": "21.0.0",
3
+ "version": "21.2.0",
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
  },
@@ -168,6 +168,31 @@ type WorkerInterceptorSpecsMap = Readonly<Record<string, readonly WorkerIntercep
168
168
  * ```
169
169
  */
170
170
  declare const WORKER_TARGET: HttpContextToken<string>;
171
+ /**
172
+ * Per-request HttpContextToken carrying an external `AbortSignal`.
173
+ *
174
+ * When the signal fires, the backend posts a `cancel` message to the worker
175
+ * and the request errors with `WorkerHttpAbortError` (wrapped by Angular into
176
+ * `HttpErrorResponse`).
177
+ *
178
+ * @example
179
+ * ```typescript
180
+ * const ac = new AbortController();
181
+ * this.http.get('/api/users', { signal: ac.signal });
182
+ * // later
183
+ * ac.abort();
184
+ * ```
185
+ */
186
+ declare const WORKER_HTTP_SIGNAL: HttpContextToken<AbortSignal>;
187
+ /**
188
+ * Per-request HttpContextToken carrying a timeout in milliseconds.
189
+ *
190
+ * When set, overrides the transport-level `requestTimeout` for this single
191
+ * call. `0` or non-finite disables the timeout for this request only.
192
+ *
193
+ * On expiry the request errors with `WorkerHttpTimeoutError`.
194
+ */
195
+ declare const WORKER_HTTP_TIMEOUT: HttpContextToken<number>;
171
196
  /**
172
197
  * Optional serializer for crossing the worker boundary.
173
198
  * Provided via `withWorkerSerialization()`. Defaults to `null` (structured clone).
@@ -422,6 +447,20 @@ declare class WorkerHttpBackend extends HttpBackend implements OnDestroy {
422
447
  interface WorkerRequestOptions {
423
448
  /** Target worker ID. Overrides URL-pattern routing for this specific request. */
424
449
  worker?: string | null;
450
+ /**
451
+ * External `AbortSignal`. When it fires, the backend posts a `cancel` to
452
+ * the worker and the request errors with `WorkerHttpAbortError` (wrapped in
453
+ * `HttpErrorResponse`). Useful with `AbortController` or
454
+ * `takeUntilDestroyed()`.
455
+ */
456
+ signal?: AbortSignal;
457
+ /**
458
+ * Per-request timeout in milliseconds. Overrides the transport-level
459
+ * `requestTimeout` for this single call. On expiry the request errors with
460
+ * `WorkerHttpTimeoutError`. `0` or non-finite disables the timeout for this
461
+ * request only.
462
+ */
463
+ timeout?: number;
425
464
  context?: HttpContext;
426
465
  headers?: Record<string, string | string[]>;
427
466
  params?: Record<string, string | number | boolean | ReadonlyArray<string | number | boolean>>;
@@ -494,5 +533,5 @@ declare function matchWorkerRoute(url: string, routes: Array<{
494
533
  priority?: number;
495
534
  }>): string | null;
496
535
 
497
- export { WORKER_HTTP_INTERCEPTORS_TOKEN, WORKER_HTTP_SERIALIZER_TOKEN, WORKER_HTTP_STREAMS_POLYFILL_TOKEN, WORKER_TARGET, WorkerHttpBackend, WorkerHttpClient, matchWorkerRoute, provideWorkerHttpClient, toHttpResponse, toSerializableRequest, withTelemetry, withWorkerConfigs, withWorkerFallback, withWorkerInterceptors, withWorkerRoutes, withWorkerSerialization, withWorkerStreamsPolyfill };
536
+ export { WORKER_HTTP_INTERCEPTORS_TOKEN, WORKER_HTTP_SERIALIZER_TOKEN, WORKER_HTTP_SIGNAL, WORKER_HTTP_STREAMS_POLYFILL_TOKEN, WORKER_HTTP_TIMEOUT, WORKER_TARGET, WorkerHttpBackend, WorkerHttpClient, matchWorkerRoute, provideWorkerHttpClient, toHttpResponse, toSerializableRequest, withTelemetry, withWorkerConfigs, withWorkerFallback, withWorkerInterceptors, withWorkerRoutes, withWorkerSerialization, withWorkerStreamsPolyfill };
498
537
  export type { SerializableRequest, SerializableResponse, WorkerConfig, WorkerFallbackStrategy, WorkerHttpErrorEvent, WorkerHttpFeature, WorkerHttpFeatureKind, WorkerHttpRequestEvent, WorkerHttpResponseEvent, WorkerHttpTelemetry, WorkerHttpTelemetryEventBase, WorkerHttpTransportKind, WorkerInterceptorSpecsMap, WorkerRequestOptions, WorkerRoute };
@@ -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 };
@@ -109,13 +109,26 @@ interface WorkerErrorResponse {
109
109
  statusText?: string;
110
110
  };
111
111
  }
112
+ /**
113
+ * Per-request options accepted by `WorkerTransport.execute()`.
114
+ *
115
+ * - `signal` — external `AbortSignal`. When it fires, the transport posts a
116
+ * `cancel` message to the worker and rejects the Observable with
117
+ * `WorkerHttpAbortError`.
118
+ * - `timeout` — overrides the transport-level `requestTimeout` for this single
119
+ * call. `0` or non-finite disables the timeout for this request.
120
+ */
121
+ interface WorkerExecuteOptions {
122
+ signal?: AbortSignal;
123
+ timeout?: number;
124
+ }
112
125
  /**
113
126
  * Typed transport interface for communicating with a web worker.
114
127
  * Observable-based: unsubscribing sends a cancel message to the worker.
115
128
  */
116
129
  interface WorkerTransport<TRequest = unknown, TResponse = unknown> {
117
130
  /** Send a request to the worker and get an Observable response */
118
- execute(request: TRequest): Observable<TResponse>;
131
+ execute(request: TRequest, options?: WorkerExecuteOptions): Observable<TResponse>;
119
132
  /** Terminate all workers and release resources */
120
133
  terminate(): void;
121
134
  /** Whether the transport has active workers */
@@ -173,6 +186,35 @@ declare class WorkerHttpTimeoutError extends Error {
173
186
  constructor(timeoutMs: number);
174
187
  }
175
188
 
189
+ /**
190
+ * Thrown by `createWorkerTransport` when a request is aborted via an external
191
+ * `AbortSignal` passed to `execute(request, { signal })`.
192
+ *
193
+ * Distinct from `WorkerHttpTimeoutError` (which fires when the per-request
194
+ * `timeout` elapses) and from a silent unsubscribe (which sends a `cancel`
195
+ * message but does not surface an error to the subscriber, since RxJS already
196
+ * tore down the stream).
197
+ *
198
+ * @example
199
+ * ```typescript
200
+ * const ac = new AbortController();
201
+ * transport.execute(req, { signal: ac.signal }).subscribe({
202
+ * error: (err) => {
203
+ * if (err instanceof WorkerHttpAbortError) {
204
+ * // user-driven cancellation; usually safe to ignore in UI
205
+ * }
206
+ * },
207
+ * });
208
+ * ac.abort('user navigated away');
209
+ * ```
210
+ */
211
+ declare class WorkerHttpAbortError extends Error {
212
+ readonly name = "WorkerHttpAbortError";
213
+ /** The reason passed to `AbortController.abort(reason)`, if any. */
214
+ readonly reason: unknown;
215
+ constructor(reason?: unknown);
216
+ }
217
+
176
218
  /**
177
219
  * Scans a payload one level deep and collects every `Transferable` instance
178
220
  * (ArrayBuffer, MessagePort, ImageBitmap, OffscreenCanvas, ReadableStream,
@@ -193,5 +235,5 @@ declare class WorkerHttpTimeoutError extends Error {
193
235
  */
194
236
  declare function detectTransferables(payload: unknown): Transferable[];
195
237
 
196
- export { WorkerHttpTimeoutError, createWorkerTransport, detectTransferables };
197
- export type { WorkerErrorResponse, WorkerMessage, WorkerResponse, WorkerTransport, WorkerTransportConfig };
238
+ export { WorkerHttpAbortError, WorkerHttpTimeoutError, createWorkerTransport, detectTransferables };
239
+ export type { WorkerErrorResponse, WorkerExecuteOptions, WorkerMessage, WorkerResponse, WorkerTransport, WorkerTransportConfig };