first commit

This commit is contained in:
2026-05-29 08:20:56 +01:00
commit 949299f58d
6368 changed files with 527176 additions and 0 deletions

View File

@@ -0,0 +1,155 @@
# `http` — Shared HTTP helper for sibling provider packages
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Exposes the two HTTP utilities every sibling
`@suaveplan/currency-provider-<name>` package repeats: `httpRequest` (an
async request through the injected `FetchLike` with typed-error mapping and
abort-signal composition) and `composeAbortSignal` (a pure helper that
merges a caller `AbortSignal` with an optional timeout-derived one).
The previous shape of `@suaveplan/currency-providers` had every provider
re-implement the same `await this.fetchImpl(url); if (!response.ok) throw
new CurrencyProviderError(HTTP_ERROR, ...)` block by hand. That repetition
is exactly the signal that the helper belongs in the slim core: every
sibling now `import { httpRequest } from "@suaveplan/currency-providers"`
and the HTTP_ERROR call site lives in one place, exercised by one test
file.
## Features
- `httpRequest` — single async helper covering the full HTTP lifecycle:
abort-signal composition, timeout derivation, fetch invocation, non-2xx
→ `CurrencyProviderError(HTTP_ERROR)` mapping
- `composeAbortSignal` — pure four-way composition: `undefined`, caller
only, timeout only, both (via `AbortSignal.any`)
- Returns the raw `Response` so callers handle `.json()` / `.text()` /
`.arrayBuffer()` according to their own payload shape — no premature
body-format assumption
- Universal — uses only WHATWG `fetch`, `AbortSignal.timeout`, and
`AbortSignal.any` (all available in Node 20+, Bun, and modern browsers)
- Typed error carries `providerName` and `status` discriminants so audit
log handlers can dispatch without parsing the message string
## Usage: Basic Example
```ts
import { httpRequest } from "@suaveplan/currency-providers";
const response = await httpRequest({
url: "https://api.example.com/v1/rates",
fetch: globalThis.fetch,
providerName: "example",
});
const payload = await response.json();
```
## Usage: Advanced Example
Wire `httpRequest` into a sibling provider with both a timeout and the
caller's `RateFetchOptions.signal`, then handle `HTTP_ERROR` against the
typed code rather than the message:
```ts
import {
CURRENCY_PROVIDER_CODES,
CurrencyProviderError,
httpRequest,
type NoKeyConfig,
} from "@suaveplan/currency-providers";
import type { RateFetchOptions, RateFetchResult } from "@suaveplan/currency";
const DEFAULT_URL = "https://api.example.com/v1/rates";
const NAME = "example";
export class ExampleProvider {
public readonly name = NAME;
public readonly supportsCrypto = false;
public readonly supportsFiat = true;
private readonly baseUrl: string;
private readonly fetchImpl;
constructor(config?: NoKeyConfig) {
this.baseUrl = config?.baseUrl ?? DEFAULT_URL;
this.fetchImpl = config?.fetch ?? globalThis.fetch;
}
async fetchRates(options?: RateFetchOptions): Promise<RateFetchResult> {
try {
const response = await httpRequest({
url: this.baseUrl,
fetch: this.fetchImpl,
providerName: NAME,
signal: options?.signal,
timeoutMs: 10_000,
});
const payload = (await response.json()) as {
base: string;
rates: Record<string, number>;
timestamp: number;
};
return {
base: payload.base,
rates: payload.rates,
timestamp: payload.timestamp * 1000,
};
} catch (error) {
if (
error instanceof CurrencyProviderError &&
error.code === CURRENCY_PROVIDER_CODES.HTTP_ERROR
) {
// optionally: backoff, fallback, audit-log etc.
}
throw error;
}
}
}
```
## API Reference
- `interface HttpRequestInput`
- `url: string` — fully-resolved request URL (query string already
appended at the call site).
- `fetch: FetchLike` — injected `FetchLike` (the sibling provider
defaults to `globalThis.fetch` at construction time).
- `providerName: string` — carried on the raised
`CurrencyProviderError.providerName`.
- `init?: RequestInit` — optional WHATWG `RequestInit` (method, headers,
body, etc.). The helper preserves caller-supplied `headers` and
`method`; it only inserts `signal` when an abort signal needs to be
attached.
- `signal?: AbortSignal` — caller's abort signal (usually from
`RateFetchOptions.signal`).
- `timeoutMs?: number` — when set, the helper derives
`AbortSignal.timeout(timeoutMs)` and composes it with the caller signal.
- `function httpRequest(input: HttpRequestInput): Promise<Response>` —
resolves to the raw `Response` on 2xx, rejects with
`CurrencyProviderError(HTTP_ERROR)` on non-2xx.
- `function composeAbortSignal(caller, timeout): AbortSignal | undefined`
— pure four-way composition. Returns the single input verbatim when only
one is provided (no wrapping cost). Returns
`AbortSignal.any([caller, timeout])` when both are present.
## Implementation Notes
- The helper does **not** apply retry, backoff, or rate-limit semantics —
those wrappers compose at a higher layer (see the deferred surfaces in
the 2026-05-17 archive's `## Out of scope`).
- `AbortSignal.timeout` was chosen over a manual `setTimeout +
AbortController` to keep the helper universal (no `setTimeout`
scheduling owned by the helper) and to avoid the cleanup foot-gun of a
pending timer outliving a fast-success request.
- `composeAbortSignal` deliberately returns the **single input** unchanged
when only one is provided. Wrapping a single signal through
`AbortSignal.any([s])` would still produce a valid signal but would
break identity-comparison invariants downstream consumers might rely on.
- The non-2xx error message intentionally uses `response.statusText` (the
HTTP reason phrase) so the human-readable form survives audit log
redaction; programmatic dispatch should use `error.code` and
`error.status` instead.