# rest-pipeline-js — AI Reference A resilient HTTP client (retry/backoff/jitter, cache, rate limiting, circuit breaker, offline queue, tracing, idempotency) plus a DAG-capable request pipeline orchestrator (sequential/parallel/sub-pipeline/SSE-stream/ WebSocket stages, pause/resume, persistence, plugins) — framework-agnostic core with Vue and React adapters. Version 2.1.4. This document is hand-written for AI agents and other tools that generate code against this package: every signature, default, and behavior note below is verified directly against the TypeScript source (not summarized from prose docs), and prose is kept to the minimum needed to use the API correctly. For human-readable narrative docs (why you'd reach for each piece, worked examples), see the interactive site instead: - Full docs (EN): https://npm.vuecraft.ru/en/packages/rest-pipeline-js/guide/overview - Full docs (RU): https://npm.vuecraft.ru/packages/rest-pipeline-js/guide/overview - GitHub: https://github.com/macrulezru/pipeline-js - npm: https://www.npmjs.com/package/rest-pipeline-js Links below starting with "/" are relative to https://npm.vuecraft.ru. --- ## 1. Package map | Import path | Contains | |---|---| | `rest-pipeline-js` | Core: HTTP client, `RequestExecutor`, `PipelineOrchestrator`, `createPipeline`/`pipe()`, `validatePipelineConfig`, pagination — no framework dependency (section 2–7). | | `rest-pipeline-js/vue` | Everything in core (`export *`) **plus** 5 Vue composables (section 8.1). | | `rest-pipeline-js/react` | Everything in core (`export *`) **plus** 5 React hooks (section 8.2). | | `rest-pipeline-js/testing` | `createMockAdapter` — a route-based `HttpAdapter` mock for tests, kept out of the production bundle (section 9). | `axios` is the only hard runtime dependency (used unless `HttpConfig.adapter` is set, in which case axios is never touched — useful for edge/serverless runtimes or to use raw `fetch`). Everything else (rate-limit/cache/circuit- breaker Redis-style stores, tracing providers) is bring-your-own via a plain-object interface — no optional peers to install. --- ## 2. HTTP client — `HttpConfig` All fields optional. Verbatim defaults/behavior (from `types/http.ts`'s own JSDoc, cross-checked against `rest-client.ts`/`request-executor.ts`): | Field | Default | Behavior | |---|---|---| | `baseURL`, `timeout`, `headers`, `withCredentials` | — | Passed straight to axios (or ignored if `adapter` is set — the adapter receives `baseURL`/`url` separately, must build its own request). | | `retry` | off | See section 3 (`RequestExecutor`, not the raw client — see note below). | | `cache` | off | See 2.1. | | `rateLimit` | off | See 2.2. | | `circuitBreaker` | off | See 2.3. | | `offlineQueue` | off | See 2.4. | | `auth` | off | `AuthProvider` — `getToken()` called before every request (or once per `tokenTtlMs` if set, then cached and reused); on a 401, `onUnauthorized?.()` runs, the token cache is invalidated, and the request is retried **exactly once** (a second 401 is not intercepted again). | | `sanitizeHeaders` | **`true`** | Masks `DEFAULT_SENSITIVE_HEADERS` (`authorization`, `x-api-key`, `x-auth-token`, `cookie`, `set-cookie`, `proxy-authorization`) as `"REDACTED"` in `metrics` callbacks only — does not affect the actual request/response. | | `sensitiveHeaders` | `[]` | Extra header names to mask, in addition to the defaults. Case-insensitive. | | `onError` | — | Called with `(ApiError, RestRequestConfig)` on every request error, before it's thrown. | | `interceptors.request/response/error` | `[]` | Single fn or array; run in array order. Request interceptors run AFTER auth/tracing/idempotency headers are merged in, before sending. | | `deduplicateRequests` | `false` | Merges simultaneous identical **GET** requests (same method+url+params+cacheKey) into one in-flight promise. Ignored if `req.skipRateLimit` is set on that call. | | `adapter` | axios | `HttpAdapter` — replaces axios entirely (see 2.5). | | `tracing.generateTraceparent` | `false` | Adds a W3C `traceparent` header (unless the caller already set one, case-insensitively). | | `tracing.provider` | — | `startSpan()`/`span.end()` around every request — duck-typed OpenTelemetry-`Span`-compatible, no `@opentelemetry/api` dependency. | | `idempotencyHeaderName` | `"Idempotency-Key"` | Header name used for `req.idempotencyKey`. | | `autoIdempotencyKey` | `false` | `RequestExecutor` (not the raw client) auto-generates an idempotency key for POST/PUT/PATCH/DELETE **once before the retry loop**, if the caller didn't set one — every retry attempt of that call reuses the same key. Direct `client.post()` calls bypassing `RequestExecutor` are unaffected. | **Important: `retry` only applies through `RequestExecutor`/`PipelineOrchestrator` — a direct `client.get()`/`.post()`/etc. call on the raw REST client does NOT retry**, regardless of `HttpConfig.retry` being set. Use `new RequestExecutor(httpConfig).execute(...)` (or a pipeline stage, which uses `RequestExecutor` internally) for retry behavior. ### 2.1 Response cache (`CacheConfig`) ```ts interface CacheConfig { enabled: boolean; ttlMs: number strategy?: 'strict' | 'stale-while-revalidate' // default 'strict' staleMs?: number // default 0 — 'stale-while-revalidate' extra grace period after ttlMs (0 = stale forever until evicted some other way) store?: CacheStore // default: built-in in-memory TtlCache(maxSize: 1000), LRU-evicted } ``` Only GET requests are cached by default (`cache.enabled && method === 'GET'`); override per-call via `req.useCache`/`req.cacheTtlMs`/`req.cacheKey`. Cache key = `JSON.stringify({ method, url, params, cacheKey })` — two requests differing only in headers/body share a cache entry if method+url+params+ cacheKey match. `client.invalidateCache(matcher)` (substring/RegExp/predicate on `{method,url}`) needs `store.deleteWhere` — returns 0 silently if the store doesn't implement it (the built-in `TtlCache` does). ### 2.2 Rate limiting (`RateLimitConfig`) ```ts interface RateLimitConfig { maxConcurrent?: number; maxRequestsPerInterval?: number; intervalMs?: number store?: RateLimiterStore // default: exact in-memory (per-process) limiter key?: string // bucket key when sharing a `store` — default: random per-instance (no sharing without an explicit key) leaseMs?: number // default 30000 — store-backed concurrency slot auto-release if never explicitly released onRateLimitHeaders?: (headers, control: { throttleFor(ms) }) => void } ``` In-memory concurrency limiting is an exact semaphore with FIFO queueing (reserves the freed slot for the next waiter synchronously, so one release never wakes more than one queued caller). The sliding window (`maxRequestsPerInterval`/`intervalMs`) is an exact in-memory sliding log when unbounded by a store; the STORE-backed version is a fixed-window counter instead (`incrementWindow`), which allows a burst of up to ~2×limit right at a window boundary — a deliberate simplicity trade-off, not a bug. `skipRateLimit: true` on a request bypasses the limiter entirely for that call (also disables dedup for it, see 2). `onRateLimitHeaders` fires after EVERY response (success or error) with the raw headers — the library parses none of the many real-world rate-limit header formats itself; call `control.throttleFor(ms)` yourself based on whatever headers the backend actually sends. ### 2.3 Circuit breaker (`CircuitBreakerConfig`) ```ts interface CircuitBreakerConfig { failureThreshold: number; openMs: number store?: CircuitBreakerStore // default: in-memory, per-process key?: string // default: random per-instance successThreshold?: number // default 1 — successes needed in half-open to close isFailure?: (error: ApiError) => boolean // default: every error counts } ``` States: `closed → open → half-open → closed`. In `open`, requests are rejected with `CircuitOpenError` (`code: 'CIRCUIT_OPEN'`) **without touching the network** — no auth/tracing/interceptors run either, and `onError` still fires. `open` auto-transitions to `half-open` once `openMs` has elapsed since it opened (checked lazily on the next `canExecute()` call, not on a timer). A single failure in `half-open` immediately reopens the circuit. Request cancellations (`REQUEST_CANCELLED`/`AbortError`) are never counted as failures. `client.getCircuitBreakerState()` is `async` (resolves instantly without a `store`, awaits the store otherwise). ### 2.4 Offline queue (`OfflineQueueConfig`) ```ts interface OfflineQueueConfig { enabled: boolean persistAdapter: PipelineStateAdapter // REQUIRED — no in-memory-only mode isOnline?: () => boolean // default: navigator.onLine in browsers, `true` elsewhere onOnlineChange?: (cb) => (() => void) | void // default: window 'online' event; no-op outside a browser shouldQueue?: (info) => boolean // default: POST/PUT/PATCH/DELETE only, never GET maxQueueSize?: number // default unbounded; FIFO-drops the oldest on overflow onFlushSuccess?, onFlushError?: (request, response|error) => void } ``` When offline and `shouldQueue` matches, `request()` throws `OfflineQueuedError` (`queueId`, `method`, `url`) instead of attempting the network — the request itself never happens, it's just recorded (with an auto-generated or caller-supplied `idempotencyKey`) and persisted via `persistAdapter`. `flush()` (auto-triggered by `onOnlineChange`, or call `client.flushQueue()` manually) replays queued requests **sequentially**, oldest first, stopping the moment `isOnline()` reports false again. A replay that gets a real HTTP error response is removed from the queue and reported via `onFlushError` (permanent failure, not retried by the queue itself); a replay that fails with no HTTP status at all (network-level, indistinguishable from "still offline") stays queued and is retried on the next flush — this is a single pass per flush, not a backoff loop (that's `RequestExecutor`'s job for an individual attempt). ### 2.5 Custom `HttpAdapter` ```ts type HttpAdapter = { request(config: RestRequestConfig & { baseURL?: string }): Promise> } ``` Replaces axios entirely when set — the axios instance isn't even constructed. `onUploadProgress`/`onDownloadProgress` (from `AxiosRequestConfig`) only work automatically on the built-in axios path; a custom adapter receives them in `config` as-is but must invoke them itself. A thrown `Error` with `.status`/`.response.status` attached (duck- typed, no axios-specific shape required) still surfaces correctly as `ApiError.status` — needed for `retry.retriableStatus`/ `circuitBreaker.isFailure` to work with a non-axios adapter. --- ## 3. `RequestExecutor` — retry, backoff, jitter ```ts class RequestExecutor { constructor(httpConfig: HttpConfig) execute(command: string, reqConfig?: RestRequestConfig, retryCount?: number, timeoutMs?: number /* default 10000 */, externalSignal?: AbortSignal): Promise> } ``` This is what pipeline stages use internally (via `stage.retryCount`/ `stage.timeoutMs`) — not the raw `client.get()`/etc. `retryCount` param overrides `httpConfig.retry.attempts` for this call if given. ```ts interface RetryConfig { attempts: number; delayMs: number; backoffMultiplier: number retriableStatus?: number[] // unset (default): retry on any non-abort error. Set: an error WITH an HTTP status not in this list throws immediately (no retry); an error with NO status at all (a network-level failure) still retries regardless — the filter only ever excludes on a known, non-matching status maxRetryAfterMs?: number // default 60000 — ceiling for a server Retry-After value jitterStrategy?: 'fixed' | 'full' | 'decorrelated' // default 'fixed' } ``` Exact backoff formula per attempt `n` (1-indexed): - `nominal = delayMs * backoffMultiplier^(n-1)` - `'fixed'` (default): `nominal + random(0, delayMs * 0.1)` — always ≥ nominal. - `'full'` (AWS full jitter): `random(0, nominal)` — can be much shorter than nominal, spreads concurrent retries out better. - `'decorrelated'` (AWS decorrelated jitter): `min(cap, random(delayMs, prevDelay * 3))` where `prevDelay` starts at `delayMs` and updates every attempt within this one `execute()` call; `cap = delayMs * backoffMultiplier^attempts`. A `Retry-After` response header, when present, **always wins over the computed backoff delay** (both numeric-seconds and HTTP-date formats supported), clamped to `[0, maxRetryAfterMs]`; falls back to the backoff formula only if the header fails to parse. An abort (timeout or `externalSignal`) is never retried, regardless of `attempts`. Each attempt gets its own fresh `AbortController` for `timeoutMs`, merged with `externalSignal` — a timed-out attempt doesn't poison subsequent retry attempts' ability to run. --- ## 4. Pipeline — stage types A `PipelineConfig.stages` array can mix five item shapes, discriminated structurally (not by a `type` field) — `isParallelGroup`/`isSubPipeline`/ `isStreamStage`/`isWebSocketStage` check for `'parallel'`/`'subPipeline'`/ `'stream'`/`'onMessage'` keys respectively; anything else is a regular `PipelineStageConfig`. ### 4.1 `PipelineStageConfig` — full per-step hook order ```ts type PipelineStageConfig = { key: string condition?: (p) => boolean // false → status 'skipped', step body never runs before?: (p) => Input | void | Promise<...> // return value (if defined) replaces `prev` for validateInput/request validateInput?: (data, ctx) => Input | Promise // after `before`; throw to fail the step (same path as a request error) request?: (p) => Promise | Output // if omitted, `key` is GET'd as a URL via RequestExecutor after?: (p) => Output | Promise // return value replaces the result pauseAfter?: number // ms, sequential setTimeout — happens BEFORE onStepPause // (onStepPause callback, passed to run()/rerunStep(), runs here if provided) validateOutput?: (data, ctx) => Output | Promise // LAST — sees the final value about to be recorded as `data` errorHandler?: (p) => any | PipelineStepRecovery // return recoverStep(data) to treat the step as succeeded retryCount?: number; timeoutMs?: number // forwarded to RequestExecutor.execute pauseBefore?: number // ms, before `before` even runs continueOnError?: boolean // overrides PipelineOptions.continueOnError for this step next?: (p) => string | null // DAG jump: string = target stage key, null = continue in array order } ``` Exact execution order per regular stage (from `executeStage` in `pipeline-orchestrator.ts`): `condition` → (abort check) → middleware `beforeEach` → `pauseBefore` sleep → (abort check) → `before` → (abort check) → `validateInput` → `request` (or GET `key` as a URL) → (abort check) → `after` → `pauseAfter` sleep → `onStepPause` callback (from `run()`) → `validateOutput` → commit success (persist, middleware `afterEach`, emit events, THEN check pause/resume). On any thrown error anywhere in that chain, `errorHandler` (if set) gets first say — returning `recoverStep(data)` (or `{ recover: true, data }` directly) makes the step commit as `'success'` with that data instead of `'error'`; anything else `errorHandler` returns becomes the `ApiError` (via `toApiError`) recorded on the step, same as if there were no `errorHandler` at all. `recoverStep(data: T): PipelineStepRecovery` / `isStepRecovery(value)` are the documented way to build/check that error-handler return shape. ### 4.2 `ParallelStageGroup` ```ts type ParallelStageGroup = { key: string; parallel: PipelineStageConfig[]; continueOnError?: boolean; concurrency?: number } ``` Runs every listed stage through the SAME `executeStage` logic as 4.1 (all hooks apply per-item). Without `concurrency`, behaves exactly like `Promise.all` (every item starts at once); with it, a worker pool runs at most `concurrency` items concurrently, filling a finished slot with the next pending item — **results still come back in original item order** regardless of completion order. `next` on regular stages after a parallel group take `prev` from the last REGULAR (`step`) stage before it, not from anything inside the group — a parallel group never sets what `prev` the following stage sees. ### 4.3 `SubPipelineStage` ```ts type SubPipelineStage = { key: string; subPipeline: PipelineConfig; httpConfig?: HttpConfig; sharedData?: Record; continueOnError?: boolean } ``` Constructs a brand-new `PipelineOrchestrator` for `subPipeline` (own `sharedData` = parent's merged with this stage's own override, own `httpConfig` if given, otherwise none), runs it fully, and folds the whole nested `PipelineResult` (`{ stageResults, success }`) into the PARENT step's `data` — the parent step's `data` is the nested pipeline's full result object, not a scalar. The sub-orchestrator is `destroy()`ed (releasing its plugins) in a `finally`, whether it succeeded or not. ### 4.4 `StreamStageConfig` / `WebSocketStageConfig` ```ts type StreamStageConfig = { key: string; stream: (p) => AsyncIterable; onChunk?: (chunk, sharedData) => void; continueOnError?: boolean } type WebSocketStageConfig = { key: string url: string | ((p) => string); protocols?: string | string[] createWebSocket?: (url, protocols?) => WebSocketLike // default: global WebSocket (browser/Deno/Node ≥22) — throws if unavailable onOpen?, onMessage: (data, p) => T | void | Promise<...>, onChunk?, onClose?, onError? closeOn?: (data: T, p) => boolean // return true to close the connection and end the step successfully timeoutMs?: number; continueOnError?: boolean } ``` Both accumulate every emitted chunk/message into the step's final `data` array (`onChunk` fires per item in real time, same as `step::progress` events). **WebSocket success/failure is decided by the `close` event's `wasClean`, not by `onError` firing** — most implementations emit `error` immediately before `close`, so `onError` alone never fails the step; only an unclean close, a thrown `onMessage`/ `onClose`, or `timeoutMs` elapsing does. `onMessage` calls are serialized (a promise chain) so overlapping messages are processed in arrival order even if a handler is itself async and slow. --- ## 5. `PipelineOrchestrator` ```ts new PipelineOrchestrator({ config: PipelineConfig, httpConfig?: HttpConfig, sharedData?: Record }) ``` `TKeys` is a purely type-level union of stage keys for autocomplete on `.on('step::...')` — pass it explicitly, it isn't inferred from `config.stages` automatically. ### 5.1 Running ```ts run(onStepPause?: (stepIndex, stepResult, stageResults) => unknown | Promise, externalSignal?: AbortSignal): Promise rerunStep(stepKey: TKeys | string, options?: { onStepPause?; externalSignal?: AbortSignal }): Promise abort(): void // aborts BOTH the current run() and any in-flight rerunStep() lacking its own externalSignal; also wakes a paused pipeline so it can actually finish aborting isAborted(): boolean getRunId(): string // regenerated at the start of every run()/rerunStep(); shared across all pipelineRetry attempts within one run() ``` `rerunStep` re-executes ONE stage in place (fully re-running its `before`/`validateInput`/`request`/`after`/`validateOutput`/`errorHandler` chain) without restarting the rest of the pipeline — but cannot target a stream/WebSocket/sub-pipeline stage (silently returns `undefined`; a regular stage nested inside a parallel group CAN be rerun by its own key). Pipeline-level retry (`PipelineOptions.pipelineRetry`, distinct from a per-stage `retryCount`): ```ts pipelineRetry?: { attempts: number; delayMs?: number; retryFrom?: 'start' | 'failed-step' /* default 'start' */ } ``` `'start'` clears ALL stage results and restarts from index 0 on each retry attempt; `'failed-step'` keeps already-succeeded stages' results and resumes from the index that failed. `run()`'s returned `PipelineResult` is from the LAST attempt only. `PipelineOptions.maxSteps` (default `stages.length * 10`) guards against an infinite loop from `next` DAG transitions — exceeding it fails the whole run immediately (not per-attempt-retried) with a logged error. ### 5.2 Pause / resume ```ts pause(): void; resume(): void; isPaused(): boolean ``` `pause()` takes effect after the CURRENTLY RUNNING stage finishes (checked right after a stage commits success, before moving to the next) — it does not interrupt an in-flight request. `abort()` while paused automatically resumes first so the abort can actually propagate and finish the run. ### 5.3 Subscriptions & events ```ts on(event: `step:${TKeys}:${'start'|'success'|'error'|'progress'|'skipped'}` | 'log' | string, handler): () => void onStepStart/onStepFinish/onStepError(handler: (event: PipelineStepEvent) => void | Promise): () => void // fire for EVERY step, not filtered by key subscribeStageResults(listener: (results) => void): () => void // called immediately with current results, then on every change subscribeProgress(listener: (progress: PipelineProgress) => void): () => void // same immediate-call behavior subscribeStepProgress(stepKey, listener: (status: PipelineStepStatus) => void): () => void // sugar for on(`step:${key}:progress`, ...) ``` A throwing event handler is caught and logged (`addLog('error', ...)`) — it does NOT turn an otherwise-successful step into an error and does not stop remaining handlers for that event from running. ### 5.4 State export/import & persistence ```ts exportState(): PipelineExportedState // { stageResults, logs } — deep-cloned, JSON-serializable importState(state: PipelineExportedState): void getStageResults(): Record // synchronous snapshot getLogs(): Array<{ type, message, data?, timestamp: Date, runId? }> clearStageResults(): void // also resets progress ``` `PipelineOptions.persistAdapter` (a `PipelineStateAdapter`, same interface `HttpConfig.offlineQueue.persistAdapter` uses) auto-loads saved state at the START of `run()` (skipped entirely if `autoReset` is also true — autoReset wins, otherwise loading a persisted snapshot right after clearing it would silently undo the reset) and auto-saves after EVERY successful stage/stream/WebSocket commit (best-effort — a save failure is swallowed, never aborts the pipeline). `PipelineOptions.maxLogs` caps the in-memory log array (FIFO-trimmed) — unset means unbounded growth for a long-lived orchestrator reused across many runs without `autoReset`. ### 5.5 Plugins ```ts type PipelinePlugin = { name: string; install(orchestrator): void | (() => void) } ``` `PipelineOptions.plugins` — each plugin's `install()` runs once in the constructor; a returned cleanup function is called by `orchestrator.destroy()` (which you must call yourself when done with a long-lived orchestrator — it's not automatic). --- ## 6. Building a pipeline ```ts createPipeline(stages: PipelineItem[], options?: { httpConfig?, sharedData?, middleware?, pipelineOptions?, metrics? }): PipelineOrchestrator ``` Shorthand — avoids the nested `{ config: { stages, ... } }` literal. ```ts pipe(): PipelineBuilder // .step(cfg).parallel(stages, opts?).subPipeline(item).stream(cfg).websocket(cfg) → .build(options?) | .toConfig(options?) ``` Fluent builder. `TPrev` (the type `.step()`'s `request({prev})` sees) is a compile-time-only phantom type threaded through `.step()` calls — ` .parallel()`/`.subPipeline()`/`.stream()`/`.websocket()` do NOT advance it (matches the orchestrator's real `_getPrevData` behavior: `prev` always comes from the last REGULAR `.step()`, never from those other item kinds). `.build()` creates a real `PipelineOrchestrator`; `.toConfig()` returns just the `PipelineConfig` object without instantiating one. ```ts validatePipelineConfig(config: PipelineConfig, context?: string): { valid: boolean; errors: string[] } ``` Static, pre-run checks only (does not execute anything): non-empty `stages`, every stage/group/sub-pipeline/stream/websocket item has a non-empty string `key`, no duplicate keys (including keys nested inside parallel groups, checked in the same flat namespace as top-level keys), recurses into `subPipeline` configs, and shape-checks `request`/ `condition`/`retryCount`/`timeoutMs`/websocket `url`/`onMessage`/`timeoutMs` types. Does not catch everything runtime execution would (e.g. it can't know if `next()` will return a real key) — a config passing validation can still fail at `maxSteps` from a bad DAG loop. --- ## 7. Pagination (`paginate` / `paginateAll` / `flattenPages`) ```ts function paginate(options: PaginationOptions): AsyncGenerator function paginateAll(options: PaginationOptions): Promise function flattenPages(pages: AsyncIterable): AsyncGenerator ``` ```ts type PaginationOptions = | { strategy?: 'cursor'; fetchPage: (cursor: C | undefined, signal?) => Promise<{ items: T[]; nextCursor?: C | null }>; signal?: AbortSignal } | { strategy: 'offset'; fetchPage: (offset: number, limit: number, signal?) => Promise<{ items: T[]; total?: number }>; limit: number; startOffset?: number /* default 0 */; signal?: AbortSignal } ``` Cursor strategy stops when `nextCursor` is `null`/`undefined`. Offset strategy stops when a page is empty, OR shorter than `limit`, OR `offset >= total` (only checked if the API reports `total`) — without `total`, it relies purely on the short-page heuristic, so a backend that always returns exactly `limit` items (even on a genuinely-final, exactly- full last page) would need one more empty-page request to detect the end; in that specific edge case `paginate` issues one extra request to confirm. `flattenPages` is meant to turn a `paginate()` output (or any page stream) into a `StreamStageConfig.stream` source that yields individual items instead of batches. --- ## 8. Framework adapters Both `/vue` and `/react` `export *` from core (section 1) — import everything from whichever one you install, never both. ### 8.1 Vue (`rest-pipeline-js/vue`) All return a plain object (not a tuple): ```ts usePipelineRunVue(orchestrator): { run, running: Ref, result: Ref, error: Ref, stageResults: Ref>, abort, pause, resume, rerunStep, clearStageResults } usePipelineProgressVue(orchestrator): Ref usePipelineStageResultVue(orchestrator, stepKey): Ref usePipelineStepEventVue(orchestrator, stepKey, eventType: 'success'|'error'|'progress'): Ref // last event payload only, null until first fires usePipelineLogsVue(orchestrator): Ref useRerunPipelineStepVue(orchestrator): typeof orchestrator.rerunStep // just orchestrator.rerunStep.bind(orchestrator) useRestClientVue(config: HttpConfig): ComputedRef // via getRestClient() — see 8.3's caching note ``` All subscription-based ones auto-unsubscribe `onUnmounted`. ### 8.2 React (`rest-pipeline-js/react`) **`usePipelineRunReact` returns a TUPLE, not an object** (the one structural difference from its Vue counterpart): ```ts usePipelineRunReact(orchestrator): [run, { running, result, error, stageResults, abort, pause, resume, rerunStep, clearStageResults }] usePipelineProgressReact(orchestrator): PipelineProgress usePipelineStageResultReact(orchestrator, stepKey): PipelineStepResult | null usePipelineStepEventReact(orchestrator, stepKey, eventType): any usePipelineLogsReact(orchestrator): log[] useRerunPipelineStepReact(orchestrator): typeof orchestrator.rerunStep useRestClientReact(config: HttpConfig): RestClient // via createRestClient() DIRECTLY — see 8.3 ``` All effects are keyed on `[orchestrator]` (or `[config]` for the client hook) via `useEffect`/`useCallback`/`useMemo` — pass a stable `orchestrator` reference (create it once, e.g. in a ref/state/module scope), not a fresh `new PipelineOrchestrator(...)` inline on every render. ### 8.3 `useRestClientVue` vs `useRestClientReact` — NOT equivalent - **Vue's `useRestClientVue`** calls `getRestClient(config)` — the module-level cache keyed by a normalized JSON shape of `config` (see `getRestClient` below). Two calls anywhere in the app with structurally-equivalent configs (even different object references) share the SAME underlying client, cache, rate limiter, and circuit breaker state. - **React's `useRestClientReact`** calls `createRestClient(config)` DIRECTLY — bypassing that shared cache entirely — memoized only by `useMemo(..., [config])`, i.e. by object reference. Two components passing structurally-identical but differently-referenced `config` objects get TWO fully independent clients (separate response cache, separate rate limiter counters, separate circuit breaker state) even though Vue's equivalent would have shared one. This was a deliberate fix (an earlier version keyed on `JSON.stringify(config)`, which silently dropped function-valued fields like `auth`/`metrics`/ `onError`/`interceptors`/`adapter` from the comparison — a new inline callback on a later render was never picked up). Memoize the `config` object yourself (module-level constant, or your own `useMemo`) if you want one stable client across renders/components in React. `getRestClient(config)` (core, also used internally by `useRestClientVue`): caches by a JSON-stringified shape of `config` with function-valued fields (`auth`, `cache.store`, `rateLimit.store`, `circuitBreaker.store`/`isFailure`, `metrics`, `interceptors`, `onError`, `adapter`, `tracing.provider`) tracked as booleans (not their identity) — **except `auth`**, which gets a real per-object stable id (a `WeakMap`), so two configs differing only in *which* `AuthProvider` instance they pass don't collide onto the same cached client. Cache capped at 100 entries, oldest evicted on overflow. `clearRestClientCache()` (exported from core) clears it entirely — useful in tests. --- ## 9. Testing (`rest-pipeline-js/testing`) ```ts function createMockAdapter(routes: MockRoute[]): MockAdapter // pass as HttpConfig.adapter ``` ```ts interface MockRoute { method?: string url: string | RegExp // string: substring match via .includes(); RegExp: .test() (stateful lastIndex reset before each test so a reused sticky/global regex doesn't alternate match/no-match) respond: MockResponseSpec | ((info: MockRequestInfo) => MockResponseSpec | Promise<...>) | Array<...> // an array is consumed one entry per matching call; once exhausted, the LAST entry repeats forever } interface MockResponseSpec { data?; status?; statusText?; headers?; delayMs?; error?: boolean } ``` `status >= 400` rejects by default (matching axios/fetch semantics) unless `error` explicitly overrides it either way. A rejection throws a real `Error` with `.status`/`.response.status` set — compatible with `retry.retriableStatus`/`circuitBreaker.isFailure`/error interceptors that check those fields. `adapter.calls` records EVERY request attempt (matched or not, in order) with a `matched: boolean` flag; an unmatched request throws immediately (`"no route matched"`) rather than falling through to some default response. `adapter.reset()` clears call history and each route's array-response position counter, but not the routes themselves. --- ## 10. Consolidated gotcha list Cross-cutting facts most likely to produce subtly wrong generated code if missed — each is explained in full where it first applies above, listed here for a fast pre-flight check: 1. `HttpConfig.retry` has NO effect on a direct `client.get()`/`.post()`/ etc. call — only `RequestExecutor.execute()` (which pipeline stages use internally) implements retry (section 2, 3). 2. `RetryConfig.jitterStrategy: 'full'` can produce a delay MUCH shorter than the nominal backoff (uniformly random between 0 and nominal) — by design, not a bug, for spreading out synchronized retries (3). 3. A `Retry-After` response header always overrides the computed backoff delay, clamped to `maxRetryAfterMs` (default 60000) — not additive, not a fallback, a full override (3). 4. `useRestClientVue` shares clients across structurally-equal configs via a module-level cache; `useRestClientReact` does NOT — it creates a fully independent client (separate cache/rate-limiter/circuit-breaker state) per distinct config OBJECT REFERENCE (8.3). 5. `ParallelStageGroup`/`SubPipelineStage`/stream/WebSocket stages never become the next regular stage's `prev` — only the last `.step()`-style stage does, DAG jumps via `next` included (4.2, 4.3, 6). 6. A WebSocket stage's pass/fail outcome is decided by the `close` event's `wasClean`, not by whether `onError` fired — `onError` alone never fails the step (4.4). 7. `errorHandler`'s `recoverStep(data)` makes a failed step commit as `'success'` — the pipeline continues as if nothing failed, no `continueOnError` needed for that specific step (4.1). 8. `PipelineOptions.pipelineRetry.retryFrom: 'failed-step'` (not the default) keeps already-succeeded stage results and resumes only from the failed index — `'start'` (default) wipes everything and restarts at 0 (5.1). 9. `orchestrator.pause()` takes effect only after the CURRENTLY RUNNING stage finishes — it cannot interrupt an in-flight request (5.2). 10. `HttpConfig.offlineQueue.persistAdapter` is REQUIRED (no in-memory-only mode) — an offline queue that only lived in memory would lose every queued mutation on a reload while offline, defeating its purpose (2.4). 11. `validatePipelineConfig` is a static pre-run check — it cannot catch a bad `next()` DAG transition that only manifests at runtime (that's what `PipelineOptions.maxSteps` guards against instead) (6). 12. `orchestrator.destroy()` (releasing plugin cleanup functions) is NOT called automatically — call it yourself when a long-lived orchestrator is no longer needed (5.5).