@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,92 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import { mkdirSync, existsSync, readdirSync, readFileSync, writeFileSync, rmSync, utimesSync } from "fs";
|
|
3
|
+
import { join } from "path";
|
|
4
|
+
|
|
5
|
+
const TEST_LOG_DIR = join(process.cwd(), "test-logs");
|
|
6
|
+
|
|
7
|
+
describe("Logger", () => {
|
|
8
|
+
beforeEach(async () => {
|
|
9
|
+
if (existsSync(TEST_LOG_DIR)) {
|
|
10
|
+
rmSync(TEST_LOG_DIR, { recursive: true, force: true });
|
|
11
|
+
}
|
|
12
|
+
mkdirSync(TEST_LOG_DIR, { recursive: true });
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
afterEach(() => {
|
|
16
|
+
if (existsSync(TEST_LOG_DIR)) {
|
|
17
|
+
rmSync(TEST_LOG_DIR, { recursive: true, force: true });
|
|
18
|
+
}
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("should write logs asynchronously", async () => {
|
|
22
|
+
const { createLogger, configureLogDir, flushLogs } = await import("../logger");
|
|
23
|
+
await configureLogDir(TEST_LOG_DIR);
|
|
24
|
+
|
|
25
|
+
const logger = createLogger("test");
|
|
26
|
+
logger.info("test message");
|
|
27
|
+
|
|
28
|
+
await flushLogs();
|
|
29
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
30
|
+
|
|
31
|
+
const files = readdirSync(TEST_LOG_DIR);
|
|
32
|
+
expect(files.length).toBeGreaterThan(0);
|
|
33
|
+
|
|
34
|
+
const content = readFileSync(join(TEST_LOG_DIR, files[0]), "utf-8");
|
|
35
|
+
expect(content).toContain("test message");
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("should support multiple log levels", async () => {
|
|
39
|
+
const { createLogger, configureLogDir, flushLogs } = await import("../logger");
|
|
40
|
+
await configureLogDir(TEST_LOG_DIR);
|
|
41
|
+
|
|
42
|
+
const logger = createLogger("test");
|
|
43
|
+
logger.debug("debug msg");
|
|
44
|
+
logger.info("info msg");
|
|
45
|
+
logger.warn("warn msg");
|
|
46
|
+
logger.error("error msg");
|
|
47
|
+
|
|
48
|
+
await flushLogs();
|
|
49
|
+
|
|
50
|
+
const files = readdirSync(TEST_LOG_DIR);
|
|
51
|
+
const content = readFileSync(join(TEST_LOG_DIR, files[0]), "utf-8");
|
|
52
|
+
expect(content).toContain("info msg");
|
|
53
|
+
expect(content).toContain("warn msg");
|
|
54
|
+
expect(content).toContain("error msg");
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("should clean up old log files based on maxAgeDays", async () => {
|
|
58
|
+
const { configureLogDir } = await import("../logger");
|
|
59
|
+
|
|
60
|
+
const oldDate = new Date(Date.now() - 10 * 86400000).toISOString().slice(0, 10);
|
|
61
|
+
const oldPath = join(TEST_LOG_DIR, `${oldDate}.log`);
|
|
62
|
+
writeFileSync(oldPath, "old log");
|
|
63
|
+
const oldTime = new Date(Date.now() - 10 * 86400000);
|
|
64
|
+
utimesSync(oldPath, oldTime, oldTime);
|
|
65
|
+
|
|
66
|
+
const todayFile = new Date().toISOString().slice(0, 10);
|
|
67
|
+
writeFileSync(join(TEST_LOG_DIR, `${todayFile}.log`), "today log");
|
|
68
|
+
|
|
69
|
+
await configureLogDir(TEST_LOG_DIR, { maxAgeDays: 5 });
|
|
70
|
+
|
|
71
|
+
const files = readdirSync(TEST_LOG_DIR);
|
|
72
|
+
expect(files.find((f) => f.includes(oldDate))).toBeUndefined();
|
|
73
|
+
expect(files.find((f) => f.includes(todayFile))).toBeDefined();
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("should flush all pending writes", async () => {
|
|
77
|
+
const { createLogger, configureLogDir, flushLogs } = await import("../logger");
|
|
78
|
+
await configureLogDir(TEST_LOG_DIR);
|
|
79
|
+
|
|
80
|
+
const logger = createLogger("test");
|
|
81
|
+
for (let i = 0; i < 100; i++) {
|
|
82
|
+
logger.info(`msg ${i}`);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
await flushLogs();
|
|
86
|
+
|
|
87
|
+
const files = readdirSync(TEST_LOG_DIR);
|
|
88
|
+
const content = readFileSync(join(TEST_LOG_DIR, files[0]), "utf-8");
|
|
89
|
+
expect(content).toContain("msg 0");
|
|
90
|
+
expect(content).toContain("msg 99");
|
|
91
|
+
});
|
|
92
|
+
});
|
|
@@ -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
|
}
|