rest-pipeline-js 1.3.14 → 1.4.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.
Files changed (39) hide show
  1. package/.claude/settings.local.json +11 -0
  2. package/CHANGELOG.md +50 -0
  3. package/README.md +169 -35
  4. package/dist/cjs/cache.js +15 -0
  5. package/dist/cjs/circuit-breaker.js +84 -0
  6. package/dist/cjs/index.js +1 -0
  7. package/dist/cjs/pipeline-builder.js +22 -3
  8. package/dist/cjs/pipeline-orchestrator.js +159 -97
  9. package/dist/cjs/rest-client.js +97 -20
  10. package/dist/cjs/types.js +13 -0
  11. package/dist/esm/cache.d.ts +4 -0
  12. package/dist/esm/cache.js +15 -0
  13. package/dist/esm/circuit-breaker.d.ts +33 -0
  14. package/dist/esm/circuit-breaker.js +79 -0
  15. package/dist/esm/index.d.ts +1 -0
  16. package/dist/esm/index.js +1 -0
  17. package/dist/esm/pipeline-builder.d.ts +24 -9
  18. package/dist/esm/pipeline-builder.js +22 -3
  19. package/dist/esm/pipeline-orchestrator.d.ts +20 -0
  20. package/dist/esm/pipeline-orchestrator.js +159 -97
  21. package/dist/esm/rest-client.d.ts +10 -0
  22. package/dist/esm/rest-client.js +97 -20
  23. package/dist/esm/types.d.ts +77 -2
  24. package/dist/esm/types.js +11 -0
  25. package/dist/esm/usePipelineStepEvents-react.d.ts +1 -0
  26. package/dist/esm/usePipelineStepEvents-vue.d.ts +3 -0
  27. package/dist/esm/useRestClient-react.d.ts +5 -0
  28. package/dist/esm/useRestClient-vue.d.ts +5 -0
  29. package/package.json +1 -1
  30. package/src/cache.ts +17 -0
  31. package/src/circuit-breaker.ts +90 -0
  32. package/src/index.ts +1 -0
  33. package/src/pipeline-builder.ts +34 -13
  34. package/src/pipeline-orchestrator.ts +203 -105
  35. package/src/rest-client.ts +93 -9
  36. package/src/types.ts +90 -3
  37. package/tests/pipeline-builder.test.ts +112 -0
  38. package/tests/pipeline-orchestrator.test.ts +279 -0
  39. package/tests/rest-client.test.ts +290 -1
@@ -0,0 +1,11 @@
1
+ {
2
+ "permissions": {
3
+ "allow": [
4
+ "Bash(grep -n \"stepIndex,$\\\\|stepIndex: i,\\\\|emitStepStart\\(\\\\|emitStepFinish\\(\\\\|emitStepError\\(\\\\|emitStepSkipped\\(\\\\|stageResults: { \\\\.\\\\.\\\\.this\\\\.stageResults }\" src/pipeline-orchestrator.ts)",
5
+ "Bash(cat > *)"
6
+ ],
7
+ "additionalDirectories": [
8
+ "\\tmp"
9
+ ]
10
+ }
11
+ }
package/CHANGELOG.md CHANGED
@@ -1,5 +1,55 @@
1
1
  # Changelog
2
2
 
3
+ ## [Unreleased]
4
+
5
+ ### Fixed
6
+
7
+ - **Flaky test in `tests/rest-client.test.ts`** ("при повторном 401 после onUnauthorized — не попадает в бесконечный цикл") — the mock error set `err.isAxiosError = true` *after* `Object.setPrototypeOf(err, axios.AxiosError.prototype)`. `AxiosError.prototype.isAxiosError` is defined as non-writable (`Object.defineProperty(..., { value: true })`), so that assignment threw a `TypeError` in strict mode, which masked the actual 401-retry logic being exercised. Fixed by assigning `isAxiosError` before swapping the prototype, matching the (correct) pattern already used by the other axios-error mocks in the same file. No production code changed.
8
+
9
+ ### Added
10
+
11
+ #### Pipeline Orchestrator
12
+
13
+ - **`signal` in stage hooks** — `request`, `condition`, `before`, `after`, `errorHandler`, and `StreamStageConfig.stream` now receive the pipeline's `AbortSignal` in their params object. Pass it down to `fetch`/`axios`/etc. so `abort()` actually cancels custom async work inside stage functions, not just the orchestrator's own bookkeeping.
14
+ - **`recoverStep(data)`** (from `types.ts`, re-exported from the root entry point) — `errorHandler` can return `recoverStep(data)` to recover a failed stage back into a successful one (`status: "success"`, `data`), running the same commit path as a normal success (metrics, `persistAdapter.save()`, `middleware.afterEach`, `step:success` event) instead of stopping/continuing-as-error. Returning anything else keeps the previous behavior (error, transformed via `toApiError`).
15
+
16
+ #### RestClient
17
+
18
+ - **No `axios.create()` when `adapter` is set** — `createRestClient()` no longer constructs the built-in axios instance if a custom `HttpAdapter` is provided, avoiding unnecessary work in edge/serverless environments that only use the adapter.
19
+
20
+ ### Changed
21
+
22
+ - Internal: `PipelineOrchestrator.executeStage()` success/error commit logic was extracted into `_commitStepSuccess()` / `_commitStepError()` so the new `errorHandler` recovery path and the normal success path share identical metrics/persist/middleware/event behavior.
23
+
24
+ #### Pipeline Orchestrator
25
+
26
+ - **`ParallelStageGroup.concurrency`** — caps how many stages of a parallel group run at once instead of always starting all of them via `Promise.all`. Useful for fan-out over many items (e.g. paginated fetches) without opening hundreds of requests at the same time. Results are still returned/stored in the same shape and order as an unlimited group. Supported by the `pipe()` builder via `.parallel(stages, { concurrency })`.
27
+
28
+ #### RestClient
29
+
30
+ - **`AuthProvider.tokenTtlMs`** — caches `getToken()`'s result for the given duration instead of calling it before every request. The cache is invalidated automatically on a `401` (before `onUnauthorized` runs), so the retried request always fetches a fresh token. Without `tokenTtlMs`, behavior is unchanged (`getToken()` called every request).
31
+ - **`invalidateCache(matcher)`** — new method on the client returned by `createRestClient()`. Removes only the response-cache entries whose URL matches `matcher` (substring, `RegExp`, or `(info: { method, url }) => boolean`) instead of clearing the whole cache like `clearCache()`. Returns the number of entries removed.
32
+ - `TtlCache` gained `keys()` and `deleteWhere(predicate)` to support the above.
33
+
34
+ ### Added (continued)
35
+
36
+ #### RestClient — Circuit breaker
37
+
38
+ - **`HttpConfig.circuitBreaker`** (new `CircuitBreakerConfig`: `{ failureThreshold, openMs, successThreshold?, isFailure? }`) — after `failureThreshold` consecutive failures the client rejects requests immediately with `CircuitOpenError` (`code: "CIRCUIT_OPEN"`) for `openMs`, without making a network call. After `openMs` it probes with real requests in a `half-open` state: success (×`successThreshold`, default 1) closes the circuit, failure re-opens it. `isFailure(error)` can exclude certain errors (e.g. 4xx) from counting as failures. Cancelled/aborted requests never count as failures. New module `src/circuit-breaker.ts` exports `CircuitBreaker`, `CircuitOpenError`, and the `CircuitBreakerState` type.
39
+ - **`client.getCircuitBreakerState()`** — returns `"closed" | "open" | "half-open"`, or `null` if `circuitBreaker` isn't configured.
40
+ - Not set by default — without `circuitBreaker`, behavior is unchanged.
41
+
42
+ #### Pipeline Orchestrator — run correlation
43
+
44
+ - **`runId`** — every `run()` call generates a fresh ID (via `crypto.randomUUID()`, falling back to a timestamp-based string), shared by `PipelineMetrics.onPipelineStart/onPipelineEnd/onStepDuration`, every `PipelineStepEvent` (`.runId`), and every entry returned by `getLogs()`/`exportState()`. All attempts within one `run()` (including `pipelineRetry` retries) share the same `runId`. `rerunStep()` generates its own separate `runId`. New `orchestrator.getRunId()` reads the current/last one. `PipelineMetrics`' three callback `info` objects and `PipelineStepEvent` gained a `runId` field (required on the former, optional on the latter for backward compatibility).
45
+
46
+ #### DX utilities — typed `pipe()` builder
47
+
48
+ - **`PipelineBuilder<TPrev>`** — the fluent builder is now generic: `.step()` infers and threads the previous step's output type into the next step's `prev`, so TypeScript catches type mismatches across a chain and provides autocomplete. The first step's `prev` is typed `undefined`, matching actual runtime behavior. `.parallel()` / `.subPipeline()` / `.stream()` intentionally don't change the threaded type, since the orchestrator's `prev` for the next step always comes from the last regular `.step()`, never from a parallel group/sub-pipeline/stream. Purely a type-level addition — `PipelineBuilder` still mutates the same instance internally, so existing non-chained usage (calling `.step()` without reassigning the result) keeps working unchanged.
49
+ - `ParallelStageGroup.concurrency` is also exposed through `pipe().parallel(stages, { concurrency })`.
50
+
51
+ ---
52
+
3
53
  ## [1.3.7] - 2026-04-04
4
54
 
5
55
  ### Added
package/README.md CHANGED
@@ -23,12 +23,15 @@ Flexible, modular pipeline orchestrator for REST APIs — sequential and paralle
23
23
  - [Auth Provider](#auth-provider)
24
24
  - [Log Sanitization](#log-sanitization)
25
25
  - [RequestExecutor](#requestexecutor)
26
+ - [Circuit breaker](#circuit-breaker)
26
27
  - [PipelineOrchestrator](#pipelineorchestrator)
28
+ - [Error recovery (errorHandler + recoverStep)](#error-recovery-errorhandler--recoverstep)
27
29
  - [Parallel stages](#parallel-stages)
28
30
  - [Global middleware](#global-middleware)
29
31
  - [Pause / Resume](#pause--resume)
30
32
  - [Export / Import state](#export--import-state)
31
33
  - [Pipeline metrics](#pipeline-metrics)
34
+ - [Correlating a run (runId)](#correlating-a-run-runid)
32
35
  - [createPipeline() + pipe() builder](#createpipeline--pipe-builder)
33
36
  - [validatePipelineConfig()](#validatepipelineconfig)
34
37
  - [Plugin system](#plugin-system)
@@ -45,15 +48,16 @@ Flexible, modular pipeline orchestrator for REST APIs — sequential and paralle
45
48
 
46
49
  ## Features
47
50
 
48
- - **`createRestClient()`** — full-featured HTTP client built on top of axios: retry with exponential backoff and `Retry-After` support, response caching for GET requests, rate limiting (concurrency + req/interval), auth provider with automatic 401 refresh, request cancellation by key, custom HTTP adapters
49
- - **`PipelineOrchestrator`** — sequential and parallel stage execution; each stage has `condition`, `before`, `request`, `after`, `errorHandler` hooks; `sharedData` pool shared across all stages
51
+ - **`createRestClient()`** — full-featured HTTP client built on top of axios: retry with exponential backoff and `Retry-After` support, response caching (incl. targeted `invalidateCache()`), rate limiting (concurrency + req/interval), circuit breaker, auth provider with automatic 401 refresh and optional token caching, request cancellation by key, custom HTTP adapters
52
+ - **`PipelineOrchestrator`** — sequential and parallel stage execution; each stage has `condition`, `before`, `request`, `after`, `errorHandler` hooks (all receive the pipeline's `AbortSignal`); `sharedData` pool shared across all stages
53
+ - **Error recovery** — `errorHandler` can return `recoverStep(data)` to turn a failed stage back into a successful one and keep the pipeline going, instead of only transforming the error
50
54
  - **Global middleware** — `beforeEach` / `afterEach` / `onError` hooks that apply to every stage without modifying individual configs
51
- - **Parallel groups** — multiple stages run concurrently via `Promise.all`; single failure stops the group
52
- - **Pause / Resume / Abort** — `pause()` waits after the current stage; `resume()` continues; `abort()` cancels the current HTTP request via `AbortController`
55
+ - **Parallel groups** — multiple stages run concurrently via `Promise.all`, or through a bounded pool via `concurrency`; single failure stops the group
56
+ - **Pause / Resume / Abort** — `pause()` waits after the current stage; `resume()` continues; `abort()` cancels the current HTTP request and propagates its `AbortSignal` into every stage hook so custom `request`/`before`/`after` functions can cancel their own work too
53
57
  - **Export / Import state** — serialize `stageResults` + logs to a plain object; restore on the next page load
54
58
  - **Stream stages** — `stream: async function*` for SSE / any `AsyncIterable`; `onChunk` callback in real time; abort-aware
55
- - **Pipeline metrics** — `onPipelineStart`, `onPipelineEnd`, `onStepDuration` callbacks without touching stage logic
56
- - **`createPipeline()` / `pipe()` builder** — short factory and fluent builder API for common patterns
59
+ - **Pipeline metrics & run correlation** — `onPipelineStart`, `onPipelineEnd`, `onStepDuration` callbacks, plus a `runId` (also on `getRunId()`, log entries, and step events) shared by every callback/event from the same run
60
+ - **`createPipeline()` / `pipe()` builder** — short factory and fluent builder API for common patterns; in TypeScript, `pipe().step()` chains infer `prev`'s type from the previous step automatically
57
61
  - **`validatePipelineConfig()`** — catch duplicate keys, empty keys, type errors before runtime
58
62
  - **Plugin system** — install reusable behavior (logging, analytics, etc.); cleanup via `destroy()`
59
63
  - **Persist adapter** — pluggable save/load interface; auto-save after each stage
@@ -168,7 +172,9 @@ Creates a REST client with advanced HTTP features.
168
172
  | `request(url, config?)` | Generic request |
169
173
  | `cancellableRequest(key, url, config?)` | Request cancellable by key |
170
174
  | `cancelRequest(key)` | Cancel request by key |
171
- | `clearCache()` | Clear this client's response cache |
175
+ | `clearCache()` | Clear this client's entire response cache |
176
+ | `invalidateCache(matcher)` | Clear only cache entries whose URL matches `matcher` (substring, `RegExp`, or `(info) => boolean`); returns the number of entries removed |
177
+ | `getCircuitBreakerState()` | `"closed" \| "open" \| "half-open"`, or `null` if `circuitBreaker` isn't configured |
172
178
 
173
179
  ### HttpConfig options
174
180
 
@@ -190,11 +196,13 @@ Creates a REST client with advanced HTTP features.
190
196
  | `rateLimit.intervalMs` | Time window size in ms |
191
197
  | `metrics.onRequestStart` | Callback on request start |
192
198
  | `metrics.onRequestEnd` | Callback on request end (includes duration and bytes) |
193
- | `auth.getToken` | Async function returning a Bearer token (called before every request) |
199
+ | `auth.getToken` | Async function returning a Bearer token (called before every request, unless `auth.tokenTtlMs` is set) |
194
200
  | `auth.onUnauthorized` | Optional async callback on 401 — refresh the token here; request is retried once |
201
+ | `auth.tokenTtlMs` | Cache `getToken()`'s result for this many ms instead of calling it before every request; invalidated automatically on 401 |
195
202
  | `sanitizeHeaders` | Mask sensitive headers in metrics callbacks (default: `false`) |
196
203
  | `sensitiveHeaders` | Additional headers to mask (extends `DEFAULT_SENSITIVE_HEADERS`) |
197
204
  | `adapter` | Custom HTTP adapter (e.g. native `fetch`) — replaces built-in axios |
205
+ | `circuitBreaker` | See [Circuit breaker](#circuit-breaker) — `{ failureThreshold, openMs, successThreshold?, isFailure? }` |
198
206
 
199
207
  ### Per-request cache override
200
208
 
@@ -206,6 +214,18 @@ const res = await client.get("/data", {
206
214
  });
207
215
  ```
208
216
 
217
+ ### Targeted cache invalidation
218
+
219
+ `clearCache()` wipes the entire response cache. To invalidate only the entries affected by a mutation (e.g. after a `POST`/`PUT`/`DELETE`), use `invalidateCache()` instead — it accepts a substring, a `RegExp`, or a predicate over `{ method, url }`, and returns how many entries were removed:
220
+
221
+ ```js
222
+ await client.post("/users/1/orders", newOrder);
223
+
224
+ client.invalidateCache("/users/1"); // substring match on the cached URL
225
+ client.invalidateCache(/^https:\/\/api\.example\.com\/users\/\d+$/);
226
+ client.invalidateCache(({ method, url }) => method === "GET" && url.includes("/orders"));
227
+ ```
228
+
209
229
  ### Full example
210
230
 
211
231
  ```js
@@ -269,6 +289,21 @@ const client = createRestClient({
269
289
  const res = await client.get("/profile");
270
290
  ```
271
291
 
292
+ ### Caching the token
293
+
294
+ If `getToken()` is expensive (e.g. it talks to a secure storage or refresh endpoint), set `tokenTtlMs` to reuse the result across requests instead of calling `getToken()` before every single one. The cache is invalidated automatically on a `401`, so the next request always re-fetches a fresh token before retrying:
295
+
296
+ ```js
297
+ const client = createRestClient({
298
+ baseURL: "https://api.example.com",
299
+ auth: {
300
+ getToken: async () => requestTokenFromSecureEnclave(), // expensive
301
+ onUnauthorized: async () => refreshAccessToken(),
302
+ tokenTtlMs: 5 * 60_000, // reuse for up to 5 minutes
303
+ },
304
+ });
305
+ ```
306
+
272
307
  ---
273
308
 
274
309
  ## Log Sanitization
@@ -334,6 +369,41 @@ When the server returns a `Retry-After` header (numeric seconds or HTTP-date), t
334
369
 
335
370
  ---
336
371
 
372
+ ## Circuit breaker
373
+
374
+ Protect a failing backend (and your own app) from piling up retries/timeouts: after `failureThreshold` consecutive failures, the client stops calling the network entirely for `openMs` and rejects requests immediately with a `CircuitOpenError` (`code: "CIRCUIT_OPEN"`). After `openMs`, it lets a probe request through (`half-open`); success closes the circuit again, failure re-opens it.
375
+
376
+ ```js
377
+ import { createRestClient, CircuitOpenError } from "rest-pipeline-js";
378
+
379
+ const client = createRestClient({
380
+ baseURL: "https://api.example.com",
381
+ circuitBreaker: {
382
+ failureThreshold: 5, // open after 5 consecutive failures
383
+ openMs: 30_000, // stay open for 30s before probing again
384
+ successThreshold: 2, // need 2 successful probes to fully close
385
+ isFailure: (error) => error.status === undefined || error.status >= 500, // ignore 4xx
386
+ },
387
+ });
388
+
389
+ try {
390
+ await client.get("/flaky-endpoint");
391
+ } catch (err) {
392
+ if (err instanceof CircuitOpenError) {
393
+ // rejected locally — no network call was made
394
+ }
395
+ }
396
+
397
+ client.getCircuitBreakerState(); // "closed" | "open" | "half-open"
398
+ ```
399
+
400
+ - Works on top of retry, cache, rate limiting, auth, and custom `adapter`s — it sits around the actual network call, same as those features.
401
+ - Each retry attempt (from `RequestExecutor`/`request.retry`) counts as its own pass through the breaker, so a flaky endpoint with retries enabled opens the circuit faster, not slower.
402
+ - Cancelled/aborted requests are never counted as failures.
403
+ - Not set by default — without `circuitBreaker`, behavior is unchanged.
404
+
405
+ ---
406
+
337
407
  ## PipelineOrchestrator
338
408
 
339
409
  Main class for building and managing a pipeline of sequential (and parallel) stages.
@@ -363,6 +433,7 @@ new PipelineOrchestrator({
363
433
  | `exportState()` | Serialize stageResults and logs to a plain object |
364
434
  | `importState(state)` | Restore stageResults and logs from a snapshot |
365
435
  | `getStageResults()` | Synchronous snapshot of all stage results |
436
+ | `getRunId()` | ID of the current/last `run()` or `rerunStep()` — see [Correlating a run](#correlating-a-run-runid) |
366
437
  | `destroy()` | Run cleanup callbacks from all installed plugins |
367
438
  | `subscribeProgress(listener)` | Subscribe to progress updates |
368
439
  | `subscribeStageResults(listener)` | Subscribe to stageResults changes |
@@ -375,18 +446,37 @@ new PipelineOrchestrator({
375
446
 
376
447
  ### Stage parameters (PipelineStageConfig)
377
448
 
378
- | Parameter | Description |
379
- | --------------------------------------------- | ------------------------------------------------------------------------ |
380
- | `key` | Unique stage identifier |
381
- | `request({ prev, allResults, sharedData })` | Main stage function — return value becomes the stage result |
382
- | `condition({ prev, allResults, sharedData })` | If returns `false`, stage is skipped with status `"skipped"` |
383
- | `before({ prev, allResults, sharedData })` | Pre-processing hookreturned value replaces `prev` passed to `request` |
384
- | `after({ result, allResults, sharedData })` | Post-processing hook returned value replaces the stage result |
385
- | `errorHandler({ error, key, sharedData })` | Per-stage error handler |
386
- | `retryCount` | Override retry count for this stage |
387
- | `timeoutMs` | Override timeout for this stage |
388
- | `pauseBefore` | Delay in ms before executing `request` |
389
- | `pauseAfter` | Delay in ms after executing `request` |
449
+ Every hook below also receives `signal: AbortSignal` in its params object — the same signal used by `orchestrator.abort()`. Pass it down to `fetch`/`axios`/etc. inside `request`/`before`/`after` so cancellation actually stops in-flight work, not just the pipeline's bookkeeping.
450
+
451
+ | Parameter | Description |
452
+ | ------------------------------------------------------ | ------------------------------------------------------------------------ |
453
+ | `key` | Unique stage identifier |
454
+ | `request({ prev, allResults, sharedData, signal })` | Main stage function return value becomes the stage result |
455
+ | `condition({ prev, allResults, sharedData, signal })` | If returns `false`, stage is skipped with status `"skipped"` |
456
+ | `before({ prev, allResults, sharedData, signal })` | Pre-processing hook — returned value replaces `prev` passed to `request` |
457
+ | `after({ result, allResults, sharedData, signal })` | Post-processing hook returned value replaces the stage result |
458
+ | `errorHandler({ error, key, sharedData, signal })` | Per-stage error handler see [Error recovery](#error-recovery-errorhandler--recoverstep) below |
459
+ | `retryCount` | Override retry count for this stage |
460
+ | `timeoutMs` | Override timeout for this stage |
461
+ | `pauseBefore` | Delay in ms before executing `request` |
462
+ | `pauseAfter` | Delay in ms after executing `request` |
463
+
464
+ ### Error recovery (`errorHandler` + `recoverStep`)
465
+
466
+ By default, whatever `errorHandler` returns is wrapped into an `ApiError` and the stage stays `"error"` — it can transform/enrich the error but not turn the failure into a success. Return `recoverStep(data)` to recover the stage instead: it's committed exactly like a successful stage (status `"success"`, `afterEach` middleware, metrics, persistence) and the pipeline continues normally.
467
+
468
+ ```js
469
+ import { recoverStep } from "rest-pipeline-js";
470
+
471
+ {
472
+ key: "fetchPrice",
473
+ request: async () => fetchPriceFromApi(),
474
+ errorHandler: ({ error }) => {
475
+ if (isNetworkError(error)) return recoverStep(0); // fall back to a default and continue
476
+ return error; // anything else: keep failing as before
477
+ },
478
+ }
479
+ ```
390
480
 
391
481
  ### Stage execution flow
392
482
 
@@ -410,7 +500,9 @@ middleware.afterEach
410
500
  [status: success] → next stage
411
501
 
412
502
  On error at any point:
413
- └─► stage.errorHandler (if set) → middleware.onError → [status: error] → stop
503
+ └─► stage.errorHandler (if set)
504
+ ├─► returns recoverStep(data) → [status: success] → next stage
505
+ └─► otherwise → middleware.onError → [status: error] → stop
414
506
  ```
415
507
 
416
508
  ### Full example
@@ -501,11 +593,28 @@ const orchestrator = new PipelineOrchestrator({
501
593
  });
502
594
  ```
503
595
 
504
- - All stages in a `parallel` group run simultaneously via `Promise.all`.
596
+ - All stages in a `parallel` group run simultaneously via `Promise.all` — unless `concurrency` is set (see below).
505
597
  - If **any** stage in the group fails, the pipeline stops and marks `success: false`.
506
598
  - Each parallel stage has its own key and result in `stageResults`.
507
599
  - `rerunStep(key)` works for stages inside parallel groups too.
508
600
 
601
+ ### Limiting concurrency
602
+
603
+ For fan-out over many items (e.g. paginated fetches), set `concurrency` on the group to cap how many stages run at once instead of starting all of them immediately:
604
+
605
+ ```js
606
+ {
607
+ key: "fetch-all-pages",
608
+ parallel: pageNumbers.map((n) => ({
609
+ key: `page-${n}`,
610
+ request: async () => fetchPage(n),
611
+ })),
612
+ concurrency: 5, // at most 5 requests in flight at a time
613
+ }
614
+ ```
615
+
616
+ Results land in `stageResults` under their own key regardless of `concurrency`, in the same shape as an unlimited group. With the `pipe()` builder: `.parallel(stages, { concurrency: 5 })`.
617
+
509
618
  ---
510
619
 
511
620
  ## Global middleware
@@ -600,25 +709,33 @@ const orchestrator = new PipelineOrchestrator({
600
709
  /* ... */
601
710
  ],
602
711
  metrics: {
603
- onPipelineStart: ({ timestamp }) => {
604
- console.log("Pipeline started at", new Date(timestamp).toISOString());
712
+ onPipelineStart: ({ timestamp, runId }) => {
713
+ console.log(`[${runId}] Pipeline started at`, new Date(timestamp).toISOString());
605
714
  },
606
- onPipelineEnd: ({ durationMs, success, stageResults }) => {
607
- analytics.track("pipeline_complete", { durationMs, success });
715
+ onPipelineEnd: ({ durationMs, success, stageResults, runId }) => {
716
+ analytics.track("pipeline_complete", { durationMs, success, runId });
608
717
  },
609
- onStepDuration: ({ stepKey, durationMs, status }) => {
610
- console.log(`[${stepKey}] ${status} in ${durationMs}ms`);
718
+ onStepDuration: ({ stepKey, durationMs, status, runId }) => {
719
+ console.log(`[${runId}] [${stepKey}] ${status} in ${durationMs}ms`);
611
720
  },
612
721
  },
613
722
  },
614
723
  });
615
724
  ```
616
725
 
617
- | Callback | Receives | Description |
618
- | ----------------- | --------------------------------------- | --------------------------------- |
619
- | `onPipelineStart` | `{ timestamp }` | Fires at the beginning of `run()` |
620
- | `onPipelineEnd` | `{ durationMs, success, stageResults }` | Fires when `run()` completes |
621
- | `onStepDuration` | `{ stepKey, durationMs, status }` | Fires after every executed step |
726
+ | Callback | Receives | Description |
727
+ | ----------------- | ------------------------------------------------- | --------------------------------- |
728
+ | `onPipelineStart` | `{ timestamp, runId }` | Fires at the beginning of `run()` |
729
+ | `onPipelineEnd` | `{ durationMs, success, stageResults, runId }` | Fires when `run()` completes |
730
+ | `onStepDuration` | `{ stepKey, durationMs, status, runId }` | Fires after every executed step |
731
+
732
+ ### Correlating a run (`runId`)
733
+
734
+ Every `run()` call generates a fresh `runId` (a UUID, or a timestamp-based fallback in environments without `crypto.randomUUID`), shared by all metrics callbacks, log entries (`getLogs()`), and step events (`PipelineStepEvent.runId`) produced during that run — including all attempts of `pipelineRetry`. `rerunStep()` generates its own separate `runId`. Use `orchestrator.getRunId()` to read the current/last one, or read `runId` off any event/log/metrics callback to correlate everything that happened during one execution in your logging/tracing backend:
735
+
736
+ ```js
737
+ orchestrator.on("log", (entry) => sendToLogBackend({ ...entry, runId: orchestrator.getRunId() }));
738
+ ```
622
739
 
623
740
  ---
624
741
 
@@ -671,12 +788,26 @@ const orchestrator = pipe()
671
788
  | Builder method | Description |
672
789
  | ----------------------------- | -------------------------------------------------------- |
673
790
  | `.step(stage)` | Add a sequential stage |
674
- | `.parallel(stages, options?)` | Add a parallel group (`key` auto-generated if omitted) |
791
+ | `.parallel(stages, options?)` | Add a parallel group (`key`/`concurrency` optional, see [Limiting concurrency](#limiting-concurrency)) |
675
792
  | `.subPipeline(item)` | Embed a sub-pipeline as a stage |
676
793
  | `.stream(stage)` | Add a stream stage (AsyncIterable) |
677
794
  | `.build(options?)` | Create and return a `PipelineOrchestrator` |
678
795
  | `.toConfig(options?)` | Return `PipelineConfig` without creating an orchestrator |
679
796
 
797
+ #### Typed chaining (TypeScript)
798
+
799
+ In TypeScript, `pipe().step(...)` tracks the type of `prev` across the chain: each `.step()`'s `prev` is typed as the previous step's return value (`undefined` for the very first step, matching the orchestrator's actual runtime behavior). `.parallel()` / `.subPipeline()` / `.stream()` don't change it — exactly like at runtime, where `prev` for the next step still comes from the last regular `.step()`, not from a parallel group's results:
800
+
801
+ ```ts
802
+ const orchestrator = pipe()
803
+ .step({ key: "auth", request: async (): Promise<string> => getToken() })
804
+ .step({ key: "fetchUser", request: async ({ prev }) => fetchUser(prev) }) // prev: string — inferred, autocompletes
805
+ .step({ key: "oops", request: async ({ prev }) => prev.totallyNotAMethod() }) // ✗ compile error: wrong type for prev
806
+ .build();
807
+ ```
808
+
809
+ This works whether or not you keep reassigning the chain (`builder.step(...)` without capturing the return value still mutates the same instance, just like before) — the typing is purely additive and doesn't change runtime behavior.
810
+
680
811
  ---
681
812
 
682
813
  ## validatePipelineConfig()
@@ -848,6 +979,8 @@ type HttpAdapter = {
848
979
  };
849
980
  ```
850
981
 
982
+ When `adapter` is set, `createRestClient()` never calls `axios.create()` — the built-in axios instance simply isn't constructed, so adapter-only usage (e.g. in Cloudflare Workers / Deno) doesn't pay for it.
983
+
851
984
  ---
852
985
 
853
986
  ## Vue integration
@@ -989,7 +1122,8 @@ rest-pipeline-js
989
1122
  │ ├── RequestExecutor — retry + backoff + Retry-After + AbortController timeout
990
1123
  │ ├── CacheManager — in-memory TTL cache for GET responses
991
1124
  │ ├── RateLimiter — concurrency + req/interval sliding window
992
- │ ├── AuthProvider Bearer injection; 401 refresh + one retry
1125
+ │ ├── CircuitBreaker closed → open → half-open; rejects locally when open
1126
+ │ ├── AuthProvider — Bearer injection; 401 refresh + one retry; optional tokenTtlMs cache
993
1127
  │ ├── MetricsCollector — onRequestStart / onRequestEnd callbacks
994
1128
  │ ├── HeaderSanitizer — masks sensitive headers before metrics callbacks
995
1129
  │ └── HttpAdapter — pluggable transport (default: axios; swap for fetch / edge)
package/dist/cjs/cache.js CHANGED
@@ -64,6 +64,21 @@ class TtlCache {
64
64
  delete(key) {
65
65
  this.store.delete(key);
66
66
  }
67
+ /** Итератор по всем ключам кэша (включая потенциально устаревшие — не фильтрует по TTL). */
68
+ keys() {
69
+ return this.store.keys();
70
+ }
71
+ /** Удаляет все записи, для которых predicate(key) вернул true. Возвращает количество удалённых записей. */
72
+ deleteWhere(predicate) {
73
+ let count = 0;
74
+ for (const key of [...this.store.keys()]) {
75
+ if (predicate(key)) {
76
+ this.store.delete(key);
77
+ count++;
78
+ }
79
+ }
80
+ return count;
81
+ }
67
82
  clear() {
68
83
  this.store.clear();
69
84
  }
@@ -0,0 +1,84 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.CircuitBreaker = exports.CircuitOpenError = void 0;
4
+ /** Ошибка, бросаемая вместо реального запроса, когда circuit breaker открыт. */
5
+ class CircuitOpenError extends Error {
6
+ constructor() {
7
+ super("Circuit breaker is open — request rejected without calling the network");
8
+ this.code = "CIRCUIT_OPEN";
9
+ this.name = "CircuitOpenError";
10
+ }
11
+ }
12
+ exports.CircuitOpenError = CircuitOpenError;
13
+ /**
14
+ * Простой circuit breaker (closed → open → half-open → closed) для HTTP-клиента.
15
+ * - closed: запросы выполняются как обычно, считаются последовательные ошибки.
16
+ * - open: запросы немедленно отклоняются (CircuitOpenError), без обращения к сети.
17
+ * - half-open: после openMs пропускает запросы "на пробу"; успех закрывает circuit,
18
+ * неудача снова открывает его.
19
+ */
20
+ class CircuitBreaker {
21
+ constructor(config) {
22
+ this.config = config;
23
+ this.state = "closed";
24
+ this.failureCount = 0;
25
+ this.successCount = 0;
26
+ this.openedAt = 0;
27
+ }
28
+ /** Текущее состояние (учитывает автоматический переход open → half-open по таймауту). */
29
+ getState() {
30
+ this._maybeTransitionToHalfOpen();
31
+ return this.state;
32
+ }
33
+ /** Можно ли выполнить запрос сейчас (false, если circuit открыт). */
34
+ canExecute() {
35
+ this._maybeTransitionToHalfOpen();
36
+ return this.state !== "open";
37
+ }
38
+ /** Зарегистрировать успешный запрос. */
39
+ onSuccess() {
40
+ var _a;
41
+ if (this.state === "half-open") {
42
+ this.successCount++;
43
+ const needed = (_a = this.config.successThreshold) !== null && _a !== void 0 ? _a : 1;
44
+ if (this.successCount >= needed) {
45
+ this._close();
46
+ }
47
+ }
48
+ else {
49
+ this.failureCount = 0;
50
+ }
51
+ }
52
+ /** Зарегистрировать неудачный запрос (error уже приведена к ApiError). */
53
+ onFailure(error) {
54
+ if (this.config.isFailure && !this.config.isFailure(error))
55
+ return;
56
+ if (this.state === "half-open") {
57
+ this._open();
58
+ return;
59
+ }
60
+ this.failureCount++;
61
+ if (this.failureCount >= this.config.failureThreshold) {
62
+ this._open();
63
+ }
64
+ }
65
+ _maybeTransitionToHalfOpen() {
66
+ if (this.state === "open" &&
67
+ Date.now() - this.openedAt >= this.config.openMs) {
68
+ this.state = "half-open";
69
+ this.successCount = 0;
70
+ }
71
+ }
72
+ _open() {
73
+ this.state = "open";
74
+ this.openedAt = Date.now();
75
+ this.failureCount = 0;
76
+ this.successCount = 0;
77
+ }
78
+ _close() {
79
+ this.state = "closed";
80
+ this.failureCount = 0;
81
+ this.successCount = 0;
82
+ }
83
+ }
84
+ exports.CircuitBreaker = CircuitBreaker;
package/dist/cjs/index.js CHANGED
@@ -23,3 +23,4 @@ __exportStar(require("./progress-tracker"), exports);
23
23
  __exportStar(require("./pipeline-orchestrator"), exports);
24
24
  __exportStar(require("./pipeline-builder"), exports);
25
25
  __exportStar(require("./pipeline-validator"), exports);
26
+ __exportStar(require("./circuit-breaker"), exports);
@@ -36,10 +36,19 @@ function createPipeline(stages, options = {}) {
36
36
  * Fluent builder для создания pipeline.
37
37
  * Позволяет строить конвейер цепочкой вызовов вместо ручного конструирования массива stages.
38
38
  *
39
+ * `TPrev` — тип `prev`, который получит *следующий* `.step()` (тип данных, возвращённых
40
+ * текущим шагом). Это чисто типовой (phantom) параметр — во время выполнения класс всегда
41
+ * работает с одним и тем же массивом stages, поведение не меняется по сравнению с
42
+ * нетипизированным использованием (без чейнинга — через отдельные вызовы без переприсвоения).
43
+ *
44
+ * `.parallel()` / `.subPipeline()` / `.stream()` не меняют `TPrev` — это соответствует
45
+ * реальному поведению orchestrator: `prev` следующего шага берётся из последнего обычного
46
+ * (`step`) шага, а не из параллельной группы/sub-pipeline/стрима.
47
+ *
39
48
  * @example
40
49
  * const orchestrator = pipe()
41
- * .step({ key: "auth", request: async () => getToken() })
42
- * .step({ key: "fetchUser", condition: ({ prev }) => !!prev, request: async ({ prev }) => fetchUser(prev) })
50
+ * .step({ key: "auth", request: async () => getToken() }) // TPrev для следующего шага: string
51
+ * .step({ key: "fetchUser", request: async ({ prev }) => fetchUser(prev) }) // prev: string — автокомплит и проверка типов
43
52
  * .parallel([
44
53
  * { key: "loadA", request: async () => loadA() },
45
54
  * { key: "loadB", request: async () => loadB() },
@@ -52,14 +61,19 @@ class PipelineBuilder {
52
61
  }
53
62
  /**
54
63
  * Добавить обычный (последовательный) шаг.
64
+ * `prev` в этом шаге типизируется как результат предыдущего `.step()` (или `undefined` для первого).
65
+ * Тип `TOutput` обычно выводится автоматически из возвращаемого значения `request`/`after`.
55
66
  */
56
67
  step(stage) {
57
68
  this.stages.push(stage);
69
+ // Безопасный cast: TPrev/TOutput — чисто типовые параметры, не хранятся в экземпляре,
70
+ // поэтому смена фантомного типа не требует создания нового объекта.
58
71
  return this;
59
72
  }
60
73
  /**
61
74
  * Добавить группу параллельных шагов.
62
- * Все шаги в группе выполняются одновременно через Promise.all.
75
+ * Все шаги в группе выполняются одновременно через Promise.all (либо через пул,
76
+ * если задан `concurrency`).
63
77
  */
64
78
  parallel(stages, options) {
65
79
  var _a;
@@ -69,6 +83,9 @@ class PipelineBuilder {
69
83
  ...((options === null || options === void 0 ? void 0 : options.continueOnError) !== undefined
70
84
  ? { continueOnError: options.continueOnError }
71
85
  : {}),
86
+ ...((options === null || options === void 0 ? void 0 : options.concurrency) !== undefined
87
+ ? { concurrency: options.concurrency }
88
+ : {}),
72
89
  };
73
90
  this.stages.push(group);
74
91
  return this;
@@ -110,6 +127,8 @@ exports.PipelineBuilder = PipelineBuilder;
110
127
  /**
111
128
  * Создаёт новый PipelineBuilder.
112
129
  * Точка входа для fluent API.
130
+ * `prev` первого `.step()` типизируется как `undefined` — ровно так, как ведёт себя
131
+ * orchestrator в реальности (у первого шага pipeline нет предыдущего результата).
113
132
  *
114
133
  * @example
115
134
  * const orchestrator = pipe()