@jacksontian/kite-server 0.1.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.
@@ -0,0 +1,37 @@
1
+ # kite 云端 server 配置模板。
2
+ # 复制为实际配置文件(如 server.yaml)后填写,启动时显式传入:
3
+ # node server.js --config /path/to/server.yaml
4
+ # 实际配置文件含密钥,不要提交到 git。
5
+
6
+ # 必填:两端共享的 HMAC 签名密钥(永不在网络上传输)。生成:
7
+ # node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
8
+ apiSecret: 换成你生成的密钥
9
+
10
+ # 监听端口
11
+ port: 8787
12
+
13
+ # 持久化目录;建议写绝对路径;相对路径相对启动工作目录解析,
14
+ # 支持 ~ 展开;留空/删掉该行则默认 ~/.kite/data
15
+ dataDir: ./data
16
+
17
+ # 终态(done/failed/canceled)任务最多保留条数
18
+ maxTasks: 200
19
+
20
+ # running 任务超过该秒数且 Worker 离线则判失败(pty 交互会话不受此限)
21
+ staleRunningSec: 600
22
+
23
+ # 签名时间窗(秒);两端时钟漂移大时可调宽
24
+ sigWindowSec: 300
25
+
26
+ # pty 会话空闲(Worker 断开且无控制台输入)回收秒数
27
+ ptyIdleSec: 90
28
+
29
+ # 钉钉群机器人 webhook;配置后任务终态事件以 markdown 推送到群,留空则完全静默
30
+ imWebhook: ""
31
+
32
+ # 机器人启用“加签”安全设置时的密钥(SEC 开头);关键词/白名单模式留空
33
+ imSecret: ""
34
+
35
+ # 触发 IM 推送的事件集合,可选值:done / failed / canceled
36
+ imNotifyEvents:
37
+ - failed
package/config.js ADDED
@@ -0,0 +1,205 @@
1
+ /**
2
+ * 云端 server 配置加载与校验。
3
+ *
4
+ * 只认 --config 指定的 YAML 配置文件(不再读取任何环境变量):
5
+ * node server.js --config /path/to/server.yaml
6
+ * 结构与类型校验由 zod schema 完成(CONFIG_SCHEMA):未知配置项、
7
+ * 类型错误、缺必填项一次性报全后 exit 1;dataDir 的可创建/可写校验
8
+ * 是运行时行为,仍在校验通过后单独执行。模板见包内 config.example.yaml。
9
+ */
10
+
11
+ import fs from 'node:fs';
12
+ import os from 'node:os';
13
+ import path from 'node:path';
14
+ import YAML from 'yaml';
15
+ import { z } from 'zod';
16
+
17
+ /** 解析启动参数;--config 必填,缺失/重复/取不到值立即退出 */
18
+ function parseArgs() {
19
+ const args = process.argv.slice(2);
20
+ const values = [];
21
+ for (let i = 0; i < args.length; i++) {
22
+ if (args[i] === '--config' || args[i] === '-c') {
23
+ values.push(args[i + 1]);
24
+ i++;
25
+ } else if (args[i].startsWith('--config=')) {
26
+ values.push(args[i].slice('--config='.length));
27
+ }
28
+ }
29
+ if (values.length === 0) {
30
+ console.error('[server] 缺少启动参数:node server.js --config <配置文件路径>');
31
+ console.error('[server] 配置模板见包内 config.example.yaml');
32
+ process.exit(1);
33
+ }
34
+ if (values.length > 1) {
35
+ console.error('[server] --config 只能指定一次');
36
+ process.exit(1);
37
+ }
38
+ const file = String(values[0] ?? '').trim();
39
+ if (!file || file.startsWith('-')) {
40
+ console.error('[server] --config 后需要给出配置文件路径');
41
+ process.exit(1);
42
+ }
43
+ return path.resolve(file);
44
+ }
45
+
46
+ function fail(errors, tag) {
47
+ console.error(`[${tag}] 配置错误,无法启动:`);
48
+ for (const e of errors) console.error(' - ' + e);
49
+ console.error(`[${tag}] 请检查 --config 指定的 YAML 文件,模板见包内 config.example.yaml`);
50
+ process.exit(1);
51
+ }
52
+
53
+ function isPlainObject(v) {
54
+ return v !== null && typeof v === 'object' && !Array.isArray(v);
55
+ }
56
+
57
+ /** 可选字符串:缺省/给 null 都按空串处理,统一 trim
58
+ * (zod v4 中 union(z.undefined()) 不能让对象键变可选,须用 .optional()) */
59
+ const optStr = (errMsg) =>
60
+ z.string({ error: errMsg }).trim().optional().nullable().transform((v) => v ?? '');
61
+
62
+ /** 把 zod issues 转成错误行:未知配置项逐条展开并附该层级可用项清单;
63
+ * custom/各叶子的报错文案均已自带字段名,直接透传 */
64
+ function formatIssues(issues, knownByPath) {
65
+ const out = [];
66
+ for (const iss of issues) {
67
+ if (iss.code === 'unrecognized_keys') {
68
+ const known = knownByPath[iss.path.join('.')] || [];
69
+ for (const key of iss.keys) out.push(`未知配置项 "${key}"(可用:${known.join(', ')})`);
70
+ continue;
71
+ }
72
+ out.push(iss.message);
73
+ }
74
+ return out;
75
+ }
76
+
77
+ /** 展开开头的 ~ 为用户主目录(YAML 不会替我们做这件事) */
78
+ function expandHome(p) {
79
+ if (p === '~') return os.homedir();
80
+ if (p.startsWith('~/')) return path.join(os.homedir(), p.slice(2));
81
+ return p;
82
+ }
83
+
84
+ const SECRET_HINT =
85
+ '(两端共享的签名密钥;生成:node -e "console.log(require(\'crypto\').randomBytes(32).toString(\'hex\'))")';
86
+
87
+ const CONFIG_SCHEMA = z.strictObject({
88
+ apiSecret: z
89
+ .string({
90
+ error: (iss) =>
91
+ iss.input === undefined || iss.input === null
92
+ ? `缺少 apiSecret${SECRET_HINT}`
93
+ : 'apiSecret 必须是字符串(建议加引号,避免被 YAML 解析为数字)',
94
+ })
95
+ .trim()
96
+ .min(1, `缺少 apiSecret${SECRET_HINT}`),
97
+ port: z.coerce
98
+ .number({ error: 'port 必须是数字' })
99
+ .int('port 必须是整数')
100
+ .min(1, 'port 超出范围 [1, 65535]')
101
+ .max(65535, 'port 超出范围 [1, 65535]')
102
+ .default(8787),
103
+ // 语义(~ 展开 / 相对启动工作目录 / 缺省 ~/.kite/data / 可写校验)
104
+ // 无法纯声明式表达,schema 只约束类型,解析后命令式处理
105
+ dataDir: optStr('dataDir 必须是字符串'),
106
+ maxTasks: z.coerce.number({ error: 'maxTasks 必须是数字' }).positive('maxTasks 必须是正数').default(200),
107
+ staleRunningSec: z.coerce
108
+ .number({ error: 'staleRunningSec 必须是数字' })
109
+ .positive('staleRunningSec 必须是正数')
110
+ .default(600),
111
+ sigWindowSec: z.coerce
112
+ .number({ error: 'sigWindowSec 必须是数字' })
113
+ .positive('sigWindowSec 必须是正数')
114
+ .default(300),
115
+ ptyIdleSec: z.coerce
116
+ .number({ error: 'ptyIdleSec 必须是数字' })
117
+ .positive('ptyIdleSec 必须是正数')
118
+ .default(90),
119
+ imWebhook: optStr('imWebhook 必须是字符串'),
120
+ imSecret: optStr('imSecret 必须是字符串'),
121
+ imNotifyEvents: z
122
+ .array(
123
+ // 与原实现一致:元素宽容转字符串后归一化小写再比枚举
124
+ z.coerce.string({ error: 'imNotifyEvents 元素必须是字符串' }).trim().toLowerCase().pipe(
125
+ z.enum(['done', 'failed', 'canceled'], {
126
+ error: (iss) => `imNotifyEvents 含非法事件 "${iss.input}"(可用:done|failed|canceled)`,
127
+ }),
128
+ ),
129
+ { error: 'imNotifyEvents 必须是数组(如 [failed, done])' },
130
+ )
131
+ .default(['failed']),
132
+ });
133
+
134
+ /** 加载并校验配置,返回归一化后的常量对象 */
135
+ export async function loadServerConfig() {
136
+ const tag = 'server';
137
+ const configFile = parseArgs();
138
+
139
+ let raw;
140
+ try {
141
+ raw = await fs.promises.readFile(configFile, 'utf8');
142
+ } catch (err) {
143
+ console.error(`[${tag}] 无法读取配置文件 ${configFile}: ${err.message}`);
144
+ process.exit(1);
145
+ }
146
+
147
+ const doc = YAML.parseDocument(raw);
148
+ if (doc.errors.length) {
149
+ console.error(`[${tag}] 配置文件 YAML 语法错误(${configFile}):`);
150
+ for (const e of doc.errors) console.error(' - ' + e.message);
151
+ process.exit(1);
152
+ }
153
+ const cfg = doc.toJS();
154
+ if (!isPlainObject(cfg)) {
155
+ console.error(`[${tag}] 配置文件内容必须是一个键值映射(${configFile})`);
156
+ process.exit(1);
157
+ }
158
+
159
+ const parsed = CONFIG_SCHEMA.safeParse(cfg);
160
+ if (!parsed.success) {
161
+ fail(formatIssues(parsed.error.issues, { '': Object.keys(CONFIG_SCHEMA.shape) }), tag);
162
+ }
163
+ const v = parsed.data;
164
+ const errors = [];
165
+
166
+ // 未配置时默认主目录下的固定位置,与配置文件位置、启动目录都解耦;
167
+ // 相对路径相对启动工作目录解析(path.resolve 默认行为),建议写绝对路径
168
+ const dataDir = path.resolve(
169
+ expandHome(v.dataDir || path.join(os.homedir(), '.kite', 'data')),
170
+ );
171
+ // 启动前校验可创建、可写:误配在这里一次性报清,
172
+ // 而不是运行中落盘失败才静默降级(只打日志、数据留在内存)
173
+ try {
174
+ await fs.promises.mkdir(dataDir, { recursive: true });
175
+ const probe = path.join(dataDir, '.rw-write-probe');
176
+ await fs.promises.writeFile(probe, '');
177
+ await fs.promises.rm(probe);
178
+ } catch (err) {
179
+ if (err.code === 'EEXIST' || err.code === 'ENOTDIR') {
180
+ errors.push(`dataDir 指向的路径已存在但不是目录:${dataDir}`);
181
+ } else {
182
+ errors.push(`dataDir 无法创建或不可写(${dataDir}):${err.message};请确认运行用户对该目录有读写权限`);
183
+ }
184
+ }
185
+ if (errors.length) fail(errors, tag);
186
+
187
+ // 典型误配提示:配了加签密钥但没配 webhook,secret 会被静默忽略
188
+ if (v.imSecret && !v.imWebhook) {
189
+ console.warn(`[${tag}] 已配置 imSecret 但 imWebhook 为空,加签密钥将被忽略`);
190
+ }
191
+
192
+ return {
193
+ configFile,
194
+ apiSecret: v.apiSecret,
195
+ port: v.port,
196
+ dataDir,
197
+ maxTasks: v.maxTasks,
198
+ staleRunningMs: v.staleRunningSec * 1000,
199
+ sigWindowSec: v.sigWindowSec,
200
+ ptyIdleMs: v.ptyIdleSec * 1000,
201
+ imWebhook: v.imWebhook,
202
+ imSecret: v.imSecret,
203
+ imNotifyEvents: new Set(v.imNotifyEvents),
204
+ };
205
+ }
package/package.json ADDED
@@ -0,0 +1,37 @@
1
+ {
2
+ "name": "@jacksontian/kite-server",
3
+ "version": "0.1.0",
4
+ "description": "kite 云端信箱 API:HMAC 签名鉴权、任务队列、WebSocket 实时推送、手机端 Web 控制台",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "engines": {
8
+ "node": ">=24.0.0"
9
+ },
10
+ "bin": {
11
+ "kites": "server.js"
12
+ },
13
+ "main": "server.js",
14
+ "files": [
15
+ "server.js",
16
+ "config.js",
17
+ "web/",
18
+ "config.example.yaml"
19
+ ],
20
+ "keywords": [
21
+ "remote",
22
+ "agent",
23
+ "mailbox",
24
+ "websocket",
25
+ "hmac"
26
+ ],
27
+ "dependencies": {
28
+ "dingbot": "^2.1.0",
29
+ "ws": "^8.21.3",
30
+ "yaml": "^2.8.2",
31
+ "zod": "^4.4.3"
32
+ },
33
+ "publishConfig": {
34
+ "registry": "https://registry.npmjs.org/",
35
+ "access": "public"
36
+ }
37
+ }