@koishi-ce/plugin-market 1.0.11 → 1.0.13

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/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,16 @@ 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
- /** 限流(429)与请求超时、服务端瞬态错误(408 / 5xx)可安全重试 */
111
+ /**
112
+ * 限流(429)、请求超时(abort 后无 response,code 为 ETIMEDOUT)、
113
+ * 服务端瞬态错误(408 / 5xx)可安全重试;allowNotFound 时把 404
114
+ * 也视为瞬态——镜像索引以「302 指向版本化文件」方式发布,切换文件
115
+ * 的部署窗口会出现短时 404。
116
+ *
117
+ * 无 code 且无 response 的 HTTP.Error 是 plugin-http 对 fetch 网络层
118
+ * 异常(证书验证失败、连接重置、DNS 等)的统一包装——代理节点切换
119
+ * 等瞬态网络抖动即此形态,重试可自愈。
120
+ */
112
121
  private isRetryable;
113
122
  /**
114
123
  * 带退避重试的 registry 请求。优先遵循服务端的 Retry-After 响应头
@@ -160,8 +169,8 @@ declare module "@koishi-ce/console" {
160
169
  "market/registry"(names: string[]): Promise<Dict<Dict<Pick<RemotePackage, DependencyMetaKey>>>>;
161
170
  }
162
171
  }
163
- declare const name = "market";
164
- declare const inject: {
172
+ export declare const name = "market";
173
+ export declare const inject: {
165
174
  http: {
166
175
  required: boolean;
167
176
  };
@@ -169,12 +178,12 @@ declare const inject: {
169
178
  required: boolean;
170
179
  };
171
180
  };
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 {
181
+ 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)。";
182
+ export interface Config {
174
183
  registry?: Installer.Config;
175
184
  search?: MarketProvider$1.Config;
176
185
  }
177
- declare const Config: Schema<Config>;
178
- declare function apply(ctx: Context, config: Config): void;
186
+ export declare const Config: Schema<Config>;
187
+ export declare function apply(ctx: Context, config: Config): void;
179
188
  //#endregion
180
- export { Config, DependencyProvider, Installer, MarketProvider, RegistryProvider, apply, inject, name, usage };
189
+ export { DependencyProvider, Installer, RegistryProvider };
package/lib/index.mjs CHANGED
@@ -354,21 +354,31 @@ var MarketProvider$1 = class extends MarketProvider {
354
354
  await this.prepare();
355
355
  super.start();
356
356
  }
357
- /** 限流(429)与请求超时、服务端瞬态错误(408 / 5xx)可安全重试 */
358
- isRetryable(error) {
357
+ /**
358
+ * 限流(429)、请求超时(abort 后无 response,code 为 ETIMEDOUT)、
359
+ * 服务端瞬态错误(408 / 5xx)可安全重试;allowNotFound 时把 404
360
+ * 也视为瞬态——镜像索引以「302 指向版本化文件」方式发布,切换文件
361
+ * 的部署窗口会出现短时 404。
362
+ *
363
+ * 无 code 且无 response 的 HTTP.Error 是 plugin-http 对 fetch 网络层
364
+ * 异常(证书验证失败、连接重置、DNS 等)的统一包装——代理节点切换
365
+ * 等瞬态网络抖动即此形态,重试可自愈。
366
+ */
367
+ isRetryable(error, allowNotFound = false) {
359
368
  if (!this.ctx.http.isError(error)) return false;
360
- const { status } = error.response ?? {};
361
- return status === 429 || status === 408 || status !== void 0 && status >= 500;
369
+ if (!error.response) return error.code === void 0 || error.code === "ETIMEDOUT";
370
+ const { status } = error.response;
371
+ return status === 429 || status === 408 || allowNotFound && status === 404 || status !== void 0 && status >= 500;
362
372
  }
363
373
  /**
364
374
  * 带退避重试的 registry 请求。优先遵循服务端的 Retry-After 响应头
365
375
  * (秒数),缺省时按基础时长指数退避。
366
376
  */
367
- async request(http, url, config) {
377
+ async request(http, url, config, allowNotFound = false) {
368
378
  for (let attempt = 0;; attempt++) try {
369
379
  return await http.get(url, config);
370
380
  } catch (error) {
371
- if (attempt >= maxRetries || !this.isRetryable(error)) throw error;
381
+ if (attempt >= maxRetries || !this.isRetryable(error, allowNotFound)) throw error;
372
382
  const retryAfter = Number(error.response?.headers.get("retry-after") ?? "");
373
383
  const delay = Number.isFinite(retryAfter) && retryAfter > 0 ? retryAfter * Time.second : retryBaseDelay * 2 ** attempt;
374
384
  logger.debug("请求 %s 失败(%s),%d 秒后重试(第 %d 次)", url, error.message, delay / Time.second, attempt + 1);
@@ -381,7 +391,7 @@ var MarketProvider$1 = class extends MarketProvider {
381
391
  this.failed = [];
382
392
  this.scanner = new Scanner((url, config) => this.request(registry, url, config));
383
393
  if (this.http) {
384
- const result = await this.request(this.http, "");
394
+ const result = await this.request(this.http, "", void 0, true);
385
395
  this.scanner.objects = result.objects.filter((object) => !object.ignored);
386
396
  this.scanner.total = this.scanner.objects.length;
387
397
  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.11",
4
+ "version": "1.0.13",
5
5
  "type": "module",
6
6
  "main": "lib/index.mjs",
7
7
  "typings": "lib/index.d.ts",
@@ -60,9 +60,10 @@
60
60
  }
61
61
  },
62
62
  "devDependencies": {
63
- "@koishi-ce/client": "^1.0.4",
64
- "@koishi-ce/loader": "^1.0.5",
65
- "@koishi-ce/plugin-config": "^1.0.9",
63
+ "@koishi-ce/plugin-database-memory": "^1.1.0",
64
+ "@koishi-ce/client": "^1.2.3",
65
+ "@koishi-ce/loader": "^1.0.8",
66
+ "@koishi-ce/plugin-config": "^1.1.2",
66
67
  "@koishijs/market": "^4.2.10",
67
68
  "@types/semver": "^7.5.8",
68
69
  "@vueuse/core": "^14.4.0",
@@ -16,9 +16,10 @@ import {
16
16
  rmSync,
17
17
  writeFileSync,
18
18
  } from "node:fs";
19
+ import net from "node:net";
19
20
  import { tmpdir } from "node:os";
20
21
  import { join } from "node:path";
21
- import memory from "@koishijs/plugin-database-memory";
22
+ import memory from "@koishi-ce/plugin-database-memory";
22
23
 
23
24
  /**
24
25
  * market 插件测试:
@@ -139,6 +140,12 @@ let searchResponse: {
139
140
  total: 0,
140
141
  };
141
142
 
143
+ /** flaky-index 镜像索引桩:已发生的请求计数与计划内的 404 次数。 */
144
+ let flakyIndexHits = 0;
145
+ let flakyIndexMisses = 0;
146
+ /** slow-index 慢响应桩:已发生的请求计数。 */
147
+ let slowIndexHits = 0;
148
+
142
149
  /** 声明预期触发 market 域告警(404 / 限流等桩失败路径)的用例:执行期间静默该域。 */
143
150
  const itQuiet = (
144
151
  name: string,
@@ -173,6 +180,33 @@ const registryServer = Bun.serve({
173
180
  }
174
181
  return Response.json(searchResponse);
175
182
  }
183
+ // 模拟镜像索引的部署窗口:前 flakyIndexMisses 次 404,随后返回完整索引
184
+ if (url.pathname === "/flaky-index") {
185
+ if (flakyIndexHits++ < flakyIndexMisses) {
186
+ return new Response("not found", { status: 404 });
187
+ }
188
+ return Response.json({
189
+ version: "4",
190
+ objects: [
191
+ {
192
+ package: {
193
+ name: "koishi-plugin-demo",
194
+ version: "2.0.0",
195
+ date: "2024-06-01T00:00:00Z",
196
+ keywords: ["koishi", "plugin"],
197
+ },
198
+ },
199
+ ],
200
+ });
201
+ }
202
+ // 模拟镜像索引挂起:延迟超过用例配置的 timeout,驱动超时重试路径
203
+ if (url.pathname === "/slow-index") {
204
+ slowIndexHits++;
205
+ await new Promise((resolve) =>
206
+ setTimeout(resolve, 300),
207
+ );
208
+ return Response.json({ version: "4", objects: [] });
209
+ }
176
210
  // 模拟 registry 限流:恒定 429 + 极短的 Retry-After,驱动重试后失败
177
211
  if (
178
212
  url.pathname.includes("koishi-plugin-ratelimited")
@@ -264,8 +298,7 @@ type TestApp = InstanceType<typeof App>;
264
298
 
265
299
  const app = new App();
266
300
 
267
- // 同 admin:CJS 实现配 ESM 声明,nodenext 互操作视图多包一层 default,类型层穿透取真实类
268
- app.plugin(memory as unknown as typeof memory.default);
301
+ app.plugin(memory);
269
302
  app.plugin(http);
270
303
  // Console 基类的 static inject 是 cordis 3 旧形态,与 Plugin.Constructor 期待类型不兼容,仅做类型层转型
271
304
  app.plugin(
@@ -973,3 +1006,135 @@ 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(memory);
1018
+ mirror.plugin(http);
1019
+ mirror.plugin(
1020
+ FakeConsole as unknown as Plugin.Constructor<TestApp>,
1021
+ );
1022
+ mirror.plugin(FakeLoader);
1023
+ mirror.plugin(market, {
1024
+ registry: {
1025
+ endpoint: `http://127.0.0.1:${registryServer.port}/`,
1026
+ },
1027
+ search,
1028
+ });
1029
+ await mirror.start();
1030
+ return mirror;
1031
+ }
1032
+
1033
+ it("镜像索引部署窗口 404 经退避重试后恢复", async () => {
1034
+ flakyIndexHits = 0;
1035
+ flakyIndexMisses = 2;
1036
+ const mirror = await createMirrorApp({
1037
+ endpoint: `http://127.0.0.1:${registryServer.port}/flaky-index`,
1038
+ });
1039
+ const svc = mirror.get("console.services.market");
1040
+ // start(true) 内部 await prepare:前两次 404,退避重试后第三次成功
1041
+ await svc?.start(true);
1042
+ expect(flakyIndexHits).toBeGreaterThanOrEqual(3);
1043
+ const payload = await svc?.get();
1044
+ expect(Object.keys(payload?.data ?? {})).toEqual([
1045
+ "koishi-plugin-demo",
1046
+ ]);
1047
+ expect(payload?.total).toBe(1);
1048
+ await mirror.stop();
1049
+ }, 10000);
1050
+
1051
+ itQuiet(
1052
+ "镜像索引持续超时经重试后优雅失败",
1053
+ async () => {
1054
+ slowIndexHits = 0;
1055
+ const mirror = await createMirrorApp({
1056
+ endpoint: `http://127.0.0.1:${registryServer.port}/slow-index`,
1057
+ timeout: 50,
1058
+ });
1059
+ const svc = mirror.get("console.services.market");
1060
+ // 每次尝试 50ms 即超时,退避 1s + 2s 后仍失败;重试确实发生
1061
+ await svc?.start(true);
1062
+ expect(slowIndexHits).toBeGreaterThanOrEqual(3);
1063
+ // start 末尾的 refresh 会再触发一轮 collect,等待其失败落地
1064
+ await new Promise((resolve) =>
1065
+ setTimeout(resolve, 3600),
1066
+ );
1067
+ const error = svc?.["_error" as keyof typeof svc];
1068
+ expect(error).toBeDefined();
1069
+ await mirror.stop();
1070
+ },
1071
+ 15000,
1072
+ );
1073
+
1074
+ // 裸 TCP 桩:前 planned 次请求收到请求头后直接销毁连接(复现
1075
+ // 证书验证失败 / 连接重置等 fetch 网络层瞬态错误),随后回正常索引
1076
+ function startFlakyTcpServer(planned: number) {
1077
+ let hits = 0;
1078
+ const server = net.createServer((socket) => {
1079
+ socket.on("data", () => {
1080
+ hits++;
1081
+ if (hits <= planned) {
1082
+ socket.destroy();
1083
+ return;
1084
+ }
1085
+ const body = JSON.stringify({
1086
+ version: "4",
1087
+ objects: [
1088
+ {
1089
+ package: {
1090
+ name: "koishi-plugin-demo",
1091
+ version: "2.0.0",
1092
+ date: "2024-06-01T00:00:00Z",
1093
+ keywords: ["koishi", "plugin"],
1094
+ },
1095
+ },
1096
+ ],
1097
+ });
1098
+ socket.end(
1099
+ `HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: ${body.length}\r\nConnection: close\r\n\r\n${body}`,
1100
+ );
1101
+ });
1102
+ });
1103
+ return new Promise<{
1104
+ port: number;
1105
+ hits: () => number;
1106
+ close: () => void;
1107
+ }>((resolve) => {
1108
+ server.listen(0, "127.0.0.1", () => {
1109
+ const { port } =
1110
+ server.address() as net.AddressInfo;
1111
+ resolve({
1112
+ port,
1113
+ hits: () => hits,
1114
+ close: () => {
1115
+ server.close();
1116
+ },
1117
+ });
1118
+ });
1119
+ });
1120
+ }
1121
+
1122
+ it("网络层瞬态错误(连接重置)经退避重试后恢复", async () => {
1123
+ const stub = await startFlakyTcpServer(2);
1124
+ const mirror = await createMirrorApp({
1125
+ endpoint: `http://127.0.0.1:${stub.port}/`,
1126
+ });
1127
+ const svc = mirror.get("console.services.market");
1128
+ // 前两次连接被重置(fetch 抛 TypeError,plugin-http 包装为无 code
1129
+ // 无 response 的 HTTP.Error),退避重试后第三次成功
1130
+ await svc?.start(true);
1131
+ expect(stub.hits()).toBeGreaterThanOrEqual(3);
1132
+ const payload = await svc?.get();
1133
+ expect(Object.keys(payload?.data ?? {})).toEqual([
1134
+ "koishi-plugin-demo",
1135
+ ]);
1136
+ expect(payload?.total).toBe(1);
1137
+ await mirror.stop();
1138
+ stub.close();
1139
+ }, 10000);
1140
+ });
@@ -174,7 +174,7 @@ class Installer extends Service {
174
174
  Promise<Dict<Pick<RemotePackage, DependencyMetaKey>>>
175
175
  > = {};
176
176
  private manifest: LocalPackage;
177
- private declare depTask: Promise<Dict<Dependency>>;
177
+ declare private depTask: Promise<Dict<Dependency>>;
178
178
  private flushData: () => void;
179
179
 
180
180
  override config: Installer.Config;
@@ -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 首页搜索一旦失败会中断整个市场数据刷新(prepare
25
- // 置 _error,页面清空)。对限流与 5xx 瞬态错误做指数退避重试。
24
+ // 返回 429,collect 一旦失败会中断整个市场数据刷新(prepare 置 _error,
25
+ // 页面清空)。对限流、请求超时、fetch 网络层瞬态错误与 5xx 做指数退避
26
+ // 重试;镜像索引的 404(部署窗口内版本化文件尚未就绪)同样按瞬态处理。
26
27
  /** 首次请求失败后的最大重试次数 */
27
28
  const maxRetries = 2;
28
29
  /** 重试的基础退避时长,按尝试次数指数放大 */
@@ -63,13 +64,31 @@ class MarketProvider extends BaseMarketProvider {
63
64
  super.start();
64
65
  }
65
66
 
66
- /** 限流(429)与请求超时、服务端瞬态错误(408 / 5xx)可安全重试 */
67
- private isRetryable(error: unknown): error is HTTP.Error {
67
+ /**
68
+ * 限流(429)、请求超时(abort 后无 response,code 为 ETIMEDOUT)、
69
+ * 服务端瞬态错误(408 / 5xx)可安全重试;allowNotFound 时把 404
70
+ * 也视为瞬态——镜像索引以「302 指向版本化文件」方式发布,切换文件
71
+ * 的部署窗口会出现短时 404。
72
+ *
73
+ * 无 code 且无 response 的 HTTP.Error 是 plugin-http 对 fetch 网络层
74
+ * 异常(证书验证失败、连接重置、DNS 等)的统一包装——代理节点切换
75
+ * 等瞬态网络抖动即此形态,重试可自愈。
76
+ */
77
+ private isRetryable(
78
+ error: unknown,
79
+ allowNotFound = false,
80
+ ): error is HTTP.Error {
68
81
  if (!this.ctx.http.isError(error)) return false;
69
- const { status } = error.response ?? {};
82
+ if (!error.response)
83
+ return (
84
+ error.code === undefined ||
85
+ error.code === "ETIMEDOUT"
86
+ );
87
+ const { status } = error.response;
70
88
  return (
71
89
  status === 429 ||
72
90
  status === 408 ||
91
+ (allowNotFound && status === 404) ||
73
92
  (status !== undefined && status >= 500)
74
93
  );
75
94
  }
@@ -82,6 +101,7 @@ class MarketProvider extends BaseMarketProvider {
82
101
  http: HTTP,
83
102
  url: string,
84
103
  config?: RequestConfig,
104
+ allowNotFound = false,
85
105
  ): Promise<T> {
86
106
  for (let attempt = 0; ; attempt++) {
87
107
  try {
@@ -91,7 +111,7 @@ class MarketProvider extends BaseMarketProvider {
91
111
  } catch (error) {
92
112
  if (
93
113
  attempt >= maxRetries ||
94
- !this.isRetryable(error)
114
+ !this.isRetryable(error, allowNotFound)
95
115
  )
96
116
  throw error;
97
117
  const retryAfter = Number(
@@ -126,6 +146,8 @@ class MarketProvider extends BaseMarketProvider {
126
146
  const result = await this.request<SearchResult>(
127
147
  this.http,
128
148
  "",
149
+ undefined,
150
+ true,
129
151
  );
130
152
  this.scanner.objects = result.objects.filter(
131
153
  (object) => !object.ignored,