UNPKG

fhir-package-installer

Version:

A utility module for downloading, indexing, caching, and managing FHIR packages from the FHIR Package Registry and Simplifier

1,370 lines (1,367 loc) 116 kB
// src/index.ts import https from "https"; import http from "http"; import fs from "fs-extra"; // node_modules/yocto-queue/index.js var Node = class { value; next; constructor(value) { this.value = value; } }; var Queue = class { #head; #tail; #size; constructor() { this.clear(); } enqueue(value) { const node = new Node(value); if (this.#head) { this.#tail.next = node; this.#tail = node; } else { this.#head = node; this.#tail = node; } this.#size++; } dequeue() { const current = this.#head; if (!current) { return; } this.#head = this.#head.next; this.#size--; if (!this.#head) { this.#tail = void 0; } return current.value; } peek() { if (!this.#head) { return; } return this.#head.value; } clear() { this.#head = void 0; this.#tail = void 0; this.#size = 0; } get size() { return this.#size; } *[Symbol.iterator]() { let current = this.#head; while (current) { yield current.value; current = current.next; } } *drain() { while (this.#head) { yield this.dequeue(); } } }; // node_modules/p-limit/index.js function pLimit(concurrency) { let rejectOnClear = false; if (typeof concurrency === "object") { ({ concurrency, rejectOnClear = false } = concurrency); } validateConcurrency(concurrency); if (typeof rejectOnClear !== "boolean") { throw new TypeError("Expected `rejectOnClear` to be a boolean"); } const queue = new Queue(); let activeCount = 0; const resumeNext = () => { if (activeCount < concurrency && queue.size > 0) { activeCount++; queue.dequeue().run(); } }; const next = () => { activeCount--; resumeNext(); }; const run = async (function_, resolve, arguments_) => { const result = (async () => function_(...arguments_))(); resolve(result); try { await result; } catch { } next(); }; const enqueue = (function_, resolve, reject, arguments_) => { const queueItem = { reject }; new Promise((internalResolve) => { queueItem.run = internalResolve; queue.enqueue(queueItem); }).then(run.bind(void 0, function_, resolve, arguments_)); if (activeCount < concurrency) { resumeNext(); } }; const generator = (function_, ...arguments_) => new Promise((resolve, reject) => { enqueue(function_, resolve, reject, arguments_); }); Object.defineProperties(generator, { activeCount: { get: () => activeCount }, pendingCount: { get: () => queue.size }, clearQueue: { value() { if (!rejectOnClear) { queue.clear(); return; } const abortError = AbortSignal.abort().reason; while (queue.size > 0) { queue.dequeue().reject(abortError); } } }, concurrency: { get: () => concurrency, set(newConcurrency) { validateConcurrency(newConcurrency); concurrency = newConcurrency; queueMicrotask(() => { while (activeCount < concurrency && queue.size > 0) { resumeNext(); } }); } }, map: { async value(iterable, function_) { const promises = Array.from(iterable, (value, index) => this(function_, value, index)); return Promise.all(promises); } } }); return generator; } function validateConcurrency(concurrency) { if (!((Number.isInteger(concurrency) || concurrency === Number.POSITIVE_INFINITY) && concurrency > 0)) { throw new TypeError("Expected `concurrency` to be a number from 1 and up"); } } // src/index.ts import path from "path"; import { finished, pipeline } from "stream/promises"; import * as tar from "tar-stream"; import * as zlib from "zlib"; import os from "os"; import semver from "semver"; import crypto from "crypto"; var tempDirs = /* @__PURE__ */ new Set(); var tempCleanupRegistered = false; var registerTempCleanup = () => { if (tempCleanupRegistered) return; tempCleanupRegistered = true; process.once("exit", () => { for (const dir of tempDirs) { try { fs.removeSync(dir); } catch { } } tempDirs.clear(); }); }; var createTempDir = (baseDir) => { registerTempCleanup(); const parentDir = baseDir ?? os.tmpdir(); fs.ensureDirSync(parentDir); const dir = fs.mkdtempSync(path.join(parentDir, "fhir-package-installer-")); tempDirs.add(dir); return dir; }; var FPI_VERSION = "1.13.0".trim().length > 0 ? "1.13.0" : "0.0.0"; var FPI_INDEX_CACHE_VERSION = (() => { const v = semver.parse(FPI_VERSION); if (!v) return "0.0"; return `${v.major}.${v.minor}`; })(); var FPI_STAGING_MAX_AGE_MS = 24 * 60 * 60 * 1e3; var FPI_MATERIALIZATION_MARKER = ".fpi.materialized"; var DEPENDENCY_CLAIM_WAIT_MS = 1e3; var DEPENDENCY_POST_CLAIM_YIELD_MS = 500; var DEPENDENCY_WAIT_LOG_INTERVAL_MS = 5e3; var PACKAGE_INSTALL_WAIT_LOG_INTERVAL_MS = 5e3; var DEPENDENCY_PEER_HANDOFF_WAIT_MS = 3e3; var DEPENDENCY_PEER_DISCOVERY_GRACE_MS = 300; var IMPLICIT_DEPENDENCIES_MAP = { "hl7.fhir.r3.core": [ "hl7.terminology.r3", "hl7.fhir.uv.extensions.r3" ], "hl7.fhir.r4.core": [ "hl7.terminology.r4", "hl7.fhir.uv.extensions.r4" ], "hl7.fhir.r5.core": [ "hl7.terminology.r5", "hl7.fhir.uv.extensions.r5" ] }; var IMPLICIT_PACKAGE_IDS = (() => { const s = /* @__PURE__ */ new Set(); for (const ids of Object.values(IMPLICIT_DEPENDENCIES_MAP)) { for (const id of ids) s.add(id); } return s; })(); var DEFAULT_REGISTRY_TTL_MS = 30 * 60 * 1e3; var MEM_CACHE_MAX_ENTRIES = 500; var BoundedTtlCache = class { maxEntries; map = /* @__PURE__ */ new Map(); constructor(maxEntries) { this.maxEntries = maxEntries; } get(key) { const entry = this.map.get(key); if (!entry) return void 0; if (Date.now() >= entry.expiresAt) { this.map.delete(key); return void 0; } this.map.delete(key); this.map.set(key, entry); return entry.value; } set(key, value, ttlMs) { const expiresAt = Date.now() + ttlMs; const entry = { value, expiresAt }; this.map.delete(key); this.map.set(key, entry); while (this.map.size > this.maxEntries) { const firstKey = this.map.keys().next().value; if (firstKey === void 0) break; this.map.delete(firstKey); } } delete(key) { return this.map.delete(key); } }; var inFlightJson = /* @__PURE__ */ new Map(); var inFlightTarball = /* @__PURE__ */ new Map(); var inFlightIndex = /* @__PURE__ */ new Map(); var inFlightImplicitEffectiveVersion = /* @__PURE__ */ new Map(); var implicitEffectiveVersionCache = new BoundedTtlCache(MEM_CACHE_MAX_ENTRIES); var implicitResolutionFailureCache = new BoundedTtlCache(MEM_CACHE_MAX_ENTRIES); var ImplicitPackageResolutionError = class extends Error { packageId; attemptedVersions; registryUrl; cachePath; causes; constructor(args) { const attempted = args.attemptedVersions.length > 0 ? args.attemptedVersions.join(", ") : "(none)"; const prefix = `Failed to resolve implicit package ${args.packageId}`; const meta = `attemptedVersions=[${attempted}] registryUrl=${args.registryUrl} cachePath=${args.cachePath}`; const causeText = args.causes && args.causes.length > 0 ? ` causes=[${args.causes.join(" | ")}]` : ""; super(`${prefix}. ${meta}.${causeText}`); this.name = "ImplicitPackageResolutionError"; this.packageId = args.packageId; this.attemptedVersions = args.attemptedVersions; this.registryUrl = args.registryUrl; this.cachePath = args.cachePath; this.causes = args.causes ?? []; } }; var FhirPackageInstallError = class extends Error { packageId; version; registryUrl; cachePath; step; tarballUrl; constructor(args) { const safe = (v) => { if (v == null) return ""; if (typeof v === "string") return v; if (v instanceof Error) return v.message; try { return JSON.stringify(v); } catch { return String(v); } }; const pkg = `${args.packageId}@${args.version}`; const meta = `step=${args.step} registryUrl=${args.registryUrl} cachePath=${args.cachePath}`; const tarball = args.tarballUrl ? ` tarballUrl=${args.tarballUrl}` : ""; const causeText = args.cause ? ` Cause: ${safe(args.cause)}` : ""; super(`Failed to install ${pkg}. ${meta}.${tarball}${causeText}`, { cause: args.cause }); this.name = "FhirPackageInstallError"; this.packageId = args.packageId; this.version = args.version; this.registryUrl = args.registryUrl; this.cachePath = args.cachePath; this.step = args.step; this.tarballUrl = args.tarballUrl; } }; var withSingleFlight = async (map, key, fn) => { const existing = map.get(key); if (existing) return existing; const p = (async () => { try { return await fn(); } finally { map.delete(key); } })(); map.set(key, p); return p; }; var sha256Hex = (value) => crypto.createHash("sha256").update(value).digest("hex"); var memCache = /* @__PURE__ */ new Map(); var memGet = (key) => { const e = memCache.get(key); if (!e) return null; if (typeof e.expiresAt === "number") { if (Date.now() >= e.expiresAt) { memCache.delete(key); return null; } } return e.value; }; var memSet = (key, value, ttlMs) => { const expiresAt = Date.now() + ttlMs; memCache.delete(key); memCache.set(key, { expiresAt, value }); while (memCache.size > MEM_CACHE_MAX_ENTRIES) { const firstKey = memCache.keys().next().value; if (!firstKey) break; memCache.delete(firstKey); } }; var memSetNoTtl = (key, value) => { memCache.delete(key); memCache.set(key, { value }); while (memCache.size > MEM_CACHE_MAX_ENTRIES) { const firstKey = memCache.keys().next().value; if (!firstKey) break; memCache.delete(firstKey); } }; var defaultLogger = { info: () => void 0, warn: () => void 0, error: () => void 0 }; var limit = pLimit(Math.max(4, Math.min(32, os.cpus().length))); var extractResourceIndexEntry = (filename, content) => { const evalAttribute = (att) => typeof att === "string" ? att : void 0; const indexEntry = { filename, resourceType: content.resourceType, id: content.id, url: evalAttribute(content.url), name: evalAttribute(content.name), version: evalAttribute(content.version), kind: evalAttribute(content.kind), type: evalAttribute(content.type), supplements: evalAttribute(content.supplements), content: evalAttribute(content.content), baseDefinition: evalAttribute(content.baseDefinition), derivation: evalAttribute(content.derivation), date: evalAttribute(content.date) }; return indexEntry; }; var FhirPackageInstaller = class { logger = defaultLogger; registryUrl = "https://packages.fhir.org"; registryDisabled = false; registryToken; // optional token for private registries requestTimeoutMs = 9e4; // 90 seconds extractTimeoutMs = 6e4; // 60 seconds registryTtlMs = DEFAULT_REGISTRY_TTL_MS; /** * Path to the FHIR package cache directory. * This directory is used to store downloaded and extracted FHIR packages. * If the directory does not exist, it will be created. * Default location follows FHIR spec: * - User apps: ~/.fhir/packages (Windows: C:\Users\<user>\.fhir\packages) * - System services: /var/lib/.fhir/packages (Windows: %ProgramData%\.fhir\packages) */ cachePath; skipExamples = false; // skip dependency installation of example packages allowHttp = false; // allow HTTP URLs for testing resolvingImplicitDeps = /* @__PURE__ */ new Set(); installingPackages = /* @__PURE__ */ new Set(); formatPackageForDebug(packageObject) { return `${packageObject.id}@${packageObject.version}`; } formatMaterializationStatusForDebug(status) { const missingPreview = status.missingFiles.length > 0 ? ` missingFiles=${status.missingFiles.slice(0, 3).join(",")}${status.missingFiles.length > 3 ? ",..." : ""}` : ""; return `materialization=${status.complete ? "complete" : "incomplete"} reason=${status.reason}${missingPreview}`; } async describePackageInstallWaitState(packageObject) { try { const status = await this.getPackageMaterializationStatus(packageObject, { emitTiming: true }); return this.formatMaterializationStatusForDebug(status); } catch (error) { return `materialization-check-error=${error instanceof Error ? error.message : String(error)}`; } } formatElapsedMs(startedAtNs) { return (Number(process.hrtime.bigint() - startedAtNs) / 1e6).toFixed(1); } async withDebugTiming(label, action, describeResult) { if (!this.logger.debug || typeof this.logger.debug !== "function") { return await action(); } const startedAtNs = process.hrtime.bigint(); try { const result = await action(); const resultText = describeResult ? ` ${describeResult(result)}` : ""; this.logger.debug(`[timing] ${label} completed in ${this.formatElapsedMs(startedAtNs)}ms.${resultText}`); return result; } catch (error) { this.logger.debug( `[timing] ${label} failed in ${this.formatElapsedMs(startedAtNs)}ms: ${error instanceof Error ? error.message : String(error)}` ); throw error; } } getPackageKey(packageObject) { return `${packageObject.id}#${packageObject.version}`; } normalizeDependencies(dependencies) { if (dependencies["hl7.fhir.r4.core"] === "4.0.0") { return { ...dependencies, "hl7.fhir.r4.core": "4.0.1" }; } return dependencies; } constructor(config) { const { logger, registryUrl, registryToken, cachePath, skipExamples, allowHttp, requestTimeoutMs, extractTimeoutMs, registryTtlMs } = config || {}; if (logger) { this.logger = logger; } const normalizedCachePath = (() => { if (cachePath == null) { return void 0; } if (typeof cachePath !== "string") { this.logger.warn?.( `Non-string cachePath provided (${typeof cachePath}); falling back to FHIR spec default cache path.` ); return void 0; } const trimmed = cachePath.trim(); if (trimmed === "" || trimmed.toLowerCase() === "n/a") { this.logger.warn?.( 'Non-usable cachePath provided (empty/whitespace or "n/a"); falling back to FHIR spec default cache path.' ); return void 0; } return trimmed; })(); this.cachePath = normalizedCachePath ?? this.getDefaultCachePath(); if (registryUrl) { const normalized = registryUrl.trim(); this.registryUrl = registryUrl; if (normalized.toLowerCase() === "n/a") { this.registryUrl = "n/a"; this.registryDisabled = true; } } if (registryToken) { this.registryToken = registryToken; } if (allowHttp) { this.allowHttp = allowHttp; } if (typeof requestTimeoutMs === "number" && Number.isFinite(requestTimeoutMs) && requestTimeoutMs > 0) { this.requestTimeoutMs = requestTimeoutMs; } if (typeof extractTimeoutMs === "number" && Number.isFinite(extractTimeoutMs) && extractTimeoutMs > 0) { this.extractTimeoutMs = extractTimeoutMs; } const effectiveRegistryTtlMs = typeof registryTtlMs === "number" && Number.isFinite(registryTtlMs) && registryTtlMs > 0 ? registryTtlMs : void 0; if (typeof effectiveRegistryTtlMs === "number") { this.registryTtlMs = effectiveRegistryTtlMs; } if (skipExamples) { this.skipExamples = skipExamples; } } /** * Determines the default FHIR package cache path based on FHIR specifications: * https://confluence.hl7.org/display/FHIR/FHIR+Package+Cache * * For user applications: * - Windows: C:\Users\<username>\.fhir\packages * - Unix/Linux: ~/.fhir/packages * * For system services (daemons): * - Windows: %ProgramData%\.fhir\packages (typically C:\ProgramData\.fhir\packages) * - Unix/Linux: /var/lib/.fhir/packages * * Behavior can be overridden via the FHIR_PACKAGE_CACHE_MODE environment variable: * - FHIR_PACKAGE_CACHE_MODE=system -> always use system service paths * - FHIR_PACKAGE_CACHE_MODE=user -> always use user paths */ getDefaultCachePath() { const isWindows = process.platform === "win32"; const homeDir = os.homedir(); const cacheMode = process.env.FHIR_PACKAGE_CACHE_MODE?.toLowerCase(); let isSystemService; if (cacheMode === "system") { isSystemService = true; } else if (cacheMode === "user") { isSystemService = false; } else { if (isWindows) { const normalizedHome = path.normalize(homeDir).toLowerCase(); const systemProfileSuffix = path.normalize(path.join("Windows", "System32", "config", "systemprofile")).toLowerCase(); isSystemService = normalizedHome.endsWith(systemProfileSuffix); } else { const isRoot = process.getuid?.() === 0; const isSudo = !!process.env.SUDO_USER; const hasDisplay = !!process.env.DISPLAY; const hasSshConnection = !!process.env.SSH_CONNECTION; const hasTerm = !!process.env.TERM; isSystemService = Boolean( isRoot && !isSudo && !hasDisplay && !hasSshConnection && !hasTerm ); } } if (isSystemService) { if (isWindows) { let programData = process.env.ProgramData; if (!programData || programData.trim() === "") { const fallbackProgramData = "C:\\ProgramData"; try { if (fs.pathExistsSync(fallbackProgramData)) { fs.accessSync(fallbackProgramData, fs.constants.W_OK); this.logger.warn( 'ProgramData environment variable is not set; using fallback "C:\\ProgramData" for system service cache directory.' ); programData = fallbackProgramData; } else { this.logger.warn( 'ProgramData environment variable is not set and fallback "C:\\ProgramData" does not exist. Falling back to user cache directory.' ); } } catch { this.logger.warn( 'ProgramData environment variable is not set and fallback "C:\\ProgramData" is not writable. Falling back to user cache directory.' ); } } if (programData) { return path.join(programData, ".fhir", "packages"); } return path.join(homeDir, ".fhir", "packages"); } else { return "/var/lib/.fhir/packages"; } } return path.join(homeDir, ".fhir", "packages"); } async withDiskLock(lockKey, fn, options) { const locksDir = await this.ensureDiskCacheSubdir("locks"); const lockPath = path.join(locksDir, `${sha256Hex(lockKey)}.lock`); const start = Date.now(); const maxWaitMs = Math.max(1e3, this.requestTimeoutMs); const staleMs = Math.max(2 * 60 * 1e3, maxWaitMs * 2); const debugLabel = options?.debugLabel?.trim(); const debugEnabled = Boolean(debugLabel) && typeof this.logger.debug === "function"; const waitLogIntervalMs = Math.max(250, options?.waitLogIntervalMs ?? PACKAGE_INSTALL_WAIT_LOG_INTERVAL_MS); const proceedWithoutLockAfterTimeout = options?.proceedWithoutLockAfterTimeout ?? true; let contentionObserved = false; let lastWaitLogAt = 0; const readWaitState = async () => { if (!debugEnabled || !options?.describeWaitState) { return null; } try { return await options.describeWaitState(); } catch (error) { return `wait-state-error=${error instanceof Error ? error.message : String(error)}`; } }; while (true) { try { await fs.ensureDir(path.dirname(lockPath)); await fs.writeFile(lockPath, `${process.pid} ${Date.now()} `, { flag: "wx" }); if (debugEnabled) { const waitedMs = Date.now() - start; this.logger.debug?.( contentionObserved ? `Claimed ${debugLabel} after waiting ${waitedMs}ms.` : `Claimed ${debugLabel}.` ); } const heartbeat = setInterval(() => { fs.utimes(lockPath, /* @__PURE__ */ new Date(), /* @__PURE__ */ new Date()).catch(() => void 0); }, 1e3); heartbeat.unref?.(); try { return await fn(); } finally { clearInterval(heartbeat); await fs.remove(lockPath).catch(() => void 0); if (debugEnabled) { this.logger.debug?.(`Released ${debugLabel}.`); } } } catch (e) { if (e?.code !== "EEXIST") { if (debugEnabled) { this.logger.debug?.( `Skipping ${debugLabel} because the lock could not be created (${e?.code || e?.message || String(e)}); proceeding without the lock.` ); } return await fn(); } const elapsedMs = Date.now() - start; let lockAgeMs = null; let waitState = null; if (!contentionObserved && debugEnabled) { waitState = await readWaitState(); this.logger.debug?.( `Another process holds ${debugLabel}; entering wait loop.${waitState ? ` Current materialization state: ${waitState}.` : ""}` ); } contentionObserved = true; try { const stat = await fs.stat(lockPath); lockAgeMs = Date.now() - stat.mtimeMs; if (lockAgeMs > staleMs) { if (debugEnabled) { waitState ??= await readWaitState(); this.logger.debug?.( `Breaking stale ${debugLabel} after waiting ${elapsedMs}ms (lockAgeMs=${Math.round(lockAgeMs)}).${waitState ? ` Current materialization state: ${waitState}.` : ""}` ); } await fs.remove(lockPath).catch(() => void 0); continue; } } catch { } if (Date.now() - start > maxWaitMs) { if (proceedWithoutLockAfterTimeout) { if (debugEnabled) { waitState ??= await readWaitState(); this.logger.debug?.( `Waited ${elapsedMs}ms for ${debugLabel}, exceeding maxWaitMs=${maxWaitMs}; proceeding without the lock.${waitState ? ` Current materialization state: ${waitState}.` : ""}` ); } return await fn(); } if (debugEnabled && Date.now() - lastWaitLogAt >= waitLogIntervalMs) { waitState ??= await readWaitState(); this.logger.debug?.( `Waited ${elapsedMs}ms for ${debugLabel}, exceeding maxWaitMs=${maxWaitMs}; continuing to wait for the live lock holder.${waitState ? ` Current materialization state: ${waitState}.` : ""}` ); lastWaitLogAt = Date.now(); } } if (debugEnabled && Date.now() - lastWaitLogAt >= waitLogIntervalMs) { waitState ??= await readWaitState(); const lockAgeText = typeof lockAgeMs === "number" ? ` lockAgeMs=${Math.round(lockAgeMs)}.` : ""; this.logger.debug?.( `Still waiting for ${debugLabel} after ${elapsedMs}ms.${lockAgeText}${waitState ? ` Current materialization state: ${waitState}.` : ""}` ); lastWaitLogAt = Date.now(); } await new Promise((r) => setTimeout(r, 50 + Math.floor(Math.random() * 100))); } } } async tryWithDiskLock(lockKey, fn, options) { const locksDir = await this.ensureDiskCacheSubdir("locks"); const lockPath = path.join(locksDir, `${sha256Hex(lockKey)}.lock`); const debugLabel = options?.debugLabel?.trim(); const debugEnabled = Boolean(debugLabel) && typeof this.logger.debug === "function"; try { await fs.ensureDir(path.dirname(lockPath)); await fs.writeFile(lockPath, `${process.pid} ${Date.now()} `, { flag: "wx" }); if (debugEnabled) { this.logger.debug?.(`Claimed ${debugLabel}.`); } const heartbeat = setInterval(() => { fs.utimes(lockPath, /* @__PURE__ */ new Date(), /* @__PURE__ */ new Date()).catch(() => void 0); }, 1e3); heartbeat.unref?.(); try { return { acquired: true, result: await fn() }; } finally { clearInterval(heartbeat); await fs.remove(lockPath).catch(() => void 0); if (debugEnabled) { this.logger.debug?.(`Released ${debugLabel}.`); } } } catch (e) { if (e?.code === "EEXIST") { return { acquired: false }; } if (debugEnabled) { this.logger.debug?.( `Skipping ${debugLabel} because the lock could not be created (${e?.code || e?.message || String(e)}); proceeding without the lock.` ); } return { acquired: true, result: await fn() }; } } // ---- Per-cachePath persistent cache helpers ---- async ensureDiskCacheSubdir(name) { const dir = path.join(this.cachePath, ".fpi.cache", name); await fs.ensureDir(dir); return dir; } async createWorkingTempDir(options) { if (options?.preferCache !== false) { try { const cacheTempRoot = await this.ensureDiskCacheSubdir("tmp"); return createTempDir(cacheTempRoot); } catch { } } return createTempDir(); } getPackageInstallLockKey(packageObject) { return `package-install|${packageObject.id}#${packageObject.version}`; } async getInstallParticipantDir(packageObject) { const participantsRoot = await this.ensureDiskCacheSubdir("installers"); return path.join(participantsRoot, sha256Hex(`install-participants|${this.getPackageKey(packageObject)}`)); } async withInstallParticipant(packageObject, fn) { const participantsDir = await this.getInstallParticipantDir(packageObject); const participantPath = path.join( participantsDir, `${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}.participant` ); await fs.ensureDir(participantsDir); await fs.writeFile(participantPath, `${process.pid} ${Date.now()} `, "utf8"); const heartbeat = setInterval(() => { fs.utimes(participantPath, /* @__PURE__ */ new Date(), /* @__PURE__ */ new Date()).catch(() => void 0); }, 1e3); heartbeat.unref?.(); try { return await fn(); } finally { clearInterval(heartbeat); await fs.remove(participantPath).catch(() => void 0); } } async countActiveInstallParticipants(packageObject) { const participantsDir = await this.getInstallParticipantDir(packageObject); if (!await fs.exists(participantsDir)) { return 0; } const staleMs = Math.max(2 * 60 * 1e3, this.requestTimeoutMs * 2); const now = Date.now(); const entries = await fs.readdir(participantsDir); let activeCount = 0; for (const entry of entries) { const entryPath = path.join(participantsDir, entry); try { const stat = await fs.stat(entryPath); if (now - stat.mtimeMs <= staleMs) { activeCount += 1; } else { await fs.remove(entryPath).catch(() => void 0); } } catch { } } return activeCount; } async isPackageInstallLockHeld(packageObject) { const locksDir = await this.ensureDiskCacheSubdir("locks"); const lockPath = path.join(locksDir, `${sha256Hex(this.getPackageInstallLockKey(packageObject))}.lock`); return await fs.exists(lockPath); } async waitForPeerDependencyHandoff(rootPackage, pendingDependencies) { if (pendingDependencies.length === 0) { return; } const startedAt = Date.now(); let peerDiscoveryGraceApplied = false; while (Date.now() - startedAt < DEPENDENCY_PEER_HANDOFF_WAIT_MS) { for (const dependency of pendingDependencies) { if (await this.isStrictlyMaterialized(dependency) || await this.isPackageInstallLockHeld(dependency)) { return; } } if (await this.countActiveInstallParticipants(rootPackage) <= 1) { if (!peerDiscoveryGraceApplied) { peerDiscoveryGraceApplied = true; await new Promise((resolve) => setTimeout(resolve, DEPENDENCY_PEER_DISCOVERY_GRACE_MS)); continue; } return; } await new Promise((resolve) => setTimeout(resolve, 50)); } } async withPackageInstallLock(packageObject, fn) { const lockKey = this.getPackageInstallLockKey(packageObject); return await this.withDiskLock(lockKey, async () => { return await fn(); }, { debugLabel: `package install ${this.formatPackageForDebug(packageObject)}`, describeWaitState: async () => await this.describePackageInstallWaitState(packageObject), proceedWithoutLockAfterTimeout: false }); } async tryWithPackageInstallLock(packageObject, fn) { return await this.tryWithDiskLock(this.getPackageInstallLockKey(packageObject), fn, { debugLabel: `package install ${this.formatPackageForDebug(packageObject)}` }); } async getStagingPath() { return await this.ensureDiskCacheSubdir("staging"); } async cleanupStaleStagingDirectories(maxAgeMs = FPI_STAGING_MAX_AGE_MS) { try { const stagingPath = await this.getStagingPath(); const entries = await fs.readdir(stagingPath); const now = Date.now(); for (const entry of entries) { const entryPath = path.join(stagingPath, entry); try { const stats = await fs.stat(entryPath); if (now - stats.mtimeMs > maxAgeMs) { await fs.remove(entryPath); } } catch { } } } catch { } } async createStagingDirectory(packageObject) { await this.cleanupStaleStagingDirectories(); const stagingPath = await this.getStagingPath(); const dirName = `${await this.toDirName(packageObject)}.${process.pid}.${Date.now()}.${crypto.randomBytes(4).toString("hex")}`; const fullPath = path.join(stagingPath, dirName); await fs.ensureDir(fullPath); return fullPath; } async buildPackageIndexFromPackageDir(packageDir) { return await this.withDebugTiming( `build-package-index packageDir=${packageDir}`, async () => { const discoverStartedAtNs = process.hrtime.bigint(); const fileList = await fs.readdir(packageDir); const candidateFiles = fileList.filter( (file) => file.endsWith(".json") && file !== "package.json" && !file.endsWith(".index.json") ); this.logger.debug?.( `[index] Discovered ${candidateFiles.length} candidate JSON resources in ${packageDir} in ${this.formatElapsedMs(discoverStartedAtNs)}ms.` ); const parseStartedAtNs = process.hrtime.bigint(); const files = await Promise.all( candidateFiles.map( (file) => limit( async () => { const contentText = await fs.readFile(path.join(packageDir, file), { encoding: "utf8" }); const content = JSON.parse(contentText); return extractResourceIndexEntry(file, content); } ) ) ); this.logger.debug?.( `[index] Parsed ${files.length} resource entries from ${packageDir} in ${this.formatElapsedMs(parseStartedAtNs)}ms.` ); return { "index-version": 2, files }; }, (indexJson) => `fileCount=${indexJson.files.length}` ); } normalizeIndexEntry(entry) { const filename = typeof entry.filename === "string" ? entry.filename : null; const resourceType = typeof entry.resourceType === "string" ? entry.resourceType : null; const id = typeof entry.id === "string" ? entry.id : null; if (!filename) { return null; } if (!resourceType || !id) { return null; } const readOptionalString = (key) => { const value = entry[key]; return typeof value === "string" ? value : void 0; }; return { filename, resourceType, id, url: readOptionalString("url"), name: readOptionalString("name"), version: readOptionalString("version"), kind: readOptionalString("kind"), type: readOptionalString("type"), supplements: readOptionalString("supplements"), content: readOptionalString("content"), baseDefinition: readOptionalString("baseDefinition"), derivation: readOptionalString("derivation"), date: readOptionalString("date") }; } normalizePackageIndex(raw) { if (!raw || typeof raw !== "object") { return null; } const candidate = raw; if (!Array.isArray(candidate.files)) { return null; } const files = []; for (const file of candidate.files) { if (!file || typeof file !== "object") { return null; } const normalized = this.normalizeIndexEntry(file); if (!normalized) { return null; } files.push(normalized); } return { "index-version": 2, files }; } async persistMaterializedPackageIndex(packageObject, packageDir, indexJson) { const indexPath = path.join(packageDir, ".fpi.index.json"); await fs.writeJSON(indexPath, indexJson); const memKey = this.getIndexMemKey(packageObject); memSetNoTtl(memKey, indexJson); try { const diskPath = this.getDiskIndexCachePath(packageObject); await this.ensureDiskCacheSubdir("indexes"); await this.writeDiskCacheJsonNoTtl(diskPath, indexJson); } catch { } return indexJson; } async tryMaterializeLegacyPackageIndex(packageObject, packageDir) { const legacyIndexPath = path.join(packageDir, ".index.json"); if (!await fs.exists(legacyIndexPath)) { return null; } return await this.withDiskLock(this.getIndexDiskLockKey(packageObject), async () => { const fpiIndexPath = path.join(packageDir, ".fpi.index.json"); if (await fs.exists(fpiIndexPath)) { const current = await fs.readJSON(fpiIndexPath, { encoding: "utf8" }); return this.normalizePackageIndex(current); } const legacyRaw = await fs.readJSON(legacyIndexPath, { encoding: "utf8" }); const legacyIndex = this.normalizePackageIndex(legacyRaw); if (!legacyIndex) { return null; } for (const file of legacyIndex.files) { if (!await fs.exists(path.join(packageDir, file.filename))) { return null; } } return await this.persistMaterializedPackageIndex(packageObject, packageDir, legacyIndex); }, { proceedWithoutLockAfterTimeout: false }); } async materializePackageIndex(packageObject, packageDir) { return await this.withDebugTiming( `materialize-package-index ${this.formatPackageForDebug(packageObject)}`, async () => { const memKey = this.getIndexMemKey(packageObject); const memHit = memGet(memKey); if (memHit) { return await this.persistMaterializedPackageIndex(packageObject, packageDir, memHit); } return await this.withDiskLock(this.getIndexDiskLockKey(packageObject), async () => { const memHit2 = memGet(memKey); if (memHit2) { return await this.persistMaterializedPackageIndex(packageObject, packageDir, memHit2); } const diskPath = this.getDiskIndexCachePath(packageObject); const diskHit = await withSingleFlight(inFlightIndex, `disk-${memKey}`, async () => { await this.ensureDiskCacheSubdir("indexes"); return await this.readDiskCacheJson(diskPath); }); if (diskHit) { return await this.persistMaterializedPackageIndex(packageObject, packageDir, diskHit); } const indexJson = await this.buildPackageIndexFromPackageDir(packageDir); return await this.persistMaterializedPackageIndex(packageObject, packageDir, indexJson); }, { proceedWithoutLockAfterTimeout: false }); }, (indexJson) => `fileCount=${indexJson.files.length}` ); } async getPackageMaterializationStatus(packageObject, options) { const computeStatus = async () => { const packageRoot = await this.getPackageDirPath(packageObject); if (!await fs.exists(packageRoot)) { return { complete: false, reason: "package-root-missing", missingFiles: [] }; } const packageDir = path.join(packageRoot, "package"); if (!await fs.exists(packageDir)) { return { complete: false, reason: "package-dir-missing", missingFiles: [] }; } const manifestPath = path.join(packageDir, "package.json"); if (!await fs.exists(manifestPath)) { return { complete: false, reason: "manifest-missing", missingFiles: [] }; } try { await fs.readJSON(manifestPath, { encoding: "utf8" }); } catch { return { complete: false, reason: "manifest-invalid", missingFiles: [] }; } const indexPath = path.join(packageDir, ".fpi.index.json"); if (!await fs.exists(indexPath)) { const legacyMaterializedIndex = await this.tryMaterializeLegacyPackageIndex(packageObject, packageDir); if (!legacyMaterializedIndex) { try { await this.materializePackageIndex(packageObject, packageDir); } catch { return { complete: false, reason: "index-missing", missingFiles: [] }; } if (!await fs.exists(indexPath)) { return { complete: false, reason: "index-missing", missingFiles: [] }; } } } if (await this.hasFreshMaterializationMarker(packageRoot, packageDir, manifestPath, indexPath)) { return { complete: true, reason: "complete", missingFiles: [] }; } let indexJson; try { indexJson = await fs.readJSON(indexPath, { encoding: "utf8" }); } catch { return { complete: false, reason: "index-invalid", missingFiles: [] }; } if (!Array.isArray(indexJson.files)) { return { complete: false, reason: "index-invalid", missingFiles: [] }; } const packageDirEntries = new Set(await fs.readdir(packageDir)); const missingFiles = []; for (const file of indexJson.files) { const filename = typeof file?.filename === "string" ? file.filename : null; if (!filename) { return { complete: false, reason: "index-invalid", missingFiles: [] }; } const canUseDirectoryListing = !filename.includes("/") && !filename.includes("\\"); if (canUseDirectoryListing ? !packageDirEntries.has(filename) : !await fs.exists(path.join(packageDir, filename))) { missingFiles.push(filename); } } if (missingFiles.length > 0) { return { complete: false, reason: "indexed-files-missing", missingFiles }; } await this.writeMaterializationMarker(packageRoot, packageDir, manifestPath, indexPath); return { complete: true, reason: "complete", missingFiles: [] }; }; if (!options?.emitTiming) { return await computeStatus(); } return await this.withDebugTiming( `materialization-status ${this.formatPackageForDebug(packageObject)}`, computeStatus, (status) => this.formatMaterializationStatusForDebug(status) ); } async stagePackageForPublish(packageObject, src, move) { return await this.withDebugTiming( `stage-package-for-publish ${this.formatPackageForDebug(packageObject)}`, async () => { const stagingRoot = await this.createStagingDirectory(packageObject); const sourcePackageDir = await fs.exists(path.join(src, "package")) ? path.join(src, "package") : src; const stagingPackageDir = path.join(stagingRoot, "package"); const packageLabel = this.formatPackageForDebug(packageObject); try { const action = move ? fs.move : fs.copy; const copyOrMoveStartedAtNs = process.hrtime.bigint(); await action(sourcePackageDir, stagingPackageDir, { overwrite: false }); this.logger.debug?.( `[publish] ${move ? "Moved" : "Copied"} staged contents for ${packageLabel} from ${sourcePackageDir} to ${stagingPackageDir} in ${this.formatElapsedMs(copyOrMoveStartedAtNs)}ms.` ); if (move && sourcePackageDir !== src) { const cleanupStartedAtNs = process.hrtime.bigint(); await fs.remove(src).catch(() => void 0); this.logger.debug?.( `[publish] Removed extraction root ${src} after staging ${packageLabel} in ${this.formatElapsedMs(cleanupStartedAtNs)}ms.` ); } const materializeIndexStartedAtNs = process.hrtime.bigint(); const materializedIndex = await this.materializePackageIndex(packageObject, stagingPackageDir); this.logger.debug?.( `[publish] Materialized package index for ${packageLabel} in staging in ${this.formatElapsedMs(materializeIndexStartedAtNs)}ms (fileCount=${materializedIndex.files.length}).` ); const markerStartedAtNs = process.hrtime.bigint(); await this.writeMaterializationMarker( stagingRoot, stagingPackageDir, path.join(stagingPackageDir, "package.json"), path.join(stagingPackageDir, ".fpi.index.json") ); this.logger.debug?.( `[publish] Wrote materialization marker for ${packageLabel} in ${this.formatElapsedMs(markerStartedAtNs)}ms.` ); return stagingRoot; } catch (error) { await fs.remove(stagingRoot).catch(() => void 0); throw error; } } ); } isAlreadyExistsError(error) { if (!error || typeof error !== "object") { return false; } const candidate = error; return candidate.code === "EEXIST" || candidate.code === "ENOTEMPTY" || /dest already exists/i.test(candidate.message ?? ""); } isRetryableStagePublishError(error) { if (!error || typeof error !== "object") { return false; } const candidate = error; return candidate.code === "EACCES" || candidate.code === "EPERM" || candidate.code === "EBUSY"; } getDiskCacheKeyPrefix() { return `${this.registryUrl}`; } async readDiskCacheJson(filePath) { try { if (!await fs.exists(filePath)) return null; const raw = await fs.readJSON(filePath, { encoding: "utf8" }); if (!raw || typeof raw !== "object") return null; if (typeof raw.expiresAt === "number" && "data" in raw) { if (Date.now() >= raw.expiresAt) { await fs.remove(filePath).catch(() => void 0); return null; } return raw.data; } return raw; } catch { return null; } } async writeDiskCacheJson(filePath, data, ttlMs) { try { await fs.ensureDir(path.dirname(filePath)); const expiresAt = Date.now() + ttlMs; const tmp = `${filePath}.${process.pid}.${Date.now()}.tmp`; await fs.writeJSON(tmp, { expiresAt, data }); await fs.move(tmp, filePath, { overwrite: true }); } catch { } } async writeDiskCacheJsonNoTtl(filePath, data) { try { await fs.ensureDir(path.dirname(filePath)); const tmp = `${filePath}.${process.pid}.${Date.now()}.tmp`; await fs.writeJSON(tmp, data); await fs.move(tmp, filePath, { overwrite: true }); } catch { } } getDiskRegistryMetadataCachePath(packageName) { const key = `registry-meta|${this.getDiskCacheKeyPrefix()}|${packageName}`; return path.join(this.cachePath, ".fpi.cache", "metadata", `${sha256Hex(key)}.json`); } getDiskIndexCachePath(packageObject) { const key = `index|${FPI_INDEX_CACHE_VERSION}|${packageObject.id}#${packageObject.version}`; return path.join(this.cachePath, ".fpi.cache", "indexes", `${sha256Hex(key)}.json`); } getDiskTarballCacheKey(packageObject) { return `tarball|${this.getDiskCacheKeyPrefix()}|${packageObject.id}#${packageObject.version}`; } async getDiskTarballCachePaths(packageObject) { const tarDir = await this.ensureDiskCacheSubdir("tarballs"); const tgzPath = path.join(tarDir, `${sha256Hex(this.getDiskTarballCacheKey(packageObject))}.tgz`); const donePath = `${tgzPath}.done`; return { tgzPath, donePath }; } async readDiskTarballCache(packageObject) { try { const { tgzPath, donePath } = await this.getDiskTarballCachePaths(packageObject); if (!await fs.exists(tgzPath)) return null; if (!await fs.exists(donePath)) { await fs.remove(tgzPath).catch(() => void 0); return null; } return tgzPath; } catch { return null; } } async writeDiskTarballDoneMarker(donePath) { try { const tmp = `${donePath}.${process.pid}.${Date.now()}.tmp`; await fs.writeFile(tmp, "ok"); await fs.move(tmp, donePath, { overwrite: true }); } catch { } } getIndexMemKey(packageObject) { return `index|${FPI_INDEX_CACHE_VERSION}|${packageObject.id}#${packageObject.version}`; } getIndexDiskLockKey(packageObject) { return `index-cache|${FPI_INDEX_CACHE_VERSION}|${packageObject.id}#${packageObject.version}`; } getMaterializationMarkerPath(packageRoot) { return path.join(packageRoot, FPI_MATERIALIZATION_MARKER); } async writeMaterializationMarker(packageRoot, packageDir, manifestPath, indexPath) { const markerPath = this.getMaterializationMarkerPath(packageRoot); const tmpPath = `${markerPath}.${process.pid}.${Date.now()}.tmp`; try { const [packageDirStat, manifestStat, indexStat] = await Promise.all([ fs.stat(packageDir), fs.stat(manifestPath), fs.stat(indexPath) ]); await fs.writeJSON(tmpPath, { packageDirMtimeMs: packageDirStat.mtimeMs, packageDirCtimeMs: packageDirStat.ctimeMs, manifestMtimeMs: manifestStat.mtimeMs, manifestCtimeMs: manifestStat.ctimeMs, indexMtimeMs: indexStat.mtimeMs, indexCtimeMs: indexStat.ctimeMs }); await fs.move(tmpPath, markerPath, { overwrite: true }); } catch { } finally { await fs.remove(tmpPath).catch(() => void 0); } } async hasFreshMaterializationMarker(packageRoot, packageDir, manifestPath, indexPath) { const markerPath = this.getMaterializationMarkerPath(packageRoot); try { const [marker, packageDirStat, manifestStat, indexStat] = await Promise.all([ fs.readJSON(markerPath, { encoding: "utf8" }), fs.stat(packageDir), fs.stat(manifestPath), fs.stat(indexPath) ]); return marker.packageDirMtimeMs === packageDirStat.mtimeMs && marker.packageDirCtimeMs === packageDirStat.ctimeMs && marker.manifestMtimeMs === manifestStat.mtimeMs && marker.manifestCtimeMs === manifestStat.ctimeMs && marker.indexMtimeMs === indexStat.mtimeMs && marker.indexCtimeMs === indexStat.ctimeMs; } catch { return false; } } isRegistryDisabled() { return this.registryDisabled; } formatRegistryDisabledMessage(detail) { return `FHIR package registry is disabled (registryUrl=n/a). ${detail}`; } async hasShallowInstalledPackage(packageObject) { const packageRoot = await this.getPackageDirPath(packageObject); if (!await fs.exists(packageRoot)) { return false; } const packageDir = path.join(packageRoot, "package"); if (!await fs.exists(packageDir)) { return false; } const manifestPath = path.join(packageDir, "package.json"); if (!await fs.exists(manifestPath)) { return false; } const indexPath = path.join(packageDir, ".fpi.index.json"); if (await fs.exists(indexPath)) { return await this.isStrictlyMaterialized(packageObject); } const legacyIndexPath = path.join(packageDir, ".index.json"); if (await fs.exists(legacyIndexPath)) { return await this.tryMaterializeLegacyPackageIndex(packageObject, packageDir) !== null; } return true; } async isStrictlyMaterialized(packageObject) { const materialization = await this.getPackageMaterializationStatus(packageObject); return materialization.complete; } async hasReadableManifest(packageObject) { try { await this.getManifest(packageObject); return true; } catch { return false; } } async collectMissingPackages(root, options) { const missing = []; const visited = /* @__PURE__ */ new Set(); const { explicitImplicitVersions } = await this.collectExplicitDependencyClosure(root); const requireStrictMaterialization = options?.requireStrictMaterialization === true; const visit = async (pkg) => { const key = `${pkg.id}#${pkg.version}`; if (visited.has(key)) return; visited.add(key); const isInstalled = requireStrictMaterialization ? await this.isStrictlyMaterialized(pkg) : await this.isInstalled(pkg, { deep: false }); if (!isInstalled) { missing.push(key); return; } const deps = await this.getDependencies(pkg, { explicitImplicitVersions }); for (const [depId, depVersion] of Object.entries(deps || {})) { if (this.skipExamples && depId.includes("examples")) continue; await visit({ id: depId, version: depVersion }); } }; await visit(root); return missing; } async collectPlannedDependencyClosure(root, seedExplicitImplicitVersions) { return await this.withDebugTiming( `collect-planned-dependency-closure ${this.formatPackageForDebug(root)}`, async () => { const { closure, explicitImplicitVersions: localExplicitImplicitVersions } = await this.collectExplicitDependencyClosure(root); const explicitImplicitVersions = new Map(seedExplicitImpl