mirror of
https://github.com/certd/certd.git
synced 2026-10-13 15:49:25 +08:00
refactor: 重构运行时依赖服务,简化逻辑并修复部分配置
1. 移除废弃的getRuntimeDependencyPluginDefines方法和相关插件依赖解析逻辑 2. 简化RuntimeDepsService的锁机制、依赖收集和安装流程 3. 更新测试用例适配重构后的API 4. 调整开发环境数据库配置和运行时依赖超时时间 5. 优化注释和代码结构
This commit is contained in:
@@ -2,38 +2,13 @@ import assert from "assert";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import os from "os";
|
||||
import { RuntimeDepsService, NpmRegistryResolver, type RuntimeDependencyPluginDefine } from "./runtime.js";
|
||||
import { accessRegistry } from "../access/registry.js";
|
||||
import { pluginRegistry } from "../plugin/registry.js";
|
||||
import { RuntimeDepsService, NpmRegistryResolver } from "./runtime.js";
|
||||
|
||||
describe("RuntimeDepsService", () => {
|
||||
it("detects conflicting dependency ranges across plugins", () => {
|
||||
const service = new RuntimeDepsService({}, null);
|
||||
const merged = service.collectDependencies([
|
||||
{ name: "a", dependPackages: { foo: "^1.0.0" } },
|
||||
{ name: "b", dependPackages: { foo: "^1.2.0" } },
|
||||
]);
|
||||
assert.deepEqual(merged.dependencies, { foo: "^1.0.0" });
|
||||
assert.equal(merged.conflicts.length, 0);
|
||||
});
|
||||
|
||||
it("reports incompatible dependency ranges", () => {
|
||||
const service = new RuntimeDepsService({}, null);
|
||||
const merged = service.collectDependencies([
|
||||
{ name: "a", dependPackages: { foo: "^1.0.0" } },
|
||||
{ name: "b", dependPackages: { foo: "^2.0.0" } },
|
||||
]);
|
||||
assert.equal(merged.conflicts.length, 1);
|
||||
assert.equal(merged.conflicts[0].packageName, "foo");
|
||||
});
|
||||
|
||||
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 }, null);
|
||||
service.registryResolver = {
|
||||
async resolve() {
|
||||
return "https://registry.npmmirror.com";
|
||||
},
|
||||
async resolveOrdered() {
|
||||
return ["https://registry.npmmirror.com"];
|
||||
},
|
||||
@@ -41,17 +16,13 @@ describe("RuntimeDepsService", () => {
|
||||
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 };
|
||||
}
|
||||
assert.equal(args[0], "install");
|
||||
assert.ok(args.includes("--ignore-workspace"));
|
||||
assert.ok(args.includes("--no-frozen-lockfile"));
|
||||
return { stdout: "", stderr: "", code: 0 };
|
||||
},
|
||||
} as any;
|
||||
const plugins: RuntimeDependencyPluginDefine[] = [{ name: "a", dependPackages: { foo: "^1.0.0" } }];
|
||||
const result = await service.ensureInstalled({ plugins });
|
||||
const result = await service.ensureDependencies({ dependencies: { foo: "^1.0.0" } });
|
||||
assert.equal(result.registryUrl, "https://registry.npmmirror.com");
|
||||
assert.ok(fs.existsSync(path.join(rootDir, "package.json")));
|
||||
});
|
||||
@@ -60,9 +31,6 @@ describe("RuntimeDepsService", () => {
|
||||
const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), "certd-runtime-deps-direct-"));
|
||||
const service = new RuntimeDepsService({ rootDir }, null);
|
||||
service.registryResolver = {
|
||||
async resolve() {
|
||||
return "";
|
||||
},
|
||||
async resolveOrdered() {
|
||||
return [""];
|
||||
},
|
||||
@@ -70,9 +38,6 @@ describe("RuntimeDepsService", () => {
|
||||
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 };
|
||||
}
|
||||
fs.mkdirSync(path.join(rootDir, "node_modules"), { recursive: true });
|
||||
return { stdout: "", stderr: "", code: 0 };
|
||||
},
|
||||
@@ -82,63 +47,6 @@ describe("RuntimeDepsService", () => {
|
||||
assert.deepEqual(manifest.dependencies, { directPkg: "^1.0.0" });
|
||||
});
|
||||
|
||||
it("recovers from a lock that exceeds the maximum duration", async () => {
|
||||
const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), "certd-runtime-deps-stale-lock-"));
|
||||
fs.writeFileSync(path.join(rootDir, ".install.lock"), JSON.stringify({ processInstanceId: "previous-instance", createdAt: "2020-01-01T00:00:00.000Z" }), "utf8");
|
||||
const service = new RuntimeDepsService({ rootDir, installTimeoutMs: 50 }, null);
|
||||
service.registryResolver = {
|
||||
async resolve() {
|
||||
return "";
|
||||
},
|
||||
async resolveOrdered() {
|
||||
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 };
|
||||
}
|
||||
fs.mkdirSync(path.join(rootDir, "node_modules"), { recursive: true });
|
||||
return { stdout: "", stderr: "", code: 0 };
|
||||
},
|
||||
} as any;
|
||||
|
||||
await service.ensureDependencies({ dependencies: { directPkg: "^1.0.0" } });
|
||||
assert.equal(fs.existsSync(path.join(rootDir, ".install.lock")), false);
|
||||
});
|
||||
|
||||
it("recovers from an expired malformed lock", async () => {
|
||||
const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), "certd-runtime-deps-invalid-lock-"));
|
||||
const lockPath = path.join(rootDir, ".install.lock");
|
||||
fs.writeFileSync(lockPath, "{", "utf8");
|
||||
const expiredAt = new Date(Date.now() - 6000);
|
||||
fs.utimesSync(lockPath, expiredAt, expiredAt);
|
||||
const service = new RuntimeDepsService({ rootDir, installTimeoutMs: 50 }, null);
|
||||
service.registryResolver = {
|
||||
async resolve() {
|
||||
return "";
|
||||
},
|
||||
async resolveOrdered() {
|
||||
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 };
|
||||
}
|
||||
fs.mkdirSync(path.join(rootDir, "node_modules"), { recursive: true });
|
||||
return { stdout: "", stderr: "", code: 0 };
|
||||
},
|
||||
} as any;
|
||||
|
||||
await service.ensureDependencies({ dependencies: { directPkg: "^1.0.0" } });
|
||||
assert.equal(fs.existsSync(lockPath), false);
|
||||
});
|
||||
|
||||
it("imports from runtime node_modules without installing", async () => {
|
||||
const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), "certd-runtime-deps-import-"));
|
||||
const packageDir = path.join(rootDir, "node_modules", "runtime-only");
|
||||
@@ -160,9 +68,6 @@ describe("RuntimeDepsService", () => {
|
||||
const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), "certd-runtime-deps-lazy-"));
|
||||
const service = new RuntimeDepsService({ rootDir, lazyDependencies: { "lazy-pkg": "^1.2.3" } }, null);
|
||||
service.registryResolver = {
|
||||
async resolve() {
|
||||
return "";
|
||||
},
|
||||
async resolveOrdered() {
|
||||
return [""];
|
||||
},
|
||||
@@ -170,9 +75,6 @@ describe("RuntimeDepsService", () => {
|
||||
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 };
|
||||
}
|
||||
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");
|
||||
@@ -186,13 +88,28 @@ describe("RuntimeDepsService", () => {
|
||||
assert.equal(mod.value, 7);
|
||||
});
|
||||
|
||||
it("reports lazy dependency installation failure without falling back to project dependencies", async () => {
|
||||
const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), "certd-runtime-deps-lazy-failed-"));
|
||||
const service = new RuntimeDepsService({ rootDir, lazyDependencies: { "failed-pkg": "^1.2.3" } }, null);
|
||||
service.registryResolver = {
|
||||
async resolveOrdered() {
|
||||
return [""];
|
||||
},
|
||||
} as any;
|
||||
service.commandRunner = {
|
||||
async run(command: string, args: string[]) {
|
||||
assert.equal(command, "pnpm");
|
||||
return { stdout: "", stderr: "package download failed", code: 1 };
|
||||
},
|
||||
} as any;
|
||||
|
||||
await assert.rejects(() => service.importRuntime("failed-pkg/sub/entry.js"), /动态依赖安装失败: failed-pkg: 动态依赖安装失败: package download failed/);
|
||||
});
|
||||
|
||||
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" } }, null);
|
||||
service.registryResolver = {
|
||||
async resolve() {
|
||||
return "";
|
||||
},
|
||||
async resolveOrdered() {
|
||||
return [""];
|
||||
},
|
||||
@@ -200,9 +117,6 @@ describe("RuntimeDepsService", () => {
|
||||
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 };
|
||||
}
|
||||
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");
|
||||
@@ -222,20 +136,87 @@ describe("RuntimeDepsService", () => {
|
||||
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 () => {
|
||||
it("rejects lazy dependency installation when disabled", async () => {
|
||||
const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), "certd-runtime-deps-disabled-"));
|
||||
const service = new RuntimeDepsService({ rootDir, enabled: false, lazyDependencies: { "lazy-pkg": "^1.0.0" } }, null);
|
||||
service.commandRunner = {
|
||||
async run() {
|
||||
throw new Error("disabled runtime dependencies should not install packages");
|
||||
},
|
||||
} as any;
|
||||
|
||||
await assert.rejects(() => service.importRuntime("lazy-pkg/index.js"), /动态安装依赖未开启/);
|
||||
});
|
||||
|
||||
it("prefers project dependencies over lazy dependency installation", async () => {
|
||||
const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), "certd-runtime-deps-project-fallback-"));
|
||||
const service = new RuntimeDepsService({ rootDir, lazyDependencies: {} }, null);
|
||||
const service = new RuntimeDepsService({ rootDir, lazyDependencies: { dayjs: "^1.11.0" } }, null);
|
||||
service.commandRunner = {
|
||||
async run() {
|
||||
throw new Error("project dependency should not trigger installation");
|
||||
},
|
||||
} as any;
|
||||
const mod = await service.importRuntime("dayjs");
|
||||
assert.equal(typeof mod.default, "function");
|
||||
});
|
||||
|
||||
it("keeps previously installed dependencies when installing a later plugin", async () => {
|
||||
it("rejects a second installation while the first installation is running", async () => {
|
||||
const rootDir = path.join(fs.mkdtempSync(path.join(os.tmpdir(), "certd-runtime-deps-concurrent-")), ".runtime-deps");
|
||||
const service = new RuntimeDepsService({ rootDir }, null);
|
||||
service.registryResolver = {
|
||||
async resolveOrdered() {
|
||||
return [""];
|
||||
},
|
||||
} as any;
|
||||
|
||||
let finishInstall: () => void;
|
||||
const installFinished = new Promise<void>(resolve => {
|
||||
finishInstall = resolve;
|
||||
});
|
||||
service.commandRunner = {
|
||||
async run() {
|
||||
await installFinished;
|
||||
return { stdout: "", stderr: "", code: 0 };
|
||||
},
|
||||
} as any;
|
||||
|
||||
const firstInstall = service.ensureDependencies({ dependencies: { first: "^1.0.0" } });
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
await assert.rejects(() => service.ensureDependencies({ dependencies: { second: "^1.0.0" } }), /动态安装依赖正在执行中/);
|
||||
await assert.rejects(() => service.clearRuntimeDeps(), /仍有依赖正在安装/);
|
||||
finishInstall!();
|
||||
await firstInstall;
|
||||
});
|
||||
|
||||
it("releases the installation lock after an installation failure", async () => {
|
||||
const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), "certd-runtime-deps-retry-"));
|
||||
const service = new RuntimeDepsService({ rootDir }, null);
|
||||
service.registryResolver = {
|
||||
async resolveOrdered() {
|
||||
return [""];
|
||||
},
|
||||
} as any;
|
||||
|
||||
let installCount = 0;
|
||||
service.commandRunner = {
|
||||
async run() {
|
||||
installCount += 1;
|
||||
if (installCount === 1) {
|
||||
return { stdout: "", stderr: "installation timeout", code: 1 };
|
||||
}
|
||||
return { stdout: "", stderr: "", code: 0 };
|
||||
},
|
||||
} as any;
|
||||
|
||||
await assert.rejects(() => service.ensureDependencies({ dependencies: { first: "^1.0.0" } }), /动态依赖安装失败/);
|
||||
await service.ensureDependencies({ dependencies: { second: "^1.0.0" } });
|
||||
assert.equal(installCount, 2);
|
||||
});
|
||||
|
||||
it("keeps previously installed dependencies when adding a later package", async () => {
|
||||
const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), "certd-runtime-deps-merge-"));
|
||||
const service = new RuntimeDepsService({ rootDir }, null);
|
||||
service.registryResolver = {
|
||||
async resolve() {
|
||||
return "";
|
||||
},
|
||||
async resolveOrdered() {
|
||||
return [""];
|
||||
},
|
||||
@@ -250,98 +231,12 @@ describe("RuntimeDepsService", () => {
|
||||
return { stdout: "", stderr: "", code: 0 };
|
||||
},
|
||||
} as any;
|
||||
await service.ensureInstalled({ plugins: [{ name: "a", pluginType: "deploy", dependPackages: { foo: "^1.0.0" } }] });
|
||||
await service.ensureInstalled({ plugins: [{ name: "b", pluginType: "deploy", dependPackages: { bar: "^2.0.0" } }] });
|
||||
await service.ensureDependencies({ dependencies: { foo: "^1.0.0" } });
|
||||
await service.ensureDependencies({ dependencies: { bar: "^2.0.0" } });
|
||||
const manifest = JSON.parse(fs.readFileSync(path.join(rootDir, "package.json"), "utf8"));
|
||||
assert.deepEqual(manifest.dependencies, { foo: "^1.0.0", bar: "^2.0.0" });
|
||||
});
|
||||
|
||||
it("includes npm dependencies from dependent plugins", async () => {
|
||||
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,
|
||||
});
|
||||
try {
|
||||
const resolved = service.resolvePluginDependencies({
|
||||
name: "deploy",
|
||||
pluginType: "deploy",
|
||||
dependPlugins: { "access:runtimeDepsAccess": "*" },
|
||||
dependPackages: { deployOnly: "^1.0.0" },
|
||||
});
|
||||
const merged = service.collectDependencies(resolved);
|
||||
assert.deepEqual(merged.dependencies, { deployOnly: "^1.0.0", accessOnly: "^1.0.0" });
|
||||
} finally {
|
||||
accessRegistry.unRegister("runtimeDepsAccess");
|
||||
}
|
||||
});
|
||||
|
||||
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 }, { pluginRegistry, accessRegistry });
|
||||
service.registryResolver = {
|
||||
async resolve() {
|
||||
return "";
|
||||
},
|
||||
async resolveOrdered() {
|
||||
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 };
|
||||
}
|
||||
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,
|
||||
});
|
||||
try {
|
||||
service.setRegistries({ pluginRegistry, accessRegistry });
|
||||
await service.ensureRuntimeDependencies({ pluginKeys: "plugin:runtimeDepsKey" });
|
||||
const manifest = JSON.parse(fs.readFileSync(path.join(rootDir, "package.json"), "utf8"));
|
||||
assert.deepEqual(manifest.dependencies, { keyed: "^1.0.0" });
|
||||
} finally {
|
||||
pluginRegistry.unRegister("runtimeDepsKey");
|
||||
}
|
||||
});
|
||||
|
||||
it("reports missing dependent plugins", () => {
|
||||
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({}, { 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,
|
||||
});
|
||||
try {
|
||||
assert.throws(
|
||||
() =>
|
||||
service.resolvePluginDependencies({
|
||||
name: "deploy",
|
||||
pluginType: "deploy",
|
||||
dependPlugins: { "access:runtimeDepsVersionedAccess": "^2.0.0" },
|
||||
}),
|
||||
/插件依赖版本冲突/
|
||||
);
|
||||
} finally {
|
||||
accessRegistry.unRegister("runtimeDepsVersionedAccess");
|
||||
}
|
||||
});
|
||||
|
||||
it("reports bare dependent plugin names as invalid format", () => {
|
||||
const service = new RuntimeDepsService({}, null);
|
||||
assert.throws(() => service.resolvePluginDependencies({ name: "deploy", pluginType: "deploy", dependPlugins: { runtimeDepsBareName: "*" } }), /插件依赖格式错误/);
|
||||
});
|
||||
|
||||
it("does not pass node debugger options to pnpm child process", async () => {
|
||||
const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), "certd-runtime-deps-env-"));
|
||||
const oldNodeOptions = process.env.NODE_OPTIONS;
|
||||
@@ -351,9 +246,6 @@ describe("RuntimeDepsService", () => {
|
||||
try {
|
||||
const service = new RuntimeDepsService({ rootDir }, null);
|
||||
service.registryResolver = {
|
||||
async resolve() {
|
||||
return "";
|
||||
},
|
||||
async resolveOrdered() {
|
||||
return [""];
|
||||
},
|
||||
@@ -364,13 +256,10 @@ describe("RuntimeDepsService", () => {
|
||||
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 };
|
||||
}
|
||||
return { stdout: "", stderr: "", code: 0 };
|
||||
},
|
||||
} as any;
|
||||
await service.ensureInstalled({ plugins: [{ name: "a", dependPackages: { foo: "^1.0.0" } }] });
|
||||
await service.ensureDependencies({ dependencies: { foo: "^1.0.0" } });
|
||||
} finally {
|
||||
if (oldNodeOptions == null) {
|
||||
delete process.env.NODE_OPTIONS;
|
||||
@@ -393,22 +282,6 @@ describe("RuntimeDepsService", () => {
|
||||
});
|
||||
|
||||
describe("NpmRegistryResolver", () => {
|
||||
it("returns the fastest successful registry via resolve()", 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",
|
||||
@@ -416,8 +289,8 @@ describe("NpmRegistryResolver", () => {
|
||||
probeTimeoutMs: 100,
|
||||
cacheTtlMs: 1000,
|
||||
});
|
||||
const result = await resolver.resolve();
|
||||
assert.equal(result, "https://registry.example.com");
|
||||
const result = await resolver.resolveOrdered();
|
||||
assert.deepEqual(result, ["https://registry.example.com"]);
|
||||
});
|
||||
it("returns ordered list via resolveOrdered (fastest first)", async () => {
|
||||
const resolver = new NpmRegistryResolver({
|
||||
@@ -454,8 +327,6 @@ describe("NpmRegistryResolver", () => {
|
||||
const resolver = new NpmRegistryResolver({ mode: "auto", candidates: [] });
|
||||
const result = await resolver.resolveOrdered();
|
||||
assert.deepEqual(result, []);
|
||||
const single = await resolver.resolve();
|
||||
assert.equal(single, "");
|
||||
});
|
||||
it("re-validates cached URL on resolveOrdered call", async () => {
|
||||
let probeCount = 0;
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
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 { spawn } from "child_process";
|
||||
import fs from "fs";
|
||||
import { createRequire } from "module";
|
||||
import path from "path";
|
||||
import { pathToFileURL } from "url";
|
||||
import type { Registry } from "../registry/registry.js";
|
||||
export type ILogger = {
|
||||
info: (message: string) => void;
|
||||
@@ -12,47 +11,6 @@ export type ILogger = {
|
||||
error?: (message: string, ...args: any[]) => void;
|
||||
};
|
||||
|
||||
export type ImportRuntime = (specifier: string, logger?: ILogger) => Promise<any>;
|
||||
|
||||
export type EnsureRuntimeDepsOptions = {
|
||||
pluginKeys: string | string[];
|
||||
logger?: ILogger;
|
||||
};
|
||||
|
||||
export interface IRuntimeDepsService {
|
||||
ensureRuntimeDependencies(options: EnsureRuntimeDepsOptions): Promise<any>;
|
||||
importRuntime: ImportRuntime;
|
||||
}
|
||||
|
||||
export type RuntimeDependencyPluginDefine = {
|
||||
name: string;
|
||||
key?: string;
|
||||
title?: string;
|
||||
version?: string;
|
||||
pluginType?: string;
|
||||
addonType?: string;
|
||||
dependPlugins?: Record<string, string>;
|
||||
dependPackages?: Record<string, string>;
|
||||
};
|
||||
|
||||
type RegisteredDefineLike = RuntimeDependencyPluginDefine & {
|
||||
key?: string;
|
||||
pluginType?: string;
|
||||
addonType?: string;
|
||||
dependPlugins?: Record<string, string>;
|
||||
dependPackages?: Record<string, string>;
|
||||
};
|
||||
|
||||
type DependencyConflict = {
|
||||
packageName: string;
|
||||
ranges: Array<{ pluginName: string; range: string }>;
|
||||
};
|
||||
|
||||
type CollectDependenciesResult = {
|
||||
dependencies: Record<string, string>;
|
||||
conflicts: DependencyConflict[];
|
||||
};
|
||||
|
||||
type InstallResult = {
|
||||
registryUrl: string;
|
||||
packageJsonPath: string;
|
||||
@@ -73,15 +31,6 @@ type CommandRunner = {
|
||||
run(command: string, args: string[], options: { cwd: string; timeoutMs: number; env?: NodeJS.ProcessEnv }): Promise<CommandRunnerResult>;
|
||||
};
|
||||
|
||||
type InstallLockInfo = {
|
||||
processInstanceId?: string;
|
||||
createdAt?: string;
|
||||
};
|
||||
|
||||
type AcquiredInstallLock = {
|
||||
fd: number;
|
||||
};
|
||||
|
||||
export type NpmRegistryResolverConfig = {
|
||||
mode?: "auto" | "fixed" | "system";
|
||||
fixedUrl?: string;
|
||||
@@ -104,34 +53,6 @@ export class NpmRegistryResolver {
|
||||
this.config = config || {};
|
||||
}
|
||||
|
||||
async resolve(): Promise<string> {
|
||||
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()) {
|
||||
const fastUrl = cached.orderedUrls[0];
|
||||
if (fastUrl) {
|
||||
const probeResult = await this.probe(fastUrl);
|
||||
if (probeResult.ok) {
|
||||
return cached.orderedUrls[0] || "";
|
||||
}
|
||||
}
|
||||
this.cache = undefined;
|
||||
}
|
||||
const candidates = (config?.candidates || []).filter(Boolean);
|
||||
if (candidates.length === 0) {
|
||||
return "";
|
||||
}
|
||||
const orderedUrls = await this.internalProbeAll(candidates);
|
||||
this.cache = { orderedUrls, expiresAt: Date.now() + (config?.cacheTtlMs ?? 300_000) };
|
||||
return orderedUrls[0] || "";
|
||||
}
|
||||
|
||||
async resolveOrdered(): Promise<string[]> {
|
||||
const config = this.config;
|
||||
if (config?.mode === "fixed" && config.fixedUrl) {
|
||||
@@ -197,7 +118,6 @@ export class NpmRegistryResolver {
|
||||
|
||||
export type RuntimeDepsConfig = {
|
||||
rootDir?: string;
|
||||
autoInstall?: boolean;
|
||||
enabled?: boolean;
|
||||
installTimeoutMs?: number;
|
||||
pnpmCommand?: string;
|
||||
@@ -221,10 +141,7 @@ function areRangesCompatible(a: string, b: string) {
|
||||
return left[0] === right[0];
|
||||
}
|
||||
|
||||
const PROCESS_LOCKS = new Map<string, Promise<unknown>>();
|
||||
const PROCESS_INSTANCE_ID = crypto.randomUUID();
|
||||
const INVALID_INSTALL_LOCK_GRACE_MS = 5000;
|
||||
const MAX_INSTALL_LOCK_AGE_MS = 20 * 60 * 1000;
|
||||
let INSTALL_LOCKER: any = null;
|
||||
|
||||
class DefaultCommandRunner implements CommandRunner {
|
||||
async run(command: string, args: string[], options: { cwd: string; timeoutMs: number; env?: NodeJS.ProcessEnv }): Promise<CommandRunnerResult> {
|
||||
@@ -269,7 +186,6 @@ class DefaultCommandRunner implements CommandRunner {
|
||||
|
||||
export class RuntimeDepsService {
|
||||
runtimeDepsRootDir: string;
|
||||
autoInstall: boolean;
|
||||
enabled: boolean;
|
||||
installTimeoutMs: number;
|
||||
pnpmCommand: string;
|
||||
@@ -277,14 +193,12 @@ export class RuntimeDepsService {
|
||||
registryResolver!: NpmRegistryResolver;
|
||||
commandRunner: CommandRunner = new DefaultCommandRunner();
|
||||
pluginLazyDependencies: Record<string, string> = {};
|
||||
private installPromises = new Map<string, Promise<InstallResult>>();
|
||||
private registriesMap: Record<string, { registry: Registry<any>; 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.installTimeoutMs = config?.installTimeoutMs ?? 60000;
|
||||
this.pnpmCommand = config?.pnpmCommand ?? "";
|
||||
this.lazyDependencies = config?.lazyDependencies ?? {};
|
||||
this.registryResolver = new NpmRegistryResolver(config?.registry);
|
||||
@@ -313,121 +227,84 @@ export class RuntimeDepsService {
|
||||
this.registriesMap = map;
|
||||
}
|
||||
|
||||
collectDependencies(plugins: RuntimeDependencyPluginDefine[]): CollectDependenciesResult {
|
||||
const merged: Record<string, string> = {};
|
||||
const seen: Record<string, Array<{ pluginName: string; range: string }>> = {};
|
||||
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<InstallResult> {
|
||||
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<string, string>; logger?: ILogger }): Promise<InstallResult> {
|
||||
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<InstallResult> {
|
||||
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") };
|
||||
if (INSTALL_LOCKER) {
|
||||
throw new Error("动态安装依赖正在执行中,请稍后再试");
|
||||
}
|
||||
const expandedPluginDefines = pluginDefines.flatMap(pluginDefine => this.resolvePluginDependencies(pluginDefine));
|
||||
return await this.ensureInstalled({ plugins: expandedPluginDefines, logger: log });
|
||||
INSTALL_LOCKER = true;
|
||||
const installPromise = this.doEnsureInstalled({ dependencies, logger: log }).finally(() => {
|
||||
INSTALL_LOCKER = null;
|
||||
});
|
||||
// 25s 内如果没返回就提前返回
|
||||
// AI注意,不要改我的实现,这里提前在25s就报超时是我故意这么做的实现
|
||||
// 因为前端配置的超时时间是30s,大部分的情况下install会在25s内完成,所以这里提前在25s就报超时,并且容忍60s的install
|
||||
const timeoutPromise: Promise<any> = new Promise((resolve, reject) => setTimeout(() => reject(new Error("安装依赖超时,请稍后重试")), 25 * 1000));
|
||||
return await Promise.race([installPromise, timeoutPromise]);
|
||||
}
|
||||
|
||||
async importRuntime(specifier: string, logger: ILogger = defaultLogger) {
|
||||
if (this.isNativeImportSpecifier(specifier)) {
|
||||
//是否本地模块
|
||||
return await import(specifier);
|
||||
}
|
||||
const resolved = await this.resolveImportSpecifier(specifier, logger);
|
||||
|
||||
let resolved: string;
|
||||
try {
|
||||
//尝试加载本项目依赖
|
||||
resolved = this.resolveProjectSpecifier(specifier).resolved;
|
||||
} catch (error) {
|
||||
if (!this.isModuleNotFoundError(error)) {
|
||||
throw error;
|
||||
}
|
||||
resolved = await this.resolveLazyOrInstallSpecifier(specifier, logger);
|
||||
}
|
||||
if (!resolved) {
|
||||
throw new Error(`依赖未安装成功: ${specifier}`);
|
||||
}
|
||||
|
||||
return await import(pathToFileURL(resolved).href);
|
||||
}
|
||||
|
||||
private async resolveImportSpecifier(specifier: string, logger: ILogger = defaultLogger) {
|
||||
//动态获取模块解析
|
||||
private async resolveLazyOrInstallSpecifier(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);
|
||||
if (!this.enabled) {
|
||||
throw new Error("动态安装依赖未开启");
|
||||
}
|
||||
//模块不存在,安装
|
||||
await this.installLazyDependencies(specifier, logger);
|
||||
|
||||
try {
|
||||
return this.resolveRuntimeSpecifier(specifier).resolved;
|
||||
} catch (lazyError: any) {
|
||||
// logger?.error?.(`动态依赖安装失败: ${lazyError.message}`);
|
||||
throw new Error(`依赖加载失败,可能动态依赖未安装成功: ${lazyError.message}`, { cause: lazyError });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async resolveMissingRuntimeSpecifier(specifier: string, runtimeError: any, logger?: ILogger) {
|
||||
//不存在模块动态依赖安装,再获取解析
|
||||
private async installLazyDependencies(specifier: string, 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}`);
|
||||
}
|
||||
throw new Error(`动态依赖未安装且未配置懒加载版本: ${packageName}`);
|
||||
}
|
||||
try {
|
||||
await this.ensureLazyDependency(packageName, logger);
|
||||
return this.resolveRuntimeSpecifier(specifier).resolved;
|
||||
await this.ensureDependencies({ dependencies: { [packageName]: lazyRange }, logger });
|
||||
} catch (lazyError: any) {
|
||||
logger?.error?.(`动态依赖安装失败: ${lazyError.message}`);
|
||||
return this.resolveProjectSpecifier(specifier, lazyError).resolved;
|
||||
// logger?.error?.(`动态依赖安装失败: ${lazyError.message}`);
|
||||
throw new Error(`动态依赖安装失败: ${packageName}: ${lazyError.message}`, { cause: lazyError });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -435,6 +312,7 @@ export class RuntimeDepsService {
|
||||
return specifier.startsWith(".") || specifier.startsWith("/") || specifier.startsWith("file:") || specifier.startsWith("node:");
|
||||
}
|
||||
|
||||
//尝试加载runtime依赖
|
||||
private resolveRuntimeSpecifier(specifier: string): RuntimeImportResolveResult {
|
||||
const packageName = this.parsePackageName(specifier);
|
||||
const packageJsonPath = path.join(this.getRuntimeDepsRootDir(), "package.json");
|
||||
@@ -443,19 +321,13 @@ export class RuntimeDepsService {
|
||||
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 resolveProjectSpecifier(specifier: string): RuntimeImportResolveResult {
|
||||
const packageName = this.parsePackageName(specifier);
|
||||
const packageJsonPath = path.resolve("package.json");
|
||||
const require = createRequire(packageJsonPath);
|
||||
const resolved = require.resolve(specifier);
|
||||
return { packageName, resolved };
|
||||
}
|
||||
|
||||
private parsePackageName(specifier: string) {
|
||||
@@ -475,250 +347,50 @@ export class RuntimeDepsService {
|
||||
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<string>();
|
||||
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<string, string>; logger?: ILogger }): Promise<InstallResult> {
|
||||
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 allRegistryUrls = await this.registryResolver.resolveOrdered();
|
||||
const urlsToTry = allRegistryUrls.length > 0 ? allRegistryUrls : [""];
|
||||
let lastError: string | undefined;
|
||||
for (const tryUrl of urlsToTry) {
|
||||
const args = ["install", "--prod", "--ignore-scripts", "--ignore-workspace", "--no-frozen-lockfile", "--reporter=append-only"];
|
||||
if (tryUrl) {
|
||||
args.push(`--registry=${tryUrl}`);
|
||||
}
|
||||
const tryEnv = tryUrl ? this.buildChildEnv(tryUrl) : env;
|
||||
log.info(`开始安装第三方依赖: ${Object.keys(dependencies).join(", ")}${tryUrl ? `,镜像: ${tryUrl}` : ""}`);
|
||||
const result = await this.commandRunner.run(command, args, { cwd: rootDir, timeoutMs: this.installTimeoutMs, env: tryEnv });
|
||||
if (result.code === 0) {
|
||||
this.writeInstallState(statePath, { installedAt: new Date().toISOString(), registryUrl: tryUrl, dependenciesHash, nodeVersion: process.version, pnpmVersion, lockFileExists: fs.existsSync(lockPath) });
|
||||
log.info(`${result.stdout?.slice(-2000) || "无npm安装日志输出"}`);
|
||||
log.info("第三方依赖安装完成");
|
||||
return { registryUrl: tryUrl, packageJsonPath };
|
||||
}
|
||||
const errOutput = (result.stderr || "").trim();
|
||||
const outOutput = (result.stdout || "").trim();
|
||||
lastError = errOutput || outOutput || "unknown error";
|
||||
log.info(`镜像 ${tryUrl || "默认"} 安装失败,退出码: ${result.code}${urlsToTry.length > 1 ? ",尝试下一个镜像..." : ""}`);
|
||||
log.info(` pnpm stderr: ${(errOutput || "无npm安装日志输出").slice(-2000)}`);
|
||||
if (outOutput) {
|
||||
log.info(` pnpm stdout: ${outOutput.slice(-2000)}`);
|
||||
}
|
||||
}
|
||||
this.writeInstallState(statePath, {
|
||||
...currentState,
|
||||
installedAt: currentState?.installedAt,
|
||||
failedAt: new Date().toISOString(),
|
||||
registryUrl: urlsToTry[0],
|
||||
dependenciesHash,
|
||||
nodeVersion: process.version,
|
||||
pnpmVersion,
|
||||
lockFileExists: fs.existsSync(lockPath),
|
||||
lastError,
|
||||
});
|
||||
throw new Error(`动态依赖安装失败: ${lastError}`);
|
||||
});
|
||||
}
|
||||
|
||||
private async withInstallLock<T>(run: () => Promise<T>): Promise<T> {
|
||||
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);
|
||||
if (!fs.existsSync(rootDir)) {
|
||||
fs.mkdirSync(rootDir, { recursive: true });
|
||||
}
|
||||
let releaseProcessLock!: () => void;
|
||||
const current = new Promise<void>(resolve => {
|
||||
releaseProcessLock = resolve;
|
||||
});
|
||||
PROCESS_LOCKS.set(lockFile, current);
|
||||
let lock: AcquiredInstallLock | undefined;
|
||||
try {
|
||||
lock = await this.acquireFileLock(lockFile);
|
||||
return await run();
|
||||
} finally {
|
||||
if (lock) {
|
||||
fs.closeSync(lock.fd);
|
||||
this.removeOwnedInstallLock(lockFile);
|
||||
const packageJsonPath = path.join(rootDir, "package.json");
|
||||
log.info(`第三方依赖安装: ${JSON.stringify(dependencies)}`);
|
||||
dependencies = this.mergeInstalledDependencies(this.readManifestDependencies(packageJsonPath), dependencies);
|
||||
|
||||
const manifest = { name: "certd-runtime-deps", private: true, type: "module", dependencies };
|
||||
fs.writeFileSync(packageJsonPath, JSON.stringify(manifest, null, 2), "utf8");
|
||||
const allRegistryUrls = await this.registryResolver.resolveOrdered();
|
||||
const urlsToTry = allRegistryUrls.length > 0 ? allRegistryUrls : [""];
|
||||
const command = this.getPnpmCommand();
|
||||
let lastError: string | undefined;
|
||||
for (const tryUrl of urlsToTry) {
|
||||
const args = ["install", "--prod", "--ignore-scripts", "--ignore-workspace", "--no-frozen-lockfile", "--reporter=append-only"];
|
||||
if (tryUrl) {
|
||||
args.push(`--registry=${tryUrl}`);
|
||||
}
|
||||
releaseProcessLock();
|
||||
if (PROCESS_LOCKS.get(lockFile) === current) {
|
||||
PROCESS_LOCKS.delete(lockFile);
|
||||
const tryEnv = this.buildChildEnv(tryUrl);
|
||||
log.info(`开始安装第三方依赖: ${Object.keys(dependencies).join(", ")}${tryUrl ? `,镜像: ${tryUrl}` : ""}`);
|
||||
const result = await this.commandRunner.run(command, args, { cwd: rootDir, timeoutMs: this.installTimeoutMs, env: tryEnv });
|
||||
if (result.code === 0) {
|
||||
log.info(`${result.stdout?.slice(-2000) || "无npm安装日志输出"}`);
|
||||
log.info("第三方依赖安装完成");
|
||||
return { registryUrl: tryUrl, packageJsonPath };
|
||||
}
|
||||
const errOutput = (result.stderr || "").trim();
|
||||
const outOutput = (result.stdout || "").trim();
|
||||
lastError = errOutput || outOutput || "unknown error";
|
||||
log.info(`镜像 ${tryUrl || "默认"} 安装失败,退出码: ${result.code}${urlsToTry.length > 1 ? ",尝试下一个镜像..." : ""}`);
|
||||
log.info(` pnpm stderr: ${(errOutput || "无npm安装日志输出").slice(-2000)}`);
|
||||
if (outOutput) {
|
||||
log.info(` pnpm stdout: ${outOutput.slice(-2000)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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({ processInstanceId: PROCESS_INSTANCE_ID, createdAt: new Date().toISOString() }), "utf8");
|
||||
return { fd };
|
||||
} catch (error: any) {
|
||||
if (error?.code !== "EEXIST") {
|
||||
throw error;
|
||||
}
|
||||
if (this.removeExpiredInstallLock(lockFile)) {
|
||||
continue;
|
||||
}
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
private removeExpiredInstallLock(lockFile: string) {
|
||||
const lockInfo = this.readInstallLockInfo(lockFile);
|
||||
if (!lockInfo) {
|
||||
return this.removeExpiredInvalidInstallLock(lockFile);
|
||||
}
|
||||
if (!this.isInstallLockExpired(lockInfo)) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
fs.rmSync(lockFile, { force: true });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private removeOwnedInstallLock(lockFile: string) {
|
||||
const lockInfo = this.readInstallLockInfo(lockFile);
|
||||
if (lockInfo?.processInstanceId !== PROCESS_INSTANCE_ID) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
fs.rmSync(lockFile, { force: true });
|
||||
} catch {}
|
||||
}
|
||||
|
||||
private isInstallLockExpired(lockInfo: InstallLockInfo) {
|
||||
const createdAt = Date.parse(lockInfo.createdAt || "");
|
||||
return !Number.isNaN(createdAt) && Date.now() - createdAt >= MAX_INSTALL_LOCK_AGE_MS;
|
||||
}
|
||||
|
||||
private removeExpiredInvalidInstallLock(lockFile: string) {
|
||||
try {
|
||||
const stat = fs.statSync(lockFile);
|
||||
if (Date.now() - stat.mtimeMs < INVALID_INSTALL_LOCK_GRACE_MS) {
|
||||
return false;
|
||||
}
|
||||
fs.rmSync(lockFile, { force: true });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private readInstallLockInfo(lockFile: string): InstallLockInfo | null {
|
||||
try {
|
||||
const content = fs.readFileSync(lockFile, "utf8");
|
||||
return JSON.parse(content) as InstallLockInfo;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
throw new Error(`动态依赖安装失败: ${lastError}`);
|
||||
}
|
||||
|
||||
async clearRuntimeDeps() {
|
||||
@@ -727,19 +399,17 @@ export class RuntimeDepsService {
|
||||
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 });
|
||||
}
|
||||
|
||||
if (INSTALL_LOCKER) {
|
||||
throw new Error("仍有依赖正在安装,请稍后");
|
||||
}
|
||||
|
||||
if (fs.existsSync(rootDir)) {
|
||||
const entries = fs.readdirSync(rootDir);
|
||||
for (const entry of entries) {
|
||||
fs.rmSync(path.join(rootDir, entry), { recursive: true, force: true });
|
||||
}
|
||||
this.installPromises.clear();
|
||||
return undefined;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
getMergedLazyDependencies(): Record<string, string> {
|
||||
@@ -776,21 +446,6 @@ export class RuntimeDepsService {
|
||||
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<string, string> {
|
||||
if (!fs.existsSync(packageJsonPath)) {
|
||||
return {};
|
||||
@@ -815,14 +470,6 @@ export class RuntimeDepsService {
|
||||
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";
|
||||
}
|
||||
@@ -861,20 +508,6 @@ export class RuntimeDepsService {
|
||||
getRuntimeDepsRootDir() {
|
||||
return path.resolve(this.runtimeDepsRootDir);
|
||||
}
|
||||
|
||||
private createDependenciesHash(dependencies: Record<string, string>) {
|
||||
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;
|
||||
|
||||
@@ -97,7 +97,7 @@ const development = {
|
||||
flyway: {
|
||||
scriptDir: "./db/migration",
|
||||
},
|
||||
|
||||
|
||||
auth: {
|
||||
jwt: {
|
||||
expire: 7 * 24 * 60 * 60, //单位秒
|
||||
@@ -109,9 +109,8 @@ const development = {
|
||||
runtimeDeps: {
|
||||
enabled: true,
|
||||
rootDir: "./data/.runtime-deps",
|
||||
autoInstall: true,
|
||||
pnpmCommand: "",
|
||||
installTimeoutMs: 120000,
|
||||
installTimeoutMs: 60000,
|
||||
lazyDependencies: pkg.lazyDependencies || {},
|
||||
registry: {
|
||||
mode: "auto",
|
||||
|
||||
@@ -138,7 +138,7 @@ export function getDefaultDnsPlugin() {
|
||||
accessType: aliyun # 授权类型名称
|
||||
#dependPackages: # 依赖第三方 npm 包,运行插件时会按需安装,尽量使用 certd 已安装的库,比如 http、lodash-es、utils
|
||||
# @alicloud/openapi-client: ^0.4.12
|
||||
#dependPlugins: # 依赖的其他插件,使用 type:name 格式避免不同类型插件同名;运行插件时会同时确保被依赖插件的 dependPackages
|
||||
#dependPlugins: # 依赖的其他插件,使用 type:name 格式避免不同类型插件同名
|
||||
# access:aliyun: *
|
||||
|
||||
`;
|
||||
|
||||
@@ -464,24 +464,6 @@ export class PluginService extends BaseService<PluginEntity> {
|
||||
});
|
||||
}
|
||||
|
||||
async getRuntimeDependencyPluginDefines() {
|
||||
const builtInList = await this.getEnabledBuiltInList();
|
||||
const customList = await this.list({
|
||||
buildQuery: bq => {
|
||||
bq.andWhere("type != :type", {
|
||||
type: "builtIn",
|
||||
});
|
||||
},
|
||||
});
|
||||
const list = [...builtInList];
|
||||
for (const plugin of customList) {
|
||||
const metadata = plugin.metadata ? yaml.load(plugin.metadata) : {};
|
||||
const extra = plugin.extra ? yaml.load(plugin.extra) : {};
|
||||
list.push({ ...plugin, ...metadata, ...extra });
|
||||
}
|
||||
return list.filter(item => item.dependPackages);
|
||||
}
|
||||
|
||||
async exportPlugin(id: number) {
|
||||
const info = await this.info(id);
|
||||
if (!info) {
|
||||
|
||||
Reference in New Issue
Block a user