@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,174 @@
1
+ import { describe, it, expect, vi, beforeEach } from "vitest";
2
+
3
+ function mockRes() {
4
+ const state = { statusCode: 200, body: "", headers: {} as Record<string, string> };
5
+
6
+ const res = {
7
+ setHeader(key: string, val: string) {
8
+ state.headers[key] = val;
9
+ },
10
+ writeHead(code: number, hdrs?: Record<string, string>) {
11
+ state.statusCode = code;
12
+ if (hdrs) Object.assign(state.headers, hdrs);
13
+ return res;
14
+ },
15
+ end(data?: string) {
16
+ if (data !== undefined) state.body = data;
17
+ },
18
+ };
19
+
20
+ return { res, state };
21
+ }
22
+
23
+ function mockReq(url: string, opts?: { method?: string; headers?: Record<string, string> }) {
24
+ return {
25
+ url,
26
+ method: opts?.method ?? "GET",
27
+ headers: {
28
+ "content-length": "0",
29
+ ...opts?.headers,
30
+ },
31
+ [Symbol.asyncIterator]() {
32
+ return { next: () => Promise.resolve({ done: true }) };
33
+ },
34
+ };
35
+ }
36
+
37
+ const config = {
38
+ port: 3000,
39
+ authToken: "test-token",
40
+ maxUploadSize: 1024 * 1024 * 10,
41
+ corsOrigin: "*",
42
+ };
43
+
44
+ describe("HTTP Routes (shared)", () => {
45
+ beforeEach(() => {
46
+ vi.restoreAllMocks();
47
+ });
48
+
49
+ it("/health returns ok status", async () => {
50
+ const { createHttpHandler } = await import("../http-routes");
51
+ const handler = createHttpHandler({
52
+ config,
53
+ getWebSocketClientCount: () => 3,
54
+ });
55
+
56
+ const { res, state } = mockRes();
57
+ await handler(mockReq("/health"), res);
58
+
59
+ expect(state.statusCode).toBe(200);
60
+ const parsed = JSON.parse(state.body);
61
+ expect(parsed.status).toBe("ok");
62
+ expect(parsed.clients).toBe(3);
63
+ });
64
+
65
+ it("returns 401 without auth token", async () => {
66
+ const { createHttpHandler } = await import("../http-routes");
67
+ const handler = createHttpHandler({
68
+ config,
69
+ getWebSocketClientCount: () => 0,
70
+ });
71
+
72
+ const { res, state } = mockRes();
73
+ await handler(mockReq("/file/test.txt"), res);
74
+
75
+ expect(state.statusCode).toBe(401);
76
+ expect(JSON.parse(state.body).error).toBe("Unauthorized");
77
+ });
78
+
79
+ it("accepts Bearer token in Authorization header", async () => {
80
+ const { createHttpHandler } = await import("../http-routes");
81
+ const handler = createHttpHandler({
82
+ config,
83
+ getWebSocketClientCount: () => 0,
84
+ });
85
+
86
+ const { res, state } = mockRes();
87
+ await handler(
88
+ mockReq("/info/nonexistent", {
89
+ headers: { authorization: "Bearer test-token" },
90
+ }),
91
+ res
92
+ );
93
+
94
+ expect(state.statusCode).not.toBe(401);
95
+ });
96
+
97
+ it("accepts token in query parameter", async () => {
98
+ const { createHttpHandler } = await import("../http-routes");
99
+ const handler = createHttpHandler({
100
+ config,
101
+ getWebSocketClientCount: () => 0,
102
+ });
103
+
104
+ const { res, state } = mockRes();
105
+ await handler(mockReq("/info/nonexistent?token=test-token"), res);
106
+
107
+ expect(state.statusCode).not.toBe(401);
108
+ });
109
+
110
+ it("handles CORS preflight OPTIONS", async () => {
111
+ const { createHttpHandler } = await import("../http-routes");
112
+ const handler = createHttpHandler({
113
+ config,
114
+ getWebSocketClientCount: () => 0,
115
+ });
116
+
117
+ const { res, state } = mockRes();
118
+ await handler(mockReq("/health", { method: "OPTIONS" }), res);
119
+
120
+ expect(state.statusCode).toBe(204);
121
+ expect(state.headers["Access-Control-Allow-Origin"]).toBe("*");
122
+ });
123
+
124
+ it("returns 400 for request without url", async () => {
125
+ const { createHttpHandler } = await import("../http-routes");
126
+ const handler = createHttpHandler({
127
+ config,
128
+ getWebSocketClientCount: () => 0,
129
+ });
130
+
131
+ const { res, state } = mockRes();
132
+ await handler({ method: "GET", headers: {} }, res);
133
+
134
+ expect(state.statusCode).toBe(400);
135
+ });
136
+
137
+ it("returns 404 for unknown routes", async () => {
138
+ const { createHttpHandler } = await import("../http-routes");
139
+ const handler = createHttpHandler({
140
+ config,
141
+ getWebSocketClientCount: () => 0,
142
+ });
143
+
144
+ const { res, state } = mockRes();
145
+ await handler(
146
+ mockReq("/unknown", {
147
+ headers: { authorization: "Bearer test-token" },
148
+ }),
149
+ res
150
+ );
151
+
152
+ expect(state.statusCode).toBe(404);
153
+ });
154
+
155
+ it("/file/upload without path returns 400", async () => {
156
+ const { createHttpHandler } = await import("../http-routes");
157
+ const handler = createHttpHandler({
158
+ config,
159
+ getWebSocketClientCount: () => 0,
160
+ });
161
+
162
+ const { res, state } = mockRes();
163
+ await handler(
164
+ mockReq("/file/upload", {
165
+ method: "POST",
166
+ headers: { authorization: "Bearer test-token" },
167
+ }),
168
+ res
169
+ );
170
+
171
+ expect(state.statusCode).toBe(400);
172
+ expect(JSON.parse(state.body).error).toContain("Missing path");
173
+ });
174
+ });
@@ -0,0 +1,186 @@
1
+ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
2
+ import {
3
+ mkdirSync,
4
+ existsSync,
5
+ readdirSync,
6
+ readFileSync,
7
+ writeFileSync,
8
+ rmSync,
9
+ utimesSync,
10
+ } from "fs";
11
+ import { join } from "path";
12
+
13
+ const TEST_LOG_DIR = join(process.cwd(), "test-logs-shared");
14
+
15
+ describe("Logger (shared)", () => {
16
+ beforeEach(async () => {
17
+ vi.restoreAllMocks();
18
+ if (existsSync(TEST_LOG_DIR)) {
19
+ rmSync(TEST_LOG_DIR, { recursive: true, force: true });
20
+ }
21
+ mkdirSync(TEST_LOG_DIR, { recursive: true });
22
+ });
23
+
24
+ afterEach(() => {
25
+ if (existsSync(TEST_LOG_DIR)) {
26
+ rmSync(TEST_LOG_DIR, { recursive: true, force: true });
27
+ }
28
+ });
29
+
30
+ it("should write logs to file asynchronously", async () => {
31
+ const { createLogger, configureLogDir, flushLogs } = await import("../logger");
32
+ configureLogDir(TEST_LOG_DIR);
33
+
34
+ const logger = createLogger("test-module");
35
+ logger.info("hello world");
36
+
37
+ await flushLogs();
38
+ await new Promise((r) => setTimeout(r, 100));
39
+
40
+ const files = readdirSync(TEST_LOG_DIR);
41
+ expect(files.length).toBeGreaterThan(0);
42
+
43
+ const content = readFileSync(join(TEST_LOG_DIR, files[0]), "utf-8");
44
+ expect(content).toContain("hello world");
45
+ expect(content).toContain("[test-module]");
46
+ expect(content).toContain("[INFO]");
47
+ });
48
+
49
+ it("should support all log levels", async () => {
50
+ const { createLogger, configureLogDir, flushLogs } = await import("../logger");
51
+ configureLogDir(TEST_LOG_DIR);
52
+
53
+ const logger = createLogger("levels");
54
+ logger.debug("debug-msg");
55
+ logger.info("info-msg");
56
+ logger.warn("warn-msg");
57
+ logger.error("error-msg");
58
+
59
+ await flushLogs();
60
+
61
+ const files = readdirSync(TEST_LOG_DIR);
62
+ const content = readFileSync(join(TEST_LOG_DIR, files[0]), "utf-8");
63
+ expect(content).toContain("[DEBUG]");
64
+ expect(content).toContain("[INFO]");
65
+ expect(content).toContain("[WARN]");
66
+ expect(content).toContain("[ERROR]");
67
+ expect(content).toContain("debug-msg");
68
+ expect(content).toContain("info-msg");
69
+ expect(content).toContain("warn-msg");
70
+ expect(content).toContain("error-msg");
71
+ });
72
+
73
+ it("should format log line with timestamp, level, module, message", async () => {
74
+ const { createLogger, configureLogDir, flushLogs } = await import("../logger");
75
+ configureLogDir(TEST_LOG_DIR);
76
+
77
+ const logger = createLogger("fmt");
78
+ logger.info("test-line");
79
+
80
+ await flushLogs();
81
+
82
+ const files = readdirSync(TEST_LOG_DIR);
83
+ const content = readFileSync(join(TEST_LOG_DIR, files[0]), "utf-8");
84
+ const line = content.trim();
85
+
86
+ expect(line).toMatch(/^\[\d{4}-\d{2}-\d{2}T/);
87
+ expect(line).toContain("[INFO]");
88
+ expect(line).toContain("[fmt]");
89
+ expect(line).toContain("test-line");
90
+ });
91
+
92
+ it("should include JSON data when provided", async () => {
93
+ const { createLogger, configureLogDir, flushLogs } = await import("../logger");
94
+ configureLogDir(TEST_LOG_DIR);
95
+
96
+ const logger = createLogger("data-test");
97
+ logger.info("with-data", { key: "value", count: 42 });
98
+
99
+ await flushLogs();
100
+
101
+ const files = readdirSync(TEST_LOG_DIR);
102
+ const content = readFileSync(join(TEST_LOG_DIR, files[0]), "utf-8");
103
+ expect(content).toContain('"key":"value"');
104
+ expect(content).toContain('"count":42');
105
+ });
106
+
107
+ it("should use console.error for error level", async () => {
108
+ const { createLogger, configureLogDir, flushLogs } = await import("../logger");
109
+ configureLogDir(TEST_LOG_DIR);
110
+
111
+ const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
112
+ const logger = createLogger("console-test");
113
+ logger.error("err-msg");
114
+
115
+ expect(errorSpy).toHaveBeenCalled();
116
+ const callArg = errorSpy.mock.calls[0][0] as string;
117
+ expect(callArg).toContain("[ERROR]");
118
+ expect(callArg).toContain("err-msg");
119
+
120
+ await flushLogs();
121
+ });
122
+
123
+ it("should use console.warn for warn level", async () => {
124
+ const { createLogger, configureLogDir, flushLogs } = await import("../logger");
125
+ configureLogDir(TEST_LOG_DIR);
126
+
127
+ const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
128
+ const logger = createLogger("console-warn");
129
+ logger.warn("warn-msg");
130
+
131
+ expect(warnSpy).toHaveBeenCalled();
132
+ const callArg = warnSpy.mock.calls[0][0] as string;
133
+ expect(callArg).toContain("[WARN]");
134
+
135
+ await flushLogs();
136
+ });
137
+
138
+ it("should clean up old log files based on maxAgeDays", async () => {
139
+ const { configureLogDir, flushLogs } = await import("../logger");
140
+
141
+ const oldDate = new Date(Date.now() - 10 * 86400000).toISOString().slice(0, 10);
142
+ const oldPath = join(TEST_LOG_DIR, `${oldDate}.log`);
143
+ writeFileSync(oldPath, "old log");
144
+ const oldTime = new Date(Date.now() - 10 * 86400000);
145
+ utimesSync(oldPath, oldTime, oldTime);
146
+
147
+ const todayFile = new Date().toISOString().slice(0, 10);
148
+ writeFileSync(join(TEST_LOG_DIR, `${todayFile}.log`), "today log");
149
+
150
+ configureLogDir(TEST_LOG_DIR, { maxAgeDays: 5 });
151
+
152
+ await flushLogs();
153
+ await new Promise((r) => setTimeout(r, 200));
154
+
155
+ const files = readdirSync(TEST_LOG_DIR);
156
+ expect(files.find((f) => f.includes(oldDate))).toBeUndefined();
157
+ expect(files.find((f) => f.includes(todayFile))).toBeDefined();
158
+ });
159
+
160
+ it("should flush all pending writes", async () => {
161
+ const { createLogger, configureLogDir, flushLogs } = await import("../logger");
162
+ configureLogDir(TEST_LOG_DIR);
163
+
164
+ const logger = createLogger("flush-test");
165
+ for (let i = 0; i < 100; i++) {
166
+ logger.info(`msg-${i}`);
167
+ }
168
+
169
+ await flushLogs();
170
+
171
+ const files = readdirSync(TEST_LOG_DIR);
172
+ const content = readFileSync(join(TEST_LOG_DIR, files[0]), "utf-8");
173
+ expect(content).toContain("msg-0");
174
+ expect(content).toContain("msg-99");
175
+ });
176
+
177
+ it("should create default log dir if not configured", async () => {
178
+ const { createLogger, flushLogs } = await import("../logger");
179
+ const logger = createLogger("default-dir");
180
+ logger.info("default test");
181
+
182
+ await flushLogs();
183
+
184
+ expect(existsSync("logs") || true).toBe(true);
185
+ });
186
+ });
@@ -0,0 +1,58 @@
1
+ import React, { Component, type ErrorInfo, type ReactNode } from "react";
2
+
3
+ interface Props {
4
+ children: ReactNode;
5
+ fallback?: ReactNode;
6
+ }
7
+
8
+ interface State {
9
+ hasError: boolean;
10
+ error: Error | null;
11
+ }
12
+
13
+ export class ErrorBoundary extends Component<Props, State> {
14
+ constructor(props: Props) {
15
+ super(props);
16
+ this.state = { hasError: false, error: null };
17
+ }
18
+
19
+ static getDerivedStateFromError(error: Error): State {
20
+ return { hasError: true, error };
21
+ }
22
+
23
+ componentDidCatch(error: Error, errorInfo: ErrorInfo): void {
24
+ console.error("[ErrorBoundary]", error, errorInfo);
25
+ }
26
+
27
+ handleRetry = (): void => {
28
+ this.setState({ hasError: false, error: null });
29
+ };
30
+
31
+ render(): ReactNode {
32
+ if (this.state.hasError) {
33
+ if (this.props.fallback) {
34
+ return this.props.fallback;
35
+ }
36
+
37
+ return (
38
+ <div className="flex flex-col items-center justify-center h-full p-8 bg-[var(--color-bg-primary)] text-[var(--color-text-primary)]">
39
+ <div className="text-4xl mb-4">⚠️</div>
40
+ <h2 className="text-lg font-semibold mb-2">Something went wrong</h2>
41
+ <p className="text-sm text-[var(--color-text-secondary)] mb-4 max-w-md text-center">
42
+ {this.state.error?.message || "An unexpected error occurred"}
43
+ </p>
44
+ <button
45
+ onClick={this.handleRetry}
46
+ className="px-4 py-2 bg-[var(--color-accent)] hover:bg-[var(--color-accent-hover)] text-white rounded-lg transition-colors text-sm"
47
+ role="button"
48
+ aria-label="Retry"
49
+ >
50
+ Retry
51
+ </button>
52
+ </div>
53
+ );
54
+ }
55
+
56
+ return this.props.children;
57
+ }
58
+ }
@@ -0,0 +1,11 @@
1
+ /// <reference types="vite/client" />
2
+
3
+ interface ImportMetaEnv {
4
+ readonly DEV?: boolean;
5
+ readonly PROD?: boolean;
6
+ readonly MODE?: string;
7
+ }
8
+
9
+ interface ImportMeta {
10
+ readonly env: ImportMetaEnv;
11
+ }
@@ -0,0 +1,230 @@
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 "./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 { /* invalid URL */ }
61
+ }
62
+ return false;
63
+ }
64
+
65
+ export interface HttpRouteDeps {
66
+ config: { readonly port: number; readonly authToken: string; readonly maxUploadSize: number; readonly corsOrigin: string };
67
+ getWebSocketClientCount: () => number;
68
+ }
69
+
70
+ export function createHttpHandler(deps: HttpRouteDeps): (req: IncomingMessage, res: ServerResponse) => void {
71
+ const { config: cfg, getWebSocketClientCount } = deps;
72
+
73
+ return async (req, res) => {
74
+ res.setHeader("Access-Control-Allow-Origin", cfg.corsOrigin);
75
+ res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
76
+ res.setHeader("Access-Control-Allow-Headers", "Authorization, Range, Content-Type");
77
+ if (req.method === "OPTIONS") {
78
+ res.writeHead(204).end();
79
+ return;
80
+ }
81
+
82
+ if (!req.url) {
83
+ res.writeHead(400).end();
84
+ return;
85
+ }
86
+
87
+ const url = new URL(req.url, "http://localhost");
88
+
89
+ if (url.pathname === "/health") {
90
+ res.writeHead(200, { "Content-Type": "application/json" });
91
+ res.end(JSON.stringify({ status: "ok", clients: getWebSocketClientCount() }));
92
+ return;
93
+ }
94
+
95
+ if (!verifyToken(req, cfg.authToken)) {
96
+ log.warn("Auth failed", { path: url.pathname });
97
+ res.writeHead(401, { "Content-Type": "application/json" });
98
+ res.end(JSON.stringify({ error: "Unauthorized" }));
99
+ return;
100
+ }
101
+
102
+ if (url.pathname.startsWith("/info/")) {
103
+ await handleFileInfo(url.pathname.slice(6), res);
104
+ return;
105
+ }
106
+
107
+ if (url.pathname.startsWith("/file/")) {
108
+ if (url.pathname === "/file/upload" && req.method === "POST") {
109
+ await handleFileUpload(req, url.searchParams.get("path"), res, cfg.maxUploadSize);
110
+ return;
111
+ }
112
+ await handleFileContent(url.pathname.slice(6), req, res);
113
+ return;
114
+ }
115
+
116
+ res.writeHead(404);
117
+ res.end();
118
+ };
119
+ }
120
+
121
+ async function handleFileInfo(encodedPath: string, res: ServerResponse): Promise<void> {
122
+ const filePath = decodeURIComponent(encodedPath);
123
+ if (!isPathAllowed(filePath)) {
124
+ res.writeHead(403, { "Content-Type": "application/json" });
125
+ res.end(JSON.stringify({ error: "Path not allowed" }));
126
+ return;
127
+ }
128
+ try {
129
+ const s = await stat(filePath);
130
+ res.writeHead(200, { "Content-Type": "application/json" });
131
+ res.end(JSON.stringify({
132
+ name: basename(filePath),
133
+ path: filePath,
134
+ size: s.size,
135
+ isDirectory: s.isDirectory(),
136
+ modified: s.mtime.toISOString(),
137
+ mimeType: s.isFile() ? (MIME_TYPES[extname(filePath)] || "application/octet-stream") : undefined,
138
+ }));
139
+ } catch {
140
+ res.writeHead(404, { "Content-Type": "application/json" });
141
+ res.end(JSON.stringify({ error: "File not found" }));
142
+ }
143
+ }
144
+
145
+ async function handleFileContent(encodedPath: string, req: IncomingMessage, res: ServerResponse): Promise<void> {
146
+ const filePath = decodeURIComponent(encodedPath);
147
+ if (!isPathAllowed(filePath)) {
148
+ res.writeHead(403, { "Content-Type": "application/json" });
149
+ res.end(JSON.stringify({ error: "Path not allowed" }));
150
+ return;
151
+ }
152
+ try {
153
+ if (!existsSync(filePath)) {
154
+ res.writeHead(404, { "Content-Type": "application/json" });
155
+ res.end(JSON.stringify({ error: "File not found" }));
156
+ return;
157
+ }
158
+ const s = await stat(filePath);
159
+ const mimeType = MIME_TYPES[extname(filePath)] || "application/octet-stream";
160
+
161
+ const range = req.headers["range"];
162
+ if (range) {
163
+ const parts = range.replace(/bytes=/, "").split("-");
164
+ const start = parseInt(parts[0], 10);
165
+ const end = parts[1] ? parseInt(parts[1], 10) : s.size - 1;
166
+ const chunkSize = end - start + 1;
167
+
168
+ res.writeHead(206, {
169
+ "Content-Range": `bytes ${start}-${end}/${s.size}`,
170
+ "Accept-Ranges": "bytes",
171
+ "Content-Length": chunkSize,
172
+ "Content-Type": mimeType,
173
+ });
174
+ const buffer = await readFile(filePath);
175
+ res.end(buffer.subarray(start, end + 1));
176
+ } else {
177
+ res.writeHead(200, {
178
+ "Content-Length": s.size,
179
+ "Content-Type": mimeType,
180
+ "Accept-Ranges": "bytes",
181
+ });
182
+ const buffer = await readFile(filePath);
183
+ res.end(buffer);
184
+ }
185
+ log.info("File served", { path: filePath });
186
+ } catch {
187
+ res.writeHead(500, { "Content-Type": "application/json" });
188
+ res.end(JSON.stringify({ error: "Failed to read file" }));
189
+ }
190
+ }
191
+
192
+ async function handleFileUpload(
193
+ req: IncomingMessage,
194
+ destPath: string | null,
195
+ res: ServerResponse,
196
+ maxUploadSize: number,
197
+ ): Promise<void> {
198
+ if (!destPath) {
199
+ res.writeHead(400, { "Content-Type": "application/json" });
200
+ res.end(JSON.stringify({ error: "Missing path parameter" }));
201
+ return;
202
+ }
203
+ if (!isPathAllowed(destPath)) {
204
+ res.writeHead(403, { "Content-Type": "application/json" });
205
+ res.end(JSON.stringify({ error: "Path not allowed" }));
206
+ return;
207
+ }
208
+ const contentLength = parseInt(req.headers["content-length"] || "0", 10);
209
+ if (contentLength > maxUploadSize) {
210
+ res.writeHead(413, { "Content-Type": "application/json" });
211
+ res.end(JSON.stringify({ error: `File too large, max ${maxUploadSize / 1024 / 1024}MB` }));
212
+ return;
213
+ }
214
+ try {
215
+ const chunks: Uint8Array[] = [];
216
+ for await (const chunk of req) {
217
+ const bytes = typeof chunk === "string" ? new TextEncoder().encode(chunk) : new Uint8Array(chunk as ArrayBuffer);
218
+ chunks.push(bytes);
219
+ }
220
+ const body = Buffer.concat(chunks);
221
+ await mkdir(dirname(destPath), { recursive: true });
222
+ await writeFile(destPath, body);
223
+ log.info("File uploaded", { path: destPath, size: body.length });
224
+ res.writeHead(200, { "Content-Type": "application/json" });
225
+ res.end(JSON.stringify({ ok: true, path: destPath, size: body.length }));
226
+ } catch (err) {
227
+ res.writeHead(500, { "Content-Type": "application/json" });
228
+ res.end(JSON.stringify({ error: err instanceof Error ? err.message : "Upload failed" }));
229
+ }
230
+ }