forked from rookedsysc/kanvibe
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathserver.ts
More file actions
178 lines (156 loc) · 6.18 KB
/
server.ts
File metadata and controls
178 lines (156 loc) · 6.18 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
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
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
import "reflect-metadata";
import { createServer } from "http";
import { parse } from "url";
import next from "next";
import { WebSocketServer, type WebSocket } from "ws";
import { validateSessionFromCookie } from "@/lib/auth";
import { getTaskRepository, getProjectRepository } from "@/lib/database";
import { attachLocalSession, attachRemoteSession } from "@/lib/terminal";
import { formatWindowName } from "@/lib/worktree";
import { parseSSHConfig } from "@/lib/sshConfig";
import { addBoardClient, removeBoardClient, getBoardClients } from "@/lib/boardNotifier";
/**
* node-pty의 ThreadSafeFunction 콜백이 실패하면 C++ 레벨에서
* Napi::Error를 throw하므로 프로세스가 abort된다.
* uncaughtException 핸들러로 가능한 범위의 에러를 로깅하고 프로세스 유지를 시도한다.
*/
process.on("uncaughtException", (error) => {
console.error("[uncaughtException]", error);
});
const dev = process.env.NODE_ENV !== "production";
const hostname = "0.0.0.0";
const port = parseInt(process.env.PORT || "4885", 10);
const wsPort = port - 1;
/**
* Next.js에 httpServer를 전달하여 HMR WebSocket 등 내부 기능이
* 커스텀 서버를 통해 동작하도록 한다.
*/
const server = createServer();
const app = next({ dev, hostname, port, httpServer: server });
const handle = app.getRequestHandler();
app.prepare().then(() => {
/**
* Next.js HTTP 요청 핸들러 등록.
* 페이지, API, HMR 등 모든 HTTP 요청을 Next.js에 위임한다.
*/
server.on("request", (req, res) => {
/** 내부 broadcast 요청은 Next.js를 거치지 않고 직접 WS 클라이언트에 전달한다 */
if (req.url === "/_internal/broadcast" && req.method === "POST") {
let body = "";
req.on("data", (chunk: Buffer) => (body += chunk));
req.on("end", () => {
for (const client of getBoardClients()) {
if (client.readyState === client.OPEN) {
client.send(body);
}
}
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ ok: true }));
});
return;
}
const parsedUrl = parse(req.url!, true);
handle(req, res, parsedUrl);
});
/**
* 터미널 WebSocket 전용 서버.
* Next.js 16 Turbopack이 같은 포트의 WebSocket upgrade를 내부적으로 가로채므로
* 별도 포트(PORT + 1)에서 독립 운영한다.
*/
const wsHttpServer = createServer();
const wss = new WebSocketServer({ server: wsHttpServer });
wss.on("connection", async (ws: WebSocket, request) => {
const parsed = parse(request.url || "", true);
const cookieHeader = request.headers.cookie || "";
/** 보드 알림 WebSocket: 연결된 클라이언트에 변경 사항을 broadcast한다 */
if (parsed.pathname === "/api/board/events") {
const isAuthed = validateSessionFromCookie(cookieHeader);
if (!isAuthed) {
ws.close(1008, "인증 실패");
return;
}
addBoardClient(ws);
ws.on("close", () => removeBoardClient(ws));
return;
}
const taskIdMatch = parsed.pathname?.match(/^\/api\/terminal\/([a-f0-9-]+)$/);
if (!taskIdMatch) {
ws.close(1008, "잘못된 경로");
return;
}
const taskId = taskIdMatch[1];
const initialCols = parseInt(parsed.query.cols as string, 10) || 120;
const initialRows = parseInt(parsed.query.rows as string, 10) || 30;
const isAuthed = validateSessionFromCookie(cookieHeader);
console.log(`[WS] 터미널 연결 요청: taskId=${taskId}, auth=${isAuthed}`);
if (!isAuthed) {
console.log("[WS] 인증 실패 — 쿠키:", cookieHeader ? "있음" : "없음");
ws.close(1008, "인증 실패");
return;
}
try {
const repo = await getTaskRepository();
const task = await repo.findOneBy({ id: taskId });
if (!task || !task.sessionType || !task.sessionName) {
ws.close(1008, "작업에 연결된 세션이 없습니다.");
return;
}
/** branchName 또는 baseBranch에서 window/tab 이름을 파생한다 */
const derivedBranch = task.branchName || task.baseBranch;
const windowName = derivedBranch ? formatWindowName(derivedBranch) : "";
if (task.sshHost) {
const sshHosts = await parseSSHConfig();
const hostConfig = sshHosts.find((h) => h.host === task.sshHost);
if (!hostConfig) {
ws.close(1008, `SSH 호스트를 찾을 수 없습니다: ${task.sshHost}`);
return;
}
/** 프로젝트의 remoteShell 설정을 조회한다 */
let remoteShell: string | null = null;
if (task.projectId) {
const projectRepo = await getProjectRepository();
const project = await projectRepo.findOneBy({ id: task.projectId });
remoteShell = project?.remoteShell ?? null;
}
await attachRemoteSession(
taskId,
task.sshHost,
task.sessionType,
task.sessionName,
windowName,
ws,
hostConfig,
initialCols,
initialRows,
remoteShell,
);
} else {
await attachLocalSession(taskId, task.sessionType, task.sessionName, windowName, ws, task.worktreePath, initialCols, initialRows);
}
} catch (error) {
console.error("터미널 연결 오류:", error);
ws.close(1011, "터미널 연결 실패");
}
});
/**
* 프로덕션 모드에서는 메인 HTTP 서버의 upgrade 이벤트를 가로채
* /api/terminal/* 및 /api/board/events 경로를 WebSocket 서버로 전달한다.
* 리버스 프록시 뒤에서 단일 포트만 노출해도 동작한다.
*/
if (!dev) {
server.on("upgrade", (req, socket, head) => {
const pathname = req.url?.split("?")[0] || "";
if (pathname.startsWith("/api/terminal/") || pathname === "/api/board/events") {
wss.handleUpgrade(req, socket, head, (ws) => {
wss.emit("connection", ws, req);
});
}
});
}
server.listen(port, hostname, () => {
console.log(`> KanVibe 서버 시작: http://${hostname}:${port}`);
});
wsHttpServer.listen(wsPort, hostname, () => {
console.log(`> 터미널 WebSocket 서버: ws://${hostname}:${wsPort}`);
});
});