@dev-anywhere/relay 0.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/LICENSE +21 -0
- package/README.md +71 -0
- package/dist/chaos.d.ts +22 -0
- package/dist/chaos.d.ts.map +1 -0
- package/dist/chunk-TBYEKF42.js +1487 -0
- package/dist/chunk-TBYEKF42.js.map +1 -0
- package/dist/handlers/client.d.ts +6 -0
- package/dist/handlers/client.d.ts.map +1 -0
- package/dist/handlers/proxy.d.ts +6 -0
- package/dist/handlers/proxy.d.ts.map +1 -0
- package/dist/health.d.ts +4 -0
- package/dist/health.d.ts.map +1 -0
- package/dist/heartbeat.d.ts +3 -0
- package/dist/heartbeat.d.ts.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +46 -0
- package/dist/index.js.map +1 -0
- package/dist/registry.d.ts +59 -0
- package/dist/registry.d.ts.map +1 -0
- package/dist/router.d.ts +22 -0
- package/dist/router.d.ts.map +1 -0
- package/dist/server.d.ts +20 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +8 -0
- package/dist/server.js.map +1 -0
- package/package.json +64 -0
|
@@ -0,0 +1,1487 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/server.ts
|
|
4
|
+
import express from "express";
|
|
5
|
+
import { createServer } from "http";
|
|
6
|
+
import { homedir as homedir2 } from "os";
|
|
7
|
+
import { WebSocketServer } from "ws";
|
|
8
|
+
|
|
9
|
+
// src/registry.ts
|
|
10
|
+
import { WebSocket } from "ws";
|
|
11
|
+
var RelayRegistry = class {
|
|
12
|
+
proxyStates = /* @__PURE__ */ new Map();
|
|
13
|
+
clientBindings = /* @__PURE__ */ new Map();
|
|
14
|
+
connectedClients = /* @__PURE__ */ new Set();
|
|
15
|
+
registerProxy(proxyId, ws, name) {
|
|
16
|
+
const existing = this.proxyStates.get(proxyId);
|
|
17
|
+
if (existing) {
|
|
18
|
+
if (existing.ws && existing.ws.readyState === WebSocket.OPEN) {
|
|
19
|
+
existing.ws.terminate();
|
|
20
|
+
}
|
|
21
|
+
existing.ws = ws;
|
|
22
|
+
existing.connectionState = "online";
|
|
23
|
+
existing.disconnectedAt = null;
|
|
24
|
+
if (name !== void 0) existing.name = name;
|
|
25
|
+
return "reconnected";
|
|
26
|
+
}
|
|
27
|
+
this.proxyStates.set(proxyId, {
|
|
28
|
+
ws,
|
|
29
|
+
connectionState: "online",
|
|
30
|
+
sessions: /* @__PURE__ */ new Set(),
|
|
31
|
+
disconnectedAt: null,
|
|
32
|
+
name
|
|
33
|
+
});
|
|
34
|
+
return "new";
|
|
35
|
+
}
|
|
36
|
+
// 标记 proxy 离线,保留所有状态等待重连,不设超时
|
|
37
|
+
// 清理只在 proxy 主动退出(proxy_disconnect)或 relay 启动清理废弃数据时发生
|
|
38
|
+
markProxyOffline(proxyId) {
|
|
39
|
+
const state = this.proxyStates.get(proxyId);
|
|
40
|
+
if (!state) return;
|
|
41
|
+
state.ws = null;
|
|
42
|
+
state.connectionState = "offline";
|
|
43
|
+
state.disconnectedAt = Date.now();
|
|
44
|
+
}
|
|
45
|
+
// 显式状态转换,校验 from 状态匹配后更新 connectionState
|
|
46
|
+
transitionProxy(proxyId, from, to) {
|
|
47
|
+
if (from === to) {
|
|
48
|
+
throw new Error(`Invalid proxy transition: ${from} -> ${to} (same state)`);
|
|
49
|
+
}
|
|
50
|
+
const state = this.proxyStates.get(proxyId);
|
|
51
|
+
if (!state) {
|
|
52
|
+
throw new Error(`Proxy not found: ${proxyId}`);
|
|
53
|
+
}
|
|
54
|
+
if (state.connectionState !== from) {
|
|
55
|
+
throw new Error(
|
|
56
|
+
`Proxy ${proxyId} state mismatch: expected ${from}, actual ${state.connectionState}`
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
state.connectionState = to;
|
|
60
|
+
if (to === "offline") {
|
|
61
|
+
state.ws = null;
|
|
62
|
+
state.disconnectedAt = Date.now();
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
// 显式客户端状态转换,校验 from 状态匹配后更新 connectionState
|
|
66
|
+
transitionClient(clientId, from, to) {
|
|
67
|
+
if (from === to) {
|
|
68
|
+
throw new Error(`Invalid client transition: ${from} -> ${to} (same state)`);
|
|
69
|
+
}
|
|
70
|
+
const binding = this.clientBindings.get(clientId);
|
|
71
|
+
if (!binding) {
|
|
72
|
+
throw new Error(`Client not found: ${clientId}`);
|
|
73
|
+
}
|
|
74
|
+
if (binding.connectionState !== from) {
|
|
75
|
+
throw new Error(
|
|
76
|
+
`Client ${clientId} state mismatch: expected ${from}, actual ${binding.connectionState}`
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
binding.connectionState = to;
|
|
80
|
+
}
|
|
81
|
+
getProxyConnectionState(proxyId) {
|
|
82
|
+
return this.proxyStates.get(proxyId)?.connectionState;
|
|
83
|
+
}
|
|
84
|
+
getClientConnectionState(clientId) {
|
|
85
|
+
return this.clientBindings.get(clientId)?.connectionState;
|
|
86
|
+
}
|
|
87
|
+
// 彻底清理 proxy 状态和客户端绑定
|
|
88
|
+
cleanupProxy(proxyId) {
|
|
89
|
+
const state = this.proxyStates.get(proxyId);
|
|
90
|
+
if (!state) return;
|
|
91
|
+
for (const [clientId, binding] of this.clientBindings) {
|
|
92
|
+
if (binding.proxyId === proxyId) {
|
|
93
|
+
this.clientBindings.delete(clientId);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
this.proxyStates.delete(proxyId);
|
|
97
|
+
}
|
|
98
|
+
unregisterProxy(proxyId) {
|
|
99
|
+
this.cleanupProxy(proxyId);
|
|
100
|
+
}
|
|
101
|
+
getProxy(proxyId) {
|
|
102
|
+
const state = this.proxyStates.get(proxyId);
|
|
103
|
+
return state?.ws ?? void 0;
|
|
104
|
+
}
|
|
105
|
+
isProxyOnline(proxyId) {
|
|
106
|
+
const state = this.proxyStates.get(proxyId);
|
|
107
|
+
if (!state || state.connectionState !== "online") return false;
|
|
108
|
+
if (!state.ws || state.ws.readyState !== WebSocket.OPEN) return false;
|
|
109
|
+
return true;
|
|
110
|
+
}
|
|
111
|
+
// proxy 是否存在(含宽限期中的)
|
|
112
|
+
hasProxy(proxyId) {
|
|
113
|
+
return this.proxyStates.has(proxyId);
|
|
114
|
+
}
|
|
115
|
+
listProxies() {
|
|
116
|
+
return Array.from(this.proxyStates.keys());
|
|
117
|
+
}
|
|
118
|
+
// 返回 proxyId、name、online 的列表,用于 proxy_list_response
|
|
119
|
+
listProxiesWithName() {
|
|
120
|
+
return Array.from(this.proxyStates.entries()).map(([proxyId, state]) => ({
|
|
121
|
+
proxyId,
|
|
122
|
+
...state.name !== void 0 ? { name: state.name } : {},
|
|
123
|
+
online: state.connectionState === "online"
|
|
124
|
+
}));
|
|
125
|
+
}
|
|
126
|
+
getProxyName(proxyId) {
|
|
127
|
+
return this.proxyStates.get(proxyId)?.name;
|
|
128
|
+
}
|
|
129
|
+
// 将 sessionId 关联到 proxy
|
|
130
|
+
addSessionToProxy(proxyId, sessionId) {
|
|
131
|
+
const state = this.proxyStates.get(proxyId);
|
|
132
|
+
if (state) {
|
|
133
|
+
state.sessions.add(sessionId);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
// 通过 sessionId 反查所属 proxyId
|
|
137
|
+
getProxyForSession(sessionId) {
|
|
138
|
+
for (const [proxyId, state] of this.proxyStates) {
|
|
139
|
+
if (state.sessions.has(sessionId)) {
|
|
140
|
+
return proxyId;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return void 0;
|
|
144
|
+
}
|
|
145
|
+
// 获取 proxy 关联的所有 sessionId
|
|
146
|
+
getSessionsForProxy(proxyId) {
|
|
147
|
+
const state = this.proxyStates.get(proxyId);
|
|
148
|
+
return state ? Array.from(state.sessions) : [];
|
|
149
|
+
}
|
|
150
|
+
// clientId 绑定方式
|
|
151
|
+
bindClientById(clientId, proxyId, ws) {
|
|
152
|
+
if (!this.proxyStates.has(proxyId)) {
|
|
153
|
+
return false;
|
|
154
|
+
}
|
|
155
|
+
this.clientBindings.set(clientId, { proxyId, ws, connectionState: "bound" });
|
|
156
|
+
return true;
|
|
157
|
+
}
|
|
158
|
+
updateClientSocket(clientId, ws) {
|
|
159
|
+
const binding = this.clientBindings.get(clientId);
|
|
160
|
+
if (binding) {
|
|
161
|
+
binding.ws = ws;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
// 断开客户端 WebSocket 但保留绑定关系,重连时可恢复
|
|
165
|
+
unbindClientById(clientId) {
|
|
166
|
+
const binding = this.clientBindings.get(clientId);
|
|
167
|
+
if (binding) {
|
|
168
|
+
binding.ws = null;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
getClientBinding(clientId) {
|
|
172
|
+
return this.clientBindings.get(clientId);
|
|
173
|
+
}
|
|
174
|
+
// 获取绑定到指定 proxy 的所有活跃客户端 WebSocket
|
|
175
|
+
getClientsForProxy(proxyId) {
|
|
176
|
+
const clients = [];
|
|
177
|
+
for (const [, binding] of this.clientBindings) {
|
|
178
|
+
if (binding.proxyId === proxyId && binding.ws && binding.ws.readyState === WebSocket.OPEN) {
|
|
179
|
+
clients.push(binding.ws);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
return clients;
|
|
183
|
+
}
|
|
184
|
+
countClients() {
|
|
185
|
+
let count = 0;
|
|
186
|
+
for (const [, binding] of this.clientBindings) {
|
|
187
|
+
if (binding.ws) count++;
|
|
188
|
+
}
|
|
189
|
+
return count;
|
|
190
|
+
}
|
|
191
|
+
addClientWs(ws) {
|
|
192
|
+
this.connectedClients.add(ws);
|
|
193
|
+
}
|
|
194
|
+
removeClientWs(ws) {
|
|
195
|
+
this.connectedClients.delete(ws);
|
|
196
|
+
}
|
|
197
|
+
getAllClientWs() {
|
|
198
|
+
const clients = [];
|
|
199
|
+
for (const ws of this.connectedClients) {
|
|
200
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
201
|
+
clients.push(ws);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
return clients;
|
|
205
|
+
}
|
|
206
|
+
// 获取单个 proxy 的详细状态信息
|
|
207
|
+
getProxyDetail(proxyId) {
|
|
208
|
+
const state = this.proxyStates.get(proxyId);
|
|
209
|
+
if (!state) return void 0;
|
|
210
|
+
return {
|
|
211
|
+
proxyId,
|
|
212
|
+
...state.name !== void 0 ? { name: state.name } : {},
|
|
213
|
+
online: state.connectionState === "online",
|
|
214
|
+
connectionState: state.connectionState,
|
|
215
|
+
sessions: Array.from(state.sessions),
|
|
216
|
+
disconnectedAt: state.disconnectedAt
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
// 获取所有客户端绑定的详细信息
|
|
220
|
+
getClientDetails() {
|
|
221
|
+
const details = [];
|
|
222
|
+
for (const [clientId, binding] of this.clientBindings) {
|
|
223
|
+
details.push({
|
|
224
|
+
clientId,
|
|
225
|
+
proxyId: binding.proxyId,
|
|
226
|
+
online: binding.ws !== null && binding.ws !== void 0 && binding.ws.readyState === WebSocket.OPEN,
|
|
227
|
+
connectionState: binding.connectionState
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
return details;
|
|
231
|
+
}
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
// src/health.ts
|
|
235
|
+
import { Router } from "express";
|
|
236
|
+
function healthRouter(registry) {
|
|
237
|
+
const router = Router();
|
|
238
|
+
router.get("/health", (_req, res) => {
|
|
239
|
+
res.json({
|
|
240
|
+
status: "ok",
|
|
241
|
+
uptime: process.uptime()
|
|
242
|
+
});
|
|
243
|
+
});
|
|
244
|
+
router.get("/status", (_req, res) => {
|
|
245
|
+
res.json({
|
|
246
|
+
proxyCount: registry.listProxies().length,
|
|
247
|
+
clientCount: registry.countClients(),
|
|
248
|
+
uptime: process.uptime()
|
|
249
|
+
});
|
|
250
|
+
});
|
|
251
|
+
router.get("/api/status", (_req, res) => {
|
|
252
|
+
res.json({
|
|
253
|
+
proxyCount: registry.listProxies().length,
|
|
254
|
+
clientCount: registry.countClients(),
|
|
255
|
+
uptime: process.uptime(),
|
|
256
|
+
bindings: registry.getClientDetails()
|
|
257
|
+
});
|
|
258
|
+
});
|
|
259
|
+
router.get("/api/proxies", (_req, res) => {
|
|
260
|
+
const proxyIds = registry.listProxies();
|
|
261
|
+
const details = proxyIds.map((id) => registry.getProxyDetail(id)).filter((d) => d !== void 0);
|
|
262
|
+
res.json(details);
|
|
263
|
+
});
|
|
264
|
+
router.get("/api/clients", (_req, res) => {
|
|
265
|
+
res.json(registry.getClientDetails());
|
|
266
|
+
});
|
|
267
|
+
return router;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// src/handlers/proxy.ts
|
|
271
|
+
import { WebSocket as WebSocket3 } from "ws";
|
|
272
|
+
|
|
273
|
+
// ../../packages/shared/dist/schemas/envelope.js
|
|
274
|
+
import { z as z5 } from "zod";
|
|
275
|
+
|
|
276
|
+
// ../../packages/shared/dist/schemas/chat.js
|
|
277
|
+
import { z } from "zod";
|
|
278
|
+
var UserInputPayloadSchema = z.object({
|
|
279
|
+
text: z.string().min(1)
|
|
280
|
+
});
|
|
281
|
+
var AssistantMessagePayloadSchema = z.object({
|
|
282
|
+
text: z.string(),
|
|
283
|
+
isPartial: z.boolean()
|
|
284
|
+
});
|
|
285
|
+
var ThinkingPayloadSchema = z.object({
|
|
286
|
+
text: z.string()
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
// ../../packages/shared/dist/schemas/tool.js
|
|
290
|
+
import { z as z2 } from "zod";
|
|
291
|
+
var ToolUseRequestPayloadSchema = z2.object({
|
|
292
|
+
toolName: z2.string(),
|
|
293
|
+
toolId: z2.string(),
|
|
294
|
+
parameters: z2.record(z2.string(), z2.unknown())
|
|
295
|
+
});
|
|
296
|
+
var ToolApprovePayloadSchema = z2.object({
|
|
297
|
+
toolId: z2.string(),
|
|
298
|
+
whitelistTool: z2.boolean().optional()
|
|
299
|
+
});
|
|
300
|
+
var ToolDenyPayloadSchema = z2.object({
|
|
301
|
+
toolId: z2.string(),
|
|
302
|
+
reason: z2.string().optional()
|
|
303
|
+
});
|
|
304
|
+
var ToolResultPayloadSchema = z2.object({
|
|
305
|
+
toolId: z2.string(),
|
|
306
|
+
result: z2.unknown(),
|
|
307
|
+
isError: z2.boolean()
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
// ../../packages/shared/dist/schemas/session.js
|
|
311
|
+
import { z as z3 } from "zod";
|
|
312
|
+
var sessionStateValues = ["idle", "working", "waiting_approval", "error", "terminated"];
|
|
313
|
+
var providerValues = ["claude", "codex"];
|
|
314
|
+
var ptyOwnerValues = ["local-terminal", "proxy-hosted"];
|
|
315
|
+
var agentStatusPhaseValues = [
|
|
316
|
+
"idle",
|
|
317
|
+
"thinking",
|
|
318
|
+
"tool_use",
|
|
319
|
+
"outputting",
|
|
320
|
+
"waiting_permission",
|
|
321
|
+
"error"
|
|
322
|
+
];
|
|
323
|
+
var SessionInfoSchema = z3.object({
|
|
324
|
+
sessionId: z3.string(),
|
|
325
|
+
name: z3.string().optional(),
|
|
326
|
+
state: z3.enum(sessionStateValues),
|
|
327
|
+
mode: z3.enum(["pty", "json"]).optional(),
|
|
328
|
+
provider: z3.enum(providerValues),
|
|
329
|
+
// PTY 尺寸所有权:
|
|
330
|
+
// - local-terminal: 本地 terminal 进程持有真实 PTY,Web 只按原始 cols/rows 展示
|
|
331
|
+
// - proxy-hosted: serve 内托管 PTY,Web 可按视口请求 resize
|
|
332
|
+
ptyOwner: z3.enum(ptyOwnerValues).optional(),
|
|
333
|
+
lastActive: z3.number().optional()
|
|
334
|
+
});
|
|
335
|
+
var SessionCreatePayloadSchema = z3.object({
|
|
336
|
+
name: z3.string().optional(),
|
|
337
|
+
cwd: z3.string().optional(),
|
|
338
|
+
streamDelta: z3.boolean().optional()
|
|
339
|
+
});
|
|
340
|
+
var SessionListPayloadSchema = z3.object({
|
|
341
|
+
sessions: z3.array(SessionInfoSchema)
|
|
342
|
+
});
|
|
343
|
+
var SessionSwitchPayloadSchema = z3.object({
|
|
344
|
+
sessionId: z3.string()
|
|
345
|
+
});
|
|
346
|
+
var SessionTerminatePayloadSchema = z3.object({
|
|
347
|
+
sessionId: z3.string()
|
|
348
|
+
});
|
|
349
|
+
var SessionStatusPayloadSchema = z3.object({
|
|
350
|
+
sessionId: z3.string(),
|
|
351
|
+
state: z3.enum(sessionStateValues),
|
|
352
|
+
lastActive: z3.number()
|
|
353
|
+
});
|
|
354
|
+
var PtyStatePayloadSchema = z3.object({
|
|
355
|
+
state: z3.enum(["working", "turn_complete", "approval_wait", "mid_pause"]),
|
|
356
|
+
title: z3.string().optional(),
|
|
357
|
+
tool: z3.string().optional()
|
|
358
|
+
});
|
|
359
|
+
var AgentStatusPayloadSchema = z3.object({
|
|
360
|
+
provider: z3.enum(providerValues),
|
|
361
|
+
phase: z3.enum(agentStatusPhaseValues),
|
|
362
|
+
seq: z3.number().int().nonnegative(),
|
|
363
|
+
updatedAt: z3.number(),
|
|
364
|
+
toolName: z3.string().optional(),
|
|
365
|
+
toolInput: z3.record(z3.string(), z3.unknown()).optional(),
|
|
366
|
+
permissionRequest: z3.object({
|
|
367
|
+
requestId: z3.string(),
|
|
368
|
+
toolName: z3.string(),
|
|
369
|
+
input: z3.record(z3.string(), z3.unknown())
|
|
370
|
+
}).optional(),
|
|
371
|
+
permissionResolution: z3.object({
|
|
372
|
+
requestId: z3.string(),
|
|
373
|
+
outcome: z3.enum(["allow", "deny"])
|
|
374
|
+
}).optional(),
|
|
375
|
+
summary: z3.string().optional()
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
// ../../packages/shared/dist/schemas/system.js
|
|
379
|
+
import { z as z4 } from "zod";
|
|
380
|
+
var HeartbeatPayloadSchema = z4.object({});
|
|
381
|
+
var AuthPayloadSchema = z4.object({
|
|
382
|
+
pairingCode: z4.string().optional(),
|
|
383
|
+
token: z4.string().optional()
|
|
384
|
+
});
|
|
385
|
+
var SyncRequestPayloadSchema = z4.object({
|
|
386
|
+
lastSeq: z4.number().int().nonnegative()
|
|
387
|
+
});
|
|
388
|
+
var SyncResponsePayloadSchema = z4.object({
|
|
389
|
+
messages: z4.array(z4.record(z4.string(), z4.unknown()))
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
// ../../packages/shared/dist/schemas/envelope.js
|
|
393
|
+
var BaseEnvelopeFields = {
|
|
394
|
+
seq: z5.number().int().nonnegative(),
|
|
395
|
+
sessionId: z5.string(),
|
|
396
|
+
timestamp: z5.number(),
|
|
397
|
+
source: z5.enum(["proxy", "client"]),
|
|
398
|
+
version: z5.string()
|
|
399
|
+
};
|
|
400
|
+
var MessageEnvelopeSchema = z5.discriminatedUnion("type", [
|
|
401
|
+
// chat (3)
|
|
402
|
+
z5.object({
|
|
403
|
+
...BaseEnvelopeFields,
|
|
404
|
+
type: z5.literal("user_input"),
|
|
405
|
+
payload: UserInputPayloadSchema
|
|
406
|
+
}),
|
|
407
|
+
z5.object({
|
|
408
|
+
...BaseEnvelopeFields,
|
|
409
|
+
type: z5.literal("assistant_message"),
|
|
410
|
+
payload: AssistantMessagePayloadSchema
|
|
411
|
+
}),
|
|
412
|
+
z5.object({
|
|
413
|
+
...BaseEnvelopeFields,
|
|
414
|
+
type: z5.literal("thinking"),
|
|
415
|
+
payload: ThinkingPayloadSchema
|
|
416
|
+
}),
|
|
417
|
+
// tool (4): 工具审批决策属于 relay control,不进入会话消息信封。
|
|
418
|
+
// tool_use_request: 审批流请求(proxy → client),toolId 是 approval requestId
|
|
419
|
+
z5.object({
|
|
420
|
+
...BaseEnvelopeFields,
|
|
421
|
+
type: z5.literal("tool_use_request"),
|
|
422
|
+
payload: ToolUseRequestPayloadSchema
|
|
423
|
+
}),
|
|
424
|
+
// tool_result: 工具执行结果(proxy → client),toolId 对应 assistant_tool_use / tool_use_request 的 toolId
|
|
425
|
+
z5.object({
|
|
426
|
+
...BaseEnvelopeFields,
|
|
427
|
+
type: z5.literal("tool_result"),
|
|
428
|
+
payload: ToolResultPayloadSchema
|
|
429
|
+
}),
|
|
430
|
+
// assistant_tool_use: 纯展示型工具调用(proxy → client),区别于 tool_use_request 无审批语义
|
|
431
|
+
// payload 结构复用 ToolUseRequestPayloadSchema;toolId 是 Claude 分配的 tool_use id
|
|
432
|
+
z5.object({
|
|
433
|
+
...BaseEnvelopeFields,
|
|
434
|
+
type: z5.literal("assistant_tool_use"),
|
|
435
|
+
payload: ToolUseRequestPayloadSchema
|
|
436
|
+
}),
|
|
437
|
+
// session (5)
|
|
438
|
+
z5.object({
|
|
439
|
+
...BaseEnvelopeFields,
|
|
440
|
+
type: z5.literal("session_create"),
|
|
441
|
+
payload: SessionCreatePayloadSchema
|
|
442
|
+
}),
|
|
443
|
+
z5.object({
|
|
444
|
+
...BaseEnvelopeFields,
|
|
445
|
+
type: z5.literal("session_list"),
|
|
446
|
+
payload: SessionListPayloadSchema
|
|
447
|
+
}),
|
|
448
|
+
z5.object({
|
|
449
|
+
...BaseEnvelopeFields,
|
|
450
|
+
type: z5.literal("session_switch"),
|
|
451
|
+
payload: SessionSwitchPayloadSchema
|
|
452
|
+
}),
|
|
453
|
+
z5.object({
|
|
454
|
+
...BaseEnvelopeFields,
|
|
455
|
+
type: z5.literal("session_terminate"),
|
|
456
|
+
payload: SessionTerminatePayloadSchema
|
|
457
|
+
}),
|
|
458
|
+
z5.object({
|
|
459
|
+
...BaseEnvelopeFields,
|
|
460
|
+
type: z5.literal("session_status"),
|
|
461
|
+
payload: SessionStatusPayloadSchema
|
|
462
|
+
}),
|
|
463
|
+
// system (5)
|
|
464
|
+
z5.object({
|
|
465
|
+
...BaseEnvelopeFields,
|
|
466
|
+
type: z5.literal("heartbeat"),
|
|
467
|
+
payload: HeartbeatPayloadSchema
|
|
468
|
+
}),
|
|
469
|
+
z5.object({
|
|
470
|
+
...BaseEnvelopeFields,
|
|
471
|
+
type: z5.literal("auth"),
|
|
472
|
+
payload: AuthPayloadSchema
|
|
473
|
+
}),
|
|
474
|
+
z5.object({
|
|
475
|
+
...BaseEnvelopeFields,
|
|
476
|
+
type: z5.literal("sync_request"),
|
|
477
|
+
payload: SyncRequestPayloadSchema
|
|
478
|
+
}),
|
|
479
|
+
z5.object({
|
|
480
|
+
...BaseEnvelopeFields,
|
|
481
|
+
type: z5.literal("sync_response"),
|
|
482
|
+
payload: SyncResponsePayloadSchema
|
|
483
|
+
})
|
|
484
|
+
]);
|
|
485
|
+
|
|
486
|
+
// ../../packages/shared/dist/schemas/relay-control.js
|
|
487
|
+
import { z as z6 } from "zod";
|
|
488
|
+
|
|
489
|
+
// ../../packages/shared/dist/constants/relay-errors.js
|
|
490
|
+
var RelayErrorCode = {
|
|
491
|
+
NOT_REGISTERED: "NOT_REGISTERED",
|
|
492
|
+
NOT_BOUND: "NOT_BOUND",
|
|
493
|
+
PROXY_OFFLINE: "PROXY_OFFLINE",
|
|
494
|
+
INVALID_MESSAGE: "INVALID_MESSAGE",
|
|
495
|
+
UNSUPPORTED: "UNSUPPORTED",
|
|
496
|
+
INVALID_RANGE: "INVALID_RANGE"
|
|
497
|
+
};
|
|
498
|
+
|
|
499
|
+
// ../../packages/shared/dist/constants/control-errors.js
|
|
500
|
+
var ControlErrorCode = {
|
|
501
|
+
INVALID_PATH: "INVALID_PATH",
|
|
502
|
+
PATH_NOT_FOUND: "PATH_NOT_FOUND",
|
|
503
|
+
PATH_NOT_DIRECTORY: "PATH_NOT_DIRECTORY",
|
|
504
|
+
PATH_ACCESS_DENIED: "PATH_ACCESS_DENIED",
|
|
505
|
+
PROXY_OFFLINE: "PROXY_OFFLINE",
|
|
506
|
+
SESSION_NOT_FOUND: "SESSION_NOT_FOUND",
|
|
507
|
+
PROVIDER_UNSUPPORTED: "PROVIDER_UNSUPPORTED",
|
|
508
|
+
WORKER_START_FAILED: "WORKER_START_FAILED",
|
|
509
|
+
PROCESS_START_FAILED: "PROCESS_START_FAILED",
|
|
510
|
+
UNKNOWN: "UNKNOWN"
|
|
511
|
+
};
|
|
512
|
+
|
|
513
|
+
// ../../packages/shared/dist/schemas/relay-control.js
|
|
514
|
+
var ProxyInfoSchema = z6.object({
|
|
515
|
+
proxyId: z6.string(),
|
|
516
|
+
name: z6.string().optional(),
|
|
517
|
+
online: z6.boolean(),
|
|
518
|
+
sessions: z6.array(z6.string()).optional()
|
|
519
|
+
});
|
|
520
|
+
var AgentCliAvailabilitySchema = z6.object({
|
|
521
|
+
available: z6.boolean(),
|
|
522
|
+
command: z6.string().optional(),
|
|
523
|
+
error: z6.string().optional(),
|
|
524
|
+
suggestions: z6.array(z6.string()).optional()
|
|
525
|
+
});
|
|
526
|
+
var AgentCliStatusSchema = z6.object({
|
|
527
|
+
claude: AgentCliAvailabilitySchema,
|
|
528
|
+
codex: AgentCliAvailabilitySchema
|
|
529
|
+
});
|
|
530
|
+
var DirEntrySchema = z6.object({ name: z6.string(), isDir: z6.boolean() });
|
|
531
|
+
var FileTreeGroupSchema = z6.object({
|
|
532
|
+
path: z6.string(),
|
|
533
|
+
entries: z6.array(DirEntrySchema)
|
|
534
|
+
});
|
|
535
|
+
var CommandEntrySchema = z6.object({
|
|
536
|
+
name: z6.string(),
|
|
537
|
+
description: z6.string(),
|
|
538
|
+
argumentHint: z6.string().optional(),
|
|
539
|
+
source: z6.string()
|
|
540
|
+
});
|
|
541
|
+
var HistorySessionSchema = z6.object({
|
|
542
|
+
id: z6.string(),
|
|
543
|
+
title: z6.string(),
|
|
544
|
+
projectDir: z6.string(),
|
|
545
|
+
updatedAt: z6.number(),
|
|
546
|
+
provider: z6.enum(["claude", "codex"]).optional()
|
|
547
|
+
});
|
|
548
|
+
var RequestIdShape = { requestId: z6.string().min(1).optional() };
|
|
549
|
+
var ControlErrorCodeSchema = z6.enum(Object.values(ControlErrorCode));
|
|
550
|
+
var RequestErrorShape = {
|
|
551
|
+
error: z6.string().optional(),
|
|
552
|
+
errorCode: ControlErrorCodeSchema.optional()
|
|
553
|
+
};
|
|
554
|
+
function control(type, shape, directions) {
|
|
555
|
+
return {
|
|
556
|
+
type,
|
|
557
|
+
directions: new Set(Array.isArray(directions) ? directions : directions ? [directions] : []),
|
|
558
|
+
schema: z6.object({
|
|
559
|
+
type: z6.literal(type),
|
|
560
|
+
...shape ?? {}
|
|
561
|
+
})
|
|
562
|
+
};
|
|
563
|
+
}
|
|
564
|
+
var relayControlDefinitions = [
|
|
565
|
+
control("proxy_register", {
|
|
566
|
+
proxyId: z6.string().min(1),
|
|
567
|
+
name: z6.string().optional()
|
|
568
|
+
}),
|
|
569
|
+
control("proxy_register_response", {
|
|
570
|
+
status: z6.enum(["new", "reconnected"])
|
|
571
|
+
}),
|
|
572
|
+
control("proxy_list_request", RequestIdShape),
|
|
573
|
+
control("proxy_list_response", {
|
|
574
|
+
...RequestIdShape,
|
|
575
|
+
proxies: z6.array(ProxyInfoSchema)
|
|
576
|
+
}),
|
|
577
|
+
control("proxy_select", { ...RequestIdShape, proxyId: z6.string().min(1) }),
|
|
578
|
+
control("proxy_select_response", {
|
|
579
|
+
...RequestIdShape,
|
|
580
|
+
success: z6.boolean(),
|
|
581
|
+
proxyId: z6.string().optional(),
|
|
582
|
+
...RequestErrorShape
|
|
583
|
+
}),
|
|
584
|
+
control("relay_error", {
|
|
585
|
+
code: z6.enum(Object.values(RelayErrorCode)),
|
|
586
|
+
message: z6.string()
|
|
587
|
+
}),
|
|
588
|
+
// 客户端注册协议
|
|
589
|
+
control("client_register", {
|
|
590
|
+
clientId: z6.string().min(1)
|
|
591
|
+
}),
|
|
592
|
+
control("client_register_response", {
|
|
593
|
+
status: z6.enum(["restored", "proxy_offline", "new"]),
|
|
594
|
+
proxyId: z6.string().optional()
|
|
595
|
+
}),
|
|
596
|
+
// Proxy 离线通知
|
|
597
|
+
control("proxy_offline", {
|
|
598
|
+
proxyId: z6.string()
|
|
599
|
+
}),
|
|
600
|
+
// Proxy 主动断开,relay 立即清理资源
|
|
601
|
+
control("proxy_disconnect", {
|
|
602
|
+
proxyId: z6.string().min(1)
|
|
603
|
+
}),
|
|
604
|
+
// Proxy 重连后通知 client 恢复
|
|
605
|
+
control("proxy_online", {
|
|
606
|
+
proxyId: z6.string().min(1)
|
|
607
|
+
}),
|
|
608
|
+
// 目录列表请求与响应
|
|
609
|
+
control("dir_list_request", {
|
|
610
|
+
proxyId: z6.string().min(1).optional(),
|
|
611
|
+
...RequestIdShape,
|
|
612
|
+
path: z6.string()
|
|
613
|
+
}, "client_to_proxy"),
|
|
614
|
+
control("dir_list_response", { ...RequestIdShape, ...RequestErrorShape, entries: z6.array(DirEntrySchema), path: z6.string() }, "proxy_to_client"),
|
|
615
|
+
// 目录创建请求与响应
|
|
616
|
+
control("dir_create_request", { ...RequestIdShape, path: z6.string() }, "client_to_proxy"),
|
|
617
|
+
control("dir_create_response", {
|
|
618
|
+
...RequestIdShape,
|
|
619
|
+
...RequestErrorShape,
|
|
620
|
+
path: z6.string(),
|
|
621
|
+
success: z6.boolean()
|
|
622
|
+
}, "proxy_to_client"),
|
|
623
|
+
// 命令列表推送,proxy 将可用命令列表推给 client
|
|
624
|
+
control("command_list_push", { commands: z6.array(CommandEntrySchema) }, "proxy_to_client"),
|
|
625
|
+
// 文件树推送: 按目录分组, 首组 path 即为 session cwd
|
|
626
|
+
// 前端直接把每组写入 tree[path], 与 dir_list_response 共享 cache slot
|
|
627
|
+
control("file_tree_push", {
|
|
628
|
+
groups: z6.array(FileTreeGroupSchema)
|
|
629
|
+
}, "proxy_to_client"),
|
|
630
|
+
// 会话列表请求与权限模式变更
|
|
631
|
+
control("session_list", void 0, ["client_to_proxy", "proxy_to_client"]),
|
|
632
|
+
control("permission_mode_change", {
|
|
633
|
+
mode: z6.enum(["default", "auto_accept", "plan"]),
|
|
634
|
+
// sessionId 可选:传入时 proxy 按该会话的 mode 分叉(PTY 发 Tab ANSI),未传走全局日志行为
|
|
635
|
+
sessionId: z6.string().optional()
|
|
636
|
+
}, "client_to_proxy"),
|
|
637
|
+
// 会话历史浏览
|
|
638
|
+
control("session_history_request", RequestIdShape, "client_to_proxy"),
|
|
639
|
+
control("session_history_response", { ...RequestIdShape, sessions: z6.array(HistorySessionSchema) }, "proxy_to_client"),
|
|
640
|
+
// PTY 语义状态,从 Envelope 迁移到 Control 层
|
|
641
|
+
control("pty_state", { sessionId: z6.string(), payload: PtyStatePayloadSchema }, "proxy_to_client"),
|
|
642
|
+
// Provider 语义状态,来自 Claude/Codex hook 等结构化事件,不从 PTY 字节推断
|
|
643
|
+
control("agent_status", { sessionId: z6.string(), payload: AgentStatusPayloadSchema }, "proxy_to_client"),
|
|
644
|
+
// 终端标题变化,proxy -> client
|
|
645
|
+
control("terminal_title", { sessionId: z6.string(), title: z6.string() }, "proxy_to_client"),
|
|
646
|
+
// 终端尺寸变化,proxy -> client
|
|
647
|
+
control("terminal_resize", { sessionId: z6.string(), cols: z6.number().int().positive(), rows: z6.number().int().positive() }, "proxy_to_client"),
|
|
648
|
+
control("terminal_resize_request", { sessionId: z6.string(), cols: z6.number().int().positive(), rows: z6.number().int().positive() }, "client_to_proxy"),
|
|
649
|
+
// 远程终止 JSON 会话,client -> proxy
|
|
650
|
+
control("session_terminate", { sessionId: z6.string() }, "client_to_proxy"),
|
|
651
|
+
// 中断当前 turn,client -> proxy,SIGINT 到 worker 进程让 claude CLI abort 当前流
|
|
652
|
+
control("session_worker_abort", { sessionId: z6.string() }, "client_to_proxy"),
|
|
653
|
+
// turn 完成信号,proxy -> client,对应 claude stream-json 的 result 事件
|
|
654
|
+
control("turn_result", {
|
|
655
|
+
sessionId: z6.string(),
|
|
656
|
+
success: z6.boolean(),
|
|
657
|
+
isError: z6.boolean(),
|
|
658
|
+
// stream-json result.result 是本轮最终文本。assistant_message 流丢失或 CLI 未发增量时,
|
|
659
|
+
// Web 用它作为 JSON 模式兜底展示,避免 turn 已结束但界面空白。
|
|
660
|
+
result: z6.string().optional()
|
|
661
|
+
}, "proxy_to_client"),
|
|
662
|
+
// 客户端发送到 PTY 的原始字节(ANSI 序列),不追加换行
|
|
663
|
+
control("remote_input_raw", { sessionId: z6.string().min(1), data: z6.string() }, "client_to_proxy"),
|
|
664
|
+
// 客户端询问 proxy 的环境信息 (home 路径等), client -> proxy -> response
|
|
665
|
+
// FilePathPicker 用 homePath 作为 select 模式下的默认起点, 新建会话时打开即可浏览
|
|
666
|
+
control("proxy_info_request", RequestIdShape, "client_to_proxy"),
|
|
667
|
+
control("proxy_info", { ...RequestIdShape, homePath: z6.string(), agentCli: AgentCliStatusSchema }, "proxy_to_client"),
|
|
668
|
+
control("agent_cli_config_update", { ...RequestIdShape, provider: z6.enum(["claude", "codex"]), path: z6.string().min(1) }, "client_to_proxy"),
|
|
669
|
+
control("agent_cli_config_update_response", {
|
|
670
|
+
...RequestIdShape,
|
|
671
|
+
provider: z6.enum(["claude", "codex"]),
|
|
672
|
+
agentCli: AgentCliStatusSchema.optional(),
|
|
673
|
+
...RequestErrorShape
|
|
674
|
+
}, "proxy_to_client"),
|
|
675
|
+
// 远程创建 JSON 会话,client -> proxy -> response
|
|
676
|
+
control("session_create", {
|
|
677
|
+
...RequestIdShape,
|
|
678
|
+
cwd: z6.string(),
|
|
679
|
+
provider: z6.enum(["claude", "codex"]),
|
|
680
|
+
mode: z6.enum(["json", "pty"]).optional(),
|
|
681
|
+
resumeSessionId: z6.string().optional(),
|
|
682
|
+
// 透传给 claude CLI 的 --permission-mode, undefined 时 proxy 兜底为 "default"
|
|
683
|
+
permissionMode: z6.enum(["default", "auto", "acceptEdits", "plan", "bypassPermissions", "dontAsk"]).optional()
|
|
684
|
+
}, "client_to_proxy"),
|
|
685
|
+
control("session_create_response", {
|
|
686
|
+
...RequestIdShape,
|
|
687
|
+
sessionId: z6.string(),
|
|
688
|
+
mode: z6.enum(["json", "pty"]).optional(),
|
|
689
|
+
provider: z6.enum(["claude", "codex"]).optional(),
|
|
690
|
+
ptyOwner: z6.enum(["local-terminal", "proxy-hosted"]).optional(),
|
|
691
|
+
...RequestErrorShape
|
|
692
|
+
}, "proxy_to_client"),
|
|
693
|
+
// 客户端请求会话历史消息,client -> proxy
|
|
694
|
+
control("session_messages_request", { ...RequestIdShape, sessionId: z6.string() }, "client_to_proxy"),
|
|
695
|
+
// 客户端请求会话资源(命令列表 + 文件树),client -> proxy
|
|
696
|
+
control("session_resources_request", { ...RequestIdShape, sessionId: z6.string() }, "client_to_proxy"),
|
|
697
|
+
control("session_resources_response", {
|
|
698
|
+
...RequestIdShape,
|
|
699
|
+
...RequestErrorShape,
|
|
700
|
+
sessionId: z6.string(),
|
|
701
|
+
commands: z6.array(CommandEntrySchema),
|
|
702
|
+
groups: z6.array(FileTreeGroupSchema)
|
|
703
|
+
}, "proxy_to_client"),
|
|
704
|
+
// 客户端请求当前 provider 语义状态;不经 relay 缓存,由 proxy 返回当前值
|
|
705
|
+
control("agent_status_request", { ...RequestIdShape, sessionId: z6.string().optional() }, "client_to_proxy"),
|
|
706
|
+
control("agent_status_response", {
|
|
707
|
+
...RequestIdShape,
|
|
708
|
+
statuses: z6.array(z6.object({ sessionId: z6.string(), payload: AgentStatusPayloadSchema }))
|
|
709
|
+
}, "proxy_to_client"),
|
|
710
|
+
// 客户端确认已收到审批请求;proxy 只记录送达状态,不把它当成用户决策
|
|
711
|
+
control("permission_request_delivered", { sessionId: z6.string(), requestId: z6.string() }, "client_to_proxy"),
|
|
712
|
+
control("tool_approve", { sessionId: z6.string(), payload: ToolApprovePayloadSchema }, "client_to_proxy"),
|
|
713
|
+
control("tool_deny", { sessionId: z6.string(), payload: ToolDenyPayloadSchema }, "client_to_proxy"),
|
|
714
|
+
// proxy 确认用户决策已进入 provider/worker 路径;web 用它更新审批卡片状态
|
|
715
|
+
control("permission_decision_result", {
|
|
716
|
+
sessionId: z6.string(),
|
|
717
|
+
requestId: z6.string(),
|
|
718
|
+
outcome: z6.enum(["allow", "deny"]),
|
|
719
|
+
delivered: z6.boolean(),
|
|
720
|
+
message: z6.string().optional()
|
|
721
|
+
}, "proxy_to_client"),
|
|
722
|
+
// proxy 推送当前 pending 的工具审批列表,client 据此恢复审批卡片
|
|
723
|
+
control("pending_approvals_push", {
|
|
724
|
+
sessionId: z6.string(),
|
|
725
|
+
approvals: z6.array(z6.object({
|
|
726
|
+
requestId: z6.string(),
|
|
727
|
+
toolName: z6.string(),
|
|
728
|
+
input: z6.record(z6.string(), z6.unknown())
|
|
729
|
+
}))
|
|
730
|
+
}, "proxy_to_client"),
|
|
731
|
+
// 恢复会话时推送历史消息,proxy -> client
|
|
732
|
+
control("session_history_messages", {
|
|
733
|
+
...RequestIdShape,
|
|
734
|
+
sessionId: z6.string(),
|
|
735
|
+
messages: z6.array(z6.object({
|
|
736
|
+
role: z6.enum(["user", "assistant"]),
|
|
737
|
+
text: z6.string(),
|
|
738
|
+
timestamp: z6.number().optional()
|
|
739
|
+
}))
|
|
740
|
+
}, "proxy_to_client"),
|
|
741
|
+
// proxy 重连后同步活跃 session 列表给 relay
|
|
742
|
+
control("session_sync", {
|
|
743
|
+
sessions: z6.array(z6.object({
|
|
744
|
+
id: z6.string(),
|
|
745
|
+
mode: z6.enum(["pty", "json"]),
|
|
746
|
+
provider: z6.enum(["claude", "codex"]),
|
|
747
|
+
ptyOwner: z6.enum(["local-terminal", "proxy-hosted"]).optional(),
|
|
748
|
+
state: z6.string()
|
|
749
|
+
}))
|
|
750
|
+
}),
|
|
751
|
+
// PTY 会话订阅,client -> proxy,触发 terminal serialize() 返回当前状态
|
|
752
|
+
control("session_subscribe", { sessionId: z6.string(), requestId: z6.string().optional() }, "client_to_proxy"),
|
|
753
|
+
// PTY 会话快照,proxy -> client,serialize() 的全量终端状态
|
|
754
|
+
control("session_snapshot", {
|
|
755
|
+
sessionId: z6.string(),
|
|
756
|
+
cols: z6.number().int().positive(),
|
|
757
|
+
rows: z6.number().int().positive(),
|
|
758
|
+
data: z6.string(),
|
|
759
|
+
outputSeq: z6.number().int().nonnegative(),
|
|
760
|
+
requestId: z6.string().optional()
|
|
761
|
+
}, "proxy_to_client")
|
|
762
|
+
];
|
|
763
|
+
var relayControlSchemas = relayControlDefinitions.map((definition) => definition.schema);
|
|
764
|
+
var RelayControlSchema = z6.discriminatedUnion("type", relayControlSchemas);
|
|
765
|
+
var ProxyToClientRelayControlTypes = new Set(relayControlDefinitions.filter((definition) => definition.directions.has("proxy_to_client")).map((definition) => definition.type));
|
|
766
|
+
function isProxyToClientRelayControlType(type) {
|
|
767
|
+
return ProxyToClientRelayControlTypes.has(type);
|
|
768
|
+
}
|
|
769
|
+
var ClientToProxyRelayControlTypes = new Set(relayControlDefinitions.filter((definition) => definition.directions.has("client_to_proxy")).map((definition) => definition.type));
|
|
770
|
+
function isClientToProxyRelayControlType(type) {
|
|
771
|
+
return ClientToProxyRelayControlTypes.has(type);
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
// ../../packages/shared/dist/logger.js
|
|
775
|
+
import { lstatSync, mkdirSync, readdirSync, renameSync, statSync, symlinkSync, unlinkSync } from "fs";
|
|
776
|
+
import { homedir } from "os";
|
|
777
|
+
import { basename, join } from "path";
|
|
778
|
+
import pino from "pino";
|
|
779
|
+
var DEFAULT_LOG_DIR = `${homedir()}/.dev-anywhere/logs`;
|
|
780
|
+
var DEFAULT_LOG_RETENTION = 50;
|
|
781
|
+
var PROCESS_LOG_RUN_ID = sanitizeRunId(process.env.DEV_ANYWHERE_LOG_RUN_ID ?? `${(/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-")}-${process.pid}`);
|
|
782
|
+
function sanitizeRunId(runId) {
|
|
783
|
+
return runId.replace(/[^a-zA-Z0-9._-]/g, "_");
|
|
784
|
+
}
|
|
785
|
+
function linkLatestLog(logDir, name, filePath, runId) {
|
|
786
|
+
const latestPath = join(logDir, `${name}.log`);
|
|
787
|
+
try {
|
|
788
|
+
const stat = lstatSync(latestPath);
|
|
789
|
+
if (stat.isSymbolicLink()) {
|
|
790
|
+
unlinkSync(latestPath);
|
|
791
|
+
} else {
|
|
792
|
+
renameSync(latestPath, join(logDir, `${name}-legacy-${runId}.log`));
|
|
793
|
+
}
|
|
794
|
+
} catch (err) {
|
|
795
|
+
const code = err.code;
|
|
796
|
+
if (code !== "ENOENT")
|
|
797
|
+
return;
|
|
798
|
+
}
|
|
799
|
+
try {
|
|
800
|
+
symlinkSync(basename(filePath), latestPath);
|
|
801
|
+
} catch {
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
function resolveRetention() {
|
|
805
|
+
const raw = process.env.DEV_ANYWHERE_LOG_RETENTION;
|
|
806
|
+
if (!raw)
|
|
807
|
+
return DEFAULT_LOG_RETENTION;
|
|
808
|
+
const parsed = Number.parseInt(raw, 10);
|
|
809
|
+
return Number.isFinite(parsed) && parsed >= 0 ? parsed : DEFAULT_LOG_RETENTION;
|
|
810
|
+
}
|
|
811
|
+
function pruneOldLogs(logDir, name, currentFilePath) {
|
|
812
|
+
const keep = resolveRetention();
|
|
813
|
+
if (keep === 0)
|
|
814
|
+
return;
|
|
815
|
+
const currentFileName = basename(currentFilePath);
|
|
816
|
+
const prefix = `${name}-`;
|
|
817
|
+
const candidates = readdirSync(logDir).filter((entry) => entry.startsWith(prefix) && entry.endsWith(".log") && entry !== currentFileName).map((entry) => {
|
|
818
|
+
const path = join(logDir, entry);
|
|
819
|
+
try {
|
|
820
|
+
return { path, mtimeMs: statSync(path).mtimeMs };
|
|
821
|
+
} catch {
|
|
822
|
+
return null;
|
|
823
|
+
}
|
|
824
|
+
}).filter((entry) => entry !== null).sort((a, b) => b.mtimeMs - a.mtimeMs);
|
|
825
|
+
for (const stale of candidates.slice(Math.max(0, keep - 1))) {
|
|
826
|
+
try {
|
|
827
|
+
unlinkSync(stale.path);
|
|
828
|
+
} catch {
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
function createLogger(options) {
|
|
833
|
+
const { name, level = "info", logDir = DEFAULT_LOG_DIR, stdout = false, silent = false } = options;
|
|
834
|
+
if (silent) {
|
|
835
|
+
return pino({ level: "silent" });
|
|
836
|
+
}
|
|
837
|
+
mkdirSync(logDir, { recursive: true });
|
|
838
|
+
const runId = PROCESS_LOG_RUN_ID;
|
|
839
|
+
const filePath = join(logDir, `${name}-${runId}.log`);
|
|
840
|
+
linkLatestLog(logDir, name, filePath, runId);
|
|
841
|
+
pruneOldLogs(logDir, name, filePath);
|
|
842
|
+
const streams = [{ stream: pino.destination(filePath) }];
|
|
843
|
+
if (stdout) {
|
|
844
|
+
streams.unshift({ stream: process.stdout });
|
|
845
|
+
}
|
|
846
|
+
return pino({ level }, pino.multistream(streams));
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
// src/router.ts
|
|
850
|
+
import { WebSocket as WebSocket2 } from "ws";
|
|
851
|
+
function parseMessage(data) {
|
|
852
|
+
let parsed;
|
|
853
|
+
try {
|
|
854
|
+
parsed = JSON.parse(data);
|
|
855
|
+
} catch {
|
|
856
|
+
return { kind: "invalid", error: "Invalid JSON" };
|
|
857
|
+
}
|
|
858
|
+
const controlResult = RelayControlSchema.safeParse(parsed);
|
|
859
|
+
if (controlResult.success) {
|
|
860
|
+
return { kind: "control", message: controlResult.data };
|
|
861
|
+
}
|
|
862
|
+
const envelopeResult = MessageEnvelopeSchema.safeParse(parsed);
|
|
863
|
+
if (envelopeResult.success) {
|
|
864
|
+
return { kind: "envelope", message: envelopeResult.data, raw: data };
|
|
865
|
+
}
|
|
866
|
+
return { kind: "invalid", error: "Message matches neither RelayControl nor MessageEnvelope" };
|
|
867
|
+
}
|
|
868
|
+
function routeProxyMessage(raw, proxyId, registry, logger, chaos) {
|
|
869
|
+
const result = parseMessage(raw);
|
|
870
|
+
if (result.kind === "invalid") {
|
|
871
|
+
logger.warn({ proxyId, error: result.error }, "Invalid message from proxy");
|
|
872
|
+
return;
|
|
873
|
+
}
|
|
874
|
+
if (result.kind === "control") {
|
|
875
|
+
logger.warn({ proxyId }, "Control message in routeProxyMessage, should be handled by handler");
|
|
876
|
+
return;
|
|
877
|
+
}
|
|
878
|
+
const { message } = result;
|
|
879
|
+
const { sessionId } = message;
|
|
880
|
+
registry.addSessionToProxy(proxyId, sessionId);
|
|
881
|
+
const clients = registry.getClientsForProxy(proxyId);
|
|
882
|
+
for (const clientWs of clients) {
|
|
883
|
+
if (clientWs.readyState === WebSocket2.OPEN) {
|
|
884
|
+
if (chaos) chaos.send(clientWs, raw, { direction: "proxy_to_client", type: message.type });
|
|
885
|
+
else clientWs.send(raw);
|
|
886
|
+
}
|
|
887
|
+
}
|
|
888
|
+
}
|
|
889
|
+
function routeClientMessage(raw, proxyId, clientWs, registry, logger, chaos) {
|
|
890
|
+
const result = parseMessage(raw);
|
|
891
|
+
if (result.kind === "invalid") {
|
|
892
|
+
logger.warn({ error: result.error }, "Invalid message from client");
|
|
893
|
+
return;
|
|
894
|
+
}
|
|
895
|
+
if (result.kind === "control") {
|
|
896
|
+
logger.warn("Control message in routeClientMessage, should be handled by handler");
|
|
897
|
+
return;
|
|
898
|
+
}
|
|
899
|
+
const proxyWs = registry.getProxy(proxyId);
|
|
900
|
+
if (!proxyWs || proxyWs.readyState !== WebSocket2.OPEN) {
|
|
901
|
+
clientWs.send(
|
|
902
|
+
JSON.stringify({
|
|
903
|
+
type: "relay_error",
|
|
904
|
+
code: RelayErrorCode.PROXY_OFFLINE,
|
|
905
|
+
message: `Proxy ${proxyId} is not available`
|
|
906
|
+
})
|
|
907
|
+
);
|
|
908
|
+
return;
|
|
909
|
+
}
|
|
910
|
+
if (chaos) chaos.send(proxyWs, raw, { direction: "client_to_proxy", type: result.message.type });
|
|
911
|
+
else proxyWs.send(raw);
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
// src/handlers/proxy.ts
|
|
915
|
+
var MAX_BINARY_FRAME_SIZE = 10 * 1024 * 1024;
|
|
916
|
+
function notifyClientsProxyOffline(proxyId, registry, logger, chaos) {
|
|
917
|
+
const clients = registry.getClientsForProxy(proxyId);
|
|
918
|
+
const msg = JSON.stringify({ type: "proxy_offline", proxyId });
|
|
919
|
+
for (const clientWs of clients) {
|
|
920
|
+
if (chaos) chaos.send(clientWs, msg, { direction: "proxy_to_client", type: "proxy_offline" });
|
|
921
|
+
else clientWs.send(msg);
|
|
922
|
+
}
|
|
923
|
+
logger.info({ proxyId, clientCount: clients.length }, "Notified clients of proxy offline");
|
|
924
|
+
}
|
|
925
|
+
function notifyClientsProxyOnline(proxyId, registry, logger, chaos) {
|
|
926
|
+
const clients = registry.getClientsForProxy(proxyId);
|
|
927
|
+
const msg = JSON.stringify({ type: "proxy_online", proxyId });
|
|
928
|
+
for (const clientWs of clients) {
|
|
929
|
+
if (chaos) chaos.send(clientWs, msg, { direction: "proxy_to_client", type: "proxy_online" });
|
|
930
|
+
else clientWs.send(msg);
|
|
931
|
+
}
|
|
932
|
+
logger.info({ proxyId, clientCount: clients.length }, "Notified clients of proxy online");
|
|
933
|
+
}
|
|
934
|
+
function broadcastProxyList(registry, chaos) {
|
|
935
|
+
const proxies = registry.listProxiesWithName().map((p) => ({
|
|
936
|
+
...p,
|
|
937
|
+
sessions: registry.getSessionsForProxy(p.proxyId)
|
|
938
|
+
}));
|
|
939
|
+
const msg = JSON.stringify({ type: "proxy_list_response", proxies });
|
|
940
|
+
for (const clientWs of registry.getAllClientWs()) {
|
|
941
|
+
if (chaos)
|
|
942
|
+
chaos.send(clientWs, msg, { direction: "proxy_to_client", type: "proxy_list_response" });
|
|
943
|
+
else clientWs.send(msg);
|
|
944
|
+
}
|
|
945
|
+
}
|
|
946
|
+
function handleProxyConnection(ws, registry, logger, chaos) {
|
|
947
|
+
const proxyWs = ws;
|
|
948
|
+
proxyWs.isAlive = true;
|
|
949
|
+
proxyWs.on("pong", () => {
|
|
950
|
+
proxyWs.isAlive = true;
|
|
951
|
+
});
|
|
952
|
+
proxyWs.on("message", (data, isBinary) => {
|
|
953
|
+
if (isBinary) {
|
|
954
|
+
if (data.length < 2 || data.length > MAX_BINARY_FRAME_SIZE) {
|
|
955
|
+
logger.warn({ size: data.length }, "Binary frame rejected: invalid size");
|
|
956
|
+
return;
|
|
957
|
+
}
|
|
958
|
+
const sessionIdLen = data[0];
|
|
959
|
+
if (sessionIdLen === 0 || sessionIdLen > 255 || data.length < 1 + sessionIdLen) {
|
|
960
|
+
logger.warn(
|
|
961
|
+
{ sessionIdLen, dataLen: data.length },
|
|
962
|
+
"Binary frame rejected: malformed sessionId prefix"
|
|
963
|
+
);
|
|
964
|
+
return;
|
|
965
|
+
}
|
|
966
|
+
if (!proxyWs.proxyId) {
|
|
967
|
+
logger.warn("Binary frame from unregistered proxy, dropped");
|
|
968
|
+
return;
|
|
969
|
+
}
|
|
970
|
+
const clients = registry.getClientsForProxy(proxyWs.proxyId);
|
|
971
|
+
for (const clientWs of clients) {
|
|
972
|
+
if (clientWs.readyState === WebSocket3.OPEN) {
|
|
973
|
+
clientWs.send(data);
|
|
974
|
+
}
|
|
975
|
+
}
|
|
976
|
+
return;
|
|
977
|
+
}
|
|
978
|
+
const raw = data.toString();
|
|
979
|
+
const result = parseMessage(raw);
|
|
980
|
+
if (result.kind === "control" && result.message.type === "proxy_register") {
|
|
981
|
+
const { proxyId, name } = result.message;
|
|
982
|
+
const status = registry.registerProxy(proxyId, proxyWs, name);
|
|
983
|
+
proxyWs.proxyId = proxyId;
|
|
984
|
+
logger.info({ proxyId, status }, "Proxy registered");
|
|
985
|
+
proxyWs.send(
|
|
986
|
+
JSON.stringify({
|
|
987
|
+
type: "proxy_register_response",
|
|
988
|
+
status
|
|
989
|
+
})
|
|
990
|
+
);
|
|
991
|
+
if (status === "reconnected") {
|
|
992
|
+
notifyClientsProxyOnline(proxyId, registry, logger, chaos);
|
|
993
|
+
}
|
|
994
|
+
broadcastProxyList(registry, chaos);
|
|
995
|
+
return;
|
|
996
|
+
}
|
|
997
|
+
if (result.kind === "control" && result.message.type === "proxy_disconnect") {
|
|
998
|
+
if (proxyWs.proxyId) {
|
|
999
|
+
notifyClientsProxyOffline(proxyWs.proxyId, registry, logger, chaos);
|
|
1000
|
+
registry.markProxyOffline(proxyWs.proxyId);
|
|
1001
|
+
logger.info({ proxyId: proxyWs.proxyId }, "Proxy gracefully disconnected, marked offline");
|
|
1002
|
+
proxyWs.proxyId = void 0;
|
|
1003
|
+
broadcastProxyList(registry, chaos);
|
|
1004
|
+
}
|
|
1005
|
+
return;
|
|
1006
|
+
}
|
|
1007
|
+
if (result.kind === "control" && result.message.type === "session_sync") {
|
|
1008
|
+
if (!proxyWs.proxyId) return;
|
|
1009
|
+
const sessions = result.message.sessions;
|
|
1010
|
+
if (Array.isArray(sessions)) {
|
|
1011
|
+
for (const s of sessions) {
|
|
1012
|
+
registry.addSessionToProxy(proxyWs.proxyId, s.id);
|
|
1013
|
+
}
|
|
1014
|
+
logger.info({ proxyId: proxyWs.proxyId, count: sessions.length }, "Session sync received");
|
|
1015
|
+
}
|
|
1016
|
+
return;
|
|
1017
|
+
}
|
|
1018
|
+
if (result.kind === "control") {
|
|
1019
|
+
if (isProxyToClientRelayControlType(result.message.type)) {
|
|
1020
|
+
if (!proxyWs.proxyId) {
|
|
1021
|
+
proxyWs.send(
|
|
1022
|
+
JSON.stringify({
|
|
1023
|
+
type: "relay_error",
|
|
1024
|
+
code: RelayErrorCode.NOT_REGISTERED,
|
|
1025
|
+
message: "Proxy must register before sending messages"
|
|
1026
|
+
})
|
|
1027
|
+
);
|
|
1028
|
+
return;
|
|
1029
|
+
}
|
|
1030
|
+
const clients = registry.getClientsForProxy(proxyWs.proxyId);
|
|
1031
|
+
for (const clientWs of clients) {
|
|
1032
|
+
if (clientWs.readyState === WebSocket3.OPEN) {
|
|
1033
|
+
if (chaos) {
|
|
1034
|
+
chaos.send(clientWs, raw, {
|
|
1035
|
+
direction: "proxy_to_client",
|
|
1036
|
+
type: result.message.type
|
|
1037
|
+
});
|
|
1038
|
+
} else {
|
|
1039
|
+
clientWs.send(raw);
|
|
1040
|
+
}
|
|
1041
|
+
}
|
|
1042
|
+
}
|
|
1043
|
+
logger.info(
|
|
1044
|
+
{ proxyId: proxyWs.proxyId, type: result.message.type, clientCount: clients.length },
|
|
1045
|
+
"Forwarded control message from proxy to clients"
|
|
1046
|
+
);
|
|
1047
|
+
return;
|
|
1048
|
+
}
|
|
1049
|
+
logger.warn({ type: result.message.type }, "Unexpected control message from proxy");
|
|
1050
|
+
return;
|
|
1051
|
+
}
|
|
1052
|
+
if (result.kind === "envelope") {
|
|
1053
|
+
if (!proxyWs.proxyId) {
|
|
1054
|
+
proxyWs.send(
|
|
1055
|
+
JSON.stringify({
|
|
1056
|
+
type: "relay_error",
|
|
1057
|
+
code: RelayErrorCode.NOT_REGISTERED,
|
|
1058
|
+
message: "Proxy must register before sending messages"
|
|
1059
|
+
})
|
|
1060
|
+
);
|
|
1061
|
+
return;
|
|
1062
|
+
}
|
|
1063
|
+
routeProxyMessage(raw, proxyWs.proxyId, registry, logger, chaos);
|
|
1064
|
+
return;
|
|
1065
|
+
}
|
|
1066
|
+
if (result.kind === "invalid") {
|
|
1067
|
+
logger.error({ error: result.error, raw: raw.slice(0, 200) }, "Invalid message from proxy");
|
|
1068
|
+
proxyWs.send(
|
|
1069
|
+
JSON.stringify({
|
|
1070
|
+
type: "relay_error",
|
|
1071
|
+
code: RelayErrorCode.INVALID_MESSAGE,
|
|
1072
|
+
message: `${result.error} | raw: ${raw.slice(0, 200)}`
|
|
1073
|
+
})
|
|
1074
|
+
);
|
|
1075
|
+
return;
|
|
1076
|
+
}
|
|
1077
|
+
});
|
|
1078
|
+
proxyWs.on("close", () => {
|
|
1079
|
+
if (proxyWs.proxyId) {
|
|
1080
|
+
notifyClientsProxyOffline(proxyWs.proxyId, registry, logger, chaos);
|
|
1081
|
+
try {
|
|
1082
|
+
registry.transitionProxy(proxyWs.proxyId, "online", "offline");
|
|
1083
|
+
} catch {
|
|
1084
|
+
}
|
|
1085
|
+
logger.info(
|
|
1086
|
+
{ proxyId: proxyWs.proxyId },
|
|
1087
|
+
"Proxy disconnected, state preserved for reconnect"
|
|
1088
|
+
);
|
|
1089
|
+
broadcastProxyList(registry, chaos);
|
|
1090
|
+
}
|
|
1091
|
+
});
|
|
1092
|
+
proxyWs.on("error", (err) => {
|
|
1093
|
+
logger.error({ err, proxyId: proxyWs.proxyId }, "Proxy WebSocket error");
|
|
1094
|
+
});
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
// src/handlers/client.ts
|
|
1098
|
+
import { WebSocket as WebSocket4 } from "ws";
|
|
1099
|
+
import { nanoid } from "nanoid";
|
|
1100
|
+
function handleClientRegister(clientId, clientWs, registry, logger) {
|
|
1101
|
+
clientWs.clientId = clientId;
|
|
1102
|
+
const binding = registry.getClientBinding(clientId);
|
|
1103
|
+
if (!binding) {
|
|
1104
|
+
clientWs.send(
|
|
1105
|
+
JSON.stringify({
|
|
1106
|
+
type: "client_register_response",
|
|
1107
|
+
status: "new"
|
|
1108
|
+
})
|
|
1109
|
+
);
|
|
1110
|
+
logger.info({ clientId, status: "new" }, "Client registered");
|
|
1111
|
+
return;
|
|
1112
|
+
}
|
|
1113
|
+
const { proxyId } = binding;
|
|
1114
|
+
registry.updateClientSocket(clientId, clientWs);
|
|
1115
|
+
clientWs.boundProxyId = proxyId;
|
|
1116
|
+
if (!registry.isProxyOnline(proxyId)) {
|
|
1117
|
+
clientWs.send(
|
|
1118
|
+
JSON.stringify({
|
|
1119
|
+
type: "client_register_response",
|
|
1120
|
+
status: "proxy_offline",
|
|
1121
|
+
proxyId
|
|
1122
|
+
})
|
|
1123
|
+
);
|
|
1124
|
+
logger.info({ clientId, proxyId, status: "proxy_offline" }, "Client registered");
|
|
1125
|
+
return;
|
|
1126
|
+
}
|
|
1127
|
+
clientWs.send(
|
|
1128
|
+
JSON.stringify({
|
|
1129
|
+
type: "client_register_response",
|
|
1130
|
+
status: "restored",
|
|
1131
|
+
proxyId
|
|
1132
|
+
})
|
|
1133
|
+
);
|
|
1134
|
+
logger.info({ clientId, proxyId, status: "restored" }, "Client registered");
|
|
1135
|
+
}
|
|
1136
|
+
function handleClientConnection(ws, registry, logger, chaos) {
|
|
1137
|
+
const clientWs = ws;
|
|
1138
|
+
clientWs.isAlive = true;
|
|
1139
|
+
registry.addClientWs(clientWs);
|
|
1140
|
+
clientWs.on("pong", () => {
|
|
1141
|
+
clientWs.isAlive = true;
|
|
1142
|
+
});
|
|
1143
|
+
clientWs.on("message", (data, isBinary) => {
|
|
1144
|
+
if (isBinary) {
|
|
1145
|
+
return;
|
|
1146
|
+
}
|
|
1147
|
+
const raw = data.toString();
|
|
1148
|
+
const result = parseMessage(raw);
|
|
1149
|
+
if (result.kind === "control") {
|
|
1150
|
+
const msg = result.message;
|
|
1151
|
+
logger.info(
|
|
1152
|
+
{ type: msg.type, clientId: clientWs.clientId, bound: clientWs.boundProxyId },
|
|
1153
|
+
"Client message received"
|
|
1154
|
+
);
|
|
1155
|
+
if (msg.type === "client_register") {
|
|
1156
|
+
handleClientRegister(msg.clientId, clientWs, registry, logger);
|
|
1157
|
+
return;
|
|
1158
|
+
}
|
|
1159
|
+
if (msg.type === "proxy_list_request") {
|
|
1160
|
+
const proxies = registry.listProxiesWithName().map((p) => ({
|
|
1161
|
+
...p,
|
|
1162
|
+
sessions: registry.getSessionsForProxy(p.proxyId)
|
|
1163
|
+
}));
|
|
1164
|
+
const response = JSON.stringify({
|
|
1165
|
+
type: "proxy_list_response",
|
|
1166
|
+
requestId: msg.requestId,
|
|
1167
|
+
proxies
|
|
1168
|
+
});
|
|
1169
|
+
if (chaos) {
|
|
1170
|
+
chaos.send(clientWs, response, {
|
|
1171
|
+
direction: "proxy_to_client",
|
|
1172
|
+
type: "proxy_list_response"
|
|
1173
|
+
});
|
|
1174
|
+
} else {
|
|
1175
|
+
clientWs.send(response);
|
|
1176
|
+
}
|
|
1177
|
+
return;
|
|
1178
|
+
}
|
|
1179
|
+
if (isClientToProxyRelayControlType(msg.type)) {
|
|
1180
|
+
const targetProxyId = ("proxyId" in msg ? msg.proxyId : void 0) || clientWs.boundProxyId;
|
|
1181
|
+
if (!targetProxyId) {
|
|
1182
|
+
clientWs.send(
|
|
1183
|
+
JSON.stringify({
|
|
1184
|
+
type: "relay_error",
|
|
1185
|
+
code: RelayErrorCode.NOT_BOUND,
|
|
1186
|
+
message: "Client is not bound to any proxy"
|
|
1187
|
+
})
|
|
1188
|
+
);
|
|
1189
|
+
return;
|
|
1190
|
+
}
|
|
1191
|
+
const proxyWs = registry.getProxy(targetProxyId);
|
|
1192
|
+
if (proxyWs && proxyWs.readyState === WebSocket4.OPEN) {
|
|
1193
|
+
if (chaos) chaos.send(proxyWs, raw, { direction: "client_to_proxy", type: msg.type });
|
|
1194
|
+
else proxyWs.send(raw);
|
|
1195
|
+
} else {
|
|
1196
|
+
clientWs.send(
|
|
1197
|
+
JSON.stringify({
|
|
1198
|
+
type: "relay_error",
|
|
1199
|
+
code: RelayErrorCode.PROXY_OFFLINE,
|
|
1200
|
+
message: `Proxy ${targetProxyId} is not available`
|
|
1201
|
+
})
|
|
1202
|
+
);
|
|
1203
|
+
}
|
|
1204
|
+
return;
|
|
1205
|
+
}
|
|
1206
|
+
if (msg.type === "proxy_select") {
|
|
1207
|
+
if (!registry.isProxyOnline(msg.proxyId)) {
|
|
1208
|
+
clientWs.send(
|
|
1209
|
+
JSON.stringify({
|
|
1210
|
+
type: "proxy_select_response",
|
|
1211
|
+
requestId: msg.requestId,
|
|
1212
|
+
success: false,
|
|
1213
|
+
errorCode: ControlErrorCode.PROXY_OFFLINE,
|
|
1214
|
+
error: `Proxy not online: ${msg.proxyId}`
|
|
1215
|
+
})
|
|
1216
|
+
);
|
|
1217
|
+
return;
|
|
1218
|
+
}
|
|
1219
|
+
if (!clientWs.clientId) {
|
|
1220
|
+
clientWs.clientId = `anon-${nanoid(10)}`;
|
|
1221
|
+
}
|
|
1222
|
+
const bound = registry.bindClientById(clientWs.clientId, msg.proxyId, clientWs);
|
|
1223
|
+
if (!bound) {
|
|
1224
|
+
clientWs.send(
|
|
1225
|
+
JSON.stringify({
|
|
1226
|
+
type: "proxy_select_response",
|
|
1227
|
+
requestId: msg.requestId,
|
|
1228
|
+
success: false,
|
|
1229
|
+
errorCode: ControlErrorCode.PROXY_OFFLINE,
|
|
1230
|
+
error: `Proxy not online: ${msg.proxyId}`
|
|
1231
|
+
})
|
|
1232
|
+
);
|
|
1233
|
+
return;
|
|
1234
|
+
}
|
|
1235
|
+
clientWs.boundProxyId = msg.proxyId;
|
|
1236
|
+
const response = JSON.stringify({
|
|
1237
|
+
type: "proxy_select_response",
|
|
1238
|
+
requestId: msg.requestId,
|
|
1239
|
+
success: true,
|
|
1240
|
+
proxyId: msg.proxyId
|
|
1241
|
+
});
|
|
1242
|
+
if (chaos) {
|
|
1243
|
+
chaos.send(clientWs, response, {
|
|
1244
|
+
direction: "proxy_to_client",
|
|
1245
|
+
type: "proxy_select_response"
|
|
1246
|
+
});
|
|
1247
|
+
} else {
|
|
1248
|
+
clientWs.send(response);
|
|
1249
|
+
}
|
|
1250
|
+
logger.info({ proxyId: msg.proxyId, clientId: clientWs.clientId }, "Client bound to proxy");
|
|
1251
|
+
return;
|
|
1252
|
+
}
|
|
1253
|
+
clientWs.send(
|
|
1254
|
+
JSON.stringify({
|
|
1255
|
+
type: "relay_error",
|
|
1256
|
+
code: RelayErrorCode.UNSUPPORTED,
|
|
1257
|
+
message: `Unsupported control message: ${msg.type}`
|
|
1258
|
+
})
|
|
1259
|
+
);
|
|
1260
|
+
return;
|
|
1261
|
+
}
|
|
1262
|
+
if (result.kind === "envelope") {
|
|
1263
|
+
if (!clientWs.boundProxyId) {
|
|
1264
|
+
clientWs.send(
|
|
1265
|
+
JSON.stringify({
|
|
1266
|
+
type: "relay_error",
|
|
1267
|
+
code: RelayErrorCode.NOT_BOUND,
|
|
1268
|
+
message: "Client is not bound to any proxy"
|
|
1269
|
+
})
|
|
1270
|
+
);
|
|
1271
|
+
return;
|
|
1272
|
+
}
|
|
1273
|
+
routeClientMessage(raw, clientWs.boundProxyId, clientWs, registry, logger, chaos);
|
|
1274
|
+
return;
|
|
1275
|
+
}
|
|
1276
|
+
logger.error({ error: result.error, raw: raw.slice(0, 200) }, "Invalid message from client");
|
|
1277
|
+
clientWs.send(
|
|
1278
|
+
JSON.stringify({
|
|
1279
|
+
type: "relay_error",
|
|
1280
|
+
code: RelayErrorCode.INVALID_MESSAGE,
|
|
1281
|
+
message: `${result.error} | raw: ${raw.slice(0, 200)}`
|
|
1282
|
+
})
|
|
1283
|
+
);
|
|
1284
|
+
});
|
|
1285
|
+
clientWs.on("close", () => {
|
|
1286
|
+
registry.removeClientWs(clientWs);
|
|
1287
|
+
logger.info({ clientId: clientWs.clientId }, "Client disconnected");
|
|
1288
|
+
});
|
|
1289
|
+
clientWs.on("error", (err) => {
|
|
1290
|
+
logger.error({ err }, "Client WebSocket error");
|
|
1291
|
+
});
|
|
1292
|
+
}
|
|
1293
|
+
|
|
1294
|
+
// src/heartbeat.ts
|
|
1295
|
+
function setupHeartbeat(wss, interval = 3e4) {
|
|
1296
|
+
return setInterval(() => {
|
|
1297
|
+
for (const ws of wss.clients) {
|
|
1298
|
+
const sock = ws;
|
|
1299
|
+
if (!sock.isAlive) {
|
|
1300
|
+
sock.terminate();
|
|
1301
|
+
continue;
|
|
1302
|
+
}
|
|
1303
|
+
sock.isAlive = false;
|
|
1304
|
+
sock.ping();
|
|
1305
|
+
}
|
|
1306
|
+
}, interval);
|
|
1307
|
+
}
|
|
1308
|
+
|
|
1309
|
+
// src/chaos.ts
|
|
1310
|
+
import { WebSocket as WebSocket5 } from "ws";
|
|
1311
|
+
function parseRelayChaosFromEnv(env) {
|
|
1312
|
+
const enabled = env.DEV_ANYWHERE_RELAY_CHAOS === "1";
|
|
1313
|
+
const types = env.DEV_ANYWHERE_RELAY_CHAOS_TYPES?.split(",").map((type) => type.trim()).filter(Boolean);
|
|
1314
|
+
return {
|
|
1315
|
+
enabled,
|
|
1316
|
+
delayMs: parseInt(env.DEV_ANYWHERE_RELAY_CHAOS_DELAY_MS ?? "0", 10),
|
|
1317
|
+
duplicate: env.DEV_ANYWHERE_RELAY_CHAOS_DUPLICATE === "1",
|
|
1318
|
+
duplicateDelayMs: parseInt(env.DEV_ANYWHERE_RELAY_CHAOS_DUPLICATE_DELAY_MS ?? "10", 10),
|
|
1319
|
+
reorder: env.DEV_ANYWHERE_RELAY_CHAOS_REORDER === "1",
|
|
1320
|
+
reorderDelayMs: parseInt(env.DEV_ANYWHERE_RELAY_CHAOS_REORDER_DELAY_MS ?? "40", 10),
|
|
1321
|
+
types: types && types.length > 0 ? new Set(types) : void 0
|
|
1322
|
+
};
|
|
1323
|
+
}
|
|
1324
|
+
function createRelayChaos(options, logger) {
|
|
1325
|
+
let sequence = 0;
|
|
1326
|
+
function shouldAffect(meta) {
|
|
1327
|
+
if (!options.enabled) return false;
|
|
1328
|
+
if (options.types && !options.types.has(meta.type)) return false;
|
|
1329
|
+
return true;
|
|
1330
|
+
}
|
|
1331
|
+
function sendNow(ws, data) {
|
|
1332
|
+
if (ws.readyState === WebSocket5.OPEN) {
|
|
1333
|
+
ws.send(data);
|
|
1334
|
+
}
|
|
1335
|
+
}
|
|
1336
|
+
return {
|
|
1337
|
+
send(ws, data, meta) {
|
|
1338
|
+
if (!shouldAffect(meta)) {
|
|
1339
|
+
sendNow(ws, data);
|
|
1340
|
+
return;
|
|
1341
|
+
}
|
|
1342
|
+
sequence += 1;
|
|
1343
|
+
const reorderDelay = options.reorder && sequence % 2 === 1 ? options.reorderDelayMs : 0;
|
|
1344
|
+
const delayMs = Math.max(0, options.delayMs + reorderDelay);
|
|
1345
|
+
logger.warn(
|
|
1346
|
+
{ direction: meta.direction, type: meta.type, delayMs, duplicate: options.duplicate },
|
|
1347
|
+
"Relay chaos forwarding message"
|
|
1348
|
+
);
|
|
1349
|
+
setTimeout(() => sendNow(ws, data), delayMs);
|
|
1350
|
+
if (options.duplicate) {
|
|
1351
|
+
setTimeout(() => sendNow(ws, data), delayMs + options.duplicateDelayMs);
|
|
1352
|
+
}
|
|
1353
|
+
}
|
|
1354
|
+
};
|
|
1355
|
+
}
|
|
1356
|
+
|
|
1357
|
+
// src/server.ts
|
|
1358
|
+
function createRelayServer(options) {
|
|
1359
|
+
const { heartbeatInterval = 3e4, logger, dataDir, proxyToken, clientToken, chaos } = options;
|
|
1360
|
+
const proxyTokenRequired = typeof proxyToken === "string" && proxyToken.length > 0;
|
|
1361
|
+
const clientTokenRequired = typeof clientToken === "string" && clientToken.length > 0;
|
|
1362
|
+
if (!proxyTokenRequired) {
|
|
1363
|
+
logger.warn(
|
|
1364
|
+
"proxy auth token not set, /proxy endpoint is open \u2014 ok for dev, not for public relay"
|
|
1365
|
+
);
|
|
1366
|
+
}
|
|
1367
|
+
if (!clientTokenRequired) {
|
|
1368
|
+
logger.warn(
|
|
1369
|
+
"client auth token not set, /client endpoint is open \u2014 ok for dev, not for public relay"
|
|
1370
|
+
);
|
|
1371
|
+
}
|
|
1372
|
+
const registry = new RelayRegistry();
|
|
1373
|
+
const relayChaos = chaos?.enabled ? createRelayChaos(chaos, logger) : void 0;
|
|
1374
|
+
if (chaos?.enabled) {
|
|
1375
|
+
logger.warn(
|
|
1376
|
+
{
|
|
1377
|
+
delayMs: chaos.delayMs,
|
|
1378
|
+
duplicate: chaos.duplicate,
|
|
1379
|
+
reorder: chaos.reorder,
|
|
1380
|
+
types: chaos.types ? [...chaos.types] : "all"
|
|
1381
|
+
},
|
|
1382
|
+
"Relay chaos mode enabled"
|
|
1383
|
+
);
|
|
1384
|
+
}
|
|
1385
|
+
const app = express();
|
|
1386
|
+
const fontsDir = dataDir ? `${dataDir}/fonts` : `${homedir2()}/.dev-anywhere/relay-data/fonts`;
|
|
1387
|
+
app.use(
|
|
1388
|
+
"/fonts",
|
|
1389
|
+
(req, res, next) => {
|
|
1390
|
+
res.setHeader("Access-Control-Allow-Origin", "*");
|
|
1391
|
+
next();
|
|
1392
|
+
},
|
|
1393
|
+
express.static(fontsDir, {
|
|
1394
|
+
maxAge: "30d",
|
|
1395
|
+
immutable: true
|
|
1396
|
+
})
|
|
1397
|
+
);
|
|
1398
|
+
app.use(healthRouter(registry));
|
|
1399
|
+
const httpServer = createServer(app);
|
|
1400
|
+
const proxyWss = new WebSocketServer({ noServer: true });
|
|
1401
|
+
const clientWss = new WebSocketServer({ noServer: true });
|
|
1402
|
+
httpServer.on("upgrade", (request, socket, head) => {
|
|
1403
|
+
const url = new URL(request.url ?? "/", "http://localhost");
|
|
1404
|
+
const { pathname } = url;
|
|
1405
|
+
if (pathname === "/proxy") {
|
|
1406
|
+
if (proxyTokenRequired) {
|
|
1407
|
+
const token = url.searchParams.get("token");
|
|
1408
|
+
if (token !== proxyToken) {
|
|
1409
|
+
logger.warn(
|
|
1410
|
+
{ ip: request.socket.remoteAddress },
|
|
1411
|
+
"rejected /proxy upgrade: invalid token"
|
|
1412
|
+
);
|
|
1413
|
+
socket.write("HTTP/1.1 401 Unauthorized\r\n\r\n");
|
|
1414
|
+
socket.destroy();
|
|
1415
|
+
return;
|
|
1416
|
+
}
|
|
1417
|
+
}
|
|
1418
|
+
proxyWss.handleUpgrade(request, socket, head, (ws) => {
|
|
1419
|
+
proxyWss.emit("connection", ws, request);
|
|
1420
|
+
});
|
|
1421
|
+
return;
|
|
1422
|
+
}
|
|
1423
|
+
if (pathname === "/client") {
|
|
1424
|
+
if (clientTokenRequired) {
|
|
1425
|
+
const token = url.searchParams.get("token");
|
|
1426
|
+
if (token !== clientToken) {
|
|
1427
|
+
logger.warn(
|
|
1428
|
+
{ ip: request.socket.remoteAddress },
|
|
1429
|
+
"rejected /client upgrade: invalid token"
|
|
1430
|
+
);
|
|
1431
|
+
socket.write("HTTP/1.1 401 Unauthorized\r\n\r\n");
|
|
1432
|
+
socket.destroy();
|
|
1433
|
+
return;
|
|
1434
|
+
}
|
|
1435
|
+
}
|
|
1436
|
+
clientWss.handleUpgrade(request, socket, head, (ws) => {
|
|
1437
|
+
clientWss.emit("connection", ws, request);
|
|
1438
|
+
});
|
|
1439
|
+
return;
|
|
1440
|
+
}
|
|
1441
|
+
socket.destroy();
|
|
1442
|
+
});
|
|
1443
|
+
proxyWss.on("connection", (ws) => {
|
|
1444
|
+
handleProxyConnection(ws, registry, logger, relayChaos);
|
|
1445
|
+
});
|
|
1446
|
+
clientWss.on("connection", (ws) => {
|
|
1447
|
+
handleClientConnection(ws, registry, logger, relayChaos);
|
|
1448
|
+
});
|
|
1449
|
+
const proxyHeartbeat = setupHeartbeat(proxyWss, heartbeatInterval);
|
|
1450
|
+
const clientHeartbeat = setupHeartbeat(clientWss, heartbeatInterval);
|
|
1451
|
+
async function close() {
|
|
1452
|
+
clearInterval(proxyHeartbeat);
|
|
1453
|
+
clearInterval(clientHeartbeat);
|
|
1454
|
+
for (const ws of proxyWss.clients) {
|
|
1455
|
+
ws.terminate();
|
|
1456
|
+
}
|
|
1457
|
+
for (const ws of clientWss.clients) {
|
|
1458
|
+
ws.terminate();
|
|
1459
|
+
}
|
|
1460
|
+
await new Promise((resolve, reject) => {
|
|
1461
|
+
proxyWss.close((err) => {
|
|
1462
|
+
if (err) reject(err);
|
|
1463
|
+
else resolve();
|
|
1464
|
+
});
|
|
1465
|
+
});
|
|
1466
|
+
await new Promise((resolve, reject) => {
|
|
1467
|
+
clientWss.close((err) => {
|
|
1468
|
+
if (err) reject(err);
|
|
1469
|
+
else resolve();
|
|
1470
|
+
});
|
|
1471
|
+
});
|
|
1472
|
+
await new Promise((resolve, reject) => {
|
|
1473
|
+
httpServer.close((err) => {
|
|
1474
|
+
if (err) reject(err);
|
|
1475
|
+
else resolve();
|
|
1476
|
+
});
|
|
1477
|
+
});
|
|
1478
|
+
}
|
|
1479
|
+
return { httpServer, registry, close };
|
|
1480
|
+
}
|
|
1481
|
+
|
|
1482
|
+
export {
|
|
1483
|
+
createLogger,
|
|
1484
|
+
parseRelayChaosFromEnv,
|
|
1485
|
+
createRelayServer
|
|
1486
|
+
};
|
|
1487
|
+
//# sourceMappingURL=chunk-TBYEKF42.js.map
|