@dyyz1993/create-agent 2.0.0 → 2.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.
Files changed (141) hide show
  1. package/package.json +1 -1
  2. package/src/lib/copy.ts +23 -1
  3. package/templates/agent/eslint.config.mjs +15 -2
  4. package/templates/agent/package.json +10 -2
  5. package/templates/agent/src/__tests__/hybrid-mode.test.ts +126 -0
  6. package/templates/agent/src/__tests__/server-config-security.test.ts +55 -0
  7. package/templates/agent/src/bun/index.ts +96 -41
  8. package/templates/agent/src/bun/three.d.ts +1 -0
  9. package/templates/agent/src/gateway/__tests__/ws-handler-token.test.ts +118 -0
  10. package/templates/agent/src/gateway/http-routes.ts +1 -1
  11. package/templates/agent/src/gateway/ws-handler.ts +84 -56
  12. package/templates/agent/src/mainview/__tests__/i18n/i18n.test.ts +1 -0
  13. package/templates/agent/src/mainview/__tests__/setup.ts +32 -29
  14. package/templates/agent/src/mainview/__tests__/theme-variables.test.ts +36 -0
  15. package/templates/agent/src/mainview/components/common/ErrorBoundary.tsx +1 -58
  16. package/templates/agent/src/mainview/components/common/LanguageSwitcher.tsx +1 -0
  17. package/templates/agent/src/mainview/components/common/ThemeToggle.tsx +1 -0
  18. package/templates/agent/src/mainview/components/feed/FeedPanel.tsx +232 -210
  19. package/templates/agent/src/mainview/components/todo/TodoPanel.tsx +149 -122
  20. package/templates/agent/src/mainview/lib/api-client.ts +182 -172
  21. package/templates/agent/src/server-config.ts +34 -32
  22. package/templates/agent/src/server.ts +43 -54
  23. package/templates/agent/src/shared/handlers/__tests__/chat-concurrency.test.ts +127 -0
  24. package/templates/agent/src/shared/handlers/__tests__/handler-isolation.test.ts +244 -0
  25. package/templates/agent/src/shared/handlers/bash.ts +65 -65
  26. package/templates/agent/src/shared/handlers/chat.ts +189 -155
  27. package/templates/agent/src/shared/handlers/debug.ts +8 -0
  28. package/templates/agent/src/shared/handlers/feed.ts +31 -32
  29. package/templates/agent/src/shared/handlers/index.ts +1 -0
  30. package/templates/agent/src/shared/handlers/rules.ts +31 -31
  31. package/templates/agent/src/shared/handlers/todo.ts +31 -31
  32. package/templates/agent/src/shared/http-routes.ts +250 -0
  33. package/templates/agent/src/shared/lib/__tests__/web-server.test.ts +190 -0
  34. package/templates/agent/src/shared/lib/logger.ts +117 -95
  35. package/templates/agent/src/shared/lib/web-server.ts +128 -0
  36. package/templates/agent/src/shared/modules/debug.ts +8 -0
  37. package/templates/agent/src/shared/rpc-schema.ts +16 -3
  38. package/templates/agent/test-upload.txt +0 -0
  39. package/templates/agent/tsconfig.ipc.json +10 -0
  40. package/templates/agent/tsconfig.json +10 -2
  41. package/templates/chat/eslint.config.mjs +15 -2
  42. package/templates/chat/package.json +10 -2
  43. package/templates/chat/src/__tests__/hybrid-mode.test.ts +125 -0
  44. package/templates/chat/src/__tests__/server-config-security.test.ts +55 -0
  45. package/templates/chat/src/__tests__/server-config.test.ts +46 -44
  46. package/templates/chat/src/bun/index.ts +94 -44
  47. package/templates/chat/src/bun/three.d.ts +1 -0
  48. package/templates/chat/src/gateway/http-routes.ts +1 -1
  49. package/templates/chat/src/gateway/ws-handler.ts +84 -56
  50. package/templates/chat/src/mainview/App.tsx +6 -0
  51. package/templates/chat/src/mainview/__tests__/i18n/i18n.test.ts +1 -0
  52. package/templates/chat/src/mainview/__tests__/theme-variables.test.ts +36 -0
  53. package/templates/chat/src/mainview/components/common/LanguageSwitcher.tsx +1 -0
  54. package/templates/chat/src/mainview/components/common/ThemeToggle.tsx +1 -0
  55. package/templates/chat/src/mainview/lib/api-client.ts +183 -156
  56. package/templates/chat/src/mainview/lib/rpc-cache.ts +84 -0
  57. package/templates/chat/src/mainview/main.tsx +4 -3
  58. package/templates/chat/src/server-config.ts +33 -31
  59. package/templates/chat/src/server.ts +43 -60
  60. package/templates/chat/src/shared/handlers/chat.ts +196 -162
  61. package/templates/chat/src/shared/handlers/debug.ts +8 -0
  62. package/templates/chat/src/shared/handlers/index.ts +1 -0
  63. package/templates/chat/src/shared/http-routes.ts +250 -0
  64. package/templates/chat/src/shared/lib/__tests__/logger.test.ts +92 -0
  65. package/templates/chat/src/shared/lib/logger.ts +108 -95
  66. package/templates/chat/src/shared/lib/web-server.ts +128 -0
  67. package/templates/chat/src/shared/modules/debug.ts +8 -0
  68. package/templates/chat/src/shared/rpc-schema.ts +3 -2
  69. package/templates/chat/test-upload.txt +0 -0
  70. package/templates/chat/tsconfig.ipc.json +10 -0
  71. package/templates/general/eslint.config.mjs +15 -2
  72. package/templates/general/package.json +10 -2
  73. package/templates/general/src/__tests__/hybrid-mode.test.ts +125 -0
  74. package/templates/general/src/__tests__/server-config-security.test.ts +55 -0
  75. package/templates/general/src/__tests__/server-config.test.ts +46 -44
  76. package/templates/general/src/bun/index.ts +94 -44
  77. package/templates/general/src/bun/three.d.ts +1 -0
  78. package/templates/general/src/gateway/http-routes.ts +1 -1
  79. package/templates/general/src/gateway/ws-handler.ts +84 -56
  80. package/templates/general/src/mainview/__tests__/i18n/i18n.test.ts +1 -0
  81. package/templates/general/src/mainview/__tests__/theme-variables.test.ts +36 -0
  82. package/templates/general/src/mainview/components/common/LanguageSwitcher.tsx +1 -0
  83. package/templates/general/src/mainview/components/common/ThemeToggle.tsx +1 -0
  84. package/templates/general/src/mainview/components/feed/FeedPanel.tsx +241 -219
  85. package/templates/general/src/mainview/components/layout/AppLayout.tsx +6 -0
  86. package/templates/general/src/mainview/lib/api-client.ts +183 -156
  87. package/templates/general/src/mainview/lib/rpc-cache.ts +84 -0
  88. package/templates/general/src/mainview/main.tsx +4 -3
  89. package/templates/general/src/server-config.ts +33 -31
  90. package/templates/general/src/server.ts +43 -60
  91. package/templates/general/src/shared/handlers/__tests__/chat.test.ts +165 -160
  92. package/templates/general/src/shared/handlers/chat.ts +189 -155
  93. package/templates/general/src/shared/handlers/debug.ts +8 -0
  94. package/templates/general/src/shared/handlers/feed.ts +38 -40
  95. package/templates/general/src/shared/handlers/index.ts +1 -0
  96. package/templates/general/src/shared/http-routes.ts +250 -0
  97. package/templates/general/src/shared/lib/__tests__/logger.test.ts +92 -0
  98. package/templates/general/src/shared/lib/logger.ts +117 -95
  99. package/templates/general/src/shared/lib/web-server.ts +128 -0
  100. package/templates/general/src/shared/modules/debug.ts +8 -0
  101. package/templates/general/src/shared/rpc-schema.ts +12 -2
  102. package/templates/general/test-upload.txt +0 -0
  103. package/templates/general/tsconfig.ipc.json +10 -0
  104. package/templates/shared/__tests__/cors-security.test.ts +165 -0
  105. package/templates/shared/__tests__/file-path-security.test.ts +260 -0
  106. package/templates/shared/__tests__/http-routes.test.ts +174 -0
  107. package/templates/shared/__tests__/logger.test.ts +186 -0
  108. package/templates/shared/components/ErrorBoundary.tsx +58 -0
  109. package/templates/shared/env.d.ts +11 -0
  110. package/templates/shared/http-routes.ts +230 -0
  111. package/templates/shared/logger.ts +129 -0
  112. package/templates/shared/test-upload.txt +0 -0
  113. package/templates/shared/vite-base.config.ts +92 -0
  114. package/templates/shared/vitest-base.config.ts +42 -0
  115. package/templates/agent/eslint-plugin-rpc/index.js +0 -50
  116. package/templates/agent/eslint-plugin-rpc/package.json +0 -6
  117. package/templates/agent/eslint-plugin-rpc/rules/module-file-naming.js +0 -99
  118. package/templates/agent/eslint-plugin-rpc/rules/no-bare-method.js +0 -78
  119. package/templates/agent/eslint-plugin-rpc/rules/no-direct-register.js +0 -65
  120. package/templates/agent/eslint-plugin-rpc/rules/no-hardcoded-strings.js +0 -69
  121. package/templates/agent/eslint-plugin-rpc/rules/require-api-client.js +0 -58
  122. package/templates/agent/eslint-plugin-rpc/rules/require-typed-register.js +0 -78
  123. package/templates/agent/eslint-plugin-rpc/rules/schema-merge-only.js +0 -55
  124. package/templates/chat/eslint-plugin-rpc/index.js +0 -50
  125. package/templates/chat/eslint-plugin-rpc/package.json +0 -6
  126. package/templates/chat/eslint-plugin-rpc/rules/module-file-naming.js +0 -99
  127. package/templates/chat/eslint-plugin-rpc/rules/no-bare-method.js +0 -78
  128. package/templates/chat/eslint-plugin-rpc/rules/no-direct-register.js +0 -65
  129. package/templates/chat/eslint-plugin-rpc/rules/no-hardcoded-strings.js +0 -69
  130. package/templates/chat/eslint-plugin-rpc/rules/require-api-client.js +0 -58
  131. package/templates/chat/eslint-plugin-rpc/rules/require-typed-register.js +0 -78
  132. package/templates/chat/eslint-plugin-rpc/rules/schema-merge-only.js +0 -55
  133. package/templates/general/eslint-plugin-rpc/index.js +0 -50
  134. package/templates/general/eslint-plugin-rpc/package.json +0 -6
  135. package/templates/general/eslint-plugin-rpc/rules/module-file-naming.js +0 -99
  136. package/templates/general/eslint-plugin-rpc/rules/no-bare-method.js +0 -78
  137. package/templates/general/eslint-plugin-rpc/rules/no-direct-register.js +0 -65
  138. package/templates/general/eslint-plugin-rpc/rules/no-hardcoded-strings.js +0 -69
  139. package/templates/general/eslint-plugin-rpc/rules/require-api-client.js +0 -58
  140. package/templates/general/eslint-plugin-rpc/rules/require-typed-register.js +0 -78
  141. package/templates/general/eslint-plugin-rpc/rules/schema-merge-only.js +0 -55
@@ -0,0 +1,250 @@
1
+ /**
2
+ * HTTP route handlers for the web gateway.
3
+ * Handles: /health, /info/{path}, /file/{path}, /file/upload
4
+ */
5
+
6
+ import type { IncomingMessage, ServerResponse } from "http";
7
+ import { stat, readFile, writeFile, mkdir } from "fs/promises";
8
+ import { existsSync } from "fs";
9
+ import { extname, basename, dirname, resolve } from "path";
10
+ import { timingSafeEqual } from "crypto";
11
+ import { createLogger } from "./lib/logger";
12
+
13
+ const log = createLogger("gateway");
14
+
15
+ function safeEqual(a: string, b: string): boolean {
16
+ const bufA = Buffer.from(a);
17
+ const bufB = Buffer.from(b);
18
+ if (bufA.length !== bufB.length) return false;
19
+ return timingSafeEqual(bufA, bufB);
20
+ }
21
+
22
+ const MIME_TYPES: Record<string, string> = {
23
+ ".html": "text/html",
24
+ ".css": "text/css",
25
+ ".js": "application/javascript",
26
+ ".json": "application/json",
27
+ ".png": "image/png",
28
+ ".jpg": "image/jpeg",
29
+ ".jpeg": "image/jpeg",
30
+ ".gif": "image/gif",
31
+ ".svg": "image/svg+xml",
32
+ ".ico": "image/x-icon",
33
+ ".txt": "text/plain",
34
+ ".md": "text/markdown",
35
+ ".ts": "text/plain",
36
+ ".tsx": "text/plain",
37
+ ".py": "text/plain",
38
+ ".pdf": "application/pdf",
39
+ ".zip": "application/zip",
40
+ ".mp4": "video/mp4",
41
+ ".mp3": "audio/mpeg",
42
+ ".wav": "audio/wav",
43
+ };
44
+
45
+ const ALLOWED_ROOTS = [resolve(process.cwd())];
46
+ function isPathAllowed(requestedPath: string): boolean {
47
+ const resolved = resolve(requestedPath);
48
+ return ALLOWED_ROOTS.some((root) => resolved === root || resolved.startsWith(root + "/"));
49
+ }
50
+
51
+ function verifyToken(req: IncomingMessage, authToken: string): boolean {
52
+ const auth = req.headers["authorization"];
53
+ if (auth && safeEqual(auth, `Bearer ${authToken}`)) return true;
54
+
55
+ if (req.url) {
56
+ try {
57
+ const url = new URL(req.url, "http://localhost");
58
+ const token = url.searchParams.get("token");
59
+ if (token && safeEqual(token, authToken)) return true;
60
+ } catch {
61
+ /* invalid URL */
62
+ }
63
+ }
64
+ return false;
65
+ }
66
+
67
+ export interface HttpRouteDeps {
68
+ config: {
69
+ readonly port: number;
70
+ readonly authToken: string;
71
+ readonly maxUploadSize: number;
72
+ readonly corsOrigin: string;
73
+ };
74
+ getWebSocketClientCount: () => number;
75
+ }
76
+
77
+ export function createHttpHandler(
78
+ deps: HttpRouteDeps
79
+ ): (req: IncomingMessage, res: ServerResponse) => void {
80
+ const { config: cfg, getWebSocketClientCount } = deps;
81
+
82
+ return async (req, res) => {
83
+ res.setHeader("Access-Control-Allow-Origin", cfg.corsOrigin);
84
+ res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
85
+ res.setHeader("Access-Control-Allow-Headers", "Authorization, Range, Content-Type");
86
+ if (req.method === "OPTIONS") {
87
+ res.writeHead(204).end();
88
+ return;
89
+ }
90
+
91
+ if (!req.url) {
92
+ res.writeHead(400).end();
93
+ return;
94
+ }
95
+
96
+ const url = new URL(req.url, "http://localhost");
97
+
98
+ if (url.pathname === "/health") {
99
+ res.writeHead(200, { "Content-Type": "application/json" });
100
+ res.end(JSON.stringify({ status: "ok", clients: getWebSocketClientCount() }));
101
+ return;
102
+ }
103
+
104
+ if (!verifyToken(req, cfg.authToken)) {
105
+ log.warn("Auth failed", { path: url.pathname });
106
+ res.writeHead(401, { "Content-Type": "application/json" });
107
+ res.end(JSON.stringify({ error: "Unauthorized" }));
108
+ return;
109
+ }
110
+
111
+ if (url.pathname.startsWith("/info/")) {
112
+ await handleFileInfo(url.pathname.slice(6), res);
113
+ return;
114
+ }
115
+
116
+ if (url.pathname.startsWith("/file/")) {
117
+ if (url.pathname === "/file/upload" && req.method === "POST") {
118
+ await handleFileUpload(req, url.searchParams.get("path"), res, cfg.maxUploadSize);
119
+ return;
120
+ }
121
+ await handleFileContent(url.pathname.slice(6), req, res);
122
+ return;
123
+ }
124
+
125
+ res.writeHead(404);
126
+ res.end();
127
+ };
128
+ }
129
+
130
+ async function handleFileInfo(encodedPath: string, res: ServerResponse): Promise<void> {
131
+ const filePath = decodeURIComponent(encodedPath);
132
+ if (!isPathAllowed(filePath)) {
133
+ res.writeHead(403, { "Content-Type": "application/json" });
134
+ res.end(JSON.stringify({ error: "Path not allowed" }));
135
+ return;
136
+ }
137
+ try {
138
+ const s = await stat(filePath);
139
+ res.writeHead(200, { "Content-Type": "application/json" });
140
+ res.end(
141
+ JSON.stringify({
142
+ name: basename(filePath),
143
+ path: filePath,
144
+ size: s.size,
145
+ isDirectory: s.isDirectory(),
146
+ modified: s.mtime.toISOString(),
147
+ mimeType: s.isFile()
148
+ ? MIME_TYPES[extname(filePath)] || "application/octet-stream"
149
+ : undefined,
150
+ })
151
+ );
152
+ } catch {
153
+ res.writeHead(404, { "Content-Type": "application/json" });
154
+ res.end(JSON.stringify({ error: "File not found" }));
155
+ }
156
+ }
157
+
158
+ async function handleFileContent(
159
+ encodedPath: string,
160
+ req: IncomingMessage,
161
+ res: ServerResponse
162
+ ): Promise<void> {
163
+ const filePath = decodeURIComponent(encodedPath);
164
+ if (!isPathAllowed(filePath)) {
165
+ res.writeHead(403, { "Content-Type": "application/json" });
166
+ res.end(JSON.stringify({ error: "Path not allowed" }));
167
+ return;
168
+ }
169
+ try {
170
+ if (!existsSync(filePath)) {
171
+ res.writeHead(404, { "Content-Type": "application/json" });
172
+ res.end(JSON.stringify({ error: "File not found" }));
173
+ return;
174
+ }
175
+ const s = await stat(filePath);
176
+ const mimeType = MIME_TYPES[extname(filePath)] || "application/octet-stream";
177
+
178
+ const range = req.headers["range"];
179
+ if (range) {
180
+ const parts = range.replace(/bytes=/, "").split("-");
181
+ const start = parseInt(parts[0], 10);
182
+ const end = parts[1] ? parseInt(parts[1], 10) : s.size - 1;
183
+ const chunkSize = end - start + 1;
184
+
185
+ res.writeHead(206, {
186
+ "Content-Range": `bytes ${start}-${end}/${s.size}`,
187
+ "Accept-Ranges": "bytes",
188
+ "Content-Length": chunkSize,
189
+ "Content-Type": mimeType,
190
+ });
191
+ const buffer = await readFile(filePath);
192
+ res.end(buffer.subarray(start, end + 1));
193
+ } else {
194
+ res.writeHead(200, {
195
+ "Content-Length": s.size,
196
+ "Content-Type": mimeType,
197
+ "Accept-Ranges": "bytes",
198
+ });
199
+ const buffer = await readFile(filePath);
200
+ res.end(buffer);
201
+ }
202
+ log.info("File served", { path: filePath });
203
+ } catch {
204
+ res.writeHead(500, { "Content-Type": "application/json" });
205
+ res.end(JSON.stringify({ error: "Failed to read file" }));
206
+ }
207
+ }
208
+
209
+ async function handleFileUpload(
210
+ req: IncomingMessage,
211
+ destPath: string | null,
212
+ res: ServerResponse,
213
+ maxUploadSize: number
214
+ ): Promise<void> {
215
+ if (!destPath) {
216
+ res.writeHead(400, { "Content-Type": "application/json" });
217
+ res.end(JSON.stringify({ error: "Missing path parameter" }));
218
+ return;
219
+ }
220
+ if (!isPathAllowed(destPath)) {
221
+ res.writeHead(403, { "Content-Type": "application/json" });
222
+ res.end(JSON.stringify({ error: "Path not allowed" }));
223
+ return;
224
+ }
225
+ const contentLength = parseInt(req.headers["content-length"] || "0", 10);
226
+ if (contentLength > maxUploadSize) {
227
+ res.writeHead(413, { "Content-Type": "application/json" });
228
+ res.end(JSON.stringify({ error: `File too large, max ${maxUploadSize / 1024 / 1024}MB` }));
229
+ return;
230
+ }
231
+ try {
232
+ const chunks: Uint8Array[] = [];
233
+ for await (const chunk of req) {
234
+ const bytes =
235
+ typeof chunk === "string"
236
+ ? new TextEncoder().encode(chunk)
237
+ : new Uint8Array(chunk as ArrayBuffer);
238
+ chunks.push(bytes);
239
+ }
240
+ const body = Buffer.concat(chunks);
241
+ await mkdir(dirname(destPath), { recursive: true });
242
+ await writeFile(destPath, body);
243
+ log.info("File uploaded", { path: destPath, size: body.length });
244
+ res.writeHead(200, { "Content-Type": "application/json" });
245
+ res.end(JSON.stringify({ ok: true, path: destPath, size: body.length }));
246
+ } catch (err) {
247
+ res.writeHead(500, { "Content-Type": "application/json" });
248
+ res.end(JSON.stringify({ error: err instanceof Error ? err.message : "Upload failed" }));
249
+ }
250
+ }
@@ -0,0 +1,190 @@
1
+ import { describe, it, expect, afterEach } from "vitest";
2
+ import http from "http";
3
+ import { WebSocket } from "ws";
4
+ import {
5
+ createWebServer,
6
+ getLocalIP,
7
+ findAvailablePort,
8
+ type WebServerResult,
9
+ } from "../web-server";
10
+
11
+ function httpGet(url: string): Promise<{ status: number; body: string }> {
12
+ return new Promise((resolve, reject) => {
13
+ http
14
+ .get(url, (res) => {
15
+ let data = "";
16
+ res.on("data", (chunk) => {
17
+ data += chunk;
18
+ });
19
+ res.on("end", () => resolve({ status: res.statusCode ?? 0, body: data }));
20
+ })
21
+ .on("error", reject);
22
+ });
23
+ }
24
+
25
+ describe("createWebServer 工厂函数", () => {
26
+ let webServer: WebServerResult | null = null;
27
+
28
+ afterEach(async () => {
29
+ if (webServer) {
30
+ await webServer.close();
31
+ webServer = null;
32
+ }
33
+ });
34
+
35
+ it("应返回 httpServer + wss + port + authToken", async () => {
36
+ webServer = await createWebServer({
37
+ port: 0,
38
+ authToken: "test-token-001",
39
+ maxUploadSize: 1024,
40
+ corsOrigin: "*",
41
+ });
42
+
43
+ expect(webServer.httpServer).toBeDefined();
44
+ expect(webServer.wss).toBeDefined();
45
+ expect(webServer.port).toBeGreaterThan(0);
46
+ expect(webServer.authToken).toBe("test-token-001");
47
+ expect(typeof webServer.close).toBe("function");
48
+ });
49
+
50
+ it("应启动 HTTP 服务并监听指定端口", async () => {
51
+ const testServer = http.createServer();
52
+ const port = await new Promise<number>((resolve) => {
53
+ testServer.listen(0, () => {
54
+ const addr = testServer.address();
55
+ resolve(typeof addr === "object" && addr ? addr.port : 3100);
56
+ });
57
+ });
58
+ await new Promise<void>((resolve) => testServer.close(() => resolve()));
59
+
60
+ webServer = await createWebServer({
61
+ port,
62
+ authToken: "test-token-002",
63
+ maxUploadSize: 1024,
64
+ corsOrigin: "*",
65
+ });
66
+
67
+ expect(webServer.port).toBe(port);
68
+
69
+ const { status, body } = await httpGet(`http://localhost:${webServer.port}/health`);
70
+ expect(status).toBe(200);
71
+ const json = JSON.parse(body);
72
+ expect(json.status).toBe("ok");
73
+ });
74
+
75
+ it("端口冲突时应自动递增到可用端口", async () => {
76
+ const blocker = http.createServer();
77
+ const blockerPort = await new Promise<number>((resolve) => {
78
+ blocker.listen(0, () => {
79
+ const addr = blocker.address();
80
+ resolve(typeof addr === "object" && addr ? addr.port : 3100);
81
+ });
82
+ });
83
+
84
+ webServer = await createWebServer({
85
+ port: blockerPort,
86
+ authToken: "test-token-003",
87
+ maxUploadSize: 1024,
88
+ corsOrigin: "*",
89
+ });
90
+
91
+ expect(webServer.port).not.toBe(blockerPort);
92
+ expect(webServer.port).toBeGreaterThan(blockerPort);
93
+
94
+ await new Promise<void>((resolve) => blocker.close(() => resolve()));
95
+ });
96
+
97
+ it("WS 客户端应能通过 token 连接", async () => {
98
+ webServer = await createWebServer({
99
+ port: 0,
100
+ authToken: "test-token-004",
101
+ maxUploadSize: 1024,
102
+ corsOrigin: "*",
103
+ });
104
+
105
+ const ws = new WebSocket(`ws://localhost:${webServer.port}/ws?token=${webServer.authToken}`);
106
+
107
+ await new Promise<void>((resolve, reject) => {
108
+ ws.on("open", () => {
109
+ ws.close();
110
+ resolve();
111
+ });
112
+ ws.on("error", reject);
113
+ });
114
+ });
115
+
116
+ it("无 token 的 WS 连接应被拒绝", async () => {
117
+ webServer = await createWebServer({
118
+ port: 0,
119
+ authToken: "test-token-005",
120
+ maxUploadSize: 1024,
121
+ corsOrigin: "*",
122
+ });
123
+
124
+ const ws = new WebSocket(`ws://localhost:${webServer.port}/ws`);
125
+
126
+ const closeCode = await new Promise<number>((resolve) => {
127
+ ws.on("close", (code) => resolve(code));
128
+ ws.on("error", () => {});
129
+ });
130
+ expect(closeCode).toBe(4001);
131
+ });
132
+
133
+ it("HTTP 请求需要 token 才能访问文件路由", async () => {
134
+ webServer = await createWebServer({
135
+ port: 0,
136
+ authToken: "test-token-006",
137
+ maxUploadSize: 1024,
138
+ corsOrigin: "*",
139
+ });
140
+
141
+ const { status } = await httpGet(`http://localhost:${webServer.port}/info/some/path`);
142
+ expect(status).toBe(401);
143
+ });
144
+
145
+ it("health 端点不需要 token", async () => {
146
+ webServer = await createWebServer({
147
+ port: 0,
148
+ authToken: "test-token-007",
149
+ maxUploadSize: 1024,
150
+ corsOrigin: "*",
151
+ });
152
+
153
+ const { status, body } = await httpGet(`http://localhost:${webServer.port}/health`);
154
+ expect(status).toBe(200);
155
+ const json = JSON.parse(body);
156
+ expect(json).toHaveProperty("status", "ok");
157
+ expect(json).toHaveProperty("clients");
158
+ });
159
+
160
+ it("关闭 webServer 应停止 HTTP 和 WS 服务", async () => {
161
+ webServer = await createWebServer({
162
+ port: 0,
163
+ authToken: "test-token-008",
164
+ maxUploadSize: 1024,
165
+ corsOrigin: "*",
166
+ });
167
+
168
+ const port = webServer.port;
169
+ await webServer.close();
170
+ webServer = null;
171
+
172
+ await expect(httpGet(`http://localhost:${port}/health`)).rejects.toThrow();
173
+ });
174
+ });
175
+
176
+ describe("getLocalIP", () => {
177
+ it("应返回非空字符串", () => {
178
+ const ip = getLocalIP();
179
+ expect(typeof ip).toBe("string");
180
+ expect(ip.length).toBeGreaterThan(0);
181
+ expect(ip).toMatch(/^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/);
182
+ });
183
+ });
184
+
185
+ describe("findAvailablePort", () => {
186
+ it("应返回一个可用端口", async () => {
187
+ const port = await findAvailablePort(40000);
188
+ expect(port).toBeGreaterThanOrEqual(40000);
189
+ });
190
+ });
@@ -1,130 +1,152 @@
1
- import { appendFile, mkdir as mkdirAsync, readdir as readdirAsync, unlink as unlinkAsync, stat as statAsync } from "fs/promises";
1
+ import {
2
+ appendFile,
3
+ mkdir as mkdirAsync,
4
+ readdir as readdirAsync,
5
+ unlink as unlinkAsync,
6
+ stat as statAsync,
7
+ } from "fs/promises";
2
8
  import { mkdirSync, existsSync } from "fs";
3
9
  import { join } from "path";
4
10
 
5
- export type LogModule = "server" | "gateway" | "system" | "chat" | "file" | "timer" | "git" | "feed";
11
+ export type LogModule =
12
+ | "server"
13
+ | "gateway"
14
+ | "system"
15
+ | "chat"
16
+ | "file"
17
+ | "timer"
18
+ | "git"
19
+ | "feed"
20
+ | "web-server";
6
21
  type LogLevel = "debug" | "info" | "warn" | "error";
7
22
 
8
23
  interface LogEntry {
9
- timestamp: string;
10
- level: LogLevel;
11
- module: LogModule;
12
- message: string;
13
- data?: Record<string, unknown>;
24
+ timestamp: string;
25
+ level: LogLevel;
26
+ module: LogModule;
27
+ message: string;
28
+ data?: Record<string, unknown>;
14
29
  }
15
30
 
16
31
  let _logDir: string | null = null;
17
32
  let _maxAgeDays = 30;
18
33
 
19
- export async function configureLogDir(dir: string, options?: { maxAgeDays?: number }): Promise<void> {
20
- _logDir = dir;
21
- if (options?.maxAgeDays !== undefined) {
22
- _maxAgeDays = options.maxAgeDays;
23
- }
24
- if (!existsSync(dir)) {
25
- mkdirSync(dir, { recursive: true });
26
- }
27
- await cleanOldLogs(dir, _maxAgeDays);
34
+ export async function configureLogDir(
35
+ dir: string,
36
+ options?: { maxAgeDays?: number }
37
+ ): Promise<void> {
38
+ _logDir = dir;
39
+ if (options?.maxAgeDays !== undefined) {
40
+ _maxAgeDays = options.maxAgeDays;
41
+ }
42
+ if (!existsSync(dir)) {
43
+ mkdirSync(dir, { recursive: true });
44
+ }
45
+ await cleanOldLogs(dir, _maxAgeDays);
28
46
  }
29
47
 
30
48
  function getLogDir(): string {
31
- if (!_logDir) {
32
- _logDir = "logs";
33
- if (!existsSync(_logDir)) {
34
- mkdirSync(_logDir, { recursive: true });
35
- }
36
- }
37
- return _logDir;
49
+ if (!_logDir) {
50
+ _logDir = "logs";
51
+ if (!existsSync(_logDir)) {
52
+ mkdirSync(_logDir, { recursive: true });
53
+ }
54
+ }
55
+ return _logDir;
38
56
  }
39
57
 
40
58
  async function cleanOldLogs(dir: string, maxAgeDays: number): Promise<void> {
41
- try {
42
- if (!existsSync(dir)) return;
43
- const files = await readdirAsync(dir);
44
- const cutoff = Date.now() - maxAgeDays * 86400000;
45
- for (const f of files) {
46
- if (!f.endsWith(".log")) continue;
47
- const filePath = join(dir, f);
48
- const s = await statAsync(filePath);
49
- if (s.mtimeMs < cutoff) {
50
- await unlinkAsync(filePath);
51
- }
52
- }
53
- } catch { /* ignore */ }
59
+ try {
60
+ if (!existsSync(dir)) return;
61
+ const files = await readdirAsync(dir);
62
+ const cutoff = Date.now() - maxAgeDays * 86400000;
63
+ for (const f of files) {
64
+ if (!f.endsWith(".log")) continue;
65
+ const filePath = join(dir, f);
66
+ const s = await statAsync(filePath);
67
+ if (s.mtimeMs < cutoff) {
68
+ await unlinkAsync(filePath);
69
+ }
70
+ }
71
+ } catch {
72
+ /* ignore */
73
+ }
54
74
  }
55
75
 
56
76
  function formatLine(entry: LogEntry): string {
57
- const base = `[${entry.timestamp}] [${entry.level.toUpperCase()}] [${entry.module}] ${entry.message}`;
58
- return entry.data ? `${base} ${JSON.stringify(entry.data)}` : base;
77
+ const base = `[${entry.timestamp}] [${entry.level.toUpperCase()}] [${entry.module}] ${entry.message}`;
78
+ return entry.data ? `${base} ${JSON.stringify(entry.data)}` : base;
59
79
  }
60
80
 
61
81
  let writeQueue: Promise<void> = Promise.resolve();
62
82
 
63
83
  function writeToFile(line: string): void {
64
- writeQueue = writeQueue.then(async () => {
65
- try {
66
- const date = new Date().toISOString().slice(0, 10);
67
- const dir = getLogDir();
68
- if (!existsSync(dir)) {
69
- await mkdirAsync(dir, { recursive: true });
70
- }
71
- await appendFile(join(dir, `${date}.log`), `${line}\n`);
72
- } catch { /* ignore */ }
73
- });
84
+ writeQueue = writeQueue.then(async () => {
85
+ try {
86
+ const date = new Date().toISOString().slice(0, 10);
87
+ const dir = getLogDir();
88
+ if (!existsSync(dir)) {
89
+ await mkdirAsync(dir, { recursive: true });
90
+ }
91
+ await appendFile(join(dir, `${date}.log`), `${line}\n`);
92
+ } catch {
93
+ /* ignore */
94
+ }
95
+ });
74
96
  }
75
97
 
76
98
  export function flushLogs(): Promise<void> {
77
- return writeQueue;
99
+ return writeQueue;
78
100
  }
79
101
 
80
102
  export class Logger {
81
- private readonly module: LogModule;
82
-
83
- constructor(module: LogModule) {
84
- this.module = module;
85
- }
86
-
87
- debug(message: string, data?: Record<string, unknown>): void {
88
- this.write("debug", message, data);
89
- }
90
-
91
- info(message: string, data?: Record<string, unknown>): void {
92
- this.write("info", message, data);
93
- }
94
-
95
- warn(message: string, data?: Record<string, unknown>): void {
96
- this.write("warn", message, data);
97
- }
98
-
99
- error(message: string, data?: Record<string, unknown>): void {
100
- this.write("error", message, data);
101
- }
102
-
103
- private write(level: LogLevel, message: string, data?: Record<string, unknown>): void {
104
- const entry: LogEntry = {
105
- timestamp: new Date().toISOString(),
106
- level,
107
- module: this.module,
108
- message,
109
- ...(data ? { data } : {}),
110
- };
111
-
112
- const line = formatLine(entry);
113
-
114
- if (level === "error") {
115
- console.error(line);
116
- } else if (level === "warn") {
117
- console.warn(line);
118
- } else {
119
- console.log(line);
120
- }
121
-
122
- if (process.env.NODE_ENV !== "production" || level !== "debug") {
123
- writeToFile(line);
124
- }
125
- }
103
+ private readonly module: LogModule;
104
+
105
+ constructor(module: LogModule) {
106
+ this.module = module;
107
+ }
108
+
109
+ debug(message: string, data?: Record<string, unknown>): void {
110
+ this.write("debug", message, data);
111
+ }
112
+
113
+ info(message: string, data?: Record<string, unknown>): void {
114
+ this.write("info", message, data);
115
+ }
116
+
117
+ warn(message: string, data?: Record<string, unknown>): void {
118
+ this.write("warn", message, data);
119
+ }
120
+
121
+ error(message: string, data?: Record<string, unknown>): void {
122
+ this.write("error", message, data);
123
+ }
124
+
125
+ private write(level: LogLevel, message: string, data?: Record<string, unknown>): void {
126
+ const entry: LogEntry = {
127
+ timestamp: new Date().toISOString(),
128
+ level,
129
+ module: this.module,
130
+ message,
131
+ ...(data ? { data } : {}),
132
+ };
133
+
134
+ const line = formatLine(entry);
135
+
136
+ if (level === "error") {
137
+ console.error(line);
138
+ } else if (level === "warn") {
139
+ console.warn(line);
140
+ } else {
141
+ console.log(line);
142
+ }
143
+
144
+ if (process.env.NODE_ENV !== "production" || level !== "debug") {
145
+ writeToFile(line);
146
+ }
147
+ }
126
148
  }
127
149
 
128
150
  export function createLogger(module: LogModule): Logger {
129
- return new Logger(module);
151
+ return new Logger(module);
130
152
  }