Loading questionnaire...
;
+
+ // Render questionnaire with loaded value sets
+ return (null);
+
+ const handleValidate = async () => {
+ const isValid = await validateCode(codeSystemUrl, code);
+ setValid(isValid);
+ };
+
+ return (
+
+ setCode(e.target.value)}
+ />
+
+ {valid !== null && (
+ {valid ? 'ā
Valid' : 'ā Invalid'}
+ )}
+
+ );
+}
+```
+
+## Application-Level Integration
+
+### App Initialization
+
+Add FHIR resource preloading to your application startup:
+
+```typescript
+// In App.js or App.tsx
+import { initializeFHIRResources } from './utils/fhirResourceIntegration';
+
+function App() {
+ React.useEffect(() => {
+ // Preload common resources in the background
+ initializeFHIRResources().catch(err => {
+ console.warn('Failed to preload FHIR resources:', err);
+ });
+ }, []);
+
+ return (
+ // Your app content
+ );
+}
+```
+
+### Context Provider (Optional)
+
+For larger applications, use the context provider:
+
+```typescript
+// In App.js or App.tsx
+import { FHIRResourceProvider } from './utils/fhirResourceIntegration';
+
+function App() {
+ return (
+
+ {/* Your app content */}
+
+ );
+}
+
+// In any component
+import { useFHIRResourceLoader } from './utils/fhirResourceIntegration';
+
+function MyComponent() {
+ const { loadResource } = useFHIRResourceLoader();
+
+ const handleLoad = async () => {
+ const resource = await loadResource(canonicalUrl);
+ // ...
+ };
+}
+```
+
+## Migration Checklist
+
+### Step 1: Identify Components Using FHIR Resources
+
+Find components that import or use FHIR resources:
+```bash
+# Search for potential FHIR resource usage
+grep -r "ValueSet\|CodeSystem\|ConceptMap" src/components/
+```
+
+### Step 2: Replace Static Imports
+
+For each component:
+- [ ] Remove static JSON imports
+- [ ] Add FHIR Resource Loader import
+- [ ] Use `useFHIRValueSet` hook or `loadFHIRResource` function
+- [ ] Add loading and error states
+- [ ] Test the component
+
+### Step 3: Add Preloading
+
+Identify frequently used resources and add to preload list:
+
+```typescript
+// In src/utils/fhirResourceIntegration.tsx
+export const COMMON_VALUE_SETS = [
+ 'http://hl7.org/fhir/ValueSet/administrative-gender',
+ 'http://hl7.org/fhir/ValueSet/marital-status',
+ // Add more commonly used resources
+];
+```
+
+### Step 4: Measure Impact
+
+After migration:
+```bash
+# Build and check bundle size
+npm run build:check
+
+# Expected results:
+# - Main bundle reduction: ~3 MB
+# - Largest chunk reduction: significant decrease
+# - Total bundle: under 10 MB target
+```
+
+## Component Examples
+
+### Example 1: CoreDataDictionaryViewer
+
+If this component uses ValueSets:
+
+```typescript
+// Before (hypothetical)
+import valueSets from './valuesets.json';
+
+// After
+import { loadMultipleFHIRResources } from '../services/fhirResourceLoaderService';
+
+function CoreDataDictionaryViewer() {
+ const [valueSets, setValueSets] = React.useState([]);
+ const [loading, setLoading] = React.useState(true);
+
+ React.useEffect(() => {
+ async function loadValueSets() {
+ const urls = getValueSetUrlsFromDictionary();
+ const resources = await loadMultipleFHIRResources(urls);
+ setValueSets(resources.filter(r => r !== null));
+ setLoading(false);
+ }
+
+ loadValueSets();
+ }, []);
+
+ // Component logic
+}
+```
+
+### Example 2: DecisionSupportLogicView
+
+If this component validates codes:
+
+```typescript
+import { validateCode, getCodeDisplay } from '../utils/fhirResourceIntegration';
+
+function DecisionSupportLogicView() {
+ const [codeInfo, setCodeInfo] = React.useState(null);
+
+ const loadCodeInfo = async (system: string, code: string) => {
+ const [isValid, display] = await Promise.all([
+ validateCode(system, code),
+ getCodeDisplay(system, code),
+ ]);
+
+ setCodeInfo({ isValid, display });
+ };
+
+ // Component logic
+}
+```
+
+## Performance Considerations
+
+### Caching
+
+The service caches loaded resources by default:
+- First load: Network request
+- Subsequent loads: Instant from cache
+- Cache persists for the session
+
+### Parallel Loading
+
+Load multiple resources concurrently:
+```typescript
+// Good: Parallel loading
+const resources = await loadMultipleFHIRResources([url1, url2, url3]);
+
+// Avoid: Sequential loading
+const res1 = await loadFHIRResource(url1);
+const res2 = await loadFHIRResource(url2);
+const res3 = await loadFHIRResource(url3);
+```
+
+### Preloading
+
+Preload common resources during app initialization:
+- Reduces perceived loading time
+- Resources ready when needed
+- Happens in background
+
+## Testing
+
+### Unit Tests
+
+Mock the FHIR Resource Loader in tests:
+
+```typescript
+jest.mock('./services/fhirResourceLoaderService', () => ({
+ loadFHIRResource: jest.fn(),
+ loadMultipleFHIRResources: jest.fn(),
+}));
+
+test('loads ValueSet on mount', async () => {
+ const mockValueSet = { resourceType: 'ValueSet', id: 'test' };
+ (loadFHIRResource as jest.Mock).mockResolvedValue(mockValueSet);
+
+ render();
+
+ await waitFor(() => {
+ expect(loadFHIRResource).toHaveBeenCalledWith(expectedUrl);
+ });
+});
+```
+
+### Integration Tests
+
+Test with real network requests:
+
+```typescript
+test('loads actual FHIR resource', async () => {
+ const resource = await loadFHIRResource(
+ 'http://hl7.org/fhir/ValueSet/administrative-gender'
+ );
+
+ expect(resource).not.toBeNull();
+ expect(resource?.resourceType).toBe('ValueSet');
+});
+```
+
+## Troubleshooting
+
+### Resource Not Found
+
+If a resource fails to load:
+1. Check the canonical URL is correct
+2. Verify the published URL is accessible
+3. Check if CI build URL fallback is needed
+4. Review browser console for network errors
+
+### CORS Issues
+
+If you encounter CORS errors:
+1. Ensure the FHIR server supports CORS
+2. Consider proxying requests through your backend
+3. Use published resources from CORS-enabled servers
+
+### Performance Issues
+
+If loading feels slow:
+1. Enable preloading for common resources
+2. Use parallel loading with `loadMultipleFHIRResources`
+3. Verify caching is enabled (default)
+4. Consider adjusting timeout settings
+
+## Bundle Size Verification
+
+After integration, verify the bundle size reduction:
+
+```bash
+# Build the application
+npm run build
+
+# Check bundle sizes
+npm run check-bundle-size
+
+# Expected improvements:
+# ā
Main bundle: <500 KB (from 532 KB)
+# ā
Largest chunk: <2 MB (from 5.64 MB)
+# ā
Total JS: <8 MB (from 10.43 MB)
+```
+
+## Next Steps
+
+1. **Identify integration points**: Run grep to find components using FHIR resources
+2. **Start with high-impact components**: Focus on components with most FHIR usage
+3. **Migrate incrementally**: Convert one component at a time
+4. **Test thoroughly**: Ensure functionality is preserved
+5. **Measure impact**: Check bundle size after each major migration
+6. **Document changes**: Update component documentation
+
+## Resources
+
+- [FHIR Resource Loader Service](../services/fhirResourceLoaderService.ts)
+- [FHIR Resource Loader Documentation](fhir-resource-loader.md)
+- [Integration Helpers](../utils/fhirResourceIntegration.tsx)
+- [Bundle Analysis Report](../BUNDLE_ANALYSIS_REPORT.md)
+
+## Support
+
+For questions or issues:
+1. Check this integration guide
+2. Review the FHIR Resource Loader documentation
+3. Check the service tests for examples
+4. Open an issue in the repository
diff --git a/docs/fhir-resource-loader.md b/docs/fhir-resource-loader.md
new file mode 100644
index 000000000..4b27771b0
--- /dev/null
+++ b/docs/fhir-resource-loader.md
@@ -0,0 +1,275 @@
+# FHIR Resource Loader Service
+
+## Overview
+
+The FHIR Resource Loader Service provides dynamic loading of FHIR resources (ValueSets, CodeSystems, ConceptMaps, etc.) from external sources instead of bundling them in the application. This significantly reduces bundle size and improves initial load performance.
+
+## Features
+
+- **Dynamic Loading**: Loads FHIR resources on-demand from external URLs
+- **Fallback Strategy**: Tries published URLs first, falls back to CI/draft builds
+- **Caching**: In-memory caching to avoid redundant network requests
+- **Parallel Loading**: Load multiple resources concurrently
+- **Framework Agnostic**: Works with any FHIR Implementation Guide (IG), not just DAKs
+- **Configurable**: Control timeout, caching, and URL resolution behavior
+
+## Bundle Size Impact
+
+**Before**: FHIR profiles bundled in application
+- `fhir/profiles/valuesets.json`: 2.01 MB
+- `fhir/profiles/types.json`: 1.07 MB
+- **Total**: ~3 MB bundled
+
+**After**: FHIR resources loaded dynamically
+- Main bundle reduction: **~3 MB** (29% of current bundle size)
+- Resources loaded only when needed
+- Cached for repeat access
+
+## Usage
+
+### Basic Loading
+
+```typescript
+import { loadFHIRResource } from './services/fhirResourceLoaderService';
+
+// Load a ValueSet
+const valueSet = await loadFHIRResource(
+ 'http://hl7.org/fhir/ValueSet/administrative-gender'
+);
+
+if (valueSet) {
+ console.log('Loaded ValueSet:', valueSet.name);
+}
+```
+
+### With Options
+
+```typescript
+// Load with custom options
+const codeSystem = await loadFHIRResource(
+ 'https://myprofile.github.io/myrepo/CodeSystem/my-codes',
+ {
+ allowCIBuild: true, // Try CI build if published fails
+ allowPublished: true, // Try published URL first
+ timeout: 5000, // 5 second timeout
+ cache: true // Cache the result
+ }
+);
+```
+
+### Loading Multiple Resources
+
+```typescript
+import { loadMultipleFHIRResources } from './services/fhirResourceLoaderService';
+
+const resources = await loadMultipleFHIRResources([
+ 'http://hl7.org/fhir/ValueSet/administrative-gender',
+ 'http://hl7.org/fhir/CodeSystem/observation-category',
+ 'https://myprofile.github.io/myrepo/ConceptMap/my-map'
+]);
+
+// Handle results (nulls for failed loads)
+resources.forEach((resource, index) => {
+ if (resource) {
+ console.log(`Loaded resource ${index}:`, resource.resourceType);
+ }
+});
+```
+
+### Preloading
+
+```typescript
+import { preloadFHIRResources } from './services/fhirResourceLoaderService';
+
+// Preload commonly used resources during app startup
+await preloadFHIRResources([
+ 'http://hl7.org/fhir/ValueSet/administrative-gender',
+ 'http://hl7.org/fhir/ValueSet/marital-status',
+ // ... more resources
+]);
+```
+
+## URL Resolution Strategy
+
+The service resolves FHIR resources using a two-step fallback strategy:
+
+### 1. Published URL (if `allowPublished` is true)
+
+Canonical URL + `.json` extension:
+```
+http://hl7.org/fhir/ValueSet/administrative-gender
+ ā http://hl7.org/fhir/ValueSet/administrative-gender.json
+```
+
+### 2. CI/Draft Build URL (if `allowCIBuild` is true and URL is GitHub)
+
+Extract profile and repo from GitHub URL, construct CI build path:
+```
+https://myprofile.github.io/myrepo/ValueSet/my-valueset
+ ā https://myprofile.github.io/myrepo/my-valueset.json
+```
+
+**Note**: CI build URLs only work for resources hosted on GitHub Pages.
+
+## Cache Management
+
+```typescript
+import {
+ clearResourceCache,
+ getCacheSize,
+ isResourceCached
+} from './services/fhirResourceLoaderService';
+
+// Check if resource is cached
+if (isResourceCached('http://hl7.org/fhir/ValueSet/administrative-gender')) {
+ console.log('Resource is in cache');
+}
+
+// Get cache statistics
+console.log(`Cache size: ${getCacheSize()} resources`);
+
+// Clear specific resource
+clearResourceCache('http://hl7.org/fhir/ValueSet/administrative-gender');
+
+// Clear entire cache
+clearResourceCache();
+```
+
+## Options
+
+| Option | Type | Default | Description |
+|--------|------|---------|-------------|
+| `allowCIBuild` | boolean | `true` | Allow loading from CI/draft builds |
+| `allowPublished` | boolean | `true` | Allow loading from published URLs |
+| `timeout` | number | `10000` | Request timeout in milliseconds |
+| `cache` | boolean | `true` | Enable in-memory caching |
+
+## Integration Examples
+
+### Loading ValueSets for Questionnaire
+
+```typescript
+async function loadQuestionnaireValueSets(questionnaire) {
+ const valueSetUrls = questionnaire.item
+ .filter(item => item.answerValueSet)
+ .map(item => item.answerValueSet);
+
+ const valueSets = await loadMultipleFHIRResources(valueSetUrls);
+
+ return valueSets.filter(vs => vs !== null);
+}
+```
+
+### Loading CodeSystems for Validation
+
+```typescript
+async function validateCode(codeSystemUrl, code) {
+ const codeSystem = await loadFHIRResource(codeSystemUrl);
+
+ if (!codeSystem) {
+ throw new Error(`CodeSystem not found: ${codeSystemUrl}`);
+ }
+
+ const concept = codeSystem.concept?.find(c => c.code === code);
+ return !!concept;
+}
+```
+
+### Preloading Common Resources
+
+```typescript
+// In app initialization
+import { preloadFHIRResources } from './services/fhirResourceLoaderService';
+
+const COMMON_RESOURCES = [
+ 'http://hl7.org/fhir/ValueSet/administrative-gender',
+ 'http://hl7.org/fhir/ValueSet/marital-status',
+ 'http://hl7.org/fhir/ValueSet/languages',
+ // ... add more commonly used resources
+];
+
+export async function initializeApp() {
+ // Preload resources in background
+ preloadFHIRResources(COMMON_RESOURCES).catch(err => {
+ console.warn('Failed to preload some FHIR resources:', err);
+ });
+
+ // Continue with app initialization
+}
+```
+
+## Error Handling
+
+The service handles errors gracefully:
+
+```typescript
+const resource = await loadFHIRResource('http://example.org/invalid-url');
+
+if (resource === null) {
+ // Resource not found or failed to load
+ console.error('Failed to load resource');
+} else {
+ // Resource loaded successfully
+ console.log('Loaded:', resource.resourceType);
+}
+```
+
+For multiple resources, check each result:
+
+```typescript
+const results = await loadMultipleFHIRResources(urls);
+
+results.forEach((resource, index) => {
+ if (resource === null) {
+ console.error(`Failed to load resource at index ${index}`);
+ }
+});
+```
+
+## Performance Considerations
+
+1. **Caching**: Enable caching (default) to avoid redundant network requests
+2. **Preloading**: Preload commonly used resources during app startup
+3. **Parallel Loading**: Use `loadMultipleFHIRResources` for concurrent requests
+4. **Timeout**: Adjust timeout based on network conditions
+5. **Selective Loading**: Only load resources when actually needed
+
+## Testing
+
+The service includes comprehensive tests covering:
+
+- URL resolution (published and CI build)
+- Fallback behavior
+- Caching functionality
+- Parallel loading
+- Error handling
+- Option handling
+
+Run tests:
+```bash
+npm test -- fhirResourceLoaderService.test.ts
+```
+
+## Future Enhancements
+
+Potential improvements for future versions:
+
+1. **IndexedDB Storage**: Persist cache across sessions
+2. **Service Worker**: Enable offline access to cached resources
+3. **Automatic Retry**: Retry failed requests with exponential backoff
+4. **Version Management**: Support loading specific versions of resources
+5. **Batch Loading**: Optimize network requests for multiple resources
+6. **Progress Tracking**: Report loading progress for multiple resources
+
+## Related Documentation
+
+- [Bundle Analysis Report](../BUNDLE_ANALYSIS_REPORT.md) - Performance impact analysis
+- [Bundle Analysis Guide](bundle-analysis-guide.md) - Bundle optimization strategies
+- [FHIR Specification](http://hl7.org/fhir/) - FHIR resource definitions
+
+## Support
+
+For issues or questions:
+1. Check the service tests for usage examples
+2. Review the inline documentation in the source code
+3. Open an issue in the repository
diff --git a/package-lock.json b/package-lock.json
index b95533c59..fc72c18d3 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -55,7 +55,8 @@
"process": "^0.11.10",
"ts-json-schema-generator": "^2.4.0",
"typescript": "^5.9.2",
- "typescript-json-schema": "^0.65.1"
+ "typescript-json-schema": "^0.65.1",
+ "webpack-bundle-analyzer": "^4.10.2"
},
"engines": {
"node": ">=20.0.0",
@@ -2742,6 +2743,16 @@
"kuler": "^2.0.0"
}
},
+ "node_modules/@discoveryjs/json-ext": {
+ "version": "0.5.7",
+ "resolved": "https://registry.npmjs.org/@discoveryjs/json-ext/-/json-ext-0.5.7.tgz",
+ "integrity": "sha512-dBVuXR082gk3jsFp7Rd/JI4kytwGHecnCoTtXFb7DB6CNHp4rg5k1bhg0nWdLGLnOV71lmDzGQaLMy8iPLY0pw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10.0.0"
+ }
+ },
"node_modules/@eslint-community/eslint-utils": {
"version": "4.7.0",
"resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.7.0.tgz",
@@ -4190,6 +4201,13 @@
}
}
},
+ "node_modules/@polka/url": {
+ "version": "1.0.0-next.29",
+ "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz",
+ "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@rollup/plugin-babel": {
"version": "5.3.1",
"resolved": "https://registry.npmjs.org/@rollup/plugin-babel/-/plugin-babel-5.3.1.tgz",
@@ -9964,6 +9982,13 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/debounce": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/debounce/-/debounce-1.2.1.tgz",
+ "integrity": "sha512-XRRe6Glud4rd/ZGQfiV1ruXSfbvfJedlV9Y6zOlP+2K04vBYiJEte6stfFkCP03aMnY5tsipamumUjL14fofug==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/debug": {
"version": "4.4.1",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz",
@@ -17698,6 +17723,16 @@
"moddle": ">= 6.2.0"
}
},
+ "node_modules/mrmime": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz",
+ "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ }
+ },
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@@ -18139,6 +18174,16 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/opener": {
+ "version": "1.5.2",
+ "resolved": "https://registry.npmjs.org/opener/-/opener-1.5.2.tgz",
+ "integrity": "sha512-ur5UIdyw5Y7yEj9wLzhqXiy6GZ3Mwx0yGI+5sMn2r0N0v3cKJvUmFH5yPP+WXh9e0xfyzyJX95D8l088DNFj7A==",
+ "dev": true,
+ "license": "(WTFPL OR MIT)",
+ "bin": {
+ "opener": "bin/opener-bin.js"
+ }
+ },
"node_modules/optionator": {
"version": "0.9.4",
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
@@ -21972,6 +22017,21 @@
"integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==",
"license": "ISC"
},
+ "node_modules/sirv": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/sirv/-/sirv-2.0.4.tgz",
+ "integrity": "sha512-94Bdh3cC2PKrbgSOUqTiGPWVZeSiXfKOVZNJniWoqrWrRkB1CJzBU3NEbiTsPcYy1lDsANA/THzS+9WBiy5nfQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@polka/url": "^1.0.0-next.24",
+ "mrmime": "^2.0.0",
+ "totalist": "^3.0.0"
+ },
+ "engines": {
+ "node": ">= 10"
+ }
+ },
"node_modules/sisteransi": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz",
@@ -23241,6 +23301,16 @@
"node": ">=0.6"
}
},
+ "node_modules/totalist": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz",
+ "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/tough-cookie": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.0.tgz",
@@ -24459,6 +24529,65 @@
}
}
},
+ "node_modules/webpack-bundle-analyzer": {
+ "version": "4.10.2",
+ "resolved": "https://registry.npmjs.org/webpack-bundle-analyzer/-/webpack-bundle-analyzer-4.10.2.tgz",
+ "integrity": "sha512-vJptkMm9pk5si4Bv922ZbKLV8UTT4zib4FPgXMhgzUny0bfDDkLXAVQs3ly3fS4/TN9ROFtb0NFrm04UXFE/Vw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@discoveryjs/json-ext": "0.5.7",
+ "acorn": "^8.0.4",
+ "acorn-walk": "^8.0.0",
+ "commander": "^7.2.0",
+ "debounce": "^1.2.1",
+ "escape-string-regexp": "^4.0.0",
+ "gzip-size": "^6.0.0",
+ "html-escaper": "^2.0.2",
+ "opener": "^1.5.2",
+ "picocolors": "^1.0.0",
+ "sirv": "^2.0.3",
+ "ws": "^7.3.1"
+ },
+ "bin": {
+ "webpack-bundle-analyzer": "lib/bin/analyzer.js"
+ },
+ "engines": {
+ "node": ">= 10.13.0"
+ }
+ },
+ "node_modules/webpack-bundle-analyzer/node_modules/commander": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz",
+ "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/webpack-bundle-analyzer/node_modules/ws": {
+ "version": "7.5.10",
+ "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.10.tgz",
+ "integrity": "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8.3.0"
+ },
+ "peerDependencies": {
+ "bufferutil": "^4.0.1",
+ "utf-8-validate": "^5.0.2"
+ },
+ "peerDependenciesMeta": {
+ "bufferutil": {
+ "optional": true
+ },
+ "utf-8-validate": {
+ "optional": true
+ }
+ }
+ },
"node_modules/webpack-dev-middleware": {
"version": "7.4.2",
"resolved": "https://registry.npmjs.org/webpack-dev-middleware/-/webpack-dev-middleware-7.4.2.tgz",
diff --git a/package.json b/package.json
index 3762994d6..c43a490cb 100644
--- a/package.json
+++ b/package.json
@@ -38,6 +38,12 @@
"configure:repo": "node scripts/configure-repository.js",
"start": "npm run configure:repo && craco start",
"build": "npm run configure:repo && craco build",
+ "build:analyze": "ANALYZE=true npm run build",
+ "analyze": "npm run build:analyze && echo '\nā
Bundle analysis complete! Open bundle-report.html to view the interactive report.'",
+ "check-bundle-size": "node scripts/check-bundle-size.js",
+ "build:check": "npm run build && npm run check-bundle-size",
+ "bundle-report:json": "node scripts/generate-bundle-report-json.js",
+ "build:report": "npm run build && npm run bundle-report:json",
"test": "react-scripts test",
"eject": "react-scripts eject",
"serve": "npm run build && cd build && python3 -m http.server 3000",
@@ -116,6 +122,7 @@
"process": "^0.11.10",
"ts-json-schema-generator": "^2.4.0",
"typescript": "^5.9.2",
- "typescript-json-schema": "^0.65.1"
+ "typescript-json-schema": "^0.65.1",
+ "webpack-bundle-analyzer": "^4.10.2"
}
}
diff --git a/scripts/analyze_webpack_stats.py b/scripts/analyze_webpack_stats.py
index c503f558b..cc0a5e794 100755
--- a/scripts/analyze_webpack_stats.py
+++ b/scripts/analyze_webpack_stats.py
@@ -303,6 +303,76 @@ def generate_report(
return report
+ def generate_json_report(
+ self,
+ build_dir: Optional[Path] = None
+ ) -> Dict:
+ """
+ Generate JSON bundle analysis report.
+
+ Args:
+ build_dir: Optional path to build directory for file analysis
+
+ Returns:
+ Dictionary with report data
+ """
+ timestamp = datetime.now(timezone.utc).isoformat()
+
+ # Build analysis data
+ build_analysis = {}
+ if build_dir and build_dir.exists():
+ build_analysis = self.analyze_build_directory(build_dir)
+
+ # Prepare JSON structure
+ report_data = {
+ 'timestamp': timestamp,
+ 'build_directory': str(build_dir) if build_dir else None,
+ 'stats_loaded': self.stats is not None,
+ }
+
+ # Add build files info if available
+ if build_analysis:
+ files = build_analysis.get('files', [])
+ total_size = build_analysis.get('total_size', 0)
+
+ report_data['build_files'] = {
+ 'total_count': len(files),
+ 'total_size': total_size,
+ 'total_size_formatted': self.format_size(total_size),
+ 'files': files[:50], # Limit to top 50 files
+ 'large_files': [
+ f for f in files
+ if f['size'] > self.LARGE_MODULE_THRESHOLD
+ ],
+ }
+
+ # Add JavaScript-specific analysis
+ js_files = [f for f in files if f['type'] == '.js']
+ if js_files:
+ js_total = sum(f['size'] for f in js_files)
+ report_data['javascript'] = {
+ 'count': len(js_files),
+ 'total_size': js_total,
+ 'total_size_formatted': self.format_size(js_total),
+ 'files': js_files,
+ }
+
+ # Add stats info if available
+ if self.stats:
+ assets = self.stats.get('assets', [])
+ chunks = self.stats.get('chunks', [])
+ modules = self.stats.get('modules', [])
+
+ report_data['webpack_stats'] = {
+ 'asset_count': len(assets),
+ 'chunk_count': len(chunks),
+ 'module_count': len(modules),
+ 'assets': assets[:50], # Limit to top 50 assets
+ 'chunks': chunks[:50], # Limit to top 50 chunks
+ }
+
+ return report_data
+
def parse_arguments():
"""Parse command line arguments."""
@@ -352,6 +422,14 @@ def parse_arguments():
help='Path to write report (default: print to stdout)'
)
+ parser.add_argument(
+ '--format',
+ type=str,
+ choices=['text', 'json'],
+ default='text',
+ help='Output format: text or json (default: text)'
+ )
+
return parser.parse_args()
@@ -366,16 +444,33 @@ def main():
if args.stats_file:
analyzer.load_stats(args.stats_file)
- # Generate report
- report = analyzer.generate_report(
- build_dir=args.build_dir,
- output_file=args.output_file
- )
-
- # Print to stdout if no output file specified
- if not args.output_file:
- print()
- print(report)
+ # Generate report based on format
+ if args.format == 'json':
+ report_data = analyzer.generate_json_report(build_dir=args.build_dir)
+ report = json.dumps(report_data, indent=2)
+
+ # Write to file if requested
+ if args.output_file:
+ try:
+ args.output_file.parent.mkdir(parents=True, exist_ok=True)
+ with open(args.output_file, 'w', encoding='utf-8') as f:
+ f.write(report)
+ print(f"ā
JSON report written to: {args.output_file}")
+ except Exception as e:
+ print(f"ā Error writing JSON report: {e}", file=sys.stderr)
+ else:
+ print(report)
+ else:
+ # Generate text report
+ report = analyzer.generate_report(
+ build_dir=args.build_dir,
+ output_file=args.output_file
+ )
+
+ # Print to stdout if no output file specified
+ if not args.output_file:
+ print()
+ print(report)
sys.exit(0)
diff --git a/scripts/check-bundle-size.js b/scripts/check-bundle-size.js
new file mode 100755
index 000000000..5991c0801
--- /dev/null
+++ b/scripts/check-bundle-size.js
@@ -0,0 +1,199 @@
+#!/usr/bin/env node
+/**
+ * Bundle Size Checker
+ *
+ * Checks that bundle sizes don't exceed defined thresholds.
+ * Helps enforce bundle size budgets and catch regressions early.
+ *
+ * Usage:
+ * node scripts/check-bundle-size.js
+ *
+ * Exit codes:
+ * 0 - All bundles within size limits
+ * 1 - One or more bundles exceed size limits
+ */
+
+const fs = require('fs');
+const path = require('path');
+
+// Bundle size budgets (in bytes)
+const SIZE_LIMITS = {
+ // Main bundle should stay under 300 KB (compressed ~100 KB)
+ main: 300 * 1024,
+
+ // Individual chunks should stay under 1 MB (compressed ~300 KB)
+ chunk: 1 * 1024 * 1024,
+
+ // Total JS size warning threshold: 8 MB
+ totalWarning: 8 * 1024 * 1024,
+
+ // Total JS size error threshold: 10 MB
+ totalError: 10 * 1024 * 1024,
+};
+
+// ANSI color codes
+const colors = {
+ reset: '\x1b[0m',
+ red: '\x1b[31m',
+ yellow: '\x1b[33m',
+ green: '\x1b[32m',
+ cyan: '\x1b[36m',
+ bold: '\x1b[1m',
+};
+
+function formatSize(bytes) {
+ if (bytes < 1024) return `${bytes} B`;
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(2)} KB`;
+ return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
+}
+
+function checkBundleSize() {
+ const buildDir = path.join(__dirname, '..', 'build', 'static', 'js');
+
+ if (!fs.existsSync(buildDir)) {
+ console.error(`${colors.red}ā Build directory not found: ${buildDir}${colors.reset}`);
+ console.error('Run "npm run build" first');
+ return 1;
+ }
+
+ const files = fs.readdirSync(buildDir);
+ const jsFiles = files.filter(f => f.endsWith('.js') && !f.endsWith('.map'));
+
+ if (jsFiles.length === 0) {
+ console.error(`${colors.red}ā No JavaScript files found in build directory${colors.reset}`);
+ return 1;
+ }
+
+ console.log(`${colors.cyan}${colors.bold}Bundle Size Check${colors.reset}`);
+ console.log(`${colors.cyan}${'='.repeat(80)}${colors.reset}\n`);
+
+ let totalSize = 0;
+ let hasErrors = false;
+ const violations = [];
+ const mainBundles = [];
+ const chunks = [];
+
+ // Analyze all JS files
+ jsFiles.forEach(file => {
+ const filePath = path.join(buildDir, file);
+ const stats = fs.statSync(filePath);
+ const size = stats.size;
+ totalSize += size;
+
+ const isMainBundle = file.startsWith('main.');
+ const isChunk = file.includes('.chunk.');
+
+ if (isMainBundle) {
+ mainBundles.push({ file, size });
+ } else if (isChunk) {
+ chunks.push({ file, size });
+ }
+ });
+
+ // Check main bundle
+ console.log(`${colors.bold}Main Bundle:${colors.reset}`);
+ if (mainBundles.length === 0) {
+ console.log(` ${colors.yellow}ā No main bundle found${colors.reset}\n`);
+ } else {
+ mainBundles.forEach(({ file, size }) => {
+ const status = size > SIZE_LIMITS.main
+ ? `${colors.red}ā EXCEEDS LIMIT${colors.reset}`
+ : `${colors.green}ā
OK${colors.reset}`;
+ const sizeStr = formatSize(size);
+ const limitStr = formatSize(SIZE_LIMITS.main);
+
+ console.log(` ${file}`);
+ console.log(` Size: ${sizeStr} / ${limitStr} ${status}`);
+
+ if (size > SIZE_LIMITS.main) {
+ const overage = size - SIZE_LIMITS.main;
+ console.log(` ${colors.red}Exceeds limit by ${formatSize(overage)}${colors.reset}`);
+ violations.push({
+ file,
+ size,
+ limit: SIZE_LIMITS.main,
+ type: 'main bundle'
+ });
+ hasErrors = true;
+ }
+ });
+ console.log('');
+ }
+
+ // Check chunks
+ console.log(`${colors.bold}Chunks:${colors.reset}`);
+ const largeChunks = chunks.filter(c => c.size > SIZE_LIMITS.chunk);
+ const okChunks = chunks.filter(c => c.size <= SIZE_LIMITS.chunk);
+
+ if (largeChunks.length > 0) {
+ console.log(` ${colors.red}ā ${largeChunks.length} chunk(s) exceed size limit:${colors.reset}`);
+ largeChunks
+ .sort((a, b) => b.size - a.size)
+ .forEach(({ file, size }) => {
+ const overage = size - SIZE_LIMITS.chunk;
+ console.log(` ${file}: ${formatSize(size)} (exceeds by ${formatSize(overage)})`);
+ violations.push({
+ file,
+ size,
+ limit: SIZE_LIMITS.chunk,
+ type: 'chunk'
+ });
+ hasErrors = true;
+ });
+ }
+
+ if (okChunks.length > 0) {
+ console.log(` ${colors.green}ā
${okChunks.length} chunk(s) within size limit${colors.reset}`);
+ // Show largest 3 OK chunks
+ const topOk = okChunks.sort((a, b) => b.size - a.size).slice(0, 3);
+ if (topOk.length > 0) {
+ console.log(` Largest:`);
+ topOk.forEach(({ file, size }) => {
+ console.log(` ${file}: ${formatSize(size)}`);
+ });
+ }
+ }
+ console.log('');
+
+ // Check total size
+ console.log(`${colors.bold}Total JavaScript:${colors.reset}`);
+ console.log(` Size: ${formatSize(totalSize)}`);
+
+ if (totalSize > SIZE_LIMITS.totalError) {
+ console.log(` ${colors.red}ā CRITICAL: Total size exceeds ${formatSize(SIZE_LIMITS.totalError)}${colors.reset}`);
+ hasErrors = true;
+ } else if (totalSize > SIZE_LIMITS.totalWarning) {
+ console.log(` ${colors.yellow}ā WARNING: Total size exceeds ${formatSize(SIZE_LIMITS.totalWarning)}${colors.reset}`);
+ } else {
+ console.log(` ${colors.green}ā
OK${colors.reset}`);
+ }
+ console.log('');
+
+ // Summary
+ console.log(`${colors.cyan}${'='.repeat(80)}${colors.reset}`);
+
+ if (hasErrors) {
+ console.log(`${colors.red}${colors.bold}Bundle Size Check FAILED${colors.reset}`);
+ console.log('');
+ console.log(`${colors.yellow}Violations:${colors.reset}`);
+ violations.forEach(({ file, size, limit, type }) => {
+ const overage = size - limit;
+ console.log(` ⢠${file} (${type})`);
+ console.log(` ${formatSize(size)} exceeds ${formatSize(limit)} by ${formatSize(overage)}`);
+ });
+ console.log('');
+ console.log(`${colors.cyan}Recommendations:${colors.reset}`);
+ console.log(' 1. Run "npm run analyze" to identify what\'s in large chunks');
+ console.log(' 2. Review BUNDLE_ANALYSIS_REPORT.md for optimization strategies');
+ console.log(' 3. Consider lazy loading, code splitting, or removing unused dependencies');
+ console.log('');
+ return 1;
+ } else {
+ console.log(`${colors.green}${colors.bold}ā
All bundles within size limits${colors.reset}`);
+ console.log('');
+ return 0;
+ }
+}
+
+// Run the check
+process.exit(checkBundleSize());
diff --git a/scripts/generate-bundle-report-json.js b/scripts/generate-bundle-report-json.js
new file mode 100755
index 000000000..6d8e1fb8f
--- /dev/null
+++ b/scripts/generate-bundle-report-json.js
@@ -0,0 +1,154 @@
+#!/usr/bin/env node
+/**
+ * Bundle Report JSON Generator
+ *
+ * Generates a structured JSON report of bundle analysis results.
+ * This report can be uploaded as an artifact and parsed by CI/CD systems.
+ *
+ * Usage:
+ * node scripts/generate-bundle-report-json.js [output-file]
+ *
+ * Default output: bundle-report.json
+ */
+
+const fs = require('fs');
+const path = require('path');
+
+const DEFAULT_OUTPUT = 'bundle-report.json';
+
+// Bundle size budgets (in bytes) - must match check-bundle-size.js
+const SIZE_LIMITS = {
+ main: 300 * 1024,
+ chunk: 1 * 1024 * 1024,
+ totalWarning: 8 * 1024 * 1024,
+ totalError: 10 * 1024 * 1024,
+};
+
+function formatSize(bytes) {
+ if (bytes < 1024) return `${bytes} B`;
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(2)} KB`;
+ return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
+}
+
+function analyzeBundleSize() {
+ const buildDir = path.join(process.cwd(), 'build', 'static', 'js');
+
+ if (!fs.existsSync(buildDir)) {
+ return {
+ error: 'Build directory not found',
+ buildDir,
+ timestamp: new Date().toISOString(),
+ };
+ }
+
+ const files = fs.readdirSync(buildDir)
+ .filter(file => file.endsWith('.js'))
+ .map(file => {
+ const filePath = path.join(buildDir, file);
+ const stats = fs.statSync(filePath);
+ const size = stats.size;
+
+ // Determine file type
+ let type = 'other';
+ let exceedsLimit = false;
+ let limitBytes = null;
+
+ if (file.includes('main')) {
+ type = 'main';
+ limitBytes = SIZE_LIMITS.main;
+ exceedsLimit = size > SIZE_LIMITS.main;
+ } else if (file.match(/^\d+\./)) {
+ type = 'chunk';
+ limitBytes = SIZE_LIMITS.chunk;
+ exceedsLimit = size > SIZE_LIMITS.chunk;
+ }
+
+ return {
+ name: file,
+ size,
+ sizeFormatted: formatSize(size),
+ type,
+ limit: limitBytes,
+ limitFormatted: limitBytes ? formatSize(limitBytes) : null,
+ exceedsLimit,
+ overage: exceedsLimit && limitBytes ? size - limitBytes : 0,
+ overageFormatted: exceedsLimit && limitBytes ? formatSize(size - limitBytes) : null,
+ };
+ })
+ .sort((a, b) => b.size - a.size);
+
+ const totalSize = files.reduce((sum, file) => sum + file.size, 0);
+ const violations = files.filter(f => f.exceedsLimit);
+
+ const summary = {
+ totalFiles: files.length,
+ totalSize,
+ totalSizeFormatted: formatSize(totalSize),
+ mainBundles: files.filter(f => f.type === 'main').length,
+ chunks: files.filter(f => f.type === 'chunk').length,
+ violations: violations.length,
+ passed: violations.length === 0 && totalSize <= SIZE_LIMITS.totalError,
+ totalExceedsWarning: totalSize > SIZE_LIMITS.totalWarning,
+ totalExceedsError: totalSize > SIZE_LIMITS.totalError,
+ };
+
+ return {
+ timestamp: new Date().toISOString(),
+ summary,
+ limits: {
+ main: SIZE_LIMITS.main,
+ mainFormatted: formatSize(SIZE_LIMITS.main),
+ chunk: SIZE_LIMITS.chunk,
+ chunkFormatted: formatSize(SIZE_LIMITS.chunk),
+ totalWarning: SIZE_LIMITS.totalWarning,
+ totalWarningFormatted: formatSize(SIZE_LIMITS.totalWarning),
+ totalError: SIZE_LIMITS.totalError,
+ totalErrorFormatted: formatSize(SIZE_LIMITS.totalError),
+ },
+ files,
+ violations: violations.map(v => ({
+ name: v.name,
+ size: v.size,
+ sizeFormatted: v.sizeFormatted,
+ limit: v.limit,
+ limitFormatted: v.limitFormatted,
+ overage: v.overage,
+ overageFormatted: v.overageFormatted,
+ })),
+ };
+}
+
+function main() {
+ const outputFile = process.argv[2] || DEFAULT_OUTPUT;
+
+ console.log('š Generating bundle analysis JSON report...');
+
+ const report = analyzeBundleSize();
+
+ // Write JSON report
+ fs.writeFileSync(outputFile, JSON.stringify(report, null, 2));
+
+ console.log(`ā
Bundle report written to: ${outputFile}`);
+
+ if (report.error) {
+ console.error(`ā Error: ${report.error}`);
+ process.exit(1);
+ }
+
+ console.log(`š¦ Total files: ${report.summary.totalFiles}`);
+ console.log(`š Total size: ${report.summary.totalSizeFormatted}`);
+ console.log(`ā ļø Violations: ${report.summary.violations}`);
+
+ if (!report.summary.passed) {
+ console.log(`ā Bundle size check failed`);
+ process.exit(1);
+ } else {
+ console.log(`ā
Bundle size check passed`);
+ }
+}
+
+if (require.main === module) {
+ main();
+}
+
+module.exports = { analyzeBundleSize, formatSize };
diff --git a/scripts/log-workflow-event-json.js b/scripts/log-workflow-event-json.js
new file mode 100755
index 000000000..0959d6c34
--- /dev/null
+++ b/scripts/log-workflow-event-json.js
@@ -0,0 +1,149 @@
+#!/usr/bin/env node
+/**
+ * Workflow Event Logger (JSON Format)
+ *
+ * Logs workflow events in structured JSON format for better parsing and analysis.
+ *
+ * Usage:
+ * node scripts/log-workflow-event-json.js --event --stage [options]
+ *
+ * Options:
+ * --event Event name (required)
+ * --stage Stage name (required)
+ * --workflow Workflow name
+ * --run-id Workflow run ID
+ * --commit Commit SHA
+ * --branch Branch name
+ * --pr PR number
+ * --status Status (success, failure, in_progress)
+ * --message Custom message
+ * --data Additional data as JSON string
+ * --output Output file (default: workflow-event.json)
+ */
+
+const fs = require('fs');
+
+function parseArgs() {
+ const args = process.argv.slice(2);
+ const parsed = {
+ event: null,
+ stage: null,
+ workflow: null,
+ runId: null,
+ commit: null,
+ branch: null,
+ pr: null,
+ status: null,
+ message: null,
+ data: {},
+ output: 'workflow-event.json',
+ };
+
+ for (let i = 0; i < args.length; i += 2) {
+ const key = args[i];
+ const value = args[i + 1];
+
+ switch (key) {
+ case '--event':
+ parsed.event = value;
+ break;
+ case '--stage':
+ parsed.stage = value;
+ break;
+ case '--workflow':
+ parsed.workflow = value;
+ break;
+ case '--run-id':
+ parsed.runId = value;
+ break;
+ case '--commit':
+ parsed.commit = value;
+ break;
+ case '--branch':
+ parsed.branch = value;
+ break;
+ case '--pr':
+ parsed.pr = value;
+ break;
+ case '--status':
+ parsed.status = value;
+ break;
+ case '--message':
+ parsed.message = value;
+ break;
+ case '--data':
+ try {
+ parsed.data = JSON.parse(value);
+ } catch (e) {
+ console.error(`Warning: Failed to parse --data JSON: ${e.message}`);
+ }
+ break;
+ case '--output':
+ parsed.output = value;
+ break;
+ }
+ }
+
+ return parsed;
+}
+
+function main() {
+ const args = parseArgs();
+
+ if (!args.event || !args.stage) {
+ console.error('Error: --event and --stage are required');
+ console.error('Usage: node scripts/log-workflow-event-json.js --event --stage [options]');
+ process.exit(1);
+ }
+
+ const logEntry = {
+ timestamp: new Date().toISOString(),
+ event: args.event,
+ stage: args.stage,
+ workflow: args.workflow,
+ runId: args.runId,
+ commit: args.commit,
+ branch: args.branch,
+ pr: args.pr,
+ status: args.status,
+ message: args.message,
+ ...args.data,
+ };
+
+ // Remove null/undefined values
+ Object.keys(logEntry).forEach(key => {
+ if (logEntry[key] === null || logEntry[key] === undefined) {
+ delete logEntry[key];
+ }
+ });
+
+ // Read existing log or create new array
+ let logs = [];
+ if (fs.existsSync(args.output)) {
+ try {
+ const content = fs.readFileSync(args.output, 'utf8');
+ logs = JSON.parse(content);
+ if (!Array.isArray(logs)) {
+ logs = [logs]; // Wrap single object in array
+ }
+ } catch (e) {
+ console.warn(`Warning: Could not parse existing log file, starting fresh: ${e.message}`);
+ logs = [];
+ }
+ }
+
+ // Append new log entry
+ logs.push(logEntry);
+
+ // Write updated logs
+ fs.writeFileSync(args.output, JSON.stringify(logs, null, 2));
+
+ console.log(`ā
Logged event: ${args.event} / ${args.stage}`);
+ console.log(`š Output: ${args.output}`);
+}
+
+if (require.main === module) {
+ main();
+}
+
+module.exports = { parseArgs };
diff --git a/src/components/PagesManager.js b/src/components/PagesManager.js
index b4384013d..7ad476d0b 100644
--- a/src/components/PagesManager.js
+++ b/src/components/PagesManager.js
@@ -1,12 +1,15 @@
-import React, { useState, useEffect } from 'react';
+import React, { useState, useEffect, Suspense, lazy } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import githubService from '../services/githubService';
import stagingGroundService from '../services/stagingGroundService';
import { PageLayout } from './framework';
import PageViewModal from './PageViewModal';
-import PageEditModal from './PageEditModal';
import DAKStatusBox from './DAKStatusBox';
+// Lazy load PageEditModal to reduce initial bundle size
+// This modal contains the markdown editor which is ~2.4 MB
+const PageEditModal = lazy(() => import('./PageEditModal'));
+
const PagesManager = () => {
const location = useLocation();
const navigate = useNavigate();
@@ -570,11 +573,22 @@ const PagesManager = () => {
{/* Edit Modal */}
{editModalPage && (
- setEditModalPage(null)}
- onSave={handleSavePage}
- />
+ Loading editor...}>
+ setEditModalPage(null)}
+ onSave={handleSavePage}
+ />
+
)}
diff --git a/src/services/fhirResourceLoaderService.test.ts b/src/services/fhirResourceLoaderService.test.ts
new file mode 100644
index 000000000..854ab167c
--- /dev/null
+++ b/src/services/fhirResourceLoaderService.test.ts
@@ -0,0 +1,310 @@
+/**
+ * Tests for FHIR Resource Loader Service
+ */
+
+import {
+ loadFHIRResource,
+ loadMultipleFHIRResources,
+ clearResourceCache,
+ getCacheSize,
+ isResourceCached,
+} from './fhirResourceLoaderService';
+
+// Mock fetch globally
+global.fetch = jest.fn();
+
+describe('FHIRResourceLoaderService', () => {
+ beforeEach(() => {
+ // Clear cache before each test
+ clearResourceCache();
+ // Reset fetch mock
+ (global.fetch as jest.Mock).mockReset();
+ });
+
+ describe('loadFHIRResource', () => {
+ it('should load a resource from published URL', async () => {
+ const mockResource = {
+ resourceType: 'ValueSet',
+ id: 'test-valueset',
+ url: 'http://example.org/fhir/ValueSet/test-valueset',
+ name: 'Test ValueSet',
+ };
+
+ (global.fetch as jest.Mock).mockResolvedValueOnce({
+ ok: true,
+ json: async () => mockResource,
+ });
+
+ const result = await loadFHIRResource('http://example.org/fhir/ValueSet/test-valueset');
+
+ expect(result).toEqual(mockResource);
+ expect(global.fetch).toHaveBeenCalledWith(
+ 'http://example.org/fhir/ValueSet/test-valueset.json',
+ expect.objectContaining({
+ headers: expect.objectContaining({
+ 'Accept': 'application/fhir+json, application/json',
+ }),
+ })
+ );
+ });
+
+ it('should handle URL already ending with .json', async () => {
+ const mockResource = {
+ resourceType: 'CodeSystem',
+ id: 'test-codesystem',
+ };
+
+ (global.fetch as jest.Mock).mockResolvedValueOnce({
+ ok: true,
+ json: async () => mockResource,
+ });
+
+ const result = await loadFHIRResource('http://example.org/fhir/CodeSystem/test.json');
+
+ expect(result).toEqual(mockResource);
+ expect(global.fetch).toHaveBeenCalledWith(
+ 'http://example.org/fhir/CodeSystem/test.json',
+ expect.any(Object)
+ );
+ });
+
+ it('should fallback to CI build URL when published fails', async () => {
+ const mockResource = {
+ resourceType: 'ValueSet',
+ id: 'test-valueset',
+ };
+
+ // First call (published) fails
+ (global.fetch as jest.Mock).mockResolvedValueOnce({
+ ok: false,
+ status: 404,
+ statusText: 'Not Found',
+ });
+
+ // Second call (CI build) succeeds
+ (global.fetch as jest.Mock).mockResolvedValueOnce({
+ ok: true,
+ json: async () => mockResource,
+ });
+
+ const result = await loadFHIRResource(
+ 'https://myprofile.github.io/myrepo/ValueSet/test-valueset'
+ );
+
+ expect(result).toEqual(mockResource);
+ expect(global.fetch).toHaveBeenCalledTimes(2);
+ expect(global.fetch).toHaveBeenNthCalledWith(
+ 1,
+ 'https://myprofile.github.io/myrepo/ValueSet/test-valueset.json',
+ expect.any(Object)
+ );
+ expect(global.fetch).toHaveBeenNthCalledWith(
+ 2,
+ 'https://myprofile.github.io/myrepo/test-valueset.json',
+ expect.any(Object)
+ );
+ });
+
+ it('should return null when resource is not found', async () => {
+ (global.fetch as jest.Mock).mockResolvedValue({
+ ok: false,
+ status: 404,
+ statusText: 'Not Found',
+ });
+
+ const result = await loadFHIRResource('http://example.org/fhir/ValueSet/nonexistent');
+
+ expect(result).toBeNull();
+ });
+
+ it('should cache resources when caching is enabled', async () => {
+ const mockResource = {
+ resourceType: 'ValueSet',
+ id: 'test-valueset',
+ };
+
+ (global.fetch as jest.Mock).mockResolvedValue({
+ ok: true,
+ json: async () => mockResource,
+ });
+
+ // First call - should fetch
+ await loadFHIRResource('http://example.org/fhir/ValueSet/test-valueset', { cache: true });
+ expect(global.fetch).toHaveBeenCalledTimes(1);
+
+ // Second call - should use cache
+ const result = await loadFHIRResource('http://example.org/fhir/ValueSet/test-valueset', {
+ cache: true,
+ });
+ expect(global.fetch).toHaveBeenCalledTimes(1); // Still 1, not called again
+ expect(result).toEqual(mockResource);
+ });
+
+ it('should not cache when caching is disabled', async () => {
+ const mockResource = {
+ resourceType: 'ValueSet',
+ id: 'test-valueset',
+ };
+
+ (global.fetch as jest.Mock).mockResolvedValue({
+ ok: true,
+ json: async () => mockResource,
+ });
+
+ // First call
+ await loadFHIRResource('http://example.org/fhir/ValueSet/test-valueset', { cache: false });
+ expect(global.fetch).toHaveBeenCalledTimes(1);
+
+ // Second call - should fetch again
+ await loadFHIRResource('http://example.org/fhir/ValueSet/test-valueset', { cache: false });
+ expect(global.fetch).toHaveBeenCalledTimes(2);
+ });
+
+ it('should respect allowPublished option', async () => {
+ const mockResource = {
+ resourceType: 'ValueSet',
+ id: 'test-valueset',
+ };
+
+ (global.fetch as jest.Mock).mockResolvedValue({
+ ok: true,
+ json: async () => mockResource,
+ });
+
+ // With allowPublished: false, should go straight to CI build
+ const result = await loadFHIRResource(
+ 'https://myprofile.github.io/myrepo/ValueSet/test-valueset',
+ { allowPublished: false }
+ );
+
+ expect(result).toEqual(mockResource);
+ expect(global.fetch).toHaveBeenCalledWith(
+ 'https://myprofile.github.io/myrepo/test-valueset.json',
+ expect.any(Object)
+ );
+ });
+
+ it('should respect allowCIBuild option', async () => {
+ (global.fetch as jest.Mock).mockResolvedValue({
+ ok: false,
+ status: 404,
+ statusText: 'Not Found',
+ });
+
+ // With allowCIBuild: false, should not try CI build
+ const result = await loadFHIRResource(
+ 'https://myprofile.github.io/myrepo/ValueSet/test-valueset',
+ { allowCIBuild: false }
+ );
+
+ expect(result).toBeNull();
+ expect(global.fetch).toHaveBeenCalledTimes(1); // Only published attempt
+ });
+ });
+
+ describe('loadMultipleFHIRResources', () => {
+ it('should load multiple resources in parallel', async () => {
+ const mockResources = [
+ { resourceType: 'ValueSet', id: 'valueset1' },
+ { resourceType: 'CodeSystem', id: 'codesystem1' },
+ { resourceType: 'ConceptMap', id: 'conceptmap1' },
+ ];
+
+ (global.fetch as jest.Mock)
+ .mockResolvedValueOnce({ ok: true, json: async () => mockResources[0] })
+ .mockResolvedValueOnce({ ok: true, json: async () => mockResources[1] })
+ .mockResolvedValueOnce({ ok: true, json: async () => mockResources[2] });
+
+ const results = await loadMultipleFHIRResources([
+ 'http://example.org/ValueSet/valueset1',
+ 'http://example.org/CodeSystem/codesystem1',
+ 'http://example.org/ConceptMap/conceptmap1',
+ ]);
+
+ expect(results).toEqual(mockResources);
+ expect(global.fetch).toHaveBeenCalledTimes(3);
+ });
+
+ it('should handle partial failures', async () => {
+ const mockResource1 = { resourceType: 'ValueSet', id: 'valueset1' };
+ const mockResource3 = { resourceType: 'ConceptMap', id: 'conceptmap1' };
+
+ (global.fetch as jest.Mock)
+ // First resource - published succeeds
+ .mockResolvedValueOnce({ ok: true, json: async () => mockResource1 })
+ // Second resource - published fails
+ .mockResolvedValueOnce({ ok: false, status: 404, statusText: 'Not Found' })
+ // Third resource - published fails
+ .mockResolvedValueOnce({ ok: false, status: 404, statusText: 'Not Found' })
+ // Second resource - CI build fails (not a GitHub URL, so no CI attempt)
+ // Third resource - CI build succeeds
+ .mockResolvedValueOnce({ ok: true, json: async () => mockResource3 });
+
+ const results = await loadMultipleFHIRResources([
+ 'http://example.org/ValueSet/valueset1',
+ 'http://example.org/CodeSystem/codesystem1', // Not a GitHub URL, will only try published
+ 'https://myprofile.github.io/myrepo/ConceptMap/conceptmap1', // GitHub URL, will try CI build
+ ]);
+
+ expect(results).toHaveLength(3);
+ expect(results[0]).toEqual(mockResource1);
+ expect(results[1]).toBeNull();
+ expect(results[2]).toEqual(mockResource3);
+ });
+ });
+
+ describe('Cache management', () => {
+ it('should track cache size correctly', async () => {
+ expect(getCacheSize()).toBe(0);
+
+ const mockResource = { resourceType: 'ValueSet', id: 'test' };
+ (global.fetch as jest.Mock).mockResolvedValue({
+ ok: true,
+ json: async () => mockResource,
+ });
+
+ await loadFHIRResource('http://example.org/ValueSet/test1', { cache: true });
+ expect(getCacheSize()).toBe(1);
+
+ await loadFHIRResource('http://example.org/ValueSet/test2', { cache: true });
+ expect(getCacheSize()).toBe(2);
+
+ clearResourceCache();
+ expect(getCacheSize()).toBe(0);
+ });
+
+ it('should check if resource is cached', async () => {
+ const url = 'http://example.org/ValueSet/test';
+ expect(isResourceCached(url)).toBe(false);
+
+ const mockResource = { resourceType: 'ValueSet', id: 'test' };
+ (global.fetch as jest.Mock).mockResolvedValue({
+ ok: true,
+ json: async () => mockResource,
+ });
+
+ await loadFHIRResource(url, { cache: true });
+ expect(isResourceCached(url)).toBe(true);
+ });
+
+ it('should clear specific resource from cache', async () => {
+ const mockResource = { resourceType: 'ValueSet', id: 'test' };
+ (global.fetch as jest.Mock).mockResolvedValue({
+ ok: true,
+ json: async () => mockResource,
+ });
+
+ const url1 = 'http://example.org/ValueSet/test1';
+ const url2 = 'http://example.org/ValueSet/test2';
+
+ await loadFHIRResource(url1, { cache: true });
+ await loadFHIRResource(url2, { cache: true });
+ expect(getCacheSize()).toBe(2);
+
+ clearResourceCache(url1);
+ expect(getCacheSize()).toBe(1);
+ expect(isResourceCached(url1)).toBe(false);
+ expect(isResourceCached(url2)).toBe(true);
+ });
+ });
+});
diff --git a/src/services/fhirResourceLoaderService.ts b/src/services/fhirResourceLoaderService.ts
new file mode 100644
index 000000000..a7683480b
--- /dev/null
+++ b/src/services/fhirResourceLoaderService.ts
@@ -0,0 +1,319 @@
+/**
+ * FHIR Resource Loader Service
+ *
+ * Provides dynamic loading of FHIR resources (ValueSets, CodeSystems, ConceptMaps, etc.)
+ * from external sources instead of bundling them in the application.
+ *
+ * Resolution strategy:
+ * 1. Try published URL (canonical URL + .json)
+ * 2. Fallback to CI/draft build ({profile}.github.io/{repo}/{resource_id}.json)
+ *
+ * Supports loading from any FHIR Implementation Guide (IG), not just DAKs.
+ *
+ * @module fhirResourceLoaderService
+ */
+
+import logger from '../utils/logger';
+
+const serviceLogger = logger.getLogger('FHIRResourceLoader');
+
+/**
+ * Options for FHIR resource loading
+ */
+export interface FHIRResourceLoadOptions {
+ /** Whether to allow loading from CI/draft builds */
+ allowCIBuild?: boolean;
+ /** Whether to allow loading from published builds */
+ allowPublished?: boolean;
+ /** Custom timeout in milliseconds */
+ timeout?: number;
+ /** Whether to cache the resource in memory */
+ cache?: boolean;
+}
+
+/**
+ * FHIR resource metadata
+ */
+export interface FHIRResource {
+ resourceType: string;
+ id: string;
+ url?: string;
+ [key: string]: any;
+}
+
+/**
+ * Cache for loaded FHIR resources
+ */
+const resourceCache = new Map();
+
+/**
+ * Default options for resource loading
+ */
+const DEFAULT_OPTIONS: FHIRResourceLoadOptions = {
+ allowCIBuild: true,
+ allowPublished: true,
+ timeout: 10000, // 10 seconds
+ cache: true,
+};
+
+/**
+ * Extract GitHub profile and repo from a canonical URL
+ *
+ * @param canonicalUrl - The canonical URL of the resource
+ * @returns Object with profile and repo, or null if not a GitHub URL
+ */
+function parseGitHubUrl(canonicalUrl: string): { profile: string; repo: string } | null {
+ // Pattern: https://profile.github.io/repo/...
+ const match = canonicalUrl.match(/https?:\/\/([^.]+)\.github\.io\/([^/]+)/);
+ if (match) {
+ return {
+ profile: match[1],
+ repo: match[2],
+ };
+ }
+ return null;
+}
+
+/**
+ * Extract resource ID from a canonical URL
+ *
+ * @param canonicalUrl - The canonical URL of the resource
+ * @returns The resource ID (last part of the URL)
+ */
+function extractResourceId(canonicalUrl: string): string {
+ // Remove trailing slash if present
+ const url = canonicalUrl.replace(/\/$/, '');
+ // Get the last segment
+ const parts = url.split('/');
+ return parts[parts.length - 1];
+}
+
+/**
+ * Construct published resource URL from canonical URL
+ *
+ * @param canonicalUrl - The canonical URL of the resource
+ * @returns The URL with .json extension
+ */
+function getPublishedUrl(canonicalUrl: string): string {
+ // If already ends with .json, return as-is
+ if (canonicalUrl.endsWith('.json')) {
+ return canonicalUrl;
+ }
+ // Add .json extension
+ return `${canonicalUrl}.json`;
+}
+
+/**
+ * Construct CI build URL from canonical URL
+ *
+ * @param canonicalUrl - The canonical URL of the resource
+ * @returns The CI build URL, or null if not a GitHub URL
+ */
+function getCIBuildUrl(canonicalUrl: string): string | null {
+ const githubInfo = parseGitHubUrl(canonicalUrl);
+ if (!githubInfo) {
+ return null;
+ }
+
+ const resourceId = extractResourceId(canonicalUrl);
+ return `https://${githubInfo.profile}.github.io/${githubInfo.repo}/${resourceId}.json`;
+}
+
+/**
+ * Fetch a resource from a URL with timeout
+ *
+ * @param url - The URL to fetch from
+ * @param timeout - Timeout in milliseconds
+ * @returns The fetched resource, or null if fetch fails
+ */
+async function fetchWithTimeout(url: string, timeout: number): Promise {
+ const controller = new AbortController();
+ const timeoutId = setTimeout(() => controller.abort(), timeout);
+
+ try {
+ serviceLogger.debug(`Fetching resource from: ${url}`);
+ const response = await fetch(url, {
+ signal: controller.signal,
+ headers: {
+ 'Accept': 'application/fhir+json, application/json',
+ },
+ });
+
+ clearTimeout(timeoutId);
+
+ if (!response.ok) {
+ serviceLogger.debug(`Failed to fetch from ${url}: ${response.status} ${response.statusText}`);
+ return null;
+ }
+
+ const data = await response.json();
+ serviceLogger.info(`Successfully loaded resource from: ${url}`);
+ return data;
+ } catch (error) {
+ clearTimeout(timeoutId);
+ if (error instanceof Error) {
+ if (error.name === 'AbortError') {
+ serviceLogger.warn(`Request timeout for: ${url}`);
+ } else {
+ serviceLogger.debug(`Error fetching from ${url}: ${error.message}`);
+ }
+ }
+ return null;
+ }
+}
+
+/**
+ * Load a FHIR resource by its canonical URL
+ *
+ * This function attempts to load a FHIR resource (ValueSet, CodeSystem, ConceptMap, etc.)
+ * from external sources using a fallback strategy:
+ *
+ * 1. If allowPublished is true, try the published URL (canonical URL + .json)
+ * 2. If that fails and allowCIBuild is true, try the CI build URL
+ *
+ * @param canonicalUrl - The canonical URL of the FHIR resource
+ * @param options - Loading options
+ * @returns The loaded FHIR resource, or null if not found
+ *
+ * @example
+ * ```typescript
+ * // Load a ValueSet from published URL
+ * const valueSet = await loadFHIRResource('http://hl7.org/fhir/ValueSet/administrative-gender');
+ *
+ * // Load with custom options
+ * const codeSystem = await loadFHIRResource(
+ * 'https://profile.github.io/repo/CodeSystem/my-codes',
+ * { allowCIBuild: true, allowPublished: false }
+ * );
+ * ```
+ */
+export async function loadFHIRResource(
+ canonicalUrl: string,
+ options: FHIRResourceLoadOptions = {}
+): Promise {
+ const opts = { ...DEFAULT_OPTIONS, ...options };
+
+ serviceLogger.info(`Loading FHIR resource: ${canonicalUrl}`);
+
+ // Check cache first if caching is enabled
+ if (opts.cache && resourceCache.has(canonicalUrl)) {
+ serviceLogger.debug(`Returning cached resource: ${canonicalUrl}`);
+ return resourceCache.get(canonicalUrl)!;
+ }
+
+ let resource: FHIRResource | null = null;
+
+ // Try published URL first
+ if (opts.allowPublished) {
+ const publishedUrl = getPublishedUrl(canonicalUrl);
+ resource = await fetchWithTimeout(publishedUrl, opts.timeout!);
+
+ if (resource) {
+ serviceLogger.info(`Loaded resource from published URL: ${publishedUrl}`);
+ if (opts.cache) {
+ resourceCache.set(canonicalUrl, resource);
+ }
+ return resource;
+ }
+ }
+
+ // Fallback to CI build URL
+ if (opts.allowCIBuild) {
+ const ciBuildUrl = getCIBuildUrl(canonicalUrl);
+ if (ciBuildUrl) {
+ resource = await fetchWithTimeout(ciBuildUrl, opts.timeout!);
+
+ if (resource) {
+ serviceLogger.info(`Loaded resource from CI build URL: ${ciBuildUrl}`);
+ if (opts.cache) {
+ resourceCache.set(canonicalUrl, resource);
+ }
+ return resource;
+ }
+ }
+ }
+
+ serviceLogger.warn(`Failed to load FHIR resource: ${canonicalUrl}`);
+ return null;
+}
+
+/**
+ * Load multiple FHIR resources in parallel
+ *
+ * @param canonicalUrls - Array of canonical URLs to load
+ * @param options - Loading options (applied to all resources)
+ * @returns Array of loaded resources (nulls for failed loads)
+ */
+export async function loadMultipleFHIRResources(
+ canonicalUrls: string[],
+ options: FHIRResourceLoadOptions = {}
+): Promise<(FHIRResource | null)[]> {
+ serviceLogger.info(`Loading ${canonicalUrls.length} FHIR resources in parallel`);
+
+ const promises = canonicalUrls.map(url => loadFHIRResource(url, options));
+ return Promise.all(promises);
+}
+
+/**
+ * Clear the resource cache
+ *
+ * @param canonicalUrl - Optional specific URL to clear, or clear all if not provided
+ */
+export function clearResourceCache(canonicalUrl?: string): void {
+ if (canonicalUrl) {
+ resourceCache.delete(canonicalUrl);
+ serviceLogger.debug(`Cleared cache for: ${canonicalUrl}`);
+ } else {
+ resourceCache.clear();
+ serviceLogger.debug('Cleared entire resource cache');
+ }
+}
+
+/**
+ * Get the current cache size
+ *
+ * @returns Number of cached resources
+ */
+export function getCacheSize(): number {
+ return resourceCache.size;
+}
+
+/**
+ * Check if a resource is in the cache
+ *
+ * @param canonicalUrl - The canonical URL to check
+ * @returns True if the resource is cached
+ */
+export function isResourceCached(canonicalUrl: string): boolean {
+ return resourceCache.has(canonicalUrl);
+}
+
+/**
+ * Preload FHIR resources for better performance
+ *
+ * Useful for preloading commonly used resources during application startup
+ *
+ * @param canonicalUrls - Array of canonical URLs to preload
+ * @param options - Loading options
+ */
+export async function preloadFHIRResources(
+ canonicalUrls: string[],
+ options: FHIRResourceLoadOptions = {}
+): Promise {
+ serviceLogger.info(`Preloading ${canonicalUrls.length} FHIR resources`);
+ await loadMultipleFHIRResources(canonicalUrls, options);
+}
+
+/**
+ * FHIR Resource Loader Service
+ */
+const FHIRResourceLoaderService = {
+ loadFHIRResource,
+ loadMultipleFHIRResources,
+ clearResourceCache,
+ getCacheSize,
+ isResourceCached,
+ preloadFHIRResources,
+};
+
+export default FHIRResourceLoaderService;
diff --git a/src/utils/fhirResourceIntegration.tsx b/src/utils/fhirResourceIntegration.tsx
new file mode 100644
index 000000000..cf25099dd
--- /dev/null
+++ b/src/utils/fhirResourceIntegration.tsx
@@ -0,0 +1,338 @@
+/**
+ * Example integration of FHIR Resource Loader Service
+ *
+ * This file demonstrates how to replace static FHIR resource imports
+ * with dynamic loading using the FHIR Resource Loader service.
+ *
+ * Usage: Import and use these helper functions in components that need FHIR resources.
+ */
+
+import React from 'react';
+import {
+ loadFHIRResource,
+ loadMultipleFHIRResources,
+ preloadFHIRResources,
+ FHIRResource,
+ FHIRResourceLoadOptions,
+} from '../services/fhirResourceLoaderService';
+
+/**
+ * Common FHIR value sets that can be preloaded
+ */
+export const COMMON_VALUE_SETS = [
+ 'http://hl7.org/fhir/ValueSet/administrative-gender',
+ 'http://hl7.org/fhir/ValueSet/marital-status',
+ 'http://hl7.org/fhir/ValueSet/languages',
+ 'http://hl7.org/fhir/ValueSet/contact-point-system',
+ 'http://hl7.org/fhir/ValueSet/contact-point-use',
+];
+
+/**
+ * Hook to load a FHIR ValueSet
+ *
+ * @example
+ * ```tsx
+ * function MyComponent() {
+ * const { valueSet, loading, error } = useFHIRValueSet(
+ * 'http://hl7.org/fhir/ValueSet/administrative-gender'
+ * );
+ *
+ * if (loading) return Loading...
;
+ * if (error) return Error: {error}
;
+ * if (!valueSet) return ValueSet not found
;
+ *
+ * return {valueSet.name}
;
+ * }
+ * ```
+ */
+export function useFHIRValueSet(canonicalUrl: string, options?: FHIRResourceLoadOptions) {
+ const [valueSet, setValueSet] = React.useState(null);
+ const [loading, setLoading] = React.useState(true);
+ const [error, setError] = React.useState(null);
+
+ React.useEffect(() => {
+ let mounted = true;
+
+ async function load() {
+ try {
+ setLoading(true);
+ setError(null);
+ const resource = await loadFHIRResource(canonicalUrl, options);
+
+ if (mounted) {
+ if (resource) {
+ setValueSet(resource);
+ } else {
+ setError('ValueSet not found');
+ }
+ setLoading(false);
+ }
+ } catch (err) {
+ if (mounted) {
+ setError(err instanceof Error ? err.message : 'Failed to load ValueSet');
+ setLoading(false);
+ }
+ }
+ }
+
+ load();
+
+ return () => {
+ mounted = false;
+ };
+ }, [canonicalUrl, options]);
+
+ return { valueSet, loading, error };
+}
+
+/**
+ * Load ValueSets for a Questionnaire
+ *
+ * Extracts and loads all ValueSets referenced in a FHIR Questionnaire
+ *
+ * @example
+ * ```typescript
+ * const valueSets = await loadQuestionnaireValueSets(questionnaire);
+ * console.log(`Loaded ${valueSets.length} value sets`);
+ * ```
+ */
+export async function loadQuestionnaireValueSets(
+ questionnaire: any,
+ options?: FHIRResourceLoadOptions
+): Promise {
+ if (!questionnaire.item) {
+ return [];
+ }
+
+ // Extract all answerValueSet URLs from questionnaire items
+ const valueSetUrls: string[] = [];
+
+ const extractValueSets = (items: any[]): void => {
+ items.forEach(item => {
+ if (item.answerValueSet) {
+ valueSetUrls.push(item.answerValueSet);
+ }
+ if (item.item) {
+ extractValueSets(item.item);
+ }
+ });
+ };
+
+ extractValueSets(questionnaire.item);
+
+ // Remove duplicates
+ const uniqueUrls = Array.from(new Set(valueSetUrls));
+
+ // Load all value sets in parallel
+ const resources = await loadMultipleFHIRResources(uniqueUrls, options);
+
+ // Filter out nulls (failed loads)
+ return resources.filter((r): r is FHIRResource => r !== null);
+}
+
+/**
+ * Load a CodeSystem and check if a code is valid
+ *
+ * @example
+ * ```typescript
+ * const isValid = await validateCode(
+ * 'http://hl7.org/fhir/CodeSystem/observation-category',
+ * 'vital-signs'
+ * );
+ * ```
+ */
+export async function validateCode(
+ codeSystemUrl: string,
+ code: string,
+ options?: FHIRResourceLoadOptions
+): Promise {
+ const codeSystem = await loadFHIRResource(codeSystemUrl, options);
+
+ if (!codeSystem) {
+ throw new Error(`CodeSystem not found: ${codeSystemUrl}`);
+ }
+
+ if (!codeSystem.concept) {
+ return false;
+ }
+
+ // Check if code exists in concepts
+ const findCode = (concepts: any[]): boolean => {
+ for (const concept of concepts) {
+ if (concept.code === code) {
+ return true;
+ }
+ // Check nested concepts
+ if (concept.concept && findCode(concept.concept)) {
+ return true;
+ }
+ }
+ return false;
+ };
+
+ return findCode(codeSystem.concept);
+}
+
+/**
+ * Get display text for a code from a CodeSystem
+ *
+ * @example
+ * ```typescript
+ * const display = await getCodeDisplay(
+ * 'http://hl7.org/fhir/CodeSystem/observation-category',
+ * 'vital-signs'
+ * );
+ * console.log(display); // "Vital Signs"
+ * ```
+ */
+export async function getCodeDisplay(
+ codeSystemUrl: string,
+ code: string,
+ options?: FHIRResourceLoadOptions
+): Promise {
+ const codeSystem = await loadFHIRResource(codeSystemUrl, options);
+
+ if (!codeSystem || !codeSystem.concept) {
+ return null;
+ }
+
+ // Find the concept with matching code
+ const findDisplay = (concepts: any[]): string | null => {
+ for (const concept of concepts) {
+ if (concept.code === code) {
+ return concept.display || null;
+ }
+ // Check nested concepts
+ if (concept.concept) {
+ const display = findDisplay(concept.concept);
+ if (display) return display;
+ }
+ }
+ return null;
+ };
+
+ return findDisplay(codeSystem.concept);
+}
+
+/**
+ * Expand a ValueSet to get all codes
+ *
+ * Note: This performs a simple expansion. For full FHIR terminology services,
+ * consider using a terminology server.
+ *
+ * @example
+ * ```typescript
+ * const codes = await expandValueSet(
+ * 'http://hl7.org/fhir/ValueSet/administrative-gender'
+ * );
+ * ```
+ */
+export async function expandValueSet(
+ valueSetUrl: string,
+ options?: FHIRResourceLoadOptions
+): Promise<{ code: string; display?: string; system?: string }[]> {
+ const valueSet = await loadFHIRResource(valueSetUrl, options);
+
+ if (!valueSet) {
+ return [];
+ }
+
+ const codes: { code: string; display?: string; system?: string }[] = [];
+
+ // Handle compose.include
+ if (valueSet.compose?.include) {
+ for (const include of valueSet.compose.include) {
+ if (include.concept) {
+ // Explicitly listed concepts
+ include.concept.forEach((concept: any) => {
+ codes.push({
+ code: concept.code,
+ display: concept.display,
+ system: include.system,
+ });
+ });
+ } else if (include.system) {
+ // Include all codes from a system - would need to load the CodeSystem
+ const codeSystem = await loadFHIRResource(include.system, options);
+ if (codeSystem?.concept) {
+ const extractCodes = (concepts: any[]): void => {
+ concepts.forEach(concept => {
+ codes.push({
+ code: concept.code,
+ display: concept.display,
+ system: include.system,
+ });
+ if (concept.concept) {
+ extractCodes(concept.concept);
+ }
+ });
+ };
+ extractCodes(codeSystem.concept);
+ }
+ }
+ }
+ }
+
+ return codes;
+}
+
+/**
+ * Initialize FHIR resource loading
+ *
+ * Call this during application startup to preload common resources
+ *
+ * @example
+ * ```typescript
+ * // In App.js or index.js
+ * initializeFHIRResources().catch(err => {
+ * console.warn('Failed to preload FHIR resources:', err);
+ * });
+ * ```
+ */
+export async function initializeFHIRResources(): Promise {
+ // Preload common value sets in the background
+ await preloadFHIRResources(COMMON_VALUE_SETS);
+}
+
+/**
+ * React context for FHIR resources (optional enhancement)
+ *
+ * Provides a centralized way to manage FHIR resource loading
+ */
+export const FHIRResourceContext = React.createContext<{
+ loadResource: typeof loadFHIRResource;
+ loadMultiple: typeof loadMultipleFHIRResources;
+}>({
+ loadResource: loadFHIRResource,
+ loadMultiple: loadMultipleFHIRResources,
+});
+
+/**
+ * Provider component for FHIR resources
+ */
+export function FHIRResourceProvider({ children }: { children: React.ReactNode }) {
+ React.useEffect(() => {
+ // Preload common resources on mount
+ initializeFHIRResources().catch(err => {
+ console.warn('Failed to preload FHIR resources:', err);
+ });
+ }, []);
+
+ return (
+
+ {children}
+
+ );
+}
+
+/**
+ * Hook to access FHIR resource loader from context
+ */
+export function useFHIRResourceLoader() {
+ return React.useContext(FHIRResourceContext);
+}