babelfhir-ts 1.0.35 → 1.0.37

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -23,9 +23,30 @@
23
23
  - **Install any FHIR profile as a node module**—use `babelfhir-ts install` to add Implementation Guides directly to your project
24
24
 
25
25
  <!-- PARITY-BADGES:START - Do not remove or modify this section -->
26
- ## Pipeline Parity Results
26
+ ## Continuous Validation
27
27
 
28
- BabelFHIR-TS validators achieve **100% parity** with the Firely .NET SDK validator across major Implementation Guides:
28
+ Every pull request runs two independent CI pipelines that validate generated code against real-world FHIR Implementation Guides. For each IG, the pipeline:
29
+
30
+ 1. Downloads the FHIR package from a registry
31
+ 2. Generates TypeScript interfaces, validators, and classes
32
+ 3. Compiles the output with `tsc` (zero errors required)
33
+ 4. Generates `empty()` and `random()` test resources for every profile
34
+ 5. Validates those resources against two external FHIR validators
35
+
36
+ ### Tested Implementation Guides
37
+
38
+ | Implementation Guide | Package | Profiles |
39
+ |---|---|---|
40
+ | US Core | `hl7.fhir.us.core@8.0.0` | Patient, Condition, Observation, Encounter, … |
41
+ | ISiK Basis (Germany) | `de.gematik.isik-basismodul@4.0.3` | ISiKPatient, ISiKDiagnose, … |
42
+ | IPS (International Patient Summary) | `hl7.fhir.uv.ips@2.0.0` | Composition, MedicationStatement, … |
43
+ | SMART App Launch | `hl7.fhir.uv.smart-app-launch@2.2.0` | WellKnown endpoints |
44
+ | CH Core (Switzerland) | `ch.fhir.ig.ch-core@5.0.0` | CHCorePatient, CHCoreEncounter, … |
45
+ | DaVinci PAS | `hl7.fhir.us.davinci-pas@2.0.1` | PASClaim, PASClaimResponse, … |
46
+
47
+ ### Validation with Firely .NET SDK
48
+
49
+ The first pipeline validates generated resources using the [Firely .NET SDK validator](https://docs.fire.ly/projects/Firely-NET-SDK/) (v3.0.1). Results are published as live badges:
29
50
 
30
51
  ![US Core 8.0.0](https://img.shields.io/endpoint?url=https://gist.githubusercontent.com/quotentiroler/e9bc625cfe88a0a67a73abc06660f40f/raw/badge-us-core.json)
31
52
  ![ISiK Basis 4.0.3](https://img.shields.io/endpoint?url=https://gist.githubusercontent.com/quotentiroler/e9bc625cfe88a0a67a73abc06660f40f/raw/badge-isik-basis.json)
@@ -33,14 +54,11 @@ BabelFHIR-TS validators achieve **100% parity** with the Firely .NET SDK validat
33
54
  ![SMART 2.2.0](https://img.shields.io/endpoint?url=https://gist.githubusercontent.com/quotentiroler/e9bc625cfe88a0a67a73abc06660f40f/raw/badge-smart.json)
34
55
  ![CH Core 5.0.0](https://img.shields.io/endpoint?url=https://gist.githubusercontent.com/quotentiroler/e9bc625cfe88a0a67a73abc06660f40f/raw/badge-ch-core.json)
35
56
 
36
- > **11 profiles excluded** due to [Firely SDK bugs](./docs/FIRELY-VALIDATOR-BUGS.md) — validated successfully with HL7 Java Validator.
37
-
38
- 📊 **[Full Report](https://gist.github.com/quotentiroler/e9bc625cfe88a0a67a73abc06660f40f)** · *Last updated: Dec 10, 2025*
57
+ > 11 profiles are excluded from this pipeline due to schema loading issues in the Firely SDK. These profiles validate successfully with the HL7 Java Validator below. Details in [docs/FIRELY-VALIDATOR-BUGS.md](./docs/FIRELY-VALIDATOR-BUGS.md).
39
58
 
40
- <!-- HL7-PARITY-BADGES:START - Do not remove or modify this section -->
41
- ### HL7 Java Validator Parity
59
+ ### Validation with HL7 Java Validator
42
60
 
43
- Generated `random()` and `empty()` methods also pass structural validation with the [official HL7 FHIR Validator](https://confluence.hl7.org/display/FHIR/Using+the+FHIR+Validator):
61
+ The second pipeline validates using the [official HL7 FHIR Validator](https://confluence.hl7.org/display/FHIR/Using+the+FHIR+Validator) (v6.3.11), the reference implementation for FHIR conformance checking:
44
62
 
45
63
  ![US Core 8.0.0](https://img.shields.io/endpoint?url=https://gist.githubusercontent.com/quotentiroler/e9bc625cfe88a0a67a73abc06660f40f/raw/badge-hl7-us-core.json)
46
64
  ![ISiK Basis 4.0.3](https://img.shields.io/endpoint?url=https://gist.githubusercontent.com/quotentiroler/e9bc625cfe88a0a67a73abc06660f40f/raw/badge-hl7-isik-basis.json)
@@ -48,9 +66,9 @@ Generated `random()` and `empty()` methods also pass structural validation with
48
66
  ![SMART 2.2.0](https://img.shields.io/endpoint?url=https://gist.githubusercontent.com/quotentiroler/e9bc625cfe88a0a67a73abc06660f40f/raw/badge-hl7-smart.json)
49
67
  ![CH Core 5.0.0](https://img.shields.io/endpoint?url=https://gist.githubusercontent.com/quotentiroler/e9bc625cfe88a0a67a73abc06660f40f/raw/badge-hl7-ch-core.json)
50
68
 
51
- > **Note:** Terminology validation (codes bound to ValueSets) requires a terminology server. The HL7 validator validates terminology bindings against `tx.fhir.org` by default, which may report errors for generated placeholder codes. Use `--tx-server https://tx.fhir.org/r4` during generation to expand ValueSets and use real codes.
69
+ > Terminology validation requires a tx server. The pipeline uses `--tx-server https://tx.fhir.org/r4` during generation to expand ValueSets and produce valid codes.
52
70
 
53
- 📊 **[Full Report](https://gist.github.com/quotentiroler/e9bc625cfe88a0a67a73abc06660f40f)** · *Last updated: Jan 24, 2026*
71
+ 📊 **[Full Report & Badge Data](https://gist.github.com/quotentiroler/e9bc625cfe88a0a67a73abc06660f40f)**
54
72
  <!-- HL7-PARITY-BADGES:END -->
55
73
  <!-- PARITY-BADGES:END -->
56
74
 
@@ -233,7 +251,7 @@ For comprehensive conformance testing, use the official [HL7 FHIR Validator](htt
233
251
  - Reference integrity constraints
234
252
 
235
253
  Use `random()` for development, testing, and prototyping, but always validate production data on the FHIR server side.
236
- - **`validate()` parity**: The generated validation methods execute FHIRPath expressions and check constraints from StructureDefinitions, but are **only guaranteed to match** the Firely .NET SDK validator for scenarios covered by our test suite (see GitHub actions). Edge cases, complex slicing patterns, or profiles not in our test pipeline may produce different results. For production conformance testing, use the official [HL7 FHIR Validator](https://confluence.hl7.org/display/FHIR/Using+the+FHIR+Validator) as the source of truth.
254
+ - **`validate()` coverage**: The generated validation methods execute FHIRPath expressions and check constraints from StructureDefinitions. They are tested against the HL7 Java Validator and the Firely .NET SDK for the Implementation Guides listed in the [Continuous Validation](#continuous-validation) section. Edge cases, complex slicing patterns, or profiles not in the CI pipeline may produce different results. For production conformance testing, use the official [HL7 FHIR Validator](https://confluence.hl7.org/display/FHIR/Using+the+FHIR+Validator) as the source of truth.
237
255
 
238
256
  ### FHIR Version Support
239
257
 
@@ -242,7 +260,7 @@ For comprehensive conformance testing, use the official [HL7 FHIR Validator](htt
242
260
 
243
261
  ### Reporting Issues
244
262
 
245
- If you encounter an Implementation Guide that doesn't generate correctly, please [open an issue](https://github.com/quotentiroler/BabelFHIR-ts/issues) with:
263
+ If you encounter an Implementation Guide that doesn't generate correctly, please [open an issue](https://github.com/Max-Health-Inc/BabelFHIR-TS/issues) with:
246
264
 
247
265
  - The package name and version
248
266
  - The specific StructureDefinition URL
@@ -266,6 +284,6 @@ For security issues, please see [SECURITY.md](SECURITY.md) for our security poli
266
284
  ## Links
267
285
 
268
286
  - [npm package](https://www.npmjs.com/package/babelfhir-ts)
269
- - [GitHub repository](https://github.com/quotentiroler/BabelFHIR-ts)
270
- - [Issue tracker](https://github.com/quotentiroler/BabelFHIR-ts/issues)
287
+ - [GitHub repository](https://github.com/Max-Health-Inc/BabelFHIR-TS)
288
+ - [Issue tracker](https://github.com/Max-Health-Inc/BabelFHIR-TS/issues)
271
289
  - [Changelog](CHANGELOG.md)
@@ -28,9 +28,11 @@ export function generateClient(options) {
28
28
  generateResourceWriter(clientDir, resourceTypes);
29
29
  generateBundleParser(clientDir, resourceTypes);
30
30
  generateFhirClient(clientDir, resourceTypes);
31
+ generateSmartAuth(clientDir);
32
+ generateSmartClient(clientDir, resourceTypes);
31
33
  generateIndex(clientDir, resourceTypes);
32
34
  generateReadme(clientDir);
33
- log.success(`Generated FHIR client with ${resourceTypes.length} resource types`);
35
+ log.success(`Generated FHIR client with ${resourceTypes.length} resource types + SMART auth`);
34
36
  }
35
37
  /**
36
38
  * Generate types.ts
@@ -120,18 +122,28 @@ export interface FhirResourceSearcher<T> {
120
122
  */
121
123
  ${readerTypes}
122
124
 
125
+ /**
126
+ * Custom fetch function type for injecting auth headers
127
+ */
128
+ export type FetchFn = typeof globalThis.fetch;
129
+
123
130
  /**
124
131
  * Implementation of FHIR resource reader
125
132
  */
126
133
  export class FhirResourceReader<T> implements FhirResourceSearcher<T> {
134
+ private readonly fetchFn: FetchFn;
135
+
127
136
  constructor(
128
137
  public readonly baseUrl: string,
129
138
  public readonly resourceType: string,
130
- ) {}
139
+ fetchFn?: FetchFn,
140
+ ) {
141
+ this.fetchFn = fetchFn ?? globalThis.fetch.bind(globalThis);
142
+ }
131
143
 
132
144
  async read(id: string): Promise<WithId<T>> {
133
145
  const url = \`\${this.baseUrl}/\${this.resourceType}/\${id}\`;
134
- const response = await fetch(url, {
146
+ const response = await this.fetchFn(url, {
135
147
  headers: {
136
148
  Accept: "application/fhir+json",
137
149
  },
@@ -163,7 +175,7 @@ export class FhirResourceReader<T> implements FhirResourceSearcher<T> {
163
175
  }
164
176
  }
165
177
 
166
- const response = await fetch(url.toString(), {
178
+ const response = await this.fetchFn(url.toString(), {
167
179
  headers: {
168
180
  Accept: "application/fhir+json",
169
181
  },
@@ -197,7 +209,7 @@ export class FhirResourceReader<T> implements FhirResourceSearcher<T> {
197
209
 
198
210
  if (!nextLink) break;
199
211
 
200
- const response = await fetch(nextLink, {
212
+ const response = await this.fetchFn(nextLink, {
201
213
  headers: {
202
214
  Accept: "application/fhir+json",
203
215
  },
@@ -258,18 +270,25 @@ export interface FhirResourceWriter<T> {
258
270
  */
259
271
  ${writerTypes}
260
272
 
273
+ import type { FetchFn } from "./resource-reader.js";
274
+
261
275
  /**
262
276
  * Implementation of FHIR resource writer
263
277
  */
264
278
  export class FhirResourceWriterImpl<T> implements FhirResourceWriter<T> {
279
+ private readonly fetchFn: FetchFn;
280
+
265
281
  constructor(
266
282
  public readonly baseUrl: string,
267
283
  public readonly resourceType: string,
268
- ) {}
284
+ fetchFn?: FetchFn,
285
+ ) {
286
+ this.fetchFn = fetchFn ?? globalThis.fetch.bind(globalThis);
287
+ }
269
288
 
270
289
  async create(resource: T): Promise<WithId<T>> {
271
290
  const url = \`\${this.baseUrl}/\${this.resourceType}\`;
272
- const response = await fetch(url, {
291
+ const response = await this.fetchFn(url, {
273
292
  method: "POST",
274
293
  headers: {
275
294
  "Content-Type": "application/fhir+json",
@@ -289,7 +308,7 @@ export class FhirResourceWriterImpl<T> implements FhirResourceWriter<T> {
289
308
 
290
309
  async update(resource: WithId<T>): Promise<WithId<T>> {
291
310
  const url = \`\${this.baseUrl}/\${this.resourceType}/\${resource.id}\`;
292
- const response = await fetch(url, {
311
+ const response = await this.fetchFn(url, {
293
312
  method: "PUT",
294
313
  headers: {
295
314
  "Content-Type": "application/fhir+json",
@@ -309,7 +328,7 @@ export class FhirResourceWriterImpl<T> implements FhirResourceWriter<T> {
309
328
 
310
329
  async delete(id: string): Promise<void> {
311
330
  const url = \`\${this.baseUrl}/\${this.resourceType}/\${id}\`;
312
- const response = await fetch(url, {
331
+ const response = await this.fetchFn(url, {
313
332
  method: "DELETE",
314
333
  });
315
334
 
@@ -516,7 +535,7 @@ function generateFhirClient(clientDir, resourceTypes) {
516
535
  .map((rt) => {
517
536
  const methodName = rt.profileName.charAt(0).toLowerCase() + rt.profileName.slice(1);
518
537
  return ` ${methodName}(): ${rt.profileName}Reader {
519
- return new FhirResourceReader(this.baseUrl, "${rt.baseResourceType}");
538
+ return new FhirResourceReader(this.baseUrl, "${rt.baseResourceType}", this.fetchFn);
520
539
  }`;
521
540
  })
522
541
  .join("\n\n");
@@ -524,13 +543,14 @@ function generateFhirClient(clientDir, resourceTypes) {
524
543
  .map((rt) => {
525
544
  const methodName = rt.profileName.charAt(0).toLowerCase() + rt.profileName.slice(1);
526
545
  return ` ${methodName}(): ${rt.profileName}Writer {
527
- return new FhirResourceWriterImpl(this.baseUrl, "${rt.baseResourceType}");
546
+ return new FhirResourceWriterImpl(this.baseUrl, "${rt.baseResourceType}", this.fetchFn);
528
547
  }`;
529
548
  })
530
549
  .join("\n\n");
531
550
  const content = `import {
532
551
  ${readerImports}
533
552
  FhirResourceReader,
553
+ type FetchFn,
534
554
  } from "./resource-reader.js";
535
555
  import {
536
556
  ${writerImports}
@@ -541,7 +561,10 @@ ${writerImports}
541
561
  * FHIR Client for reading resources
542
562
  */
543
563
  export class FhirReadClient {
544
- constructor(private readonly baseUrl: string) {}
564
+ constructor(
565
+ private readonly baseUrl: string,
566
+ private readonly fetchFn?: FetchFn,
567
+ ) {}
545
568
 
546
569
  ${readerMethods}
547
570
  }
@@ -550,21 +573,34 @@ ${readerMethods}
550
573
  * FHIR Client for writing resources
551
574
  */
552
575
  export class FhirWriteClient {
553
- constructor(private readonly baseUrl: string) {}
576
+ constructor(
577
+ private readonly baseUrl: string,
578
+ private readonly fetchFn?: FetchFn,
579
+ ) {}
554
580
 
555
581
  ${writerMethods}
556
582
  }
557
583
 
558
584
  /**
559
- * Main FHIR Client
585
+ * Main FHIR Client — works with plain fetch or an authenticated fetch wrapper.
586
+ *
587
+ * @example
588
+ * // Unauthenticated
589
+ * const client = new FhirClient("https://fhir.example.com");
590
+ *
591
+ * // With custom fetch (e.g. from SmartFhirClient)
592
+ * const client = new FhirClient("https://fhir.example.com", authenticatedFetch);
560
593
  */
561
594
  export class FhirClient {
562
595
  private readonly readClient: FhirReadClient;
563
596
  private readonly writeClient: FhirWriteClient;
564
597
 
565
- constructor(public readonly baseUrl: string) {
566
- this.readClient = new FhirReadClient(baseUrl);
567
- this.writeClient = new FhirWriteClient(baseUrl);
598
+ constructor(
599
+ public readonly baseUrl: string,
600
+ fetchFn?: FetchFn,
601
+ ) {
602
+ this.readClient = new FhirReadClient(baseUrl, fetchFn);
603
+ this.writeClient = new FhirWriteClient(baseUrl, fetchFn);
568
604
  }
569
605
 
570
606
  /**
@@ -584,6 +620,604 @@ export class FhirClient {
584
620
  `;
585
621
  fs.writeFileSync(path.join(clientDir, "fhir-client.ts"), content);
586
622
  }
623
+ /**
624
+ * Generate smart-auth.ts — PKCE, discovery, token management (static, IG-agnostic)
625
+ */
626
+ function generateSmartAuth(clientDir) {
627
+ const content = `/**
628
+ * SMART on FHIR Authorization Module
629
+ *
630
+ * Implements SMART App Launch Framework (v2) with PKCE.
631
+ * Works in any browser environment — no framework dependencies.
632
+ *
633
+ * @see https://hl7.org/fhir/smart-app-launch/
634
+ */
635
+
636
+ // ── Types ────────────────────────────────────────────────────────────────────
637
+
638
+ export interface SmartConfig {
639
+ /** OAuth2 client_id registered with the auth server */
640
+ clientId: string;
641
+ /** Redirect URI registered with the auth server */
642
+ redirectUri: string;
643
+ /** FHIR server base URL (used as \`aud\` param) */
644
+ fhirBaseUrl: string;
645
+ /** Space-separated OAuth2 scopes */
646
+ scopes: string;
647
+ /** Optional storage backend (defaults to sessionStorage) */
648
+ storage?: Storage;
649
+ /** Optional prefix for storage keys (defaults to "smart_") */
650
+ storagePrefix?: string;
651
+ /** Where to redirect after IdP logout (defaults to current origin) */
652
+ postLogoutRedirectUri?: string;
653
+ }
654
+
655
+ export interface SmartToken {
656
+ access_token: string;
657
+ token_type: string;
658
+ expires_in: number;
659
+ scope: string;
660
+ id_token?: string;
661
+ refresh_token?: string;
662
+ /** Extracted from id_token or token response */
663
+ fhirUser?: string;
664
+ /** Patient context from EHR launch */
665
+ patient?: string;
666
+ /** Encounter context from EHR launch */
667
+ encounter?: string;
668
+ }
669
+
670
+ export interface SmartConfiguration {
671
+ authorization_endpoint: string;
672
+ token_endpoint: string;
673
+ userinfo_endpoint?: string;
674
+ introspection_endpoint?: string;
675
+ revocation_endpoint?: string;
676
+ end_session_endpoint?: string;
677
+ capabilities?: string[];
678
+ }
679
+
680
+ export type LaunchMode = "standalone" | "ehr";
681
+
682
+ // ── PKCE helpers ─────────────────────────────────────────────────────────────
683
+
684
+ function generateRandomString(length: number): string {
685
+ const array = new Uint8Array(length);
686
+ crypto.getRandomValues(array);
687
+ return Array.from(array, (b) => b.toString(36).padStart(2, "0"))
688
+ .join("")
689
+ .slice(0, length);
690
+ }
691
+
692
+ async function sha256(plain: string): Promise<ArrayBuffer> {
693
+ const encoder = new TextEncoder();
694
+ return crypto.subtle.digest("SHA-256", encoder.encode(plain));
695
+ }
696
+
697
+ function base64UrlEncode(buffer: ArrayBuffer): string {
698
+ const bytes = new Uint8Array(buffer);
699
+ let binary = "";
700
+ for (const byte of bytes) binary += String.fromCharCode(byte);
701
+ return btoa(binary).replace(/\\+/g, "-").replace(/\\//g, "_").replace(/=+$/, "");
702
+ }
703
+
704
+ // ── Storage keys ─────────────────────────────────────────────────────────────
705
+
706
+ function storageKeys(prefix: string) {
707
+ return {
708
+ token: \`\${prefix}token\`,
709
+ verifier: \`\${prefix}code_verifier\`,
710
+ state: \`\${prefix}state\`,
711
+ expiresAt: \`\${prefix}expires_at\`,
712
+ launchMode: \`\${prefix}launch_mode\`,
713
+ } as const;
714
+ }
715
+
716
+ // ── Discovery ────────────────────────────────────────────────────────────────
717
+
718
+ /**
719
+ * Discover SMART authorization endpoints from a FHIR server.
720
+ * Tries \`.well-known/smart-configuration\` first, falls back to CapabilityStatement.
721
+ */
722
+ export async function discoverEndpoints(fhirBaseUrl: string): Promise<SmartConfiguration> {
723
+ const smartUrl = \`\${fhirBaseUrl}/.well-known/smart-configuration\`;
724
+ try {
725
+ const res = await fetch(smartUrl);
726
+ if (res.ok) return (await res.json()) as SmartConfiguration;
727
+ } catch {
728
+ // fallthrough to CapabilityStatement
729
+ }
730
+
731
+ const metaUrl = \`\${fhirBaseUrl}/metadata\`;
732
+ const res = await fetch(metaUrl, {
733
+ headers: { Accept: "application/fhir+json" },
734
+ });
735
+ if (!res.ok) {
736
+ throw new Error(\`Failed to discover SMART endpoints from \${metaUrl}\`);
737
+ }
738
+
739
+ const meta = await res.json();
740
+ const security = meta.rest?.[0]?.security;
741
+ const oauthExt = security?.extension?.find(
742
+ (e: { url: string }) =>
743
+ e.url === "http://fhir-registry.smarthealthit.org/StructureDefinition/oauth-uris",
744
+ );
745
+ const findExt = (url: string): string | undefined =>
746
+ oauthExt?.extension?.find((e: { url: string }) => e.url === url)?.valueUri;
747
+
748
+ const authorization_endpoint = findExt("authorize");
749
+ const token_endpoint = findExt("token");
750
+ if (!authorization_endpoint || !token_endpoint) {
751
+ throw new Error(
752
+ "Could not find SMART authorization/token endpoints in CapabilityStatement",
753
+ );
754
+ }
755
+ return { authorization_endpoint, token_endpoint };
756
+ }
757
+
758
+ // ── SmartAuth class ──────────────────────────────────────────────────────────
759
+
760
+ /**
761
+ * Manages the SMART on FHIR authorization lifecycle.
762
+ *
763
+ * @example
764
+ * const auth = new SmartAuth({
765
+ * clientId: "my-app",
766
+ * redirectUri: "http://localhost:3000/callback",
767
+ * fhirBaseUrl: "https://fhir.example.com/fhir",
768
+ * scopes: "openid fhirUser patient/*.read",
769
+ * });
770
+ *
771
+ * // Check if this is a callback
772
+ * if (auth.isCallback()) {
773
+ * const token = await auth.handleCallback();
774
+ * } else {
775
+ * await auth.authorize(); // redirects to auth server
776
+ * }
777
+ *
778
+ * // Create authenticated fetch
779
+ * const authFetch = auth.createAuthenticatedFetch();
780
+ */
781
+ export class SmartAuth {
782
+ private readonly storage: Storage;
783
+ private readonly keys: ReturnType<typeof storageKeys>;
784
+
785
+ constructor(public readonly config: SmartConfig) {
786
+ this.storage = config.storage ?? sessionStorage;
787
+ this.keys = storageKeys(config.storagePrefix ?? "smart_");
788
+ }
789
+
790
+ // ── Launch ───────────────────────────────────────────────────────────
791
+
792
+ /** Detect launch mode from current URL parameters */
793
+ detectLaunchMode(): LaunchMode {
794
+ const params = new URLSearchParams(window.location.search);
795
+ return params.has("launch") && params.has("iss") ? "ehr" : "standalone";
796
+ }
797
+
798
+ /** Start standalone SMART launch (redirects the browser) */
799
+ async authorize(): Promise<void> {
800
+ const mode = this.detectLaunchMode();
801
+ if (mode === "ehr") {
802
+ const params = new URLSearchParams(window.location.search);
803
+ await this.startEhrLaunch(
804
+ params.get("launch")!,
805
+ params.get("iss")!,
806
+ );
807
+ } else {
808
+ await this.startStandaloneLaunch();
809
+ }
810
+ }
811
+
812
+ /** Start standalone SMART launch */
813
+ async startStandaloneLaunch(): Promise<void> {
814
+ const endpoints = await discoverEndpoints(this.config.fhirBaseUrl);
815
+ const { codeChallenge, codeVerifier, state } = await this.preparePkce();
816
+
817
+ this.storage.setItem(this.keys.launchMode, "standalone");
818
+
819
+ const params = new URLSearchParams({
820
+ response_type: "code",
821
+ client_id: this.config.clientId,
822
+ redirect_uri: this.config.redirectUri,
823
+ scope: this.config.scopes,
824
+ state,
825
+ code_challenge: codeChallenge,
826
+ code_challenge_method: "S256",
827
+ aud: this.config.fhirBaseUrl,
828
+ });
829
+
830
+ window.location.href = \`\${endpoints.authorization_endpoint}?\${params}\`;
831
+ }
832
+
833
+ /** Start EHR SMART launch */
834
+ async startEhrLaunch(launch: string, iss: string): Promise<void> {
835
+ const endpoints = await discoverEndpoints(this.config.fhirBaseUrl);
836
+ const { codeChallenge, codeVerifier, state } = await this.preparePkce();
837
+
838
+ this.storage.setItem(this.keys.launchMode, "ehr");
839
+
840
+ const params = new URLSearchParams({
841
+ response_type: "code",
842
+ client_id: this.config.clientId,
843
+ redirect_uri: this.config.redirectUri,
844
+ scope: this.config.scopes,
845
+ state,
846
+ code_challenge: codeChallenge,
847
+ code_challenge_method: "S256",
848
+ aud: iss,
849
+ launch,
850
+ });
851
+
852
+ window.location.href = \`\${endpoints.authorization_endpoint}?\${params}\`;
853
+ }
854
+
855
+ // ── Callback ─────────────────────────────────────────────────────────
856
+
857
+ /** Check if the current URL is an OAuth callback */
858
+ isCallback(): boolean {
859
+ const params = new URLSearchParams(window.location.search);
860
+ return params.has("code") || params.has("error");
861
+ }
862
+
863
+ /** Handle the OAuth callback — exchanges code for token */
864
+ async handleCallback(): Promise<SmartToken> {
865
+ const params = new URLSearchParams(window.location.search);
866
+ const code = params.get("code");
867
+ const state = params.get("state");
868
+ const error = params.get("error");
869
+
870
+ if (error) {
871
+ throw new Error(
872
+ \`Authorization error: \${error} — \${params.get("error_description") ?? ""}\`,
873
+ );
874
+ }
875
+ if (!code) {
876
+ throw new Error("Missing authorization code in callback");
877
+ }
878
+
879
+ const savedState = this.storage.getItem(this.keys.state);
880
+ if (state !== savedState) {
881
+ throw new Error("State mismatch — possible CSRF");
882
+ }
883
+
884
+ const codeVerifier = this.storage.getItem(this.keys.verifier);
885
+ if (!codeVerifier) {
886
+ throw new Error("Missing PKCE code_verifier");
887
+ }
888
+
889
+ const endpoints = await discoverEndpoints(this.config.fhirBaseUrl);
890
+
891
+ const body = new URLSearchParams({
892
+ grant_type: "authorization_code",
893
+ code,
894
+ redirect_uri: this.config.redirectUri,
895
+ client_id: this.config.clientId,
896
+ code_verifier: codeVerifier,
897
+ });
898
+
899
+ const res = await fetch(endpoints.token_endpoint, {
900
+ method: "POST",
901
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
902
+ body,
903
+ });
904
+
905
+ if (!res.ok) {
906
+ const text = await res.text();
907
+ throw new Error(\`Token exchange failed (\${res.status}): \${text}\`);
908
+ }
909
+
910
+ const token: SmartToken = await res.json();
911
+
912
+ // Extract fhirUser from id_token claims
913
+ if (token.id_token) {
914
+ try {
915
+ const payload = JSON.parse(atob(token.id_token.split(".")[1]));
916
+ token.fhirUser = payload.fhirUser ?? payload.profile;
917
+ } catch {
918
+ // id_token decode failed — non-critical
919
+ }
920
+ }
921
+
922
+ // Clean up PKCE state
923
+ this.storage.removeItem(this.keys.verifier);
924
+ this.storage.removeItem(this.keys.state);
925
+
926
+ // Save token + expiry
927
+ this.storage.setItem(this.keys.token, JSON.stringify(token));
928
+ if (token.expires_in) {
929
+ const expiresAt = Date.now() + (token.expires_in - 60) * 1000;
930
+ this.storage.setItem(this.keys.expiresAt, String(expiresAt));
931
+ }
932
+
933
+ return token;
934
+ }
935
+
936
+ // ── Token management ─────────────────────────────────────────────────
937
+
938
+ /** Get the stored token, or null if not authenticated */
939
+ getToken(): SmartToken | null {
940
+ const raw = this.storage.getItem(this.keys.token);
941
+ if (!raw) return null;
942
+ try {
943
+ return JSON.parse(raw) as SmartToken;
944
+ } catch {
945
+ return null;
946
+ }
947
+ }
948
+
949
+ /** Get the stored launch mode */
950
+ getLaunchMode(): LaunchMode {
951
+ return (this.storage.getItem(this.keys.launchMode) as LaunchMode) ?? "standalone";
952
+ }
953
+
954
+ /** Check if the user is authenticated */
955
+ isAuthenticated(): boolean {
956
+ return this.getToken() !== null;
957
+ }
958
+
959
+ /** Check if the current token is expired */
960
+ isTokenExpired(): boolean {
961
+ const expiresAt = this.storage.getItem(this.keys.expiresAt);
962
+ if (!expiresAt) return false;
963
+ return Date.now() >= Number(expiresAt);
964
+ }
965
+
966
+ /** Clear stored token and auth state */
967
+ clearToken(): void {
968
+ this.storage.removeItem(this.keys.token);
969
+ this.storage.removeItem(this.keys.expiresAt);
970
+ this.storage.removeItem(this.keys.launchMode);
971
+ }
972
+
973
+ /**
974
+ * Full logout — clears local state and redirects to the IdP's
975
+ * end_session_endpoint so the Keycloak / OAuth2 session is terminated.
976
+ *
977
+ * Falls back to just clearing local state if no end_session_endpoint
978
+ * is advertised by the server.
979
+ */
980
+ async logout(postLogoutRedirectUri?: string): Promise<void> {
981
+ const token = this.getToken();
982
+ this.clearToken();
983
+
984
+ try {
985
+ const endpoints = await discoverEndpoints(this.config.fhirBaseUrl);
986
+ if (endpoints.end_session_endpoint) {
987
+ const params = new URLSearchParams({
988
+ post_logout_redirect_uri:
989
+ postLogoutRedirectUri ??
990
+ this.config.postLogoutRedirectUri ??
991
+ window.location.origin,
992
+ client_id: this.config.clientId,
993
+ });
994
+ if (token?.id_token) {
995
+ params.set("id_token_hint", token.id_token);
996
+ }
997
+ window.location.href = \`\${endpoints.end_session_endpoint}?\${params}\`;
998
+ return;
999
+ }
1000
+ } catch {
1001
+ // discovery failed — fall through to local-only logout
1002
+ }
1003
+
1004
+ // No end_session_endpoint — just reload to the app root
1005
+ window.location.href = postLogoutRedirectUri ?? this.config.postLogoutRedirectUri ?? window.location.origin;
1006
+ }
1007
+
1008
+ /** Refresh the access token using the refresh_token grant */
1009
+ async refreshAccessToken(): Promise<SmartToken | null> {
1010
+ const token = this.getToken();
1011
+ if (!token?.refresh_token) return null;
1012
+
1013
+ try {
1014
+ const endpoints = await discoverEndpoints(this.config.fhirBaseUrl);
1015
+ const body = new URLSearchParams({
1016
+ grant_type: "refresh_token",
1017
+ refresh_token: token.refresh_token,
1018
+ client_id: this.config.clientId,
1019
+ });
1020
+
1021
+ const res = await fetch(endpoints.token_endpoint, {
1022
+ method: "POST",
1023
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
1024
+ body,
1025
+ });
1026
+
1027
+ if (!res.ok) return null;
1028
+
1029
+ const newToken: SmartToken = await res.json();
1030
+
1031
+ // Preserve context from previous token if not in the new one
1032
+ if (!newToken.fhirUser && token.fhirUser) newToken.fhirUser = token.fhirUser;
1033
+ if (!newToken.refresh_token && token.refresh_token)
1034
+ newToken.refresh_token = token.refresh_token;
1035
+ if (!newToken.patient && token.patient) newToken.patient = token.patient;
1036
+ if (!newToken.encounter && token.encounter) newToken.encounter = token.encounter;
1037
+
1038
+ this.storage.setItem(this.keys.token, JSON.stringify(newToken));
1039
+ if (newToken.expires_in) {
1040
+ const expiresAt = Date.now() + (newToken.expires_in - 60) * 1000;
1041
+ this.storage.setItem(this.keys.expiresAt, String(expiresAt));
1042
+ }
1043
+ return newToken;
1044
+ } catch {
1045
+ return null;
1046
+ }
1047
+ }
1048
+
1049
+ /** Get a valid (non-expired) token, refreshing if needed */
1050
+ async getValidToken(): Promise<SmartToken | null> {
1051
+ const token = this.getToken();
1052
+ if (!token) return null;
1053
+ if (!this.isTokenExpired()) return token;
1054
+ return this.refreshAccessToken();
1055
+ }
1056
+
1057
+ // ── Authenticated fetch ──────────────────────────────────────────────
1058
+
1059
+ /**
1060
+ * Create a fetch wrapper that injects the Bearer token.
1061
+ * Automatically refreshes expired tokens when a refresh_token is available.
1062
+ *
1063
+ * Pass the returned function to \`FhirClient\` as the \`fetchFn\` parameter.
1064
+ *
1065
+ * @example
1066
+ * const auth = new SmartAuth(config);
1067
+ * const client = new FhirClient(config.fhirBaseUrl, auth.createAuthenticatedFetch());
1068
+ */
1069
+ createAuthenticatedFetch(): typeof globalThis.fetch {
1070
+ const self = this;
1071
+
1072
+ return async function authenticatedFetch(
1073
+ input: RequestInfo | URL,
1074
+ init?: RequestInit,
1075
+ ): Promise<Response> {
1076
+ const token = await self.getValidToken();
1077
+ if (!token) {
1078
+ throw new Error("No valid SMART token available — authorize first");
1079
+ }
1080
+
1081
+ const headers = new Headers(init?.headers);
1082
+ headers.set("Authorization", \`Bearer \${token.access_token}\`);
1083
+
1084
+ const response = await fetch(input, { ...init, headers });
1085
+
1086
+ // Auto-retry once on 401 with a refreshed token
1087
+ if (response.status === 401 && token.refresh_token) {
1088
+ const refreshed = await self.refreshAccessToken();
1089
+ if (refreshed) {
1090
+ headers.set("Authorization", \`Bearer \${refreshed.access_token}\`);
1091
+ return fetch(input, { ...init, headers });
1092
+ }
1093
+ }
1094
+
1095
+ return response;
1096
+ };
1097
+ }
1098
+
1099
+ // ── Internal ─────────────────────────────────────────────────────────
1100
+
1101
+ private async preparePkce(): Promise<{
1102
+ codeVerifier: string;
1103
+ codeChallenge: string;
1104
+ state: string;
1105
+ }> {
1106
+ const codeVerifier = generateRandomString(64);
1107
+ const codeChallenge = base64UrlEncode(await sha256(codeVerifier));
1108
+ const state = generateRandomString(32);
1109
+
1110
+ this.storage.setItem(this.keys.verifier, codeVerifier);
1111
+ this.storage.setItem(this.keys.state, state);
1112
+
1113
+ return { codeVerifier, codeChallenge, state };
1114
+ }
1115
+ }
1116
+ `;
1117
+ fs.writeFileSync(path.join(clientDir, "smart-auth.ts"), content);
1118
+ }
1119
+ /**
1120
+ * Generate smart-client.ts — SmartFhirClient that wraps FhirClient with SMART auth
1121
+ */
1122
+ function generateSmartClient(clientDir, resourceTypes) {
1123
+ const content = `import { FhirClient, FhirReadClient, FhirWriteClient } from "./fhir-client.js";
1124
+ import { SmartAuth } from "./smart-auth.js";
1125
+ import type { SmartConfig, SmartToken, LaunchMode } from "./smart-auth.js";
1126
+
1127
+ export type { SmartConfig, SmartToken, LaunchMode };
1128
+
1129
+ /**
1130
+ * SMART on FHIR enabled FHIR Client.
1131
+ *
1132
+ * Combines \`SmartAuth\` (PKCE, discovery, token management) with the typed
1133
+ * \`FhirClient\` so every request is automatically authenticated.
1134
+ *
1135
+ * @example
1136
+ * const smart = new SmartFhirClient({
1137
+ * clientId: "my-app",
1138
+ * redirectUri: "http://localhost:3000/callback",
1139
+ * fhirBaseUrl: "https://fhir.example.com/fhir",
1140
+ * scopes: "openid fhirUser patient/*.read",
1141
+ * });
1142
+ *
1143
+ * // On page load — either handle callback or start auth
1144
+ * if (smart.isCallback()) {
1145
+ * await smart.handleCallback();
1146
+ * } else if (!smart.isAuthenticated()) {
1147
+ * await smart.authorize(); // redirects
1148
+ * return;
1149
+ * }
1150
+ *
1151
+ * // Use the typed FHIR client — auth headers injected automatically
1152
+ * const patient = await smart.client.read().patient().read("123");
1153
+ */
1154
+ export class SmartFhirClient {
1155
+ /** The underlying SMART auth manager */
1156
+ public readonly auth: SmartAuth;
1157
+
1158
+ /** The typed FHIR client with automatic auth headers */
1159
+ public readonly client: FhirClient;
1160
+
1161
+ constructor(config: SmartConfig) {
1162
+ this.auth = new SmartAuth(config);
1163
+ this.client = new FhirClient(
1164
+ config.fhirBaseUrl,
1165
+ this.auth.createAuthenticatedFetch(),
1166
+ );
1167
+ }
1168
+
1169
+ // ── Convenience delegates ────────────────────────────────────────────
1170
+
1171
+ /** Start the authorization flow (auto-detects standalone vs EHR launch) */
1172
+ async authorize(): Promise<void> {
1173
+ return this.auth.authorize();
1174
+ }
1175
+
1176
+ /** Check if the current page is an OAuth callback */
1177
+ isCallback(): boolean {
1178
+ return this.auth.isCallback();
1179
+ }
1180
+
1181
+ /** Handle the OAuth callback and store the token */
1182
+ async handleCallback(): Promise<SmartToken> {
1183
+ return this.auth.handleCallback();
1184
+ }
1185
+
1186
+ /** Check if the user has a stored token */
1187
+ isAuthenticated(): boolean {
1188
+ return this.auth.isAuthenticated();
1189
+ }
1190
+
1191
+ /** Get the current token (null if not authenticated) */
1192
+ getToken(): SmartToken | null {
1193
+ return this.auth.getToken();
1194
+ }
1195
+
1196
+ /** Get the launch mode (standalone or ehr) */
1197
+ getLaunchMode(): LaunchMode {
1198
+ return this.auth.getLaunchMode();
1199
+ }
1200
+
1201
+ /** Full logout — clears local state and ends the IdP session */
1202
+ async logout(postLogoutRedirectUri?: string): Promise<void> {
1203
+ return this.auth.logout(postLogoutRedirectUri);
1204
+ }
1205
+
1206
+ // ── Typed read/write access ──────────────────────────────────────────
1207
+
1208
+ /** Get the read client (all requests are authenticated) */
1209
+ read(): FhirReadClient {
1210
+ return this.client.read();
1211
+ }
1212
+
1213
+ /** Get the write client (all requests are authenticated) */
1214
+ write(): FhirWriteClient {
1215
+ return this.client.write();
1216
+ }
1217
+ }
1218
+ `;
1219
+ fs.writeFileSync(path.join(clientDir, "smart-client.ts"), content);
1220
+ }
587
1221
  /**
588
1222
  * Generate index.ts
589
1223
  */
@@ -596,6 +1230,7 @@ function generateIndex(clientDir, resourceTypes) {
596
1230
  .join("\n");
597
1231
  const content = `export { FhirClient, FhirReadClient, FhirWriteClient } from "./fhir-client.js";
598
1232
  export { FhirResourceReader } from "./resource-reader.js";
1233
+ export type { FetchFn } from "./resource-reader.js";
599
1234
  export type {
600
1235
  ${readerTypes}
601
1236
  FhirResourceSearcher,
@@ -608,6 +1243,9 @@ ${writerTypes}
608
1243
  export { BundleParser, parseBundle } from "./bundle-parser.js";
609
1244
  export type { BundleEntry } from "./bundle-parser.js";
610
1245
  export type { Bundle, FhirResource, SearchParams, WithId } from "./types.js";
1246
+ export { SmartAuth, discoverEndpoints } from "./smart-auth.js";
1247
+ export type { SmartConfig, SmartToken, SmartConfiguration, LaunchMode } from "./smart-auth.js";
1248
+ export { SmartFhirClient } from "./smart-client.js";
611
1249
  `;
612
1250
  fs.writeFileSync(path.join(clientDir, "index.ts"), content);
613
1251
  }
@@ -617,7 +1255,7 @@ export type { Bundle, FhirResource, SearchParams, WithId } from "./types.js";
617
1255
  function generateReadme(clientDir) {
618
1256
  const content = `# FHIR Client
619
1257
 
620
- Auto-generated type-safe FHIR client for TypeScript.
1258
+ Auto-generated type-safe FHIR client for TypeScript with SMART on FHIR authorization.
621
1259
 
622
1260
  ## Usage
623
1261
 
@@ -658,6 +1296,85 @@ const updated = await client.write().patient().update({
658
1296
  await client.write().patient().delete('patient-id');
659
1297
  \`\`\`
660
1298
 
1299
+ ### SMART on FHIR Authorization
1300
+
1301
+ The client includes full SMART App Launch Framework (v2) support with PKCE.
1302
+
1303
+ #### Quick Start — SmartFhirClient
1304
+
1305
+ \`\`\`typescript
1306
+ import { SmartFhirClient } from './fhir-client';
1307
+
1308
+ const smart = new SmartFhirClient({
1309
+ clientId: 'my-app',
1310
+ redirectUri: 'http://localhost:3000/callback',
1311
+ fhirBaseUrl: 'https://fhir.example.com/fhir',
1312
+ scopes: 'openid fhirUser patient/*.read',
1313
+ });
1314
+
1315
+ // On page load — handle callback or start auth
1316
+ if (smart.isCallback()) {
1317
+ await smart.handleCallback();
1318
+ } else if (!smart.isAuthenticated()) {
1319
+ await smart.authorize(); // redirects to auth server
1320
+ return;
1321
+ }
1322
+
1323
+ // All requests include the Bearer token automatically
1324
+ const patient = await smart.read().patient().read('123');
1325
+ const token = smart.getToken(); // access SmartToken
1326
+ \`\`\`
1327
+
1328
+ #### Advanced — SmartAuth + FhirClient
1329
+
1330
+ For more control, use \`SmartAuth\` directly with \`FhirClient\`:
1331
+
1332
+ \`\`\`typescript
1333
+ import { SmartAuth, FhirClient } from './fhir-client';
1334
+
1335
+ const auth = new SmartAuth({
1336
+ clientId: 'my-app',
1337
+ redirectUri: 'http://localhost:3000/callback',
1338
+ fhirBaseUrl: 'https://fhir.example.com/fhir',
1339
+ scopes: 'openid fhirUser patient/*.read launch/patient',
1340
+ });
1341
+
1342
+ // Get an authenticated fetch function
1343
+ const authFetch = auth.createAuthenticatedFetch();
1344
+
1345
+ // Pass it to FhirClient
1346
+ const client = new FhirClient('https://fhir.example.com/fhir', authFetch);
1347
+ \`\`\`
1348
+
1349
+ #### EHR Launch
1350
+
1351
+ EHR launches are auto-detected from URL parameters (\`launch\` + \`iss\`):
1352
+
1353
+ \`\`\`typescript
1354
+ const smart = new SmartFhirClient({ ... });
1355
+
1356
+ // authorize() auto-detects and handles both modes
1357
+ await smart.authorize();
1358
+
1359
+ // After callback
1360
+ const mode = smart.getLaunchMode(); // "standalone" | "ehr"
1361
+ const token = smart.getToken();
1362
+ console.log('Patient context:', token?.patient);
1363
+ console.log('Encounter context:', token?.encounter);
1364
+ \`\`\`
1365
+
1366
+ #### Token Management
1367
+
1368
+ \`\`\`typescript
1369
+ // Check authentication state
1370
+ smart.isAuthenticated(); // boolean
1371
+ smart.getToken(); // SmartToken | null
1372
+ smart.getLaunchMode(); // "standalone" | "ehr"
1373
+
1374
+ // Logout
1375
+ smart.logout();
1376
+ \`\`\`
1377
+
661
1378
  ### Bundle Parsing
662
1379
 
663
1380
  \`\`\`typescript
@@ -700,6 +1417,23 @@ for (const entry of entries) {
700
1417
  console.log(\`Search score: \${entry.search?.score}\`);
701
1418
  }
702
1419
  \`\`\`
1420
+
1421
+ ### Custom Fetch
1422
+
1423
+ You can pass any custom fetch function to \`FhirClient\` — useful for logging, retries, or custom auth:
1424
+
1425
+ \`\`\`typescript
1426
+ import { FhirClient } from './fhir-client';
1427
+
1428
+ const loggingFetch: typeof fetch = async (input, init) => {
1429
+ console.log('Request:', input);
1430
+ const response = await fetch(input, init);
1431
+ console.log('Response:', response.status);
1432
+ return response;
1433
+ };
1434
+
1435
+ const client = new FhirClient('https://fhir.example.com/fhir', loggingFetch);
1436
+ \`\`\`
703
1437
  `;
704
1438
  fs.writeFileSync(path.join(clientDir, "README.md"), content);
705
1439
  }
@@ -158,14 +158,13 @@ async function generateIndexFile(dir) {
158
158
  const files = fs.readdirSync(dir, { withFileTypes: true });
159
159
  const exports = [];
160
160
  const exportedNames = new Set();
161
- // First pass: collect all files and their exports
161
+ // First pass: collect all files and their exported names
162
162
  const fileExports = new Map();
163
163
  for (const file of files) {
164
164
  if (file.isFile() && file.name.endsWith('.ts') && !file.name.endsWith('.d.ts') && file.name !== 'index.ts' && file.name !== 'RandomSupport.ts') {
165
165
  const filePath = path.join(dir, file.name);
166
166
  const content = fs.readFileSync(filePath, 'utf-8');
167
167
  const names = new Set();
168
- // Extract export names (interfaces, classes, functions, types)
169
168
  const exportMatches = content.matchAll(/export\s+(?:interface|class|type|const|function)\s+(\w+)/g);
170
169
  for (const match of exportMatches) {
171
170
  names.add(match[1]);
@@ -173,26 +172,35 @@ async function generateIndexFile(dir) {
173
172
  fileExports.set(file.name, names);
174
173
  }
175
174
  }
176
- // Second pass: only export from files that have unique exports or are primary files
175
+ // Second pass: export files, using selective named exports when there are conflicts
177
176
  for (const file of files) {
178
177
  if (file.isFile() && file.name.endsWith('.ts') && !file.name.endsWith('.d.ts') && file.name !== 'index.ts' && file.name !== 'RandomSupport.ts') {
179
178
  const baseName = file.name.replace('.ts', '');
180
179
  const fileNames = fileExports.get(file.name) || new Set();
181
- // Check if this file's exports would conflict with already exported names
182
- let hasConflict = false;
180
+ // Find which names from this file conflict with already-exported names
181
+ const conflicting = new Set();
182
+ const unique = new Set();
183
183
  for (const name of fileNames) {
184
184
  if (exportedNames.has(name)) {
185
- hasConflict = true;
186
- break;
185
+ conflicting.add(name);
186
+ }
187
+ else {
188
+ unique.add(name);
187
189
  }
188
190
  }
189
- // Only export if no conflicts, or if it's a Class file (which are primary)
190
- if (!hasConflict || file.name.endsWith('Class.ts')) {
191
+ if (conflicting.size === 0) {
192
+ // No conflicts use simple wildcard export
191
193
  exports.push(`export * from './${baseName}.js';`);
192
- // Mark all names from this file as exported
193
- for (const name of fileNames) {
194
- exportedNames.add(name);
195
- }
194
+ }
195
+ else if (unique.size > 0) {
196
+ // Has conflicts but also has unique names — use selective named export
197
+ const names = [...unique].sort().join(', ');
198
+ exports.push(`export { ${names} } from './${baseName}.js';`);
199
+ }
200
+ // If all names conflict (unique.size === 0), skip entirely
201
+ // Track all names from this file as exported
202
+ for (const name of fileNames) {
203
+ exportedNames.add(name);
196
204
  }
197
205
  }
198
206
  }
@@ -1280,7 +1288,7 @@ export async function generateIntoPackage(packageArchivePath, outArchivePath, fl
1280
1288
  license: 'MIT',
1281
1289
  // fhirpath is required at runtime by validators; keep as peer so host decides version
1282
1290
  peerDependencies: {
1283
- 'fhirpath': '^4.0.0'
1291
+ 'fhirpath': '^3.0.0 || ^4.0.0'
1284
1292
  },
1285
1293
  // Type definitions for FHIR are needed for the .d.ts files
1286
1294
  dependencies: {
@@ -1291,10 +1299,14 @@ export async function generateIntoPackage(packageArchivePath, outArchivePath, fl
1291
1299
  fs.writeFileSync(generatedPackageJsonPath, JSON.stringify(generatedPackageJson, null, 2));
1292
1300
  logger.log(`Created package.json in generated folder: ${packageName}-generated@${packageVersion}`);
1293
1301
  // Copy fhir-r4.d.ts ambient module declaration to support fhir/r4 imports
1294
- // The file is at out/fhir-r4.d.ts (root of compiled output)
1295
- const fhirR4DtsSource = path.join(path.dirname(path.dirname(fileURLToPath(import.meta.url))), 'fhir-r4.d.ts');
1302
+ // In dev: src/generator/ -> src/fhir-r4.d.ts. In compiled: out/src/generator/ -> out/fhir-r4.d.ts
1303
+ const _selfDir = path.dirname(fileURLToPath(import.meta.url));
1304
+ const fhirR4DtsSource = [
1305
+ path.join(_selfDir, '..', 'fhir-r4.d.ts'),
1306
+ path.join(_selfDir, '..', '..', 'fhir-r4.d.ts'),
1307
+ ].find(p => fs.existsSync(p));
1296
1308
  const fhirR4DtsDest = path.join(outputDir, 'fhir-r4.d.ts');
1297
- if (fs.existsSync(fhirR4DtsSource)) {
1309
+ if (fhirR4DtsSource) {
1298
1310
  fs.copyFileSync(fhirR4DtsSource, fhirR4DtsDest);
1299
1311
  logger.log('Copied fhir-r4.d.ts ambient module declaration');
1300
1312
  }
package/out/src/main.js CHANGED
@@ -5,11 +5,25 @@ import { getCacheConfig, getFhirPackagesCacheDir, clearAllCaches, ensureCacheDir
5
5
  import { fetchArrayBuffer } from "./generator/fetchUtils.js";
6
6
  import fs from 'fs';
7
7
  import path from 'path';
8
- import { createRequire } from 'module';
8
+ import { fileURLToPath } from 'url';
9
9
  import { spawn } from 'child_process';
10
10
  import { logger } from './logger.js';
11
- const require = createRequire(import.meta.url);
12
- const packageJson = require('../package.json');
11
+ // Resolve package.json relative to this file — works from both src/ (dev) and out/src/ (compiled)
12
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
13
+ const _pkgJsonPath = [
14
+ path.resolve(__dirname, '../package.json'),
15
+ path.resolve(__dirname, '../../package.json'),
16
+ ].find(p => {
17
+ try {
18
+ return JSON.parse(fs.readFileSync(p, 'utf8')).name === 'babelfhir-ts';
19
+ }
20
+ catch {
21
+ return false;
22
+ }
23
+ });
24
+ if (!_pkgJsonPath)
25
+ throw new Error('Cannot find babelfhir-ts package.json');
26
+ const packageJson = JSON.parse(fs.readFileSync(_pkgJsonPath, 'utf8'));
13
27
  function printUsage() {
14
28
  console.log("BabelFHIR-TS: Generate TypeScript interfaces from FHIR StructureDefinitions");
15
29
  console.log("");
@@ -80,11 +94,27 @@ function cleanupCache() {
80
94
  console.log('Cache cleaned.');
81
95
  }
82
96
  }
97
+ /** Detect the package manager used in the current working directory */
98
+ function detectPackageManager() {
99
+ const cwd = process.cwd();
100
+ const isWin = process.platform === 'win32';
101
+ if (fs.existsSync(path.join(cwd, 'bun.lock')) || fs.existsSync(path.join(cwd, 'bun.lockb'))) {
102
+ return { cmd: isWin ? 'bun.exe' : 'bun', args: ['add'] };
103
+ }
104
+ if (fs.existsSync(path.join(cwd, 'pnpm-lock.yaml'))) {
105
+ return { cmd: isWin ? 'pnpm.cmd' : 'pnpm', args: ['add'] };
106
+ }
107
+ if (fs.existsSync(path.join(cwd, 'yarn.lock'))) {
108
+ return { cmd: isWin ? 'yarn.cmd' : 'yarn', args: ['add'] };
109
+ }
110
+ return { cmd: isWin ? 'npm.cmd' : 'npm', args: ['install'] };
111
+ }
83
112
  function npmInstall(packagePath) {
84
113
  return new Promise((resolve, reject) => {
85
- console.log(`Installing package with npm...`);
86
- const npmCmd = process.platform === 'win32' ? 'npm.cmd' : 'npm';
87
- const child = spawn(npmCmd, ['install', packagePath], {
114
+ const pm = detectPackageManager();
115
+ const pmName = path.basename(pm.cmd).replace(/\.(cmd|exe)$/, '');
116
+ console.log(`Installing package with ${pmName}...`);
117
+ const child = spawn(pm.cmd, [...pm.args, packagePath], {
88
118
  stdio: 'inherit',
89
119
  shell: true
90
120
  });
@@ -94,11 +124,11 @@ function npmInstall(packagePath) {
94
124
  resolve();
95
125
  }
96
126
  else {
97
- reject(new Error(`npm install failed with exit code ${code}`));
127
+ reject(new Error(`${pmName} install failed with exit code ${code}`));
98
128
  }
99
129
  });
100
130
  child.on('error', (err) => {
101
- reject(new Error(`Failed to run npm install: ${err.message}`));
131
+ reject(new Error(`Failed to run ${pmName} install: ${err.message}`));
102
132
  });
103
133
  });
104
134
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "babelfhir-ts",
3
- "version": "1.0.35",
3
+ "version": "1.0.37",
4
4
  "description": "BabelFHIR-TS: generate TypeScript interfaces, validators, and helper classes from FHIR R4 StructureDefinitions (profiles) directly inside package archives.",
5
5
  "type": "module",
6
6
  "main": "out/src/main.js",