From 1184608f1af1346abafe57afe4ce32dc0936c142 Mon Sep 17 00:00:00 2001 From: Gerard Date: Fri, 8 May 2026 12:10:29 +0200 Subject: [PATCH 1/3] fix(http): fail fast on relative baseURL with clear error (queue #21) createHttpService(baseURL) previously called `new URL(baseURL)` directly, which throws an opaque native `TypeError: Invalid URL` on relative paths like '/api'. The error pointed at fs-http internals rather than the consumer's call site. This was latent for 6 days on entreezuil (PR #40 adoption -> PR #96 fix) because integration tests mock @script-development/fs-http and the real factory never ran in CI. Wrap the URL parse in a `parseBaseURL` helper that catches the native TypeError and re-throws a library-attributed Error naming the package (@script-development/fs-http), the function (createHttpService), the expectation (absolute baseURL with example), and the offending value (JSON.stringify of the input). Library-side fail-fast prevents the class for every fs-http adopter. Closes enforcement queue #21. Tests: 4 new guard cases (relative '/api', empty string, malformed strings 'not a url' / 'http://', happy-path regression for absolute URLs). Pre-fix all 3 negative cases failed with 'Invalid URL'; post-fix all 4 pass. Stash-and-rerun proof captured. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/http/src/http.ts | 20 +++++++++++++++- packages/http/tests/http.spec.ts | 40 ++++++++++++++++++++++++++++++++ 2 files changed, 59 insertions(+), 1 deletion(-) diff --git a/packages/http/src/http.ts b/packages/http/src/http.ts index 8a9eb86..8e4abd3 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 f4ece1a..b2acd41 100644 --- a/packages/http/tests/http.spec.ts +++ b/packages/http/tests/http.spec.ts @@ -47,6 +47,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 From e71cd77838737f1daf15121fa61db78fa8f3ebff Mon Sep 17 00:00:00 2001 From: Gerard Date: Fri, 8 May 2026 12:10:37 +0200 Subject: [PATCH 2/3] chore(http): bump 0.3.0 -> 0.3.1 + changelog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Patch bump — additive guard on previously-undefined behavior, no API change. Existing absolute-URL behavior unchanged. Consumer peer ranges already include `^0.3.0` (verified packages/adapter-store/package.json + packages/loading/package.json) — no peer-range cascade required. Lockfile regenerated; all @script-development/* still resolve to workspace symlinks (no nested registry copies). Co-Authored-By: Claude Opus 4.7 (1M context) --- package-lock.json | 2 +- packages/http/CHANGELOG.md | 6 ++++++ packages/http/package.json | 2 +- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index a3e0418..18a2c93 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10585,7 +10585,7 @@ }, "packages/http": { "name": "@script-development/fs-http", - "version": "0.3.0", + "version": "0.3.1", "license": "MIT", "dependencies": { "axios": "^1.16.0" diff --git a/packages/http/CHANGELOG.md b/packages/http/CHANGELOG.md index 3c80323..6075001 100644 --- a/packages/http/CHANGELOG.md +++ b/packages/http/CHANGELOG.md @@ -1,5 +1,11 @@ # @script-development/fs-http +## 0.3.1 — 2026-05-08 + +### 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.3.0 — 2026-04-30 ### Breaking Changes diff --git a/packages/http/package.json b/packages/http/package.json index 09e2cec..80223fe 100644 --- a/packages/http/package.json +++ b/packages/http/package.json @@ -1,6 +1,6 @@ { "name": "@script-development/fs-http", - "version": "0.3.0", + "version": "0.3.1", "description": "Framework-agnostic HTTP service factory with middleware architecture", "homepage": "https://packages.script.nl/packages/http", "license": "MIT", From 22da1c023941da85029852d47ef74855babff727 Mon Sep 17 00:00:00 2001 From: Gerard Date: Fri, 8 May 2026 12:10:42 +0200 Subject: [PATCH 3/3] docs(http): note that baseURL must be absolute in API Reference One-liner clarification on the existing baseURL row of the createHttpService API Reference table. The library-attributed error message remains the primary documentation surface for the failure path; this is the static-page corollary so consumers reading the API table before writing the call site see the constraint up front. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/packages/http.md | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/docs/packages/http.md b/docs/packages/http.md index c8d66d3..e85001b 100644 --- a/docs/packages/http.md +++ b/docs/packages/http.md @@ -199,14 +199,14 @@ try { ### `createHttpService(baseURL, options?)` -| Parameter | Type | Description | -| -------------------------- | ------------------------ | ----------------------------------------------------------------------- | -| `baseURL` | `string` | Base URL for all requests | -| `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` | Include XSRF token (default: `false`) | -| `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` | Include XSRF token (default: `false`) | +| `options.smartCredentials` | `boolean` | Auto-toggle credentials by origin (default: `false`) | ### Constants