From 21e5aed3f318d0fbbe3a8f459596a80a54e52eb1 Mon Sep 17 00:00:00 2001 From: xiaojunnuo Date: Wed, 15 Jul 2026 23:25:28 +0800 Subject: [PATCH] =?UTF-8?q?chore:=20=E4=BF=AE=E5=A4=8D=E5=8D=95=E5=85=83?= =?UTF-8?q?=E6=B5=8B=E8=AF=95=E7=9A=84=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/service/npm-registry-resolver.test.ts | 31 - .../src/service/runtime-deps-service.ts | 773 ----------------- ...e-deps-service.test.ts => runtime.test.ts} | 173 +++- packages/core/pipeline/src/service/runtime.ts | 776 +++++++++++++++++- 4 files changed, 901 insertions(+), 852 deletions(-) delete mode 100644 packages/core/pipeline/src/service/npm-registry-resolver.test.ts delete mode 100644 packages/core/pipeline/src/service/runtime-deps-service.ts rename packages/core/pipeline/src/service/{runtime-deps-service.test.ts => runtime.test.ts} (71%) diff --git a/packages/core/pipeline/src/service/npm-registry-resolver.test.ts b/packages/core/pipeline/src/service/npm-registry-resolver.test.ts deleted file mode 100644 index 988db70f8..000000000 --- a/packages/core/pipeline/src/service/npm-registry-resolver.test.ts +++ /dev/null @@ -1,31 +0,0 @@ -import assert from "assert"; -import { NpmRegistryResolver } from "./runtime-deps-service.js"; - -describe("NpmRegistryResolver", () => { - it("chooses the fastest successful registry in auto mode", async () => { - const resolver = new NpmRegistryResolver({ - mode: "auto", - candidates: ["https://slow.example.com", "https://fast.example.com"], - probeTimeoutMs: 100, - cacheTtlMs: 1000, - }); - resolver.probe = async registryUrl => ({ - registryUrl, - ok: true, - elapsedMs: registryUrl.includes("fast") ? 10 : 50, - }); - const result = await resolver.resolve(); - assert.equal(result, "https://fast.example.com"); - }); - - it("uses fixed registry without probing", async () => { - const resolver = new NpmRegistryResolver({ - mode: "fixed", - fixedUrl: "https://registry.example.com", - probeTimeoutMs: 100, - cacheTtlMs: 1000, - }); - const result = await resolver.resolve(); - assert.equal(result, "https://registry.example.com"); - }); -}); diff --git a/packages/core/pipeline/src/service/runtime-deps-service.ts b/packages/core/pipeline/src/service/runtime-deps-service.ts deleted file mode 100644 index 8c7ea7a4c..000000000 --- a/packages/core/pipeline/src/service/runtime-deps-service.ts +++ /dev/null @@ -1,773 +0,0 @@ -import fs from "fs"; -import path from "path"; -import { spawn } from "child_process"; -import crypto from "crypto"; -import { createRequire } from "module"; -import { pathToFileURL } from "url"; -import { logger as defaultLogger } from "@certd/basic"; -import type { Registry } from "../registry/registry.js"; -export type ILogger = { - info: (message: string) => void; - warn?: (message: string) => void; - error?: (message: string, ...args: any[]) => void; -}; - -export type ImportRuntime = (specifier: string, logger?: ILogger) => Promise; - -export type EnsureRuntimeDepsOptions = { - pluginKeys: string | string[]; - logger?: ILogger; -}; - -export interface IRuntimeDepsService { - ensureRuntimeDependencies(options: EnsureRuntimeDepsOptions): Promise; - importRuntime: ImportRuntime; -} - -export type RuntimeDependencyPluginDefine = { - name: string; - key?: string; - title?: string; - version?: string; - pluginType?: string; - addonType?: string; - dependPlugins?: Record; - dependPackages?: Record; -}; - -type RegisteredDefineLike = RuntimeDependencyPluginDefine & { - key?: string; - pluginType?: string; - addonType?: string; - dependPlugins?: Record; - dependPackages?: Record; -}; - -type DependencyConflict = { - packageName: string; - ranges: Array<{ pluginName: string; range: string }>; -}; - -type CollectDependenciesResult = { - dependencies: Record; - conflicts: DependencyConflict[]; -}; - -type InstallResult = { - registryUrl: string; - packageJsonPath: string; -}; - -type RuntimeImportResolveResult = { - resolved: string; - packageName: string; -}; - -type CommandRunnerResult = { - stdout: string; - stderr: string; - code: number; -}; - -type CommandRunner = { - run(command: string, args: string[], options: { cwd: string; timeoutMs: number; env?: NodeJS.ProcessEnv }): Promise; -}; - -export type NpmRegistryResolverConfig = { - mode?: "auto" | "fixed" | "system"; - fixedUrl?: string; - candidates?: string[]; - probeTimeoutMs?: number; - cacheTtlMs?: number; -}; - -export type RegistryProbeResult = { - registryUrl: string; - ok: boolean; - elapsedMs: number; -}; - -export class NpmRegistryResolver { - config: NpmRegistryResolverConfig; - private cache?: { registryUrl: string; expiresAt: number }; - - constructor(config?: NpmRegistryResolverConfig) { - this.config = config || {}; - } - - async resolve(): Promise { - const config = this.config; - if (config?.mode === "fixed" && config.fixedUrl) { - return config.fixedUrl; - } - if (config?.mode === "system") { - return ""; - } - const cached = this.cache; - if (cached && cached.expiresAt > Date.now()) { - return cached.registryUrl; - } - const candidates = (config?.candidates || []).filter(Boolean); - if (candidates.length === 0) { - return ""; - } - const probes = await Promise.allSettled(candidates.map(registryUrl => this.probe(registryUrl))); - const okList = probes.map(item => (item.status === "fulfilled" ? item.value : null)).filter((item): item is RegistryProbeResult => !!item && item.ok); - if (okList.length > 0) { - okList.sort((a, b) => a.elapsedMs - b.elapsedMs); - const best = okList[0].registryUrl; - this.cache = { registryUrl: best, expiresAt: Date.now() + (config?.cacheTtlMs || 6 * 60 * 60 * 1000) }; - return best; - } - return ""; - } - - async probe(registryUrl: string): Promise { - const timeoutMs = this.config?.probeTimeoutMs || 3000; - const started = Date.now(); - try { - const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), timeoutMs); - try { - const res = await fetch(`${registryUrl.replace(/\/$/, "")}/-/ping`, { signal: controller.signal }); - return { registryUrl, ok: res.ok, elapsedMs: Date.now() - started }; - } finally { - clearTimeout(timer); - } - } catch { - return { registryUrl, ok: false, elapsedMs: Date.now() - started }; - } - } -} - -export type RuntimeDepsConfig = { - rootDir?: string; - autoInstall?: boolean; - enabled?: boolean; - installTimeoutMs?: number; - pnpmCommand?: string; - lazyDependencies?: Record; - registry?: NpmRegistryResolverConfig; -}; - -function normalizeRange(range: string) { - return range.trim().replace(/^\^/, "").replace(/^~?/, ""); -} - -function areRangesCompatible(a: string, b: string) { - if (!a || !b) { - return true; - } - if (a === "*" || b === "*") { - return true; - } - const left = normalizeRange(a).split("."); - const right = normalizeRange(b).split("."); - return left[0] === right[0]; -} - -const PROCESS_LOCKS = new Map>(); - -class DefaultCommandRunner implements CommandRunner { - async run(command: string, args: string[], options: { cwd: string; timeoutMs: number; env?: NodeJS.ProcessEnv }): Promise { - return await new Promise(resolve => { - let stdout = ""; - let stderr = ""; - let settled = false; - const child = spawn(command, args, { cwd: options.cwd, env: options.env, windowsHide: true, shell: process.platform === "win32" }); - const timer = setTimeout(() => { - if (settled) { - return; - } - settled = true; - child.kill("SIGTERM"); - resolve({ stdout, stderr: stderr || `command timeout after ${options.timeoutMs}ms`, code: 1 }); - }, options.timeoutMs); - child.stdout?.on("data", chunk => { - stdout += chunk.toString(); - }); - child.stderr?.on("data", chunk => { - stderr += chunk.toString(); - }); - child.on("error", error => { - if (settled) { - return; - } - settled = true; - clearTimeout(timer); - resolve({ stdout, stderr: error.message, code: 1 }); - }); - child.on("close", code => { - if (settled) { - return; - } - settled = true; - clearTimeout(timer); - resolve({ stdout, stderr, code: code || 0 }); - }); - }); - } -} - -export class RuntimeDepsService { - runtimeDepsRootDir: string; - autoInstall: boolean; - enabled: boolean; - installTimeoutMs: number; - pnpmCommand: string; - lazyDependencies: Record; - registryResolver!: NpmRegistryResolver; - commandRunner: CommandRunner = new DefaultCommandRunner(); - pluginLazyDependencies: Record = {}; - private installPromises = new Map>(); - private registriesMap: Record; pluginType: string; addonType?: string }> | null = null; - - constructor(config: RuntimeDepsConfig, registries: any) { - this.runtimeDepsRootDir = config?.rootDir ?? "./data/.runtime-deps"; - this.autoInstall = config?.autoInstall ?? true; - this.enabled = config?.enabled ?? true; - this.installTimeoutMs = config?.installTimeoutMs ?? 120000; - this.pnpmCommand = config?.pnpmCommand ?? ""; - this.lazyDependencies = config?.lazyDependencies ?? {}; - this.registryResolver = new NpmRegistryResolver(config?.registry); - if (registries) { - this.setRegistries(registries); - } - } - - setRegistries(registries: { pluginRegistry?: Registry; accessRegistry?: Registry; notificationRegistry?: Registry; dnsProviderRegistry?: Registry; addonRegistry?: Registry }) { - const map: Record; pluginType: string; addonType?: string }> = {}; - if (registries.pluginRegistry) { - map["plugin"] = { registry: registries.pluginRegistry, pluginType: "plugin" }; - } - if (registries.accessRegistry) { - map["access"] = { registry: registries.accessRegistry, pluginType: "access" }; - } - if (registries.notificationRegistry) { - map["notification"] = { registry: registries.notificationRegistry, pluginType: "notification" }; - } - if (registries.dnsProviderRegistry) { - map["dnsProvider"] = { registry: registries.dnsProviderRegistry, pluginType: "dnsProvider" }; - } - if (registries.addonRegistry) { - map["addon"] = { registry: registries.addonRegistry, pluginType: "addon", addonType: "" }; - } - this.registriesMap = map; - } - - collectDependencies(plugins: RuntimeDependencyPluginDefine[]): CollectDependenciesResult { - const merged: Record = {}; - const seen: Record> = {}; - for (const plugin of plugins) { - const deps = plugin.dependPackages || {}; - for (const [packageName, range] of Object.entries(deps)) { - seen[packageName] ||= []; - seen[packageName].push({ pluginName: plugin.name, range }); - } - } - const conflicts: DependencyConflict[] = []; - for (const [packageName, ranges] of Object.entries(seen)) { - const first = ranges[0]?.range; - if (!first) { - continue; - } - const conflict = ranges.some(item => !areRangesCompatible(first, item.range)); - if (conflict) { - conflicts.push({ packageName, ranges }); - continue; - } - merged[packageName] = first; - } - return { dependencies: merged, conflicts }; - } - - async ensureInstalled(options: { plugins: RuntimeDependencyPluginDefine[]; logger?: ILogger }): Promise { - const { plugins, logger: log } = options; - const { dependencies, conflicts } = this.resolveDependenciesFromPlugins(plugins); - if (conflicts.length > 0) { - const conflict = conflicts[0]; - throw new Error(`动态依赖版本冲突: ${conflict.packageName} => ${conflict.ranges.map(item => `${item.pluginName}:${item.range}`).join(", ")}`); - } - return await this.ensureDependencies({ dependencies, logger: log }); - } - - async ensureDependencies(options: { dependencies: Record; logger?: ILogger }): Promise { - const { dependencies, logger: log } = options; - if (!this.enabled) { - return { registryUrl: "", packageJsonPath: path.join(this.getRuntimeDepsRootDir(), "package.json") }; - } - if (!this.autoInstall) { - return { registryUrl: "", packageJsonPath: path.join(this.getRuntimeDepsRootDir(), "package.json") }; - } - const dependenciesHash = this.createDependenciesHash(dependencies); - let installPromise = this.installPromises.get(dependenciesHash); - if (installPromise) { - const nodeModulesPath = path.join(this.getRuntimeDepsRootDir(), "node_modules"); - if (!fs.existsSync(nodeModulesPath)) { - this.installPromises.delete(dependenciesHash); - installPromise = undefined; - } - } - if (!installPromise) { - installPromise = this.doEnsureInstalled({ dependencies, logger: log }).catch(error => { - this.installPromises.delete(dependenciesHash); - throw error; - }); - this.installPromises.set(dependenciesHash, installPromise); - } - return await installPromise; - } - - resolveDependenciesFromPlugins(plugins: RuntimeDependencyPluginDefine[]): CollectDependenciesResult { - const expandedPlugins = plugins.flatMap(plugin => this.resolvePluginDependencies(plugin)); - return this.collectDependencies(expandedPlugins); - } - - async ensureRuntimeDependencies(options: { pluginKeys: string | string[]; logger?: ILogger }): Promise { - const { pluginKeys, logger: log } = options; - const keys = Array.isArray(pluginKeys) ? pluginKeys : [pluginKeys]; - const pluginDefines = keys.map(pluginKey => this.getDefineByPluginKey(pluginKey)); - if (pluginDefines.every(pluginDefine => !pluginDefine.dependPackages && !pluginDefine.dependPlugins)) { - return { registryUrl: "", packageJsonPath: path.join(this.getRuntimeDepsRootDir(), "package.json") }; - } - const expandedPluginDefines = pluginDefines.flatMap(pluginDefine => this.resolvePluginDependencies(pluginDefine)); - return await this.ensureInstalled({ plugins: expandedPluginDefines, logger: log }); - } - - async importRuntime(specifier: string, logger: ILogger = defaultLogger) { - if (this.isNativeImportSpecifier(specifier)) { - return await import(specifier); - } - const resolved = await this.resolveImportSpecifier(specifier, logger); - return await import(pathToFileURL(resolved).href); - } - - private async resolveImportSpecifier(specifier: string, logger: ILogger = defaultLogger) { - try { - return this.resolveRuntimeSpecifier(specifier).resolved; - } catch (runtimeError: any) { - if (!this.isModuleNotFoundError(runtimeError)) { - throw runtimeError; - } - return await this.resolveMissingRuntimeSpecifier(specifier, runtimeError, logger); - } - } - - private async resolveMissingRuntimeSpecifier(specifier: string, runtimeError: any, logger?: ILogger) { - const packageName = this.parsePackageName(specifier); - const mergedDeps = this.getMergedLazyDependencies(); - const lazyRange = mergedDeps[packageName]; - if (!lazyRange) { - try { - return this.resolveProjectSpecifier(specifier, runtimeError).resolved; - } catch { - throw new Error(`动态依赖未安装且未配置懒加载版本: ${packageName}`); - } - } - try { - await this.ensureLazyDependency(packageName, logger); - return this.resolveRuntimeSpecifier(specifier).resolved; - } catch (lazyError: any) { - return this.resolveProjectSpecifier(specifier, lazyError).resolved; - } - } - - private isNativeImportSpecifier(specifier: string) { - return specifier.startsWith(".") || specifier.startsWith("/") || specifier.startsWith("file:") || specifier.startsWith("node:"); - } - - private resolveRuntimeSpecifier(specifier: string): RuntimeImportResolveResult { - const packageName = this.parsePackageName(specifier); - const packageJsonPath = path.join(this.getRuntimeDepsRootDir(), "package.json"); - const require = createRequire(packageJsonPath); - const resolved = require.resolve(specifier); - return { packageName, resolved }; - } - - private resolveProjectSpecifier(specifier: string, cause?: any): RuntimeImportResolveResult { - try { - const packageName = this.parsePackageName(specifier); - const packageJsonPath = path.resolve("package.json"); - const require = createRequire(packageJsonPath); - const resolved = require.resolve(specifier); - return { packageName, resolved }; - } catch (projectError: any) { - if (cause) { - projectError.cause = cause; - } - throw projectError; - } - } - - private parsePackageName(specifier: string) { - if (!specifier || specifier.trim() !== specifier) { - throw new Error(`动态依赖导入路径无效: ${specifier}`); - } - const parts = specifier.split("/"); - if (specifier.startsWith("@")) { - if (parts.length < 2 || !parts[0] || !parts[1]) { - throw new Error(`动态依赖导入路径无效: ${specifier}`); - } - return `${parts[0]}/${parts[1]}`; - } - if (!parts[0]) { - throw new Error(`动态依赖导入路径无效: ${specifier}`); - } - return parts[0]; - } - - private async ensureLazyDependency(packageName: string, logger?: ILogger) { - const range = this.lazyDependencies?.[packageName]; - if (!range) { - throw new Error(`动态依赖未安装且未配置懒加载版本: ${packageName}`); - } - await this.ensureDependencies({ dependencies: { [packageName]: range }, logger }); - } - - private isModuleNotFoundError(error: any) { - return error?.code === "MODULE_NOT_FOUND" || error?.code === "ERR_MODULE_NOT_FOUND"; - } - - resolvePluginDependencies(current: RuntimeDependencyPluginDefine): RuntimeDependencyPluginDefine[] { - const resolved: RuntimeDependencyPluginDefine[] = []; - const visited = new Set(); - const visit = (item: RuntimeDependencyPluginDefine) => { - const key = this.buildPluginDependencyKey(item); - if (visited.has(key)) { - return; - } - visited.add(key); - resolved.push(item); - for (const [dependencyName, expectedRange] of Object.entries(item.dependPlugins || {})) { - const dependency = this.getDefineByPluginKey(dependencyName, item); - if (!isPluginVersionCompatible(dependency, expectedRange)) { - throw new Error(`插件依赖版本冲突: ${item.name} 依赖 ${dependencyName}@${expectedRange},当前版本为 ${dependency.version || "未声明"}`); - } - visit(dependency); - } - }; - visit(current); - return resolved; - } - - private buildPluginDependencyKey(plugin: RuntimeDependencyPluginDefine) { - if (plugin.pluginType === "addon" && plugin.addonType) { - return `addon:${plugin.addonType}:${plugin.name}`; - } - const pluginType = plugin.pluginType === "deploy" ? "plugin" : plugin.pluginType || "unknown"; - return `${pluginType}:${plugin.name}`; - } - - private getDefineByPluginKey(pluginKey: string, owner?: RuntimeDependencyPluginDefine): RuntimeDependencyPluginDefine { - const parts = pluginKey.split(":"); - let pluginType: string, name: string, subtype: string | undefined; - if (parts.length === 2) { - [pluginType, name] = parts; - } else if (parts.length === 3) { - [pluginType, subtype, name] = parts; - } else { - const ownerName = owner?.name || pluginKey; - throw new Error(`插件依赖格式错误: ${ownerName} 依赖 ${pluginKey}`); - } - if (!this.registriesMap) { - throw new Error("注册表未设置,请先调用 setRegistries"); - } - const target = this.registriesMap[pluginType]; - if (!target) { - const ownerName = owner?.name || pluginKey; - throw new Error(`插件依赖格式错误: ${ownerName} 依赖 ${pluginKey},未知插件类型 ${pluginType}`); - } - // addon 类型的 key 需要包含 subtype - const registryKey = pluginType === "addon" && subtype ? `${subtype}:${name}` : name; - const define = target.registry.getDefine(registryKey) as RegisteredDefineLike; - if (!define) { - throw new Error(`插件依赖缺失: ${owner?.name || pluginKey} 依赖 ${pluginKey},但该插件未注册或已禁用`); - } - return { ...define, key: pluginKey, pluginType: target.pluginType, addonType: target.addonType }; - } - - private async doEnsureInstalled(options: { dependencies: Record; logger?: ILogger }): Promise { - let { dependencies } = options; - const log = options.logger || defaultLogger; - return await this.withInstallLock(async () => { - const rootDir = this.getRuntimeDepsRootDir(); - const packageJsonPath = path.join(rootDir, "package.json"); - const lockPath = path.join(rootDir, "pnpm-lock.yaml"); - log.info(`第三方依赖安装: ${JSON.stringify(dependencies)}`); - dependencies = this.mergeInstalledDependencies(this.readManifestDependencies(packageJsonPath), dependencies); - const dependenciesHash = this.createDependenciesHash(dependencies); - const statePath = path.join(rootDir, "install-state.json"); - const currentState = this.readInstallState(statePath); - if (currentState?.dependenciesHash === dependenciesHash && fs.existsSync(path.join(rootDir, "node_modules"))) { - log.info("第三方依赖已安装"); - return { registryUrl: currentState.registryUrl || "", packageJsonPath }; - } - const manifest = { name: "certd-runtime-deps", private: true, type: "module", dependencies }; - fs.writeFileSync(packageJsonPath, JSON.stringify(manifest, null, 2), "utf8"); - const registryUrl = await this.registryResolver.resolve(); - const env = this.buildChildEnv(registryUrl); - const command = this.getPnpmCommand(); - const pnpmVersion = await this.getPnpmVersion(command, env); - const args = ["install", "--prod", "--ignore-scripts", "--ignore-workspace", "--no-frozen-lockfile", "--reporter=append-only"]; - if (registryUrl) { - args.push(`--registry=${registryUrl}`); - } - log.info(`开始安装第三方依赖: ${Object.keys(dependencies).join(", ")}`); - const result = await this.commandRunner.run(command, args, { cwd: rootDir, timeoutMs: this.installTimeoutMs, env }); - if (result.code !== 0) { - const message = result.stderr || result.stdout || "unknown error"; - this.writeInstallState(statePath, { - ...currentState, - installedAt: currentState?.installedAt, - failedAt: new Date().toISOString(), - registryUrl, - dependenciesHash, - nodeVersion: process.version, - pnpmVersion, - lockFileExists: fs.existsSync(lockPath), - lastError: message, - }); - throw new Error(`动态依赖安装失败: ${message}`); - } - this.writeInstallState(statePath, { installedAt: new Date().toISOString(), registryUrl, dependenciesHash, nodeVersion: process.version, pnpmVersion, lockFileExists: fs.existsSync(lockPath) }); - log.info("第三方依赖安装完成"); - return { registryUrl, packageJsonPath }; - }); - } - - private async withInstallLock(run: () => Promise): Promise { - const rootDir = this.getRuntimeDepsRootDir(); - fs.mkdirSync(rootDir, { recursive: true }); - const lockFile = path.join(rootDir, ".install.lock"); - const previous = PROCESS_LOCKS.get(lockFile); - if (previous) { - await previous.catch(() => undefined); - } - let releaseProcessLock!: () => void; - const current = new Promise(resolve => { - releaseProcessLock = resolve; - }); - PROCESS_LOCKS.set(lockFile, current); - let fd: number | undefined; - try { - fd = await this.acquireFileLock(lockFile); - return await run(); - } finally { - if (fd != null) { - fs.closeSync(fd); - try { - fs.rmSync(lockFile, { force: true }); - } catch { - try { - fs.rmSync(lockFile, { force: true }); - } catch {} - } - } - releaseProcessLock(); - if (PROCESS_LOCKS.get(lockFile) === current) { - PROCESS_LOCKS.delete(lockFile); - } - } - } - - private async acquireFileLock(lockFile: string) { - const deadline = Date.now() + this.installTimeoutMs; - while (true) { - try { - const fd = fs.openSync(lockFile, "wx"); - fs.writeFileSync(fd, JSON.stringify({ pid: process.pid, createdAt: new Date().toISOString() }), "utf8"); - return fd; - } catch (error: any) { - if (error?.code !== "EEXIST") { - throw error; - } - if (Date.now() > deadline) { - throw new Error(`动态依赖安装锁等待超时: ${lockFile}`); - } - await this.waitForExternalLock(lockFile, deadline); - } - } - } - - private async waitForExternalLock(lockFile: string, deadline: number) { - while (fs.existsSync(lockFile)) { - if (Date.now() > deadline) { - throw new Error(`动态依赖安装锁等待超时: ${lockFile}`); - } - await new Promise(resolve => setTimeout(resolve, 300)); - } - } - - async clearRuntimeDeps() { - const rootDir = this.getRuntimeDepsRootDir(); - const normalizedRootDir = path.normalize(rootDir); - if (!normalizedRootDir.endsWith(path.normalize(".runtime-deps"))) { - throw new Error(`动态依赖目录配置异常,拒绝清理: ${rootDir}`); - } - await this.withInstallLock(async () => { - if (fs.existsSync(rootDir)) { - const entries = fs.readdirSync(rootDir); - for (const entry of entries) { - if (entry === ".install.lock") { - continue; - } - fs.rmSync(path.join(rootDir, entry), { recursive: true, force: true }); - } - } - this.installPromises.clear(); - return undefined; - }); - } - - getMergedLazyDependencies(): Record { - return { ...this.lazyDependencies, ...this.pluginLazyDependencies }; - } - - collectPluginDeps(logger?: ILogger) { - if (!this.registriesMap) { - return; - } - const deps: Record = {}; - for (const { registry } of Object.values(this.registriesMap)) { - const defineList = registry.getDefineList(); - for (const define of defineList) { - const dependPackages = (define as any).dependPackages as Record | undefined; - if (!dependPackages) { - continue; - } - for (const [pkgName, range] of Object.entries(dependPackages)) { - const existing = deps[pkgName]; - if (existing && !areRangesCompatible(existing, range)) { - (logger || defaultLogger).warn?.(`懒加载依赖版本冲突: ${pkgName} => ${existing} vs ${range},保留已有版本`); - continue; - } - deps[pkgName] = range; - } - } - } - this.pluginLazyDependencies = deps; - (logger || defaultLogger).info(`从插件注册表收集到 ${Object.keys(deps).length} 个懒加载依赖`); - } - - refreshPluginDeps(logger?: ILogger) { - this.collectPluginDeps(logger); - } - - private readInstallState(statePath: string): any { - if (!fs.existsSync(statePath)) { - return null; - } - try { - return JSON.parse(fs.readFileSync(statePath, "utf8")); - } catch { - return null; - } - } - - private writeInstallState(statePath: string, state: any) { - fs.writeFileSync(statePath, JSON.stringify(state, null, 2), "utf8"); - } - - private readManifestDependencies(packageJsonPath: string): Record { - if (!fs.existsSync(packageJsonPath)) { - return {}; - } - try { - const manifest = JSON.parse(fs.readFileSync(packageJsonPath, "utf8")); - return manifest.dependencies || {}; - } catch { - return {}; - } - } - - private mergeInstalledDependencies(installed: Record, requested: Record) { - const dependencies = { ...installed }; - for (const [packageName, range] of Object.entries(requested)) { - const installedRange = dependencies[packageName]; - if (installedRange && !areRangesCompatible(installedRange, range)) { - throw new Error(`动态依赖版本冲突: ${packageName} => installed:${installedRange}, requested:${range}`); - } - dependencies[packageName] = installedRange || range; - } - return dependencies; - } - - private async getPnpmVersion(command: string, env: NodeJS.ProcessEnv) { - const result = await this.commandRunner.run(command, ["--version"], { cwd: this.getRuntimeDepsRootDir(), timeoutMs: Math.min(this.installTimeoutMs, 10000), env }); - if (result.code !== 0) { - return ""; - } - return (result.stdout || result.stderr || "").trim(); - } - - private getPnpmCommand() { - return this.pnpmCommand || "pnpm"; - } - - private buildChildEnv(registryUrl: string) { - const env = { ...process.env }; - for (const key of ["NODE_OPTIONS", "VSCODE_INSPECTOR_OPTIONS", "NODE_INSPECTOR_PORT", "NODE_DEBUG"]) { - if (!env[key]) { - continue; - } - if (key === "NODE_OPTIONS") { - env[key] = this.stripDebugNodeOptions(env[key] as string); - } else { - delete env[key]; - } - } - if (registryUrl) { - env.npm_config_registry = registryUrl; - env.pnpm_config_registry = registryUrl; - } - env.CI = env.CI || "true"; - env.npm_config_confirm_modules_purge = "false"; - env.pnpm_config_confirm_modules_purge = "false"; - return env; - } - - private stripDebugNodeOptions(value: string) { - return value - .split(/\s+/) - .filter(Boolean) - .filter(item => !/^--inspect(-brk|-port)?(=|$)/.test(item)) - .filter(item => !/^--debug(=|$)/.test(item)) - .join(" "); - } - - getRuntimeDepsRootDir() { - return path.resolve(this.runtimeDepsRootDir); - } - - private createDependenciesHash(dependencies: Record) { - return crypto.createHash("sha256").update(JSON.stringify(dependencies)).digest("hex"); - } -} - -function isPluginVersionCompatible(plugin: RuntimeDependencyPluginDefine, expectedRange: string) { - if (!expectedRange || expectedRange === "*") { - return true; - } - if (!plugin.version) { - return false; - } - return areRangesCompatible(expectedRange, plugin.version); -} - -let runtimeDepsServiceInstance: RuntimeDepsService | null = null; - -export function initRuntimeDepsService(config: RuntimeDepsConfig, registries: any): RuntimeDepsService { - runtimeDepsServiceInstance = new RuntimeDepsService(config, registries); - return runtimeDepsServiceInstance; -} - -export function getRuntimeDepsService(): RuntimeDepsService { - if (!runtimeDepsServiceInstance) { - throw new Error("RuntimeDepsService 未初始化"); - } - return runtimeDepsServiceInstance!; -} - -export async function importRuntime(specifier: string, logger: ILogger = defaultLogger): Promise { - return getRuntimeDepsService().importRuntime(specifier, logger); -} diff --git a/packages/core/pipeline/src/service/runtime-deps-service.test.ts b/packages/core/pipeline/src/service/runtime.test.ts similarity index 71% rename from packages/core/pipeline/src/service/runtime-deps-service.test.ts rename to packages/core/pipeline/src/service/runtime.test.ts index 4b3aebcaa..1947840a3 100644 --- a/packages/core/pipeline/src/service/runtime-deps-service.test.ts +++ b/packages/core/pipeline/src/service/runtime.test.ts @@ -2,13 +2,13 @@ import fs from "fs"; import path from "path"; import os from "os"; -import { RuntimeDepsService, type RuntimeDependencyPluginDefine } from "./runtime-deps-service.js"; +import { RuntimeDepsService, NpmRegistryResolver, type RuntimeDependencyPluginDefine } from "./runtime.js"; import { accessRegistry } from "../access/registry.js"; import { pluginRegistry } from "../plugin/registry.js"; describe("RuntimeDepsService", () => { it("detects conflicting dependency ranges across plugins", () => { - const service = new RuntimeDepsService(); + const service = new RuntimeDepsService({}, null); const merged = service.collectDependencies([ { name: "a", dependPackages: { foo: "^1.0.0" } }, { name: "b", dependPackages: { foo: "^1.2.0" } }, @@ -18,7 +18,7 @@ describe("RuntimeDepsService", () => { }); it("reports incompatible dependency ranges", () => { - const service = new RuntimeDepsService(); + const service = new RuntimeDepsService({}, null); const merged = service.collectDependencies([ { name: "a", dependPackages: { foo: "^1.0.0" } }, { name: "b", dependPackages: { foo: "^2.0.0" } }, @@ -29,14 +29,18 @@ describe("RuntimeDepsService", () => { it("builds a runtime package manifest in the target directory", async () => { const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), "certd-runtime-deps-")); - const service = new RuntimeDepsService({ rootDir }); + const service = new RuntimeDepsService({ rootDir }, null); service.registryResolver = { - async resolve() { return "https://registry.npmmirror.com"; }, + async resolve() { + return "https://registry.npmmirror.com"; + }, } as any; service.commandRunner = { async run(command: string, args: string[]) { assert.equal(command, "pnpm"); - if (args.includes("--version")) { return { stdout: "9.1.0\n", stderr: "", code: 0 }; } + if (args.includes("--version")) { + return { stdout: "9.1.0\n", stderr: "", code: 0 }; + } assert.equal(args[0], "install"); assert.ok(args.includes("--ignore-workspace")); assert.ok(args.includes("--no-frozen-lockfile")); @@ -51,12 +55,18 @@ describe("RuntimeDepsService", () => { it("installs direct dependency maps without plugin metadata", async () => { const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), "certd-runtime-deps-direct-")); - const service = new RuntimeDepsService({ rootDir }); - service.registryResolver = { async resolve() { return ""; } } as any; + const service = new RuntimeDepsService({ rootDir }, null); + service.registryResolver = { + async resolve() { + return ""; + }, + } as any; service.commandRunner = { async run(command: string, args: string[]) { assert.equal(command, "pnpm"); - if (args.includes("--version")) { return { stdout: "9.1.0\n", stderr: "", code: 0 }; } + if (args.includes("--version")) { + return { stdout: "9.1.0\n", stderr: "", code: 0 }; + } fs.mkdirSync(path.join(rootDir, "node_modules"), { recursive: true }); return { stdout: "", stderr: "", code: 0 }; }, @@ -73,20 +83,30 @@ describe("RuntimeDepsService", () => { fs.writeFileSync(path.join(rootDir, "package.json"), JSON.stringify({ name: "runtime-root", type: "module" }), "utf8"); fs.writeFileSync(path.join(packageDir, "package.json"), JSON.stringify({ name: "runtime-only", type: "module", main: "index.js" }), "utf8"); fs.writeFileSync(path.join(packageDir, "index.js"), "export const value = 42;\n", "utf8"); - const service = new RuntimeDepsService({ rootDir }); - service.commandRunner = { async run() { throw new Error("install should not run"); } } as any; + const service = new RuntimeDepsService({ rootDir }, null); + service.commandRunner = { + async run() { + throw new Error("install should not run"); + }, + } as any; const mod = await service.importRuntime("runtime-only"); assert.equal(mod.value, 42); }); it("installs configured lazy dependency when import target is missing", async () => { const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), "certd-runtime-deps-lazy-")); - const service = new RuntimeDepsService({ rootDir, lazyDependencies: { "lazy-pkg": "^1.2.3" } }); - service.registryResolver = { async resolve() { return ""; } } as any; + const service = new RuntimeDepsService({ rootDir, lazyDependencies: { "lazy-pkg": "^1.2.3" } }, null); + service.registryResolver = { + async resolve() { + return ""; + }, + } as any; service.commandRunner = { async run(command: string, args: string[]) { assert.equal(command, "pnpm"); - if (args.includes("--version")) { return { stdout: "9.1.0\n", stderr: "", code: 0 }; } + if (args.includes("--version")) { + return { stdout: "9.1.0\n", stderr: "", code: 0 }; + } const packageDir = path.join(rootDir, "node_modules", "lazy-pkg", "sub"); fs.mkdirSync(packageDir, { recursive: true }); fs.writeFileSync(path.join(rootDir, "node_modules", "lazy-pkg", "package.json"), JSON.stringify({ name: "lazy-pkg", type: "module" }), "utf8"); @@ -102,12 +122,18 @@ describe("RuntimeDepsService", () => { it("resolves scoped package names for lazy imports", async () => { const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), "certd-runtime-deps-scoped-")); - const service = new RuntimeDepsService({ rootDir, lazyDependencies: { "@scope/lazy": "^2.0.0" } }); - service.registryResolver = { async resolve() { return ""; } } as any; + const service = new RuntimeDepsService({ rootDir, lazyDependencies: { "@scope/lazy": "^2.0.0" } }, null); + service.registryResolver = { + async resolve() { + return ""; + }, + } as any; service.commandRunner = { async run(command: string, args: string[]) { assert.equal(command, "pnpm"); - if (args.includes("--version")) { return { stdout: "9.1.0\n", stderr: "", code: 0 }; } + if (args.includes("--version")) { + return { stdout: "9.1.0\n", stderr: "", code: 0 }; + } const packageDir = path.join(rootDir, "node_modules", "@scope", "lazy", "dist"); fs.mkdirSync(packageDir, { recursive: true }); fs.writeFileSync(path.join(rootDir, "node_modules", "@scope", "lazy", "package.json"), JSON.stringify({ name: "@scope/lazy", type: "module" }), "utf8"); @@ -123,25 +149,31 @@ describe("RuntimeDepsService", () => { it("reports missing lazy dependency configuration", async () => { const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), "certd-runtime-deps-lazy-missing-")); - const service = new RuntimeDepsService({ rootDir, lazyDependencies: {} }); + const service = new RuntimeDepsService({ rootDir, lazyDependencies: {} }, null); await assert.rejects(() => service.importRuntime("missing-pkg/sub.js"), /未配置懒加载版本: missing-pkg/); }); it("falls back to project node_modules when lazy dependency is not configured", async () => { const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), "certd-runtime-deps-project-fallback-")); - const service = new RuntimeDepsService({ rootDir, lazyDependencies: {} }); + const service = new RuntimeDepsService({ rootDir, lazyDependencies: {} }, null); const mod = await service.importRuntime("dayjs"); assert.equal(typeof mod.default, "function"); }); it("keeps previously installed dependencies when installing a later plugin", async () => { const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), "certd-runtime-deps-merge-")); - const service = new RuntimeDepsService({ rootDir }); - service.registryResolver = { async resolve() { return ""; } } as any; + const service = new RuntimeDepsService({ rootDir }, null); + service.registryResolver = { + async resolve() { + return ""; + }, + } as any; service.commandRunner = { async run(command: string, args: string[]) { assert.equal(command, "pnpm"); - if (args.includes("--version")) { return { stdout: "9.1.0\n", stderr: "", code: 0 }; } + if (args.includes("--version")) { + return { stdout: "9.1.0\n", stderr: "", code: 0 }; + } fs.mkdirSync(path.join(rootDir, "node_modules"), { recursive: true }); return { stdout: "", stderr: "", code: 0 }; }, @@ -153,14 +185,16 @@ describe("RuntimeDepsService", () => { }); it("includes npm dependencies from dependent plugins", async () => { - const service = new RuntimeDepsService(); + const service = new RuntimeDepsService({}, { accessRegistry, pluginRegistry }); accessRegistry.register("runtimeDepsAccess", { define: { name: "runtimeDepsAccess", title: "access", dependPackages: { accessOnly: "^1.0.0" } } as any, - target: async () => ({} as any), + target: async () => ({}) as any, }); try { const resolved = service.resolvePluginDependencies({ - name: "deploy", pluginType: "deploy", dependPlugins: { "access:runtimeDepsAccess": "*" }, + name: "deploy", + pluginType: "deploy", + dependPlugins: { "access:runtimeDepsAccess": "*" }, dependPackages: { deployOnly: "^1.0.0" }, }); const merged = service.collectDependencies(resolved); @@ -172,19 +206,25 @@ describe("RuntimeDepsService", () => { it("installs dependencies by registered plugin key", async () => { const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), "certd-runtime-deps-key-")); - const service = new RuntimeDepsService({ rootDir }); - service.registryResolver = { async resolve() { return ""; } } as any; + const service = new RuntimeDepsService({ rootDir }, { pluginRegistry, accessRegistry }); + service.registryResolver = { + async resolve() { + return ""; + }, + } as any; service.commandRunner = { async run(command: string, args: string[]) { assert.equal(command, "pnpm"); - if (args.includes("--version")) { return { stdout: "9.1.0\n", stderr: "", code: 0 }; } + if (args.includes("--version")) { + return { stdout: "9.1.0\n", stderr: "", code: 0 }; + } fs.mkdirSync(path.join(rootDir, "node_modules"), { recursive: true }); return { stdout: "", stderr: "", code: 0 }; }, } as any; pluginRegistry.register("runtimeDepsKey", { define: { name: "runtimeDepsKey", title: "key", dependPackages: { keyed: "^1.0.0" } } as any, - target: async () => ({} as any), + target: async () => ({}) as any, }); try { service.setRegistries({ pluginRegistry, accessRegistry }); @@ -197,22 +237,24 @@ describe("RuntimeDepsService", () => { }); it("reports missing dependent plugins", () => { - const service = new RuntimeDepsService(); + const service = new RuntimeDepsService({}, { accessRegistry, pluginRegistry }); assert.throws(() => service.resolvePluginDependencies({ name: "deploy", pluginType: "deploy", dependPlugins: { "access:access": "*" } }), /插件依赖缺失/); }); it("reports incompatible dependent plugin versions", () => { - const service = new RuntimeDepsService(); + const service = new RuntimeDepsService({}, { accessRegistry, pluginRegistry }); accessRegistry.register("runtimeDepsVersionedAccess", { define: { name: "runtimeDepsVersionedAccess", title: "access", version: "1.4.0", dependPackages: { accessOnly: "^1.0.0" } } as any, - target: async () => ({} as any), + target: async () => ({}) as any, }); try { assert.throws( - () => service.resolvePluginDependencies({ - name: "deploy", pluginType: "deploy", - dependPlugins: { "access:runtimeDepsVersionedAccess": "^2.0.0" }, - }), + () => + service.resolvePluginDependencies({ + name: "deploy", + pluginType: "deploy", + dependPlugins: { "access:runtimeDepsVersionedAccess": "^2.0.0" }, + }), /插件依赖版本冲突/ ); } finally { @@ -221,7 +263,7 @@ describe("RuntimeDepsService", () => { }); it("reports bare dependent plugin names as invalid format", () => { - const service = new RuntimeDepsService(); + const service = new RuntimeDepsService({}, null); assert.throws(() => service.resolvePluginDependencies({ name: "deploy", pluginType: "deploy", dependPlugins: { runtimeDepsBareName: "*" } }), /插件依赖格式错误/); }); @@ -232,30 +274,71 @@ describe("RuntimeDepsService", () => { process.env.NODE_OPTIONS = "--inspect=127.0.0.1:9229 --max-old-space-size=4096"; process.env.VSCODE_INSPECTOR_OPTIONS = '{"inspectorIpc":"test"}'; try { - const service = new RuntimeDepsService({ rootDir }); - service.registryResolver = { async resolve() { return ""; } } as any; + const service = new RuntimeDepsService({ rootDir }, null); + service.registryResolver = { + async resolve() { + return ""; + }, + } as any; service.commandRunner = { async run(command: string, args: string[], options: { env?: NodeJS.ProcessEnv }) { assert.equal(options.env?.NODE_OPTIONS, "--max-old-space-size=4096"); assert.equal(options.env?.VSCODE_INSPECTOR_OPTIONS, undefined); assert.equal(options.env?.CI, "true"); assert.equal(options.env?.pnpm_config_confirm_modules_purge, "false"); - if (args.includes("--version")) { return { stdout: "9.1.0\n", stderr: "", code: 0 }; } + if (args.includes("--version")) { + return { stdout: "9.1.0\n", stderr: "", code: 0 }; + } return { stdout: "", stderr: "", code: 0 }; }, } as any; await service.ensureInstalled({ plugins: [{ name: "a", dependPackages: { foo: "^1.0.0" } }] }); } finally { - if (oldNodeOptions == null) { delete process.env.NODE_OPTIONS; } - else { process.env.NODE_OPTIONS = oldNodeOptions; } - if (oldInspectorOptions == null) { delete process.env.VSCODE_INSPECTOR_OPTIONS; } - else { process.env.VSCODE_INSPECTOR_OPTIONS = oldInspectorOptions; } + if (oldNodeOptions == null) { + delete process.env.NODE_OPTIONS; + } else { + process.env.NODE_OPTIONS = oldNodeOptions; + } + if (oldInspectorOptions == null) { + delete process.env.VSCODE_INSPECTOR_OPTIONS; + } else { + process.env.VSCODE_INSPECTOR_OPTIONS = oldInspectorOptions; + } } }); it("rejects clearing unexpected runtime dependency path", async () => { const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), "certd-runtime-clear-invalid-")); - const service = new RuntimeDepsService({ rootDir }); + const service = new RuntimeDepsService({ rootDir }, null); await assert.rejects(() => service.clearRuntimeDeps(), /动态依赖目录配置异常/); }); }); + +describe("NpmRegistryResolver", () => { + it("chooses the fastest successful registry in auto mode", async () => { + const resolver = new NpmRegistryResolver({ + mode: "auto", + candidates: ["https://slow.example.com", "https://fast.example.com"], + probeTimeoutMs: 100, + cacheTtlMs: 1000, + }); + resolver.probe = async (registryUrl: string) => ({ + registryUrl, + ok: true, + elapsedMs: registryUrl.includes("fast") ? 10 : 50, + }); + const result = await resolver.resolve(); + assert.equal(result, "https://fast.example.com"); + }); + + it("uses fixed registry without probing", async () => { + const resolver = new NpmRegistryResolver({ + mode: "fixed", + fixedUrl: "https://registry.example.com", + probeTimeoutMs: 100, + cacheTtlMs: 1000, + }); + const result = await resolver.resolve(); + assert.equal(result, "https://registry.example.com"); + }); +}); diff --git a/packages/core/pipeline/src/service/runtime.ts b/packages/core/pipeline/src/service/runtime.ts index 1ded20de5..8c7ea7a4c 100644 --- a/packages/core/pipeline/src/service/runtime.ts +++ b/packages/core/pipeline/src/service/runtime.ts @@ -1,3 +1,773 @@ -// 类型已迁至 runtime-deps-service.ts,通过 index.ts 重新导出 -// 此文件保留空壳,避免已有引用报错 -export * from "./runtime-deps-service.js"; +import fs from "fs"; +import path from "path"; +import { spawn } from "child_process"; +import crypto from "crypto"; +import { createRequire } from "module"; +import { pathToFileURL } from "url"; +import { logger as defaultLogger } from "@certd/basic"; +import type { Registry } from "../registry/registry.js"; +export type ILogger = { + info: (message: string) => void; + warn?: (message: string) => void; + error?: (message: string, ...args: any[]) => void; +}; + +export type ImportRuntime = (specifier: string, logger?: ILogger) => Promise; + +export type EnsureRuntimeDepsOptions = { + pluginKeys: string | string[]; + logger?: ILogger; +}; + +export interface IRuntimeDepsService { + ensureRuntimeDependencies(options: EnsureRuntimeDepsOptions): Promise; + importRuntime: ImportRuntime; +} + +export type RuntimeDependencyPluginDefine = { + name: string; + key?: string; + title?: string; + version?: string; + pluginType?: string; + addonType?: string; + dependPlugins?: Record; + dependPackages?: Record; +}; + +type RegisteredDefineLike = RuntimeDependencyPluginDefine & { + key?: string; + pluginType?: string; + addonType?: string; + dependPlugins?: Record; + dependPackages?: Record; +}; + +type DependencyConflict = { + packageName: string; + ranges: Array<{ pluginName: string; range: string }>; +}; + +type CollectDependenciesResult = { + dependencies: Record; + conflicts: DependencyConflict[]; +}; + +type InstallResult = { + registryUrl: string; + packageJsonPath: string; +}; + +type RuntimeImportResolveResult = { + resolved: string; + packageName: string; +}; + +type CommandRunnerResult = { + stdout: string; + stderr: string; + code: number; +}; + +type CommandRunner = { + run(command: string, args: string[], options: { cwd: string; timeoutMs: number; env?: NodeJS.ProcessEnv }): Promise; +}; + +export type NpmRegistryResolverConfig = { + mode?: "auto" | "fixed" | "system"; + fixedUrl?: string; + candidates?: string[]; + probeTimeoutMs?: number; + cacheTtlMs?: number; +}; + +export type RegistryProbeResult = { + registryUrl: string; + ok: boolean; + elapsedMs: number; +}; + +export class NpmRegistryResolver { + config: NpmRegistryResolverConfig; + private cache?: { registryUrl: string; expiresAt: number }; + + constructor(config?: NpmRegistryResolverConfig) { + this.config = config || {}; + } + + async resolve(): Promise { + const config = this.config; + if (config?.mode === "fixed" && config.fixedUrl) { + return config.fixedUrl; + } + if (config?.mode === "system") { + return ""; + } + const cached = this.cache; + if (cached && cached.expiresAt > Date.now()) { + return cached.registryUrl; + } + const candidates = (config?.candidates || []).filter(Boolean); + if (candidates.length === 0) { + return ""; + } + const probes = await Promise.allSettled(candidates.map(registryUrl => this.probe(registryUrl))); + const okList = probes.map(item => (item.status === "fulfilled" ? item.value : null)).filter((item): item is RegistryProbeResult => !!item && item.ok); + if (okList.length > 0) { + okList.sort((a, b) => a.elapsedMs - b.elapsedMs); + const best = okList[0].registryUrl; + this.cache = { registryUrl: best, expiresAt: Date.now() + (config?.cacheTtlMs || 6 * 60 * 60 * 1000) }; + return best; + } + return ""; + } + + async probe(registryUrl: string): Promise { + const timeoutMs = this.config?.probeTimeoutMs || 3000; + const started = Date.now(); + try { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + try { + const res = await fetch(`${registryUrl.replace(/\/$/, "")}/-/ping`, { signal: controller.signal }); + return { registryUrl, ok: res.ok, elapsedMs: Date.now() - started }; + } finally { + clearTimeout(timer); + } + } catch { + return { registryUrl, ok: false, elapsedMs: Date.now() - started }; + } + } +} + +export type RuntimeDepsConfig = { + rootDir?: string; + autoInstall?: boolean; + enabled?: boolean; + installTimeoutMs?: number; + pnpmCommand?: string; + lazyDependencies?: Record; + registry?: NpmRegistryResolverConfig; +}; + +function normalizeRange(range: string) { + return range.trim().replace(/^\^/, "").replace(/^~?/, ""); +} + +function areRangesCompatible(a: string, b: string) { + if (!a || !b) { + return true; + } + if (a === "*" || b === "*") { + return true; + } + const left = normalizeRange(a).split("."); + const right = normalizeRange(b).split("."); + return left[0] === right[0]; +} + +const PROCESS_LOCKS = new Map>(); + +class DefaultCommandRunner implements CommandRunner { + async run(command: string, args: string[], options: { cwd: string; timeoutMs: number; env?: NodeJS.ProcessEnv }): Promise { + return await new Promise(resolve => { + let stdout = ""; + let stderr = ""; + let settled = false; + const child = spawn(command, args, { cwd: options.cwd, env: options.env, windowsHide: true, shell: process.platform === "win32" }); + const timer = setTimeout(() => { + if (settled) { + return; + } + settled = true; + child.kill("SIGTERM"); + resolve({ stdout, stderr: stderr || `command timeout after ${options.timeoutMs}ms`, code: 1 }); + }, options.timeoutMs); + child.stdout?.on("data", chunk => { + stdout += chunk.toString(); + }); + child.stderr?.on("data", chunk => { + stderr += chunk.toString(); + }); + child.on("error", error => { + if (settled) { + return; + } + settled = true; + clearTimeout(timer); + resolve({ stdout, stderr: error.message, code: 1 }); + }); + child.on("close", code => { + if (settled) { + return; + } + settled = true; + clearTimeout(timer); + resolve({ stdout, stderr, code: code || 0 }); + }); + }); + } +} + +export class RuntimeDepsService { + runtimeDepsRootDir: string; + autoInstall: boolean; + enabled: boolean; + installTimeoutMs: number; + pnpmCommand: string; + lazyDependencies: Record; + registryResolver!: NpmRegistryResolver; + commandRunner: CommandRunner = new DefaultCommandRunner(); + pluginLazyDependencies: Record = {}; + private installPromises = new Map>(); + private registriesMap: Record; pluginType: string; addonType?: string }> | null = null; + + constructor(config: RuntimeDepsConfig, registries: any) { + this.runtimeDepsRootDir = config?.rootDir ?? "./data/.runtime-deps"; + this.autoInstall = config?.autoInstall ?? true; + this.enabled = config?.enabled ?? true; + this.installTimeoutMs = config?.installTimeoutMs ?? 120000; + this.pnpmCommand = config?.pnpmCommand ?? ""; + this.lazyDependencies = config?.lazyDependencies ?? {}; + this.registryResolver = new NpmRegistryResolver(config?.registry); + if (registries) { + this.setRegistries(registries); + } + } + + setRegistries(registries: { pluginRegistry?: Registry; accessRegistry?: Registry; notificationRegistry?: Registry; dnsProviderRegistry?: Registry; addonRegistry?: Registry }) { + const map: Record; pluginType: string; addonType?: string }> = {}; + if (registries.pluginRegistry) { + map["plugin"] = { registry: registries.pluginRegistry, pluginType: "plugin" }; + } + if (registries.accessRegistry) { + map["access"] = { registry: registries.accessRegistry, pluginType: "access" }; + } + if (registries.notificationRegistry) { + map["notification"] = { registry: registries.notificationRegistry, pluginType: "notification" }; + } + if (registries.dnsProviderRegistry) { + map["dnsProvider"] = { registry: registries.dnsProviderRegistry, pluginType: "dnsProvider" }; + } + if (registries.addonRegistry) { + map["addon"] = { registry: registries.addonRegistry, pluginType: "addon", addonType: "" }; + } + this.registriesMap = map; + } + + collectDependencies(plugins: RuntimeDependencyPluginDefine[]): CollectDependenciesResult { + const merged: Record = {}; + const seen: Record> = {}; + for (const plugin of plugins) { + const deps = plugin.dependPackages || {}; + for (const [packageName, range] of Object.entries(deps)) { + seen[packageName] ||= []; + seen[packageName].push({ pluginName: plugin.name, range }); + } + } + const conflicts: DependencyConflict[] = []; + for (const [packageName, ranges] of Object.entries(seen)) { + const first = ranges[0]?.range; + if (!first) { + continue; + } + const conflict = ranges.some(item => !areRangesCompatible(first, item.range)); + if (conflict) { + conflicts.push({ packageName, ranges }); + continue; + } + merged[packageName] = first; + } + return { dependencies: merged, conflicts }; + } + + async ensureInstalled(options: { plugins: RuntimeDependencyPluginDefine[]; logger?: ILogger }): Promise { + const { plugins, logger: log } = options; + const { dependencies, conflicts } = this.resolveDependenciesFromPlugins(plugins); + if (conflicts.length > 0) { + const conflict = conflicts[0]; + throw new Error(`动态依赖版本冲突: ${conflict.packageName} => ${conflict.ranges.map(item => `${item.pluginName}:${item.range}`).join(", ")}`); + } + return await this.ensureDependencies({ dependencies, logger: log }); + } + + async ensureDependencies(options: { dependencies: Record; logger?: ILogger }): Promise { + const { dependencies, logger: log } = options; + if (!this.enabled) { + return { registryUrl: "", packageJsonPath: path.join(this.getRuntimeDepsRootDir(), "package.json") }; + } + if (!this.autoInstall) { + return { registryUrl: "", packageJsonPath: path.join(this.getRuntimeDepsRootDir(), "package.json") }; + } + const dependenciesHash = this.createDependenciesHash(dependencies); + let installPromise = this.installPromises.get(dependenciesHash); + if (installPromise) { + const nodeModulesPath = path.join(this.getRuntimeDepsRootDir(), "node_modules"); + if (!fs.existsSync(nodeModulesPath)) { + this.installPromises.delete(dependenciesHash); + installPromise = undefined; + } + } + if (!installPromise) { + installPromise = this.doEnsureInstalled({ dependencies, logger: log }).catch(error => { + this.installPromises.delete(dependenciesHash); + throw error; + }); + this.installPromises.set(dependenciesHash, installPromise); + } + return await installPromise; + } + + resolveDependenciesFromPlugins(plugins: RuntimeDependencyPluginDefine[]): CollectDependenciesResult { + const expandedPlugins = plugins.flatMap(plugin => this.resolvePluginDependencies(plugin)); + return this.collectDependencies(expandedPlugins); + } + + async ensureRuntimeDependencies(options: { pluginKeys: string | string[]; logger?: ILogger }): Promise { + const { pluginKeys, logger: log } = options; + const keys = Array.isArray(pluginKeys) ? pluginKeys : [pluginKeys]; + const pluginDefines = keys.map(pluginKey => this.getDefineByPluginKey(pluginKey)); + if (pluginDefines.every(pluginDefine => !pluginDefine.dependPackages && !pluginDefine.dependPlugins)) { + return { registryUrl: "", packageJsonPath: path.join(this.getRuntimeDepsRootDir(), "package.json") }; + } + const expandedPluginDefines = pluginDefines.flatMap(pluginDefine => this.resolvePluginDependencies(pluginDefine)); + return await this.ensureInstalled({ plugins: expandedPluginDefines, logger: log }); + } + + async importRuntime(specifier: string, logger: ILogger = defaultLogger) { + if (this.isNativeImportSpecifier(specifier)) { + return await import(specifier); + } + const resolved = await this.resolveImportSpecifier(specifier, logger); + return await import(pathToFileURL(resolved).href); + } + + private async resolveImportSpecifier(specifier: string, logger: ILogger = defaultLogger) { + try { + return this.resolveRuntimeSpecifier(specifier).resolved; + } catch (runtimeError: any) { + if (!this.isModuleNotFoundError(runtimeError)) { + throw runtimeError; + } + return await this.resolveMissingRuntimeSpecifier(specifier, runtimeError, logger); + } + } + + private async resolveMissingRuntimeSpecifier(specifier: string, runtimeError: any, logger?: ILogger) { + const packageName = this.parsePackageName(specifier); + const mergedDeps = this.getMergedLazyDependencies(); + const lazyRange = mergedDeps[packageName]; + if (!lazyRange) { + try { + return this.resolveProjectSpecifier(specifier, runtimeError).resolved; + } catch { + throw new Error(`动态依赖未安装且未配置懒加载版本: ${packageName}`); + } + } + try { + await this.ensureLazyDependency(packageName, logger); + return this.resolveRuntimeSpecifier(specifier).resolved; + } catch (lazyError: any) { + return this.resolveProjectSpecifier(specifier, lazyError).resolved; + } + } + + private isNativeImportSpecifier(specifier: string) { + return specifier.startsWith(".") || specifier.startsWith("/") || specifier.startsWith("file:") || specifier.startsWith("node:"); + } + + private resolveRuntimeSpecifier(specifier: string): RuntimeImportResolveResult { + const packageName = this.parsePackageName(specifier); + const packageJsonPath = path.join(this.getRuntimeDepsRootDir(), "package.json"); + const require = createRequire(packageJsonPath); + const resolved = require.resolve(specifier); + return { packageName, resolved }; + } + + private resolveProjectSpecifier(specifier: string, cause?: any): RuntimeImportResolveResult { + try { + const packageName = this.parsePackageName(specifier); + const packageJsonPath = path.resolve("package.json"); + const require = createRequire(packageJsonPath); + const resolved = require.resolve(specifier); + return { packageName, resolved }; + } catch (projectError: any) { + if (cause) { + projectError.cause = cause; + } + throw projectError; + } + } + + private parsePackageName(specifier: string) { + if (!specifier || specifier.trim() !== specifier) { + throw new Error(`动态依赖导入路径无效: ${specifier}`); + } + const parts = specifier.split("/"); + if (specifier.startsWith("@")) { + if (parts.length < 2 || !parts[0] || !parts[1]) { + throw new Error(`动态依赖导入路径无效: ${specifier}`); + } + return `${parts[0]}/${parts[1]}`; + } + if (!parts[0]) { + throw new Error(`动态依赖导入路径无效: ${specifier}`); + } + return parts[0]; + } + + private async ensureLazyDependency(packageName: string, logger?: ILogger) { + const range = this.lazyDependencies?.[packageName]; + if (!range) { + throw new Error(`动态依赖未安装且未配置懒加载版本: ${packageName}`); + } + await this.ensureDependencies({ dependencies: { [packageName]: range }, logger }); + } + + private isModuleNotFoundError(error: any) { + return error?.code === "MODULE_NOT_FOUND" || error?.code === "ERR_MODULE_NOT_FOUND"; + } + + resolvePluginDependencies(current: RuntimeDependencyPluginDefine): RuntimeDependencyPluginDefine[] { + const resolved: RuntimeDependencyPluginDefine[] = []; + const visited = new Set(); + const visit = (item: RuntimeDependencyPluginDefine) => { + const key = this.buildPluginDependencyKey(item); + if (visited.has(key)) { + return; + } + visited.add(key); + resolved.push(item); + for (const [dependencyName, expectedRange] of Object.entries(item.dependPlugins || {})) { + const dependency = this.getDefineByPluginKey(dependencyName, item); + if (!isPluginVersionCompatible(dependency, expectedRange)) { + throw new Error(`插件依赖版本冲突: ${item.name} 依赖 ${dependencyName}@${expectedRange},当前版本为 ${dependency.version || "未声明"}`); + } + visit(dependency); + } + }; + visit(current); + return resolved; + } + + private buildPluginDependencyKey(plugin: RuntimeDependencyPluginDefine) { + if (plugin.pluginType === "addon" && plugin.addonType) { + return `addon:${plugin.addonType}:${plugin.name}`; + } + const pluginType = plugin.pluginType === "deploy" ? "plugin" : plugin.pluginType || "unknown"; + return `${pluginType}:${plugin.name}`; + } + + private getDefineByPluginKey(pluginKey: string, owner?: RuntimeDependencyPluginDefine): RuntimeDependencyPluginDefine { + const parts = pluginKey.split(":"); + let pluginType: string, name: string, subtype: string | undefined; + if (parts.length === 2) { + [pluginType, name] = parts; + } else if (parts.length === 3) { + [pluginType, subtype, name] = parts; + } else { + const ownerName = owner?.name || pluginKey; + throw new Error(`插件依赖格式错误: ${ownerName} 依赖 ${pluginKey}`); + } + if (!this.registriesMap) { + throw new Error("注册表未设置,请先调用 setRegistries"); + } + const target = this.registriesMap[pluginType]; + if (!target) { + const ownerName = owner?.name || pluginKey; + throw new Error(`插件依赖格式错误: ${ownerName} 依赖 ${pluginKey},未知插件类型 ${pluginType}`); + } + // addon 类型的 key 需要包含 subtype + const registryKey = pluginType === "addon" && subtype ? `${subtype}:${name}` : name; + const define = target.registry.getDefine(registryKey) as RegisteredDefineLike; + if (!define) { + throw new Error(`插件依赖缺失: ${owner?.name || pluginKey} 依赖 ${pluginKey},但该插件未注册或已禁用`); + } + return { ...define, key: pluginKey, pluginType: target.pluginType, addonType: target.addonType }; + } + + private async doEnsureInstalled(options: { dependencies: Record; logger?: ILogger }): Promise { + let { dependencies } = options; + const log = options.logger || defaultLogger; + return await this.withInstallLock(async () => { + const rootDir = this.getRuntimeDepsRootDir(); + const packageJsonPath = path.join(rootDir, "package.json"); + const lockPath = path.join(rootDir, "pnpm-lock.yaml"); + log.info(`第三方依赖安装: ${JSON.stringify(dependencies)}`); + dependencies = this.mergeInstalledDependencies(this.readManifestDependencies(packageJsonPath), dependencies); + const dependenciesHash = this.createDependenciesHash(dependencies); + const statePath = path.join(rootDir, "install-state.json"); + const currentState = this.readInstallState(statePath); + if (currentState?.dependenciesHash === dependenciesHash && fs.existsSync(path.join(rootDir, "node_modules"))) { + log.info("第三方依赖已安装"); + return { registryUrl: currentState.registryUrl || "", packageJsonPath }; + } + const manifest = { name: "certd-runtime-deps", private: true, type: "module", dependencies }; + fs.writeFileSync(packageJsonPath, JSON.stringify(manifest, null, 2), "utf8"); + const registryUrl = await this.registryResolver.resolve(); + const env = this.buildChildEnv(registryUrl); + const command = this.getPnpmCommand(); + const pnpmVersion = await this.getPnpmVersion(command, env); + const args = ["install", "--prod", "--ignore-scripts", "--ignore-workspace", "--no-frozen-lockfile", "--reporter=append-only"]; + if (registryUrl) { + args.push(`--registry=${registryUrl}`); + } + log.info(`开始安装第三方依赖: ${Object.keys(dependencies).join(", ")}`); + const result = await this.commandRunner.run(command, args, { cwd: rootDir, timeoutMs: this.installTimeoutMs, env }); + if (result.code !== 0) { + const message = result.stderr || result.stdout || "unknown error"; + this.writeInstallState(statePath, { + ...currentState, + installedAt: currentState?.installedAt, + failedAt: new Date().toISOString(), + registryUrl, + dependenciesHash, + nodeVersion: process.version, + pnpmVersion, + lockFileExists: fs.existsSync(lockPath), + lastError: message, + }); + throw new Error(`动态依赖安装失败: ${message}`); + } + this.writeInstallState(statePath, { installedAt: new Date().toISOString(), registryUrl, dependenciesHash, nodeVersion: process.version, pnpmVersion, lockFileExists: fs.existsSync(lockPath) }); + log.info("第三方依赖安装完成"); + return { registryUrl, packageJsonPath }; + }); + } + + private async withInstallLock(run: () => Promise): Promise { + const rootDir = this.getRuntimeDepsRootDir(); + fs.mkdirSync(rootDir, { recursive: true }); + const lockFile = path.join(rootDir, ".install.lock"); + const previous = PROCESS_LOCKS.get(lockFile); + if (previous) { + await previous.catch(() => undefined); + } + let releaseProcessLock!: () => void; + const current = new Promise(resolve => { + releaseProcessLock = resolve; + }); + PROCESS_LOCKS.set(lockFile, current); + let fd: number | undefined; + try { + fd = await this.acquireFileLock(lockFile); + return await run(); + } finally { + if (fd != null) { + fs.closeSync(fd); + try { + fs.rmSync(lockFile, { force: true }); + } catch { + try { + fs.rmSync(lockFile, { force: true }); + } catch {} + } + } + releaseProcessLock(); + if (PROCESS_LOCKS.get(lockFile) === current) { + PROCESS_LOCKS.delete(lockFile); + } + } + } + + private async acquireFileLock(lockFile: string) { + const deadline = Date.now() + this.installTimeoutMs; + while (true) { + try { + const fd = fs.openSync(lockFile, "wx"); + fs.writeFileSync(fd, JSON.stringify({ pid: process.pid, createdAt: new Date().toISOString() }), "utf8"); + return fd; + } catch (error: any) { + if (error?.code !== "EEXIST") { + throw error; + } + if (Date.now() > deadline) { + throw new Error(`动态依赖安装锁等待超时: ${lockFile}`); + } + await this.waitForExternalLock(lockFile, deadline); + } + } + } + + private async waitForExternalLock(lockFile: string, deadline: number) { + while (fs.existsSync(lockFile)) { + if (Date.now() > deadline) { + throw new Error(`动态依赖安装锁等待超时: ${lockFile}`); + } + await new Promise(resolve => setTimeout(resolve, 300)); + } + } + + async clearRuntimeDeps() { + const rootDir = this.getRuntimeDepsRootDir(); + const normalizedRootDir = path.normalize(rootDir); + if (!normalizedRootDir.endsWith(path.normalize(".runtime-deps"))) { + throw new Error(`动态依赖目录配置异常,拒绝清理: ${rootDir}`); + } + await this.withInstallLock(async () => { + if (fs.existsSync(rootDir)) { + const entries = fs.readdirSync(rootDir); + for (const entry of entries) { + if (entry === ".install.lock") { + continue; + } + fs.rmSync(path.join(rootDir, entry), { recursive: true, force: true }); + } + } + this.installPromises.clear(); + return undefined; + }); + } + + getMergedLazyDependencies(): Record { + return { ...this.lazyDependencies, ...this.pluginLazyDependencies }; + } + + collectPluginDeps(logger?: ILogger) { + if (!this.registriesMap) { + return; + } + const deps: Record = {}; + for (const { registry } of Object.values(this.registriesMap)) { + const defineList = registry.getDefineList(); + for (const define of defineList) { + const dependPackages = (define as any).dependPackages as Record | undefined; + if (!dependPackages) { + continue; + } + for (const [pkgName, range] of Object.entries(dependPackages)) { + const existing = deps[pkgName]; + if (existing && !areRangesCompatible(existing, range)) { + (logger || defaultLogger).warn?.(`懒加载依赖版本冲突: ${pkgName} => ${existing} vs ${range},保留已有版本`); + continue; + } + deps[pkgName] = range; + } + } + } + this.pluginLazyDependencies = deps; + (logger || defaultLogger).info(`从插件注册表收集到 ${Object.keys(deps).length} 个懒加载依赖`); + } + + refreshPluginDeps(logger?: ILogger) { + this.collectPluginDeps(logger); + } + + private readInstallState(statePath: string): any { + if (!fs.existsSync(statePath)) { + return null; + } + try { + return JSON.parse(fs.readFileSync(statePath, "utf8")); + } catch { + return null; + } + } + + private writeInstallState(statePath: string, state: any) { + fs.writeFileSync(statePath, JSON.stringify(state, null, 2), "utf8"); + } + + private readManifestDependencies(packageJsonPath: string): Record { + if (!fs.existsSync(packageJsonPath)) { + return {}; + } + try { + const manifest = JSON.parse(fs.readFileSync(packageJsonPath, "utf8")); + return manifest.dependencies || {}; + } catch { + return {}; + } + } + + private mergeInstalledDependencies(installed: Record, requested: Record) { + const dependencies = { ...installed }; + for (const [packageName, range] of Object.entries(requested)) { + const installedRange = dependencies[packageName]; + if (installedRange && !areRangesCompatible(installedRange, range)) { + throw new Error(`动态依赖版本冲突: ${packageName} => installed:${installedRange}, requested:${range}`); + } + dependencies[packageName] = installedRange || range; + } + return dependencies; + } + + private async getPnpmVersion(command: string, env: NodeJS.ProcessEnv) { + const result = await this.commandRunner.run(command, ["--version"], { cwd: this.getRuntimeDepsRootDir(), timeoutMs: Math.min(this.installTimeoutMs, 10000), env }); + if (result.code !== 0) { + return ""; + } + return (result.stdout || result.stderr || "").trim(); + } + + private getPnpmCommand() { + return this.pnpmCommand || "pnpm"; + } + + private buildChildEnv(registryUrl: string) { + const env = { ...process.env }; + for (const key of ["NODE_OPTIONS", "VSCODE_INSPECTOR_OPTIONS", "NODE_INSPECTOR_PORT", "NODE_DEBUG"]) { + if (!env[key]) { + continue; + } + if (key === "NODE_OPTIONS") { + env[key] = this.stripDebugNodeOptions(env[key] as string); + } else { + delete env[key]; + } + } + if (registryUrl) { + env.npm_config_registry = registryUrl; + env.pnpm_config_registry = registryUrl; + } + env.CI = env.CI || "true"; + env.npm_config_confirm_modules_purge = "false"; + env.pnpm_config_confirm_modules_purge = "false"; + return env; + } + + private stripDebugNodeOptions(value: string) { + return value + .split(/\s+/) + .filter(Boolean) + .filter(item => !/^--inspect(-brk|-port)?(=|$)/.test(item)) + .filter(item => !/^--debug(=|$)/.test(item)) + .join(" "); + } + + getRuntimeDepsRootDir() { + return path.resolve(this.runtimeDepsRootDir); + } + + private createDependenciesHash(dependencies: Record) { + return crypto.createHash("sha256").update(JSON.stringify(dependencies)).digest("hex"); + } +} + +function isPluginVersionCompatible(plugin: RuntimeDependencyPluginDefine, expectedRange: string) { + if (!expectedRange || expectedRange === "*") { + return true; + } + if (!plugin.version) { + return false; + } + return areRangesCompatible(expectedRange, plugin.version); +} + +let runtimeDepsServiceInstance: RuntimeDepsService | null = null; + +export function initRuntimeDepsService(config: RuntimeDepsConfig, registries: any): RuntimeDepsService { + runtimeDepsServiceInstance = new RuntimeDepsService(config, registries); + return runtimeDepsServiceInstance; +} + +export function getRuntimeDepsService(): RuntimeDepsService { + if (!runtimeDepsServiceInstance) { + throw new Error("RuntimeDepsService 未初始化"); + } + return runtimeDepsServiceInstance!; +} + +export async function importRuntime(specifier: string, logger: ILogger = defaultLogger): Promise { + return getRuntimeDepsService().importRuntime(specifier, logger); +}