@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 +112 -20
- package/fesm2022/angular-helpers-worker-http-backend.mjs +11 -1
- package/fesm2022/angular-helpers-worker-http-interceptors.mjs +70 -19
- package/fesm2022/angular-helpers-worker-http-serializer.mjs +157 -5
- package/fesm2022/angular-helpers-worker-http-transport.mjs +107 -70
- package/package.json +7 -2
- package/types/angular-helpers-worker-http-backend.d.ts +13 -0
- package/types/angular-helpers-worker-http-interceptors.d.ts +16 -1
- package/types/angular-helpers-worker-http-serializer.d.ts +74 -4
- package/types/angular-helpers-worker-http-transport.d.ts +36 -28
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.
|
|
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
|
-
|
|
326
|
-
|
|
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
|
-
|
|
489
|
-
|
|
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.
|
|
616
|
-
|
|
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
|
|
681
|
-
|
|
|
682
|
-
| Simple objects, arrays of primitives
|
|
683
|
-
|
|
|
684
|
-
|
|
|
685
|
-
|
|
|
686
|
-
|
|
|
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
|
-
|
|
706
|
-
|
|
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: () =>
|
|
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
|
-
*
|
|
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
|
|
83
|
+
function attachPortLoop(port, chain) {
|
|
91
84
|
const controllers = new Map();
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
-
|
|
114
|
+
responseBuffer.push({ type: 'response', requestId, result: response });
|
|
115
|
+
scheduleFlush();
|
|
108
116
|
}
|
|
109
117
|
catch (error) {
|
|
110
|
-
|
|
118
|
+
responseBuffer.push({
|
|
111
119
|
type: 'error',
|
|
112
120
|
requestId,
|
|
113
121
|
error: {
|
|
114
|
-
message: error
|
|
115
|
-
name: error
|
|
116
|
-
stack: error
|
|
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
|
-
|
|
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
|
-
*
|
|
112
|
-
*
|
|
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
|
|
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
|
|
201
|
+
function createInstance(index) {
|
|
202
|
+
let worker;
|
|
175
203
|
if (config.workerFactory) {
|
|
176
|
-
|
|
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
|
-
|
|
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
|
-
|
|
221
|
+
return wrapWorker(worker);
|
|
185
222
|
}
|
|
186
|
-
function
|
|
187
|
-
if (
|
|
188
|
-
const
|
|
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
|
-
|
|
230
|
+
instance.postMessage(config.initMessage);
|
|
191
231
|
}
|
|
192
|
-
|
|
193
|
-
return
|
|
232
|
+
instances.push(instance);
|
|
233
|
+
return instance;
|
|
194
234
|
}
|
|
195
|
-
const
|
|
235
|
+
const instance = instances[roundRobinIndex % instances.length];
|
|
196
236
|
roundRobinIndex++;
|
|
197
|
-
return
|
|
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
|
|
258
|
+
const instance = getOrCreateInstance();
|
|
222
259
|
let settled = false;
|
|
223
260
|
let timeoutHandle;
|
|
224
261
|
let abortListener;
|
|
225
262
|
const cleanup = () => {
|
|
226
|
-
|
|
227
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
262
|
-
|
|
307
|
+
instance.addEventListener('message', messageHandler);
|
|
308
|
+
instance.addEventListener('error', errorHandler);
|
|
263
309
|
if (transferDetection === 'auto') {
|
|
264
310
|
const transferables = detectTransferables(request);
|
|
265
|
-
|
|
311
|
+
dispatchToWorker(instance, { type: 'request', requestId, payload: request }, transferables);
|
|
266
312
|
}
|
|
267
313
|
else {
|
|
268
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
330
|
-
|
|
364
|
+
for (const instance of instances) {
|
|
365
|
+
if (instance.terminate) {
|
|
366
|
+
instance.terminate();
|
|
367
|
+
}
|
|
331
368
|
}
|
|
332
|
-
|
|
369
|
+
instances.length = 0;
|
|
333
370
|
}
|
|
334
371
|
return {
|
|
335
372
|
execute,
|
|
336
373
|
terminate,
|
|
337
374
|
get isActive() {
|
|
338
|
-
return !terminated &&
|
|
375
|
+
return !terminated && instances.length > 0;
|
|
339
376
|
},
|
|
340
377
|
get activeInstances() {
|
|
341
|
-
return
|
|
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.
|
|
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
|
-
*
|
|
74
|
-
*
|
|
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
|
|
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
|
-
|
|
239
|
-
|
|
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 };
|