diff --git a/docs/packages/http.md b/docs/packages/http.md index a69a04b..2d9ab76 100644 --- a/docs/packages/http.md +++ b/docs/packages/http.md @@ -172,6 +172,58 @@ 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`) + +`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. + +`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. + +```typescript +import {createHttpService, guarded} 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 + }), +); + +// 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 + }), +); +``` + +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. + +### 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: + +```typescript +import {guarded, 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), +); +``` + +::: 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. +::: + ## File Operations `downloadRequest` and `previewRequest` are **transport-only** — they GET an endpoint as a `Blob` and return the full `AxiosResponse`. 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 { | `registerRequestMiddleware(fn)` | `UnregisterMiddleware` | | `registerResponseMiddleware(fn)` | `UnregisterMiddleware` | | `registerResponseErrorMiddleware(fn)` | `UnregisterMiddleware` | + +### 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. | diff --git a/package-lock.json b/package-lock.json index 783f21b..8dc4be0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10419,11 +10419,11 @@ }, "packages/adapter-store": { "name": "@script-development/fs-adapter-store", - "version": "0.3.0", + "version": "0.3.1", "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", + "@script-development/fs-http": "^0.1.0 || ^0.2.0 || ^0.3.0 || ^0.4.0 || ^0.5.0", "@script-development/fs-loading": "^0.1.0", "@script-development/fs-storage": "^0.1.0", "happy-dom": "^20.10.3", @@ -10434,7 +10434,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", + "@script-development/fs-http": "^0.1.0 || ^0.2.0 || ^0.3.0 || ^0.4.0 || ^0.5.0", "@script-development/fs-loading": "^0.1.0", "@script-development/fs-storage": "^0.1.0", "vue": "^3.5.39" @@ -10442,11 +10442,11 @@ }, "packages/cached-adapter-store": { "name": "@script-development/fs-cached-adapter-store", - "version": "0.2.2", + "version": "0.2.4", "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", + "@script-development/fs-http": "^0.1.0 || ^0.2.0 || ^0.3.0 || ^0.4.0 || ^0.5.0", "@script-development/fs-storage": "^0.1.0", "axios": "^1.18.1", "happy-dom": "^20.10.3", @@ -10457,7 +10457,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", + "@script-development/fs-http": "^0.1.0 || ^0.2.0 || ^0.3.0 || ^0.4.0 || ^0.5.0", "@script-development/fs-storage": "^0.1.0", "vue": "^3.5.39" } @@ -10497,7 +10497,7 @@ }, "packages/http": { "name": "@script-development/fs-http", - "version": "0.4.1", + "version": "0.5.0", "license": "MIT", "dependencies": { "axios": "^1.18.1" @@ -10511,10 +10511,10 @@ }, "packages/loading": { "name": "@script-development/fs-loading", - "version": "0.1.4", + "version": "0.1.5", "license": "MIT", "devDependencies": { - "@script-development/fs-http": "^0.1.0 || ^0.2.0 || ^0.3.0 || ^0.4.0", + "@script-development/fs-http": "^0.1.0 || ^0.2.0 || ^0.3.0 || ^0.4.0 || ^0.5.0", "@vue/test-utils": "^2.4.11", "axios": "^1.18.1", "happy-dom": "^20.10.3", @@ -10524,7 +10524,7 @@ "node": ">=24.0.0" }, "peerDependencies": { - "@script-development/fs-http": "^0.1.0 || ^0.2.0 || ^0.3.0 || ^0.4.0", + "@script-development/fs-http": "^0.1.0 || ^0.2.0 || ^0.3.0 || ^0.4.0 || ^0.5.0", "vue": "^3.5.39" } }, diff --git a/packages/adapter-store/package.json b/packages/adapter-store/package.json index 95b5143..6e44e09 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.0", + "version": "0.3.1", "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", + "@script-development/fs-http": "^0.1.0 || ^0.2.0 || ^0.3.0 || ^0.4.0 || ^0.5.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", + "@script-development/fs-http": "^0.1.0 || ^0.2.0 || ^0.3.0 || ^0.4.0 || ^0.5.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/CHANGELOG.md b/packages/cached-adapter-store/CHANGELOG.md index d26bfde..9e03f5a 100644 --- a/packages/cached-adapter-store/CHANGELOG.md +++ b/packages/cached-adapter-store/CHANGELOG.md @@ -1,5 +1,11 @@ # @script-development/fs-cached-adapter-store +## 0.2.4 — 2026-07-02 + +### Patch Changes + +- **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. + ## 0.2.3 — 2026-06-29 ### Patch Changes diff --git a/packages/cached-adapter-store/package.json b/packages/cached-adapter-store/package.json index 2cbda3d..f463f41 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.3", + "version": "0.2.4", "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", + "@script-development/fs-http": "^0.1.0 || ^0.2.0 || ^0.3.0 || ^0.4.0 || ^0.5.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", + "@script-development/fs-http": "^0.1.0 || ^0.2.0 || ^0.3.0 || ^0.4.0 || ^0.5.0", "@script-development/fs-storage": "^0.1.0", "vue": "^3.5.39" }, diff --git a/packages/http/CHANGELOG.md b/packages/http/CHANGELOG.md index 9f04a2e..c18635a 100644 --- a/packages/http/CHANGELOG.md +++ b/packages/http/CHANGELOG.md @@ -1,5 +1,15 @@ # @script-development/fs-http +## 0.5.0 — 2026-07-02 + +### Minor Changes + +- **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) => { ... }))`. + - 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. + - 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. + - Also exports the `GuardedMiddlewareErrorHandler` type. + - Additive and non-breaking — no existing API changed. + ## 0.4.1 — 2026-05-29 ### Patch Changes diff --git a/packages/http/package.json b/packages/http/package.json index 9f995dd..991288a 100644 --- a/packages/http/package.json +++ b/packages/http/package.json @@ -1,6 +1,6 @@ { "name": "@script-development/fs-http", - "version": "0.4.1", + "version": "0.5.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 new file mode 100644 index 0000000..7641711 --- /dev/null +++ b/packages/http/src/guarded.ts @@ -0,0 +1,53 @@ +/** + * Error handler invoked when a middleware body wrapped by {@link guarded} throws. + * Receives the thrown value (typed `unknown`, since a throw can be anything). + * Must not re-throw — doing so re-opens the exact failure `guarded` closes. + */ +export type GuardedMiddlewareErrorHandler = (error: unknown) => void; + +/** + * Default handler: surface the swallowed failure loudly (visible to `console` + * and any error tracker that hooks it) without letting it propagate. Loud, not + * silent — a swallowed middleware throw is still a bug the consumer should see. + */ +const defaultOnError: GuardedMiddlewareErrorHandler = (error) => { + console.error('[fs-http] middleware body threw and was swallowed by guarded():', error); +}; + +/** + * Wrap an `fs-http` middleware body so a side-effect throw (toast, store write, + * router push, cache-hash parse) cannot corrupt the interceptor chain — i.e. + * 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. + * + * All three middleware types (`RequestMiddlewareFunc`, `ResponseMiddlewareFunc`, + * `ResponseErrorMiddlewareFunc`) share the `(arg) => void` shape, so this one + * generic wraps any of them and stays assignable to the source type with zero + * casts: + * + * ```ts + * service.registerResponseMiddleware(guarded((response) => { ...may throw... })); + * ``` + * + * @param fn the middleware body to protect. + * @param onError handler for a thrown value; defaults to a loud `console.error`. + * Pass a custom handler to route the failure elsewhere (e.g. an + * error tracker). Do not re-throw from it. + * @returns a middleware function of the same shape that never throws. + */ +export const guarded = ( + fn: (arg: T) => void, + onError: GuardedMiddlewareErrorHandler = defaultOnError, +): ((arg: T) => void) => { + return (arg: T) => { + try { + fn(arg); + } catch (error) { + onError(error); + } + }; +}; diff --git a/packages/http/src/index.ts b/packages/http/src/index.ts index 8fdd735..1deb795 100644 --- a/packages/http/src/index.ts +++ b/packages/http/src/index.ts @@ -1,4 +1,6 @@ export {DEFAULT_TIMEOUT_MS, createHttpService} from './http'; +export {guarded} from './guarded'; +export type {GuardedMiddlewareErrorHandler} from './guarded'; export type { HttpService, HttpServiceOptions, diff --git a/packages/http/tests/guarded.spec.ts b/packages/http/tests/guarded.spec.ts new file mode 100644 index 0000000..0fae6cb --- /dev/null +++ b/packages/http/tests/guarded.spec.ts @@ -0,0 +1,303 @@ +import type {AxiosError, AxiosResponse, InternalAxiosRequestConfig} from 'axios'; + +import axios from 'axios'; +import MockAdapter from 'axios-mock-adapter'; +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; + +import type { + RequestMiddlewareFunc, + ResponseErrorMiddlewareFunc, + ResponseMiddlewareFunc, + AxiosResponseError, +} from '../src/types'; + +import {createHttpService, guarded, isAxiosError} from '../src/index'; + +const BASE_URL = 'https://api.example.com'; + +describe('guarded', () => { + describe('unit behaviour', () => { + it('invokes the wrapped body with the passed argument on the happy path', () => { + // Arrange + const body = vi.fn<(arg: string) => void>(); + const wrapped = guarded(body); + + // Act + wrapped('payload'); + + // Assert — argument passes through untouched + expect(body).toHaveBeenCalledTimes(1); + expect(body).toHaveBeenCalledWith('payload'); + }); + + it('returns undefined (void) on the happy path', () => { + // Arrange + const wrapped = guarded(() => {}); + + // Act & Assert + expect(wrapped(1)).toBeUndefined(); + }); + + it('swallows a throw from the body and does not re-throw', () => { + // Arrange + const boom = new Error('side-effect exploded'); + const wrapped = guarded(() => { + throw boom; + }); + + // Act & Assert — the whole point: a throwing body cannot escape + expect(() => wrapped('x')).not.toThrow(); + }); + + it('routes the thrown value to a custom onError handler', () => { + // Arrange + const boom = new Error('side-effect exploded'); + const onError = vi.fn<(error: unknown) => void>(); + const wrapped = guarded(() => { + throw boom; + }, onError); + + // Act + wrapped('x'); + + // Assert — the exact thrown value is handed to the handler + expect(onError).toHaveBeenCalledTimes(1); + expect(onError).toHaveBeenCalledWith(boom); + }); + + it('does not call onError when the body does not throw', () => { + // Arrange + const onError = vi.fn<(error: unknown) => void>(); + const wrapped = guarded(() => {}, onError); + + // Act + wrapped('x'); + + // Assert + expect(onError).not.toHaveBeenCalled(); + }); + + it('surfaces a non-Error throw verbatim to the handler', () => { + // Arrange — a throw can be anything; guarded must not assume Error + const onError = vi.fn<(error: unknown) => void>(); + const wrapped = guarded(() => { + throw 'string failure'; + }, onError); + + // Act + wrapped('x'); + + // Assert + expect(onError).toHaveBeenCalledWith('string failure'); + }); + + describe('default onError', () => { + let consoleErrorSpy: ReturnType; + + beforeEach(() => { + consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(() => { + consoleErrorSpy.mockRestore(); + }); + + it('logs the swallowed failure loudly via console.error (message + error)', () => { + // Arrange — no custom handler → default fires + const boom = new Error('boom'); + const wrapped = guarded(() => { + throw boom; + }); + + // Act + wrapped('x'); + + // Assert — message names guarded() and the original error is passed through + expect(consoleErrorSpy).toHaveBeenCalledTimes(1); + expect(consoleErrorSpy).toHaveBeenCalledWith(expect.stringContaining('guarded()'), boom); + expect(consoleErrorSpy).toHaveBeenCalledWith(expect.stringContaining('[fs-http]'), boom); + }); + + it('does not log when the body succeeds', () => { + // Arrange + const wrapped = guarded(() => {}); + + // Act + wrapped('x'); + + // Assert + expect(consoleErrorSpy).not.toHaveBeenCalled(); + }); + }); + }); + + describe('type assignability (zero-cast into all three register* APIs)', () => { + // These assignments are the compile-time contract: guarded(body) infers T + // from the middleware body's typed argument and stays assignable to the + // corresponding *MiddlewareFunc with no cast. A regression here is a + // type error at author time (and under an explicit tsc over tests/). + + it('wraps a RequestMiddlewareFunc body and stays assignable', () => { + // Arrange + const reqBody: RequestMiddlewareFunc = (request) => { + request.headers.set('X-Guarded', '1'); + }; + + // Act — assignment target proves the shape is preserved + const wrapped: RequestMiddlewareFunc = guarded(reqBody); + + // Assert + expect(typeof wrapped).toBe('function'); + }); + + it('wraps a ResponseMiddlewareFunc body and stays assignable', () => { + // Arrange + const resBody: ResponseMiddlewareFunc = (response) => { + void response.status; + }; + + // Act + const wrapped: ResponseMiddlewareFunc = guarded(resBody); + + // Assert + expect(typeof wrapped).toBe('function'); + }); + + it('wraps a ResponseErrorMiddlewareFunc body and stays assignable', () => { + // Arrange + const errBody: ResponseErrorMiddlewareFunc = (error) => { + void error.response?.status; + }; + + // Act + const wrapped: ResponseErrorMiddlewareFunc = guarded(errBody); + + // Assert + expect(typeof wrapped).toBe('function'); + }); + + it('registers inline into all three register* APIs with an annotated param, zero casts', () => { + // Arrange + const service = createHttpService(BASE_URL); + + // Act & Assert — if any of these needed a cast, this file would not compile + expect(() => + service.registerRequestMiddleware( + guarded((request: InternalAxiosRequestConfig) => { + request.headers.set('X-Guarded', '1'); + }), + ), + ).not.toThrow(); + expect(() => + service.registerResponseMiddleware( + guarded((response: AxiosResponse) => { + void response.status; + }), + ), + ).not.toThrow(); + expect(() => + service.registerResponseErrorMiddleware( + guarded((error: AxiosError) => { + void error.response?.status; + }), + ), + ).not.toThrow(); + }); + }); + + // The load-bearing test: prove guarded() closes the exposure end-to-end against + // the real createHttpService interceptor loop (not a stubbed one). fs-http runs + // middleware synchronously and un-caught, so a throwing body would otherwise + // reject a resolved 200 / mask the original AxiosError. + describe('end-to-end against the real interceptor loop', () => { + let mock: MockAdapter; + + beforeEach(() => { + mock = new MockAdapter(axios); + }); + + afterEach(() => { + mock.restore(); + }); + + it('CONTRAST: an UN-guarded throwing response body rejects the resolved 200', async () => { + // Arrange — demonstrates the exposure guarded() closes. + mock.onGet(/.*/).reply(200, {ok: true}); + const service = createHttpService(BASE_URL); + service.registerResponseMiddleware(() => { + throw new Error('toast blew up'); + }); + + // Act & Assert — the successful 200 is turned into a rejection + await expect(service.getRequest('/ok')).rejects.toThrow('toast blew up'); + }); + + it('a guarded throwing response body lets the resolved 200 still resolve', async () => { + // Arrange + mock.onGet(/.*/).reply(200, {ok: true}); + const service = createHttpService(BASE_URL); + const onError = vi.fn<(error: unknown) => void>(); + const boom = new Error('toast blew up'); + service.registerResponseMiddleware( + guarded(() => { + throw boom; + }, onError), + ); + + // Act + const response = await service.getRequest('/ok'); + + // Assert — 200 survives; the swallowed throw went to onError, not the caller + expect(response.status).toBe(200); + expect(response.data).toEqual({ok: true}); + expect(onError).toHaveBeenCalledWith(boom); + }); + + it('a guarded throwing error body still rejects with the ORIGINAL AxiosError', async () => { + // Arrange + mock.onGet(/.*/).reply(500, {error: 'server'}); + const service = createHttpService(BASE_URL); + const onError = vi.fn<(error: unknown) => void>(); + const boom = new Error('error-dialog blew up'); + service.registerResponseErrorMiddleware( + guarded>(() => { + throw boom; + }, onError), + ); + + // Act + let caught: unknown; + try { + await service.getRequest('/fail'); + } catch (error) { + caught = error; + } + + // Assert — the caller sees the real 500 AxiosError, NOT the middleware's throw + expect(caught).not.toBe(boom); + expect(isAxiosError(caught)).toBe(true); + expect((caught as AxiosError).response?.status).toBe(500); + expect(onError).toHaveBeenCalledWith(boom); + }); + + it('a guarded throwing request body lets the request still go through', async () => { + // Arrange + mock.onGet(/.*/).reply(200, {ok: true}); + const service = createHttpService(BASE_URL); + const onError = vi.fn<(error: unknown) => void>(); + const boom = new Error('auth-header build blew up'); + service.registerRequestMiddleware( + guarded(() => { + throw boom; + }, onError), + ); + + // Act + const response = await service.getRequest('/ok'); + + // Assert + expect(response.status).toBe(200); + expect(onError).toHaveBeenCalledWith(boom); + }); + }); +}); diff --git a/packages/loading/package.json b/packages/loading/package.json index d39fc45..db07546 100644 --- a/packages/loading/package.json +++ b/packages/loading/package.json @@ -1,6 +1,6 @@ { "name": "@script-development/fs-loading", - "version": "0.1.4", + "version": "0.1.5", "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", + "@script-development/fs-http": "^0.1.0 || ^0.2.0 || ^0.3.0 || ^0.4.0 || ^0.5.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", + "@script-development/fs-http": "^0.1.0 || ^0.2.0 || ^0.3.0 || ^0.4.0 || ^0.5.0", "vue": "^3.5.39" }, "engines": {