@vitex/nook 0.0.0-stage → 0.1.0

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.
@@ -0,0 +1,433 @@
1
+ /**
2
+ * Nook HTTP 服务:
3
+ * - 构建后的前端静态文件
4
+ * - JSON API(项目、会话、对话)
5
+ * - SSE 事件流(Pi 的思考 / 工具调用 / 文本增量)
6
+ *
7
+ * 只按传入的 host / port 监听,不感知任何特定网络服务。
8
+ */
9
+ import http from 'node:http';
10
+ import fs from 'node:fs';
11
+ import path from 'node:path';
12
+ import { getWebDir, getHost, getPort } from './config.js';
13
+ import { listProjects, getProject, createProject, removeProject } from './projects.js';
14
+ import {
15
+ listSessions,
16
+ newSessionId,
17
+ isValidSessionId,
18
+ findSessionFile,
19
+ readTranscript,
20
+ deleteSessionFile,
21
+ openSessionManager,
22
+ } from './sessions.js';
23
+ import { SessionPool } from './agent/pool.js';
24
+ import { listDirectories, createDirectory } from './fs-browser.js';
25
+ import { serializeEvent } from './agent/serialize.js';
26
+ import { accessUrls } from './net.js';
27
+ import { log, logErr } from './logging.js';
28
+
29
+ const PUBLIC_DIR = getWebDir();
30
+ const EVENT_LIMIT = 5000;
31
+ const MAX_BODY = 1024 * 1024;
32
+ const SSE_BUFFER_LIMIT = 8 * 1024 * 1024;
33
+
34
+ const MIME = {
35
+ '.html': 'text/html; charset=utf-8',
36
+ '.js': 'text/javascript; charset=utf-8',
37
+ '.mjs': 'text/javascript; charset=utf-8',
38
+ '.css': 'text/css; charset=utf-8',
39
+ '.json': 'application/json; charset=utf-8',
40
+ '.webmanifest': 'application/manifest+json; charset=utf-8',
41
+ '.svg': 'image/svg+xml',
42
+ '.png': 'image/png',
43
+ '.jpg': 'image/jpeg',
44
+ '.jpeg': 'image/jpeg',
45
+ '.ico': 'image/x-icon',
46
+ '.woff2': 'font/woff2',
47
+ '.txt': 'text/plain; charset=utf-8',
48
+ };
49
+
50
+ class HttpError extends Error {
51
+ constructor(status, message) {
52
+ super(message);
53
+ this.status = status;
54
+ }
55
+ }
56
+
57
+ function sendJson(res, status, data) {
58
+ const body = JSON.stringify(data);
59
+ res.writeHead(status, {
60
+ 'Content-Type': 'application/json; charset=utf-8',
61
+ 'Content-Length': Buffer.byteLength(body),
62
+ 'Cache-Control': 'no-store',
63
+ 'X-Content-Type-Options': 'nosniff',
64
+ });
65
+ res.end(body);
66
+ }
67
+
68
+ function sendText(res, status, text, type = 'text/plain; charset=utf-8') {
69
+ res.writeHead(status, {
70
+ 'Content-Type': type,
71
+ 'Cache-Control': 'no-store',
72
+ 'X-Content-Type-Options': 'nosniff',
73
+ });
74
+ res.end(text);
75
+ }
76
+
77
+ async function readJson(req) {
78
+ let size = 0;
79
+ const chunks = [];
80
+ for await (const chunk of req) {
81
+ size += chunk.length;
82
+ if (size > MAX_BODY) throw new HttpError(413, '请求体过大');
83
+ chunks.push(chunk);
84
+ }
85
+ if (!chunks.length) return {};
86
+ try {
87
+ return JSON.parse(Buffer.concat(chunks).toString('utf8'));
88
+ } catch {
89
+ throw new HttpError(400, 'JSON 格式不正确');
90
+ }
91
+ }
92
+
93
+ function projectOr404(id) {
94
+ const project = getProject(id);
95
+ if (!project) throw new HttpError(404, '项目不存在');
96
+ return project;
97
+ }
98
+
99
+ function sessionIdOr400(id) {
100
+ if (!isValidSessionId(id)) throw new HttpError(400, '会话 ID 不正确');
101
+ return id;
102
+ }
103
+
104
+ function staticFile(res, pathname, reqMethod) {
105
+ if (!PUBLIC_DIR) {
106
+ if (pathname === '/' || pathname === '/index.html') {
107
+ return sendText(res, 200, 'Nook API is running. Start the frontend with `npm run dev:frontend`, or build it with `npm run build`.', 'text/plain; charset=utf-8');
108
+ }
109
+ return sendText(res, 404, 'Not Found');
110
+ }
111
+ let rel = pathname === '/' ? '/index.html' : pathname;
112
+ try { rel = decodeURIComponent(rel); } catch { }
113
+ const full = path.resolve(PUBLIC_DIR, '.' + rel);
114
+ if (!full.startsWith(PUBLIC_DIR + path.sep) && full !== PUBLIC_DIR) {
115
+ return sendText(res, 403, 'Forbidden');
116
+ }
117
+ let stat = null;
118
+ try { stat = fs.statSync(full); } catch { }
119
+ if (!stat || !stat.isFile()) {
120
+ // 纯前端只有 hash 路由,其余未知路径回 index.html
121
+ if (!path.extname(rel)) {
122
+ const index = path.join(PUBLIC_DIR, 'index.html');
123
+ return serveFile(res, index, reqMethod);
124
+ }
125
+ return sendText(res, 404, 'Not Found');
126
+ }
127
+ return serveFile(res, full, reqMethod);
128
+ }
129
+
130
+ function serveFile(res, file, reqMethod) {
131
+ const ext = path.extname(file).toLowerCase();
132
+ const type = MIME[ext] || 'application/octet-stream';
133
+ const stat = fs.statSync(file);
134
+ res.writeHead(200, {
135
+ 'Content-Type': type,
136
+ 'Content-Length': stat.size,
137
+ 'Cache-Control': 'no-cache',
138
+ 'X-Content-Type-Options': 'nosniff',
139
+ 'Referrer-Policy': 'no-referrer',
140
+ 'Content-Security-Policy': "default-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; script-src 'self'; connect-src 'self'",
141
+ });
142
+ if (reqMethod === 'HEAD') { res.end(); return; }
143
+ fs.createReadStream(file).pipe(res);
144
+ }
145
+
146
+ function sseEnvelope(res, env) {
147
+ if (res.destroyed) return;
148
+ if (res.writableLength > SSE_BUFFER_LIMIT) {
149
+ try { res.end(); } catch { }
150
+ return;
151
+ }
152
+ res.write(`id: ${env.seq}\nevent: pi\ndata: ${JSON.stringify(env)}\n\n`);
153
+ }
154
+
155
+ export function createNookServer({ port = getPort(), host = getHost() } = {}) {
156
+ /** @type {Set<{res: import('node:http').ServerResponse, projectId: string, sessionId: string, timer: NodeJS.Timeout}>} */
157
+ const clients = new Set();
158
+ const eventLog = [];
159
+ let seq = 0;
160
+
161
+ const pool = new SessionPool({
162
+ emit(project, sessionId, event) {
163
+ const clean = serializeEvent(event);
164
+ const envelope = {
165
+ seq: ++seq,
166
+ projectId: project.id,
167
+ sessionId,
168
+ ts: Date.now(),
169
+ event: clean,
170
+ };
171
+ eventLog.push(envelope);
172
+ if (eventLog.length > EVENT_LIMIT) eventLog.splice(0, eventLog.length - EVENT_LIMIT);
173
+ for (const client of clients) {
174
+ if (client.projectId && client.projectId !== envelope.projectId) continue;
175
+ if (client.sessionId && client.sessionId !== envelope.sessionId) continue;
176
+ sseEnvelope(client.res, envelope);
177
+ }
178
+ return envelope.seq;
179
+ },
180
+ });
181
+
182
+ async function handleSse(req, res, url) {
183
+ const projectId = url.searchParams.get('projectId') || '';
184
+ const sessionId = url.searchParams.get('sessionId') || '';
185
+ const sinceParam = req.headers['last-event-id'] || url.searchParams.get('since') || '';
186
+ let since = Number(sinceParam);
187
+ if (!Number.isFinite(since)) since = seq;
188
+
189
+ res.writeHead(200, {
190
+ 'Content-Type': 'text/event-stream; charset=utf-8',
191
+ 'Cache-Control': 'no-cache, no-transform',
192
+ Connection: 'keep-alive',
193
+ 'X-Accel-Buffering': 'no',
194
+ });
195
+ res.write('retry: 3000\n\n');
196
+
197
+ const matches = (env) => (!projectId || env.projectId === projectId) && (!sessionId || env.sessionId === sessionId);
198
+
199
+ if (eventLog.length && since < eventLog[0].seq - 1) {
200
+ res.write(`id: ${seq}\nevent: reset\ndata: ${JSON.stringify({ reason: 'gap' })}\n\n`);
201
+ } else {
202
+ for (const env of eventLog) {
203
+ if (env.seq > since && matches(env)) sseEnvelope(res, env);
204
+ }
205
+ }
206
+
207
+ const timer = setInterval(() => {
208
+ if (!res.destroyed) res.write(': ping\n\n');
209
+ }, 20_000);
210
+ if (timer.unref) timer.unref();
211
+
212
+ const client = { res, projectId, sessionId, timer };
213
+ clients.add(client);
214
+ const close = () => {
215
+ clients.delete(client);
216
+ clearInterval(timer);
217
+ };
218
+ req.on('close', close);
219
+ req.on('error', close);
220
+ res.on('close', close);
221
+ res.on('error', close);
222
+ }
223
+
224
+ async function handleApi(req, res, url) {
225
+ const method = req.method || 'GET';
226
+ const parts = url.pathname.split('/').filter(Boolean); // ['api', ...]
227
+ const [, a, b, c, d, e] = parts;
228
+
229
+ if (a === 'health') {
230
+ return sendJson(res, 200, { ok: true, seq });
231
+ }
232
+
233
+ if (a === 'config') {
234
+ if (method !== 'GET') throw new HttpError(405, 'Method Not Allowed');
235
+ return sendJson(res, 200, {
236
+ urls: accessUrls(port, host),
237
+ });
238
+ }
239
+
240
+ if (a === 'events') {
241
+ if (method !== 'GET') throw new HttpError(405, 'Method Not Allowed');
242
+ return handleSse(req, res, url);
243
+ }
244
+
245
+ if (a === 'fs') {
246
+ if (b === 'list') {
247
+ if (method !== 'GET') throw new HttpError(405, 'Method Not Allowed');
248
+ try {
249
+ return sendJson(res, 200, await listDirectories(url.searchParams.get('path') || ''));
250
+ } catch (e) {
251
+ throw new HttpError(400, e?.message || String(e));
252
+ }
253
+ }
254
+ if (b === 'mkdir') {
255
+ if (method !== 'POST') throw new HttpError(405, 'Method Not Allowed');
256
+ const body = await readJson(req);
257
+ try {
258
+ return sendJson(res, 201, await createDirectory(body.parent, body.name));
259
+ } catch (e) {
260
+ throw new HttpError(400, e?.message || String(e));
261
+ }
262
+ }
263
+ throw new HttpError(404, 'Not Found');
264
+ }
265
+
266
+ if (a !== 'projects') throw new HttpError(404, 'Not Found');
267
+
268
+ // /api/projects
269
+ if (!b) {
270
+ if (method === 'GET') {
271
+ return sendJson(res, 200, { projects: listProjects() });
272
+ }
273
+ if (method === 'POST') {
274
+ const body = await readJson(req);
275
+ const project = createProject({ name: body.name, path: body.path });
276
+ log(`新建项目:${project.name} → ${project.path}`);
277
+ return sendJson(res, 201, { project });
278
+ }
279
+ throw new HttpError(405, 'Method Not Allowed');
280
+ }
281
+
282
+ const project = projectOr404(b);
283
+
284
+ // /api/projects/:id
285
+ if (!c) {
286
+ if (method === 'DELETE') {
287
+ if (pool.runningSessions(project.id).size > 0) throw new HttpError(409, '项目里还有会话在运行');
288
+ const removed = removeProject(project.id);
289
+ pool.disposeProject(project.id);
290
+ return sendJson(res, 200, { removed: Boolean(removed) });
291
+ }
292
+ if (method === 'GET') return sendJson(res, 200, { project });
293
+ throw new HttpError(405, 'Method Not Allowed');
294
+ }
295
+
296
+ if (c !== 'sessions') throw new HttpError(404, 'Not Found');
297
+
298
+ // /api/projects/:id/sessions
299
+ if (!d) {
300
+ if (method === 'GET') {
301
+ const sessions = await listSessions(project);
302
+ const running = pool.runningSessions(project.id);
303
+ for (const s of sessions) s.running = running.has(s.id);
304
+ // 尚未落盘、但正在运行的新会话也放进列表
305
+ const known = new Set(sessions.map((s) => s.id));
306
+ for (const id of running) {
307
+ if (known.has(id)) continue;
308
+ const run = pool.getRun(project, id);
309
+ sessions.unshift({
310
+ id,
311
+ title: (run?.userText || '新会话').slice(0, 80),
312
+ name: '',
313
+ firstMessage: run?.userText || '',
314
+ created: new Date(run?.startedAt || Date.now()).toISOString(),
315
+ modified: new Date(run?.startedAt || Date.now()).toISOString(),
316
+ messageCount: 0,
317
+ path: '',
318
+ running: true,
319
+ });
320
+ }
321
+ return sendJson(res, 200, { sessions });
322
+ }
323
+ if (method === 'POST') {
324
+ const id = newSessionId();
325
+ return sendJson(res, 201, { session: { id, title: '新会话', name: '', messages: [] } });
326
+ }
327
+ throw new HttpError(405, 'Method Not Allowed');
328
+ }
329
+
330
+ const sessionId = sessionIdOr400(d);
331
+
332
+ // /api/projects/:id/sessions/:sid
333
+ if (!e) {
334
+ if (method === 'GET') {
335
+ const file = findSessionFile(project, sessionId);
336
+ const sm = openSessionManager(project, sessionId, file);
337
+ const session = readTranscript(sm);
338
+ return sendJson(res, 200, {
339
+ project: { id: project.id, name: project.name, path: project.path },
340
+ session,
341
+ run: pool.getRun(project, sessionId),
342
+ seq,
343
+ });
344
+ }
345
+ if (method === 'DELETE') {
346
+ if (pool.isRunning(project.id, sessionId)) throw new HttpError(409, '会话正在运行,无法删除');
347
+ const file = findSessionFile(project, sessionId);
348
+ pool.dispose(project.id, sessionId);
349
+ const deleted = file ? deleteSessionFile(file) : true;
350
+ return sendJson(res, 200, { deleted });
351
+ }
352
+ throw new HttpError(405, 'Method Not Allowed');
353
+ }
354
+
355
+ if (e === 'messages') {
356
+ if (method !== 'POST') throw new HttpError(405, 'Method Not Allowed');
357
+ const body = await readJson(req);
358
+ const text = typeof body.text === 'string' ? body.text.trim() : '';
359
+ if (!text) throw new HttpError(400, '消息不能为空');
360
+ if (text.length > 100_000) throw new HttpError(413, '消息过长');
361
+ const mode = body.mode === 'steer' || body.mode === 'followUp' ? body.mode : 'prompt';
362
+ try {
363
+ const result = await pool.send(project, sessionId, text, mode);
364
+ return sendJson(res, 202, result);
365
+ } catch (err) {
366
+ if (err?.code === 'RUNNING') throw new HttpError(409, '这个会话正在运行,请用插话或跟进');
367
+ if (mode !== 'prompt') throw new HttpError(400, err?.message || String(err));
368
+ throw err;
369
+ }
370
+ }
371
+
372
+ if (e === 'queue') {
373
+ if (method !== 'POST') throw new HttpError(405, 'Method Not Allowed');
374
+ const cleared = pool.clearQueue(project, sessionId);
375
+ return sendJson(res, 200, { cleared });
376
+ }
377
+
378
+ if (e === 'abort') {
379
+ if (method !== 'POST') throw new HttpError(405, 'Method Not Allowed');
380
+ const result = await pool.abort(project, sessionId);
381
+ return sendJson(res, 200, result);
382
+ }
383
+
384
+ throw new HttpError(404, 'Not Found');
385
+ }
386
+
387
+ const server = http.createServer(async (req, res) => {
388
+ const url = new URL(req.url || '/', `http://${req.headers.host || 'localhost'}`);
389
+ try {
390
+ if (url.pathname.startsWith('/api/')) {
391
+ await handleApi(req, res, url);
392
+ } else if (req.method === 'GET' || req.method === 'HEAD') {
393
+ staticFile(res, url.pathname, req.method);
394
+ } else {
395
+ sendText(res, 405, 'Method Not Allowed');
396
+ }
397
+ } catch (err) {
398
+ if (res.headersSent) {
399
+ try { res.end(); } catch { }
400
+ return;
401
+ }
402
+ const status = err instanceof HttpError ? err.status : 500;
403
+ if (status >= 500) logErr('请求失败:', err?.stack || err);
404
+ sendJson(res, status, { error: err?.message || String(err) });
405
+ }
406
+ });
407
+
408
+ server.on('close', () => {
409
+ for (const client of clients) {
410
+ clearInterval(client.timer);
411
+ try { client.res.end(); } catch { }
412
+ }
413
+ clients.clear();
414
+ pool.disposeAll();
415
+ });
416
+
417
+ return { server, pool, currentSeq: () => seq };
418
+ }
419
+
420
+ export async function startServer({ port = getPort(), host = getHost() } = {}) {
421
+ const bindHost = host || '127.0.0.1';
422
+ const { server, pool, currentSeq } = createNookServer({ port, host: bindHost });
423
+ await new Promise((resolve, reject) => {
424
+ const onError = (err) => { server.off('listening', onListening); reject(err); };
425
+ const onListening = () => { server.off('error', onError); resolve(); };
426
+ server.once('error', onError);
427
+ server.once('listening', onListening);
428
+ server.listen(port, bindHost);
429
+ });
430
+ const address = server.address();
431
+ const actualPort = typeof address === 'object' && address ? address.port : port;
432
+ return { server, pool, currentSeq, port: actualPort, host: bindHost };
433
+ }