@fhir-dsl/runtime
Overview
@fhir-dsl/runtime is the lower-level HTTP layer. FhirExecutor wraps a performRequest-based client with FHIR-specific concerns — cross-origin Authorization stripping on redirects (RFC 6750 §5.3), non-enumerable response metadata, and OperationOutcome-aware error construction. paginate() walks the Bundle.link[rel="next"] chain with cycle detection, and unwrapBundle() splits a searchset bundle into { data, included }.
Installation
npm install @fhir-dsl/runtime
Exports
| Name | Kind | One-liner |
|---|---|---|
FhirExecutor | class | Wraps fetch with FHIR error parsing, auth-strip on cross-origin redirect, and non-enumerable metadata. |
ExecuteRequestOptions | interface | { signal?: AbortSignal } — forwarded to fetch. |
FhirClientConfig | interface | Runtime-level client config (subset of the core config). |
paginate | function | Async generator yielding pages of resources from a starting Bundle. |
fetchAllPages | function | Drains paginate() into an array. |
unwrapBundle | function | Splits a searchset Bundle into { data, included, total, hasNext, nextUrl, raw }. |
SearchResult | interface | Return shape of unwrapBundle. |
FhirError | class | Error with status, statusText, operationOutcome, and responseText fallback. |
OperationOutcome / OperationOutcomeIssue | interface | FHIR outcome shape re-exported for convenience. |
API
FhirExecutor
Signature
class FhirExecutor {
constructor(config: FhirClientConfig);
execute<T = unknown>(query: CompiledQuery, options?: ExecuteRequestOptions): Promise<T>;
executeUrl<T = unknown>(url: string, options?: ExecuteRequestOptions): Promise<T>;
}
interface ExecuteRequestOptions {
signal?: AbortSignal;
}
interface FhirClientConfig {
baseUrl: string;
auth?: AuthConfig;
headers?: Record<string, string>;
fetch?: typeof globalThis.fetch;
retry?: RetryConfig;
}
Parameters
config.baseUrl— used as the origin forexecute()and as the trust boundary forexecuteUrl()(auth is stripped for any URL whose origin differs).config.auth— static creds or a pluggableAuthProvider.config.retry— transient-failure retries (429/503) honouringRetry-After.
Returns — Parsed JSON body, with response headers attached as three non-enumerable properties (headers, location, etag) so JSON.stringify(result) stays clean.
Example
import { FhirExecutor } from "@fhir-dsl/runtime";
const exec = new FhirExecutor({ baseUrl: "https://fhir.example/r4" });
const bundle = await exec.execute({ method: "GET", path: "Patient", params: [] });
const next = await exec.executeUrl(bundle.link?.find((l) => l.relation === "next")?.url ?? "");
Notes
- Cross-origin Authorization strip (RFC 6750 §5.3). When
executeUrl()is called with a URL whose origin differs frombaseUrl, the auth provider and any pre-setAuthorizationheader are dropped before the request. This prevents a server-controllednextlink from exfiltrating bearer tokens to a third-party host. - Non-enumerable metadata. Parsed resources get
Object.defineProperty(value, "headers", { enumerable: false, ... }), withLocation,ETag, andLast-Modifiedsurfaced when present.locationandetagare also attached directly as non-enumerable properties. Prefer: respond-async+ 202 polling. The higher-levelFhirClientin@fhir-dsl/corepollsContent-Locationon 202 responses;FhirExecutoritself does not poll — if you need async semantics, use the core client withasync: AsyncPollingConfig.- Auto-POST upgrade.
SearchQueryBuilder.usePost()forces POST; the builder also auto-upgrades GET to POST when the URL exceeds the default1900-byte threshold (DEFAULT_AUTO_POST_THRESHOLD) — override per-builder via.getUrlByteLimit(n).
paginate / fetchAllPages
Signature
function paginate<T extends Resource>(
executor: FhirExecutor,
firstBundle: Bundle,
options?: ExecuteRequestOptions,
): AsyncGenerator<T[], void, undefined>;
function fetchAllPages<T extends Resource>(
executor: FhirExecutor,
firstBundle: Bundle,
options?: ExecuteRequestOptions,
): Promise<T[]>;
Parameters
executor— theFhirExecutorto follownextlinks with.firstBundle— the initialBundle(usually the result of anexecute()).options.signal— abort signal propagated to every follow-upfetch.
Returns — paginate yields each page's resources as a T[]; fetchAllPages returns a single flat array.
Example
const first = await exec.execute<Bundle>({ method: "GET", path: "Patient", params: [{ name: "_count", value: 50 }] });
for await (const page of paginate<Patient>(exec, first)) {
console.log(`got ${page.length} patients`);
}
Notes — Visited next URLs are tracked in a Set; if the server produces a cycle, the loop terminates instead of issuing unbounded requests. paginate also peeks one link ahead and aborts early if the fetched bundle's own next points back into the visited set.
unwrapBundle
Signature
function unwrapBundle<Primary extends Resource, Included extends Resource = never>(
bundle: Bundle,
): SearchResult<Primary, Included>;
interface SearchResult<Primary extends Resource, Included extends Resource = never> {
data: Primary[];
included: [Included] extends [never] ? [] : Included[];
total?: number;
hasNext: boolean;
nextUrl?: string;
raw: Bundle;
}
Parameters
bundle— a FHIRBundleof typesearchset. Entries withsearch.mode === "include"route toincluded.
Example
import { unwrapBundle } from "@fhir-dsl/runtime";
const { data, included, hasNext, nextUrl } = unwrapBundle<Patient, Organization>(bundle);
FhirError, OperationOutcome, OperationOutcomeIssue
Signature
class FhirError extends Error {
readonly status: number;
readonly statusText: string;
readonly operationOutcome?: OperationOutcome | null;
readonly responseText?: string; // raw body when it wasn't JSON
readonly issues: OperationOutcomeIssue[];
}
interface OperationOutcome { resourceType: "OperationOutcome"; issue: OperationOutcomeIssue[]; }
interface OperationOutcomeIssue {
severity: "fatal" | "error" | "warning" | "information";
code: string;
details?: { text?: string };
diagnostics?: string;
location?: string[];
expression?: string[];
}
Example
try {
await exec.execute({ method: "GET", path: "Patient/does-not-exist", params: [] });
} catch (err) {
if (err instanceof FhirError) {
console.error(err.status, err.issues[0]?.diagnostics, err.responseText);
}
}
Notes — When the server returns a non-JSON body (HTML error page, text/plain from an auth proxy), operationOutcome stays null and responseText carries the raw text so diagnostics aren't lost.