@samveys/http-client
From packages/http-client/README.md in the framework repository.
An HTTP client over fetch-style requests with JSON helpers, timeouts, retries, a concurrency pool, test fakes and SSRF protection for URLs that come from users. No runtime dependencies.
Make requests
import { createHttpClient } from '@samveys/http-client'
const client = createHttpClient({
baseUrl: 'https://api.example.com/v1',
timeoutMs: 5_000, // per attempt, via AbortSignal.timeout
retries: { count: 2, backoffMs: [100, 400], on: [502, 503, 504] },
concurrency: 4, // at most 4 requests in flight; the rest wait their turn
})
export async function loadUser(id: number) {
return client.json<{ id: number; name: string }>(`/users/${id}`, { query: { expand: 'profile' } })
}
export async function createUser(name: string) {
const response = await client.post('/users', { json: { name } })
return response.status
}client.json()parses a 2xx JSON answer and throwsSVS_E107for any other status.- A request that still fails after the last retry (timeout, network error, or a status in
on) isSVS_E107(502). retriesandtimeoutMscan be overridden per request.
Never fetch a user-supplied URL directly (SSRF)
A URL typed by a user (an avatar link, a webhook target, an import source) can point at your own network. Use userUrl() (or untrusted: true) and the client:
- accepts only
http:andhttps:and no credentials in the URL; - resolves the host with
node:dns/promises(lookupwithall) and refuses the request if any address is loopback, private (10/8,172.16/12,192.168/16), carrier-grade NAT (100.64/10), link-local (169.254/16, which includes the cloud metadata address169.254.169.254), IPv6::1,fc00::/7,fe80::/10, an IPv4-mapped form such as::ffff:127.0.0.1, or0.0.0.0; - connects to the address it vetted, so DNS cannot change between the check and the connection (no DNS rebinding);
- follows redirects itself and vets every hop, so a public URL that redirects to
http://10.0.0.1/is refused; - drops
AuthorizationandCookiewhen a redirect leaves the origin.
A refusal is SVS_E106 (502).
import { createHttpClient } from '@samveys/http-client'
const client = createHttpClient({ timeoutMs: 3_000 })
export async function fetchAvatar(url: string): Promise<Uint8Array> {
const response = await client.userUrl(url) // SVS_E106 for 169.254.169.254, localhost, private ranges…
return new Uint8Array(await response.arrayBuffer())
}Fake requests in tests
import { createHttpClient } from '@samveys/http-client'
export async function exampleTest() {
const client = createHttpClient()
client
.fake({
'https://api.x/users/*': { json: { id: 7 } },
'https://api.x/health': { status: 204 },
'https://api.x/echo': (request) => new Response(request.method),
})
.preventStrayRequests()
const user = await client.json('https://api.x/users/7') // { id: 7 }, no network
await client.get('https://unfaked.example/') // throws SVS_E106: the request was not faked
return { user, sent: client.recorded() }
}A * in a pattern matches any run of characters. recorded() lists every request the client handled (method, URL, headers, body, whether it was faked) for assertions.
Reference
createHttpClient({ baseUrl?, timeoutMs?, retries?: { count, backoffMs[], on[] }, concurrency?, headers?, untrusted?, maxRedirects?, resolver?, transport? })
client.request | get | post | put | patch | delete (url, { query?, json?, body?, headers?, timeoutMs?, retries?, untrusted?, signal? })
client.json<T>(url, options?) client.userUrl(url, options?)
client.fake(table) client.preventStrayRequests() client.allowStrayRequests() client.recorded()