@angular-helpers/worker-http 0.6.0 → 1.0.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
@@ -16,13 +16,15 @@ On top of that, workers provide a natural isolation boundary for security-sensit
16
16
 
17
17
  ## Package map
18
18
 
19
- | Entry point | Description | Status |
20
- | ------------------------------------------- | ---------------------------------------------------------------- | ------------ |
21
- | `@angular-helpers/worker-http/transport` | Typed RPC bridge, round-robin pool, cancellation | ✅ Available |
22
- | `@angular-helpers/worker-http/serializer` | Pluggable serialization (structured clone, seroval, auto-detect) | ✅ Available |
23
- | `@angular-helpers/worker-http/interceptors` | Pure-function interceptor pipeline for workers | ✅ Available |
24
- | `@angular-helpers/worker-http/crypto` | WebCrypto primitives (HMAC, AES-GCM, SHA hashing) | ✅ Available |
25
- | `@angular-helpers/worker-http/backend` | Angular `HttpBackend` replacement — `provideWorkerHttpClient()` | ✅ Available |
19
+ | Entry point | Description | Status |
20
+ | ----------------------------------------------- | ---------------------------------------------------------------- | ------------ |
21
+ | `@angular-helpers/worker-http/transport` | Typed RPC bridge, round-robin pool, cancellation | ✅ Available |
22
+ | `@angular-helpers/worker-http/serializer` | Pluggable serialization (structured clone, seroval, auto-detect) | ✅ Available |
23
+ | `@angular-helpers/worker-http/interceptors` | Pure-function interceptor pipeline for workers | ✅ Available |
24
+ | `@angular-helpers/worker-http/crypto` | WebCrypto primitives (HMAC, AES-GCM, SHA hashing) | ✅ Available |
25
+ | `@angular-helpers/worker-http/backend` | Angular `HttpBackend` replacement — `provideWorkerHttpClient()` | ✅ Available |
26
+ | `@angular-helpers/worker-http/esbuild-plugin` | esbuild plugin for auto-bundling interceptors into workers | ✅ Available |
27
+ | `@angular-helpers/worker-http/streams-polyfill` | Safari streams ponyfill for transferable stream support | ✅ Available |
26
28
 
27
29
  ---
28
30
 
@@ -43,6 +45,43 @@ Angular HttpClient createWorkerPipeline([
43
45
 
44
46
  ---
45
47
 
48
+ ## Installation
49
+
50
+ ### Quick setup with ng-add
51
+
52
+ The easiest way to get started is using the Angular CLI schematic:
53
+
54
+ ```bash
55
+ ng add @angular-helpers/worker-http
56
+ ```
57
+
58
+ This will:
59
+
60
+ 1. Install the package
61
+ 2. Create a worker file at `src/app/workers/http-api.worker.ts`
62
+ 3. Update `tsconfig.json` with webworker lib
63
+ 4. Add `provideWorkerHttpClient()` to your `app.config.ts`
64
+
65
+ **Options:**
66
+
67
+ ```bash
68
+ # Custom worker path
69
+ ng add @angular-helpers/worker-http --workerPath=src/workers/api.worker.ts
70
+
71
+ # Configure esbuild plugin (for custom build setups)
72
+ ng add @angular-helpers/worker-http --installEsbuildPlugin=true
73
+ ```
74
+
75
+ ### Manual installation
76
+
77
+ ```bash
78
+ npm install @angular-helpers/worker-http
79
+ ```
80
+
81
+ Then follow the setup in the `/backend` section below.
82
+
83
+ ---
84
+
46
85
  ## Entry points
47
86
 
48
87
  ### `/transport` — Typed RPC bridge
@@ -67,9 +106,40 @@ transport.terminate();
67
106
  **Features:**
68
107
 
69
108
  - Round-robin pool (`maxInstances`) for parallel request handling
70
- - Request cancellation via `AbortController` in the worker
71
- - Automatic `Transferable` detection for zero-copy `ArrayBuffer` transfer
72
109
  - Lazy worker instantiation — no worker created until first request
110
+ - **Cancellation that actually aborts `fetch()`** — unsubscribing posts a
111
+ cancel message; the worker-side message loop threads an `AbortSignal` all
112
+ the way into `fetch()` so the in-flight HTTP request is truly aborted
113
+ - **Per-request timeout** (default `30_000` ms) via `requestTimeout`; errors
114
+ with `WorkerHttpTimeoutError` and sends a cancel message to the worker.
115
+ Set to `0` to disable.
116
+ - **Opt-in transferable detection** via `transferDetection: 'auto'` — passes
117
+ detected `ArrayBuffer` / `MessagePort` / `ImageBitmap` /
118
+ `OffscreenCanvas` / streams as the transfer list of `postMessage`, enabling
119
+ zero-copy transfer of large buffers. Default is `'none'` to preserve the
120
+ caller's access to the original data after post.
121
+
122
+ ```typescript
123
+ import {
124
+ createWorkerTransport,
125
+ WorkerHttpTimeoutError,
126
+ } from '@angular-helpers/worker-http/transport';
127
+
128
+ const transport = createWorkerTransport({
129
+ workerUrl: new URL('./workers/api.worker', import.meta.url),
130
+ maxInstances: 2,
131
+ requestTimeout: 10_000, // override default 30 s
132
+ transferDetection: 'auto', // zero-copy ArrayBuffer at postMessage
133
+ });
134
+
135
+ transport.execute(request).subscribe({
136
+ error: (err) => {
137
+ if (err instanceof WorkerHttpTimeoutError) {
138
+ // dedicated timeout handling
139
+ }
140
+ },
141
+ });
142
+ ```
73
143
 
74
144
  ---
75
145
 
@@ -479,6 +549,61 @@ interface WorkerHttpTelemetryEventBase {
479
549
 
480
550
  ---
481
551
 
552
+ ### `/esbuild-plugin` — Interceptor auto-bundling
553
+
554
+ 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
+
556
+ ```typescript
557
+ // esbuild.config.ts
558
+ import { workerHttpPlugin } from '@angular-helpers/worker-http/esbuild-plugin';
559
+
560
+ export default {
561
+ plugins: [
562
+ workerHttpPlugin({
563
+ // Explicit interceptors (relative to project root)
564
+ interceptors: ['./src/interceptors/auth.ts', './src/interceptors/logging.ts'],
565
+
566
+ // Or auto-discover all files matching interceptor naming pattern
567
+ autoDiscover: true,
568
+ }),
569
+ ],
570
+ };
571
+ ```
572
+
573
+ **Options:**
574
+
575
+ | Option | Type | Default | Description |
576
+ | -------------- | ---------- | ------- | ------------------------------------------------------ |
577
+ | `interceptors` | `string[]` | `[]` | Explicit list of interceptor paths to bundle |
578
+ | `autoDiscover` | `boolean` | `false` | Scan `src/` for files matching `*interceptor*` pattern |
579
+
580
+ Discovered interceptors are merged with explicit ones. Test files (`.spec.ts`, `.test.ts`) are automatically excluded.
581
+
582
+ ---
583
+
584
+ ### `/streams-polyfill` — Safari transferable streams
585
+
586
+ 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
+
588
+ ```typescript
589
+ // Enable in your app config (main thread)
590
+ import { withWorkerStreamsPolyfill } from '@angular-helpers/worker-http/backend';
591
+
592
+ provideWorkerHttpClient(
593
+ withWorkerConfigs([...]),
594
+ withWorkerStreamsPolyfill(), // Enable Safari 16-17 compatibility
595
+ );
596
+ ```
597
+
598
+ **When to use:**
599
+
600
+ - Your app uses `responseType: 'stream'` and targets Safari 16-17
601
+ - You see `DataCloneError` when transferring streams to/from workers
602
+
603
+ **Bundle impact:** Zero for modern browsers. The polyfill is lazy-loaded only on affected Safari versions when streams are actually used.
604
+
605
+ ---
606
+
482
607
  ## SSR + hydration
483
608
 
484
609
  Worker HTTP integrates transparently with Angular SSR. The two problems SSR
@@ -62,6 +62,15 @@ const WORKER_HTTP_INTERCEPTORS_TOKEN = new InjectionToken('WorkerHttpInterceptor
62
62
  * affects the HTTP request).
63
63
  */
64
64
  const WORKER_HTTP_TELEMETRY_TOKEN = new InjectionToken('WorkerHttpTelemetry', { factory: () => [] });
65
+ /**
66
+ * Enable Safari streams polyfill for transferable ReadableStream/TransformStream
67
+ * support in workers. Provided via `withWorkerStreamsPolyfill()`.
68
+ * Defaults to `false` (native streams only).
69
+ *
70
+ * When enabled, the transport dynamically imports `@angular-helpers/worker-http/streams-polyfill`
71
+ * on Safari 16-17 to enable stream transfer via postMessage.
72
+ */
73
+ const WORKER_HTTP_STREAMS_POLYFILL_TOKEN = new InjectionToken('WorkerHttpStreamsPolyfill', { factory: () => false });
65
74
 
66
75
  /**
67
76
  * Converts an Angular `HttpRequest` into a structured-clone-safe POJO
@@ -156,6 +165,7 @@ class WorkerHttpBackend extends HttpBackend {
156
165
  interceptorSpecs = inject(WORKER_HTTP_INTERCEPTORS_TOKEN);
157
166
  fetchBackend = inject(FetchBackend, { optional: true });
158
167
  telemetry = inject(WORKER_HTTP_TELEMETRY_TOKEN);
168
+ streamsPolyfill = inject(WORKER_HTTP_STREAMS_POLYFILL_TOKEN);
159
169
  transports = new Map();
160
170
  handle(req) {
161
171
  if (typeof Worker === 'undefined') {
@@ -208,6 +218,7 @@ class WorkerHttpBackend extends HttpBackend {
208
218
  workerFactory: () => new Worker(config.workerUrl, { type: 'module' }),
209
219
  maxInstances: config.maxInstances ?? 1,
210
220
  initMessage: specs.length > 0 ? { type: 'init-interceptors', specs } : undefined,
221
+ streamsPolyfill: this.streamsPolyfill,
211
222
  });
212
223
  this.transports.set(config.id, transport);
213
224
  return transport;
@@ -285,6 +296,7 @@ class WorkerHttpBackend extends HttpBackend {
285
296
  }
286
297
  catch (telemetryError) {
287
298
  // A throwing telemetry subscriber must never affect the HTTP request.
299
+ // oxlint-disable-next-line no-console -- defensive log when user-provided telemetry throws
288
300
  console.error('[WorkerHttpBackend] telemetry subscriber threw:', telemetryError);
289
301
  }
290
302
  }
@@ -557,9 +569,38 @@ function withTelemetry(telemetry) {
557
569
  providers: [{ provide: WORKER_HTTP_TELEMETRY_TOKEN, useValue: telemetry, multi: true }],
558
570
  };
559
571
  }
572
+ /**
573
+ * Enables the Safari streams polyfill for transferable ReadableStream/TransformStream
574
+ * support in Web Workers.
575
+ *
576
+ * Safari 16-17 lacks native transferable streams. When this feature is enabled,
577
+ * the transport layer dynamically loads a ponyfill that enables stream transfer
578
+ * via postMessage on affected browsers.
579
+ *
580
+ * **When to use:**
581
+ * - Your application uses `responseType: 'stream'` and targets Safari 16-17
582
+ * - You see "DataCloneError" when transferring streams to/from workers
583
+ *
584
+ * **Bundle impact:** The polyfill is lazy-loaded only on Safari 16-17 when
585
+ * streams are actually used. Non-Safari browsers and modern Safari pay 0 bytes.
586
+ *
587
+ * @example
588
+ * ```typescript
589
+ * provideWorkerHttpClient(
590
+ * withWorkerConfigs([...]),
591
+ * withWorkerStreamsPolyfill(), // Enable for Safari compatibility
592
+ * )
593
+ * ```
594
+ */
595
+ function withWorkerStreamsPolyfill() {
596
+ return {
597
+ kind: 'StreamsPolyfill',
598
+ providers: [{ provide: WORKER_HTTP_STREAMS_POLYFILL_TOKEN, useValue: true }],
599
+ };
600
+ }
560
601
 
561
602
  /**
562
603
  * Generated bundle index. Do not edit.
563
604
  */
564
605
 
565
- export { WORKER_HTTP_INTERCEPTORS_TOKEN, WORKER_HTTP_SERIALIZER_TOKEN, WORKER_TARGET, WorkerHttpBackend, WorkerHttpClient, matchWorkerRoute, provideWorkerHttpClient, toHttpResponse, toSerializableRequest, withTelemetry, withWorkerConfigs, withWorkerFallback, withWorkerInterceptors, withWorkerRoutes, withWorkerSerialization };
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 };
@@ -80,9 +80,13 @@ async function createAesEncryptor(config) {
80
80
  ? new Uint8Array(config.keyMaterial.buffer.slice(0))
81
81
  : new Uint8Array(config.keyMaterial);
82
82
  const cryptoKey = await crypto.subtle.importKey('raw', keyMaterial, { name: algorithm, length: keyLength }, false, ['encrypt', 'decrypt']);
83
+ // AES-GCM uses a 96-bit (12-byte) IV per NIST SP 800-38D recommendation.
84
+ // AES-CBC and AES-CTR require exactly one block (16 bytes) for their IV /
85
+ // counter respectively; passing 12 bytes causes `OperationError`.
86
+ const ivLength = algorithm === 'AES-GCM' ? 12 : 16;
83
87
  return {
84
88
  async encrypt(data) {
85
- const iv = crypto.getRandomValues(new Uint8Array(12));
89
+ const iv = crypto.getRandomValues(new Uint8Array(ivLength));
86
90
  const params = algorithm === 'AES-GCM'
87
91
  ? { name: algorithm, iv }
88
92
  : algorithm === 'AES-CBC'
@@ -0,0 +1,170 @@
1
+ import * as fs from 'node:fs';
2
+ import * as path from 'node:path';
3
+
4
+ /**
5
+ * esbuild plugin for worker-http that bundles interceptors into worker files.
6
+ *
7
+ * This plugin:
8
+ * 1. Intercepts worker file builds
9
+ * 2. Discovers interceptor files if autoDiscover is true
10
+ * 3. Injects interceptor imports into the worker bootstrap
11
+ * 4. Ensures interceptors are available in the worker's interceptor pipeline
12
+ *
13
+ * @param options - Plugin configuration
14
+ * @returns esbuild Plugin
15
+ *
16
+ * @example
17
+ * ```typescript
18
+ * import { workerHttpPlugin } from '@angular-helpers/worker-http/esbuild-plugin';
19
+ *
20
+ * const config = {
21
+ * plugins: [
22
+ * workerHttpPlugin({
23
+ * autoDiscover: true,
24
+ * })
25
+ * ]
26
+ * };
27
+ * ```
28
+ */
29
+ function workerHttpPlugin(options = {}) {
30
+ const { interceptors = [], autoDiscover = false } = options;
31
+ return {
32
+ name: 'worker-http',
33
+ setup(build) {
34
+ const filter = /\.worker\.(ts|js|mjs)$/;
35
+ const discoveredInterceptors = [];
36
+ // Auto-discover interceptors from src directory
37
+ if (autoDiscover) {
38
+ const rootDir = build.initialOptions.absWorkingDir ?? process.cwd();
39
+ discoveredInterceptors.push(...discoverInterceptors(rootDir));
40
+ }
41
+ const allInterceptors = [...new Set([...interceptors, ...discoveredInterceptors])];
42
+ // Intercept worker file loads to inject interceptor imports
43
+ build.onLoad({ filter }, async (args) => {
44
+ let contents = await fs.promises.readFile(args.path, 'utf-8');
45
+ if (allInterceptors.length > 0) {
46
+ const importStatements = generateInterceptorImports(allInterceptors, args.path);
47
+ contents = injectImports(contents, importStatements);
48
+ }
49
+ return {
50
+ contents,
51
+ loader: 'ts',
52
+ };
53
+ });
54
+ },
55
+ };
56
+ }
57
+ /**
58
+ * Discovers interceptor files by scanning src directories.
59
+ * Looks for files matching interceptor naming patterns
60
+ * in TypeScript or JavaScript files.
61
+ */
62
+ function discoverInterceptors(rootDir) {
63
+ const interceptors = [];
64
+ const srcDir = path.join(rootDir, 'src');
65
+ if (!fs.existsSync(srcDir)) {
66
+ return interceptors;
67
+ }
68
+ scanDirectory(srcDir, interceptors, rootDir);
69
+ return interceptors;
70
+ }
71
+ /**
72
+ * Recursively scans a directory for interceptor files.
73
+ */
74
+ function scanDirectory(dir, interceptors, rootDir) {
75
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
76
+ for (const entry of entries) {
77
+ const fullPath = path.join(dir, entry.name);
78
+ if (entry.isDirectory()) {
79
+ // Skip node_modules and dist
80
+ if (entry.name === 'node_modules' || entry.name === 'dist') {
81
+ continue;
82
+ }
83
+ scanDirectory(fullPath, interceptors, rootDir);
84
+ }
85
+ else if (entry.isFile()) {
86
+ const isInterceptorFile = /interceptor/i.test(entry.name) &&
87
+ (entry.name.endsWith('.ts') || entry.name.endsWith('.js') || entry.name.endsWith('.mjs')) &&
88
+ !entry.name.endsWith('.spec.ts') &&
89
+ !entry.name.endsWith('.test.ts') &&
90
+ !entry.name.endsWith('.d.ts');
91
+ if (isInterceptorFile) {
92
+ // Get relative path from project root
93
+ const relativePath = path.relative(rootDir, fullPath);
94
+ interceptors.push('./' + relativePath.replace(/\\/g, '/'));
95
+ }
96
+ }
97
+ }
98
+ }
99
+ /**
100
+ * Generates import statements for discovered interceptors.
101
+ * Converts relative paths to valid import specifiers.
102
+ */
103
+ function generateInterceptorImports(interceptorPaths, workerFilePath) {
104
+ return interceptorPaths.map((interceptorPath, index) => {
105
+ // Create a valid identifier from the path
106
+ const identifier = `interceptor_${index}_${interceptorPath.replace(/[^a-zA-Z0-9]/g, '_')}`;
107
+ return `import ${identifier} from '${interceptorPath}';`;
108
+ });
109
+ }
110
+ /**
111
+ * Injects import statements at the top of the worker file.
112
+ * Preserves any existing imports.
113
+ */
114
+ function injectImports(contents, importStatements) {
115
+ if (importStatements.length === 0) {
116
+ return contents;
117
+ }
118
+ const imports = importStatements.join('\n');
119
+ const marker = '// Auto-injected by worker-http esbuild plugin';
120
+ // Check if already injected
121
+ if (contents.includes(marker)) {
122
+ return contents;
123
+ }
124
+ const injection = `${marker}\n${imports}\n`;
125
+ // Find the first non-comment, non-whitespace line to insert before
126
+ const lines = contents.split('\n');
127
+ let insertIndex = 0;
128
+ for (let i = 0; i < lines.length; i++) {
129
+ const line = lines[i].trim();
130
+ // Skip shebang, comments, and empty lines
131
+ if (line.startsWith('#!') ||
132
+ line.startsWith('//') ||
133
+ line.startsWith('/*') ||
134
+ line.startsWith('*') ||
135
+ line === '') {
136
+ insertIndex = i + 1;
137
+ continue;
138
+ }
139
+ break;
140
+ }
141
+ lines.splice(insertIndex, 0, injection);
142
+ return lines.join('\n');
143
+ }
144
+
145
+ /**
146
+ * esbuild plugin for @angular-helpers/worker-http
147
+ *
148
+ * Automatically bundles worker interceptors and injects them into the worker bootstrap.
149
+ *
150
+ * @example
151
+ * ```typescript
152
+ * // angular.json custom webpack config
153
+ * import { workerHttpPlugin } from '@angular-helpers/worker-http/esbuild-plugin';
154
+ *
155
+ * export default {
156
+ * plugins: [
157
+ * workerHttpPlugin({
158
+ * interceptors: ['./src/interceptors/auth.ts'],
159
+ * autoDiscover: true
160
+ * })
161
+ * ]
162
+ * };
163
+ * ```
164
+ */
165
+
166
+ /**
167
+ * Generated bundle index. Do not edit.
168
+ */
169
+
170
+ export { workerHttpPlugin };
@@ -1,9 +1,14 @@
1
1
  /**
2
2
  * Composes interceptor functions around a final handler, producing a single
3
- * `(req) => Promise<resp>` chain. Pure — no side effects.
3
+ * `(req, signal?) => Promise<resp>` chain. Pure — no side effects.
4
+ *
5
+ * The optional `signal` is threaded through interceptor boundaries automatically
6
+ * via a wrapper around `next`, so legacy `WorkerInterceptorFn` implementations
7
+ * (which take only `req, next`) keep working and still propagate cancellation
8
+ * when they invoke `next(req)`.
4
9
  */
5
10
  function buildChain(fns, finalHandler) {
6
- return fns.reduceRight((next, interceptor) => (req) => interceptor(req, next), finalHandler);
11
+ return fns.reduceRight((next, interceptor) => (req, signal) => interceptor(req, (r) => next(r, signal)), finalHandler);
7
12
  }
8
13
  /**
9
14
  * Performs the actual `fetch()` call inside the worker, translating the
@@ -98,7 +103,7 @@ function attachRequestLoop(chain) {
98
103
  const controller = new AbortController();
99
104
  controllers.set(requestId, controller);
100
105
  try {
101
- const response = await chain(payload);
106
+ const response = await chain(payload, controller.signal);
102
107
  self.postMessage({ type: 'response', requestId, result: response });
103
108
  }
104
109
  catch (error) {
@@ -145,7 +150,7 @@ function attachRequestLoop(chain) {
145
150
  * ```
146
151
  */
147
152
  function createWorkerPipeline(interceptors) {
148
- const chain = buildChain(interceptors, (req) => executeFetch(req));
153
+ const chain = buildChain(interceptors, (req, signal) => executeFetch(req, signal));
149
154
  attachRequestLoop(chain);
150
155
  }
151
156
 
@@ -325,7 +330,9 @@ function hmacSigningInterceptor(config) {
325
330
  * ```
326
331
  */
327
332
  function loggingInterceptor(config) {
328
- const logger = config?.logger ?? ((msg, data) => console.log(msg, data));
333
+ const logger = config?.logger ??
334
+ // oxlint-disable-next-line no-console -- default logger of a logging interceptor; consumers inject their own via config.logger
335
+ ((msg, data) => console.log(msg, data));
329
336
  const includeHeaders = config?.includeHeaders ?? false;
330
337
  function safeLog(message, data) {
331
338
  try {
@@ -538,7 +545,7 @@ function createConfigurableWorkerPipeline() {
538
545
  if (data.type === INIT_MESSAGE_TYPE) {
539
546
  const specs = data.specs ?? [];
540
547
  const fns = specs.map((spec) => resolveSpec(spec));
541
- const chain = buildChain(fns, (req) => executeFetch(req));
548
+ const chain = buildChain(fns, (req, signal) => executeFetch(req, signal));
542
549
  // Swap to the regular request loop and replay any buffered messages.
543
550
  attachRequestLoop(chain);
544
551
  const handler = self.onmessage;
@@ -0,0 +1,125 @@
1
+ /**
2
+ * Detects if the current browser needs the streams ponyfill.
3
+ *
4
+ * Safari 16-17 fails to transfer `ReadableStream`/`TransformStream`
5
+ * to/from Web Workers via `structuredClone` because they lack
6
+ * the transferable streams implementation.
7
+ *
8
+ * @param userAgent - Optional user agent string for testing (defaults to feature detection)
9
+ * @returns `true` if ponyfill is needed, `false` if native works
10
+ */
11
+ function needsPolyfill(userAgent) {
12
+ // If userAgent provided (testing mode), use UA-based detection
13
+ if (userAgent !== undefined) {
14
+ return isLegacySafari(userAgent);
15
+ }
16
+ // Quick check: if we're not in a browser context, assume no polyfill needed
17
+ if (typeof ReadableStream === 'undefined') {
18
+ return false;
19
+ }
20
+ try {
21
+ const rs = new ReadableStream({
22
+ start(controller) {
23
+ controller.close();
24
+ },
25
+ });
26
+ // Safari < 18 lacks the transferable streams spec extension
27
+ // The presence of 'getReader' alone isn't enough — we need to verify
28
+ // that the stream can be transferred via postMessage
29
+ const channel = new MessageChannel();
30
+ let transferable = true;
31
+ // Try to transfer — this throws on Safari 16-17
32
+ try {
33
+ channel.port1.postMessage(rs, [rs]);
34
+ }
35
+ catch {
36
+ transferable = false;
37
+ }
38
+ channel.port1.close();
39
+ channel.port2.close();
40
+ return !transferable;
41
+ }
42
+ catch {
43
+ // Any error means we should try polyfill
44
+ return true;
45
+ }
46
+ }
47
+ /**
48
+ * User-agent based detection for Safari.
49
+ * Used as a fast-path before the more expensive transfer test.
50
+ *
51
+ * @param userAgent - Optional user agent string (defaults to navigator.userAgent)
52
+ */
53
+ function isSafari(userAgent) {
54
+ const ua = userAgent ?? (typeof navigator !== 'undefined' ? navigator.userAgent : '');
55
+ return /Safari/.test(ua) && !/Chrome/.test(ua) && !/Chromium/.test(ua);
56
+ }
57
+ /**
58
+ * Check if Safari version is known to need polyfill (< 18).
59
+ *
60
+ * @param userAgent - Optional user agent string (defaults to navigator.userAgent)
61
+ */
62
+ function isLegacySafari(userAgent) {
63
+ if (!isSafari(userAgent)) {
64
+ return false;
65
+ }
66
+ const ua = userAgent ?? (typeof navigator !== 'undefined' ? navigator.userAgent : '');
67
+ const match = /Version\/(\d+)/.exec(ua);
68
+ if (!match) {
69
+ return false;
70
+ }
71
+ const version = Number.parseInt(match[1], 10);
72
+ return version < 18;
73
+ }
74
+
75
+ /**
76
+ * Ponyfill for Web Streams API that supports transfer to/from workers.
77
+ *
78
+ * Uses `web-streams-polyfill` internally but only loads it when needed.
79
+ * This keeps bundle size small for non-Safari browsers.
80
+ *
81
+ * @see {@link needsPolyfill} for detection
82
+ */
83
+ let cachedPonyfill = null;
84
+ /**
85
+ * Lazily loads the web-streams-polyfill ponyfill.
86
+ *
87
+ * @returns Ponyfilled streams or native if not needed
88
+ */
89
+ async function ponyfillStreams() {
90
+ if (cachedPonyfill) {
91
+ return cachedPonyfill;
92
+ }
93
+ // Dynamic import to avoid bundling polyfill for non-Safari users
94
+ const { ReadableStream, TransformStream, WritableStream } = await import('web-streams-polyfill');
95
+ cachedPonyfill = {
96
+ ReadableStream,
97
+ TransformStream,
98
+ WritableStream,
99
+ };
100
+ return cachedPonyfill;
101
+ }
102
+
103
+ /**
104
+ * Safari Transferable Streams Ponyfill
105
+ *
106
+ * Provides `ReadableStream` and `TransformStream` implementations that support
107
+ * `structuredClone` transfer to/from Web Workers on Safari 16-17.
108
+ *
109
+ * This is a **ponyfill** (not a polyfill) — it does not modify global scope.
110
+ * Native implementations are re-exported when available.
111
+ *
112
+ * Usage:
113
+ * ```typescript
114
+ * import '@angular-helpers/worker-http/streams-polyfill';
115
+ * ```
116
+ * Or let the transport auto-inject when `safariPolyfill: true` is configured.
117
+ *
118
+ * @see {@link detect} for feature detection
119
+ */
120
+
121
+ /**
122
+ * Generated bundle index. Do not edit.
123
+ */
124
+
125
+ export { needsPolyfill, ponyfillStreams };
@@ -1,14 +1,118 @@
1
1
  import { Observable } from 'rxjs';
2
2
 
3
+ /**
4
+ * Scans a payload one level deep and collects every `Transferable` instance
5
+ * (ArrayBuffer, MessagePort, ImageBitmap, OffscreenCanvas, ReadableStream,
6
+ * WritableStream, TransformStream) found in its own enumerable properties.
7
+ *
8
+ * Used by `createWorkerTransport` when `transferDetection === 'auto'` to build
9
+ * the second argument of `worker.postMessage(data, transfer)` so large buffers
10
+ * move zero-copy instead of being structured-cloned.
11
+ *
12
+ * Design notes:
13
+ * - Only one level deep by design: deep traversal has quadratic cost on heavy
14
+ * graphs and makes the transfer list surprising. Real payloads that care
15
+ * about zero-copy put the buffer at the top level.
16
+ * - Duplicates are filtered — the same buffer referenced twice is transferred
17
+ * only once (required by the structured-clone algorithm).
18
+ * - Returns an empty array for primitives, plain serializable values, or when
19
+ * no transferable is found; `postMessage` accepts `[]` safely.
20
+ */
21
+ function detectTransferables(payload) {
22
+ if (payload === null || payload === undefined)
23
+ return [];
24
+ if (typeof payload !== 'object')
25
+ return [];
26
+ const found = [];
27
+ const seen = new Set();
28
+ const collect = (value) => {
29
+ if (value === null || value === undefined)
30
+ return;
31
+ if (isTransferable(value)) {
32
+ if (!seen.has(value)) {
33
+ seen.add(value);
34
+ found.push(value);
35
+ }
36
+ }
37
+ };
38
+ if (isTransferable(payload)) {
39
+ collect(payload);
40
+ return found;
41
+ }
42
+ if (Array.isArray(payload)) {
43
+ for (const item of payload)
44
+ collect(item);
45
+ return found;
46
+ }
47
+ for (const key of Object.keys(payload)) {
48
+ collect(payload[key]);
49
+ }
50
+ return found;
51
+ }
52
+ function isTransferable(value) {
53
+ if (value === null || value === undefined)
54
+ return false;
55
+ if (typeof value !== 'object')
56
+ return false;
57
+ // ArrayBuffer is the common case; check first.
58
+ if (typeof ArrayBuffer !== 'undefined' && value instanceof ArrayBuffer)
59
+ return true;
60
+ // Typed-array views carry an underlying buffer but are NOT Transferable —
61
+ // only the buffer is. Caller must pass `.buffer` explicitly if they want it.
62
+ if (typeof MessagePort !== 'undefined' && value instanceof MessagePort)
63
+ return true;
64
+ if (typeof ImageBitmap !== 'undefined' && value instanceof ImageBitmap)
65
+ return true;
66
+ if (typeof OffscreenCanvas !== 'undefined' && value instanceof OffscreenCanvas)
67
+ return true;
68
+ if (typeof ReadableStream !== 'undefined' && value instanceof ReadableStream)
69
+ return true;
70
+ if (typeof WritableStream !== 'undefined' && value instanceof WritableStream)
71
+ return true;
72
+ if (typeof TransformStream !== 'undefined' && value instanceof TransformStream)
73
+ return true;
74
+ return false;
75
+ }
76
+
77
+ /**
78
+ * Thrown by `createWorkerTransport` when a request exceeds its configured
79
+ * `requestTimeout`. Consumers can `instanceof`-check this error to distinguish
80
+ * timeout rejections from transport/worker errors.
81
+ *
82
+ * @example
83
+ * ```typescript
84
+ * transport.execute(req).subscribe({
85
+ * error: (err) => {
86
+ * if (err instanceof WorkerHttpTimeoutError) {
87
+ * // dedicated timeout handling
88
+ * }
89
+ * },
90
+ * });
91
+ * ```
92
+ */
93
+ class WorkerHttpTimeoutError extends Error {
94
+ name = 'WorkerHttpTimeoutError';
95
+ timeoutMs;
96
+ constructor(timeoutMs) {
97
+ super(`Worker request timed out after ${timeoutMs} ms`);
98
+ this.timeoutMs = timeoutMs;
99
+ // Maintain a proper prototype chain across TS transpile targets.
100
+ Object.setPrototypeOf(this, new.target.prototype);
101
+ }
102
+ }
103
+
104
+ const DEFAULT_REQUEST_TIMEOUT_MS = 30_000;
3
105
  /**
4
106
  * Creates a typed, Observable-based transport for communicating with a web worker.
5
107
  *
6
108
  * Features:
7
109
  * - Request/response correlation via `requestId`
8
- * - Automatic cancellation on Observable unsubscribe
110
+ * - Cancellation on Observable unsubscribe (also aborts `fetch()` in the worker)
111
+ * - Per-request timeout (default 30 s) rejecting with `WorkerHttpTimeoutError`
9
112
  * - Optional worker pool with round-robin dispatch
10
113
  * - Lazy worker creation (default)
11
- * - Transferable auto-detection for ArrayBuffer payloads
114
+ * - Opt-in transferable detection (`transferDetection: 'auto'`) for zero-copy
115
+ * `ArrayBuffer` / stream payloads
12
116
  *
13
117
  * @example
14
118
  * ```typescript
@@ -28,6 +132,10 @@ function createWorkerTransport(config) {
28
132
  let roundRobinIndex = 0;
29
133
  let terminated = false;
30
134
  const maxInstances = Math.min(config.maxInstances ?? 1, typeof navigator !== 'undefined' ? (navigator.hardwareConcurrency ?? 4) : 1);
135
+ const requestTimeout = config.requestTimeout ?? DEFAULT_REQUEST_TIMEOUT_MS;
136
+ const transferDetection = config.transferDetection ?? 'none';
137
+ const streamsPolyfill = config.streamsPolyfill ?? false;
138
+ let polyfillLoaded = false;
31
139
  function createWorker() {
32
140
  if (config.workerFactory) {
33
141
  return config.workerFactory();
@@ -61,13 +169,31 @@ function createWorkerTransport(config) {
61
169
  }
62
170
  const requestId = crypto.randomUUID();
63
171
  return new Observable((subscriber) => {
172
+ // Lazy-load polyfill on first request if enabled
173
+ if (streamsPolyfill && !polyfillLoaded) {
174
+ loadStreamsPolyfill().catch((err) => {
175
+ console.warn('[worker-http] Streams polyfill failed to load:', err);
176
+ });
177
+ }
64
178
  const worker = getOrCreateWorker();
179
+ let settled = false;
180
+ let timeoutHandle;
181
+ const cleanup = () => {
182
+ worker.removeEventListener('message', messageHandler);
183
+ worker.removeEventListener('error', errorHandler);
184
+ if (timeoutHandle !== undefined) {
185
+ clearTimeout(timeoutHandle);
186
+ timeoutHandle = undefined;
187
+ }
188
+ };
65
189
  const messageHandler = (event) => {
66
190
  const data = event.data;
67
191
  if (data.requestId !== requestId)
68
192
  return;
69
- worker.removeEventListener('message', messageHandler);
70
- worker.removeEventListener('error', errorHandler);
193
+ if (settled)
194
+ return;
195
+ settled = true;
196
+ cleanup();
71
197
  if (data.type === 'error') {
72
198
  const err = data.error;
73
199
  subscriber.error(new Error(err.message));
@@ -78,21 +204,63 @@ function createWorkerTransport(config) {
78
204
  }
79
205
  };
80
206
  const errorHandler = (event) => {
81
- worker.removeEventListener('message', messageHandler);
82
- worker.removeEventListener('error', errorHandler);
207
+ if (settled)
208
+ return;
209
+ settled = true;
210
+ cleanup();
83
211
  subscriber.error(new Error(event.message ?? 'Worker error'));
84
212
  };
85
213
  worker.addEventListener('message', messageHandler);
86
214
  worker.addEventListener('error', errorHandler);
87
- worker.postMessage({ type: 'request', requestId, payload: request });
215
+ if (transferDetection === 'auto') {
216
+ const transferables = detectTransferables(request);
217
+ worker.postMessage({ type: 'request', requestId, payload: request }, transferables);
218
+ }
219
+ else {
220
+ worker.postMessage({ type: 'request', requestId, payload: request });
221
+ }
222
+ if (requestTimeout > 0 && Number.isFinite(requestTimeout)) {
223
+ timeoutHandle = setTimeout(() => {
224
+ if (settled)
225
+ return;
226
+ settled = true;
227
+ cleanup();
228
+ // Ask the worker to abort any in-flight work for this id. The
229
+ // cancellation fix wires this through to `fetch()`.
230
+ worker.postMessage({ type: 'cancel', requestId });
231
+ subscriber.error(new WorkerHttpTimeoutError(requestTimeout));
232
+ }, requestTimeout);
233
+ }
88
234
  // Teardown: send cancel message on unsubscribe
89
235
  return () => {
90
- worker.removeEventListener('message', messageHandler);
91
- worker.removeEventListener('error', errorHandler);
236
+ if (settled)
237
+ return;
238
+ settled = true;
239
+ cleanup();
92
240
  worker.postMessage({ type: 'cancel', requestId });
93
241
  };
94
242
  });
95
243
  }
244
+ /**
245
+ * Lazy-loads the streams polyfill if enabled and not already loaded.
246
+ * Called before operations that might involve stream transfer.
247
+ */
248
+ async function loadStreamsPolyfill() {
249
+ if (!streamsPolyfill || polyfillLoaded) {
250
+ return;
251
+ }
252
+ try {
253
+ const { needsPolyfill, ponyfillStreams } = await import('@angular-helpers/worker-http/streams-polyfill');
254
+ if (needsPolyfill()) {
255
+ await ponyfillStreams();
256
+ }
257
+ polyfillLoaded = true;
258
+ }
259
+ catch (err) {
260
+ // Log warning but don't block request — streams will fail naturally
261
+ console.warn('[worker-http] Failed to load streams polyfill:', err);
262
+ }
263
+ }
96
264
  function terminate() {
97
265
  terminated = true;
98
266
  for (const worker of workers) {
@@ -116,4 +284,4 @@ function createWorkerTransport(config) {
116
284
  * Generated bundle index. Do not edit.
117
285
  */
118
286
 
119
- export { createWorkerTransport };
287
+ export { WorkerHttpTimeoutError, createWorkerTransport, detectTransferables };
@@ -1,20 +1,3 @@
1
- /**
2
- * @angular-helpers/worker-http
3
- *
4
- * Angular HTTP over Web Workers — off-main-thread HTTP pipelines
5
- * with configurable interceptors, WebCrypto security, and pluggable serialization.
6
- *
7
- * Sub-entry points:
8
- * - @angular-helpers/worker-http/transport (P1: typed RPC bridge)
9
- * - @angular-helpers/worker-http/serializer (P2: TOON, seroval, auto-detect)
10
- * - @angular-helpers/worker-http/backend (P3: Angular HttpBackend replacement)
11
- * - @angular-helpers/worker-http/interceptors (P4: pure-fn interceptors for workers)
12
- * - @angular-helpers/worker-http/crypto (P5: WebCrypto primitives)
13
- */
14
- const WORKER_HTTP_VERSION = '0.0.1';
15
-
16
1
  /**
17
2
  * Generated bundle index. Do not edit.
18
3
  */
19
-
20
- export { WORKER_HTTP_VERSION };
package/package.json CHANGED
@@ -1,7 +1,53 @@
1
1
  {
2
2
  "name": "@angular-helpers/worker-http",
3
- "version": "0.6.0",
3
+ "version": "1.0.0",
4
4
  "description": "Angular HTTP over Web Workers — off-main-thread HTTP pipelines with configurable interceptors, WebCrypto security, and pluggable serialization",
5
+ "schematics": "./schematics/collection.json",
6
+ "exports": {
7
+ ".": {
8
+ "types": "./types/angular-helpers-worker-http.d.ts",
9
+ "esm": "./esm/src/index.js",
10
+ "default": "./fesm2022/angular-helpers-worker-http.mjs"
11
+ },
12
+ "./backend": {
13
+ "types": "./types/angular-helpers-worker-http-backend.d.ts",
14
+ "esm": "./esm/backend/src/index.js",
15
+ "default": "./fesm2022/angular-helpers-worker-http-backend.mjs"
16
+ },
17
+ "./transport": {
18
+ "types": "./types/angular-helpers-worker-http-transport.d.ts",
19
+ "esm": "./esm/transport/src/index.js",
20
+ "default": "./fesm2022/angular-helpers-worker-http-transport.mjs"
21
+ },
22
+ "./interceptors": {
23
+ "types": "./types/angular-helpers-worker-http-interceptors.d.ts",
24
+ "esm": "./esm/interceptors/src/index.js",
25
+ "default": "./fesm2022/angular-helpers-worker-http-interceptors.mjs"
26
+ },
27
+ "./crypto": {
28
+ "types": "./types/angular-helpers-worker-http-crypto.d.ts",
29
+ "esm": "./esm/crypto/src/index.js",
30
+ "default": "./fesm2022/angular-helpers-worker-http-crypto.mjs"
31
+ },
32
+ "./serializer": {
33
+ "types": "./types/angular-helpers-worker-http-serializer.d.ts",
34
+ "esm": "./esm/serializer/src/index.js",
35
+ "default": "./fesm2022/angular-helpers-worker-http-serializer.mjs"
36
+ },
37
+ "./esbuild-plugin": {
38
+ "types": "./types/angular-helpers-worker-http-esbuild-plugin.d.ts",
39
+ "esm": "./esm/esbuild-plugin/src/index.js",
40
+ "default": "./fesm2022/angular-helpers-worker-http-esbuild-plugin.mjs"
41
+ },
42
+ "./streams-polyfill": {
43
+ "types": "./types/angular-helpers-worker-http-streams-polyfill.d.ts",
44
+ "esm": "./esm/streams-polyfill/src/index.js",
45
+ "default": "./fesm2022/angular-helpers-worker-http-streams-polyfill.mjs"
46
+ },
47
+ "./package.json": {
48
+ "default": "./package.json"
49
+ }
50
+ },
5
51
  "keywords": [
6
52
  "angular",
7
53
  "web-worker",
@@ -41,39 +87,16 @@
41
87
  },
42
88
  "seroval": {
43
89
  "optional": true
44
- }
45
- },
46
- "module": "fesm2022/angular-helpers-worker-http.mjs",
47
- "typings": "types/angular-helpers-worker-http.d.ts",
48
- "exports": {
49
- "./package.json": {
50
- "default": "./package.json"
51
- },
52
- ".": {
53
- "types": "./types/angular-helpers-worker-http.d.ts",
54
- "default": "./fesm2022/angular-helpers-worker-http.mjs"
55
- },
56
- "./backend": {
57
- "types": "./types/angular-helpers-worker-http-backend.d.ts",
58
- "default": "./fesm2022/angular-helpers-worker-http-backend.mjs"
59
- },
60
- "./crypto": {
61
- "types": "./types/angular-helpers-worker-http-crypto.d.ts",
62
- "default": "./fesm2022/angular-helpers-worker-http-crypto.mjs"
63
- },
64
- "./interceptors": {
65
- "types": "./types/angular-helpers-worker-http-interceptors.d.ts",
66
- "default": "./fesm2022/angular-helpers-worker-http-interceptors.mjs"
67
90
  },
68
- "./serializer": {
69
- "types": "./types/angular-helpers-worker-http-serializer.d.ts",
70
- "default": "./fesm2022/angular-helpers-worker-http-serializer.mjs"
91
+ "esbuild": {
92
+ "optional": true
71
93
  },
72
- "./transport": {
73
- "types": "./types/angular-helpers-worker-http-transport.d.ts",
74
- "default": "./fesm2022/angular-helpers-worker-http-transport.mjs"
94
+ "web-streams-polyfill": {
95
+ "optional": true
75
96
  }
76
97
  },
98
+ "module": "fesm2022/angular-helpers-worker-http.mjs",
99
+ "typings": "types/angular-helpers-worker-http.d.ts",
77
100
  "sideEffects": false,
78
101
  "dependencies": {
79
102
  "tslib": "^2.3.0"
@@ -9,7 +9,7 @@ import { Observable } from 'rxjs';
9
9
  * Discriminated union for worker HTTP feature kinds.
10
10
  * Mirrors Angular's HttpFeatureKind pattern.
11
11
  */
12
- type WorkerHttpFeatureKind = 'WorkerConfigs' | 'WorkerRoutes' | 'WorkerFallback' | 'WorkerSerialization' | 'WorkerInterceptors' | 'Telemetry';
12
+ type WorkerHttpFeatureKind = 'WorkerConfigs' | 'WorkerRoutes' | 'WorkerFallback' | 'WorkerSerialization' | 'WorkerInterceptors' | 'Telemetry' | 'StreamsPolyfill';
13
13
  /**
14
14
  * Feature object — mirrors Angular's HttpFeature<K> shape.
15
15
  */
@@ -182,6 +182,15 @@ declare const WORKER_HTTP_SERIALIZER_TOKEN: InjectionToken<WorkerSerializer>;
182
182
  * Defaults to an empty map (no interceptors).
183
183
  */
184
184
  declare const WORKER_HTTP_INTERCEPTORS_TOKEN: InjectionToken<Readonly<Record<string, readonly WorkerInterceptorSpec[]>>>;
185
+ /**
186
+ * Enable Safari streams polyfill for transferable ReadableStream/TransformStream
187
+ * support in workers. Provided via `withWorkerStreamsPolyfill()`.
188
+ * Defaults to `false` (native streams only).
189
+ *
190
+ * When enabled, the transport dynamically imports `@angular-helpers/worker-http/streams-polyfill`
191
+ * on Safari 16-17 to enable stream transfer via postMessage.
192
+ */
193
+ declare const WORKER_HTTP_STREAMS_POLYFILL_TOKEN: InjectionToken<boolean>;
185
194
 
186
195
  /**
187
196
  * Sets up the worker HTTP infrastructure and replaces Angular's `HttpBackend`
@@ -345,6 +354,30 @@ declare function withWorkerInterceptors(specs: readonly WorkerInterceptorSpec[]
345
354
  * ```
346
355
  */
347
356
  declare function withTelemetry(telemetry: WorkerHttpTelemetry): WorkerHttpFeature<'Telemetry'>;
357
+ /**
358
+ * Enables the Safari streams polyfill for transferable ReadableStream/TransformStream
359
+ * support in Web Workers.
360
+ *
361
+ * Safari 16-17 lacks native transferable streams. When this feature is enabled,
362
+ * the transport layer dynamically loads a ponyfill that enables stream transfer
363
+ * via postMessage on affected browsers.
364
+ *
365
+ * **When to use:**
366
+ * - Your application uses `responseType: 'stream'` and targets Safari 16-17
367
+ * - You see "DataCloneError" when transferring streams to/from workers
368
+ *
369
+ * **Bundle impact:** The polyfill is lazy-loaded only on Safari 16-17 when
370
+ * streams are actually used. Non-Safari browsers and modern Safari pay 0 bytes.
371
+ *
372
+ * @example
373
+ * ```typescript
374
+ * provideWorkerHttpClient(
375
+ * withWorkerConfigs([...]),
376
+ * withWorkerStreamsPolyfill(), // Enable for Safari compatibility
377
+ * )
378
+ * ```
379
+ */
380
+ declare function withWorkerStreamsPolyfill(): WorkerHttpFeature<'StreamsPolyfill'>;
348
381
 
349
382
  /**
350
383
  * Angular `HttpBackend` replacement that routes HTTP requests to web workers.
@@ -366,6 +399,7 @@ declare class WorkerHttpBackend extends HttpBackend implements OnDestroy {
366
399
  private readonly interceptorSpecs;
367
400
  private readonly fetchBackend;
368
401
  private readonly telemetry;
402
+ private readonly streamsPolyfill;
369
403
  private readonly transports;
370
404
  handle(req: HttpRequest<unknown>): Observable<HttpEvent<unknown>>;
371
405
  ngOnDestroy(): void;
@@ -460,5 +494,5 @@ declare function matchWorkerRoute(url: string, routes: Array<{
460
494
  priority?: number;
461
495
  }>): string | null;
462
496
 
463
- export { WORKER_HTTP_INTERCEPTORS_TOKEN, WORKER_HTTP_SERIALIZER_TOKEN, WORKER_TARGET, WorkerHttpBackend, WorkerHttpClient, matchWorkerRoute, provideWorkerHttpClient, toHttpResponse, toSerializableRequest, withTelemetry, withWorkerConfigs, withWorkerFallback, withWorkerInterceptors, withWorkerRoutes, withWorkerSerialization };
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 };
464
498
  export type { SerializableRequest, SerializableResponse, WorkerConfig, WorkerFallbackStrategy, WorkerHttpErrorEvent, WorkerHttpFeature, WorkerHttpFeatureKind, WorkerHttpRequestEvent, WorkerHttpResponseEvent, WorkerHttpTelemetry, WorkerHttpTelemetryEventBase, WorkerHttpTransportKind, WorkerInterceptorSpecsMap, WorkerRequestOptions, WorkerRoute };
@@ -0,0 +1,46 @@
1
+ import { Plugin } from 'esbuild';
2
+
3
+ interface WorkerHttpPluginOptions {
4
+ /**
5
+ * Explicit list of interceptor file paths to bundle into the worker.
6
+ * Paths are relative to project root.
7
+ * @example ['./src/interceptors/auth.ts', './src/interceptors/logging.ts']
8
+ */
9
+ interceptors?: string[];
10
+ /**
11
+ * Auto-discover interceptors by scanning src folders for files
12
+ * matching the interceptor naming pattern.
13
+ * Discovered interceptors are merged with explicit interceptors list.
14
+ * @default false
15
+ */
16
+ autoDiscover?: boolean;
17
+ }
18
+ /**
19
+ * esbuild plugin for worker-http that bundles interceptors into worker files.
20
+ *
21
+ * This plugin:
22
+ * 1. Intercepts worker file builds
23
+ * 2. Discovers interceptor files if autoDiscover is true
24
+ * 3. Injects interceptor imports into the worker bootstrap
25
+ * 4. Ensures interceptors are available in the worker's interceptor pipeline
26
+ *
27
+ * @param options - Plugin configuration
28
+ * @returns esbuild Plugin
29
+ *
30
+ * @example
31
+ * ```typescript
32
+ * import { workerHttpPlugin } from '@angular-helpers/worker-http/esbuild-plugin';
33
+ *
34
+ * const config = {
35
+ * plugins: [
36
+ * workerHttpPlugin({
37
+ * autoDiscover: true,
38
+ * })
39
+ * ]
40
+ * };
41
+ * ```
42
+ */
43
+ declare function workerHttpPlugin(options?: WorkerHttpPluginOptions): Plugin;
44
+
45
+ export { workerHttpPlugin };
46
+ export type { WorkerHttpPluginOptions };
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Detects if the current browser needs the streams ponyfill.
3
+ *
4
+ * Safari 16-17 fails to transfer `ReadableStream`/`TransformStream`
5
+ * to/from Web Workers via `structuredClone` because they lack
6
+ * the transferable streams implementation.
7
+ *
8
+ * @param userAgent - Optional user agent string for testing (defaults to feature detection)
9
+ * @returns `true` if ponyfill is needed, `false` if native works
10
+ */
11
+ declare function needsPolyfill(userAgent?: string): boolean;
12
+
13
+ /**
14
+ * Ponyfill for Web Streams API that supports transfer to/from workers.
15
+ *
16
+ * Uses `web-streams-polyfill` internally but only loads it when needed.
17
+ * This keeps bundle size small for non-Safari browsers.
18
+ *
19
+ * @see {@link needsPolyfill} for detection
20
+ */
21
+ interface StreamPonyfillExports {
22
+ ReadableStream: typeof ReadableStream;
23
+ TransformStream: typeof TransformStream;
24
+ WritableStream: typeof WritableStream;
25
+ }
26
+ /**
27
+ * Lazily loads the web-streams-polyfill ponyfill.
28
+ *
29
+ * @returns Ponyfilled streams or native if not needed
30
+ */
31
+ declare function ponyfillStreams(): Promise<StreamPonyfillExports>;
32
+
33
+ export { needsPolyfill, ponyfillStreams };
34
+ export type { StreamPonyfillExports };
@@ -34,9 +34,27 @@ interface WorkerTransportConfig {
34
34
  workerUrl?: string | URL;
35
35
  /** Maximum number of worker instances in the pool (default: 1) */
36
36
  maxInstances?: number;
37
- /** Transfer strategy for large payloads */
37
+ /**
38
+ * Transfer strategy for `postMessage` payloads.
39
+ *
40
+ * - `'none'` (default) — payloads are always structured-cloned, preserving
41
+ * the caller's access to the original data after post.
42
+ * - `'auto'` — shallowly walks the payload and passes every detected
43
+ * `Transferable` (ArrayBuffer, MessagePort, ImageBitmap, OffscreenCanvas,
44
+ * ReadableStream, WritableStream, TransformStream) in the transfer list
45
+ * of `postMessage`. Large buffers move zero-copy; their `byteLength`
46
+ * becomes `0` in the main thread after post.
47
+ *
48
+ * The `'manual'` value is reserved for a future API where callers supply
49
+ * their own transfer list per request. It currently behaves like `'none'`.
50
+ */
38
51
  transferDetection?: 'auto' | 'manual' | 'none';
39
- /** Timeout in ms for a single request (default: 30000) */
52
+ /**
53
+ * Per-request timeout in milliseconds. If the worker does not respond
54
+ * within this window, `execute()` errors with `WorkerHttpTimeoutError`
55
+ * and a cancel message is posted to the worker. Set to `0` or
56
+ * non-finite to disable the timeout entirely. Default: `30000` (30 s).
57
+ */
40
58
  requestTimeout?: number;
41
59
  /**
42
60
  * Optional handshake message posted to every worker as soon as it is
@@ -50,6 +68,14 @@ interface WorkerTransportConfig {
50
68
  type: string;
51
69
  [key: string]: unknown;
52
70
  };
71
+ /**
72
+ * Enable Safari streams polyfill for transferable ReadableStream/TransformStream
73
+ * support. When `true`, the transport lazy-loads the ponyfill on Safari 16-17
74
+ * to enable stream transfer via postMessage.
75
+ *
76
+ * @default false
77
+ */
78
+ streamsPolyfill?: boolean;
53
79
  }
54
80
  /**
55
81
  * Message sent from main thread to worker.
@@ -103,10 +129,12 @@ interface WorkerTransport<TRequest = unknown, TResponse = unknown> {
103
129
  *
104
130
  * Features:
105
131
  * - Request/response correlation via `requestId`
106
- * - Automatic cancellation on Observable unsubscribe
132
+ * - Cancellation on Observable unsubscribe (also aborts `fetch()` in the worker)
133
+ * - Per-request timeout (default 30 s) rejecting with `WorkerHttpTimeoutError`
107
134
  * - Optional worker pool with round-robin dispatch
108
135
  * - Lazy worker creation (default)
109
- * - Transferable auto-detection for ArrayBuffer payloads
136
+ * - Opt-in transferable detection (`transferDetection: 'auto'`) for zero-copy
137
+ * `ArrayBuffer` / stream payloads
110
138
  *
111
139
  * @example
112
140
  * ```typescript
@@ -123,5 +151,47 @@ interface WorkerTransport<TRequest = unknown, TResponse = unknown> {
123
151
  */
124
152
  declare function createWorkerTransport<TRequest = unknown, TResponse = unknown>(config: WorkerTransportConfig): WorkerTransport<TRequest, TResponse>;
125
153
 
126
- export { createWorkerTransport };
154
+ /**
155
+ * Thrown by `createWorkerTransport` when a request exceeds its configured
156
+ * `requestTimeout`. Consumers can `instanceof`-check this error to distinguish
157
+ * timeout rejections from transport/worker errors.
158
+ *
159
+ * @example
160
+ * ```typescript
161
+ * transport.execute(req).subscribe({
162
+ * error: (err) => {
163
+ * if (err instanceof WorkerHttpTimeoutError) {
164
+ * // dedicated timeout handling
165
+ * }
166
+ * },
167
+ * });
168
+ * ```
169
+ */
170
+ declare class WorkerHttpTimeoutError extends Error {
171
+ readonly name = "WorkerHttpTimeoutError";
172
+ readonly timeoutMs: number;
173
+ constructor(timeoutMs: number);
174
+ }
175
+
176
+ /**
177
+ * Scans a payload one level deep and collects every `Transferable` instance
178
+ * (ArrayBuffer, MessagePort, ImageBitmap, OffscreenCanvas, ReadableStream,
179
+ * WritableStream, TransformStream) found in its own enumerable properties.
180
+ *
181
+ * Used by `createWorkerTransport` when `transferDetection === 'auto'` to build
182
+ * the second argument of `worker.postMessage(data, transfer)` so large buffers
183
+ * move zero-copy instead of being structured-cloned.
184
+ *
185
+ * Design notes:
186
+ * - Only one level deep by design: deep traversal has quadratic cost on heavy
187
+ * graphs and makes the transfer list surprising. Real payloads that care
188
+ * about zero-copy put the buffer at the top level.
189
+ * - Duplicates are filtered — the same buffer referenced twice is transferred
190
+ * only once (required by the structured-clone algorithm).
191
+ * - Returns an empty array for primitives, plain serializable values, or when
192
+ * no transferable is found; `postMessage` accepts `[]` safely.
193
+ */
194
+ declare function detectTransferables(payload: unknown): Transferable[];
195
+
196
+ export { WorkerHttpTimeoutError, createWorkerTransport, detectTransferables };
127
197
  export type { WorkerErrorResponse, WorkerMessage, WorkerResponse, WorkerTransport, WorkerTransportConfig };
@@ -1,16 +1,2 @@
1
- /**
2
- * @angular-helpers/worker-http
3
- *
4
- * Angular HTTP over Web Workers — off-main-thread HTTP pipelines
5
- * with configurable interceptors, WebCrypto security, and pluggable serialization.
6
- *
7
- * Sub-entry points:
8
- * - @angular-helpers/worker-http/transport (P1: typed RPC bridge)
9
- * - @angular-helpers/worker-http/serializer (P2: TOON, seroval, auto-detect)
10
- * - @angular-helpers/worker-http/backend (P3: Angular HttpBackend replacement)
11
- * - @angular-helpers/worker-http/interceptors (P4: pure-fn interceptors for workers)
12
- * - @angular-helpers/worker-http/crypto (P5: WebCrypto primitives)
13
- */
14
- declare const WORKER_HTTP_VERSION = "0.0.1";
15
1
 
16
- export { WORKER_HTTP_VERSION };
2
+ export { };