@lelouchhe/webagent 0.1.9 → 0.1.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +3 -3
- package/lib/bridge.js +0 -284
- package/lib/config.js +0 -62
- package/lib/daemon.js +0 -278
- package/lib/event-handler.js +0 -104
- package/lib/push-service.js +0 -168
- package/lib/routes.js +0 -929
- package/lib/server.js +0 -72
- package/lib/session-manager.js +0 -276
- package/lib/shared/constants.js +0 -16
- package/lib/sse-manager.js +0 -80
- package/lib/store.js +0 -174
- package/lib/title-service.js +0 -71
- package/lib/types.js +0 -13
package/lib/routes.js
DELETED
|
@@ -1,929 +0,0 @@
|
|
|
1
|
-
import { readFile, writeFile, mkdir } from "node:fs/promises";
|
|
2
|
-
import { spawn } from "node:child_process";
|
|
3
|
-
import { join, extname } from "node:path";
|
|
4
|
-
import { gzipSync } from "node:zlib";
|
|
5
|
-
import { Store } from "./store.js";
|
|
6
|
-
import { errorMessage } from "./types.js";
|
|
7
|
-
import { interruptBashProc } from "./session-manager.js";
|
|
8
|
-
const IS_WIN = process.platform === "win32";
|
|
9
|
-
const SAFE_ID = /^[a-zA-Z0-9_-]+$/;
|
|
10
|
-
const MIME = {
|
|
11
|
-
".html": "text/html; charset=utf-8",
|
|
12
|
-
".js": "application/javascript; charset=utf-8",
|
|
13
|
-
".css": "text/css; charset=utf-8",
|
|
14
|
-
".json": "application/json; charset=utf-8",
|
|
15
|
-
".svg": "image/svg+xml; charset=utf-8",
|
|
16
|
-
".png": "image/png",
|
|
17
|
-
".jpg": "image/jpeg",
|
|
18
|
-
".jpeg": "image/jpeg",
|
|
19
|
-
".gif": "image/gif",
|
|
20
|
-
".webp": "image/webp",
|
|
21
|
-
};
|
|
22
|
-
/** Read the full request body as a string. */
|
|
23
|
-
function readBody(req) {
|
|
24
|
-
return new Promise((resolve, reject) => {
|
|
25
|
-
const chunks = [];
|
|
26
|
-
req.on("data", (chunk) => chunks.push(chunk));
|
|
27
|
-
req.on("end", () => resolve(Buffer.concat(chunks).toString()));
|
|
28
|
-
req.on("error", reject);
|
|
29
|
-
});
|
|
30
|
-
}
|
|
31
|
-
/** Send a JSON response, gzip-compressed when the client supports it. */
|
|
32
|
-
function json(res, status, data, req) {
|
|
33
|
-
const body = JSON.stringify(data);
|
|
34
|
-
if (req && body.length > 1024 && (req.headers["accept-encoding"] || "").includes("gzip")) {
|
|
35
|
-
const compressed = gzipSync(body);
|
|
36
|
-
res.writeHead(status, {
|
|
37
|
-
"Content-Type": "application/json",
|
|
38
|
-
"Content-Encoding": "gzip",
|
|
39
|
-
"Content-Length": compressed.length,
|
|
40
|
-
});
|
|
41
|
-
res.end(compressed);
|
|
42
|
-
}
|
|
43
|
-
else {
|
|
44
|
-
res.writeHead(status, { "Content-Type": "application/json" });
|
|
45
|
-
res.end(body);
|
|
46
|
-
}
|
|
47
|
-
}
|
|
48
|
-
export function createRequestHandler(storeOrDeps, publicDir, dataDir, limits, pushService) {
|
|
49
|
-
// Normalize to deps object (support legacy positional args)
|
|
50
|
-
const deps = (storeOrDeps instanceof Store)
|
|
51
|
-
? { store: storeOrDeps, publicDir: publicDir, dataDir: dataDir, limits: limits, pushService }
|
|
52
|
-
: storeOrDeps;
|
|
53
|
-
const { store, sessions, getBridge, sseManager, titleService } = deps;
|
|
54
|
-
return async (req, res) => {
|
|
55
|
-
const url = req.url ?? "/";
|
|
56
|
-
// --- API routes ---
|
|
57
|
-
if (url === "/api/v1" || url.startsWith("/api/v1/")) {
|
|
58
|
-
res.setHeader("Content-Type", "application/json");
|
|
59
|
-
// GET /api/v1 — discovery endpoint
|
|
60
|
-
if (url === "/api/v1" && req.method === "GET") {
|
|
61
|
-
json(res, 200, {
|
|
62
|
-
version: "v1",
|
|
63
|
-
endpoints: {
|
|
64
|
-
sessions: "/api/v1/sessions",
|
|
65
|
-
config: "/api/v1/config",
|
|
66
|
-
events_stream: "/api/v1/events/stream",
|
|
67
|
-
prompt: "/api/v1/prompt",
|
|
68
|
-
push: "/api/v1/push",
|
|
69
|
-
clients: "/api/v1/clients",
|
|
70
|
-
},
|
|
71
|
-
});
|
|
72
|
-
return;
|
|
73
|
-
}
|
|
74
|
-
// GET /api/v1/sessions
|
|
75
|
-
if (url.startsWith("/api/v1/sessions") && !url.slice("/api/v1/sessions".length).match(/^\//) && req.method === "GET") {
|
|
76
|
-
const params = new URLSearchParams(url.split("?")[1] ?? "");
|
|
77
|
-
const source = params.get("source") ?? undefined;
|
|
78
|
-
res.end(JSON.stringify(store.listSessions(source ? { source } : undefined)));
|
|
79
|
-
return;
|
|
80
|
-
}
|
|
81
|
-
// --- GET /api/v1/config ---
|
|
82
|
-
if (url === "/api/v1/config" && req.method === "GET") {
|
|
83
|
-
json(res, 200, {
|
|
84
|
-
configOptions: sessions?.cachedConfigOptions ?? [],
|
|
85
|
-
cancelTimeout: deps.limits.cancel_timeout ?? 0,
|
|
86
|
-
});
|
|
87
|
-
return;
|
|
88
|
-
}
|
|
89
|
-
// --- Permissions (session-scoped) ---
|
|
90
|
-
// GET /api/v1/sessions/:id/permissions
|
|
91
|
-
const permListMatch = url.match(/^\/api\/v1\/sessions\/([^/]+)\/permissions\/?(\?.*)?$/);
|
|
92
|
-
if (permListMatch && req.method === "GET") {
|
|
93
|
-
const sessionId = decodeURIComponent(permListMatch[1]);
|
|
94
|
-
const perms = sessions?.getPendingPermissions(sessionId) ?? [];
|
|
95
|
-
json(res, 200, perms);
|
|
96
|
-
return;
|
|
97
|
-
}
|
|
98
|
-
// POST /api/v1/sessions/:id/permissions/:reqId
|
|
99
|
-
const permActionMatch = url.match(/^\/api\/v1\/sessions\/([^/]+)\/permissions\/([^/?]+)\/?$/);
|
|
100
|
-
if (permActionMatch && req.method === "POST") {
|
|
101
|
-
const sessionId = decodeURIComponent(permActionMatch[1]);
|
|
102
|
-
const requestId = decodeURIComponent(permActionMatch[2]);
|
|
103
|
-
const perm = sessions?.pendingPermissions.get(requestId);
|
|
104
|
-
if (!perm) {
|
|
105
|
-
json(res, 404, { error: "Permission not found" });
|
|
106
|
-
return;
|
|
107
|
-
}
|
|
108
|
-
if (perm.sessionId !== sessionId) {
|
|
109
|
-
json(res, 400, { error: "Session ID mismatch" });
|
|
110
|
-
return;
|
|
111
|
-
}
|
|
112
|
-
const bridge = getBridge?.();
|
|
113
|
-
if (!bridge) {
|
|
114
|
-
json(res, 503, { error: "Agent not ready yet" });
|
|
115
|
-
return;
|
|
116
|
-
}
|
|
117
|
-
let body;
|
|
118
|
-
try {
|
|
119
|
-
body = JSON.parse(await readBody(req));
|
|
120
|
-
}
|
|
121
|
-
catch {
|
|
122
|
-
json(res, 400, { error: "Invalid JSON" });
|
|
123
|
-
return;
|
|
124
|
-
}
|
|
125
|
-
if (!body.optionId && !body.denied) {
|
|
126
|
-
json(res, 400, { error: "Provide optionId or denied:true" });
|
|
127
|
-
return;
|
|
128
|
-
}
|
|
129
|
-
const denied = !!body.denied;
|
|
130
|
-
const optionId = body.optionId ?? "deny";
|
|
131
|
-
const optionName = perm.options.find(o => o.optionId === optionId)?.label ?? optionId;
|
|
132
|
-
if (denied) {
|
|
133
|
-
await bridge.denyPermission(requestId);
|
|
134
|
-
}
|
|
135
|
-
else {
|
|
136
|
-
await bridge.resolvePermission(requestId, optionId);
|
|
137
|
-
}
|
|
138
|
-
sessions.pendingPermissions.delete(requestId);
|
|
139
|
-
// Store event and broadcast
|
|
140
|
-
store.saveEvent(perm.sessionId, "permission_response", {
|
|
141
|
-
requestId, optionId, optionName, denied,
|
|
142
|
-
});
|
|
143
|
-
const permEvent = {
|
|
144
|
-
type: "permission_resolved",
|
|
145
|
-
sessionId: perm.sessionId,
|
|
146
|
-
requestId,
|
|
147
|
-
optionName,
|
|
148
|
-
denied,
|
|
149
|
-
};
|
|
150
|
-
sseManager.broadcast(permEvent);
|
|
151
|
-
json(res, 200, { ok: true });
|
|
152
|
-
return;
|
|
153
|
-
}
|
|
154
|
-
// --- POST /api/v1/sessions/:id/cancel ---
|
|
155
|
-
const cancelMatch = url.match(/^\/api\/v1\/sessions\/([^/]+)\/cancel\/?$/);
|
|
156
|
-
if (cancelMatch && req.method === "POST") {
|
|
157
|
-
const sessionId = decodeURIComponent(cancelMatch[1]);
|
|
158
|
-
const session = store.getSession(sessionId);
|
|
159
|
-
if (!session) {
|
|
160
|
-
json(res, 404, { error: "Session not found" });
|
|
161
|
-
return;
|
|
162
|
-
}
|
|
163
|
-
const bridge = getBridge?.();
|
|
164
|
-
if (!bridge) {
|
|
165
|
-
json(res, 503, { error: "Agent not ready yet" });
|
|
166
|
-
return;
|
|
167
|
-
}
|
|
168
|
-
// Kill running bash process if any
|
|
169
|
-
const proc = sessions?.runningBashProcs.get(sessionId);
|
|
170
|
-
if (proc) {
|
|
171
|
-
interruptBashProc(proc);
|
|
172
|
-
sessions.runningBashProcs.delete(sessionId);
|
|
173
|
-
}
|
|
174
|
-
// Cancel agent prompt
|
|
175
|
-
if (sessions?.activePrompts.has(sessionId)) {
|
|
176
|
-
await bridge.cancel(sessionId);
|
|
177
|
-
sessions.activePrompts.delete(sessionId);
|
|
178
|
-
}
|
|
179
|
-
json(res, 200, { ok: true });
|
|
180
|
-
return;
|
|
181
|
-
}
|
|
182
|
-
// --- GET /api/v1/sessions/:id/status ---
|
|
183
|
-
const statusMatch = url.match(/^\/api\/v1\/sessions\/([^/]+)\/status\/?$/);
|
|
184
|
-
if (statusMatch && req.method === "GET") {
|
|
185
|
-
const sessionId = decodeURIComponent(statusMatch[1]);
|
|
186
|
-
const session = store.getSession(sessionId);
|
|
187
|
-
if (!session) {
|
|
188
|
-
json(res, 404, { error: "Session not found" });
|
|
189
|
-
return;
|
|
190
|
-
}
|
|
191
|
-
const busyKind = sessions?.getBusyKind(sessionId) ?? null;
|
|
192
|
-
const pendingPerms = sessions?.getPendingPermissions(sessionId) ?? [];
|
|
193
|
-
json(res, 200, {
|
|
194
|
-
busy: busyKind != null,
|
|
195
|
-
busyKind,
|
|
196
|
-
pendingPermissions: pendingPerms,
|
|
197
|
-
});
|
|
198
|
-
return;
|
|
199
|
-
}
|
|
200
|
-
// --- POST /api/v1/sessions/:id/prompt ---
|
|
201
|
-
const promptMatch = url.match(/^\/api\/v1\/sessions\/([^/]+)\/prompt\/?(\?.*)?$/);
|
|
202
|
-
if (promptMatch && req.method === "POST") {
|
|
203
|
-
const sessionId = decodeURIComponent(promptMatch[1]);
|
|
204
|
-
const session = store.getSession(sessionId);
|
|
205
|
-
if (!session) {
|
|
206
|
-
json(res, 404, { error: "Session not found" });
|
|
207
|
-
return;
|
|
208
|
-
}
|
|
209
|
-
const bridge = getBridge?.();
|
|
210
|
-
if (!bridge) {
|
|
211
|
-
json(res, 503, { error: "Agent not ready yet" });
|
|
212
|
-
return;
|
|
213
|
-
}
|
|
214
|
-
if (!sessions) {
|
|
215
|
-
json(res, 503, { error: "Session manager not available" });
|
|
216
|
-
return;
|
|
217
|
-
}
|
|
218
|
-
// Ensure session is live in ACP before prompting (awaits in-flight resume)
|
|
219
|
-
try {
|
|
220
|
-
await sessions.ensureResumed(bridge, sessionId);
|
|
221
|
-
}
|
|
222
|
-
catch (err) {
|
|
223
|
-
json(res, 500, { error: `Failed to resume session: ${err instanceof Error ? err.message : String(err)}` });
|
|
224
|
-
return;
|
|
225
|
-
}
|
|
226
|
-
// Check if session is busy
|
|
227
|
-
const busyKind = sessions.getBusyKind(sessionId);
|
|
228
|
-
if (busyKind) {
|
|
229
|
-
json(res, 409, { error: "Session is busy", busyKind });
|
|
230
|
-
return;
|
|
231
|
-
}
|
|
232
|
-
let body;
|
|
233
|
-
try {
|
|
234
|
-
body = JSON.parse(await readBody(req));
|
|
235
|
-
}
|
|
236
|
-
catch {
|
|
237
|
-
json(res, 400, { error: "Invalid JSON" });
|
|
238
|
-
return;
|
|
239
|
-
}
|
|
240
|
-
if (!body.text) {
|
|
241
|
-
json(res, 400, { error: "Missing required field: text" });
|
|
242
|
-
return;
|
|
243
|
-
}
|
|
244
|
-
// Store user_message event and update last_active_at
|
|
245
|
-
store.saveEvent(sessionId, "user_message", { text: body.text, images: body.images });
|
|
246
|
-
store.updateSessionLastActive(sessionId);
|
|
247
|
-
const userMsgEvent = { type: "user_message", sessionId, text: body.text, images: body.images };
|
|
248
|
-
sseManager.broadcast(userMsgEvent);
|
|
249
|
-
// Generate title (fire-and-forget)
|
|
250
|
-
if (titleService && sessions && !sessions.sessionHasTitle.has(sessionId)) {
|
|
251
|
-
titleService.generate(bridge, body.text, sessionId, (title) => {
|
|
252
|
-
const titleEvent = { type: "session_title_updated", sessionId, title };
|
|
253
|
-
sseManager.broadcast(titleEvent);
|
|
254
|
-
});
|
|
255
|
-
}
|
|
256
|
-
// Fire prompt asynchronously (don't await — response is 202)
|
|
257
|
-
sessions.activePrompts.add(sessionId);
|
|
258
|
-
bridge.prompt(sessionId, body.text, body.images).catch((err) => {
|
|
259
|
-
console.error(`[prompt] error for ${sessionId}:`, err);
|
|
260
|
-
}).finally(() => {
|
|
261
|
-
sessions.activePrompts.delete(sessionId);
|
|
262
|
-
});
|
|
263
|
-
json(res, 202, { status: "accepted" });
|
|
264
|
-
return;
|
|
265
|
-
}
|
|
266
|
-
// --- POST /api/v1/sessions/:id/bash ---
|
|
267
|
-
const bashMatch = url.match(/^\/api\/v1\/sessions\/([^/]+)\/bash\/?$/);
|
|
268
|
-
if (bashMatch && req.method === "POST") {
|
|
269
|
-
const sessionId = decodeURIComponent(bashMatch[1]);
|
|
270
|
-
const session = store.getSession(sessionId);
|
|
271
|
-
if (!session) {
|
|
272
|
-
json(res, 404, { error: "Session not found" });
|
|
273
|
-
return;
|
|
274
|
-
}
|
|
275
|
-
if (!sessions) {
|
|
276
|
-
json(res, 503, { error: "Session manager not available" });
|
|
277
|
-
return;
|
|
278
|
-
}
|
|
279
|
-
if (sessions.runningBashProcs.has(sessionId)) {
|
|
280
|
-
json(res, 409, { error: "A bash command is already running in this session" });
|
|
281
|
-
return;
|
|
282
|
-
}
|
|
283
|
-
let body;
|
|
284
|
-
try {
|
|
285
|
-
body = JSON.parse(await readBody(req));
|
|
286
|
-
}
|
|
287
|
-
catch {
|
|
288
|
-
json(res, 400, { error: "Invalid JSON" });
|
|
289
|
-
return;
|
|
290
|
-
}
|
|
291
|
-
if (!body.command) {
|
|
292
|
-
json(res, 400, { error: "Missing required field: command" });
|
|
293
|
-
return;
|
|
294
|
-
}
|
|
295
|
-
const cwd = sessions.getSessionCwd(sessionId);
|
|
296
|
-
store.saveEvent(sessionId, "bash_command", { command: body.command });
|
|
297
|
-
const bashCmdEvent = { type: "bash_command", sessionId, command: body.command };
|
|
298
|
-
sseManager.broadcast(bashCmdEvent);
|
|
299
|
-
const shell = IS_WIN ? (process.env.COMSPEC || "cmd.exe") : (process.env.SHELL || "bash");
|
|
300
|
-
const shellArgs = IS_WIN ? ["/s", "/c", body.command] : ["-c", body.command];
|
|
301
|
-
const child = spawn(shell, shellArgs, {
|
|
302
|
-
cwd,
|
|
303
|
-
detached: !IS_WIN,
|
|
304
|
-
env: { ...process.env, TERM: "dumb" },
|
|
305
|
-
stdio: ["ignore", "pipe", "pipe"],
|
|
306
|
-
});
|
|
307
|
-
sessions.runningBashProcs.set(sessionId, child);
|
|
308
|
-
let output = "";
|
|
309
|
-
let outputTruncated = false;
|
|
310
|
-
const limit = deps.limits.bash_output;
|
|
311
|
-
const onData = (stream) => (chunk) => {
|
|
312
|
-
const text = chunk.toString();
|
|
313
|
-
if (!outputTruncated) {
|
|
314
|
-
output += text;
|
|
315
|
-
if (output.length > limit) {
|
|
316
|
-
output = output.slice(-limit);
|
|
317
|
-
outputTruncated = true;
|
|
318
|
-
}
|
|
319
|
-
}
|
|
320
|
-
else {
|
|
321
|
-
output = (output + text).slice(-limit);
|
|
322
|
-
}
|
|
323
|
-
const bashOutEvent = { type: "bash_output", sessionId, text, stream };
|
|
324
|
-
sseManager.broadcast(bashOutEvent);
|
|
325
|
-
};
|
|
326
|
-
child.stdout.on("data", onData("stdout"));
|
|
327
|
-
child.stderr.on("data", onData("stderr"));
|
|
328
|
-
child.on("close", (code, signal) => {
|
|
329
|
-
sessions.runningBashProcs.delete(sessionId);
|
|
330
|
-
const stored = outputTruncated ? "[truncated]\n" + output : output;
|
|
331
|
-
store.saveEvent(sessionId, "bash_result", { output: stored, code, signal });
|
|
332
|
-
const bashDoneEvent = { type: "bash_done", sessionId, code, signal };
|
|
333
|
-
sseManager.broadcast(bashDoneEvent);
|
|
334
|
-
});
|
|
335
|
-
child.on("error", (err) => {
|
|
336
|
-
sessions.runningBashProcs.delete(sessionId);
|
|
337
|
-
const errMsg = errorMessage(err);
|
|
338
|
-
store.saveEvent(sessionId, "bash_result", { output: errMsg, code: -1, signal: null });
|
|
339
|
-
const bashErrEvent = { type: "bash_done", sessionId, code: -1, signal: null, error: errMsg };
|
|
340
|
-
sseManager.broadcast(bashErrEvent);
|
|
341
|
-
});
|
|
342
|
-
json(res, 202, { status: "accepted" });
|
|
343
|
-
return;
|
|
344
|
-
}
|
|
345
|
-
// --- POST /api/v1/sessions/:id/bash/cancel ---
|
|
346
|
-
const bashCancelMatch = url.match(/^\/api\/v1\/sessions\/([^/]+)\/bash\/cancel\/?$/);
|
|
347
|
-
if (bashCancelMatch && req.method === "POST") {
|
|
348
|
-
const sessionId = decodeURIComponent(bashCancelMatch[1]);
|
|
349
|
-
const session = store.getSession(sessionId);
|
|
350
|
-
if (!session) {
|
|
351
|
-
json(res, 404, { error: "Session not found" });
|
|
352
|
-
return;
|
|
353
|
-
}
|
|
354
|
-
interruptBashProc(sessions?.runningBashProcs.get(sessionId));
|
|
355
|
-
json(res, 200, { ok: true });
|
|
356
|
-
return;
|
|
357
|
-
}
|
|
358
|
-
// --- PUT /api/v1/sessions/:id/{model,mode,reasoning-effort} ---
|
|
359
|
-
const configPutMatch = url.match(/^\/api\/v1\/sessions\/([^/]+)\/(model|mode|reasoning-effort)\/?$/);
|
|
360
|
-
if (configPutMatch && req.method === "PUT") {
|
|
361
|
-
const sessionId = decodeURIComponent(configPutMatch[1]);
|
|
362
|
-
const configPath = configPutMatch[2];
|
|
363
|
-
const configId = configPath === "reasoning-effort" ? "reasoning_effort" : configPath;
|
|
364
|
-
const session = store.getSession(sessionId);
|
|
365
|
-
if (!session) {
|
|
366
|
-
json(res, 404, { error: "Session not found" });
|
|
367
|
-
return;
|
|
368
|
-
}
|
|
369
|
-
const bridge = getBridge?.();
|
|
370
|
-
if (!bridge) {
|
|
371
|
-
json(res, 503, { error: "Agent not ready yet" });
|
|
372
|
-
return;
|
|
373
|
-
}
|
|
374
|
-
let body;
|
|
375
|
-
try {
|
|
376
|
-
body = JSON.parse(await readBody(req));
|
|
377
|
-
}
|
|
378
|
-
catch {
|
|
379
|
-
json(res, 400, { error: "Invalid JSON" });
|
|
380
|
-
return;
|
|
381
|
-
}
|
|
382
|
-
if (!body.value) {
|
|
383
|
-
json(res, 400, { error: "Missing required field: value" });
|
|
384
|
-
return;
|
|
385
|
-
}
|
|
386
|
-
try {
|
|
387
|
-
const configOptions = await bridge.setConfigOption(sessionId, configId, body.value);
|
|
388
|
-
for (const opt of configOptions) {
|
|
389
|
-
store.updateSessionConfig(sessionId, opt.id, opt.currentValue);
|
|
390
|
-
}
|
|
391
|
-
sseManager.broadcast({ type: "config_option_update", sessionId, configOptions });
|
|
392
|
-
sseManager.broadcast({ type: "config_set", sessionId, configId, value: body.value });
|
|
393
|
-
json(res, 200, { configOptions });
|
|
394
|
-
}
|
|
395
|
-
catch (err) {
|
|
396
|
-
json(res, 500, { error: `Failed to set ${configId}: ${err instanceof Error ? err.message : String(err)}` });
|
|
397
|
-
}
|
|
398
|
-
return;
|
|
399
|
-
}
|
|
400
|
-
// --- PUT /api/v1/sessions/:id/title ---
|
|
401
|
-
const titlePutMatch = url.match(/^\/api\/v1\/sessions\/([^/]+)\/title\/?$/);
|
|
402
|
-
if (titlePutMatch && req.method === "PUT") {
|
|
403
|
-
const sessionId = decodeURIComponent(titlePutMatch[1]);
|
|
404
|
-
const session = store.getSession(sessionId);
|
|
405
|
-
if (!session) {
|
|
406
|
-
json(res, 404, { error: "Session not found" });
|
|
407
|
-
return;
|
|
408
|
-
}
|
|
409
|
-
let body;
|
|
410
|
-
try {
|
|
411
|
-
body = JSON.parse(await readBody(req));
|
|
412
|
-
}
|
|
413
|
-
catch {
|
|
414
|
-
json(res, 400, { error: "Invalid JSON" });
|
|
415
|
-
return;
|
|
416
|
-
}
|
|
417
|
-
if (!body.value) {
|
|
418
|
-
json(res, 400, { error: "Missing required field: value" });
|
|
419
|
-
return;
|
|
420
|
-
}
|
|
421
|
-
store.updateSessionTitle(sessionId, body.value);
|
|
422
|
-
const titleEvent = { type: "session_title_updated", sessionId, title: body.value };
|
|
423
|
-
sseManager.broadcast(titleEvent);
|
|
424
|
-
json(res, 200, { title: body.value });
|
|
425
|
-
return;
|
|
426
|
-
}
|
|
427
|
-
// --- Session CRUD: /api/v1/sessions/:id ---
|
|
428
|
-
const sessionIdMatch = url.match(/^\/api\/v1\/sessions\/([^/?]+)\/?(\?.*)?$/);
|
|
429
|
-
if (sessionIdMatch) {
|
|
430
|
-
const sessionId = decodeURIComponent(sessionIdMatch[1]);
|
|
431
|
-
// POST /api/v1/sessions (create) — handled below since :id would match "sessions" literally
|
|
432
|
-
// This match is for /api/v1/sessions/:id only (not /api/sessions)
|
|
433
|
-
// GET /api/v1/sessions/:id
|
|
434
|
-
if (req.method === "GET") {
|
|
435
|
-
const session = store.getSession(sessionId);
|
|
436
|
-
if (!session) {
|
|
437
|
-
json(res, 404, { error: "Session not found" });
|
|
438
|
-
return;
|
|
439
|
-
}
|
|
440
|
-
// Start resume in background (non-blocking) so the client gets metadata fast
|
|
441
|
-
const wasLive = sessions?.liveSessions.has(sessionId) ?? true;
|
|
442
|
-
if (sessions && getBridge && !wasLive) {
|
|
443
|
-
const bridge = getBridge();
|
|
444
|
-
if (bridge) {
|
|
445
|
-
const resumePromise = sessions.ensureResumed(bridge, sessionId);
|
|
446
|
-
// Auto-retry if the last turn was interrupted (must wait for resume)
|
|
447
|
-
const hasInterrupted = store.hasInterruptedTurn(sessionId);
|
|
448
|
-
if (hasInterrupted) {
|
|
449
|
-
// Optimistically mark busy so concurrent POST sees the session as active
|
|
450
|
-
sessions.activePrompts.add(sessionId);
|
|
451
|
-
resumePromise.then(() => {
|
|
452
|
-
if (!sessions.autoRetryIfNeeded(bridge, sessionId)) {
|
|
453
|
-
// Retry not needed after all — release the optimistic lock
|
|
454
|
-
sessions.activePrompts.delete(sessionId);
|
|
455
|
-
}
|
|
456
|
-
}).catch(() => {
|
|
457
|
-
sessions.activePrompts.delete(sessionId);
|
|
458
|
-
});
|
|
459
|
-
}
|
|
460
|
-
else {
|
|
461
|
-
resumePromise.catch((err) => {
|
|
462
|
-
console.error(`[session] background resume failed for ${sessionId.slice(0, 8)}…:`, err);
|
|
463
|
-
});
|
|
464
|
-
}
|
|
465
|
-
}
|
|
466
|
-
}
|
|
467
|
-
const configOptions = sessions ? (() => {
|
|
468
|
-
// Build configOptions from cached + stored overrides
|
|
469
|
-
const opts = sessions.cachedConfigOptions.map(opt => {
|
|
470
|
-
const stored = { model: session.model, mode: session.mode, reasoning_effort: session.reasoning_effort };
|
|
471
|
-
const override = stored[opt.id];
|
|
472
|
-
return override ? { ...opt, currentValue: override } : opt;
|
|
473
|
-
});
|
|
474
|
-
return opts;
|
|
475
|
-
})() : [];
|
|
476
|
-
const busyKind = sessions?.getBusyKind(sessionId) ?? null;
|
|
477
|
-
json(res, 200, {
|
|
478
|
-
id: session.id,
|
|
479
|
-
cwd: session.cwd,
|
|
480
|
-
title: session.title,
|
|
481
|
-
source: session.source,
|
|
482
|
-
model: session.model,
|
|
483
|
-
mode: session.mode,
|
|
484
|
-
configOptions,
|
|
485
|
-
busy: busyKind != null,
|
|
486
|
-
busyKind,
|
|
487
|
-
}, req);
|
|
488
|
-
return;
|
|
489
|
-
}
|
|
490
|
-
// DELETE /api/v1/sessions/:id
|
|
491
|
-
if (req.method === "DELETE") {
|
|
492
|
-
const session = store.getSession(sessionId);
|
|
493
|
-
if (!session) {
|
|
494
|
-
json(res, 404, { error: "Session not found" });
|
|
495
|
-
return;
|
|
496
|
-
}
|
|
497
|
-
if (sessions) {
|
|
498
|
-
sessions.deleteSession(sessionId);
|
|
499
|
-
}
|
|
500
|
-
else {
|
|
501
|
-
store.deleteSession(sessionId);
|
|
502
|
-
}
|
|
503
|
-
sseManager.broadcast({ type: "session_deleted", sessionId });
|
|
504
|
-
res.writeHead(204);
|
|
505
|
-
res.end();
|
|
506
|
-
return;
|
|
507
|
-
}
|
|
508
|
-
}
|
|
509
|
-
// POST /api/v1/sessions (create new session)
|
|
510
|
-
if (url === "/api/v1/sessions" && req.method === "POST") {
|
|
511
|
-
const bridge = getBridge?.();
|
|
512
|
-
if (!bridge) {
|
|
513
|
-
json(res, 503, { error: "Agent not ready yet" });
|
|
514
|
-
return;
|
|
515
|
-
}
|
|
516
|
-
if (!sessions) {
|
|
517
|
-
json(res, 503, { error: "Session manager not available" });
|
|
518
|
-
return;
|
|
519
|
-
}
|
|
520
|
-
let body;
|
|
521
|
-
try {
|
|
522
|
-
body = JSON.parse(await readBody(req));
|
|
523
|
-
}
|
|
524
|
-
catch {
|
|
525
|
-
json(res, 400, { error: "Invalid JSON" });
|
|
526
|
-
return;
|
|
527
|
-
}
|
|
528
|
-
const source = body.source ?? "auto";
|
|
529
|
-
try {
|
|
530
|
-
const { sessionId, configOptions } = await sessions.createSession(bridge, body.cwd, body.inheritFromSessionId, source);
|
|
531
|
-
const session = store.getSession(sessionId);
|
|
532
|
-
const sessionCreatedEvent = {
|
|
533
|
-
type: "session_created",
|
|
534
|
-
sessionId,
|
|
535
|
-
cwd: session?.cwd,
|
|
536
|
-
title: session?.title,
|
|
537
|
-
configOptions,
|
|
538
|
-
};
|
|
539
|
-
sseManager.broadcast(sessionCreatedEvent);
|
|
540
|
-
// ACP's session_created event fires before inheritance runs, so
|
|
541
|
-
// broadcast final configOptions so SSE clients get the inherited values.
|
|
542
|
-
if (configOptions.length) {
|
|
543
|
-
sseManager.broadcast({ type: "config_option_update", sessionId, configOptions });
|
|
544
|
-
}
|
|
545
|
-
json(res, 201, {
|
|
546
|
-
id: sessionId,
|
|
547
|
-
cwd: session?.cwd ?? body.cwd,
|
|
548
|
-
title: session?.title ?? null,
|
|
549
|
-
source: session?.source ?? source,
|
|
550
|
-
configOptions,
|
|
551
|
-
});
|
|
552
|
-
}
|
|
553
|
-
catch (err) {
|
|
554
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
555
|
-
if (msg.includes("does not exist")) {
|
|
556
|
-
json(res, 400, { error: msg });
|
|
557
|
-
}
|
|
558
|
-
else {
|
|
559
|
-
json(res, 500, { error: msg });
|
|
560
|
-
}
|
|
561
|
-
}
|
|
562
|
-
return;
|
|
563
|
-
}
|
|
564
|
-
// GET /api/v1/sessions/:id/events?thinking=0|1&limit=N&before=SEQ&after=SEQ
|
|
565
|
-
const eventsMatch = url.match(/^\/api\/v1\/sessions\/([^/]+)\/events(\?.*)?$/);
|
|
566
|
-
if (eventsMatch && req.method === "GET") {
|
|
567
|
-
const sessionId = decodeURIComponent(eventsMatch[1]);
|
|
568
|
-
const params = new URLSearchParams(eventsMatch[2]?.slice(1) ?? "");
|
|
569
|
-
const excludeThinking = params.get("thinking") === "0";
|
|
570
|
-
const afterRaw = params.get("after");
|
|
571
|
-
const afterSeq = afterRaw != null ? Number(afterRaw) : undefined;
|
|
572
|
-
const beforeRaw = params.get("before");
|
|
573
|
-
const beforeSeq = beforeRaw != null ? Number(beforeRaw) : undefined;
|
|
574
|
-
const limitRaw = params.get("limit");
|
|
575
|
-
const limit = limitRaw != null ? Math.max(1, Math.min(10000, Number(limitRaw))) : undefined;
|
|
576
|
-
const session = store.getSession(sessionId);
|
|
577
|
-
if (!session) {
|
|
578
|
-
json(res, 404, { error: "Session not found" });
|
|
579
|
-
return;
|
|
580
|
-
}
|
|
581
|
-
// Flush pending buffers so their content becomes part of the event list.
|
|
582
|
-
// Track whether each buffer was non-empty so the frontend can keep the
|
|
583
|
-
// last thinking/assistant element "open" for continued live streaming.
|
|
584
|
-
let streamingThinking = false;
|
|
585
|
-
let streamingAssistant = false;
|
|
586
|
-
if (sessions) {
|
|
587
|
-
if (sessions.thinkingBuffers.has(sessionId)) {
|
|
588
|
-
streamingThinking = true;
|
|
589
|
-
sessions.flushThinkingBuffer(sessionId);
|
|
590
|
-
}
|
|
591
|
-
if (sessions.assistantBuffers.has(sessionId)) {
|
|
592
|
-
streamingAssistant = true;
|
|
593
|
-
sessions.flushAssistantBuffer(sessionId);
|
|
594
|
-
}
|
|
595
|
-
}
|
|
596
|
-
const events = store.getEvents(sessionId, { excludeThinking, afterSeq, beforeSeq, limit });
|
|
597
|
-
const envelope = {
|
|
598
|
-
events,
|
|
599
|
-
streaming: { thinking: streamingThinking, assistant: streamingAssistant },
|
|
600
|
-
};
|
|
601
|
-
if (limit != null) {
|
|
602
|
-
const total = store.getEventCount(sessionId, { excludeThinking });
|
|
603
|
-
const hasMore = events.length > 0
|
|
604
|
-
? (store.getEvents(sessionId, { excludeThinking, beforeSeq: events[0].seq, limit: 1 }).length > 0)
|
|
605
|
-
: false;
|
|
606
|
-
envelope.total = total;
|
|
607
|
-
envelope.hasMore = hasMore;
|
|
608
|
-
}
|
|
609
|
-
json(res, 200, envelope, req);
|
|
610
|
-
return;
|
|
611
|
-
}
|
|
612
|
-
// POST /api/v1/prompt — quick one-shot prompt (create temp session + send)
|
|
613
|
-
if (url === "/api/v1/prompt" && req.method === "POST") {
|
|
614
|
-
if (!sessions || !getBridge) {
|
|
615
|
-
json(res, 503, { error: "Agent not available" });
|
|
616
|
-
return;
|
|
617
|
-
}
|
|
618
|
-
const bridge = getBridge();
|
|
619
|
-
if (!bridge) {
|
|
620
|
-
json(res, 503, { error: "Agent not available" });
|
|
621
|
-
return;
|
|
622
|
-
}
|
|
623
|
-
let body;
|
|
624
|
-
try {
|
|
625
|
-
body = JSON.parse(await readBody(req));
|
|
626
|
-
}
|
|
627
|
-
catch {
|
|
628
|
-
json(res, 400, { error: "Invalid JSON" });
|
|
629
|
-
return;
|
|
630
|
-
}
|
|
631
|
-
const text = body.text;
|
|
632
|
-
if (!text || typeof text !== "string") {
|
|
633
|
-
json(res, 400, { error: "Missing required field: text" });
|
|
634
|
-
return;
|
|
635
|
-
}
|
|
636
|
-
const cwd = body.cwd || undefined;
|
|
637
|
-
const { sessionId } = await sessions.createSession(bridge, cwd, undefined, "auto");
|
|
638
|
-
const streamUrl = `/api/v1/sessions/${sessionId}/events/stream`;
|
|
639
|
-
json(res, 202, { sessionId, streamUrl });
|
|
640
|
-
// Fire-and-forget: send the prompt asynchronously, tracking busy state
|
|
641
|
-
sessions.activePrompts.add(sessionId);
|
|
642
|
-
// Generate title (fire-and-forget)
|
|
643
|
-
if (titleService && !sessions.sessionHasTitle.has(sessionId)) {
|
|
644
|
-
titleService.generate(bridge, text, sessionId, (title) => {
|
|
645
|
-
const titleEvent = { type: "session_title_updated", sessionId, title };
|
|
646
|
-
sseManager.broadcast(titleEvent);
|
|
647
|
-
});
|
|
648
|
-
}
|
|
649
|
-
bridge.prompt(sessionId, text)
|
|
650
|
-
.catch(() => { })
|
|
651
|
-
.finally(() => sessions.activePrompts.delete(sessionId));
|
|
652
|
-
return;
|
|
653
|
-
}
|
|
654
|
-
// --- SSE stream endpoints ---
|
|
655
|
-
// GET /api/v1/events/stream — global SSE stream
|
|
656
|
-
if (url.startsWith("/api/v1/events/stream") && req.method === "GET") {
|
|
657
|
-
if (!deps.sseManager) {
|
|
658
|
-
json(res, 501, { error: "SSE not available" });
|
|
659
|
-
return;
|
|
660
|
-
}
|
|
661
|
-
const sseManager = deps.sseManager;
|
|
662
|
-
const clientId = sseManager.generateClientId();
|
|
663
|
-
res.writeHead(200, {
|
|
664
|
-
"Content-Type": "text/event-stream",
|
|
665
|
-
"Cache-Control": "no-cache",
|
|
666
|
-
"Connection": "keep-alive",
|
|
667
|
-
});
|
|
668
|
-
const client = { id: clientId, res };
|
|
669
|
-
sseManager.add(client);
|
|
670
|
-
// Send connected event
|
|
671
|
-
sseManager.sendEvent(client, { type: "connected", clientId });
|
|
672
|
-
return;
|
|
673
|
-
}
|
|
674
|
-
// GET /api/v1/sessions/:id/events/stream — per-session SSE stream
|
|
675
|
-
const sseSessionMatch = url.match(/^\/api\/v1\/sessions\/([^/]+)\/events\/stream(\?.*)?$/);
|
|
676
|
-
if (sseSessionMatch && req.method === "GET") {
|
|
677
|
-
if (!deps.sseManager) {
|
|
678
|
-
json(res, 501, { error: "SSE not available" });
|
|
679
|
-
return;
|
|
680
|
-
}
|
|
681
|
-
const sseManager = deps.sseManager;
|
|
682
|
-
const sessionId = decodeURIComponent(sseSessionMatch[1]);
|
|
683
|
-
const session = store.getSession(sessionId);
|
|
684
|
-
if (!session) {
|
|
685
|
-
json(res, 404, { error: "Session not found" });
|
|
686
|
-
return;
|
|
687
|
-
}
|
|
688
|
-
const clientId = sseManager.generateClientId();
|
|
689
|
-
res.writeHead(200, {
|
|
690
|
-
"Content-Type": "text/event-stream",
|
|
691
|
-
"Cache-Control": "no-cache",
|
|
692
|
-
"Connection": "keep-alive",
|
|
693
|
-
});
|
|
694
|
-
const client = { id: clientId, res, sessionId };
|
|
695
|
-
sseManager.add(client);
|
|
696
|
-
// Send connected event
|
|
697
|
-
sseManager.sendEvent(client, { type: "connected", clientId });
|
|
698
|
-
// Replay events from Last-Event-ID if provided
|
|
699
|
-
const lastEventId = req.headers["last-event-id"];
|
|
700
|
-
if (lastEventId) {
|
|
701
|
-
const afterSeq = parseInt(lastEventId, 10);
|
|
702
|
-
if (!isNaN(afterSeq)) {
|
|
703
|
-
const events = store.getEvents(sessionId, { afterSeq });
|
|
704
|
-
for (const evt of events) {
|
|
705
|
-
try {
|
|
706
|
-
sseManager.sendEvent(client, { type: evt.type, ...JSON.parse(evt.data) }, evt.seq);
|
|
707
|
-
}
|
|
708
|
-
catch {
|
|
709
|
-
// Skip malformed event data
|
|
710
|
-
}
|
|
711
|
-
}
|
|
712
|
-
}
|
|
713
|
-
}
|
|
714
|
-
return;
|
|
715
|
-
}
|
|
716
|
-
// POST /api/v1/clients/:clientId/visibility
|
|
717
|
-
const visMatch = url.match(/^\/api\/v1\/clients\/([^/]+)\/visibility$/);
|
|
718
|
-
if (visMatch && req.method === "POST") {
|
|
719
|
-
if (!deps.sseManager) {
|
|
720
|
-
json(res, 501, { error: "SSE not available" });
|
|
721
|
-
return;
|
|
722
|
-
}
|
|
723
|
-
const sseManager = deps.sseManager;
|
|
724
|
-
const clientId = decodeURIComponent(visMatch[1]);
|
|
725
|
-
if (!sseManager.clients.has(clientId)) {
|
|
726
|
-
json(res, 404, { error: "Client not found" });
|
|
727
|
-
return;
|
|
728
|
-
}
|
|
729
|
-
let body;
|
|
730
|
-
try {
|
|
731
|
-
body = JSON.parse(await readBody(req));
|
|
732
|
-
}
|
|
733
|
-
catch {
|
|
734
|
-
json(res, 400, { error: "Invalid JSON" });
|
|
735
|
-
return;
|
|
736
|
-
}
|
|
737
|
-
if (typeof body.visible !== "boolean") {
|
|
738
|
-
json(res, 400, { error: "Missing or invalid 'visible' field" });
|
|
739
|
-
return;
|
|
740
|
-
}
|
|
741
|
-
// If push service is available, update visibility and session
|
|
742
|
-
if (deps.pushService) {
|
|
743
|
-
deps.pushService.setClientVisibility(clientId, body.visible);
|
|
744
|
-
if (typeof body.sessionId === "string" && body.sessionId) {
|
|
745
|
-
deps.pushService.setClientSession(clientId, body.sessionId);
|
|
746
|
-
}
|
|
747
|
-
}
|
|
748
|
-
json(res, 200, { ok: true });
|
|
749
|
-
return;
|
|
750
|
-
}
|
|
751
|
-
// --- Images (session-scoped) ---
|
|
752
|
-
// POST /api/v1/sessions/:id/images
|
|
753
|
-
const imgUploadMatch = url.match(/^\/api\/v1\/sessions\/([^/]+)\/images\/?$/);
|
|
754
|
-
if (imgUploadMatch && req.method === "POST") {
|
|
755
|
-
const sessionId = decodeURIComponent(imgUploadMatch[1]);
|
|
756
|
-
if (!SAFE_ID.test(sessionId)) {
|
|
757
|
-
json(res, 400, { error: "Invalid session ID" });
|
|
758
|
-
return;
|
|
759
|
-
}
|
|
760
|
-
// Enforce upload size limit
|
|
761
|
-
const contentLength = parseInt(req.headers["content-length"] ?? "0", 10);
|
|
762
|
-
if (contentLength > deps.limits.image_upload) {
|
|
763
|
-
json(res, 413, { error: "Upload too large" });
|
|
764
|
-
return;
|
|
765
|
-
}
|
|
766
|
-
const chunks = [];
|
|
767
|
-
let totalSize = 0;
|
|
768
|
-
for await (const chunk of req) {
|
|
769
|
-
totalSize += chunk.length;
|
|
770
|
-
if (totalSize > deps.limits.image_upload) {
|
|
771
|
-
json(res, 413, { error: "Upload too large" });
|
|
772
|
-
return;
|
|
773
|
-
}
|
|
774
|
-
chunks.push(chunk);
|
|
775
|
-
}
|
|
776
|
-
let body;
|
|
777
|
-
try {
|
|
778
|
-
body = JSON.parse(Buffer.concat(chunks).toString());
|
|
779
|
-
}
|
|
780
|
-
catch {
|
|
781
|
-
json(res, 400, { error: "Invalid JSON" });
|
|
782
|
-
return;
|
|
783
|
-
}
|
|
784
|
-
const { data, mimeType } = body;
|
|
785
|
-
const ext = mimeType.split("/")[1]?.replace("jpeg", "jpg") ?? "png";
|
|
786
|
-
const seq = Date.now();
|
|
787
|
-
const fileName = `${seq}.${ext}`;
|
|
788
|
-
const relPath = `images/${sessionId}/${fileName}`;
|
|
789
|
-
const absPath = join(deps.dataDir, relPath);
|
|
790
|
-
await mkdir(join(deps.dataDir, "images", sessionId), { recursive: true });
|
|
791
|
-
await writeFile(absPath, Buffer.from(data, "base64"));
|
|
792
|
-
const imgUrl = `/api/v1/sessions/${sessionId}/images/${fileName}`;
|
|
793
|
-
json(res, 200, { path: relPath, url: imgUrl });
|
|
794
|
-
return;
|
|
795
|
-
}
|
|
796
|
-
// GET /api/v1/sessions/:id/images/:file
|
|
797
|
-
const imgGetMatch = url.match(/^\/api\/v1\/sessions\/([^/]+)\/images\/([^/?]+)\/?$/);
|
|
798
|
-
if (imgGetMatch && req.method === "GET") {
|
|
799
|
-
const sessionId = decodeURIComponent(imgGetMatch[1]);
|
|
800
|
-
const file = decodeURIComponent(imgGetMatch[2]);
|
|
801
|
-
const filePath = join(deps.dataDir, "images", sessionId, file);
|
|
802
|
-
if (!filePath.startsWith(join(deps.dataDir, "images"))) {
|
|
803
|
-
res.writeHead(403);
|
|
804
|
-
res.end("Forbidden");
|
|
805
|
-
return;
|
|
806
|
-
}
|
|
807
|
-
try {
|
|
808
|
-
const fileData = await readFile(filePath);
|
|
809
|
-
const ext = extname(filePath);
|
|
810
|
-
res.writeHead(200, {
|
|
811
|
-
"Content-Type": MIME[ext] ?? "application/octet-stream",
|
|
812
|
-
"Cache-Control": "public, max-age=31536000, immutable",
|
|
813
|
-
});
|
|
814
|
-
res.end(fileData);
|
|
815
|
-
}
|
|
816
|
-
catch {
|
|
817
|
-
res.writeHead(404);
|
|
818
|
-
res.end("Not found");
|
|
819
|
-
}
|
|
820
|
-
return;
|
|
821
|
-
}
|
|
822
|
-
// --- Push notification routes ---
|
|
823
|
-
// GET /api/v1/push/vapid-key
|
|
824
|
-
if (url === "/api/v1/push/vapid-key" && req.method === "GET") {
|
|
825
|
-
if (!deps.pushService) {
|
|
826
|
-
json(res, 404, { error: "Push not configured" });
|
|
827
|
-
return;
|
|
828
|
-
}
|
|
829
|
-
json(res, 200, { publicKey: deps.pushService.getPublicKey() });
|
|
830
|
-
return;
|
|
831
|
-
}
|
|
832
|
-
// POST /api/v1/push/subscribe
|
|
833
|
-
if (url === "/api/v1/push/subscribe" && req.method === "POST") {
|
|
834
|
-
if (!deps.pushService) {
|
|
835
|
-
json(res, 404, { error: "Push not configured" });
|
|
836
|
-
return;
|
|
837
|
-
}
|
|
838
|
-
const chunks = [];
|
|
839
|
-
for await (const chunk of req)
|
|
840
|
-
chunks.push(chunk);
|
|
841
|
-
let body;
|
|
842
|
-
try {
|
|
843
|
-
body = JSON.parse(Buffer.concat(chunks).toString());
|
|
844
|
-
}
|
|
845
|
-
catch {
|
|
846
|
-
json(res, 400, { error: "Invalid JSON" });
|
|
847
|
-
return;
|
|
848
|
-
}
|
|
849
|
-
if (!body.endpoint || !body.keys?.auth || !body.keys?.p256dh) {
|
|
850
|
-
json(res, 400, { error: "Missing endpoint or keys (auth, p256dh)" });
|
|
851
|
-
return;
|
|
852
|
-
}
|
|
853
|
-
store.saveSubscription(body.endpoint, body.keys.auth, body.keys.p256dh);
|
|
854
|
-
if (body.clientId && deps.pushService) {
|
|
855
|
-
deps.pushService.registerClient(body.clientId, body.endpoint);
|
|
856
|
-
}
|
|
857
|
-
json(res, 201, { ok: true });
|
|
858
|
-
return;
|
|
859
|
-
}
|
|
860
|
-
// POST /api/v1/push/register-client — associate clientId with push endpoint
|
|
861
|
-
if (url === "/api/v1/push/register-client" && req.method === "POST") {
|
|
862
|
-
if (!deps.pushService) {
|
|
863
|
-
json(res, 404, { error: "Push not configured" });
|
|
864
|
-
return;
|
|
865
|
-
}
|
|
866
|
-
const chunks = [];
|
|
867
|
-
for await (const chunk of req)
|
|
868
|
-
chunks.push(chunk);
|
|
869
|
-
let body;
|
|
870
|
-
try {
|
|
871
|
-
body = JSON.parse(Buffer.concat(chunks).toString());
|
|
872
|
-
}
|
|
873
|
-
catch {
|
|
874
|
-
json(res, 400, { error: "Invalid JSON" });
|
|
875
|
-
return;
|
|
876
|
-
}
|
|
877
|
-
if (!body.clientId || !body.endpoint) {
|
|
878
|
-
json(res, 400, { error: "Missing clientId or endpoint" });
|
|
879
|
-
return;
|
|
880
|
-
}
|
|
881
|
-
deps.pushService.registerClient(body.clientId, body.endpoint);
|
|
882
|
-
json(res, 200, { ok: true });
|
|
883
|
-
return;
|
|
884
|
-
}
|
|
885
|
-
// POST /api/v1/push/unsubscribe
|
|
886
|
-
if (url === "/api/v1/push/unsubscribe" && req.method === "POST") {
|
|
887
|
-
if (!deps.pushService) {
|
|
888
|
-
json(res, 404, { error: "Push not configured" });
|
|
889
|
-
return;
|
|
890
|
-
}
|
|
891
|
-
const chunks = [];
|
|
892
|
-
for await (const chunk of req)
|
|
893
|
-
chunks.push(chunk);
|
|
894
|
-
let body;
|
|
895
|
-
try {
|
|
896
|
-
body = JSON.parse(Buffer.concat(chunks).toString());
|
|
897
|
-
}
|
|
898
|
-
catch {
|
|
899
|
-
json(res, 400, { error: "Invalid JSON" });
|
|
900
|
-
return;
|
|
901
|
-
}
|
|
902
|
-
if (body.endpoint) {
|
|
903
|
-
store.removeSubscription(body.endpoint);
|
|
904
|
-
}
|
|
905
|
-
json(res, 200, { ok: true });
|
|
906
|
-
return;
|
|
907
|
-
}
|
|
908
|
-
json(res, 404, { error: "Not found" });
|
|
909
|
-
return;
|
|
910
|
-
}
|
|
911
|
-
// --- Static files ---
|
|
912
|
-
const filePath = join(deps.publicDir, url === "/" ? "/index.html" : url);
|
|
913
|
-
if (!filePath.startsWith(deps.publicDir)) {
|
|
914
|
-
res.writeHead(403);
|
|
915
|
-
res.end("Forbidden");
|
|
916
|
-
return;
|
|
917
|
-
}
|
|
918
|
-
try {
|
|
919
|
-
const data = await readFile(filePath);
|
|
920
|
-
const ext = extname(filePath);
|
|
921
|
-
res.writeHead(200, { "Content-Type": MIME[ext] ?? "application/octet-stream" });
|
|
922
|
-
res.end(data);
|
|
923
|
-
}
|
|
924
|
-
catch {
|
|
925
|
-
res.writeHead(404);
|
|
926
|
-
res.end("Not found");
|
|
927
|
-
}
|
|
928
|
-
};
|
|
929
|
-
}
|