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