@molecule/app-http
Core interface · http · App (browser) · v1.1.5 · Apache-2.0
HTTP client with interceptors and providers
npm install @molecule/app-httpHow it works
@molecule/app-http is the http core interface on the app (browser) side: the API your app calls, with no vendor inside.
Choose the implementation by bonding one of its 1 provider: @molecule/app-http-axios.
// In a React component, get the configured client from context and call it.
// The hook is exported by the framework binding (@molecule/app-react), not
// this core package — never construct your own fetch/axios client.
import { useHttpClient } from '@molecule/app-react'
function Plants() {
const http = useHttpClient()
const load = async () => {
const res = await http.get<Plant[]>('/plants') // the bonded client's baseURL ('/api') is prepended
setPlants(res.data)
}
// Body-carrying verbs take the body as the SECOND argument:
// http.post('/plants', plant) — wire body is `plant`
// http.post('/plants', { data: plant }) — WRONG: wire body is `{"data": plant}`
// http.put(url, body), http.patch(url, body) and http.delete(url) are also available.
}Providers (1): @molecule/app-http-axios
Works with: @molecule/app-bond, @molecule/app-i18n, @molecule/app-logger
Reference
Client HTTP interface for molecule.dev.
Provides a unified HTTP client API that works across different HTTP libraries (fetch, axios, ky, etc.).
The body is the SECOND argument: post(url, data, config?) — likewise put and patch,
and the response is HttpResponse<T> (read .data for the body). data IS the request
body; a plain object is serialized as the JSON body. Passing { data: body } there is NOT a
config object — it makes the wire body {"data":{…}}, so the API sees an object whose only
key is data, finds none of the fields it validates, and answers
422 {"errors":{"username":["can't be blank"],…}}. Write post('/users', { user }), never
post('/users', { data: { user } }). The config object (headers, params, timeout,
skipAuthGate) is the THIRD argument. Only request({ method, url, data, … }) takes data
inside an options object, because there that object IS the config.
Auto-generated, AI-first package reference for the molecule.dev ecosystem. It is written to be read by coding agents as much as by people, and is generated from this package's source — edit
src/index.tsJSDoc, not this file.
Quick Start
// In a React component, get the configured client from context and call it.
// The hook is exported by the framework binding (@molecule/app-react), not
// this core package — never construct your own fetch/axios client.
import { useHttpClient } from '@molecule/app-react'
function Plants() {
const http = useHttpClient()
const load = async () => {
const res = await http.get<Plant[]>('/plants') // the bonded client's baseURL ('/api') is prepended
setPlants(res.data)
}
// Body-carrying verbs take the body as the SECOND argument:
// http.post('/plants', plant) — wire body is `plant`
// http.post('/plants', { data: plant }) — WRONG: wire body is `{"data": plant}`
// http.put(url, body), http.patch(url, body) and http.delete(url) are also available.
}
Type
core
Installation
npm install @molecule/app-http @molecule/app-bond @molecule/app-i18n @molecule/app-logger
API
Interfaces
HttpClient
HTTP client interface.
All HTTP providers must implement this interface.
interface HttpClient {
/**
* Base URL for all requests.
*/
baseURL: string
/**
* Default headers for all requests.
*/
defaultHeaders: Record<string, string>
/**
* Makes a generic HTTP request. `data` is a field of THIS config object — the same body
* `post(url, data, config)` takes as its second argument.
*/
request<T = unknown>(config: FullRequestConfig): Promise<HttpResponse<T>>
/**
* Makes a GET request.
*/
get<T = unknown>(url: string, config?: RequestConfig): Promise<HttpResponse<T>>
/**
* Makes a POST request. `data` is the request BODY, not a config object: the wire body is
* `data` itself, so `post(url, { data: body })` sends `{"data":body}` — pass `body` directly.
* A config (headers, params) is the THIRD argument.
*/
post<T = unknown>(url: string, data?: unknown, config?: RequestConfig): Promise<HttpResponse<T>>
/**
* Makes a PUT request. `data` is the request BODY (second argument) — never `{ data: body }`,
* which sends `{"data":body}`; a config is the THIRD argument.
*/
put<T = unknown>(url: string, data?: unknown, config?: RequestConfig): Promise<HttpResponse<T>>
/**
* Makes a PATCH request. `data` is the request BODY (second argument) — never `{ data: body }`,
* which sends `{"data":body}`; a config is the THIRD argument.
*/
patch<T = unknown>(url: string, data?: unknown, config?: RequestConfig): Promise<HttpResponse<T>>
/**
* Makes a DELETE request.
*/
delete<T = unknown>(url: string, config?: RequestConfig): Promise<HttpResponse<T>>
/**
* Adds a request interceptor.
* Returns a function to remove the interceptor.
*/
addRequestInterceptor(interceptor: RequestInterceptor): () => void
/**
* Adds a response interceptor.
* Returns a function to remove the interceptor.
*/
addResponseInterceptor(interceptor: ResponseInterceptor): () => void
/**
* Adds an error interceptor.
* Returns a function to remove the interceptor.
*/
addErrorInterceptor(interceptor: ErrorInterceptor): () => void
/**
* Sets the authorization token.
*/
setAuthToken(token: string | null): void
/**
* Returns the current authorization token, or `null` if not set.
*/
getAuthToken(): string | null
/**
* Registers a handler for authentication errors (401).
*
* @returns An unsubscribe function.
*/
onAuthError(handler: () => void): () => void
}
HttpClientConfig
HTTP client configuration.
interface HttpClientConfig {
/**
* Base URL for all requests.
*/
baseURL?: string
/**
* Default headers for all requests.
*/
defaultHeaders?: Record<string, string>
/**
* Default timeout in milliseconds.
*/
timeout?: number
/**
* Whether to include credentials by default.
*/
withCredentials?: boolean
/**
* The scheme of the `Authorization` header the auth token is sent with:
* `Bearer` (default), or what your API expects, e.g. `Token` for an API that
* reads `Authorization: Token <jwt>`. It is an option of the client's
* CONSTRUCTOR (`createFetchClient({ baseURL, authScheme: 'Token' })`, or the
* bonded provider's `createAxiosClient({ … })`), never a property of the client
* it returns.
*/
authScheme?: string
}
RequestConfig
HTTP request options (headers, query params, timeout, credentials, response type, abort signal).
interface RequestConfig {
/**
* Request headers.
*/
headers?: Record<string, string>
/**
* Query parameters.
*/
params?: Record<string, string | number | boolean | undefined>
/**
* Request timeout in milliseconds.
*/
timeout?: number
/**
* Whether to include credentials (cookies).
*/
withCredentials?: boolean
/**
* Send this request WITHOUT waiting for the session restore (the auth gate).
*
* A request the gate itself depends on must set this. A gate registered with
* `setAuthGate` that waits for a session restore whose own request goes through this
* client otherwise waits on itself: the gate holds the restore's request, the restore's
* promise never settles, and every request on the page stalls until the gate times out —
* so pass `skipAuthGate: true` on the restore's own request:
*
* ```typescript
* // the request the gate's restore makes, through the same client the gate holds back
* await http.get(currentUserEndpoint, { skipAuthGate: true })
* ```
*
* `@molecule/app-auth`'s own restore does not need it — it sends its request with
* `fetch`, not through this client. An app with its own session store that registers a
* gate with `setAuthGate` does.
*/
skipAuthGate?: boolean
/**
* Response type.
*/
responseType?: 'json' | 'text' | 'blob' | 'arraybuffer'
/**
* Abort signal for cancellation.
*/
signal?: AbortSignal
/**
* Request body data, as a field of THIS config object — the `request({ method, url, data })`
* form. The verb methods take the body as their SECOND argument: `post(url, data, config)`,
* `put(url, data, config)`, `patch(url, data, config)`. Never wrap it there as
* `{ data: body }`: that object is the body, so the wire sends `{"data":body}` and the API
* sees none of the fields at its top level.
*/
data?: unknown
/**
* Custom request options (implementation-specific).
*/
options?: Record<string, unknown>
}
AuthGateRequest
What the auth gate is told about the request it is about to hold: whether the request opts out of the wait, and how to name it in the stalled-gate report.
A request's own RequestConfig satisfies this — the providers pass it straight through
(awaitAuthGate(requestConfig)), so a provider never has to build one by hand.
interface AuthGateRequest {
/**
* Send this request without waiting for the gate at all — see
* `RequestConfig.skipAuthGate`.
*/
skipAuthGate?: boolean
/**
* The request's HTTP method, for the message logged when the gate never settles.
*/
method?: string
/**
* The request's URL, for the message logged when the gate never settles. A URL may carry
* an id; it never carries a credential (tokens travel in headers).
*/
url?: string
}
FullRequestConfig
Full request configuration including method and URL.
interface FullRequestConfig extends RequestConfig {
/**
* HTTP method.
*/
method: HttpMethod
/**
* Request URL (can be relative or absolute).
*/
url: string
/**
* Request body data — a field of this config object, never a second argument here.
*/
data?: unknown
}
HttpResponse
Parsed HTTP response with status code, headers, and typed body data.
interface HttpResponse<T = unknown> {
/**
* Response data.
*/
data: T
/**
* HTTP status code.
*/
status: number
/**
* HTTP status text.
*/
statusText: string
/**
* Response headers.
*/
headers: Record<string, string>
/**
* Original request config.
*/
config: FullRequestConfig
}
Types
RequestInterceptor
Intercepts outgoing requests to modify headers, URL, or body before the request is sent.
type RequestInterceptor = (
config: FullRequestConfig,
) => FullRequestConfig | Promise<FullRequestConfig>
config— The full request configuration to transform.
ResponseInterceptor
Intercepts incoming responses to transform data, check status, or perform side effects before the response reaches the caller.
type ResponseInterceptor<T = unknown> = (
response: HttpResponse<T>,
) => HttpResponse<T> | Promise<HttpResponse<T>>
ErrorInterceptor
Intercepts HTTP errors to transform, retry, or rethrow them. Throwing from this interceptor propagates the error to the caller.
type ErrorInterceptor = (error: HttpError) => HttpError | Promise<HttpError> | never
HttpMethod
HTTP request method.
type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' | 'HEAD' | 'OPTIONS'
Classes
HttpError
HTTP error with response details.
class HttpError extends Error {
constructor(
message: string,
public status: number,
public response?: HttpResponse,
public config?: FullRequestConfig,
public errorKey?: string,
) {
super(message)
this.name = 'HttpError'
}
}
Functions
setAuthGate(next)
Registers the gate every HTTP request awaits before it is sent, or clears it
with null.
Pass a function that resolves once the session restore has settled — with the
template auth that is () => authClient.whenRestored(), which resolves
immediately when no restore is in flight, so registering it never makes a
request wait longer than the restore it was about to race anyway.
A gate whose restore sends its OWN request through the gated client deadlocks — the
gate holds the request it is waiting for. That request must pass skipAuthGate: true
(see RequestConfig.skipAuthGate); @molecule/app-auth's own restore does not
need it because it uses fetch rather than the bonded client.
function setAuthGate(next: (() => Promise<void>) | null): void
next— The gate, ornullto clear a previously registered one.
awaitAuthGate(request)
Awaits the registered gate, if any. Every HTTP provider calls this before it sends a request; callers never need to.
A gate that rejects (a restore that failed) is logged and cleared of consequence: the request
is sent rather than held forever, because a dead restore must not block every request for the
life of the page. The same holds for a gate that never settles — the wait is bounded by
GATE_TIMEOUT_MS, and a timeout with requests held is reported at error level naming the fix
(skipAuthGate).
function awaitAuthGate(request?: AuthGateRequest): Promise<void>
request— The request being gated, so a request the gate itself depends on can opt out (skipAuthGate) and a stalled gate can name what it held. A provider passes its own request config; a caller passing nothing gets the plain wait.
Returns: A promise that settles when the gate has released the request.
createFetchClient(config)
Creates a fetch-based HTTP client using the native Fetch API.
Supports request/response/error interceptors, automatic JSON serialization, auth token injection, timeout via AbortController, and 401 error handler hooks.
function createFetchClient(config?: HttpClientConfig): HttpClient
config— Client configuration including baseURL, default headers, timeout, and credentials.
Returns: A fully configured HttpClient instance.
unwrapList(res)
Normalize an unknown response body into a typed array.
Accepts:
- a bare array → returned as-is (cast)
{ data: T[] }envelope → the inner arrayHttpResponse<T[]>(i.e.{ data: T[], status, ... }) → the inner arrayHttpResponse<{ data: T[] }>(the response of an envelope-returning endpoint as it arrives from@molecule/app-http'sHttpClient) → the doubly-nested inner arrayHttpResponse<{ data: T[], total, limit, offset }>(a RICH pagination envelope from a pre-built@molecule/api-resource-*list endpoint) → the innerdataarray (the numerictotal/limit/offsetmake the shape unambiguous, so callers can pass the whole HttpResponse and still get the rows)- anything else →
[]
Callers commonly pass either the raw JSON body (e.g. from fetch().then(r => r.json())) or the HttpResponse returned by useHttpClient().get(...).
Both shapes are handled here so pages don't have to remember to call
unwrapList(res.data) vs unwrapList(res).
function unwrapList(res: unknown): T[]
res— Raw response body OR anHttpResponseenvelope from@molecule/app-http.
Returns: A typed array T[]; never null/undefined.
unwrapSingle(res)
Normalize an unknown response body into a single typed resource.
Accepts:
- a non-empty plain object → returned as-is (cast)
{ data: T }envelope → the inner value{ data: null },{ data: undefined },{ data: [] }, or{ data: {} }(mock-server's no-match shape) →null- an empty object
{}→null - arrays, primitives,
null,undefined→null
The "envelope contains an array → null" branch handles the case
where the mock server returns [] for unmatched endpoints but the
caller expects a single resource.
function unwrapSingle(res: unknown): T | null
res— Raw response body (e.g.HttpResponse.datafrom@molecule/app-http).
Returns: The typed resource T, or null when the response shape indicates "no resource" (including the various empty envelopes above).
setClient(client)
Registers an HTTP client as the active singleton.
function setClient(client: HttpClient): void
client— The HTTP client implementation to bond.
getClient()
Retrieves the bonded HTTP client. If none is bonded, automatically creates a default fetch-based client.
The fallback has NO baseURL — a core cannot know an app's API prefix. A relative
call then resolves against the page origin, where a dev server answers its own HTML
shell with a 200. It warns once so the misconfiguration is named instead of showing up
as a crash three components later; a scaffold bonds the shared client with its API
prefix, so the fallback only runs in an app that never bonded one.
function getClient(): HttpClient
Returns: The active HTTP client instance.
get(url, config)
Makes a GET request using the bonded HTTP client.
function get(url: string, config?: RequestConfig): Promise<HttpResponse<T>>
url— The request URL (relative to baseURL if configured).config— Optional request configuration (headers, params, timeout).
Returns: The HTTP response with typed data.
post(url, data, config)
Makes a POST request using the bonded HTTP client.
The body is data — the SECOND argument. post(url, { data: body }) sends the JSON body
{"data":body}, not body, so the API finds none of its fields at the top level; pass the
body directly (post('/users', { user })). The config is the THIRD argument.
function post(url: string, data?: unknown, config?: RequestConfig): Promise<HttpResponse<T>>
url— The request URL.data— The request body itself (never{ data: body }).config— Optional request configuration (headers, params, timeout).
Returns: The HTTP response with typed data.
put(url, data, config)
Makes a PUT request using the bonded HTTP client.
function put(url: string, data?: unknown, config?: RequestConfig): Promise<HttpResponse<T>>
url— The request URL. The body isdata— the SECOND argument, never{ data: body }.data— The request body itself.config— Optional request configuration (the third argument).
Returns: The HTTP response with typed data.
patch(url, data, config)
Makes a PATCH request using the bonded HTTP client.
function patch(url: string, data?: unknown, config?: RequestConfig): Promise<HttpResponse<T>>
url— The request URL. The body isdata— the SECOND argument, never{ data: body }.data— The request body itself (the partial update).config— Optional request configuration (the third argument).
Returns: The HTTP response with typed data.
del(url, config)
Makes a DELETE request using the bonded HTTP client.
function del(url: string, config?: RequestConfig): Promise<HttpResponse<T>>
url— The request URL.config— Optional request configuration.
Returns: The HTTP response with typed data.
Constants
fetchClient
Pre-created fetch client for environments where fetch is available.
null in environments without a global fetch.
const fetchClient: HttpClient | null
Available Providers
| Provider | Package |
|---|---|
| Axios | @molecule/app-http-axios |
Injection Notes
Requirements
Peer dependencies:
@molecule/app-bond^1.0.1@molecule/app-i18n^1.0.1@molecule/app-logger^1.0.1
Runtime Dependencies
@molecule/app-bond@molecule/app-i18n@molecule/app-logger
Make ALL API calls through this client (via the framework hook useHttpClient() in
React / the Vue composable) — it carries the configured baseURL, auth headers, and
interceptors. Do NOT call fetch() / axios directly in components — that bypasses auth
- base-URL config and breaks when the transport is swapped.
Two mistakes that break in preview/production (seen in real imported apps):
- Pass RELATIVE paths; never a hardcoded host. Use
'/plants'(the BONDED client'sbaseURL—'/api'in a scaffold — is prepended), NOT'/api/plants', and NEVER an absolute dev URL like'http://localhost:4000/api/…'. A hardcodedlocalhost/host works on the author's machine, then fails cross-origin (CORS) in the preview and points at the wrong server in production. The base URL is configured ONCE (viasetClient), not per call. getClient()itself knows no baseURL. The fallback client it mints when nothing is bonded hasbaseURL: '', sogetClient().get('/articles')requests the PAGE origin — in dev the Vite server answers its ownindex.htmlwith a 200, and the HTML arrives asdata(xr-molecule-flash-m126 lost 6.5 minutes to that). A scaffold bonds'/api'; an app that builds its own client must pass{ baseURL }.- The client is PUBLIC — never put a secret in it. Anything the browser sends (an API
key, a service-role /
sk_…key, a signing secret) is visible to every user. Secrets stay in YOUR API; the browser calls your API and the API uses the secret server-side. Only a publishable/public key may ever be client-side.
Auth (the bearer token / session cookie) is attached by the client's interceptors — do not
read a token from localStorage or hand-attach it (the token is memory-only; see the user
resource).
An API that wants another scheme than Bearer (Authorization: Token <jwt>) passes
authScheme: 'Token' to the client's CONSTRUCTOR — setClient(createFetchClient({ baseURL, authScheme: 'Token' })), or the bonded provider's constructor
(createAxiosClient({ … }) from @molecule/app-http-axios). It is NOT a property of
getClient()'s result: that is a built client, and client.authScheme = 'Token' is a type
error (TS2339) that changes no header. Do not hand-write a fetch wrapper for the scheme either.
A request made while the session is still being restored WAITS for that restore, so a page
that fetches viewer-dependent data on mount (a profile's following flag, a feed, a
favorited state) cannot go out unauthenticated and answer for an anonymous viewer. The auth
bond registers that wait with setAuthGate; apps that bond auth through
@molecule/app-bonds-default-react get it without doing anything. An app with its own session
module registers its own: setAuthGate(() => session.whenRestored()).
Waiting for the restore does NOT attach the token: a client created beside the auth bond needs
createDefaultHttpClientWithAuthBearer({ baseURL, getToken: () => authClient.getAccessToken(), authScheme }) from @molecule/app-bonds-default-react (or an auth client built with
createDefaultAuthClientWithHttpSync, which mirrors the token into the bonded client), because
setAuthGate alone sends no Authorization header.
A request the gate itself depends on must opt out of it: pass skipAuthGate: true. A gate
that waits for a session restore whose own request goes through this client waits on itself —
that request is held by the gate waiting for it, so the restore never settles and every request
the gate holds stalls until it times out (3 s). The request that restores the session for a gate
you registered yourself is therefore written
http.get(currentUserEndpoint, { skipAuthGate: true }); @molecule/app-auth's own restore does
not need it (it calls fetch, not this client).
Translations
Translation strings are provided by @molecule/app-locales-http.