diff --git a/CLAUDE.md b/CLAUDE.md index 620d502..0620ad2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -38,11 +38,11 @@ Consumer territories must apply per-call timeouts at instantiation OR rely on th - **Success path** (request/response loops): a throw turns a resolved 200 into a **rejected** promise. - **Error path** (response-error loop): a throw at `:93` propagates *before* the `Promise.reject(error)` at `:95`, **masking the real API error** with the middleware's own throw. -**Design stance — the library stays sync-only and loud.** A library-side try/catch was **rejected by the Commander 2026-05-13** (silent middleware failure is worse than a loud throw) and the rejection **HELD at n=2 on 2026-06-15**. fs-http does not swallow middleware throws and does not await middleware returns — by deliberate disposition, not omission. +**Design stance — the library stays sync-only and loud, and now guards by default (ADR-0037).** A library-side try/catch that *silently* swallowed was **rejected by the Commander 2026-05-13** (silent middleware failure is worse than a loud throw) and the rejection **HELD at n=2 on 2026-06-15**. ADR-0037 (Accepted 2026-07-08, shipped in fs-http **0.6.0**) is **not** a reversal of that value: `guarded()` is *loud swallow* — it surfaces the failure (`console.error` / a service handler) *and* lets the request complete correctly. That third option did not exist in 2026-05-13; a loud throw still corrupts the request outcome, so loud-swallow serves the "be loud, never silent" value strictly better. The interceptor loops remain **sync-only and un-awaited** — async middleware stays out of contract; `guarded()` is sync. -**Consumer contract — the guard lives at the consumer boundary.** Every fs-http consumer **MUST** wrap its own middleware bodies (try/catch + fail-safe swallow) so a side-effect throw — a toast, a store write, a cache-hash parse — cannot reject the request promise or mask an API error. Loud library, defensive consumer. +**Contract — the library guards middleware by default; consumers opt *out*.** `registerRequestMiddleware` / `registerResponseMiddleware` / `registerResponseErrorMiddleware` wrap the supplied body in `guarded()` internally, so a side-effect throw — a toast, a store write, a cache-hash parse — cannot reject the request promise or mask an API error, with the consumer doing nothing. A consumer opts out per call with `register*Middleware(fn, {guard: false})` (raw body, throws propagate — the deliberate escape hatch, no known consumer today). Route the loud signal via `createHttpService(url, {onMiddlewareError})` (unset ⇒ the default loud `console.error`). `guarded()` stays exported for the `{guard: false}` + manual-wrap case and for consumers on older fs-http. -**Precedent + latency.** kendo WR-0078 (PR [#1538](https://github.com/script-development/kendo/pull/1538)) independently re-derived this mechanism against fs-http source and guarded **both** kendo central + tenant middleware (try/catch + fail-safe swallow, 100% coverage, 2026-06-15). See the war-room `deferred.md [adr] fs-packages-fs-http-async-aware-middleware-rejection-doctrine` entry (promoted, n=2). **entreezuil / ublgenie / emmie / BIO carry the latent exposure** until their middleware is likewise guarded. +**Precedent + residue.** The prior consumer-side obligation ("every consumer MUST wrap its own middleware bodies") is **superseded** by the default guard. kendo WR-0078 (PR [#1538](https://github.com/script-development/kendo/pull/1538), 2026-06-15) and the WR-0290 fleet wave hand-wrapped consumer middleware under the old contract; those explicit `guarded()`/try-catch wraps (and fs-form's own `guarded()`) become **redundant-but-harmless double-wraps** (inner catches, outer auto-guard never fires) — strip in a later, unhurried cleanup, not a blocker. See the war-room `deferred.md [adr] fs-packages-fs-http-async-aware-middleware-rejection-doctrine` entry and ADR-0037. ## Packages (12) diff --git a/docs/packages/http.md b/docs/packages/http.md index 2d9ab76..216b43d 100644 --- a/docs/packages/http.md +++ b/docs/packages/http.md @@ -172,56 +172,72 @@ const unregister = http.registerResponseErrorMiddleware((error) => { Other packages hook into these middleware points. `fs-loading` registers request + response + error middleware to track loading state. `fs-dialog` can register error middleware to show error dialogs. You can stack as many middleware handlers as you need — they all run independently. ::: -## Middleware guard (`guarded`) +## Middleware guarding (default-on since 0.6.0 — ADR-0037) -`fs-http` invokes middleware **synchronously and un-caught, by design** — the library stays loud so a bug in a middleware body is never silently eaten inside the transport layer. The consequence: if a middleware body throws (a toast that blows up, a store write, a router push, a `JSON.parse` of a cache hash), that throw escapes into the interceptor chain. On the response path it **rejects a resolved 200**; on the error path it **replaces the original `AxiosError` with the middleware's throw**, masking the real API failure. +`fs-http` invokes middleware **synchronously and un-awaited, by design** — the interceptor loops are never `await`ed, so async middleware is out of contract. The hazard: if a middleware body throws (a toast that blows up, a store write, a router push, a `JSON.parse` of a cache hash), that throw would escape into the interceptor chain — on the response path **rejecting a resolved 200**, on the error path **replacing the original `AxiosError` with the middleware's throw** and masking the real API failure. -`guarded()` is the **consumer-side, opt-in** defense. Wrap a middleware body at its registration site and a throw from the body is caught, reported, and swallowed — the interceptor chain is never corrupted. Loud library, defensive consumer. +**Since 0.6.0, `fs-http` guards every registered middleware body by default.** `register*Middleware` wraps the supplied body in `guarded()` internally, so a side-effect throw is caught, reported loudly, and swallowed — the interceptor chain is never corrupted — **without you doing anything**. The library stays **loud** (it surfaces the failure, never silently eats it) and **sync-only**. ```typescript -import {createHttpService, guarded} from '@script-development/fs-http'; +import {createHttpService} from '@script-development/fs-http'; const http = createHttpService(`${location.origin}/api`); -// A throwing response body would otherwise reject a resolved 200 — guarded() -// contains it so the successful response still resolves. -http.registerResponseMiddleware( - guarded((response) => { - showToast(`Loaded ${response.config.url}`); // may throw — no longer fatal - }), -); +// Guarded automatically: a throw here no longer rejects the resolved 200. +http.registerResponseMiddleware((response) => { + showToast(`Loaded ${response.config.url}`); // may throw — contained +}); -// A throwing error body would otherwise mask the real AxiosError — guarded() -// contains it so the caller still rejects with the original error. -http.registerResponseErrorMiddleware( - guarded((error) => { - openErrorDialog(error); // may throw — the 500 still surfaces to the caller - }), -); +// Guarded automatically: a throw here no longer masks the real AxiosError. +http.registerResponseErrorMiddleware((error) => { + openErrorDialog(error); // may throw — the 500 still surfaces to the caller +}); ``` -All three middleware types share the `(arg) => void` shape, so one generic wraps any of them and stays assignable to the source type with **zero casts** — `guarded(reqBody)`, `guarded(resBody)`, and `guarded(errBody)` each infer their argument type from the body you pass. +### Routing the loud signal (`onMiddlewareError`) -### Custom error handling - -By default a swallowed throw is logged loudly via `console.error` (visible to any error tracker that hooks `console`). It is never re-thrown — re-throwing would re-open the exact failure `guarded()` closes. Pass a custom `GuardedMiddlewareErrorHandler` to route the failure elsewhere: +By default a swallowed throw is logged loudly via `console.error` (visible to any error tracker that hooks `console`). Pass a service-level `onMiddlewareError` handler to route every auto-guarded middleware failure on that service elsewhere. It receives the thrown value and **must not re-throw** — re-throwing re-opens the exact failure the guard closes. ```typescript -import {guarded, type GuardedMiddlewareErrorHandler} from '@script-development/fs-http'; +import {createHttpService, type GuardedMiddlewareErrorHandler} from '@script-development/fs-http'; const reportToTracker: GuardedMiddlewareErrorHandler = (error) => { errorTracker.capture(error); // must not re-throw }; -http.registerResponseMiddleware( - guarded((response) => { - analytics.record(response.status); - }, reportToTracker), +const http = createHttpService(`${location.origin}/api`, {onMiddlewareError: reportToTracker}); + +// Any throw from this body is routed to reportToTracker, not console.error. +http.registerResponseMiddleware((response) => { + analytics.record(response.status); +}); +``` + +### Opting out (`{guard: false}`) + +For the rare body that genuinely wants a throw to propagate, register it unguarded. No such consumer exists today; the option is insurance against a one-way door, not a feature with a known use. + +```typescript +http.registerRequestMiddleware( + (config) => { + assertInvariant(config); // a throw here WILL reject the request + }, + {guard: false}, ); ``` -::: tip Why opt-in, not library-side -Wrapping the interceptor loops in try/catch inside `fs-http` was rejected (2026-05-13): it would swallow every consumer's middleware bug silently, at the library layer, with no way to opt back into loud behaviour. `guarded()` inverts that — the library stays loud, and each consumer decides, explicitly at each registration site, which bodies to protect. +### `guarded()` (manual wrap) + +`guarded()` remains a public export for the `{guard: false}` case, for manual composition, and for consumers still on older fs-http where guarding was opt-in. All three middleware types share the `(arg) => void` shape, so one generic wraps any of them and stays assignable with **zero casts** — `guarded(reqBody)`, `guarded(resBody)`, `guarded(errBody)`. Note that wrapping a body you then register through the default path double-wraps it (inner catches, outer auto-guard never fires) — harmless, but redundant. + +```typescript +import {guarded} from '@script-development/fs-http'; + +const wrapped = guarded((response) => analytics.record(response.status), reportToTracker); +``` + +::: tip Why default-on, not opt-in +The 2026-05-13 ruling rejected a _silent_ library-side try/catch (silent middleware failure is worse than a loud throw). `guarded()` is a third option that did not exist then — **loud swallow**: it surfaces the failure _and_ lets the request complete correctly. Making it the default (ADR-0037) serves the "be loud, never silent" value strictly better than a loud throw, which still corrupts the request outcome. The fleet-wide WR-0290 wave proved an opt-in consumer obligation is unmet by default — so the guard moved to the substrate. ::: ## File Operations @@ -281,14 +297,15 @@ try { ### `createHttpService(baseURL, options?)` -| Parameter | Type | Description | -| -------------------------- | ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `baseURL` | `string` | Base URL for all requests. **Must be absolute** (e.g. `${location.origin}/api`); relative paths fail fast. | -| `options.timeout` | `number \| undefined` | Request timeout in milliseconds (default: `30000`; pass `0` to disable) | -| `options.headers` | `Record` | Default headers | -| `options.withCredentials` | `boolean` | Send cookies cross-origin (default: `true`) | -| `options.withXSRFToken` | `boolean` | Forward `XSRF-TOKEN` cookie as `X-XSRF-TOKEN` header (default: `false`). Set `true` for Laravel Sanctum SPA; leave `false` for stateless / token / non-Sanctum stacks. See [Authentication & XSRF](#authentication-xsrf). | -| `options.smartCredentials` | `boolean` | Auto-toggle credentials by origin (default: `false`) | +| Parameter | Type | Description | +| --------------------------- | ------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `baseURL` | `string` | Base URL for all requests. **Must be absolute** (e.g. `${location.origin}/api`); relative paths fail fast. | +| `options.timeout` | `number \| undefined` | Request timeout in milliseconds (default: `30000`; pass `0` to disable) | +| `options.headers` | `Record` | Default headers | +| `options.withCredentials` | `boolean` | Send cookies cross-origin (default: `true`) | +| `options.withXSRFToken` | `boolean` | Forward `XSRF-TOKEN` cookie as `X-XSRF-TOKEN` header (default: `false`). Set `true` for Laravel Sanctum SPA; leave `false` for stateless / token / non-Sanctum stacks. See [Authentication & XSRF](#authentication-xsrf). | +| `options.smartCredentials` | `boolean` | Auto-toggle credentials by origin (default: `false`) | +| `options.onMiddlewareError` | `GuardedMiddlewareErrorHandler` | Handler for a throw from any auto-guarded middleware on this service (default: a loud `console.error`). Route to an error tracker. Must not re-throw. See [Middleware guarding](#middleware-guarding-default-on-since-0-6-0-adr-0037). | ### Constants @@ -310,15 +327,17 @@ try { ### Middleware Registration -| Method | Returns | -| ------------------------------------- | ---------------------- | -| `registerRequestMiddleware(fn)` | `UnregisterMiddleware` | -| `registerResponseMiddleware(fn)` | `UnregisterMiddleware` | -| `registerResponseErrorMiddleware(fn)` | `UnregisterMiddleware` | +| Method | Returns | Notes | +| -------------------------------------------- | ---------------------- | ------------------------------------------------------------------------------------------ | +| `registerRequestMiddleware(fn, opts?)` | `UnregisterMiddleware` | Auto-guarded by default; pass `{guard: false}` to register the raw body (throws propagate) | +| `registerResponseMiddleware(fn, opts?)` | `UnregisterMiddleware` | Auto-guarded by default; pass `{guard: false}` to register the raw body (throws propagate) | +| `registerResponseErrorMiddleware(fn, opts?)` | `UnregisterMiddleware` | Auto-guarded by default; pass `{guard: false}` to register the raw body (throws propagate) | + +`opts` type: `RegisterMiddlewareOptions` = `{ guard?: boolean }` (default `guard: true`). ### Middleware Guard -| Export | Type | Description | -| ------------------------------- | --------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `guarded(fn, onError?)` | `(fn: (arg: T) => void, onError?) => (arg: T) => void` | Wraps a middleware body so a throw is caught, reported, and swallowed instead of corrupting the interceptor chain. See [Middleware guard](#middleware-guard-guarded). | -| `GuardedMiddlewareErrorHandler` | `(error: unknown) => void` | Handler type for `guarded`'s optional second argument; defaults to a loud `console.error`. Must not re-throw. | +| Export | Type | Description | +| ------------------------------- | --------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `guarded(fn, onError?)` | `(fn: (arg: T) => void, onError?) => (arg: T) => void` | Wraps a middleware body so a throw is caught, reported, and swallowed instead of corrupting the interceptor chain. Applied automatically by `register*Middleware` since 0.6.0; exported for the `{guard: false}` + manual-wrap case. See [Middleware guarding](#middleware-guarding-default-on-since-0-6-0-adr-0037). | +| `GuardedMiddlewareErrorHandler` | `(error: unknown) => void` | Handler type for `guarded`'s optional second argument and `createHttpService`'s `onMiddlewareError` option; defaults to a loud `console.error`. Must not re-throw. | diff --git a/package-lock.json b/package-lock.json index 1a0919d..9c31272 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10375,11 +10375,11 @@ }, "packages/adapter-store": { "name": "@script-development/fs-adapter-store", - "version": "0.3.1", + "version": "0.3.2", "license": "MIT", "devDependencies": { "@script-development/fs-helpers": "^0.1.0", - "@script-development/fs-http": "^0.1.0 || ^0.2.0 || ^0.3.0 || ^0.4.0 || ^0.5.0", + "@script-development/fs-http": "^0.1.0 || ^0.2.0 || ^0.3.0 || ^0.4.0 || ^0.5.0 || ^0.6.0", "@script-development/fs-loading": "^0.1.0", "@script-development/fs-storage": "^0.1.0", "happy-dom": "^20.10.3", @@ -10390,7 +10390,7 @@ }, "peerDependencies": { "@script-development/fs-helpers": "^0.1.0", - "@script-development/fs-http": "^0.1.0 || ^0.2.0 || ^0.3.0 || ^0.4.0 || ^0.5.0", + "@script-development/fs-http": "^0.1.0 || ^0.2.0 || ^0.3.0 || ^0.4.0 || ^0.5.0 || ^0.6.0", "@script-development/fs-loading": "^0.1.0", "@script-development/fs-storage": "^0.1.0", "vue": "^3.5.39" @@ -10398,11 +10398,11 @@ }, "packages/cached-adapter-store": { "name": "@script-development/fs-cached-adapter-store", - "version": "0.2.4", + "version": "0.2.5", "license": "MIT", "devDependencies": { "@script-development/fs-adapter-store": "^0.1.0 || ^0.2.0 || ^0.3.0", - "@script-development/fs-http": "^0.1.0 || ^0.2.0 || ^0.3.0 || ^0.4.0 || ^0.5.0", + "@script-development/fs-http": "^0.1.0 || ^0.2.0 || ^0.3.0 || ^0.4.0 || ^0.5.0 || ^0.6.0", "@script-development/fs-storage": "^0.1.0", "axios": "^1.18.1", "happy-dom": "^20.10.3", @@ -10413,7 +10413,7 @@ }, "peerDependencies": { "@script-development/fs-adapter-store": "^0.1.0 || ^0.2.0 || ^0.3.0", - "@script-development/fs-http": "^0.1.0 || ^0.2.0 || ^0.3.0 || ^0.4.0 || ^0.5.0", + "@script-development/fs-http": "^0.1.0 || ^0.2.0 || ^0.3.0 || ^0.4.0 || ^0.5.0 || ^0.6.0", "@script-development/fs-storage": "^0.1.0", "vue": "^3.5.39" } @@ -10439,10 +10439,10 @@ }, "packages/form": { "name": "@script-development/fs-form", - "version": "0.1.0", + "version": "0.1.1", "license": "MIT", "devDependencies": { - "@script-development/fs-http": "^0.5.0", + "@script-development/fs-http": "^0.5.0 || ^0.6.0", "@vue/test-utils": "^2.4.11", "axios": "^1.18.1", "happy-dom": "^20.10.3", @@ -10452,7 +10452,7 @@ "node": ">=24.0.0" }, "peerDependencies": { - "@script-development/fs-http": "^0.5.0", + "@script-development/fs-http": "^0.5.0 || ^0.6.0", "vue": "^3.5.39" } }, @@ -10472,7 +10472,7 @@ }, "packages/http": { "name": "@script-development/fs-http", - "version": "0.5.0", + "version": "0.6.0", "license": "MIT", "dependencies": { "axios": "^1.18.1" @@ -10486,10 +10486,10 @@ }, "packages/loading": { "name": "@script-development/fs-loading", - "version": "0.1.5", + "version": "0.1.6", "license": "MIT", "devDependencies": { - "@script-development/fs-http": "^0.1.0 || ^0.2.0 || ^0.3.0 || ^0.4.0 || ^0.5.0", + "@script-development/fs-http": "^0.1.0 || ^0.2.0 || ^0.3.0 || ^0.4.0 || ^0.5.0 || ^0.6.0", "@vue/test-utils": "^2.4.11", "axios": "^1.18.1", "happy-dom": "^20.10.3", @@ -10499,7 +10499,7 @@ "node": ">=24.0.0" }, "peerDependencies": { - "@script-development/fs-http": "^0.1.0 || ^0.2.0 || ^0.3.0 || ^0.4.0 || ^0.5.0", + "@script-development/fs-http": "^0.1.0 || ^0.2.0 || ^0.3.0 || ^0.4.0 || ^0.5.0 || ^0.6.0", "vue": "^3.5.39" } }, diff --git a/packages/adapter-store/package.json b/packages/adapter-store/package.json index 6e44e09..c01125f 100644 --- a/packages/adapter-store/package.json +++ b/packages/adapter-store/package.json @@ -1,6 +1,6 @@ { "name": "@script-development/fs-adapter-store", - "version": "0.3.1", + "version": "0.3.2", "description": "Reactive adapter-store pattern with domain state management and CRUD resource adapters", "homepage": "https://packages.script.nl/packages/adapter-store", "license": "MIT", @@ -43,7 +43,7 @@ }, "devDependencies": { "@script-development/fs-helpers": "^0.1.0", - "@script-development/fs-http": "^0.1.0 || ^0.2.0 || ^0.3.0 || ^0.4.0 || ^0.5.0", + "@script-development/fs-http": "^0.1.0 || ^0.2.0 || ^0.3.0 || ^0.4.0 || ^0.5.0 || ^0.6.0", "@script-development/fs-loading": "^0.1.0", "@script-development/fs-storage": "^0.1.0", "happy-dom": "^20.10.3", @@ -51,7 +51,7 @@ }, "peerDependencies": { "@script-development/fs-helpers": "^0.1.0", - "@script-development/fs-http": "^0.1.0 || ^0.2.0 || ^0.3.0 || ^0.4.0 || ^0.5.0", + "@script-development/fs-http": "^0.1.0 || ^0.2.0 || ^0.3.0 || ^0.4.0 || ^0.5.0 || ^0.6.0", "@script-development/fs-loading": "^0.1.0", "@script-development/fs-storage": "^0.1.0", "vue": "^3.5.39" diff --git a/packages/cached-adapter-store/package.json b/packages/cached-adapter-store/package.json index f463f41..18e4310 100644 --- a/packages/cached-adapter-store/package.json +++ b/packages/cached-adapter-store/package.json @@ -1,6 +1,6 @@ { "name": "@script-development/fs-cached-adapter-store", - "version": "0.2.4", + "version": "0.2.5", "description": "Higher-order factory wrapping @script-development/fs-adapter-store with hash-bumping cache-check that suppresses redundant retrieveAll GETs at source", "homepage": "https://packages.script.nl/packages/cached-adapter-store", "license": "MIT", @@ -43,7 +43,7 @@ }, "devDependencies": { "@script-development/fs-adapter-store": "^0.1.0 || ^0.2.0 || ^0.3.0", - "@script-development/fs-http": "^0.1.0 || ^0.2.0 || ^0.3.0 || ^0.4.0 || ^0.5.0", + "@script-development/fs-http": "^0.1.0 || ^0.2.0 || ^0.3.0 || ^0.4.0 || ^0.5.0 || ^0.6.0", "@script-development/fs-storage": "^0.1.0", "axios": "^1.18.1", "happy-dom": "^20.10.3", @@ -51,7 +51,7 @@ }, "peerDependencies": { "@script-development/fs-adapter-store": "^0.1.0 || ^0.2.0 || ^0.3.0", - "@script-development/fs-http": "^0.1.0 || ^0.2.0 || ^0.3.0 || ^0.4.0 || ^0.5.0", + "@script-development/fs-http": "^0.1.0 || ^0.2.0 || ^0.3.0 || ^0.4.0 || ^0.5.0 || ^0.6.0", "@script-development/fs-storage": "^0.1.0", "vue": "^3.5.39" }, diff --git a/packages/form/package.json b/packages/form/package.json index 67f0aa5..2bd3168 100644 --- a/packages/form/package.json +++ b/packages/form/package.json @@ -1,6 +1,6 @@ { "name": "@script-development/fs-form", - "version": "0.1.0", + "version": "0.1.1", "description": "Reactive form-submit helpers: double-submit guard plus 422 validation-error binding for fs-http", "homepage": "https://packages.script.nl/packages/form", "license": "MIT", @@ -42,14 +42,14 @@ "typecheck": "tsc --noEmit" }, "devDependencies": { - "@script-development/fs-http": "^0.5.0", + "@script-development/fs-http": "^0.5.0 || ^0.6.0", "@vue/test-utils": "^2.4.11", "axios": "^1.18.1", "happy-dom": "^20.10.3", "vue": "^3.5.39" }, "peerDependencies": { - "@script-development/fs-http": "^0.5.0", + "@script-development/fs-http": "^0.5.0 || ^0.6.0", "vue": "^3.5.39" }, "engines": { diff --git a/packages/http/CHANGELOG.md b/packages/http/CHANGELOG.md index c18635a..b8a7e9b 100644 --- a/packages/http/CHANGELOG.md +++ b/packages/http/CHANGELOG.md @@ -1,5 +1,16 @@ # @script-development/fs-http +## 0.6.0 — 2026-07-09 + +### Minor Changes + +- **Middleware is now guarded by default (ADR-0037).** `registerRequestMiddleware`, `registerResponseMiddleware`, and `registerResponseErrorMiddleware` wrap the supplied body in `guarded()` internally, so a side-effect throw (toast, store write, router push, cache-hash parse) can no longer reject a resolved 200 nor mask the real API error on the error path — without the consumer doing anything. This supersedes the consumer-side obligation to hand-wrap every middleware body (war-room Architectural Principle #8, "every consumer MUST wrap its own middleware bodies"). The library stays **loud** (surfaces via `console.error` or a service-level handler, never silent) and **sync-only** (the interceptor loops are unchanged and un-awaited; async middleware remains out of contract). + - **Per-call opt-out:** `register*Middleware(fn, { guard: false })` registers the raw body unguarded (throws propagate) — the deliberate escape hatch. No known consumer today; kept as insurance against a one-way door. + - **Service-level error routing:** `createHttpService(baseURL, { onMiddlewareError })` sets the handler passed to the internal `guarded()` for every middleware on that service, so a consumer can route the swallowed failure to an error tracker instead of `console.error`. Unset ⇒ the default loud `console.error` stands. + - `guarded()` remains a public export for the `{ guard: false }` + manual-wrap case and for consumers on older fs-http. + - New exported type: `RegisterMiddlewareOptions`. + - Existing explicit `guarded()` wraps at consumers become redundant-but-harmless double-wraps (inner catches, outer never fires) — strip in a later, unhurried cleanup. + ## 0.5.0 — 2026-07-02 ### Minor Changes diff --git a/packages/http/README.md b/packages/http/README.md index c7ea4df..1d0c644 100644 --- a/packages/http/README.md +++ b/packages/http/README.md @@ -41,6 +41,7 @@ Creates a new HTTP service instance. - `withXSRFToken` — Include XSRF token header (default: `false`) - `smartCredentials` — Auto-toggle `withCredentials` based on request host matching base URL host (default: `false`) - `timeout` — Request timeout in milliseconds (default: `30000`). Pass `0` to disable; pass any positive number to override. +- `onMiddlewareError` — Handler (`GuardedMiddlewareErrorHandler`) for a throw from any auto-guarded middleware on this service (default: a loud `console.error`). Must not re-throw. ### Timeout @@ -62,9 +63,12 @@ For Laravel Sanctum SPA consumers, `withXSRFToken: true` is required to avoid HT ### Middleware -- `registerRequestMiddleware(fn)` — Returns unregister function -- `registerResponseMiddleware(fn)` — Returns unregister function -- `registerResponseErrorMiddleware(fn)` — Returns unregister function +Every registered middleware body is wrapped in `guarded()` **by default** (ADR-0037, since 0.6.0) so a side-effect throw cannot reject a resolved 200 nor mask the real API error. Pass `{guard: false}` as the second argument to register the raw body unguarded (throws propagate). Route the loud signal via `createHttpService(url, {onMiddlewareError})`. + +- `registerRequestMiddleware(fn, opts?)` — Returns unregister function +- `registerResponseMiddleware(fn, opts?)` — Returns unregister function +- `registerResponseErrorMiddleware(fn, opts?)` — Returns unregister function +- `guarded(fn, onError?)` — Manual middleware-body guard; still exported for the `{guard: false}` + manual-wrap case ### Utilities diff --git a/packages/http/package.json b/packages/http/package.json index 991288a..0b77409 100644 --- a/packages/http/package.json +++ b/packages/http/package.json @@ -1,6 +1,6 @@ { "name": "@script-development/fs-http", - "version": "0.5.0", + "version": "0.6.0", "description": "Framework-agnostic HTTP service factory with middleware architecture", "homepage": "https://packages.script.nl/packages/http", "license": "MIT", diff --git a/packages/http/src/guarded.ts b/packages/http/src/guarded.ts index 7641711..e351985 100644 --- a/packages/http/src/guarded.ts +++ b/packages/http/src/guarded.ts @@ -20,9 +20,12 @@ const defaultOnError: GuardedMiddlewareErrorHandler = (error) => { * cannot reject a resolved 200 nor mask the real API error on the error path. * * `fs-http` invokes middleware synchronously and un-caught **by design** (the - * library stays loud; the 2026-05-13 rejection of library-side try/catch holds). - * `guarded` is the **consumer-side, opt-in** defense: apply it at the - * registration site. Loud library, defensive consumer. + * library stays loud; the loops are never awaited). Since ADR-0037 the + * `register*Middleware` functions apply `guarded` **by default** — every + * registered body is loud-swallow-protected without the consumer doing + * anything, and a consumer opts *out* per call with `{guard: false}`. This + * export remains public for that opt-out escape hatch, for manual wrapping, and + * for consumers on older fs-http where guarding was opt-in. * * All three middleware types (`RequestMiddlewareFunc`, `ResponseMiddlewareFunc`, * `ResponseErrorMiddlewareFunc`) share the `(arg) => void` shape, so this one diff --git a/packages/http/src/http.ts b/packages/http/src/http.ts index 1865e2e..367edbc 100644 --- a/packages/http/src/http.ts +++ b/packages/http/src/http.ts @@ -5,6 +5,7 @@ import axios from 'axios'; import type { HttpService, HttpServiceOptions, + RegisterMiddlewareOptions, RequestMiddlewareFunc, ResponseErrorMiddlewareFunc, ResponseMiddlewareFunc, @@ -12,6 +13,7 @@ import type { AxiosResponseError, } from './types'; +import {guarded} from './guarded'; import {isAxiosError} from './utils'; /** @@ -51,6 +53,11 @@ const parseBaseURL = (baseURL: string): URL => { export const createHttpService = (baseURL: string, options?: HttpServiceOptions): HttpService => { const apiUrl = parseBaseURL(baseURL); + // Service-level handler for auto-guarded middleware throws (ADR-0037). + // Passed to guarded() for every registered middleware; undefined ⇒ guarded()'s + // default loud console.error. + const onMiddlewareError = options?.onMiddlewareError; + const http = axios.create({ baseURL: apiUrl.toString(), withCredentials: options?.withCredentials ?? true, @@ -123,23 +130,40 @@ export const createHttpService = (baseURL: string, options?: HttpServiceOptions) const previewRequest = (endpoint: string, options?: AxiosRequestConfig) => http.get(endpoint, {...options, responseType: 'blob'}); - // Middleware registration - const registerRequestMiddleware = (fn: RequestMiddlewareFunc): UnregisterMiddleware => { - requestMiddleware.push(fn); - - return unregister(requestMiddleware, fn); + // Middleware registration. Bodies are wrapped in guarded() by default + // (ADR-0037) so a side-effect throw cannot reject a resolved 200 nor mask + // the real API error. Pass `{guard: false}` to register the raw body + // unguarded (throws propagate — the deliberate escape hatch). The stored + // (possibly wrapped) function is what's pushed AND what unregister removes, + // so reference identity holds. + const registerRequestMiddleware = ( + fn: RequestMiddlewareFunc, + opts?: RegisterMiddlewareOptions, + ): UnregisterMiddleware => { + const middleware = opts?.guard === false ? fn : guarded(fn, onMiddlewareError); + requestMiddleware.push(middleware); + + return unregister(requestMiddleware, middleware); }; - const registerResponseMiddleware = (fn: ResponseMiddlewareFunc): UnregisterMiddleware => { - responseMiddleware.push(fn); + const registerResponseMiddleware = ( + fn: ResponseMiddlewareFunc, + opts?: RegisterMiddlewareOptions, + ): UnregisterMiddleware => { + const middleware = opts?.guard === false ? fn : guarded(fn, onMiddlewareError); + responseMiddleware.push(middleware); - return unregister(responseMiddleware, fn); + return unregister(responseMiddleware, middleware); }; - const registerResponseErrorMiddleware = (fn: ResponseErrorMiddlewareFunc): UnregisterMiddleware => { - responseErrorMiddleware.push(fn); + const registerResponseErrorMiddleware = ( + fn: ResponseErrorMiddlewareFunc, + opts?: RegisterMiddlewareOptions, + ): UnregisterMiddleware => { + const middleware = opts?.guard === false ? fn : guarded(fn, onMiddlewareError); + responseErrorMiddleware.push(middleware); - return unregister(responseErrorMiddleware, fn); + return unregister(responseErrorMiddleware, middleware); }; return { diff --git a/packages/http/src/index.ts b/packages/http/src/index.ts index 1deb795..003cd99 100644 --- a/packages/http/src/index.ts +++ b/packages/http/src/index.ts @@ -4,6 +4,7 @@ export type {GuardedMiddlewareErrorHandler} from './guarded'; export type { HttpService, HttpServiceOptions, + RegisterMiddlewareOptions, RequestMiddlewareFunc, ResponseMiddlewareFunc, ResponseErrorMiddlewareFunc, diff --git a/packages/http/src/types.ts b/packages/http/src/types.ts index b8217f3..358bccd 100644 --- a/packages/http/src/types.ts +++ b/packages/http/src/types.ts @@ -1,5 +1,7 @@ import type {AxiosError, AxiosRequestConfig, AxiosResponse, InternalAxiosRequestConfig} from 'axios'; +import type {GuardedMiddlewareErrorHandler} from './guarded'; + export type AxiosResponseError = Record; export type RequestMiddlewareFunc = (request: InternalAxiosRequestConfig) => void; @@ -8,6 +10,16 @@ export type ResponseErrorMiddlewareFunc = (error: AxiosError export type UnregisterMiddleware = () => void; +/** + * Options for a `register*Middleware` call (ADR-0037). + * + * `guard` (default `true`): wrap the middleware body in `guarded()` so a + * side-effect throw cannot corrupt the interceptor chain. Pass `{guard: false}` + * to register the raw body unguarded — the deliberate escape hatch for a case + * that genuinely wants a throw to propagate. No such case exists today. + */ +export type RegisterMiddlewareOptions = {guard?: boolean}; + export type HttpServiceOptions = { headers?: Record; withCredentials?: boolean; @@ -20,6 +32,15 @@ export type HttpServiceOptions = { * parameter on each method. */ timeout?: number; + /** + * Handler invoked when an auto-guarded middleware body throws (ADR-0037). + * Becomes the `onError` passed to `guarded()` for every middleware + * registered on this service (unless the middleware opts out with + * `{guard: false}`). Unset ⇒ `guarded()`'s default loud `console.error`. + * Route it to an error tracker (Sentry, kendo-error-tracker) to surface the + * swallowed failure elsewhere. Must not re-throw. + */ + onMiddlewareError?: GuardedMiddlewareErrorHandler; }; export type HttpService = { @@ -56,7 +77,10 @@ export type HttpService = { * to render and `URL.revokeObjectURL(...)` on cleanup. */ previewRequest: (endpoint: string, options?: AxiosRequestConfig) => Promise>; - registerRequestMiddleware: (fn: RequestMiddlewareFunc) => UnregisterMiddleware; - registerResponseMiddleware: (fn: ResponseMiddlewareFunc) => UnregisterMiddleware; - registerResponseErrorMiddleware: (fn: ResponseErrorMiddlewareFunc) => UnregisterMiddleware; + registerRequestMiddleware: (fn: RequestMiddlewareFunc, opts?: RegisterMiddlewareOptions) => UnregisterMiddleware; + registerResponseMiddleware: (fn: ResponseMiddlewareFunc, opts?: RegisterMiddlewareOptions) => UnregisterMiddleware; + registerResponseErrorMiddleware: ( + fn: ResponseErrorMiddlewareFunc, + opts?: RegisterMiddlewareOptions, + ) => UnregisterMiddleware; }; diff --git a/packages/http/tests/guarded.spec.ts b/packages/http/tests/guarded.spec.ts index 0fae6cb..203f1c8 100644 --- a/packages/http/tests/guarded.spec.ts +++ b/packages/http/tests/guarded.spec.ts @@ -220,13 +220,19 @@ describe('guarded', () => { mock.restore(); }); - it('CONTRAST: an UN-guarded throwing response body rejects the resolved 200', async () => { - // Arrange — demonstrates the exposure guarded() closes. + it('CONTRAST: an UN-guarded ({guard: false}) throwing response body rejects the resolved 200', async () => { + // Arrange — demonstrates the exposure guarded() closes. Since ADR-0037 + // (fs-http 0.6.0) register* auto-guards by default, so the exposure is now + // only reachable via the deliberate {guard: false} opt-out — which is + // exactly what this CONTRAST must exercise to stay meaningful. mock.onGet(/.*/).reply(200, {ok: true}); const service = createHttpService(BASE_URL); - service.registerResponseMiddleware(() => { - throw new Error('toast blew up'); - }); + service.registerResponseMiddleware( + () => { + throw new Error('toast blew up'); + }, + {guard: false}, + ); // Act & Assert — the successful 200 is turned into a rejection await expect(service.getRequest('/ok')).rejects.toThrow('toast blew up'); diff --git a/packages/http/tests/http.spec.ts b/packages/http/tests/http.spec.ts index 667044a..312a1f8 100644 --- a/packages/http/tests/http.spec.ts +++ b/packages/http/tests/http.spec.ts @@ -547,6 +547,168 @@ describe('createHttpService', () => { expect(response.config.responseType).toBe('blob'); }); }); + + describe('middleware guarding by default (ADR-0037)', () => { + // fs-http auto-wraps every registered middleware body in guarded() so a + // side-effect throw cannot corrupt the interceptor chain. Consumers opt + // OUT per-call with {guard: false}. The library stays loud (console.error + // by default, or a service-level onMiddlewareError handler) — never silent. + + let consoleErrorSpy: ReturnType; + + beforeEach(() => { + consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(() => { + consoleErrorSpy.mockRestore(); + }); + + it('default-guards a throwing request middleware — the resolved request is NOT rejected', async () => { + // Arrange + mock.onGet(/.*/).reply(200, {ok: true}); + const service = createHttpService(BASE_URL); + const boom = new Error('request middleware boom'); + service.registerRequestMiddleware(() => { + throw boom; + }); + + // Act & Assert — a throwing request body would otherwise reject a + // resolved 200; guarded() default keeps the request whole. + const response = await service.getRequest('/test'); + expect(response.status).toBe(200); + // ...and the throw is surfaced loudly via the default console.error handler. + expect(consoleErrorSpy).toHaveBeenCalledWith(expect.stringContaining('[fs-http]'), boom); + }); + + it('default-guards a throwing response middleware — the resolved 200 is NOT rejected', async () => { + // Arrange + mock.onGet(/.*/).reply(200, {ok: true}); + const service = createHttpService(BASE_URL); + const boom = new Error('response middleware boom'); + service.registerResponseMiddleware(() => { + throw boom; + }); + + // Act & Assert + const response = await service.getRequest('/test'); + expect(response.status).toBe(200); + expect(consoleErrorSpy).toHaveBeenCalledWith(expect.stringContaining('[fs-http]'), boom); + }); + + it('default-guards a throwing response-error middleware — the REAL API error is preserved, not masked', async () => { + // Arrange — an unguarded throw here propagates before Promise.reject(error) + // and masks the real AxiosError. guarded() default swallows it so the + // original 500 surfaces to the caller. + mock.onGet(/.*/).reply(500, {error: 'server'}); + const service = createHttpService(BASE_URL); + service.registerResponseErrorMiddleware(() => { + throw new Error('error middleware boom'); + }); + + // Act & Assert — rejection is the original API error, not the middleware throw. + await expect(service.getRequest('/fail')).rejects.toMatchObject({response: {status: 500}}); + }); + + it('routes an auto-guarded throw to a service-level onMiddlewareError instead of console.error', async () => { + // Arrange + mock.onGet(/.*/).reply(200, {ok: true}); + const onMiddlewareError = vi.fn(); + const service = createHttpService(BASE_URL, {onMiddlewareError}); + const boom = new Error('routed boom'); + service.registerResponseMiddleware(() => { + throw boom; + }); + + // Act + const response = await service.getRequest('/test'); + + // Assert — custom handler receives the thrown value; default console.error does NOT fire. + expect(response.status).toBe(200); + expect(onMiddlewareError).toHaveBeenCalledTimes(1); + expect(onMiddlewareError).toHaveBeenCalledWith(boom); + expect(consoleErrorSpy).not.toHaveBeenCalled(); + }); + + it('{guard: false} on a request middleware lets the throw propagate (opt-out escape hatch)', async () => { + // Arrange + mock.onGet(/.*/).reply(200, {ok: true}); + const service = createHttpService(BASE_URL); + service.registerRequestMiddleware( + () => { + throw new Error('unguarded request boom'); + }, + {guard: false}, + ); + + // Act & Assert — unguarded body throws into the interceptor, rejecting the request. + await expect(service.getRequest('/test')).rejects.toThrow('unguarded request boom'); + }); + + it('{guard: false} on a response-error middleware masks the API error (unguarded propagation proven)', async () => { + // Arrange + mock.onGet(/.*/).reply(500, {error: 'server'}); + const service = createHttpService(BASE_URL); + service.registerResponseErrorMiddleware( + () => { + throw new Error('unguarded error boom'); + }, + {guard: false}, + ); + + // Act & Assert — the middleware throw replaces the original AxiosError. + await expect(service.getRequest('/fail')).rejects.toThrow('unguarded error boom'); + }); + + it('{guard: true} (explicit) still guards — a throwing body does NOT reject (kills the boolean-literal mutant)', async () => { + // Arrange — pins that only `=== false` opts out; an explicit `true` (and by + // extension the undefined default) takes the guarded path. + mock.onGet(/.*/).reply(200, {ok: true}); + const service = createHttpService(BASE_URL); + service.registerResponseMiddleware( + () => { + throw new Error('explicit-true boom'); + }, + {guard: true}, + ); + + // Act & Assert + const response = await service.getRequest('/test'); + expect(response.status).toBe(200); + }); + + it('unregister removes the auto-guarded wrapper (reference identity holds through the guard)', async () => { + // Arrange — the wrapper stored in the array is what unregister must splice. + mock.onGet(/.*/).reply(200, {ok: true}); + const service = createHttpService(BASE_URL); + const middlewareFn = vi.fn(); + const unregister = service.registerResponseMiddleware(middlewareFn); + + // Act + await service.getRequest('/first'); + unregister(); + await service.getRequest('/second'); + + // Assert — called once (guarded wrapper removed on unregister). + expect(middlewareFn).toHaveBeenCalledTimes(1); + }); + + it('unregister removes an unguarded ({guard: false}) middleware by raw reference', async () => { + // Arrange + mock.onGet(/.*/).reply(200, {ok: true}); + const service = createHttpService(BASE_URL); + const middlewareFn = vi.fn(); + const unregister = service.registerRequestMiddleware(middlewareFn, {guard: false}); + + // Act + await service.getRequest('/first'); + unregister(); + await service.getRequest('/second'); + + // Assert + expect(middlewareFn).toHaveBeenCalledTimes(1); + }); + }); }); describe('isAxiosError', () => { diff --git a/packages/loading/package.json b/packages/loading/package.json index db07546..699a3ca 100644 --- a/packages/loading/package.json +++ b/packages/loading/package.json @@ -1,6 +1,6 @@ { "name": "@script-development/fs-loading", - "version": "0.1.5", + "version": "0.1.6", "description": "Reactive loading state service with counter-based tracking and HTTP middleware for fs-http", "homepage": "https://packages.script.nl/packages/loading", "license": "MIT", @@ -42,14 +42,14 @@ "typecheck": "tsc --noEmit" }, "devDependencies": { - "@script-development/fs-http": "^0.1.0 || ^0.2.0 || ^0.3.0 || ^0.4.0 || ^0.5.0", + "@script-development/fs-http": "^0.1.0 || ^0.2.0 || ^0.3.0 || ^0.4.0 || ^0.5.0 || ^0.6.0", "@vue/test-utils": "^2.4.11", "axios": "^1.18.1", "happy-dom": "^20.10.3", "vue": "^3.5.39" }, "peerDependencies": { - "@script-development/fs-http": "^0.1.0 || ^0.2.0 || ^0.3.0 || ^0.4.0 || ^0.5.0", + "@script-development/fs-http": "^0.1.0 || ^0.2.0 || ^0.3.0 || ^0.4.0 || ^0.5.0 || ^0.6.0", "vue": "^3.5.39" }, "engines": {