Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 8 additions & 8 deletions docs/packages/http.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>` | 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<string, string>` | 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

Expand Down
2 changes: 1 addition & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 6 additions & 0 deletions packages/http/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
2 changes: 1 addition & 1 deletion packages/http/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
20 changes: 19 additions & 1 deletion packages/http/src/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,26 @@ const unregister = <T>(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(),
Expand Down
40 changes: 40 additions & 0 deletions packages/http/tests/http.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down