@koishi-ce/plugin-market 1.0.10 → 1.0.12
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/README.md +3 -1
- package/dist/index.js +4 -4
- package/lib/index.d.ts +15 -10
- package/lib/index.mjs +13 -7
- package/package.json +4 -4
- package/src/__tests__/index.test.ts +100 -0
- package/src/node/market.ts +20 -6
package/lib/index.d.ts
CHANGED
|
@@ -77,7 +77,7 @@ declare module "@koishi-ce/console" {
|
|
|
77
77
|
}
|
|
78
78
|
}
|
|
79
79
|
}
|
|
80
|
-
declare abstract class MarketProvider extends DataService<MarketProvider.Payload> {
|
|
80
|
+
export declare abstract class MarketProvider extends DataService<MarketProvider.Payload> {
|
|
81
81
|
private _task;
|
|
82
82
|
private _timestamp;
|
|
83
83
|
protected _error: unknown;
|
|
@@ -86,7 +86,7 @@ declare abstract class MarketProvider extends DataService<MarketProvider.Payload
|
|
|
86
86
|
abstract collect(): Promise<SearchResult | undefined>;
|
|
87
87
|
prepare(): Promise<SearchResult | undefined>;
|
|
88
88
|
}
|
|
89
|
-
declare namespace MarketProvider {
|
|
89
|
+
export declare namespace MarketProvider {
|
|
90
90
|
interface Payload {
|
|
91
91
|
registry?: string | undefined;
|
|
92
92
|
data: Dict<SearchObject>;
|
|
@@ -108,7 +108,12 @@ declare class MarketProvider$1 extends MarketProvider {
|
|
|
108
108
|
config: MarketProvider$1.Config;
|
|
109
109
|
constructor(ctx: Context, config: MarketProvider$1.Config);
|
|
110
110
|
start(refresh?: boolean): Promise<void>;
|
|
111
|
-
/**
|
|
111
|
+
/**
|
|
112
|
+
* 限流(429)、请求超时(abort 后无 response,code 为 ETIMEDOUT)、
|
|
113
|
+
* 服务端瞬态错误(408 / 5xx)可安全重试;allowNotFound 时把 404
|
|
114
|
+
* 也视为瞬态——镜像索引以「302 指向版本化文件」方式发布,切换文件
|
|
115
|
+
* 的部署窗口会出现短时 404。
|
|
116
|
+
*/
|
|
112
117
|
private isRetryable;
|
|
113
118
|
/**
|
|
114
119
|
* 带退避重试的 registry 请求。优先遵循服务端的 Retry-After 响应头
|
|
@@ -160,8 +165,8 @@ declare module "@koishi-ce/console" {
|
|
|
160
165
|
"market/registry"(names: string[]): Promise<Dict<Dict<Pick<RemotePackage, DependencyMetaKey>>>>;
|
|
161
166
|
}
|
|
162
167
|
}
|
|
163
|
-
declare const name = "market";
|
|
164
|
-
declare const inject: {
|
|
168
|
+
export declare const name = "market";
|
|
169
|
+
export declare const inject: {
|
|
165
170
|
http: {
|
|
166
171
|
required: boolean;
|
|
167
172
|
};
|
|
@@ -169,12 +174,12 @@ declare const inject: {
|
|
|
169
174
|
required: boolean;
|
|
170
175
|
};
|
|
171
176
|
};
|
|
172
|
-
declare const usage = "\n如果插件市场页面提示「无法连接到插件市场」,则可以选择一个 Koishi 社区提供的镜像地址,填入下方对应的配置项中。\n\n## 插件市场(填入 search.endpoint)\n\n- Koishi(全球):https://registry.koishi.chat/index.json\n- [t4wefan](https://k.ilharp.cc/2611)(大陆):https://registry.koishi.t4wefan.pub/index.json\n- [Lipraty](https://k.ilharp.cc/3530)(大陆):https://koi.nyan.zone/registry/index.json\n- [itzdrli](https://k.ilharp.cc/9975)(全球):https://kp.itzdrli.cc\n- [Q78KG](https://k.ilharp.cc/10042)(全球):https://koishi-registry.yumetsuki.moe/index.json\n\n要浏览更多社区镜像,请访问 [Koishi 论坛上的镜像一览](https://k.ilharp.cc/4000)。";
|
|
173
|
-
interface Config {
|
|
177
|
+
export declare const usage = "\n如果插件市场页面提示「无法连接到插件市场」,则可以选择一个 Koishi 社区提供的镜像地址,填入下方对应的配置项中。\n\n## 插件市场(填入 search.endpoint)\n\n- Koishi(全球):https://registry.koishi.chat/index.json\n- [t4wefan](https://k.ilharp.cc/2611)(大陆):https://registry.koishi.t4wefan.pub/index.json\n- [Lipraty](https://k.ilharp.cc/3530)(大陆):https://koi.nyan.zone/registry/index.json\n- [itzdrli](https://k.ilharp.cc/9975)(全球):https://kp.itzdrli.cc\n- [Q78KG](https://k.ilharp.cc/10042)(全球):https://koishi-registry.yumetsuki.moe/index.json\n\n要浏览更多社区镜像,请访问 [Koishi 论坛上的镜像一览](https://k.ilharp.cc/4000)。";
|
|
178
|
+
export interface Config {
|
|
174
179
|
registry?: Installer.Config;
|
|
175
180
|
search?: MarketProvider$1.Config;
|
|
176
181
|
}
|
|
177
|
-
declare const Config: Schema<Config>;
|
|
178
|
-
declare function apply(ctx: Context, config: Config): void;
|
|
182
|
+
export declare const Config: Schema<Config>;
|
|
183
|
+
export declare function apply(ctx: Context, config: Config): void;
|
|
179
184
|
//#endregion
|
|
180
|
-
export {
|
|
185
|
+
export { DependencyProvider, Installer, RegistryProvider };
|
package/lib/index.mjs
CHANGED
|
@@ -354,21 +354,27 @@ var MarketProvider$1 = class extends MarketProvider {
|
|
|
354
354
|
await this.prepare();
|
|
355
355
|
super.start();
|
|
356
356
|
}
|
|
357
|
-
/**
|
|
358
|
-
|
|
357
|
+
/**
|
|
358
|
+
* 限流(429)、请求超时(abort 后无 response,code 为 ETIMEDOUT)、
|
|
359
|
+
* 服务端瞬态错误(408 / 5xx)可安全重试;allowNotFound 时把 404
|
|
360
|
+
* 也视为瞬态——镜像索引以「302 指向版本化文件」方式发布,切换文件
|
|
361
|
+
* 的部署窗口会出现短时 404。
|
|
362
|
+
*/
|
|
363
|
+
isRetryable(error, allowNotFound = false) {
|
|
359
364
|
if (!this.ctx.http.isError(error)) return false;
|
|
360
|
-
|
|
361
|
-
|
|
365
|
+
if (!error.response) return error.code === "ETIMEDOUT";
|
|
366
|
+
const { status } = error.response;
|
|
367
|
+
return status === 429 || status === 408 || allowNotFound && status === 404 || status !== void 0 && status >= 500;
|
|
362
368
|
}
|
|
363
369
|
/**
|
|
364
370
|
* 带退避重试的 registry 请求。优先遵循服务端的 Retry-After 响应头
|
|
365
371
|
* (秒数),缺省时按基础时长指数退避。
|
|
366
372
|
*/
|
|
367
|
-
async request(http, url, config) {
|
|
373
|
+
async request(http, url, config, allowNotFound = false) {
|
|
368
374
|
for (let attempt = 0;; attempt++) try {
|
|
369
375
|
return await http.get(url, config);
|
|
370
376
|
} catch (error) {
|
|
371
|
-
if (attempt >= maxRetries || !this.isRetryable(error)) throw error;
|
|
377
|
+
if (attempt >= maxRetries || !this.isRetryable(error, allowNotFound)) throw error;
|
|
372
378
|
const retryAfter = Number(error.response?.headers.get("retry-after") ?? "");
|
|
373
379
|
const delay = Number.isFinite(retryAfter) && retryAfter > 0 ? retryAfter * Time.second : retryBaseDelay * 2 ** attempt;
|
|
374
380
|
logger.debug("请求 %s 失败(%s),%d 秒后重试(第 %d 次)", url, error.message, delay / Time.second, attempt + 1);
|
|
@@ -381,7 +387,7 @@ var MarketProvider$1 = class extends MarketProvider {
|
|
|
381
387
|
this.failed = [];
|
|
382
388
|
this.scanner = new Scanner((url, config) => this.request(registry, url, config));
|
|
383
389
|
if (this.http) {
|
|
384
|
-
const result = await this.request(this.http, "");
|
|
390
|
+
const result = await this.request(this.http, "", void 0, true);
|
|
385
391
|
this.scanner.objects = result.objects.filter((object) => !object.ignored);
|
|
386
392
|
this.scanner.total = this.scanner.objects.length;
|
|
387
393
|
if (result.version !== void 0) this.scanner.version = result.version;
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@koishi-ce/plugin-market",
|
|
3
3
|
"description": "Manage your bots and plugins with console",
|
|
4
|
-
"version": "1.0.
|
|
4
|
+
"version": "1.0.12",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.mjs",
|
|
7
7
|
"typings": "lib/index.d.ts",
|
|
@@ -60,9 +60,9 @@
|
|
|
60
60
|
}
|
|
61
61
|
},
|
|
62
62
|
"devDependencies": {
|
|
63
|
-
"@koishi-ce/client": "^1.0
|
|
64
|
-
"@koishi-ce/loader": "^1.0.
|
|
65
|
-
"@koishi-ce/plugin-config": "^1.0
|
|
63
|
+
"@koishi-ce/client": "^1.1.0",
|
|
64
|
+
"@koishi-ce/loader": "^1.0.6",
|
|
65
|
+
"@koishi-ce/plugin-config": "^1.1.0",
|
|
66
66
|
"@koishijs/market": "^4.2.10",
|
|
67
67
|
"@types/semver": "^7.5.8",
|
|
68
68
|
"@vueuse/core": "^14.4.0",
|
|
@@ -139,6 +139,12 @@ let searchResponse: {
|
|
|
139
139
|
total: 0,
|
|
140
140
|
};
|
|
141
141
|
|
|
142
|
+
/** flaky-index 镜像索引桩:已发生的请求计数与计划内的 404 次数。 */
|
|
143
|
+
let flakyIndexHits = 0;
|
|
144
|
+
let flakyIndexMisses = 0;
|
|
145
|
+
/** slow-index 慢响应桩:已发生的请求计数。 */
|
|
146
|
+
let slowIndexHits = 0;
|
|
147
|
+
|
|
142
148
|
/** 声明预期触发 market 域告警(404 / 限流等桩失败路径)的用例:执行期间静默该域。 */
|
|
143
149
|
const itQuiet = (
|
|
144
150
|
name: string,
|
|
@@ -173,6 +179,33 @@ const registryServer = Bun.serve({
|
|
|
173
179
|
}
|
|
174
180
|
return Response.json(searchResponse);
|
|
175
181
|
}
|
|
182
|
+
// 模拟镜像索引的部署窗口:前 flakyIndexMisses 次 404,随后返回完整索引
|
|
183
|
+
if (url.pathname === "/flaky-index") {
|
|
184
|
+
if (flakyIndexHits++ < flakyIndexMisses) {
|
|
185
|
+
return new Response("not found", { status: 404 });
|
|
186
|
+
}
|
|
187
|
+
return Response.json({
|
|
188
|
+
version: "4",
|
|
189
|
+
objects: [
|
|
190
|
+
{
|
|
191
|
+
package: {
|
|
192
|
+
name: "koishi-plugin-demo",
|
|
193
|
+
version: "2.0.0",
|
|
194
|
+
date: "2024-06-01T00:00:00Z",
|
|
195
|
+
keywords: ["koishi", "plugin"],
|
|
196
|
+
},
|
|
197
|
+
},
|
|
198
|
+
],
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
// 模拟镜像索引挂起:延迟超过用例配置的 timeout,驱动超时重试路径
|
|
202
|
+
if (url.pathname === "/slow-index") {
|
|
203
|
+
slowIndexHits++;
|
|
204
|
+
await new Promise((resolve) =>
|
|
205
|
+
setTimeout(resolve, 300),
|
|
206
|
+
);
|
|
207
|
+
return Response.json({ version: "4", objects: [] });
|
|
208
|
+
}
|
|
176
209
|
// 模拟 registry 限流:恒定 429 + 极短的 Retry-After,驱动重试后失败
|
|
177
210
|
if (
|
|
178
211
|
url.pathname.includes("koishi-plugin-ratelimited")
|
|
@@ -973,3 +1006,70 @@ describe("market 进阶链路", () => {
|
|
|
973
1006
|
20000,
|
|
974
1007
|
);
|
|
975
1008
|
});
|
|
1009
|
+
|
|
1010
|
+
describe("镜像索引瞬态重试", () => {
|
|
1011
|
+
/** 搭建配了 search.endpoint 的独立宿主(镜像索引经本测试桩提供)。 */
|
|
1012
|
+
async function createMirrorApp(search: {
|
|
1013
|
+
endpoint: string;
|
|
1014
|
+
timeout?: number;
|
|
1015
|
+
}) {
|
|
1016
|
+
const mirror = new App();
|
|
1017
|
+
mirror.plugin(
|
|
1018
|
+
memory as unknown as typeof memory.default,
|
|
1019
|
+
);
|
|
1020
|
+
mirror.plugin(http);
|
|
1021
|
+
mirror.plugin(
|
|
1022
|
+
FakeConsole as unknown as Plugin.Constructor<TestApp>,
|
|
1023
|
+
);
|
|
1024
|
+
mirror.plugin(FakeLoader);
|
|
1025
|
+
mirror.plugin(market, {
|
|
1026
|
+
registry: {
|
|
1027
|
+
endpoint: `http://127.0.0.1:${registryServer.port}/`,
|
|
1028
|
+
},
|
|
1029
|
+
search,
|
|
1030
|
+
});
|
|
1031
|
+
await mirror.start();
|
|
1032
|
+
return mirror;
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
it("镜像索引部署窗口 404 经退避重试后恢复", async () => {
|
|
1036
|
+
flakyIndexHits = 0;
|
|
1037
|
+
flakyIndexMisses = 2;
|
|
1038
|
+
const mirror = await createMirrorApp({
|
|
1039
|
+
endpoint: `http://127.0.0.1:${registryServer.port}/flaky-index`,
|
|
1040
|
+
});
|
|
1041
|
+
const svc = mirror.get("console.services.market");
|
|
1042
|
+
// start(true) 内部 await prepare:前两次 404,退避重试后第三次成功
|
|
1043
|
+
await svc?.start(true);
|
|
1044
|
+
expect(flakyIndexHits).toBeGreaterThanOrEqual(3);
|
|
1045
|
+
const payload = await svc?.get();
|
|
1046
|
+
expect(Object.keys(payload?.data ?? {})).toEqual([
|
|
1047
|
+
"koishi-plugin-demo",
|
|
1048
|
+
]);
|
|
1049
|
+
expect(payload?.total).toBe(1);
|
|
1050
|
+
await mirror.stop();
|
|
1051
|
+
}, 10000);
|
|
1052
|
+
|
|
1053
|
+
itQuiet(
|
|
1054
|
+
"镜像索引持续超时经重试后优雅失败",
|
|
1055
|
+
async () => {
|
|
1056
|
+
slowIndexHits = 0;
|
|
1057
|
+
const mirror = await createMirrorApp({
|
|
1058
|
+
endpoint: `http://127.0.0.1:${registryServer.port}/slow-index`,
|
|
1059
|
+
timeout: 50,
|
|
1060
|
+
});
|
|
1061
|
+
const svc = mirror.get("console.services.market");
|
|
1062
|
+
// 每次尝试 50ms 即超时,退避 1s + 2s 后仍失败;重试确实发生
|
|
1063
|
+
await svc?.start(true);
|
|
1064
|
+
expect(slowIndexHits).toBeGreaterThanOrEqual(3);
|
|
1065
|
+
// start 末尾的 refresh 会再触发一轮 collect,等待其失败落地
|
|
1066
|
+
await new Promise((resolve) =>
|
|
1067
|
+
setTimeout(resolve, 3600),
|
|
1068
|
+
);
|
|
1069
|
+
const error = svc?.["_error" as keyof typeof svc];
|
|
1070
|
+
expect(error).toBeDefined();
|
|
1071
|
+
await mirror.stop();
|
|
1072
|
+
},
|
|
1073
|
+
15000,
|
|
1074
|
+
);
|
|
1075
|
+
});
|
package/src/node/market.ts
CHANGED
|
@@ -21,8 +21,9 @@ import { MarketProvider as BaseMarketProvider } from "../shared/index.ts";
|
|
|
21
21
|
const logger = new Logger("market");
|
|
22
22
|
|
|
23
23
|
// registry 接口对无认证请求有速率限制:搜索接口(/-/v1/search)超频时
|
|
24
|
-
// 返回 429,collect
|
|
25
|
-
//
|
|
24
|
+
// 返回 429,collect 一旦失败会中断整个市场数据刷新(prepare 置 _error,
|
|
25
|
+
// 页面清空)。对限流、请求超时与 5xx 瞬态错误做指数退避重试;镜像索引
|
|
26
|
+
// 的 404(部署窗口内版本化文件尚未就绪)同样按瞬态处理。
|
|
26
27
|
/** 首次请求失败后的最大重试次数 */
|
|
27
28
|
const maxRetries = 2;
|
|
28
29
|
/** 重试的基础退避时长,按尝试次数指数放大 */
|
|
@@ -63,13 +64,23 @@ class MarketProvider extends BaseMarketProvider {
|
|
|
63
64
|
super.start();
|
|
64
65
|
}
|
|
65
66
|
|
|
66
|
-
/**
|
|
67
|
-
|
|
67
|
+
/**
|
|
68
|
+
* 限流(429)、请求超时(abort 后无 response,code 为 ETIMEDOUT)、
|
|
69
|
+
* 服务端瞬态错误(408 / 5xx)可安全重试;allowNotFound 时把 404
|
|
70
|
+
* 也视为瞬态——镜像索引以「302 指向版本化文件」方式发布,切换文件
|
|
71
|
+
* 的部署窗口会出现短时 404。
|
|
72
|
+
*/
|
|
73
|
+
private isRetryable(
|
|
74
|
+
error: unknown,
|
|
75
|
+
allowNotFound = false,
|
|
76
|
+
): error is HTTP.Error {
|
|
68
77
|
if (!this.ctx.http.isError(error)) return false;
|
|
69
|
-
|
|
78
|
+
if (!error.response) return error.code === "ETIMEDOUT";
|
|
79
|
+
const { status } = error.response;
|
|
70
80
|
return (
|
|
71
81
|
status === 429 ||
|
|
72
82
|
status === 408 ||
|
|
83
|
+
(allowNotFound && status === 404) ||
|
|
73
84
|
(status !== undefined && status >= 500)
|
|
74
85
|
);
|
|
75
86
|
}
|
|
@@ -82,6 +93,7 @@ class MarketProvider extends BaseMarketProvider {
|
|
|
82
93
|
http: HTTP,
|
|
83
94
|
url: string,
|
|
84
95
|
config?: RequestConfig,
|
|
96
|
+
allowNotFound = false,
|
|
85
97
|
): Promise<T> {
|
|
86
98
|
for (let attempt = 0; ; attempt++) {
|
|
87
99
|
try {
|
|
@@ -91,7 +103,7 @@ class MarketProvider extends BaseMarketProvider {
|
|
|
91
103
|
} catch (error) {
|
|
92
104
|
if (
|
|
93
105
|
attempt >= maxRetries ||
|
|
94
|
-
!this.isRetryable(error)
|
|
106
|
+
!this.isRetryable(error, allowNotFound)
|
|
95
107
|
)
|
|
96
108
|
throw error;
|
|
97
109
|
const retryAfter = Number(
|
|
@@ -126,6 +138,8 @@ class MarketProvider extends BaseMarketProvider {
|
|
|
126
138
|
const result = await this.request<SearchResult>(
|
|
127
139
|
this.http,
|
|
128
140
|
"",
|
|
141
|
+
undefined,
|
|
142
|
+
true,
|
|
129
143
|
);
|
|
130
144
|
this.scanner.objects = result.objects.filter(
|
|
131
145
|
(object) => !object.ignored,
|