@koishi-ce/koishi 1.0.0 → 1.0.1

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/cli/index.mjs CHANGED
@@ -7,7 +7,7 @@ var package_default = {
7
7
  name: "@koishi-ce/koishi",
8
8
  type: "module",
9
9
  description: "Cross-Platform Chatbot Framework Made with Love",
10
- version: "1.0.0",
10
+ version: "1.0.1",
11
11
  main: "lib/index.mjs",
12
12
  module: "lib/index.mjs",
13
13
  types: "lib/index.d.ts",
@@ -1,7 +1,10 @@
1
1
  import { t as __exportAll } from "../rolldown-runtime-BBjsoOtd.mjs";
2
2
  import { t as Loader } from "../src-1HtO5Rx4.mjs";
3
- import Loader$1 from "@koishi-ce/loader";
3
+ import Loader$1, { resolvePlugin } from "@koishi-ce/loader";
4
4
  import { Context, Logger, Schema, Time, defineProperty } from "@koishi-ce/core";
5
+ import { existsSync, readFileSync } from "node:fs";
6
+ import net from "node:net";
7
+ import { dirname, resolve } from "node:path";
5
8
  export * from "@koishi-ce/loader";
6
9
  export * from "@koishi-ce/core";
7
10
  //#region src/worker/daemon.ts
@@ -132,12 +135,88 @@ process.on("uncaughtException", handleException);
132
135
  process.on("unhandledRejection", (error) => {
133
136
  new Logger("app").warn(error);
134
137
  });
138
+ /** HTTP 服务器插件的包名集合(端口预检的识别目标,覆盖本仓 vendored 包与上下游命名) */
139
+ const serverPackages = /* @__PURE__ */ new Set([
140
+ "@koishi-ce/plugin-server",
141
+ "@koishijs/plugin-server",
142
+ "@cordisjs/plugin-server"
143
+ ]);
144
+ /** 从模块入口路径向上查找最近的 package.json,返回其 name 字段 */
145
+ function locatePackageName(filename) {
146
+ let dir = dirname(filename);
147
+ while (true) {
148
+ const file = resolve(dir, "package.json");
149
+ if (existsSync(file)) try {
150
+ const manifest = JSON.parse(readFileSync(file, "utf8"));
151
+ if (typeof manifest?.name === "string") return manifest.name;
152
+ } catch {}
153
+ const parent = dirname(dir);
154
+ if (parent === dir) return void 0;
155
+ dir = parent;
156
+ }
157
+ }
158
+ /**
159
+ * 遍历插件配置表(含 group 嵌套),收集服务器插件声明的端口区间。
160
+ * 引用键格式与 loader 一致:首个冒号前为插件名;`$` 开头为元属性。
161
+ */
162
+ function collectServerPorts(plugins, baseDir, out) {
163
+ for (const [key, source] of Object.entries(plugins)) {
164
+ if (key.startsWith("$") || source === null || typeof source !== "object") continue;
165
+ const [name = ""] = key.split(":", 1);
166
+ if (name === "group") {
167
+ collectServerPorts(source, baseDir, out);
168
+ continue;
169
+ }
170
+ try {
171
+ const pkgName = locatePackageName(resolvePlugin(name, baseDir));
172
+ if (!pkgName || !serverPackages.has(pkgName)) continue;
173
+ const { host, port, maxPort } = source;
174
+ if (typeof port !== "number") continue;
175
+ out.push({
176
+ host: typeof host === "string" ? host : "127.0.0.1",
177
+ port,
178
+ maxPort: typeof maxPort === "number" ? maxPort : port
179
+ });
180
+ } catch {}
181
+ }
182
+ }
183
+ /** 探测端口当前是否可绑定(未被占用返回 true) */
184
+ function probePort(port, host) {
185
+ return new Promise((promiseResolve) => {
186
+ const server = net.createServer();
187
+ server.once("error", () => promiseResolve(false));
188
+ server.once("listening", () => server.close(() => promiseResolve(true)));
189
+ server.listen(port, host);
190
+ });
191
+ }
192
+ /**
193
+ * 端口预检:server 插件在应用启动期绑定失败会以 cordis 错误事件暴露,
194
+ * 连锁触发依赖 server 服务的全部插件 dispose,堆栈噪音远超问题本身;
195
+ * 这里在创建应用前先行探测,端口区间全部被占时只输出一行提示并以
196
+ * 退出码 1 结束(父守护进程未收到 start 消息,随之干净退出)。
197
+ */
198
+ async function checkPorts(plugins, baseDir) {
199
+ const ports = [];
200
+ collectServerPorts(plugins, baseDir, ports);
201
+ for (const { host, port, maxPort } of ports) {
202
+ let available = false;
203
+ for (let current = port; current <= maxPort; current++) if (await probePort(current, host)) {
204
+ available = true;
205
+ break;
206
+ }
207
+ if (available) continue;
208
+ const range = port === maxPort ? `${port}` : `${port}-${maxPort}`;
209
+ new Logger("app").error(`端口 ${range} 已被占用(可能已有 Koishi 实例在运行),启动中止`);
210
+ process.exit(1);
211
+ }
212
+ }
135
213
  /** 应用启动主流程 */
136
214
  async function start() {
137
215
  const loader = new Loader$1();
138
216
  await loader.init(process.env["KOISHI_CONFIG_FILE"]);
139
217
  const config = await loader.readConfig(true);
140
218
  prepare(config.logger);
219
+ await checkPorts(config.plugins ?? {}, loader.baseDir);
141
220
  if (config.timezoneOffset !== void 0) Time.setTimezoneOffset(config.timezoneOffset);
142
221
  if (config.stackTraceLimit !== void 0) Error.stackTraceLimit = config.stackTraceLimit;
143
222
  const app = await loader.createApp();
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@koishi-ce/koishi",
3
3
  "type": "module",
4
4
  "description": "Cross-Platform Chatbot Framework Made with Love",
5
- "version": "1.0.0",
5
+ "version": "1.0.1",
6
6
  "main": "lib/index.mjs",
7
7
  "module": "lib/index.mjs",
8
8
  "types": "lib/index.d.ts",
@@ -62,7 +62,7 @@
62
62
  },
63
63
  "dependencies": {
64
64
  "@koishi-ce/core": "^1.0.0",
65
- "@koishi-ce/loader": "^1.0.0",
65
+ "@koishi-ce/loader": "^1.0.1",
66
66
  "@koishi-ce/plugin-http": "^1.0.0",
67
67
  "@koishi-ce/plugin-proxy-agent": "^1.0.0",
68
68
  "@koishi-ce/plugin-server": "^1.0.0",
@@ -7,8 +7,11 @@
7
7
  * 由父进程依据退出码决定是否重启。
8
8
  */
9
9
 
10
+ import { existsSync, readFileSync } from "node:fs";
11
+ import net from "node:net";
12
+ import { dirname, resolve } from "node:path";
10
13
  import { Context, type Dict, Logger, Schema, Time } from "@koishi-ce/core";
11
- import Loader from "@koishi-ce/loader";
14
+ import Loader, { resolvePlugin } from "@koishi-ce/loader";
12
15
  import * as daemon from "./daemon.ts";
13
16
  import * as logger from "./logger.ts";
14
17
 
@@ -65,6 +68,101 @@ process.on("unhandledRejection", (error) => {
65
68
  new Logger("app").warn(error);
66
69
  });
67
70
 
71
+ /** HTTP 服务器插件的包名集合(端口预检的识别目标,覆盖本仓 vendored 包与上下游命名) */
72
+ const serverPackages = new Set([
73
+ "@koishi-ce/plugin-server",
74
+ "@koishijs/plugin-server",
75
+ "@cordisjs/plugin-server",
76
+ ]);
77
+
78
+ /** 服务器插件声明的待检端口区间 */
79
+ interface ServerPort {
80
+ host: string;
81
+ port: number;
82
+ maxPort: number;
83
+ }
84
+
85
+ /** 从模块入口路径向上查找最近的 package.json,返回其 name 字段 */
86
+ function locatePackageName(filename: string): string | undefined {
87
+ let dir = dirname(filename);
88
+ while (true) {
89
+ const file = resolve(dir, "package.json");
90
+ if (existsSync(file)) {
91
+ try {
92
+ const manifest = JSON.parse(readFileSync(file, "utf8"));
93
+ if (typeof manifest?.name === "string") return manifest.name;
94
+ } catch {}
95
+ }
96
+ const parent = dirname(dir);
97
+ if (parent === dir) return undefined;
98
+ dir = parent;
99
+ }
100
+ }
101
+
102
+ /**
103
+ * 遍历插件配置表(含 group 嵌套),收集服务器插件声明的端口区间。
104
+ * 引用键格式与 loader 一致:首个冒号前为插件名;`$` 开头为元属性。
105
+ */
106
+ function collectServerPorts(plugins: Dict, baseDir: string, out: ServerPort[]) {
107
+ for (const [key, source] of Object.entries(plugins)) {
108
+ if (key.startsWith("$") || source === null || typeof source !== "object") {
109
+ continue;
110
+ }
111
+ const [name = ""] = key.split(":", 1);
112
+ if (name === "group") {
113
+ collectServerPorts(source as Dict, baseDir, out);
114
+ continue;
115
+ }
116
+ try {
117
+ const pkgName = locatePackageName(resolvePlugin(name, baseDir));
118
+ if (!pkgName || !serverPackages.has(pkgName)) continue;
119
+ const { host, port, maxPort } = source as Record<string, unknown>;
120
+ if (typeof port !== "number") continue;
121
+ out.push({
122
+ host: typeof host === "string" ? host : "127.0.0.1",
123
+ port,
124
+ maxPort: typeof maxPort === "number" ? maxPort : port,
125
+ });
126
+ } catch {}
127
+ }
128
+ }
129
+
130
+ /** 探测端口当前是否可绑定(未被占用返回 true) */
131
+ function probePort(port: number, host: string) {
132
+ return new Promise<boolean>((promiseResolve) => {
133
+ const server = net.createServer();
134
+ server.once("error", () => promiseResolve(false));
135
+ server.once("listening", () => server.close(() => promiseResolve(true)));
136
+ server.listen(port, host);
137
+ });
138
+ }
139
+
140
+ /**
141
+ * 端口预检:server 插件在应用启动期绑定失败会以 cordis 错误事件暴露,
142
+ * 连锁触发依赖 server 服务的全部插件 dispose,堆栈噪音远超问题本身;
143
+ * 这里在创建应用前先行探测,端口区间全部被占时只输出一行提示并以
144
+ * 退出码 1 结束(父守护进程未收到 start 消息,随之干净退出)。
145
+ */
146
+ async function checkPorts(plugins: Dict, baseDir: string) {
147
+ const ports: ServerPort[] = [];
148
+ collectServerPorts(plugins, baseDir, ports);
149
+ for (const { host, port, maxPort } of ports) {
150
+ let available = false;
151
+ for (let current = port; current <= maxPort; current++) {
152
+ if (await probePort(current, host)) {
153
+ available = true;
154
+ break;
155
+ }
156
+ }
157
+ if (available) continue;
158
+ const range = port === maxPort ? `${port}` : `${port}-${maxPort}`;
159
+ new Logger("app").error(
160
+ `端口 ${range} 已被占用(可能已有 Koishi 实例在运行),启动中止`,
161
+ );
162
+ process.exit(1);
163
+ }
164
+ }
165
+
68
166
  /** 应用启动主流程 */
69
167
  async function start() {
70
168
  const loader = new Loader();
@@ -72,6 +170,9 @@ async function start() {
72
170
  const config = await loader.readConfig(true);
73
171
  logger.prepare(config.logger);
74
172
 
173
+ // 端口预检须在日志配置生效后、应用创建前执行
174
+ await checkPorts(config.plugins ?? {}, loader.baseDir);
175
+
75
176
  if (config.timezoneOffset !== undefined) {
76
177
  Time.setTimezoneOffset(config.timezoneOffset);
77
178
  }