fhir-package-installer
Version:
A utility module for downloading, indexing, caching, and managing FHIR packages from the FHIR Package Registry and Simplifier
1 lines • 212 kB
Source Map (JSON)
{"version":3,"sources":["../src/index.ts","../node_modules/yocto-queue/index.js","../node_modules/p-limit/index.js"],"sourcesContent":["/* eslint-disable @typescript-eslint/no-explicit-any */\n/**\n * © Copyright Outburn Ltd. 2022-2025 All Rights Reserved\n * Project name: FHIR-Package-Installer\n */\n\nimport https from 'https';\nimport http from 'http';\nimport fs from 'fs-extra';\nimport pLimit from 'p-limit';\nimport path from 'path';\nimport { Readable } from 'stream';\nimport { finished, pipeline } from 'stream/promises';\nimport * as tar from 'tar-stream';\nimport * as zlib from 'zlib';\nimport os from 'os';\nimport semver from 'semver';\nimport crypto from 'crypto';\n \n\nimport type {\n FileInPackageIndex,\n PackageIndex,\n PackageManifest\n} from '@outburn/types';\n\nimport type {\n FpiConfig,\n PackageResource,\n DownloadPackageOptions,\n InstallPackageOptions\n} from './types';\nimport { Logger, FhirPackageIdentifier } from '@outburn/types';\n\nconst tempDirs = new Set<string>();\nlet tempCleanupRegistered = false;\n\nconst registerTempCleanup = (): void => {\n if (tempCleanupRegistered) return;\n tempCleanupRegistered = true;\n process.once('exit', () => {\n for (const dir of tempDirs) {\n try {\n fs.removeSync(dir);\n } catch {\n // best-effort cleanup\n }\n }\n tempDirs.clear();\n });\n};\n\nconst createTempDir = (baseDir?: string): string => {\n registerTempCleanup();\n const parentDir = baseDir ?? os.tmpdir();\n fs.ensureDirSync(parentDir);\n const dir = fs.mkdtempSync(path.join(parentDir, 'fhir-package-installer-'));\n tempDirs.add(dir);\n return dir;\n};\n\n// NOTE: This is injected at build time via tsup `define` (see tsup.config.ts).\n// It must NOT be read from package.json at runtime (supports SEA/bundling scenarios).\ndeclare const __FPI_VERSION__: string | undefined;\nconst FPI_VERSION = typeof __FPI_VERSION__ === 'string' && __FPI_VERSION__.trim().length > 0\n ? __FPI_VERSION__\n : '0.0.0';\nconst FPI_INDEX_CACHE_VERSION = (() => {\n const v = semver.parse(FPI_VERSION);\n if (!v) return '0.0';\n return `${v.major}.${v.minor}`;\n})();\nconst FPI_STAGING_MAX_AGE_MS = 24 * 60 * 60 * 1000;\nconst FPI_MATERIALIZATION_MARKER = '.fpi.materialized';\nconst DEPENDENCY_CLAIM_WAIT_MS = 1000;\nconst DEPENDENCY_POST_CLAIM_YIELD_MS = 500;\nconst DEPENDENCY_WAIT_LOG_INTERVAL_MS = 5000;\nconst PACKAGE_INSTALL_WAIT_LOG_INTERVAL_MS = 5000;\nconst DEPENDENCY_PEER_HANDOFF_WAIT_MS = 3000;\nconst DEPENDENCY_PEER_DISCOVERY_GRACE_MS = 300;\n\n/**\n * Mapping from core FHIR packages to their implicit dependencies\n * Based on https://chat.fhir.org/#narrow/stream/179239-tooling/topic/New.20Implicit.20Package/near/325318949\n */\nconst IMPLICIT_DEPENDENCIES_MAP: Record<string, string[]> = {\n 'hl7.fhir.r3.core': [\n 'hl7.terminology.r3', \n 'hl7.fhir.uv.extensions.r3'\n ],\n 'hl7.fhir.r4.core': [\n 'hl7.terminology.r4',\n 'hl7.fhir.uv.extensions.r4'\n ],\n 'hl7.fhir.r5.core': [\n 'hl7.terminology.r5',\n 'hl7.fhir.uv.extensions.r5'\n ]\n};\n\nconst IMPLICIT_PACKAGE_IDS = (() => {\n const s = new Set<string>();\n for (const ids of Object.values(IMPLICIT_DEPENDENCIES_MAP)) {\n for (const id of ids) s.add(id);\n }\n return s;\n})();\n\n// TTL for cached registry lookups (stored under `cachePath`)\nconst DEFAULT_REGISTRY_TTL_MS = 30 * 60 * 1000; // 30 minutes\n\n// Process-wide in-memory cache sizing\nconst MEM_CACHE_MAX_ENTRIES = 500;\n\ntype BoundedTtlEntry<V> = {\n value: V;\n expiresAt: number;\n};\n\ntype GetDependenciesOptions = {\n rootPackage?: string | FhirPackageIdentifier;\n explicitImplicitVersions?: ReadonlyMap<string, string>;\n includePlanningFallbacks?: boolean;\n};\n\n// Lightweight bounded TTL cache with LRU-ish behavior via Map insertion order.\nclass BoundedTtlCache<K, V> {\n private readonly maxEntries: number;\n private readonly map = new Map<K, BoundedTtlEntry<V>>();\n\n constructor(maxEntries: number) {\n this.maxEntries = maxEntries;\n }\n\n get(key: K): V | undefined {\n const entry = this.map.get(key);\n if (!entry) return undefined;\n if (Date.now() >= entry.expiresAt) {\n this.map.delete(key);\n return undefined;\n }\n // Touch for LRU-ish behavior.\n this.map.delete(key);\n this.map.set(key, entry);\n return entry.value;\n }\n\n set(key: K, value: V, ttlMs: number): void {\n const expiresAt = Date.now() + ttlMs;\n const entry: BoundedTtlEntry<V> = { value, expiresAt };\n\n // Touch for LRU-ish behavior.\n this.map.delete(key);\n this.map.set(key, entry);\n\n while (this.map.size > this.maxEntries) {\n const firstKey = this.map.keys().next().value as K | undefined;\n if (firstKey === undefined) break;\n this.map.delete(firstKey);\n }\n }\n\n delete(key: K): boolean {\n return this.map.delete(key);\n }\n}\n\n// ---- Module-level (process-wide) single-flight maps ----\n// These are intentionally module-scoped so multiple FhirPackageInstaller instances within\n// the same Node process coordinate and don't duplicate work.\nconst inFlightJson = new Map<string, Promise<any>>();\nconst inFlightTarball = new Map<string, Promise<string>>();\nconst inFlightIndex = new Map<string, Promise<PackageIndex>>();\nconst inFlightImplicitEffectiveVersion = new Map<string, Promise<string>>();\n\n// Process-wide cache for implicit package effective versions.\n// Keyed by {registryUrl, cachePath, packageId} so different installer configs don't bleed into each other.\nconst implicitEffectiveVersionCache = new BoundedTtlCache<string, string>(MEM_CACHE_MAX_ENTRIES);\n\n// Process-wide cache for implicit package resolution failures.\n// Keyed the same way as the winner cache so repeated downstream calls can surface a stable error.\nconst implicitResolutionFailureCache = new BoundedTtlCache<string, Error>(MEM_CACHE_MAX_ENTRIES);\n\nclass ImplicitPackageResolutionError extends Error {\n public readonly packageId: string;\n public readonly attemptedVersions: string[];\n public readonly registryUrl: string;\n public readonly cachePath: string;\n public readonly causes: string[];\n\n constructor(args: {\n packageId: string;\n attemptedVersions: string[];\n registryUrl: string;\n cachePath: string;\n causes?: string[];\n }) {\n const attempted = args.attemptedVersions.length > 0 ? args.attemptedVersions.join(', ') : '(none)';\n const prefix = `Failed to resolve implicit package ${args.packageId}`;\n const meta = `attemptedVersions=[${attempted}] registryUrl=${args.registryUrl} cachePath=${args.cachePath}`;\n const causeText = (args.causes && args.causes.length > 0)\n ? ` causes=[${args.causes.join(' | ')}]`\n : '';\n super(`${prefix}. ${meta}.${causeText}`);\n this.name = 'ImplicitPackageResolutionError';\n this.packageId = args.packageId;\n this.attemptedVersions = args.attemptedVersions;\n this.registryUrl = args.registryUrl;\n this.cachePath = args.cachePath;\n this.causes = args.causes ?? [];\n }\n}\n\ntype FhirPackageInstallStep =\n | 'download-tarball'\n | 'extract-tarball'\n | 'cache-package'\n | 'generate-index';\n\nclass FhirPackageInstallError extends Error {\n public readonly packageId: string;\n public readonly version: string;\n public readonly registryUrl: string;\n public readonly cachePath: string;\n public readonly step: FhirPackageInstallStep;\n public readonly tarballUrl?: string;\n\n constructor(args: {\n packageId: string;\n version: string;\n registryUrl: string;\n cachePath: string;\n step: FhirPackageInstallStep;\n tarballUrl?: string;\n cause?: unknown;\n }) {\n const safe = (v: unknown): string => {\n if (v == null) return '';\n if (typeof v === 'string') return v;\n if (v instanceof Error) return v.message;\n try {\n return JSON.stringify(v);\n } catch {\n return String(v);\n }\n };\n\n const pkg = `${args.packageId}@${args.version}`;\n const meta = `step=${args.step} registryUrl=${args.registryUrl} cachePath=${args.cachePath}`;\n const tarball = args.tarballUrl ? ` tarballUrl=${args.tarballUrl}` : '';\n const causeText = args.cause ? ` Cause: ${safe(args.cause)}` : '';\n super(`Failed to install ${pkg}. ${meta}.${tarball}${causeText}`, { cause: args.cause });\n this.name = 'FhirPackageInstallError';\n this.packageId = args.packageId;\n this.version = args.version;\n this.registryUrl = args.registryUrl;\n this.cachePath = args.cachePath;\n this.step = args.step;\n this.tarballUrl = args.tarballUrl;\n }\n}\n\nconst withSingleFlight = async <T>(\n map: Map<string, Promise<T>>,\n key: string,\n fn: () => Promise<T>\n): Promise<T> => {\n const existing = map.get(key);\n if (existing) return existing;\n\n const p = (async () => {\n try {\n return await fn();\n } finally {\n map.delete(key);\n }\n })();\n\n map.set(key, p);\n return p;\n};\n\nconst sha256Hex = (value: string): string => crypto.createHash('sha256').update(value).digest('hex');\n\ntype DiskCacheEnvelope<T> = {\n expiresAt: number;\n data: T;\n};\n\ntype MemCacheEnvelope<T> = {\n expiresAt?: number;\n value: T;\n};\n\n// ---- Module-level (process-wide) TTL memory cache ----\n// Shared across all FhirPackageInstaller instances in the same Node process.\nconst memCache = new Map<string, MemCacheEnvelope<any>>();\n\nconst memGet = <T>(key: string): T | null => {\n const e = memCache.get(key);\n if (!e) return null;\n if (typeof e.expiresAt === 'number') {\n if (Date.now() >= e.expiresAt) {\n memCache.delete(key);\n return null;\n }\n }\n return e.value as T;\n};\n\nconst memSet = <T>(key: string, value: T, ttlMs: number): void => {\n const expiresAt = Date.now() + ttlMs;\n // Update insertion order for LRU-ish behavior.\n memCache.delete(key);\n memCache.set(key, { expiresAt, value });\n while (memCache.size > MEM_CACHE_MAX_ENTRIES) {\n const firstKey = memCache.keys().next().value as string | undefined;\n if (!firstKey) break;\n memCache.delete(firstKey);\n }\n};\n\nconst memSetNoTtl = <T>(key: string, value: T): void => {\n // Update insertion order for LRU-ish behavior.\n memCache.delete(key);\n memCache.set(key, { value });\n while (memCache.size > MEM_CACHE_MAX_ENTRIES) {\n const firstKey = memCache.keys().next().value as string | undefined;\n if (!firstKey) break;\n memCache.delete(firstKey);\n }\n};\n\n/**\n * Default logger is a no-op.\n *\n * This is a library module: it should not write to stdout/stderr unless the caller\n * explicitly provides a logger (e.g. a console-mapped logger in CLI apps).\n */\nconst defaultLogger: Logger = {\n info: () => undefined,\n warn: () => undefined,\n error: () => undefined\n};\n\n/**\n * Max number of concurrent file operations (read / write))\n */\n// Cap concurrency to reduce risk of EMFILE/too-many-open-files on Windows.\nconst limit = pLimit(Math.max(4, Math.min(32, os.cpus().length)));\n\n/**\n * Generates an index entry for the package resource\n * @param filename resource filename\n * @param content resource content\n * @returns FileInPackageIndex object \n */\nconst extractResourceIndexEntry = (filename: string, content: PackageResource): FileInPackageIndex => {\n const evalAttribute = (att: any | any[]) => (typeof att === 'string' ? att : undefined);\n const indexEntry: FileInPackageIndex = {\n filename,\n resourceType: content.resourceType,\n id: content.id,\n url: evalAttribute(content.url),\n name: evalAttribute(content.name),\n version: evalAttribute(content.version),\n kind: evalAttribute(content.kind),\n type: evalAttribute(content.type),\n supplements: evalAttribute(content.supplements),\n content: evalAttribute(content.content),\n baseDefinition: evalAttribute(content.baseDefinition),\n derivation: evalAttribute(content.derivation),\n date: evalAttribute(content.date)\n };\n return indexEntry;\n};\n\nexport class FhirPackageInstaller {\n private logger: Logger = defaultLogger;\n private registryUrl = 'https://packages.fhir.org';\n private registryDisabled = false;\n private registryToken?: string; // optional token for private registries\n private requestTimeoutMs = 90000; // 90 seconds\n private extractTimeoutMs = 60000; // 60 seconds\n private registryTtlMs = DEFAULT_REGISTRY_TTL_MS;\n /**\n * Path to the FHIR package cache directory.\n * This directory is used to store downloaded and extracted FHIR packages.\n * If the directory does not exist, it will be created.\n * Default location follows FHIR spec:\n * - User apps: ~/.fhir/packages (Windows: C:\\Users\\<user>\\.fhir\\packages)\n * - System services: /var/lib/.fhir/packages (Windows: %ProgramData%\\.fhir\\packages)\n */\n private cachePath!: string;\n private skipExamples = false; // skip dependency installation of example packages\n private allowHttp = false; // allow HTTP URLs for testing\n private resolvingImplicitDeps = new Set<string>();\n private installingPackages = new Set<string>();\n\n private formatPackageForDebug(packageObject: FhirPackageIdentifier): string {\n return `${packageObject.id}@${packageObject.version}`;\n }\n\n private formatMaterializationStatusForDebug(status: {\n complete: boolean;\n reason: string;\n missingFiles: string[];\n }): string {\n const missingPreview = status.missingFiles.length > 0\n ? ` missingFiles=${status.missingFiles.slice(0, 3).join(',')}${status.missingFiles.length > 3 ? ',...' : ''}`\n : '';\n return `materialization=${status.complete ? 'complete' : 'incomplete'} reason=${status.reason}${missingPreview}`;\n }\n\n private async describePackageInstallWaitState(packageObject: FhirPackageIdentifier): Promise<string> {\n try {\n const status = await this.getPackageMaterializationStatus(packageObject, { emitTiming: true });\n return this.formatMaterializationStatusForDebug(status);\n } catch (error) {\n return `materialization-check-error=${error instanceof Error ? error.message : String(error)}`;\n }\n }\n\n private formatElapsedMs(startedAtNs: bigint): string {\n return (Number(process.hrtime.bigint() - startedAtNs) / 1_000_000).toFixed(1);\n }\n\n private async withDebugTiming<T>(\n label: string,\n action: () => Promise<T>,\n describeResult?: (result: T) => string\n ): Promise<T> {\n if (!this.logger.debug || typeof this.logger.debug !== 'function') {\n return await action();\n }\n\n const startedAtNs = process.hrtime.bigint();\n try {\n const result = await action();\n const resultText = describeResult ? ` ${describeResult(result)}` : '';\n this.logger.debug(`[timing] ${label} completed in ${this.formatElapsedMs(startedAtNs)}ms.${resultText}`);\n return result;\n } catch (error) {\n this.logger.debug(\n `[timing] ${label} failed in ${this.formatElapsedMs(startedAtNs)}ms: ${error instanceof Error ? error.message : String(error)}`\n );\n throw error;\n }\n }\n\n private getPackageKey(packageObject: FhirPackageIdentifier): string {\n return `${packageObject.id}#${packageObject.version}`;\n }\n\n private normalizeDependencies(dependencies: Record<string, string>): Record<string, string> {\n if (dependencies['hl7.fhir.r4.core'] === '4.0.0') {\n return {\n ...dependencies,\n 'hl7.fhir.r4.core': '4.0.1',\n };\n }\n return dependencies;\n }\n \n constructor(config?: FpiConfig) {\n const {\n logger,\n registryUrl,\n registryToken,\n cachePath,\n skipExamples,\n allowHttp,\n requestTimeoutMs,\n extractTimeoutMs,\n registryTtlMs\n } = config || {} as FpiConfig;\n\n // Set logger first so getDefaultCachePath() can use it for warnings\n if (logger) {\n this.logger = logger;\n }\n\n const normalizedCachePath = ((): string | undefined => {\n if (cachePath == null) {\n return undefined;\n }\n if (typeof cachePath !== 'string') {\n this.logger.warn?.(\n `Non-string cachePath provided (${typeof cachePath}); falling back to FHIR spec default cache path.`\n );\n return undefined;\n }\n const trimmed = cachePath.trim();\n if (trimmed === '' || trimmed.toLowerCase() === 'n/a') {\n this.logger.warn?.(\n 'Non-usable cachePath provided (empty/whitespace or \"n/a\"); falling back to FHIR spec default cache path.'\n );\n return undefined;\n }\n return trimmed;\n })();\n\n this.cachePath = normalizedCachePath ?? this.getDefaultCachePath();\n\n if (registryUrl) {\n const normalized = registryUrl.trim();\n this.registryUrl = registryUrl;\n if (normalized.toLowerCase() === 'n/a') {\n this.registryUrl = 'n/a';\n this.registryDisabled = true;\n }\n }\n if (registryToken) {\n this.registryToken = registryToken;\n }\n if (allowHttp) {\n this.allowHttp = allowHttp;\n }\n\n if (typeof requestTimeoutMs === 'number' && Number.isFinite(requestTimeoutMs) && requestTimeoutMs > 0) {\n this.requestTimeoutMs = requestTimeoutMs;\n }\n if (typeof extractTimeoutMs === 'number' && Number.isFinite(extractTimeoutMs) && extractTimeoutMs > 0) {\n this.extractTimeoutMs = extractTimeoutMs;\n }\n // Unify registry TTL config.\n const effectiveRegistryTtlMs =\n (typeof registryTtlMs === 'number' && Number.isFinite(registryTtlMs) && registryTtlMs > 0)\n ? registryTtlMs\n : undefined;\n if (typeof effectiveRegistryTtlMs === 'number') {\n this.registryTtlMs = effectiveRegistryTtlMs;\n }\n if (skipExamples) {\n this.skipExamples = skipExamples;\n }\n }\n\n /**\n * Determines the default FHIR package cache path based on FHIR specifications:\n * https://confluence.hl7.org/display/FHIR/FHIR+Package+Cache\n * \n * For user applications:\n * - Windows: C:\\Users\\<username>\\.fhir\\packages\n * - Unix/Linux: ~/.fhir/packages\n * \n * For system services (daemons):\n * - Windows: %ProgramData%\\.fhir\\packages (typically C:\\ProgramData\\.fhir\\packages)\n * - Unix/Linux: /var/lib/.fhir/packages\n * \n * Behavior can be overridden via the FHIR_PACKAGE_CACHE_MODE environment variable:\n * - FHIR_PACKAGE_CACHE_MODE=system -> always use system service paths\n * - FHIR_PACKAGE_CACHE_MODE=user -> always use user paths\n */\n private getDefaultCachePath(): string {\n const isWindows = process.platform === 'win32';\n const homeDir = os.homedir();\n\n // Allow explicit override of cache mode via environment variable\n const cacheMode = process.env.FHIR_PACKAGE_CACHE_MODE?.toLowerCase();\n let isSystemService: boolean;\n\n if (cacheMode === 'system') {\n isSystemService = true;\n } else if (cacheMode === 'user') {\n isSystemService = false;\n } else {\n // Detect if running as a system service/daemon\n // On Windows: Check if homedir ends with the SYSTEM profile path (no real user home)\n // Using path.normalize() handles mixed separators and ensures consistent comparison\n // On Unix: Prefer a \"daemon-like\" heuristic rather than only checking for root:\n // - running as root (uid 0)\n // - not invoked via sudo (no SUDO_USER)\n // - missing common interactive-session variables (DISPLAY, SSH_CONNECTION, TERM)\n if (isWindows) {\n const normalizedHome = path.normalize(homeDir).toLowerCase();\n const systemProfileSuffix = path\n .normalize(path.join('Windows', 'System32', 'config', 'systemprofile'))\n .toLowerCase();\n isSystemService = normalizedHome.endsWith(systemProfileSuffix);\n } else {\n const isRoot = process.getuid?.() === 0;\n const isSudo = !!process.env.SUDO_USER;\n const hasDisplay = !!process.env.DISPLAY;\n const hasSshConnection = !!process.env.SSH_CONNECTION;\n const hasTerm = !!process.env.TERM;\n\n isSystemService = Boolean(\n isRoot &&\n !isSudo &&\n !hasDisplay &&\n !hasSshConnection &&\n !hasTerm\n );\n }\n }\n\n if (isSystemService) {\n if (isWindows) {\n // Use ProgramData environment variable as per FHIR spec.\n // If ProgramData is not set, fall back to \"C:\\\\ProgramData\" if it exists and is writable;\n // otherwise fall back to user home directory.\n let programData = process.env.ProgramData;\n\n if (!programData || programData.trim() === '') {\n const fallbackProgramData = 'C:\\\\ProgramData';\n try {\n if (fs.pathExistsSync(fallbackProgramData)) {\n fs.accessSync(fallbackProgramData, fs.constants.W_OK);\n this.logger.warn(\n 'ProgramData environment variable is not set; ' +\n 'using fallback \"C:\\\\ProgramData\" for system service cache directory.'\n );\n programData = fallbackProgramData;\n } else {\n this.logger.warn(\n 'ProgramData environment variable is not set and ' +\n 'fallback \"C:\\\\ProgramData\" does not exist. Falling back to user cache directory.'\n );\n }\n } catch {\n this.logger.warn(\n 'ProgramData environment variable is not set and ' +\n 'fallback \"C:\\\\ProgramData\" is not writable. Falling back to user cache directory.'\n );\n }\n }\n\n if (programData) {\n return path.join(programData, '.fhir', 'packages');\n }\n // ProgramData unavailable or not writable - use user home\n return path.join(homeDir, '.fhir', 'packages');\n } else {\n // Unix/Linux daemon location\n return '/var/lib/.fhir/packages';\n }\n }\n\n // Standard user location\n return path.join(homeDir, '.fhir', 'packages');\n }\n\n private async withDiskLock<T>(\n lockKey: string,\n fn: () => Promise<T>,\n options?: {\n debugLabel?: string;\n describeWaitState?: () => Promise<string>;\n waitLogIntervalMs?: number;\n proceedWithoutLockAfterTimeout?: boolean;\n }\n ): Promise<T> {\n // Lock files live under cachePath so we never write outside user-controlled boundaries.\n const locksDir = await this.ensureDiskCacheSubdir('locks');\n const lockPath = path.join(locksDir, `${sha256Hex(lockKey)}.lock`);\n\n const start = Date.now();\n const maxWaitMs = Math.max(1000, this.requestTimeoutMs);\n const staleMs = Math.max(2 * 60 * 1000, maxWaitMs * 2);\n const debugLabel = options?.debugLabel?.trim();\n const debugEnabled = Boolean(debugLabel) && typeof this.logger.debug === 'function';\n const waitLogIntervalMs = Math.max(250, options?.waitLogIntervalMs ?? PACKAGE_INSTALL_WAIT_LOG_INTERVAL_MS);\n const proceedWithoutLockAfterTimeout = options?.proceedWithoutLockAfterTimeout ?? true;\n let contentionObserved = false;\n let lastWaitLogAt = 0;\n\n const readWaitState = async (): Promise<string | null> => {\n if (!debugEnabled || !options?.describeWaitState) {\n return null;\n }\n try {\n return await options.describeWaitState();\n } catch (error) {\n return `wait-state-error=${error instanceof Error ? error.message : String(error)}`;\n }\n };\n\n while (true) {\n try {\n await fs.ensureDir(path.dirname(lockPath));\n await fs.writeFile(lockPath, `${process.pid}\\n${Date.now()}\\n`, { flag: 'wx' });\n if (debugEnabled) {\n const waitedMs = Date.now() - start;\n this.logger.debug?.(\n contentionObserved\n ? `Claimed ${debugLabel} after waiting ${waitedMs}ms.`\n : `Claimed ${debugLabel}.`\n );\n }\n const heartbeat = setInterval(() => {\n // Keep mtime fresh so waiters can distinguish live vs stale locks.\n fs.utimes(lockPath, new Date(), new Date()).catch(() => undefined);\n }, 1000);\n (heartbeat as any).unref?.();\n try {\n return await fn();\n } finally {\n clearInterval(heartbeat);\n await fs.remove(lockPath).catch(() => undefined);\n if (debugEnabled) {\n this.logger.debug?.(`Released ${debugLabel}.`);\n }\n }\n } catch (e: any) {\n if (e?.code !== 'EEXIST') {\n // If locking fails for other reasons, don't break functionality.\n if (debugEnabled) {\n this.logger.debug?.(\n `Skipping ${debugLabel} because the lock could not be created (${e?.code || e?.message || String(e)}); proceeding without the lock.`\n );\n }\n return await fn();\n }\n\n const elapsedMs = Date.now() - start;\n let lockAgeMs: number | null = null;\n let waitState: string | null = null;\n\n if (!contentionObserved && debugEnabled) {\n waitState = await readWaitState();\n this.logger.debug?.(\n `Another process holds ${debugLabel}; entering wait loop.` +\n `${waitState ? ` Current materialization state: ${waitState}.` : ''}`\n );\n }\n contentionObserved = true;\n\n // If the lock looks stale, attempt to break it.\n try {\n const stat = await fs.stat(lockPath);\n lockAgeMs = Date.now() - stat.mtimeMs;\n if (lockAgeMs > staleMs) {\n if (debugEnabled) {\n waitState ??= await readWaitState();\n this.logger.debug?.(\n `Breaking stale ${debugLabel} after waiting ${elapsedMs}ms (lockAgeMs=${Math.round(lockAgeMs)}).` +\n `${waitState ? ` Current materialization state: ${waitState}.` : ''}`\n );\n }\n await fs.remove(lockPath).catch(() => undefined);\n continue;\n }\n } catch {\n // ignore\n }\n\n if (Date.now() - start > maxWaitMs) {\n if (proceedWithoutLockAfterTimeout) {\n // Avoid deadlocks: proceed without the lock after waiting.\n if (debugEnabled) {\n waitState ??= await readWaitState();\n this.logger.debug?.(\n `Waited ${elapsedMs}ms for ${debugLabel}, exceeding maxWaitMs=${maxWaitMs}; proceeding without the lock.` +\n `${waitState ? ` Current materialization state: ${waitState}.` : ''}`\n );\n }\n return await fn();\n }\n\n if (debugEnabled && (Date.now() - lastWaitLogAt >= waitLogIntervalMs)) {\n waitState ??= await readWaitState();\n this.logger.debug?.(\n `Waited ${elapsedMs}ms for ${debugLabel}, exceeding maxWaitMs=${maxWaitMs}; continuing to wait for the live lock holder.` +\n `${waitState ? ` Current materialization state: ${waitState}.` : ''}`\n );\n lastWaitLogAt = Date.now();\n }\n }\n\n if (debugEnabled && (Date.now() - lastWaitLogAt >= waitLogIntervalMs)) {\n waitState ??= await readWaitState();\n const lockAgeText = typeof lockAgeMs === 'number' ? ` lockAgeMs=${Math.round(lockAgeMs)}.` : '';\n this.logger.debug?.(\n `Still waiting for ${debugLabel} after ${elapsedMs}ms.${lockAgeText}` +\n `${waitState ? ` Current materialization state: ${waitState}.` : ''}`\n );\n lastWaitLogAt = Date.now();\n }\n\n await new Promise((r) => setTimeout(r, 50 + Math.floor(Math.random() * 100)));\n }\n }\n }\n\n private async tryWithDiskLock<T>(\n lockKey: string,\n fn: () => Promise<T>,\n options?: {\n debugLabel?: string;\n }\n ): Promise<{ acquired: true; result: T } | { acquired: false }> {\n const locksDir = await this.ensureDiskCacheSubdir('locks');\n const lockPath = path.join(locksDir, `${sha256Hex(lockKey)}.lock`);\n const debugLabel = options?.debugLabel?.trim();\n const debugEnabled = Boolean(debugLabel) && typeof this.logger.debug === 'function';\n\n try {\n await fs.ensureDir(path.dirname(lockPath));\n await fs.writeFile(lockPath, `${process.pid}\\n${Date.now()}\\n`, { flag: 'wx' });\n if (debugEnabled) {\n this.logger.debug?.(`Claimed ${debugLabel}.`);\n }\n const heartbeat = setInterval(() => {\n fs.utimes(lockPath, new Date(), new Date()).catch(() => undefined);\n }, 1000);\n (heartbeat as any).unref?.();\n try {\n return { acquired: true, result: await fn() };\n } finally {\n clearInterval(heartbeat);\n await fs.remove(lockPath).catch(() => undefined);\n if (debugEnabled) {\n this.logger.debug?.(`Released ${debugLabel}.`);\n }\n }\n } catch (e: any) {\n if (e?.code === 'EEXIST') {\n return { acquired: false };\n }\n if (debugEnabled) {\n this.logger.debug?.(\n `Skipping ${debugLabel} because the lock could not be created (${e?.code || e?.message || String(e)}); proceeding without the lock.`\n );\n }\n return { acquired: true, result: await fn() };\n }\n }\n\n // ---- Per-cachePath persistent cache helpers ----\n private async ensureDiskCacheSubdir(name: string): Promise<string> {\n const dir = path.join(this.cachePath, '.fpi.cache', name);\n await fs.ensureDir(dir);\n return dir;\n }\n\n private async createWorkingTempDir(options?: { preferCache?: boolean }): Promise<string> {\n if (options?.preferCache !== false) {\n try {\n const cacheTempRoot = await this.ensureDiskCacheSubdir('tmp');\n return createTempDir(cacheTempRoot);\n } catch {\n // Fall back to the process temp directory if the cache-local temp root is unavailable.\n }\n }\n\n return createTempDir();\n }\n\n private getPackageInstallLockKey(packageObject: FhirPackageIdentifier): string {\n return `package-install|${packageObject.id}#${packageObject.version}`;\n }\n\n private async getInstallParticipantDir(packageObject: FhirPackageIdentifier): Promise<string> {\n const participantsRoot = await this.ensureDiskCacheSubdir('installers');\n return path.join(participantsRoot, sha256Hex(`install-participants|${this.getPackageKey(packageObject)}`));\n }\n\n private async withInstallParticipant<T>(packageObject: FhirPackageIdentifier, fn: () => Promise<T>): Promise<T> {\n const participantsDir = await this.getInstallParticipantDir(packageObject);\n const participantPath = path.join(\n participantsDir,\n `${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}.participant`\n );\n\n await fs.ensureDir(participantsDir);\n await fs.writeFile(participantPath, `${process.pid}\\n${Date.now()}\\n`, 'utf8');\n\n const heartbeat = setInterval(() => {\n fs.utimes(participantPath, new Date(), new Date()).catch(() => undefined);\n }, 1000);\n (heartbeat as any).unref?.();\n\n try {\n return await fn();\n } finally {\n clearInterval(heartbeat);\n await fs.remove(participantPath).catch(() => undefined);\n }\n }\n\n private async countActiveInstallParticipants(packageObject: FhirPackageIdentifier): Promise<number> {\n const participantsDir = await this.getInstallParticipantDir(packageObject);\n if (!await fs.exists(participantsDir)) {\n return 0;\n }\n\n const staleMs = Math.max(2 * 60 * 1000, this.requestTimeoutMs * 2);\n const now = Date.now();\n const entries = await fs.readdir(participantsDir);\n let activeCount = 0;\n\n for (const entry of entries) {\n const entryPath = path.join(participantsDir, entry);\n try {\n const stat = await fs.stat(entryPath);\n if (now - stat.mtimeMs <= staleMs) {\n activeCount += 1;\n } else {\n await fs.remove(entryPath).catch(() => undefined);\n }\n } catch {\n // Ignore disappearing or unreadable participant entries.\n }\n }\n\n return activeCount;\n }\n\n private async isPackageInstallLockHeld(packageObject: FhirPackageIdentifier): Promise<boolean> {\n const locksDir = await this.ensureDiskCacheSubdir('locks');\n const lockPath = path.join(locksDir, `${sha256Hex(this.getPackageInstallLockKey(packageObject))}.lock`);\n return await fs.exists(lockPath);\n }\n\n private async waitForPeerDependencyHandoff(\n rootPackage: FhirPackageIdentifier,\n pendingDependencies: FhirPackageIdentifier[]\n ): Promise<void> {\n if (pendingDependencies.length === 0) {\n return;\n }\n\n const startedAt = Date.now();\n let peerDiscoveryGraceApplied = false;\n while (Date.now() - startedAt < DEPENDENCY_PEER_HANDOFF_WAIT_MS) {\n for (const dependency of pendingDependencies) {\n if (await this.isStrictlyMaterialized(dependency) || await this.isPackageInstallLockHeld(dependency)) {\n return;\n }\n }\n\n if (await this.countActiveInstallParticipants(rootPackage) <= 1) {\n if (!peerDiscoveryGraceApplied) {\n peerDiscoveryGraceApplied = true;\n await new Promise((resolve) => setTimeout(resolve, DEPENDENCY_PEER_DISCOVERY_GRACE_MS));\n continue;\n }\n return;\n }\n\n await new Promise((resolve) => setTimeout(resolve, 50));\n }\n }\n\n private async withPackageInstallLock<T>(\n packageObject: FhirPackageIdentifier,\n fn: () => Promise<T>\n ): Promise<T> {\n const lockKey = this.getPackageInstallLockKey(packageObject);\n return await this.withDiskLock(lockKey, async () => {\n return await fn();\n }, {\n debugLabel: `package install ${this.formatPackageForDebug(packageObject)}`,\n describeWaitState: async () => await this.describePackageInstallWaitState(packageObject),\n proceedWithoutLockAfterTimeout: false,\n });\n }\n\n private async tryWithPackageInstallLock<T>(\n packageObject: FhirPackageIdentifier,\n fn: () => Promise<T>\n ): Promise<{ acquired: true; result: T } | { acquired: false }> {\n return await this.tryWithDiskLock(this.getPackageInstallLockKey(packageObject), fn, {\n debugLabel: `package install ${this.formatPackageForDebug(packageObject)}`,\n });\n }\n\n private async getStagingPath(): Promise<string> {\n return await this.ensureDiskCacheSubdir('staging');\n }\n\n private async cleanupStaleStagingDirectories(maxAgeMs: number = FPI_STAGING_MAX_AGE_MS): Promise<void> {\n try {\n const stagingPath = await this.getStagingPath();\n const entries = await fs.readdir(stagingPath);\n const now = Date.now();\n for (const entry of entries) {\n const entryPath = path.join(stagingPath, entry);\n try {\n const stats = await fs.stat(entryPath);\n if (now - stats.mtimeMs > maxAgeMs) {\n await fs.remove(entryPath);\n }\n } catch {\n // best-effort cleanup\n }\n }\n } catch {\n // best-effort cleanup\n }\n }\n\n private async createStagingDirectory(packageObject: FhirPackageIdentifier): Promise<string> {\n await this.cleanupStaleStagingDirectories();\n const stagingPath = await this.getStagingPath();\n const dirName = `${await this.toDirName(packageObject)}.${process.pid}.${Date.now()}.${crypto.randomBytes(4).toString('hex')}`;\n const fullPath = path.join(stagingPath, dirName);\n await fs.ensureDir(fullPath);\n return fullPath;\n }\n\n private async buildPackageIndexFromPackageDir(packageDir: string): Promise<PackageIndex> {\n return await this.withDebugTiming(\n `build-package-index packageDir=${packageDir}`,\n async () => {\n const discoverStartedAtNs = process.hrtime.bigint();\n const fileList = await fs.readdir(packageDir);\n const candidateFiles = fileList.filter(\n file => file.endsWith('.json') && file !== 'package.json' && !file.endsWith('.index.json')\n );\n this.logger.debug?.(\n `[index] Discovered ${candidateFiles.length} candidate JSON resources in ${packageDir} ` +\n `in ${this.formatElapsedMs(discoverStartedAtNs)}ms.`\n );\n\n const parseStartedAtNs = process.hrtime.bigint();\n const files = await Promise.all(\n candidateFiles.map(\n file => limit(\n async () => {\n const contentText = await fs.readFile(path.join(packageDir, file), { encoding: 'utf8' });\n const content = JSON.parse(contentText) as PackageResource;\n return extractResourceIndexEntry(file, content);\n }\n )\n )\n );\n\n this.logger.debug?.(\n `[index] Parsed ${files.length} resource entries from ${packageDir} ` +\n `in ${this.formatElapsedMs(parseStartedAtNs)}ms.`\n );\n\n return {\n 'index-version': 2,\n files,\n };\n },\n (indexJson) => `fileCount=${indexJson.files.length}`\n );\n }\n\n private normalizeIndexEntry(entry: Record<string, unknown>): FileInPackageIndex | null {\n const filename = typeof entry.filename === 'string' ? entry.filename : null;\n const resourceType = typeof entry.resourceType === 'string' ? entry.resourceType : null;\n const id = typeof entry.id === 'string' ? entry.id : null;\n if (!filename) {\n return null;\n }\n if (!resourceType || !id) {\n return null;\n }\n\n const readOptionalString = (key: keyof FileInPackageIndex): string | undefined => {\n const value = entry[key as string];\n return typeof value === 'string' ? value : undefined;\n };\n\n return {\n filename,\n resourceType,\n id,\n url: readOptionalString('url'),\n name: readOptionalString('name'),\n version: readOptionalString('version'),\n kind: readOptionalString('kind'),\n type: readOptionalString('type'),\n supplements: readOptionalString('supplements'),\n content: readOptionalString('content'),\n baseDefinition: readOptionalString('baseDefinition'),\n derivation: readOptionalString('derivation'),\n date: readOptionalString('date'),\n };\n }\n\n private normalizePackageIndex(raw: unknown): PackageIndex | null {\n if (!raw || typeof raw !== 'object') {\n return null;\n }\n\n const candidate = raw as { files?: unknown };\n if (!Array.isArray(candidate.files)) {\n return null;\n }\n\n const files: FileInPackageIndex[] = [];\n for (const file of candidate.files) {\n if (!file || typeof file !== 'object') {\n return null;\n }\n const normalized = this.normalizeIndexEntry(file as Record<string, unknown>);\n if (!normalized) {\n return null;\n }\n files.push(normalized);\n }\n\n return {\n 'index-version': 2,\n files,\n };\n }\n\n private async persistMaterializedPackageIndex(\n packageObject: FhirPackageIdentifier,\n packageDir: string,\n indexJson: PackageIndex\n ): Promise<PackageIndex> {\n const indexPath = path.join(packageDir, '.fpi.index.json');\n await fs.writeJSON(indexPath, indexJson);\n\n const memKey = this.getIndexMemKey(packageObject);\n memSetNoTtl(memKey, indexJson);\n try {\n const diskPath = this.getDiskIndexCachePath(packageObject);\n await this.ensureDiskCacheSubdir('indexes');\n await this.writeDiskCacheJsonNoTtl(diskPath, indexJson);\n } catch {\n // ignore\n }\n\n return indexJson;\n }\n\n private async tryMaterializeLegacyPackageIndex(\n packageObject: FhirPackageIdentifier,\n packageDir: string\n ): Promise<PackageIndex | null> {\n const legacyIndexPath = path.join(packageDir, '.index.json');\n if (!await fs.exists(legacyIndexPath)) {\n return null;\n }\n\n return await this.withDiskLock(this.getIndexDiskLockKey(packageObject), async () => {\n const fpiIndexPath = path.join(packageDir, '.fpi.index.json');\n if (await fs.exists(fpiIndexPath)) {\n const current = await fs.readJSON(fpiIndexPath, { encoding: 'utf8' }) as unknown;\n return this.normalizePackageIndex(current);\n }\n\n const legacyRaw = await fs.readJSON(legacyIndexPath, { encoding: 'utf8' }) as unknown;\n const legacyIndex = this.normalizePackageIndex(legacyRaw);\n if (!legacyIndex) {\n return null;\n }\n\n for (const file of legacyIndex.files) {\n if (!await fs.exists(path.join(packageDir, file.filename))) {\n return null;\n }\n }\n\n return await this.persistMaterializedPackageIndex(packageObject, packageDir, legacyIndex);\n }, {\n proceedWithoutLockAfterTimeout: false,\n });\n }\n\n private async materializePackageIndex(\n packageObject: FhirPackageIdentifier,\n packageDir: string\n ): Promise<PackageIndex> {\n return await this.withDebugTiming(\n `materialize-package-index ${this.formatPackageForDebug(packageObject)}`,\n async () => {\n const memKey = this.getIndexMemKey(packageObject);\n const memHit = memGet<PackageIndex>(memKey);\n if (memHit) {\n return await this.persistMaterializedPackageIndex(packageObject, packageDir, memHit);\n }\n\n return await this.withDiskLock(this.getIndexDiskLockKey(packageObject), async () => {\n const memHit2 = memGet<PackageIndex>(memKey);\n if (memHit2) {\n return await this.persistMaterializedPackageIndex(packageObject, packageDir, memHit2);\n }\n\n const diskPath = this.getDiskIndexCachePath(packageObject);\n const diskHit = await withSingleFlight(inFlightIndex, `disk-${memKey}`, async () => {\n await this.ensureDiskCacheSubdir('indexes');\n return await this.readDiskCacheJson<PackageIndex>(diskPath);\n });\n if (diskHit) {\n return await this.persistMaterializedPackageIndex(packageObject, packageDir, diskHit);\n }\n\n const indexJson = await this.buildPackageIndexFromPackageDir(packageDir);\n return await this.persistMaterializedPackageIndex(packageObject, packageDir, indexJson);\n }, {\n proceedWithoutLockAfterTimeout: false,\n });\n },\n (indexJson) => `fileCount=${indexJson.files.length}`\n );\n }\n\n private async getPackageMaterializationStatus(\n packageObject: FhirPackageIdentifier,\n options?: { emitTiming?: boolean }\n ): Promise<{\n complete: boolean;\n reason:\n | 'package-root-missing'\n | 'package-dir-missing'\n | 'manifest-missing'\n | 'manifest-invalid'\n | 'index-missing'\n | 'index-invalid'\n | 'complete'\n | 'indexed-files-missing';\n missingFiles: string[];\n }> {\n const computeStatus = async (): Promise<{\n complete: boolean;\n reason:\n | 'package-root-missing'\n | 'package-dir-missing'\n | 'manifest-missing'\n | 'manifest-invalid'\n | 'index-missing'\n | 'index-invalid'\n | 'complete'\n | 'indexed-files-missing';\n missingFiles: string[];\n }> => {\n const packageRoot = await this.getPackageDirPath(packageObject);\n if (!await fs.exists(packageRoot)) {\n return { complete: false, reason: 'package-root-missing', missingFiles: [] };\n }\n\n const packageDir = path.join(packageRoot, 'package');\n if (!await fs.exists(packageDir)) {\n return { complete: false, reason: 'package-dir-missing', missingFiles: [] };\n }\n\n const manifestPath = path.join(packageDir, 'package.json');\n if (!await fs.exists(manifestPath)) {\n return { complete: false, reason: 'manifest-missing', missingFiles: [] };\n }\n\n try {\n await fs.readJSON(manifestPath, { encoding: 'utf8' });\n } catch {\n return { complete: false, reason: 'manifest-invalid', missingFiles: [] };\n }\n\n const indexPath = path.join(packageDir, '.fpi.index.json');\n if (!await fs.exists(indexPath)) {\n const legacyMaterializedIndex = await this.tryMaterializeLegacyPackageIndex(packageObject, packageDir);\n if (!legacyMaterializedIndex) {\n try {\n await this.materializePackageIndex(packageObject, packageDir);\n } catch {\n return { complete: false, reason: 'index-missing', missingFiles: [] };\n }\n if (!await fs.exists(indexPath)) {\n return { complete: false, reason: 'index-missing', missingFiles: [] };\n }\n }\n }\n\n if (await this.hasFreshMaterializationMarker(packageRoot, packageDir, manifestPath, indexPath)) {\n return { complete: true, reason: 'complete', missingFiles: [] };\n }\n\n let indexJson: Partial<PackageIndex>;\n try {\n indexJson = await fs.readJSON(indexPath, { encoding: 'utf8' }) as Partial<PackageIndex>;\n } catch {\n return { complete: false, reason: 'index-invalid', missingFiles: [] };\n }\n\n if (!Array.isArray(indexJson.files)) {\n return { complete: false, reason: 'index-invalid', missingFiles: [] };\n }\n\n const packageDirEntries = new Set(await fs.readdir(packageDir));\n const missingFiles: string[] = [];\n for (const file of indexJson.files) {\n const filename = typeof file?.filename === 'string' ? file.filename : null;\n if (!filename) {\n return { complete: false, reason: 'index-invalid', missingFiles: [] };\n }\n const canUseDirectoryListing = !filename.includes('/') && !filename.includes('\\\\');\n if (canUseDirectoryListing ? !packageDirEntries.has(filename) : !await fs.exists(path.join(packageDir, filename))) {\n missingFiles.push(filename);\n }\n }\n\n if (missingFiles.length > 0) {\n return { complete: false, reason: 'indexed-files-missing', missingFiles };\n }\n\n await this.writeMaterializationMarker(packageRoot, packageDir, manifestPath, indexPath);\n\n return { complete: true, reason: 'complete', missingFiles: [] };\n };\n\n if (!options?.emitTiming) {\n return await computeStatus();\n }\n\n return await this.withDebugTiming(\n `materialization-status ${this.formatPackageForDebug(packageObject)}`,\n computeStatus,\n (status) => this.formatMaterializationStatusForDebug(status)\n );\n }\n\n private async stagePackageForPublish(\n packageObject: FhirPackageIdentifier,\n src: string,\n move: boolean\n ): Promise<string> {\n return await this.withDebugTiming(\n `stage-package-for-publish ${this.formatPackageForDebug(packageObject)}`,\n async () => {\n const stagingRoot = await this.createStagingDirectory(packageObject);\n const sourcePackageDir = await fs.exists(path.join(src, 'package')) ? path.join(src, 'package') : src;\n const stagingPackageDir = path.join(stagingRoot, 'package');\n const packageLabel = this.formatPackageForDebug(packageObject);\n\n try {\n const action = move ? fs.move : fs.copy;\n const copyOrMoveStartedAtNs = process.hrtime.bigint();\n await action(sourcePackageDir, stagingPackageDir, { overwrite: false });\n this.logger.debug?.(\n `[publish] ${move ? 'Moved' : 'Copied'} staged contents for ${packageLabel} ` +\n `from ${sourcePackageDir} to ${stagingPackageDir} in ${this.formatElapsedMs(copyOrMoveStartedAtNs)}ms.`\n );\n if (move && sourcePackageDir !== src) {\n const cleanupStartedAtNs = process.hrtime.bigint();\n await fs.remove(src).catch(() => undefined);\n this.logger.debug?.(\n `[publish] Removed extraction root ${src} after staging ${packageLabel} ` +\n `in ${this.formatElapsedMs(cleanupStartedAtNs)}ms.`\n );\n }\n const materializeIndexStartedAtNs = process.hrtime.bigint();\n const materializedIndex = await this.materializePackageIndex(packageObject, stagingPackageDir);\n this.logger.debug?.(\n `[publish] Materialized package index for ${packageLabel} in staging ` +\n `in ${this.formatElapsedMs(materializeIndexStartedAtNs)}ms (fileCount=${materializedIndex.files.length}).`\n );\n const markerStartedAtNs = process.hrtime.bigint();\n await this.writeMaterializationMarker(\n stagingRoot,\n stagingPackageDir,\n path.join(stagingPackageDir, 'package.json'),\n path.join(stagingPackageDir, '.fpi.index.json')\n );\n this.logger.debug?.(\n `[publish] Wrote materialization marker for ${packageLabel} ` +\n `in ${this.formatElapsedMs(markerStartedAtNs)}ms.`\n );\n return stagingRoot;\n } catch (error) {\n await fs.remove(stagingRoot).catch(() => undefined);\n throw error;\n }\n }\n );\n }\n\n private isAlreadyExistsError(error: unknown): boolean {\n if (!error || typeof error !== 'object') {\n return false;\n }\n\n const candidate = error as { code?: string; message?: string };\n return candidate.code === 'EEXIST'\n || candidate.code === 'ENOTEMPTY'\n || /dest already exists/i.test(candidate.message ?? '');\n }\n\n private isRetryableStagePublishError(error: unknown): b