fhir-kit-client
Version:
173 lines • 6.27 kB
JavaScript
import HttpAgent from 'agentkeepalive';
import { logRequestError, logRequestInfo, logResponseInfo } from './logging.js';
import { REQUEST_KEY, RESPONSE_KEY } from './types.js';
const { HttpsAgent } = HttpAgent;
const defaultHeaders = { accept: 'application/fhir+json' };
/**
* Ensures the signal is a native AbortSignal.
*
* On Node 24+, undici's Request constructor enforces a strict
* `instanceof AbortSignal` check. Polyfill libraries such as
* `node-abort-controller` create their own AbortSignal class that is NOT
* the native one, causing a TypeError at request-build time. We bridge any
* foreign signal to a native AbortController so undici is always satisfied.
*
* See: https://github.com/Vermonster/fhir-kit-client/issues/204
*/
function normalizeSignal(signal) {
if (signal instanceof AbortSignal)
return signal;
const controller = new AbortController();
if (signal.aborted) {
controller.abort(signal.reason);
}
else {
signal.addEventListener('abort', () => controller.abort(signal.reason), { once: true });
}
return controller.signal;
}
function stringifyBody(body) {
if (body === undefined)
return undefined;
if (typeof body === 'string')
return body;
return JSON.stringify(body);
}
function lcKeys(obj) {
if (!obj)
return {};
return Object.fromEntries(Object.entries(obj).map(([k, v]) => [k.toLowerCase(), v]));
}
function buildResponseError(config) {
const error = Object.assign(new Error(`HTTP ${config.status}: ${config.method} ${config.url}`), {
response: { status: config.status, data: config.data },
config,
});
logRequestError(error);
return error;
}
/**
* Internal HTTP client. Use Client methods instead of this class directly.
*/
export class HttpClient {
baseUrlValue;
customHeaders;
baseRequestOptions;
requestSigner;
authHeader = {};
/** Keepalive agents keyed by base URL, reused across requests on this instance. */
agentCache = new Map();
constructor({ baseUrl, customHeaders = {}, requestOptions = {}, requestSigner, }) {
this.baseUrlValue = '';
this.baseUrl = baseUrl;
this.customHeaders = customHeaders;
this.baseRequestOptions = requestOptions;
this.requestSigner = requestSigner;
}
set baseUrl(url) {
if (!url)
throw new Error('baseUrl cannot be blank');
if (typeof url !== 'string')
throw new Error('baseUrl must be a string');
this.baseUrlValue = url;
}
get baseUrl() {
return this.baseUrlValue;
}
set bearerToken(token) {
this.authHeader = { authorization: `Bearer ${token}` };
}
static responseFor(requestResponse) {
return requestResponse[RESPONSE_KEY];
}
static requestFor(requestResponse) {
return requestResponse[REQUEST_KEY];
}
mergeHeaders(requestHeaders) {
return {
...lcKeys(defaultHeaders),
...lcKeys(this.authHeader),
...lcKeys(this.customHeaders),
...lcKeys(requestHeaders),
};
}
buildAgent() {
const cached = this.agentCache.get(this.baseUrl);
if (cached)
return cached;
const agent = this.baseUrl.startsWith('https')
? { agent: new HttpsAgent() }
: { agent: new HttpAgent() };
this.agentCache.set(this.baseUrl, agent);
return agent;
}
expandUrl(url = '') {
if (url.toLowerCase().startsWith('http'))
return url;
if (this.baseUrl.endsWith('/') && url.startsWith('/'))
return this.baseUrl + url.slice(1);
if (this.baseUrl.endsWith('/') || url.startsWith('/'))
return this.baseUrl + url;
return `${this.baseUrl}/${url}`;
}
buildRequest(method, url, options, body) {
const requestInit = {
...this.baseRequestOptions,
method,
body: stringifyBody(body),
headers: new Headers(this.mergeHeaders(options.headers)),
keepalive: true,
...this.buildAgent(),
};
if (options.signal)
requestInit.signal = normalizeSignal(options.signal);
if (this.requestSigner) {
this.requestSigner(url, requestInit);
}
return new Request(url, requestInit);
}
async request(method, requestUrl, options = {}, body) {
const url = this.expandUrl(requestUrl);
const req = this.buildRequest(method, url, options, body);
logRequestInfo(method, url, req.headers);
const response = await fetch(req);
const { status } = response;
logResponseInfo({ status });
const bodyText = await response.text();
let data = {};
if (bodyText) {
try {
data = JSON.parse(bodyText);
}
catch {
const err = buildResponseError({ status, data: bodyText, method, headers: response.headers, url });
throw err;
}
}
if (!response.ok) {
throw buildResponseError({ status, data, method, headers: response.headers, url });
}
const result = data;
Object.defineProperty(result, RESPONSE_KEY, { writable: false, enumerable: false, value: response });
Object.defineProperty(result, REQUEST_KEY, { writable: false, enumerable: false, value: req });
return result;
}
async get(url, options) {
return this.request('GET', url, options);
}
async delete(url, options) {
return this.request('DELETE', url, options);
}
async put(url, body, options = {}) {
const headers = { 'content-type': 'application/fhir+json', ...lcKeys(options.headers) };
return this.request('PUT', url, { ...options, headers }, body);
}
async post(url, body, options = {}) {
const headers = { 'content-type': 'application/fhir+json', ...lcKeys(options.headers) };
return this.request('POST', url, { ...options, headers }, body);
}
async patch(url, body, options = {}) {
return this.request('PATCH', url, options, body);
}
}
//# sourceMappingURL=http-client.js.map