@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/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
- /** 限流(429)与请求超时、服务端瞬态错误(408 / 5xx)可安全重试 */
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 { Config, DependencyProvider, Installer, MarketProvider, RegistryProvider, apply, inject, name, usage };
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
- /** 限流(429)与请求超时、服务端瞬态错误(408 / 5xx)可安全重试 */
358
- isRetryable(error) {
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
- const { status } = error.response ?? {};
361
- return status === 429 || status === 408 || status !== void 0 && status >= 500;
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.10",
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.3",
64
- "@koishi-ce/loader": "^1.0.5",
65
- "@koishi-ce/plugin-config": "^1.0.9",
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
+ });
@@ -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
+ // 页面清空)。对限流、请求超时与 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
- /** 限流(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
+ private isRetryable(
74
+ error: unknown,
75
+ allowNotFound = false,
76
+ ): error is HTTP.Error {
68
77
  if (!this.ctx.http.isError(error)) return false;
69
- const { status } = error.response ?? {};
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,