@koishi-ce/plugin-market 1.1.3 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +2 -2
- package/dist/style.css +1 -1
- package/lib/index.d.ts +58 -17
- package/lib/index.mjs +192 -58
- package/package.json +6 -6
- package/src/node/__tests__/commands.test.ts +349 -0
- package/src/node/__tests__/helpers.ts +380 -0
- package/src/node/__tests__/provider.test.ts +340 -0
- package/src/node/__tests__/retry.test.ts +169 -0
- package/src/node/dependencies/service.ts +118 -0
- package/src/node/dependencies/snapshot.test.ts +176 -0
- package/src/node/dependencies/snapshot.ts +92 -0
- package/src/node/dependencies/types.ts +39 -0
- package/src/node/deps.ts +1 -14
- package/src/node/index.ts +7 -8
- package/src/node/installer/index.ts +38 -20
- package/src/node/installer/installer.test.ts +282 -0
- package/src/node/installer/manifest.ts +15 -0
- package/src/index.test.ts +0 -1242
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
2
|
+
// Copyright (c) 2019-present Shigma and Koishijs contributors.
|
|
3
|
+
// Copyright (c) 2026-present Koishi-CE contributors.
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
afterAll,
|
|
7
|
+
beforeAll,
|
|
8
|
+
describe,
|
|
9
|
+
expect,
|
|
10
|
+
it,
|
|
11
|
+
} from "bun:test";
|
|
12
|
+
import net from "node:net";
|
|
13
|
+
import type { Plugin } from "@koishi-ce/koishi";
|
|
14
|
+
import {
|
|
15
|
+
App,
|
|
16
|
+
FakeConsole,
|
|
17
|
+
FakeLoader,
|
|
18
|
+
flakyIndexHits,
|
|
19
|
+
http,
|
|
20
|
+
itQuiet,
|
|
21
|
+
market,
|
|
22
|
+
memory,
|
|
23
|
+
registryServer,
|
|
24
|
+
setFlakyIndexHits,
|
|
25
|
+
setFlakyIndexMisses,
|
|
26
|
+
setSlowIndexHits,
|
|
27
|
+
slowIndexHits,
|
|
28
|
+
startApp,
|
|
29
|
+
stopApp,
|
|
30
|
+
type TestApp,
|
|
31
|
+
} from "./helpers.ts";
|
|
32
|
+
|
|
33
|
+
beforeAll(startApp);
|
|
34
|
+
afterAll(stopApp);
|
|
35
|
+
|
|
36
|
+
describe("镜像索引瞬态重试", () => {
|
|
37
|
+
/** 搭建配了 search.endpoint 的独立宿主(镜像索引经本测试桩提供)。 */
|
|
38
|
+
async function createMirrorApp(search: {
|
|
39
|
+
endpoint: string;
|
|
40
|
+
timeout?: number;
|
|
41
|
+
}) {
|
|
42
|
+
const mirror = new App();
|
|
43
|
+
// 类型层转型缘由见 helpers 的 app.plugin(memory) 注释
|
|
44
|
+
mirror.plugin(
|
|
45
|
+
memory as unknown as Plugin.Object<TestApp>,
|
|
46
|
+
);
|
|
47
|
+
mirror.plugin(http);
|
|
48
|
+
mirror.plugin(
|
|
49
|
+
FakeConsole as unknown as Plugin.Constructor<TestApp>,
|
|
50
|
+
);
|
|
51
|
+
mirror.plugin(FakeLoader);
|
|
52
|
+
mirror.plugin(market, {
|
|
53
|
+
registry: {
|
|
54
|
+
endpoint: `http://127.0.0.1:${registryServer.port}/`,
|
|
55
|
+
},
|
|
56
|
+
search,
|
|
57
|
+
});
|
|
58
|
+
await mirror.start();
|
|
59
|
+
return mirror;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
it("镜像索引部署窗口 404 经退避重试后恢复", async () => {
|
|
63
|
+
setFlakyIndexHits(0);
|
|
64
|
+
setFlakyIndexMisses(2);
|
|
65
|
+
const mirror = await createMirrorApp({
|
|
66
|
+
endpoint: `http://127.0.0.1:${registryServer.port}/flaky-index`,
|
|
67
|
+
});
|
|
68
|
+
const svc = mirror.get("console.services.market");
|
|
69
|
+
// start(true) 内部 await prepare:前两次 404,退避重试后第三次成功
|
|
70
|
+
await svc?.start(true);
|
|
71
|
+
expect(flakyIndexHits).toBeGreaterThanOrEqual(3);
|
|
72
|
+
const payload = await svc?.get();
|
|
73
|
+
expect(Object.keys(payload?.data ?? {})).toEqual([
|
|
74
|
+
"koishi-plugin-demo",
|
|
75
|
+
]);
|
|
76
|
+
expect(payload?.total).toBe(1);
|
|
77
|
+
await mirror.stop();
|
|
78
|
+
}, 10000);
|
|
79
|
+
|
|
80
|
+
itQuiet(
|
|
81
|
+
"镜像索引持续超时经重试后优雅失败",
|
|
82
|
+
async () => {
|
|
83
|
+
setSlowIndexHits(0);
|
|
84
|
+
const mirror = await createMirrorApp({
|
|
85
|
+
endpoint: `http://127.0.0.1:${registryServer.port}/slow-index`,
|
|
86
|
+
timeout: 50,
|
|
87
|
+
});
|
|
88
|
+
const svc = mirror.get("console.services.market");
|
|
89
|
+
// 每次尝试 50ms 即超时,退避 1s + 2s 后仍失败;重试确实发生
|
|
90
|
+
await svc?.start(true);
|
|
91
|
+
expect(slowIndexHits).toBeGreaterThanOrEqual(3);
|
|
92
|
+
// start 末尾的 refresh 会再触发一轮 collect,等待其失败落地
|
|
93
|
+
await new Promise((resolve) =>
|
|
94
|
+
setTimeout(resolve, 3600),
|
|
95
|
+
);
|
|
96
|
+
const error = svc?.["_error" as keyof typeof svc];
|
|
97
|
+
expect(error).toBeDefined();
|
|
98
|
+
await mirror.stop();
|
|
99
|
+
},
|
|
100
|
+
15000,
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
// 裸 TCP 桩:前 planned 次请求收到请求头后直接销毁连接(复现
|
|
104
|
+
// 证书验证失败 / 连接重置等 fetch 网络层瞬态错误),随后回正常索引
|
|
105
|
+
function startFlakyTcpServer(planned: number) {
|
|
106
|
+
let hits = 0;
|
|
107
|
+
const server = net.createServer((socket) => {
|
|
108
|
+
socket.on("data", () => {
|
|
109
|
+
hits++;
|
|
110
|
+
if (hits <= planned) {
|
|
111
|
+
socket.destroy();
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
const body = JSON.stringify({
|
|
115
|
+
version: "4",
|
|
116
|
+
objects: [
|
|
117
|
+
{
|
|
118
|
+
package: {
|
|
119
|
+
name: "koishi-plugin-demo",
|
|
120
|
+
version: "2.0.0",
|
|
121
|
+
date: "2024-06-01T00:00:00Z",
|
|
122
|
+
keywords: ["koishi", "plugin"],
|
|
123
|
+
},
|
|
124
|
+
},
|
|
125
|
+
],
|
|
126
|
+
});
|
|
127
|
+
socket.end(
|
|
128
|
+
`HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: ${body.length}\r\nConnection: close\r\n\r\n${body}`,
|
|
129
|
+
);
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
return new Promise<{
|
|
133
|
+
port: number;
|
|
134
|
+
hits: () => number;
|
|
135
|
+
close: () => void;
|
|
136
|
+
}>((resolve) => {
|
|
137
|
+
server.listen(0, "127.0.0.1", () => {
|
|
138
|
+
const { port } =
|
|
139
|
+
server.address() as net.AddressInfo;
|
|
140
|
+
resolve({
|
|
141
|
+
port,
|
|
142
|
+
hits: () => hits,
|
|
143
|
+
close: () => {
|
|
144
|
+
server.close();
|
|
145
|
+
},
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
it("网络层瞬态错误(连接重置)经退避重试后恢复", async () => {
|
|
152
|
+
const stub = await startFlakyTcpServer(2);
|
|
153
|
+
const mirror = await createMirrorApp({
|
|
154
|
+
endpoint: `http://127.0.0.1:${stub.port}/`,
|
|
155
|
+
});
|
|
156
|
+
const svc = mirror.get("console.services.market");
|
|
157
|
+
// 前两次连接被重置(fetch 抛 TypeError,plugin-http 包装为无 code
|
|
158
|
+
// 无 response 的 HTTP.Error),退避重试后第三次成功
|
|
159
|
+
await svc?.start(true);
|
|
160
|
+
expect(stub.hits()).toBeGreaterThanOrEqual(3);
|
|
161
|
+
const payload = await svc?.get();
|
|
162
|
+
expect(Object.keys(payload?.data ?? {})).toEqual([
|
|
163
|
+
"koishi-plugin-demo",
|
|
164
|
+
]);
|
|
165
|
+
expect(payload?.total).toBe(1);
|
|
166
|
+
await mirror.stop();
|
|
167
|
+
stub.close();
|
|
168
|
+
}, 10000);
|
|
169
|
+
});
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
2
|
+
// Copyright (c) 2019-present Shigma and Koishijs contributors.
|
|
3
|
+
// Copyright (c) 2026-present Koishi-CE contributors.
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* 依赖管理数据服务(`dependencies` 通道)。
|
|
7
|
+
*
|
|
8
|
+
* 单向数据流的 node 侧源头:get() 同步构建本地快照(纯磁盘读取)先行
|
|
9
|
+
* 推送(条目带 fetching 标记),随后在后台并发拉取 registry 元数据填充
|
|
10
|
+
* latest / error,完成后触发通道刷新二拍推送——前端首屏因此有明确的
|
|
11
|
+
* 「加载中」态,不会把未拉取完的条目误显示为错误。
|
|
12
|
+
*
|
|
13
|
+
* node 侧并发纪律(对标参照实现):
|
|
14
|
+
* - 单飞去重:元数据刷新任务同一时刻至多一个,并发调用复用同一任务;
|
|
15
|
+
* - 循环重扫:刷新循环每轮从快照里重新收集 fetching 条目,期间新的
|
|
16
|
+
* get()(安装完成、客户端接入)带来的新条目自动被后续轮次接管,
|
|
17
|
+
* 不会因单飞复用旧任务而漏拉;
|
|
18
|
+
* - 过期不覆盖:拉取完成后若快照代次已更新,跳过通道刷新(新循环
|
|
19
|
+
* 完成后自会推送)。
|
|
20
|
+
*/
|
|
21
|
+
import { DataService } from "@koishi-ce/console";
|
|
22
|
+
import type { Context, Dict } from "@koishi-ce/koishi";
|
|
23
|
+
import { mapLimit } from "@koishi-ce/registry";
|
|
24
|
+
import { loadManifest } from "../installer/manifest.ts";
|
|
25
|
+
import {
|
|
26
|
+
collectDependencyRequests,
|
|
27
|
+
resolveLocalDependency,
|
|
28
|
+
} from "./snapshot.ts";
|
|
29
|
+
import type { Dependency } from "./types.ts";
|
|
30
|
+
|
|
31
|
+
/** registry 元数据并发拉取上限 */
|
|
32
|
+
const CONCURRENCY = 4;
|
|
33
|
+
|
|
34
|
+
export class DependencyService extends DataService<
|
|
35
|
+
Dict<Dependency>
|
|
36
|
+
> {
|
|
37
|
+
// 基类 DataService 声明了 console 注入,子类覆盖须一并保留
|
|
38
|
+
static override inject = ["console", "installer"];
|
|
39
|
+
|
|
40
|
+
/** 快照代次:每次 get() 自增,用于判定后台刷新结果是否已过期 */
|
|
41
|
+
private serial = 0;
|
|
42
|
+
/** 进行中的元数据刷新任务(单飞去重) */
|
|
43
|
+
declare private task: Promise<void> | undefined;
|
|
44
|
+
/** 最近一轮快照(新快照构建时复用 latest 与 404 负缓存) */
|
|
45
|
+
private snapshot: Dict<Dependency> = {};
|
|
46
|
+
|
|
47
|
+
constructor(ctx: Context) {
|
|
48
|
+
super(ctx, "dependencies", { authority: 4 });
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
override async get() {
|
|
52
|
+
this.serial++;
|
|
53
|
+
const manifest = loadManifest(this.ctx.baseDir);
|
|
54
|
+
const snapshot = collectDependencyRequests(
|
|
55
|
+
manifest.dependencies,
|
|
56
|
+
);
|
|
57
|
+
for (const [name, dep] of Object.entries(snapshot)) {
|
|
58
|
+
resolveLocalDependency(name, dep, {
|
|
59
|
+
previous: this.snapshot[name],
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
this.snapshot = snapshot;
|
|
63
|
+
this.ensureMetadata();
|
|
64
|
+
return snapshot;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
private ensureMetadata() {
|
|
68
|
+
if (this.task) return;
|
|
69
|
+
this.task = this.refreshMetadata().finally(() => {
|
|
70
|
+
this.task = undefined;
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* 元数据刷新循环:拉完本轮全部 fetching 条目后,若快照代次未变则
|
|
76
|
+
* 触发通道刷新(二拍推送);代次已变(期间有新的 get())则重扫
|
|
77
|
+
* fetching 条目继续,直到无新增且代次一致。
|
|
78
|
+
*/
|
|
79
|
+
private async refreshMetadata() {
|
|
80
|
+
for (;;) {
|
|
81
|
+
const serial = this.serial;
|
|
82
|
+
const targets = Object.entries(this.snapshot)
|
|
83
|
+
.filter(([, dep]) => dep.fetching)
|
|
84
|
+
.map(([name]) => name);
|
|
85
|
+
if (!targets.length) return;
|
|
86
|
+
await mapLimit(targets, CONCURRENCY, async (name) => {
|
|
87
|
+
await this.resolveMetadata(name);
|
|
88
|
+
});
|
|
89
|
+
if (serial !== this.serial) continue;
|
|
90
|
+
// 版本表经 registry 通道下发(版本下拉消费),一并刷新
|
|
91
|
+
this.ctx.get("console")?.refresh("dependencies");
|
|
92
|
+
this.ctx.get("console")?.refresh("registry");
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** 单个条目的元数据解析:拉取版本表并填充 latest / error。 */
|
|
98
|
+
private async resolveMetadata(name: string) {
|
|
99
|
+
const dep = this.snapshot[name];
|
|
100
|
+
if (!dep?.fetching) return;
|
|
101
|
+
const installer = this.ctx.installer;
|
|
102
|
+
// getPackage 内部单飞去重并写 fullCache;失败也 resolve 空表
|
|
103
|
+
const versions = await installer.getPackage(name);
|
|
104
|
+
delete dep.fetching;
|
|
105
|
+
if (!Object.keys(versions).length) {
|
|
106
|
+
// 拉取失败:404 归类 not-found(负缓存),其余为网络错误
|
|
107
|
+
dep.error = installer.pkgErrors[name]?.notFound
|
|
108
|
+
? "not-found"
|
|
109
|
+
: "network";
|
|
110
|
+
delete dep.latest;
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
const [latest] = Object.keys(versions);
|
|
114
|
+
if (latest !== undefined) dep.latest = latest;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export default DependencyService;
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
2
|
+
// Copyright (c) 2019-present Shigma and Koishijs contributors.
|
|
3
|
+
// Copyright (c) 2026-present Koishi-CE contributors.
|
|
4
|
+
|
|
5
|
+
import { describe, expect, it } from "bun:test";
|
|
6
|
+
import {
|
|
7
|
+
mkdirSync,
|
|
8
|
+
mkdtempSync,
|
|
9
|
+
rmSync,
|
|
10
|
+
writeFileSync,
|
|
11
|
+
} from "node:fs";
|
|
12
|
+
import { tmpdir } from "node:os";
|
|
13
|
+
import { join } from "node:path";
|
|
14
|
+
import {
|
|
15
|
+
collectDependencyRequests,
|
|
16
|
+
resolveLocalDependency,
|
|
17
|
+
} from "./snapshot.ts";
|
|
18
|
+
import type { Dependency } from "./types.ts";
|
|
19
|
+
|
|
20
|
+
/** 造一个临时宿主目录,并在 node_modules 内放置指定包的清单。 */
|
|
21
|
+
function setupHost(installed: Record<string, string> = {}) {
|
|
22
|
+
const cwd = mkdtempSync(join(tmpdir(), "deps-snapshot-"));
|
|
23
|
+
for (const [name, version] of Object.entries(installed)) {
|
|
24
|
+
const dir = join(cwd, "node_modules", name);
|
|
25
|
+
mkdirSync(dir, { recursive: true });
|
|
26
|
+
writeFileSync(
|
|
27
|
+
join(dir, "package.json"),
|
|
28
|
+
JSON.stringify({ name, version }),
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
process.chdir(cwd);
|
|
32
|
+
return () => {
|
|
33
|
+
process.chdir(join(cwd, ".."));
|
|
34
|
+
rmSync(cwd, { recursive: true, force: true });
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
describe("collectDependencyRequests", () => {
|
|
39
|
+
it("以依赖声明为底建骨架并剥掉 ^/~ 前缀", () => {
|
|
40
|
+
const result = collectDependencyRequests({
|
|
41
|
+
"koishi-plugin-demo": "^1.2.3",
|
|
42
|
+
cosmokit: "~1.8.0",
|
|
43
|
+
pinned: "2.0.0",
|
|
44
|
+
});
|
|
45
|
+
expect(result["koishi-plugin-demo"]).toEqual({
|
|
46
|
+
request: "1.2.3",
|
|
47
|
+
fetching: true,
|
|
48
|
+
});
|
|
49
|
+
expect(result["cosmokit"]?.request).toBe("1.8.0");
|
|
50
|
+
expect(result["pinned"]?.request).toBe("2.0.0");
|
|
51
|
+
// 元数据全部置为待拉取
|
|
52
|
+
expect(result["pinned"]?.fetching).toBe(true);
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
describe("resolveLocalDependency", () => {
|
|
57
|
+
it("补已装版本并保持待拉取形态", () => {
|
|
58
|
+
const teardown = setupHost({ "pkg-a": "1.0.0" });
|
|
59
|
+
try {
|
|
60
|
+
const dep: Dependency = {
|
|
61
|
+
request: "1.0.0",
|
|
62
|
+
fetching: true,
|
|
63
|
+
};
|
|
64
|
+
resolveLocalDependency("pkg-a", dep, {
|
|
65
|
+
previous: undefined,
|
|
66
|
+
});
|
|
67
|
+
expect(dep.resolved).toBe("1.0.0");
|
|
68
|
+
expect(dep.workspace).toBe(false);
|
|
69
|
+
expect(dep.invalid).toBeUndefined();
|
|
70
|
+
// 已装且合法 semver:继续等待元数据刷新
|
|
71
|
+
expect(dep.fetching).toBe(true);
|
|
72
|
+
} finally {
|
|
73
|
+
teardown();
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("未安装的包 resolved 留空、不标 invalid", () => {
|
|
78
|
+
const teardown = setupHost();
|
|
79
|
+
try {
|
|
80
|
+
const dep: Dependency = {
|
|
81
|
+
request: "1.0.0",
|
|
82
|
+
fetching: true,
|
|
83
|
+
};
|
|
84
|
+
resolveLocalDependency("pkg-missing", dep, {
|
|
85
|
+
previous: undefined,
|
|
86
|
+
});
|
|
87
|
+
expect(dep.resolved).toBeUndefined();
|
|
88
|
+
expect(dep.invalid).toBeUndefined();
|
|
89
|
+
expect(dep.fetching).toBe(true);
|
|
90
|
+
} finally {
|
|
91
|
+
teardown();
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("非精确 semver 的请求标记 invalid 且不参与拉取", () => {
|
|
96
|
+
const teardown = setupHost();
|
|
97
|
+
try {
|
|
98
|
+
const dep: Dependency = {
|
|
99
|
+
request: "file:./plugins/foo",
|
|
100
|
+
fetching: true,
|
|
101
|
+
};
|
|
102
|
+
resolveLocalDependency("local-thing", dep, {
|
|
103
|
+
previous: undefined,
|
|
104
|
+
});
|
|
105
|
+
expect(dep.invalid).toBe(true);
|
|
106
|
+
expect(dep.fetching).toBeUndefined();
|
|
107
|
+
} finally {
|
|
108
|
+
teardown();
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("上一轮 latest 与 404 负缓存被复用,网络错误不沿用", () => {
|
|
113
|
+
const teardown = setupHost({ "pkg-a": "1.0.0" });
|
|
114
|
+
try {
|
|
115
|
+
const base = { request: "1.0.0", resolved: "1.0.0" };
|
|
116
|
+
// latest 复用:fetching 随之消除
|
|
117
|
+
const withLatest: Dependency = {
|
|
118
|
+
...base,
|
|
119
|
+
fetching: true,
|
|
120
|
+
latest: "2.0.0",
|
|
121
|
+
};
|
|
122
|
+
resolveLocalDependency("pkg-a", withLatest, {
|
|
123
|
+
previous: { ...base, latest: "2.0.0" },
|
|
124
|
+
});
|
|
125
|
+
expect(withLatest.latest).toBe("2.0.0");
|
|
126
|
+
expect(withLatest.fetching).toBeUndefined();
|
|
127
|
+
|
|
128
|
+
// not-found 复用(会话内不再重试)
|
|
129
|
+
const notFound: Dependency = {
|
|
130
|
+
...base,
|
|
131
|
+
fetching: true,
|
|
132
|
+
};
|
|
133
|
+
resolveLocalDependency("pkg-a", notFound, {
|
|
134
|
+
previous: { ...base, error: "not-found" },
|
|
135
|
+
});
|
|
136
|
+
expect(notFound.error).toBe("not-found");
|
|
137
|
+
expect(notFound.fetching).toBeUndefined();
|
|
138
|
+
|
|
139
|
+
// 网络错误不沿用:下一轮重新拉取
|
|
140
|
+
const network: Dependency = {
|
|
141
|
+
...base,
|
|
142
|
+
fetching: true,
|
|
143
|
+
};
|
|
144
|
+
resolveLocalDependency("pkg-a", network, {
|
|
145
|
+
previous: { ...base, error: "network" },
|
|
146
|
+
});
|
|
147
|
+
expect(network.error).toBeUndefined();
|
|
148
|
+
expect(network.fetching).toBe(true);
|
|
149
|
+
} finally {
|
|
150
|
+
teardown();
|
|
151
|
+
}
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it("request 或 resolved 变化的条目不复用上一轮结果", () => {
|
|
155
|
+
const teardown = setupHost({ "pkg-a": "1.5.0" });
|
|
156
|
+
try {
|
|
157
|
+
const dep: Dependency = {
|
|
158
|
+
request: "1.5.0",
|
|
159
|
+
fetching: true,
|
|
160
|
+
};
|
|
161
|
+
resolveLocalDependency("pkg-a", dep, {
|
|
162
|
+
previous: {
|
|
163
|
+
request: "1.0.0",
|
|
164
|
+
resolved: "1.0.0",
|
|
165
|
+
latest: "2.0.0",
|
|
166
|
+
error: "not-found",
|
|
167
|
+
},
|
|
168
|
+
});
|
|
169
|
+
expect(dep.latest).toBeUndefined();
|
|
170
|
+
expect(dep.error).toBeUndefined();
|
|
171
|
+
expect(dep.fetching).toBe(true);
|
|
172
|
+
} finally {
|
|
173
|
+
teardown();
|
|
174
|
+
}
|
|
175
|
+
});
|
|
176
|
+
});
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
2
|
+
// Copyright (c) 2019-present Shigma and Koishijs contributors.
|
|
3
|
+
// Copyright (c) 2026-present Koishi-CE contributors.
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* 宿主依赖本地快照的构建纯函数。
|
|
7
|
+
*
|
|
8
|
+
* 两步:collectDependencyRequests 以宿主 package.json 的依赖声明为底
|
|
9
|
+
* 构建骨架;resolveLocalDependency 逐包读 node_modules 补已装版本与
|
|
10
|
+
* workspace 标记。全程只碰本地磁盘、不发网络请求——registry 元数据由
|
|
11
|
+
* DependencyService 的刷新阶段另行填充。
|
|
12
|
+
*
|
|
13
|
+
* 上一轮条目的复用规则(防闪烁 + 负缓存语义):
|
|
14
|
+
* - request 与 resolved 未变时沿用 latest(省一次 registry 往返);
|
|
15
|
+
* - 仅 404(not-found)的 error 被沿用(registry 确认无此包,会话内
|
|
16
|
+
* 不再重试);网络错误的 error 不沿用,下一轮 get() 会重新拉取。
|
|
17
|
+
*/
|
|
18
|
+
import type { Dict } from "@koishi-ce/koishi";
|
|
19
|
+
import { valid } from "semver";
|
|
20
|
+
import { loadManifest } from "../installer/manifest.ts";
|
|
21
|
+
import type { Dependency } from "./types.ts";
|
|
22
|
+
|
|
23
|
+
/** 以宿主 dependencies 声明为底构建快照骨架(元数据字段全部置为待拉取)。 */
|
|
24
|
+
export function collectDependencyRequests(
|
|
25
|
+
dependencies: Record<string, string>,
|
|
26
|
+
): Dict<Dependency> {
|
|
27
|
+
const result: Dict<Dependency> = {};
|
|
28
|
+
for (const [name, request] of Object.entries(
|
|
29
|
+
dependencies,
|
|
30
|
+
)) {
|
|
31
|
+
// 剥掉 ^/~ 前缀:后续 valid() 判定与 latest 比较都按精确版本语义进行
|
|
32
|
+
result[name] = {
|
|
33
|
+
request: request.replace(/^[~^]/, ""),
|
|
34
|
+
fetching: true,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
return result;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** 逐包本地解析所需的上下文:上一轮同名条目(复用 latest 与 404 负缓存)。 */
|
|
41
|
+
export interface LocalResolveContext {
|
|
42
|
+
previous: Dependency | undefined;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* 单个依赖条目的本地解析:已装版本 / workspace 标记 / invalid 标记,
|
|
47
|
+
* 就地修改传入的 dep。node_modules 内无此包时 resolved 留空,由后续
|
|
48
|
+
* 元数据刷新阶段补齐。
|
|
49
|
+
*/
|
|
50
|
+
export function resolveLocalDependency(
|
|
51
|
+
name: string,
|
|
52
|
+
dep: Dependency,
|
|
53
|
+
ctx: LocalResolveContext,
|
|
54
|
+
): void {
|
|
55
|
+
try {
|
|
56
|
+
// 已装版本读 node_modules 内的 package.json;未安装/读取失败留空
|
|
57
|
+
const meta = loadManifest(name);
|
|
58
|
+
dep.resolved = meta.version;
|
|
59
|
+
dep.workspace = meta.$workspace;
|
|
60
|
+
if (meta.$workspace) {
|
|
61
|
+
// workspace 包不参与 registry 拉取与更新判定
|
|
62
|
+
delete dep.fetching;
|
|
63
|
+
}
|
|
64
|
+
} catch {
|
|
65
|
+
// 尚未安装(或残留声明):保持待拉取形态
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (!dep.workspace && !valid(dep.request)) {
|
|
69
|
+
// 非精确 semver 的请求(file: 路径、git/url 串等):不参与
|
|
70
|
+
// latest 刷新,前端据此降级展示
|
|
71
|
+
dep.invalid = true;
|
|
72
|
+
delete dep.fetching;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const { previous } = ctx;
|
|
76
|
+
if (
|
|
77
|
+
previous &&
|
|
78
|
+
previous.request === dep.request &&
|
|
79
|
+
previous.resolved === dep.resolved
|
|
80
|
+
) {
|
|
81
|
+
if (previous.latest) {
|
|
82
|
+
dep.latest = previous.latest;
|
|
83
|
+
delete dep.fetching;
|
|
84
|
+
}
|
|
85
|
+
// 404 负缓存:registry 已确认无此包,会话内不再重试;
|
|
86
|
+
// 网络错误不沿用,下一轮 get() 重新拉取
|
|
87
|
+
if (previous.error === "not-found") {
|
|
88
|
+
dep.error = "not-found";
|
|
89
|
+
delete dep.fetching;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
2
|
+
// Copyright (c) 2019-present Shigma and Koishijs contributors.
|
|
3
|
+
// Copyright (c) 2026-present Koishi-CE contributors.
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* 依赖条目类型定义(dependencies 数据通道的值类型)。
|
|
7
|
+
*
|
|
8
|
+
* 单个依赖条目的请求范围、已装版本与来源标记;latest / error / fetching
|
|
9
|
+
* 三个可选字段由元数据刷新阶段填充,前端依赖页的分类状态机据此判定
|
|
10
|
+
* 六态。类型集中在此,node 侧声明与 client 侧镜像(console-services.ts)
|
|
11
|
+
* 均从这里取形状。
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/** registry 元数据拉取失败的归类:404(registry 无此包)与其余(网络等) */
|
|
15
|
+
export type DependencyError = "not-found" | "network";
|
|
16
|
+
|
|
17
|
+
/** 依赖条目:请求范围、已装版本、来源标记与最新版信息。 */
|
|
18
|
+
export interface Dependency {
|
|
19
|
+
/**
|
|
20
|
+
* requested semver range
|
|
21
|
+
* @example `^1.2.3` -> `1.2.3`
|
|
22
|
+
*/
|
|
23
|
+
request: string;
|
|
24
|
+
/**
|
|
25
|
+
* installed package version
|
|
26
|
+
* @example `1.2.5`
|
|
27
|
+
*/
|
|
28
|
+
resolved?: string | undefined;
|
|
29
|
+
/** whether it is a workspace package */
|
|
30
|
+
workspace?: boolean | undefined;
|
|
31
|
+
/** valid (unsupported) syntax */
|
|
32
|
+
invalid?: boolean | undefined;
|
|
33
|
+
/** latest version */
|
|
34
|
+
latest?: string | undefined;
|
|
35
|
+
/** registry 元数据拉取失败的归类(缺失代表尚在拉取或已成功) */
|
|
36
|
+
error?: DependencyError | undefined;
|
|
37
|
+
/** 本轮元数据尚未拉取完成(前端据此显示加载中而非错误) */
|
|
38
|
+
fetching?: boolean | undefined;
|
|
39
|
+
}
|
package/src/node/deps.ts
CHANGED
|
@@ -8,19 +8,6 @@ import type {
|
|
|
8
8
|
DependencyMetaKey,
|
|
9
9
|
RemotePackage,
|
|
10
10
|
} from "@koishi-ce/registry";
|
|
11
|
-
import type { Dependency } from "./installer/index.ts";
|
|
12
|
-
|
|
13
|
-
class DependencyProvider extends DataService<
|
|
14
|
-
Dict<Dependency>
|
|
15
|
-
> {
|
|
16
|
-
constructor(ctx: Context) {
|
|
17
|
-
super(ctx, "dependencies", { authority: 4 });
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
override async get() {
|
|
21
|
-
return this.ctx.installer.getDeps();
|
|
22
|
-
}
|
|
23
|
-
}
|
|
24
11
|
|
|
25
12
|
class RegistryProvider extends DataService<
|
|
26
13
|
Dict<Dict<Pick<RemotePackage, DependencyMetaKey>>>
|
|
@@ -34,4 +21,4 @@ class RegistryProvider extends DataService<
|
|
|
34
21
|
}
|
|
35
22
|
}
|
|
36
23
|
|
|
37
|
-
export {
|
|
24
|
+
export { RegistryProvider };
|
package/src/node/index.ts
CHANGED
|
@@ -16,16 +16,15 @@ import type {
|
|
|
16
16
|
import { gt } from "semver";
|
|
17
17
|
import messageZhCN from "../../locales/message.zh-CN.yml";
|
|
18
18
|
import schemaZhCN from "../../locales/schema.zh-CN.yml";
|
|
19
|
-
import {
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
} from "./deps.ts";
|
|
19
|
+
import { DependencyService } from "./dependencies/service.ts";
|
|
20
|
+
import type { Dependency } from "./dependencies/types.ts";
|
|
21
|
+
import { RegistryProvider } from "./deps.ts";
|
|
23
22
|
import Installer from "./installer/index.ts";
|
|
24
23
|
import MarketProvider from "./market.ts";
|
|
25
24
|
|
|
26
25
|
export * from "../shared/index.ts";
|
|
27
|
-
|
|
28
|
-
export {
|
|
26
|
+
export type { Dependency };
|
|
27
|
+
export { DependencyService, Installer, RegistryProvider };
|
|
29
28
|
|
|
30
29
|
declare module "@koishi-ce/koishi" {
|
|
31
30
|
interface Context {
|
|
@@ -36,7 +35,7 @@ declare module "@koishi-ce/koishi" {
|
|
|
36
35
|
declare module "@koishi-ce/console" {
|
|
37
36
|
namespace Console {
|
|
38
37
|
interface Services {
|
|
39
|
-
dependencies:
|
|
38
|
+
dependencies: DependencyService;
|
|
40
39
|
registry: RegistryProvider;
|
|
41
40
|
}
|
|
42
41
|
}
|
|
@@ -243,7 +242,7 @@ export function apply(ctx: Context, config: Config) {
|
|
|
243
242
|
});
|
|
244
243
|
|
|
245
244
|
ctx.inject(["console", "installer"], (ctx) => {
|
|
246
|
-
ctx.plugin(
|
|
245
|
+
ctx.plugin(DependencyService);
|
|
247
246
|
ctx.plugin(RegistryProvider);
|
|
248
247
|
ctx.plugin(MarketProvider, config.search);
|
|
249
248
|
|