diff --git a/docs/packages/http.md b/docs/packages/http.md index d9264e8..c39b824 100644 --- a/docs/packages/http.md +++ b/docs/packages/http.md @@ -222,7 +222,7 @@ try { | Parameter | Type | Description | | -------------------------- | ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `baseURL` | `string` | Base URL for all requests | +| `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`) | diff --git a/package-lock.json b/package-lock.json index 6e95ae2..16f949a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10228,7 +10228,7 @@ }, "packages/http": { "name": "@script-development/fs-http", - "version": "0.4.0", + "version": "0.4.1", "license": "MIT", "dependencies": { "axios": "^1.16.1" diff --git a/packages/http/CHANGELOG.md b/packages/http/CHANGELOG.md index e98e0e6..9f04a2e 100644 --- a/packages/http/CHANGELOG.md +++ b/packages/http/CHANGELOG.md @@ -1,5 +1,11 @@ # @script-development/fs-http +## 0.4.1 — 2026-05-29 + +### Patch Changes + +- **Fail-fast guard on relative `baseURL`.** `createHttpService('/api')` now throws a library-attributed `Error` instead of an opaque native `TypeError: Invalid URL`. The new message names the package, names the function, explains that an absolute baseURL is required, and echoes the offending value — so the failure points at the consumer's call site rather than at fs-http internals. Production-bug class previously surfaced only at runtime as opaque `TypeError: Invalid URL` and remained latent in CI when consumers mock `@script-development/fs-http` in integration tests. Closes enforcement queue #21. + ## 0.4.0 — 2026-05-15 ### Breaking Changes diff --git a/packages/http/package.json b/packages/http/package.json index b36a82e..adb51b9 100644 --- a/packages/http/package.json +++ b/packages/http/package.json @@ -1,6 +1,6 @@ { "name": "@script-development/fs-http", - "version": "0.4.0", + "version": "0.4.1", "description": "Framework-agnostic HTTP service factory with middleware architecture", "homepage": "https://packages.script.nl/packages/http", "license": "MIT", diff --git a/packages/http/src/http.ts b/packages/http/src/http.ts index 255a456..1865e2e 100644 --- a/packages/http/src/http.ts +++ b/packages/http/src/http.ts @@ -30,8 +30,26 @@ const unregister = (array: T[], item: T): UnregisterMiddleware => { }; }; +/** + * Parse the consumer-supplied baseURL with a library-attributed error on failure. + * The native `new URL(baseURL)` throws an opaque `TypeError: Invalid URL` that + * points at fs-http internals rather than the consumer's call site, latent for + * 6 days on entreezuil (PR #40 adoption → PR #96 fix) because integration tests + * mocked @script-development/fs-http and the real factory never ran. Fail-fast + * here (vs. silent coercion to absolute) prevents the class for every adopter. + */ +const parseBaseURL = (baseURL: string): URL => { + try { + return new URL(baseURL); + } catch { + throw new Error( + `[@script-development/fs-http] createHttpService requires an absolute baseURL (e.g. \`\${location.origin}/api\`). Received: ${JSON.stringify(baseURL)}`, + ); + } +}; + export const createHttpService = (baseURL: string, options?: HttpServiceOptions): HttpService => { - const apiUrl = new URL(baseURL); + const apiUrl = parseBaseURL(baseURL); const http = axios.create({ baseURL: apiUrl.toString(), diff --git a/packages/http/tests/http.spec.ts b/packages/http/tests/http.spec.ts index 905ae92..667044a 100644 --- a/packages/http/tests/http.spec.ts +++ b/packages/http/tests/http.spec.ts @@ -36,6 +36,46 @@ describe('createHttpService', () => { }); }); + describe('baseURL guard (queue #21)', () => { + // Latent for 6 days on entreezuil (PR #40 adoption → PR #96 fix) because integration + // tests mock @script-development/fs-http so the real factory never ran. The opaque + // native `TypeError: Invalid URL` pointed at fs-http internals rather than the + // consumer's createHttpService call site. Library-side fail-fast guard prevents the + // class for every future adopter. + + it('throws a library-attributed error when called with a relative path', () => { + // Arrange & Act & Assert + expect(() => createHttpService('/api')).toThrow(/@script-development\/fs-http/); + expect(() => createHttpService('/api')).toThrow(/createHttpService/); + expect(() => createHttpService('/api')).toThrow(/absolute baseURL/); + expect(() => createHttpService('/api')).toThrow(/"\/api"/); + }); + + it('throws a library-attributed error when called with an empty string', () => { + // Arrange & Act & Assert + expect(() => createHttpService('')).toThrow(/@script-development\/fs-http/); + expect(() => createHttpService('')).toThrow(/createHttpService/); + expect(() => createHttpService('')).toThrow(/absolute baseURL/); + expect(() => createHttpService('')).toThrow(/""/); + }); + + it('throws a library-attributed error for malformed URL strings', () => { + // Arrange & Act & Assert — sample of malformed inputs covered by the guard. + expect(() => createHttpService('not a url')).toThrow(/@script-development\/fs-http/); + expect(() => createHttpService('not a url')).toThrow(/"not a url"/); + + expect(() => createHttpService('http://')).toThrow(/@script-development\/fs-http/); + expect(() => createHttpService('http://')).toThrow(/"http:\/\/"/); + }); + + it('does NOT throw for valid absolute URLs (happy-path regression guard)', () => { + // Arrange & Act & Assert — these all succeed because the guard parses successfully. + expect(() => createHttpService('http://localhost')).not.toThrow(); + expect(() => createHttpService('https://example.com/api')).not.toThrow(); + expect(() => createHttpService('https://api.example.com')).not.toThrow(); + }); + }); + describe('default options', () => { it('creates axios instance with correct defaults', async () => { // Arrange