clawmatrix 0.1.11 → 0.1.13
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/BOOTSTRAP.md +8 -27
- package/README.md +5 -17
- package/package.json +2 -5
- package/src/auth.ts +11 -7
- package/src/cluster-service.ts +9 -1
- package/src/config.ts +8 -0
- package/src/index.ts +6 -0
- package/src/model-proxy.ts +49 -6
- package/src/peer-manager.ts +13 -3
- package/src/web-ui.ts +1270 -0
- package/src/web.ts +230 -0
package/src/web.ts
ADDED
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
2
|
+
import type { PeerManager } from "./peer-manager.ts";
|
|
3
|
+
import type { ClawMatrixConfig } from "./config.ts";
|
|
4
|
+
import { timingSafeEqual } from "./auth.ts";
|
|
5
|
+
import { renderDashboard } from "./web-ui.ts";
|
|
6
|
+
|
|
7
|
+
const COOKIE_NAME = "clawmatrix_token";
|
|
8
|
+
const SESSION_MAX_AGE = 86400 * 7; // 7 days
|
|
9
|
+
|
|
10
|
+
export class WebHandler {
|
|
11
|
+
private config: ClawMatrixConfig;
|
|
12
|
+
private peerManager: PeerManager;
|
|
13
|
+
private token: string;
|
|
14
|
+
private startTime = Date.now();
|
|
15
|
+
|
|
16
|
+
constructor(config: ClawMatrixConfig, peerManager: PeerManager) {
|
|
17
|
+
this.config = config;
|
|
18
|
+
this.peerManager = peerManager;
|
|
19
|
+
this.token = config.web!.token;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Handle an HTTP request. Returns true if handled, false to fall through. */
|
|
23
|
+
handle(req: IncomingMessage, res: ServerResponse): boolean {
|
|
24
|
+
const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
|
|
25
|
+
const path = url.pathname;
|
|
26
|
+
|
|
27
|
+
// Public routes (no auth)
|
|
28
|
+
if (path === "/api/login" && req.method === "POST") {
|
|
29
|
+
this.handleLogin(req, res);
|
|
30
|
+
return true;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Serve dashboard HTML (auth checked client-side via API)
|
|
34
|
+
if (path === "/" && req.method === "GET") {
|
|
35
|
+
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
|
36
|
+
res.end(renderDashboard(this.config.nodeId));
|
|
37
|
+
return true;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// All /api/* routes require auth
|
|
41
|
+
if (path.startsWith("/api/")) {
|
|
42
|
+
if (!this.checkAuth(req)) {
|
|
43
|
+
res.writeHead(401, { "Content-Type": "application/json" });
|
|
44
|
+
res.end(JSON.stringify({ error: "Unauthorized" }));
|
|
45
|
+
return true;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (path === "/api/status" && req.method === "GET") {
|
|
49
|
+
this.handleStatus(res);
|
|
50
|
+
return true;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (path === "/api/chat" && req.method === "POST") {
|
|
54
|
+
this.handleChat(req, res);
|
|
55
|
+
return true;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (path === "/api/logout" && req.method === "POST") {
|
|
59
|
+
res.writeHead(200, {
|
|
60
|
+
"Content-Type": "application/json",
|
|
61
|
+
"Set-Cookie": `${COOKIE_NAME}=; Path=/; Max-Age=0; HttpOnly; SameSite=Strict`,
|
|
62
|
+
|
|
63
|
+
});
|
|
64
|
+
res.end(JSON.stringify({ ok: true }));
|
|
65
|
+
return true;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
res.writeHead(404, { "Content-Type": "application/json" });
|
|
69
|
+
res.end(JSON.stringify({ error: "Not found" }));
|
|
70
|
+
return true;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return false;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
private checkAuth(req: IncomingMessage): boolean {
|
|
77
|
+
// Check Authorization header
|
|
78
|
+
const authHeader = req.headers.authorization;
|
|
79
|
+
if (authHeader?.startsWith("Bearer ") && timingSafeEqual(authHeader.slice(7), this.token)) {
|
|
80
|
+
return true;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Check cookie
|
|
84
|
+
const cookies = req.headers.cookie ?? "";
|
|
85
|
+
const match = cookies.split(";").find((c) => c.trim().startsWith(`${COOKIE_NAME}=`));
|
|
86
|
+
if (match) {
|
|
87
|
+
const value = match.trim().slice(COOKIE_NAME.length + 1);
|
|
88
|
+
return timingSafeEqual(value, this.token);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return false;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
private async handleLogin(req: IncomingMessage, res: ServerResponse) {
|
|
95
|
+
try {
|
|
96
|
+
const body = await readBody(req);
|
|
97
|
+
const { token } = JSON.parse(body);
|
|
98
|
+
|
|
99
|
+
if (!token || !timingSafeEqual(String(token), this.token)) {
|
|
100
|
+
res.writeHead(403, { "Content-Type": "application/json" });
|
|
101
|
+
res.end(JSON.stringify({ error: "Invalid token" }));
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
res.writeHead(200, {
|
|
106
|
+
"Content-Type": "application/json",
|
|
107
|
+
"Set-Cookie": `${COOKIE_NAME}=${token}; Path=/; Max-Age=${SESSION_MAX_AGE}; HttpOnly; SameSite=Strict`,
|
|
108
|
+
});
|
|
109
|
+
res.end(JSON.stringify({ ok: true }));
|
|
110
|
+
} catch {
|
|
111
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
112
|
+
res.end(JSON.stringify({ error: "Invalid request" }));
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
private handleStatus(res: ServerResponse) {
|
|
117
|
+
const peers = this.peerManager.router.getAllPeers();
|
|
118
|
+
const localNode = {
|
|
119
|
+
nodeId: this.config.nodeId,
|
|
120
|
+
agents: this.config.agents,
|
|
121
|
+
models: this.config.models.map((m) => ({ id: m.id, provider: m.provider, description: m.description })),
|
|
122
|
+
tags: this.config.tags,
|
|
123
|
+
connection: "self" as const,
|
|
124
|
+
online: true,
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
const peerNodes = peers.map((p) => ({
|
|
128
|
+
nodeId: p.nodeId,
|
|
129
|
+
agents: p.agents,
|
|
130
|
+
models: p.models,
|
|
131
|
+
tags: p.tags,
|
|
132
|
+
connection: p.connection ? ("direct" as const) : ("relay" as const),
|
|
133
|
+
reachableVia: p.reachableVia,
|
|
134
|
+
online: this.peerManager.canReach(p.nodeId),
|
|
135
|
+
lastSeen: p.lastSeen,
|
|
136
|
+
latencyMs: p.latencyMs,
|
|
137
|
+
}));
|
|
138
|
+
|
|
139
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
140
|
+
res.end(JSON.stringify({
|
|
141
|
+
nodeId: this.config.nodeId,
|
|
142
|
+
uptime: Math.floor((Date.now() - this.startTime) / 1000),
|
|
143
|
+
listen: this.config.listen ? this.config.listenPort : false,
|
|
144
|
+
proxyPort: this.config.proxyPort,
|
|
145
|
+
local: localNode,
|
|
146
|
+
peers: peerNodes,
|
|
147
|
+
}));
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
private async handleChat(req: IncomingMessage, res: ServerResponse) {
|
|
151
|
+
try {
|
|
152
|
+
const body = await readBody(req);
|
|
153
|
+
const { model, messages, nodeId } = JSON.parse(body);
|
|
154
|
+
|
|
155
|
+
if (!model || !messages) {
|
|
156
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
157
|
+
res.end(JSON.stringify({ error: "model and messages required" }));
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Proxy to local model proxy with streaming
|
|
162
|
+
const proxyUrl = `http://127.0.0.1:${this.config.proxyPort}/v1/chat/completions`;
|
|
163
|
+
const modelId = nodeId ? `${nodeId}/${model}` : model;
|
|
164
|
+
|
|
165
|
+
const upstream = await fetch(proxyUrl, {
|
|
166
|
+
method: "POST",
|
|
167
|
+
headers: { "Content-Type": "application/json" },
|
|
168
|
+
body: JSON.stringify({ model: modelId, messages, stream: true }),
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
if (!upstream.ok) {
|
|
172
|
+
const errText = await upstream.text();
|
|
173
|
+
res.writeHead(upstream.status, { "Content-Type": "application/json" });
|
|
174
|
+
res.end(errText);
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Stream SSE back to browser
|
|
179
|
+
res.writeHead(200, {
|
|
180
|
+
"Content-Type": "text/event-stream",
|
|
181
|
+
"Cache-Control": "no-cache",
|
|
182
|
+
"Connection": "keep-alive",
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
const reader = upstream.body?.getReader();
|
|
186
|
+
if (!reader) {
|
|
187
|
+
res.end();
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
try {
|
|
192
|
+
while (true) {
|
|
193
|
+
const { done, value } = await reader.read();
|
|
194
|
+
if (done) break;
|
|
195
|
+
res.write(value);
|
|
196
|
+
}
|
|
197
|
+
} catch {
|
|
198
|
+
// Client disconnected or upstream error
|
|
199
|
+
} finally {
|
|
200
|
+
reader.releaseLock();
|
|
201
|
+
res.end();
|
|
202
|
+
}
|
|
203
|
+
} catch (err) {
|
|
204
|
+
if (!res.headersSent) {
|
|
205
|
+
res.writeHead(500, { "Content-Type": "application/json" });
|
|
206
|
+
res.end(JSON.stringify({ error: err instanceof Error ? err.message : "Internal error" }));
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const MAX_BODY_SIZE = 1024 * 1024; // 1 MB
|
|
213
|
+
|
|
214
|
+
function readBody(req: IncomingMessage): Promise<string> {
|
|
215
|
+
return new Promise((resolve, reject) => {
|
|
216
|
+
const chunks: Buffer[] = [];
|
|
217
|
+
let size = 0;
|
|
218
|
+
req.on("data", (chunk: Buffer) => {
|
|
219
|
+
size += chunk.length;
|
|
220
|
+
if (size > MAX_BODY_SIZE) {
|
|
221
|
+
req.destroy();
|
|
222
|
+
reject(new Error("Request body too large"));
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
chunks.push(chunk);
|
|
226
|
+
});
|
|
227
|
+
req.on("end", () => resolve(Buffer.concat(chunks).toString()));
|
|
228
|
+
req.on("error", reject);
|
|
229
|
+
});
|
|
230
|
+
}
|