Skip to main content

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.

KeyTypeDefaultDescription
methodstring'GET'HTTP method
baseUrlstring''Base URL prepended to path
pathstring'/'Path appended to baseUrl
urlstring—Full URL; if set, baseUrl and path are ignored
headersobject | Headers``{}Request headers — plain objects and Headers instances are both accepted
queryobject{}Key/value pairs encoded as query string
bodyany—Request body; serialized to JSON when Content-Type is application/json (set automatically for body-bearing methods — overridable)
timeoutnumber15000Abort timeout in milliseconds
transformersArraybuilt-insTransformer 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:

  1. transformHeaders — normalizes request.headers to a Headers instance
  2. transformBody — serializes the request body and deserializes the response body
  3. transformQuery — builds the request URL from baseUrl + path and appends query parameters
  4. transformTimeout — sets up an AbortController-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 object
  • error.response — the response with a parsed body (JSON or text, depending on Content-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 object
  • error.response — null
  • error.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-Type header is present, Content-Type: application/json is added automatically and the body is serialized with JSON.stringify.
  • FormData, Blob, string, ArrayBuffer, and other non-plain-object bodies are passed through untouched — no Content-Type is injected and JSON.stringify is never called on them.
  • If an explicit Content-Type: application/json is 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/json content-type → parsed with response.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 pattern
  • error.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()],
});