@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.
- package/package.json +1 -1
- package/src/lib/copy.ts +23 -1
- package/templates/agent/eslint.config.mjs +15 -2
- package/templates/agent/package.json +10 -2
- package/templates/agent/src/__tests__/hybrid-mode.test.ts +126 -0
- package/templates/agent/src/__tests__/server-config-security.test.ts +55 -0
- package/templates/agent/src/bun/index.ts +96 -41
- package/templates/agent/src/bun/three.d.ts +1 -0
- package/templates/agent/src/gateway/__tests__/ws-handler-token.test.ts +118 -0
- package/templates/agent/src/gateway/http-routes.ts +1 -1
- package/templates/agent/src/gateway/ws-handler.ts +84 -56
- package/templates/agent/src/mainview/__tests__/i18n/i18n.test.ts +1 -0
- package/templates/agent/src/mainview/__tests__/setup.ts +32 -29
- package/templates/agent/src/mainview/__tests__/theme-variables.test.ts +36 -0
- package/templates/agent/src/mainview/components/common/ErrorBoundary.tsx +1 -58
- package/templates/agent/src/mainview/components/common/LanguageSwitcher.tsx +1 -0
- package/templates/agent/src/mainview/components/common/ThemeToggle.tsx +1 -0
- package/templates/agent/src/mainview/components/feed/FeedPanel.tsx +232 -210
- package/templates/agent/src/mainview/components/todo/TodoPanel.tsx +149 -122
- package/templates/agent/src/mainview/lib/api-client.ts +182 -172
- package/templates/agent/src/server-config.ts +34 -32
- package/templates/agent/src/server.ts +43 -54
- package/templates/agent/src/shared/handlers/__tests__/chat-concurrency.test.ts +127 -0
- package/templates/agent/src/shared/handlers/__tests__/handler-isolation.test.ts +244 -0
- package/templates/agent/src/shared/handlers/bash.ts +65 -65
- package/templates/agent/src/shared/handlers/chat.ts +189 -155
- package/templates/agent/src/shared/handlers/debug.ts +8 -0
- package/templates/agent/src/shared/handlers/feed.ts +31 -32
- package/templates/agent/src/shared/handlers/index.ts +1 -0
- package/templates/agent/src/shared/handlers/rules.ts +31 -31
- package/templates/agent/src/shared/handlers/todo.ts +31 -31
- package/templates/agent/src/shared/http-routes.ts +250 -0
- package/templates/agent/src/shared/lib/__tests__/web-server.test.ts +190 -0
- package/templates/agent/src/shared/lib/logger.ts +117 -95
- package/templates/agent/src/shared/lib/web-server.ts +128 -0
- package/templates/agent/src/shared/modules/debug.ts +8 -0
- package/templates/agent/src/shared/rpc-schema.ts +16 -3
- package/templates/agent/test-upload.txt +0 -0
- package/templates/agent/tsconfig.ipc.json +10 -0
- package/templates/agent/tsconfig.json +10 -2
- package/templates/chat/eslint.config.mjs +15 -2
- package/templates/chat/package.json +10 -2
- package/templates/chat/src/__tests__/hybrid-mode.test.ts +125 -0
- package/templates/chat/src/__tests__/server-config-security.test.ts +55 -0
- package/templates/chat/src/__tests__/server-config.test.ts +46 -44
- package/templates/chat/src/bun/index.ts +94 -44
- package/templates/chat/src/bun/three.d.ts +1 -0
- package/templates/chat/src/gateway/http-routes.ts +1 -1
- package/templates/chat/src/gateway/ws-handler.ts +84 -56
- package/templates/chat/src/mainview/App.tsx +6 -0
- package/templates/chat/src/mainview/__tests__/i18n/i18n.test.ts +1 -0
- package/templates/chat/src/mainview/__tests__/theme-variables.test.ts +36 -0
- package/templates/chat/src/mainview/components/common/LanguageSwitcher.tsx +1 -0
- package/templates/chat/src/mainview/components/common/ThemeToggle.tsx +1 -0
- package/templates/chat/src/mainview/lib/api-client.ts +183 -156
- package/templates/chat/src/mainview/lib/rpc-cache.ts +84 -0
- package/templates/chat/src/mainview/main.tsx +4 -3
- package/templates/chat/src/server-config.ts +33 -31
- package/templates/chat/src/server.ts +43 -60
- package/templates/chat/src/shared/handlers/chat.ts +196 -162
- package/templates/chat/src/shared/handlers/debug.ts +8 -0
- package/templates/chat/src/shared/handlers/index.ts +1 -0
- package/templates/chat/src/shared/http-routes.ts +250 -0
- package/templates/chat/src/shared/lib/__tests__/logger.test.ts +92 -0
- package/templates/chat/src/shared/lib/logger.ts +108 -95
- package/templates/chat/src/shared/lib/web-server.ts +128 -0
- package/templates/chat/src/shared/modules/debug.ts +8 -0
- package/templates/chat/src/shared/rpc-schema.ts +3 -2
- package/templates/chat/test-upload.txt +0 -0
- package/templates/chat/tsconfig.ipc.json +10 -0
- package/templates/general/eslint.config.mjs +15 -2
- package/templates/general/package.json +10 -2
- package/templates/general/src/__tests__/hybrid-mode.test.ts +125 -0
- package/templates/general/src/__tests__/server-config-security.test.ts +55 -0
- package/templates/general/src/__tests__/server-config.test.ts +46 -44
- package/templates/general/src/bun/index.ts +94 -44
- package/templates/general/src/bun/three.d.ts +1 -0
- package/templates/general/src/gateway/http-routes.ts +1 -1
- package/templates/general/src/gateway/ws-handler.ts +84 -56
- package/templates/general/src/mainview/__tests__/i18n/i18n.test.ts +1 -0
- package/templates/general/src/mainview/__tests__/theme-variables.test.ts +36 -0
- package/templates/general/src/mainview/components/common/LanguageSwitcher.tsx +1 -0
- package/templates/general/src/mainview/components/common/ThemeToggle.tsx +1 -0
- package/templates/general/src/mainview/components/feed/FeedPanel.tsx +241 -219
- package/templates/general/src/mainview/components/layout/AppLayout.tsx +6 -0
- package/templates/general/src/mainview/lib/api-client.ts +183 -156
- package/templates/general/src/mainview/lib/rpc-cache.ts +84 -0
- package/templates/general/src/mainview/main.tsx +4 -3
- package/templates/general/src/server-config.ts +33 -31
- package/templates/general/src/server.ts +43 -60
- package/templates/general/src/shared/handlers/__tests__/chat.test.ts +165 -160
- package/templates/general/src/shared/handlers/chat.ts +189 -155
- package/templates/general/src/shared/handlers/debug.ts +8 -0
- package/templates/general/src/shared/handlers/feed.ts +38 -40
- package/templates/general/src/shared/handlers/index.ts +1 -0
- package/templates/general/src/shared/http-routes.ts +250 -0
- package/templates/general/src/shared/lib/__tests__/logger.test.ts +92 -0
- package/templates/general/src/shared/lib/logger.ts +117 -95
- package/templates/general/src/shared/lib/web-server.ts +128 -0
- package/templates/general/src/shared/modules/debug.ts +8 -0
- package/templates/general/src/shared/rpc-schema.ts +12 -2
- package/templates/general/test-upload.txt +0 -0
- package/templates/general/tsconfig.ipc.json +10 -0
- package/templates/shared/__tests__/cors-security.test.ts +165 -0
- package/templates/shared/__tests__/file-path-security.test.ts +260 -0
- package/templates/shared/__tests__/http-routes.test.ts +174 -0
- package/templates/shared/__tests__/logger.test.ts +186 -0
- package/templates/shared/components/ErrorBoundary.tsx +58 -0
- package/templates/shared/env.d.ts +11 -0
- package/templates/shared/http-routes.ts +230 -0
- package/templates/shared/logger.ts +129 -0
- package/templates/shared/test-upload.txt +0 -0
- package/templates/shared/vite-base.config.ts +92 -0
- package/templates/shared/vitest-base.config.ts +42 -0
- package/templates/agent/eslint-plugin-rpc/index.js +0 -50
- package/templates/agent/eslint-plugin-rpc/package.json +0 -6
- package/templates/agent/eslint-plugin-rpc/rules/module-file-naming.js +0 -99
- package/templates/agent/eslint-plugin-rpc/rules/no-bare-method.js +0 -78
- package/templates/agent/eslint-plugin-rpc/rules/no-direct-register.js +0 -65
- package/templates/agent/eslint-plugin-rpc/rules/no-hardcoded-strings.js +0 -69
- package/templates/agent/eslint-plugin-rpc/rules/require-api-client.js +0 -58
- package/templates/agent/eslint-plugin-rpc/rules/require-typed-register.js +0 -78
- package/templates/agent/eslint-plugin-rpc/rules/schema-merge-only.js +0 -55
- package/templates/chat/eslint-plugin-rpc/index.js +0 -50
- package/templates/chat/eslint-plugin-rpc/package.json +0 -6
- package/templates/chat/eslint-plugin-rpc/rules/module-file-naming.js +0 -99
- package/templates/chat/eslint-plugin-rpc/rules/no-bare-method.js +0 -78
- package/templates/chat/eslint-plugin-rpc/rules/no-direct-register.js +0 -65
- package/templates/chat/eslint-plugin-rpc/rules/no-hardcoded-strings.js +0 -69
- package/templates/chat/eslint-plugin-rpc/rules/require-api-client.js +0 -58
- package/templates/chat/eslint-plugin-rpc/rules/require-typed-register.js +0 -78
- package/templates/chat/eslint-plugin-rpc/rules/schema-merge-only.js +0 -55
- package/templates/general/eslint-plugin-rpc/index.js +0 -50
- package/templates/general/eslint-plugin-rpc/package.json +0 -6
- package/templates/general/eslint-plugin-rpc/rules/module-file-naming.js +0 -99
- package/templates/general/eslint-plugin-rpc/rules/no-bare-method.js +0 -78
- package/templates/general/eslint-plugin-rpc/rules/no-direct-register.js +0 -65
- package/templates/general/eslint-plugin-rpc/rules/no-hardcoded-strings.js +0 -69
- package/templates/general/eslint-plugin-rpc/rules/require-api-client.js +0 -58
- package/templates/general/eslint-plugin-rpc/rules/require-typed-register.js +0 -78
- 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 {
|
|
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 =
|
|
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
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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(
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
-
|
|
58
|
-
|
|
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
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
-
|
|
99
|
+
return writeQueue;
|
|
78
100
|
}
|
|
79
101
|
|
|
80
102
|
export class Logger {
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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
|
-
|
|
151
|
+
return new Logger(module);
|
|
130
152
|
}
|