HTTP client plugin
The HTTP client plugin adds http property to your widget with a request method. Under the hood this plugin uses native browser and Node.js fetch API.
Installation
We must add import of httpClientPlugin and register it to $plugins property of the widget.
// ./src/widget.js
import { httpClientPlugin } from '@merkur/plugin-http-client';
export const widgetProperties = {
name,
version,
$plugins: [httpClientPlugin],
// ... other properties
};
After that we have an http.request method available on the widget.
We can override default request config with setDefaultConfig method from @merkur/plugin-http-client. We can set all fetch options, baseUrl, timeout and transformers.
// ./src/widget.js
import { setDefaultConfig, getDefaultTransformers} from '@merkur/plugin-http-client';
// own debug transform
function transformDebug() {
return {
async transformResponse(widget, request, response) {
console.log(response);
return [request, response];
},
async transformRequest(widget, request, response) {
console.log(request);
return [request, response];
},
};
}
export const widgetProperties = {
name,
version,
bootstrap(widget) {
setDefaultConfig(widget,
{
transformers: [...getDefaultTransformers(widget), transformDebug()],
baseUrl: 'http://www.example.com',
timeout: 5000, // 5s
});
}
};
Methods
request
requestConfig- object - same as default config
The request method makes API call to your service throught native browser fetch.
try {
const { response, request } = await widget.http.request({ path: '/detail/1' });
console.log(request.url); // http://www.example.com/detail/1
console.log(response.status); // 200
console.log(response.body); // { data: 'value' }
} catch(error) {
if (error.response) {
// The request was made and the server responded with a status code
// that falls out of the range of 2xx
console.log(error.request.url); // http://www.example.com/detail/1
console.log(error.response.status); // 500
console.log(error.response.body); // { data: 'error message' }
} else {
// Network/timeout error – the fetch never received a response.
// The error is enriched with the original request for diagnostics:
console.log(error.message); // e.g. "Failed to fetch"
console.log(error.request.url); // http://www.example.com/detail/1
console.log(error.response); // null
console.log(error.cause.request); // same as error.request
console.log(error.cause.response); // null
}
}
POST request with JSON body, custom headers, and cookies
When a request has a body that is a plain object or array and uses a method that supports a body (anything other than GET or HEAD), Content-Type: application/json is set automatically and the body is serialized with JSON.stringify. You can override the default by providing your own Content-Type header.
FormData, Blob, string, ArrayBuffer, and other non-plain-object bodies are passed through untouched — no Content-Type is injected and no serialization is performed.
Cookies are sent automatically for same-origin requests. For cross-origin requests set credentials to 'include'. Custom headers such as Authorization or X-Request-ID can be passed via the headers option:
// Plain object — Content-Type: application/json is added automatically
const { response } = await widget.http.request({
method: 'POST',
path: '/items',
headers: {
Authorization: 'Bearer my-access-token',
'X-Request-ID': 'abc-123',
},
body: { name: 'New item', value: 42 },
credentials: 'include', // forward cookies cross-origin
});
console.log(response.status); // 201
console.log(response.body); // { id: 123, name: 'New item', value: 42 }
// FormData — no Content-Type injected; fetch sets it with the correct boundary
const formData = new FormData();
formData.append('file', fileBlob);
const { response: uploadResponse } = await widget.http.request({
method: 'POST',
path: '/upload',
body: formData,
});
Sending query parameters
const { response } = await widget.http.request({
path: '/items',
query: { page: 2, limit: 10, sort: 'name' },
});
console.log(request.url); // http://www.example.com/items?page=2&limit=10&sort=name
Overriding the base URL for a single request
const { response } = await widget.http.request({
baseUrl: 'https://api.other-service.com',
path: '/data',
});
console.log(request.url); // https://api.other-service.com/data
setDefaultConfig
setDefaultConfig(widget, newDefaultConfig)
Merges newDefaultConfig shallowly into the widget's existing default request config. Call this inside bootstrap(widget) to establish widget-wide defaults before any request is made.
| Key | Type | Default | Description |
|---|---|---|---|
method | string | 'GET' | HTTP method |
baseUrl | string | '' | Base URL prepended to path |
path | string | '/' | Path appended to baseUrl |
url | string | — | Full URL; if set, baseUrl and path are ignored |
headers | object | Headers`` | {} | Request headers — plain objects and Headers instances are both accepted |
query | object | {} | Key/value pairs encoded as query string |
body | any | — | Request body; serialized to JSON when Content-Type is application/json (set automatically for body-bearing methods — overridable) |
timeout | number | 15000 | Abort timeout in milliseconds |
transformers | Array | built-ins | Transformer pipeline (see Built-in transformers) |
| (fetch options) | Any option accepted by the native fetch API (e.g. credentials, mode, cache) |
import { setDefaultConfig, getDefaultTransformers } from '@merkur/plugin-http-client';
export const widgetProperties = {
name,
version,
$plugins: [httpClientPlugin],
bootstrap(widget) {
setDefaultConfig(widget, {
baseUrl: 'https://api.example.com',
timeout: 5000,
headers: { 'X-App-Version': version },
transformers: getDefaultTransformers(widget),
});
},
};
getDefaultTransformers
getDefaultTransformers(widget) // returns Array<HttpTransformer>
Returns the four built-in transformer instances in their default execution order:
transformHeaders— normalizesrequest.headersto aHeadersinstancetransformBody— serializes the request body and deserializes the response bodytransformQuery— builds the request URL frombaseUrl+pathand appendsqueryparameterstransformTimeout— sets up anAbortController-based request timeout
Spread the result into a custom transformers array to preserve default behaviour while adding your own transformers:
import { setDefaultConfig, getDefaultTransformers } from '@merkur/plugin-http-client';
setDefaultConfig(widget, {
transformers: [
...getDefaultTransformers(widget),
myAuthTransformer(),
myLoggingTransformer(),
],
});
Omitting getDefaultTransformers (i.e. providing a transformers array that does not include them) means header normalization, URL building, body serialization, and timeouts no longer happen automatically.
Key behaviors
Non-2xx response rejects the promise
When the server replies with a status outside the 2xx range, http.request() rejects with an Error containing:
error.message—"Received <status> status code when requesting <url>"error.request— the fully-transformed request objecterror.response— the response with a parsedbody(JSON or text, depending onContent-Type)error.cause—{ request, response }following the standard Error cause pattern
try {
await widget.http.request({ path: '/resource' });
} catch (error) {
console.log(error.response.status); // e.g. 404
console.log(error.response.body); // parsed response body
console.log(error.request.url); // https://api.example.com/resource
}
Network and timeout errors reject the promise
When fetch itself throws (DNS failure, connection refused, request aborted due to timeout, etc.), http.request() rejects after running all transformError handlers. The thrown error is enriched with:
error.request— the fully-transformed request objecterror.response—nullerror.cause—{ request, response: null }
try {
await widget.http.request({ path: '/resource', timeout: 3000 });
} catch (error) {
console.log(error.message); // e.g. "The operation was aborted"
console.log(error.response); // null
console.log(error.request.url); // https://api.example.com/resource
}
Skipping fetch from a transformer
If any transformRequest hook returns a non-null response, the fetch() call is bypassed entirely and the pipeline continues directly with transformResponse. This is useful for caching, mocking, or returning synthetic responses:
function transformCache(cache) {
return {
async transformRequest(widget, request, response) {
const cached = cache.get(request.url);
if (cached) {
return [request, cached]; // fetch is skipped
}
return [request, response];
},
async transformResponse(widget, request, response) {
cache.set(request.url, response);
return [request, response];
},
};
}
Built-in transformers
transformHeaders
Normalizes request.headers to a Headers instance. Both plain objects and existing Headers instances are accepted as input. After this transformer runs, all subsequent transformers in the pipeline receive request.headers as a Headers instance and should use the Headers API (.get(), .set(), .has()) to read or modify headers.
transformRequest — Converts request.headers to new Headers(request.headers ?? {}).
// Both forms are accepted as input:
await widget.http.request({ headers: { Authorization: 'Bearer token' } });
await widget.http.request({ headers: new Headers({ Authorization: 'Bearer token' }) });
// In a custom transformer placed after transformHeaders, use the Headers API:
function transformAuth(getToken) {
return {
async transformRequest(widget, request, response) {
request.headers.set('Authorization', `Bearer ${getToken()}`);
return [request, response];
},
};
}
transformBody
Handles serialization of the request body and deserialization of the response body.
transformRequest — When body is set and the method is not GET or HEAD:
- If the body is a plain object or array and no
Content-Typeheader is present,Content-Type: application/jsonis added automatically and the body is serialized withJSON.stringify. FormData,Blob,string,ArrayBuffer, and other non-plain-object bodies are passed through untouched — noContent-Typeis injected andJSON.stringifyis never called on them.- If an explicit
Content-Type: application/jsonis present but the body is not a plain object or array (e.g. an already-serialized string), the body is passed through as-is.
The default Content-Type can be overridden by explicitly setting a different value in the request headers.
transformResponse — After a successful fetch, reads the response stream:
application/jsoncontent-type → parsed withresponse.json()- any other content-type → read with
response.text() - HTTP 204 No Content → response is passed through unchanged (no body reading)
// Plain object — serialized automatically:
const { response } = await widget.http.request({
method: 'POST',
path: '/items',
body: { name: 'widget', count: 3 },
});
console.log(response.body); // { id: 7, name: 'widget', count: 3 } — automatically parsed
// FormData — body and headers are untouched; fetch sets Content-Type with boundary:
const formData = new FormData();
formData.append('name', 'widget');
const { response: r } = await widget.http.request({
method: 'POST',
path: '/upload',
body: formData,
});
// Pre-serialized string with explicit Content-Type — not double-stringified:
const { response: r2 } = await widget.http.request({
method: 'POST',
path: '/raw',
headers: { 'Content-Type': 'application/json' },
body: '{"already":"serialized"}',
});
transformQuery
Builds the final request URL and appends query parameters.
transformRequest — Constructs request.url from baseUrl + path (handling trailing and leading slashes). If request.url is already set it is used as-is. Values from the query object are then encodeURIComponent-encoded and appended as a query string. Existing query strings in the URL are preserved.
// URL is built automatically:
const { request } = await widget.http.request({
path: '/search',
query: { q: 'hello world', page: 1 },
});
console.log(request.url); // https://api.example.com/search?q=hello%20world&page=1
// Bypass URL building by providing a full URL:
const { request: r } = await widget.http.request({
url: 'https://cdn.example.com/asset.json',
});
console.log(r.url); // https://cdn.example.com/asset.json
transformTimeout
Enforces a request timeout using the AbortController API.
transformRequest — When timeout is present in the request config, creates an AbortController, attaches controller.signal to the request, and starts a setTimeout that calls controller.abort() after timeout milliseconds.
transformResponse — Clears the timeout timer so it does not fire after a successful response.
// Per-request timeout override:
try {
await widget.http.request({ path: '/slow-endpoint', timeout: 2000 });
} catch (error) {
// AbortError — request was aborted after 2 s
console.log(error.message); // "The operation was aborted"
}
// Disable timeout for a single request:
const { response } = await widget.http.request({
path: '/download',
timeout: undefined,
});
Custom transformers
Transformers are middleware objects that can intercept and modify requests, responses, and errors. Each transformer can implement up to three methods.
transformRequest
Called before the fetch is made. Receives (widget, request, response) and must return [request, response]. If response is returned non-null, the fetch is skipped entirely (see Skipping fetch from a transformer).
// Inject an Authorization header into every request:
// request.headers is a Headers instance after transformHeaders runs (first in the default pipeline).
function transformAuth(getToken) {
return {
async transformRequest(widget, request, response) {
const headers = new Headers(request.headers);
headers.set('Authorization', `Bearer ${getToken()}`);
return [{ ...request, headers }, response];
},
};
}
setDefaultConfig(widget, {
transformers: [...getDefaultTransformers(widget), transformAuth(() => widget.token)],
});
transformResponse
Called after a successful fetch (and after transformBody has parsed the body). Receives (widget, request, response) and must return [request, response].
// Log every response and unwrap a common API envelope:
function transformApiEnvelope() {
return {
async transformResponse(widget, request, response) {
console.log(`[${response.status}] ${request.url}`);
// Unwrap { data: ..., meta: ... } envelope
return [request, { ...response, body: response.body?.data ?? response.body }];
},
};
}
setDefaultConfig(widget, {
transformers: [...getDefaultTransformers(widget), transformApiEnvelope()],
});
transformError
Called when the fetch itself throws (network error, timeout, abort). Receives (widget, error, request) and must return [error, request]. The error is always re-thrown after all transformError handlers run — this hook is intended for side-effects such as cleanup, logging, or notifying other parts of the system. It is not called for non-2xx HTTP responses (those go through transformResponse and are rejected afterwards).
After all transformError handlers run, the thrown error is enriched with:
error.cause—{ request, response: null }following the standard Error cause patternerror.request— the fully-transformed request object (backward compatibility)error.response—null(backward compatibility, mirrors the shape of HTTP-status errors)
function transformErrorLogger() {
return {
async transformError(widget, error, request) {
console.error(`Fetch failed for ${request.url}:`, error.message);
return [error, request];
},
};
}
setDefaultConfig(widget, {
transformers: [...getDefaultTransformers(widget), transformErrorLogger()],
});