Skip to content

Commit 89bcee0

Browse files
committed
feat(http): guarded() consumer-side middleware guard wrapper
1 parent f8cd000 commit 89bcee0

11 files changed

Lines changed: 453 additions & 20 deletions

File tree

docs/packages/http.md

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,58 @@ const unregister = http.registerResponseErrorMiddleware((error) => {
172172
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.
173173
:::
174174

175+
## Middleware guard (`guarded`)
176+
177+
`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.
178+
179+
`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.
180+
181+
```typescript
182+
import {createHttpService, guarded} from '@script-development/fs-http';
183+
184+
const http = createHttpService(`${location.origin}/api`);
185+
186+
// A throwing response body would otherwise reject a resolved 200 — guarded()
187+
// contains it so the successful response still resolves.
188+
http.registerResponseMiddleware(
189+
guarded((response) => {
190+
showToast(`Loaded ${response.config.url}`); // may throw — no longer fatal
191+
}),
192+
);
193+
194+
// A throwing error body would otherwise mask the real AxiosError — guarded()
195+
// contains it so the caller still rejects with the original error.
196+
http.registerResponseErrorMiddleware(
197+
guarded((error) => {
198+
openErrorDialog(error); // may throw — the 500 still surfaces to the caller
199+
}),
200+
);
201+
```
202+
203+
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.
204+
205+
### Custom error handling
206+
207+
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:
208+
209+
```typescript
210+
import {guarded, type GuardedMiddlewareErrorHandler} from '@script-development/fs-http';
211+
212+
const reportToTracker: GuardedMiddlewareErrorHandler = (error) => {
213+
errorTracker.capture(error); // must not re-throw
214+
};
215+
216+
http.registerResponseMiddleware(
217+
guarded((response) => {
218+
analytics.record(response.status);
219+
}, reportToTracker),
220+
);
221+
```
222+
223+
::: tip Why opt-in, not library-side
224+
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.
225+
:::
226+
175227
## File Operations
176228

177229
`downloadRequest` and `previewRequest` are **transport-only** — they GET an endpoint as a `Blob` and return the full `AxiosResponse<Blob>`. Neither touches the DOM. There is no browser save dialog and no object-URL management inside fs-http; the consumer owns that orchestration (fs-packages issue #59). The two names share identical transport (`responseType: 'blob'`); the separate name communicates intent (download = save-to-disk, preview = inline-display).
@@ -263,3 +315,10 @@ try {
263315
| `registerRequestMiddleware(fn)` | `UnregisterMiddleware` |
264316
| `registerResponseMiddleware(fn)` | `UnregisterMiddleware` |
265317
| `registerResponseErrorMiddleware(fn)` | `UnregisterMiddleware` |
318+
319+
### Middleware Guard
320+
321+
| Export | Type | Description |
322+
| ------------------------------- | --------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
323+
| `guarded(fn, onError?)` | `<T>(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). |
324+
| `GuardedMiddlewareErrorHandler` | `(error: unknown) => void` | Handler type for `guarded`'s optional second argument; defaults to a loud `console.error`. Must not re-throw. |

package-lock.json

Lines changed: 10 additions & 10 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/adapter-store/package.json

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@script-development/fs-adapter-store",
3-
"version": "0.3.0",
3+
"version": "0.3.1",
44
"description": "Reactive adapter-store pattern with domain state management and CRUD resource adapters",
55
"homepage": "https://packages.script.nl/packages/adapter-store",
66
"license": "MIT",
@@ -43,15 +43,15 @@
4343
},
4444
"devDependencies": {
4545
"@script-development/fs-helpers": "^0.1.0",
46-
"@script-development/fs-http": "^0.1.0 || ^0.2.0 || ^0.3.0 || ^0.4.0",
46+
"@script-development/fs-http": "^0.1.0 || ^0.2.0 || ^0.3.0 || ^0.4.0 || ^0.5.0",
4747
"@script-development/fs-loading": "^0.1.0",
4848
"@script-development/fs-storage": "^0.1.0",
4949
"happy-dom": "^20.10.3",
5050
"vue": "^3.5.39"
5151
},
5252
"peerDependencies": {
5353
"@script-development/fs-helpers": "^0.1.0",
54-
"@script-development/fs-http": "^0.1.0 || ^0.2.0 || ^0.3.0 || ^0.4.0",
54+
"@script-development/fs-http": "^0.1.0 || ^0.2.0 || ^0.3.0 || ^0.4.0 || ^0.5.0",
5555
"@script-development/fs-loading": "^0.1.0",
5656
"@script-development/fs-storage": "^0.1.0",
5757
"vue": "^3.5.39"

packages/cached-adapter-store/CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
# @script-development/fs-cached-adapter-store
22

3+
## 0.2.4 — 2026-07-02
4+
5+
### Patch Changes
6+
7+
- **Peer-range widening for `@script-development/fs-http` `^0.5.0`.** `fs-http` published a minor (0.5.0, the additive `guarded()` middleware guard). Pre-1.0 caret semantics require every `fs-http` consumer to widen its accepted range; no behavioural change. Mechanical cascade per fs-packages `CLAUDE.md` § Versioning Discipline.
8+
39
## 0.2.3 — 2026-06-29
410

511
### Patch Changes

packages/cached-adapter-store/package.json

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@script-development/fs-cached-adapter-store",
3-
"version": "0.2.3",
3+
"version": "0.2.4",
44
"description": "Higher-order factory wrapping @script-development/fs-adapter-store with hash-bumping cache-check that suppresses redundant retrieveAll GETs at source",
55
"homepage": "https://packages.script.nl/packages/cached-adapter-store",
66
"license": "MIT",
@@ -43,15 +43,15 @@
4343
},
4444
"devDependencies": {
4545
"@script-development/fs-adapter-store": "^0.1.0 || ^0.2.0 || ^0.3.0",
46-
"@script-development/fs-http": "^0.1.0 || ^0.2.0 || ^0.3.0 || ^0.4.0",
46+
"@script-development/fs-http": "^0.1.0 || ^0.2.0 || ^0.3.0 || ^0.4.0 || ^0.5.0",
4747
"@script-development/fs-storage": "^0.1.0",
4848
"axios": "^1.18.1",
4949
"happy-dom": "^20.10.3",
5050
"vue": "^3.5.39"
5151
},
5252
"peerDependencies": {
5353
"@script-development/fs-adapter-store": "^0.1.0 || ^0.2.0 || ^0.3.0",
54-
"@script-development/fs-http": "^0.1.0 || ^0.2.0 || ^0.3.0 || ^0.4.0",
54+
"@script-development/fs-http": "^0.1.0 || ^0.2.0 || ^0.3.0 || ^0.4.0 || ^0.5.0",
5555
"@script-development/fs-storage": "^0.1.0",
5656
"vue": "^3.5.39"
5757
},

packages/http/CHANGELOG.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,15 @@
11
# @script-development/fs-http
22

3+
## 0.5.0 — 2026-07-02
4+
5+
### Minor Changes
6+
7+
- **New export: `guarded()` — consumer-side middleware guard wrapper.** A higher-order function that wraps an `fs-http` middleware body in try/catch so a side-effect throw (toast, store write, router push, cache-hash parse) cannot corrupt the interceptor chain — it can no longer reject a resolved 200 nor mask the original API error on the error path. All three middleware types (`RequestMiddlewareFunc`, `ResponseMiddlewareFunc`, `ResponseErrorMiddlewareFunc`) share the `(arg) => void` shape, so one generic wraps any of them and stays assignable to the source type with zero casts: `service.registerResponseMiddleware(guarded((response) => { ... }))`.
8+
- The library stays **sync-only and loud**`createHttpService` and the interceptor loops are unchanged (the 2026-05-13 rejection of library-side try/catch holds). `guarded()` is **opt-in at the consumer's registration site**: loud library, defensive consumer.
9+
- The default error handler surfaces the swallowed failure via `console.error` (visible to error trackers) and never re-throws. Pass a custom `GuardedMiddlewareErrorHandler` to route the failure elsewhere.
10+
- Also exports the `GuardedMiddlewareErrorHandler` type.
11+
- Additive and non-breaking — no existing API changed.
12+
313
## 0.4.1 — 2026-05-29
414

515
### Patch Changes

packages/http/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@script-development/fs-http",
3-
"version": "0.4.1",
3+
"version": "0.5.0",
44
"description": "Framework-agnostic HTTP service factory with middleware architecture",
55
"homepage": "https://packages.script.nl/packages/http",
66
"license": "MIT",

packages/http/src/guarded.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
/**
2+
* Error handler invoked when a middleware body wrapped by {@link guarded} throws.
3+
* Receives the thrown value (typed `unknown`, since a throw can be anything).
4+
* Must not re-throw — doing so re-opens the exact failure `guarded` closes.
5+
*/
6+
export type GuardedMiddlewareErrorHandler = (error: unknown) => void;
7+
8+
/**
9+
* Default handler: surface the swallowed failure loudly (visible to `console`
10+
* and any error tracker that hooks it) without letting it propagate. Loud, not
11+
* silent — a swallowed middleware throw is still a bug the consumer should see.
12+
*/
13+
const defaultOnError: GuardedMiddlewareErrorHandler = (error) => {
14+
console.error('[fs-http] middleware body threw and was swallowed by guarded():', error);
15+
};
16+
17+
/**
18+
* Wrap an `fs-http` middleware body so a side-effect throw (toast, store write,
19+
* router push, cache-hash parse) cannot corrupt the interceptor chain — i.e.
20+
* cannot reject a resolved 200 nor mask the real API error on the error path.
21+
*
22+
* `fs-http` invokes middleware synchronously and un-caught **by design** (the
23+
* library stays loud; the 2026-05-13 rejection of library-side try/catch holds).
24+
* `guarded` is the **consumer-side, opt-in** defense: apply it at the
25+
* registration site. Loud library, defensive consumer.
26+
*
27+
* All three middleware types (`RequestMiddlewareFunc`, `ResponseMiddlewareFunc`,
28+
* `ResponseErrorMiddlewareFunc`) share the `(arg) => void` shape, so this one
29+
* generic wraps any of them and stays assignable to the source type with zero
30+
* casts:
31+
*
32+
* ```ts
33+
* service.registerResponseMiddleware(guarded((response) => { ...may throw... }));
34+
* ```
35+
*
36+
* @param fn the middleware body to protect.
37+
* @param onError handler for a thrown value; defaults to a loud `console.error`.
38+
* Pass a custom handler to route the failure elsewhere (e.g. an
39+
* error tracker). Do not re-throw from it.
40+
* @returns a middleware function of the same shape that never throws.
41+
*/
42+
export const guarded = <T>(
43+
fn: (arg: T) => void,
44+
onError: GuardedMiddlewareErrorHandler = defaultOnError,
45+
): ((arg: T) => void) => {
46+
return (arg: T) => {
47+
try {
48+
fn(arg);
49+
} catch (error) {
50+
onError(error);
51+
}
52+
};
53+
};

packages/http/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
export {DEFAULT_TIMEOUT_MS, createHttpService} from './http';
2+
export {guarded} from './guarded';
3+
export type {GuardedMiddlewareErrorHandler} from './guarded';
24
export type {
35
HttpService,
46
HttpServiceOptions,

0 commit comments

Comments
 (0)