@atlisp/mcp 1.1.3 → 1.1.5
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 +1 -1
- package/src/atlisp-mcp.js +144 -170
- package/src/cad-worker.js +3 -2
- package/src/cad.js +73 -42
- package/src/session-manager.js +89 -0
- package/src/session-transport.js +89 -0
- package/src/subscription-manager.js +27 -12
package/package.json
CHANGED
package/src/atlisp-mcp.js
CHANGED
|
@@ -55,11 +55,11 @@ import rawBody from 'raw-body';
|
|
|
55
55
|
import iconv from 'iconv-lite';
|
|
56
56
|
import rateLimit from 'express-rate-limit';
|
|
57
57
|
import { randomUUID } from 'crypto';
|
|
58
|
+
import { AsyncLocalStorage } from 'async_hooks';
|
|
58
59
|
const { decode: charsetDecode, encodingExists } = iconv;
|
|
59
60
|
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
60
61
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
61
|
-
import {
|
|
62
|
-
import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
|
|
62
|
+
import { SessionTransport } from './session-transport.js';
|
|
63
63
|
import {
|
|
64
64
|
ListToolsRequestSchema,
|
|
65
65
|
CallToolRequestSchema,
|
|
@@ -79,9 +79,14 @@ import * as functionHandlers from './handlers/function-handlers.js';
|
|
|
79
79
|
import { listResources, readResource } from './handlers/resource-handlers.js';
|
|
80
80
|
import { listPrompts, getPrompt } from './handlers/prompt-handlers.js';
|
|
81
81
|
import * as subMgr from './subscription-manager.js';
|
|
82
|
-
import {
|
|
82
|
+
import { sessionManager } from './subscription-manager.js';
|
|
83
|
+
import { SseSessionManager } from './sse-session-manager.js';
|
|
84
|
+
import { SseSession } from './sse-session.js';
|
|
83
85
|
import config from './config.js';
|
|
84
86
|
|
|
87
|
+
const mcpContext = new AsyncLocalStorage();
|
|
88
|
+
const sseSessionManager = new SseSessionManager();
|
|
89
|
+
|
|
85
90
|
const SERVER_CAPABILITIES = {
|
|
86
91
|
tools: {},
|
|
87
92
|
resources: { subscribe: true, listChanged: true },
|
|
@@ -137,6 +142,37 @@ const TOOL_HANDLERS = {
|
|
|
137
142
|
}),
|
|
138
143
|
};
|
|
139
144
|
|
|
145
|
+
function getOrCreateSessionServer(sessionId) {
|
|
146
|
+
let entry = sessionServers.get(sessionId);
|
|
147
|
+
if (!entry) {
|
|
148
|
+
const server = createMcpServer();
|
|
149
|
+
const transport = new SessionTransport(sessionId);
|
|
150
|
+
server.connect(transport);
|
|
151
|
+
entry = { server, transport };
|
|
152
|
+
sessionServers.set(sessionId, entry);
|
|
153
|
+
sessionTranports.set(sessionId, transport);
|
|
154
|
+
}
|
|
155
|
+
return entry;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function removeSessionServer(sessionId) {
|
|
159
|
+
const entry = sessionServers.get(sessionId);
|
|
160
|
+
if (entry) {
|
|
161
|
+
entry.transport.close().catch(() => {});
|
|
162
|
+
entry.server.close().catch(() => {});
|
|
163
|
+
sessionServers.delete(sessionId);
|
|
164
|
+
sessionTranports.delete(sessionId);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function broadcastToAllSessions(uri) {
|
|
169
|
+
for (const [sid, entry] of sessionServers) {
|
|
170
|
+
entry.server.sendResourceUpdated({ uri }).catch(err => {
|
|
171
|
+
log(`Failed to send resource update to session ${sid}: ${err.message}`);
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
140
176
|
export const tools = [
|
|
141
177
|
{
|
|
142
178
|
name: 'connect_cad',
|
|
@@ -311,10 +347,11 @@ async function initCadConnection() {
|
|
|
311
347
|
}
|
|
312
348
|
}
|
|
313
349
|
|
|
314
|
-
|
|
350
|
+
const sessionServers = new Map();
|
|
351
|
+
const sessionTranports = new Map();
|
|
315
352
|
|
|
316
353
|
export function createMcpServer() {
|
|
317
|
-
server = new Server(
|
|
354
|
+
const server = new Server(
|
|
318
355
|
{ name: SERVER_NAME, version: SERVER_VERSION },
|
|
319
356
|
{ capabilities: SERVER_CAPABILITIES }
|
|
320
357
|
);
|
|
@@ -326,9 +363,14 @@ export function createMcpServer() {
|
|
|
326
363
|
return await handleToolCall(name, args || {});
|
|
327
364
|
});
|
|
328
365
|
|
|
329
|
-
server.setRequestHandler(ListResourcesRequestSchema, async () =>
|
|
330
|
-
|
|
331
|
-
|
|
366
|
+
server.setRequestHandler(ListResourcesRequestSchema, async (request) => {
|
|
367
|
+
const ctx = mcpContext.getStore();
|
|
368
|
+
const sessionId = ctx?.sessionId;
|
|
369
|
+
const subscribedUris = sessionId ? subMgr.list(sessionId) : subMgr.list();
|
|
370
|
+
return {
|
|
371
|
+
resources: await listResources(subscribedUris)
|
|
372
|
+
};
|
|
373
|
+
});
|
|
332
374
|
|
|
333
375
|
server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
|
|
334
376
|
const { uri } = request.params;
|
|
@@ -352,13 +394,15 @@ export function createMcpServer() {
|
|
|
352
394
|
|
|
353
395
|
server.setRequestHandler(SubscribeRequestSchema, async (request) => {
|
|
354
396
|
const { uri } = request.params;
|
|
355
|
-
|
|
397
|
+
const ctx = mcpContext.getStore();
|
|
398
|
+
subMgr.subscribe(uri, ctx?.sessionId);
|
|
356
399
|
return { success: true };
|
|
357
400
|
});
|
|
358
401
|
|
|
359
402
|
server.setRequestHandler(UnsubscribeRequestSchema, async (request) => {
|
|
360
403
|
const { uri } = request.params;
|
|
361
|
-
|
|
404
|
+
const ctx = mcpContext.getStore();
|
|
405
|
+
subMgr.unsubscribe(uri, ctx?.sessionId);
|
|
362
406
|
return { success: true };
|
|
363
407
|
});
|
|
364
408
|
|
|
@@ -374,74 +418,18 @@ export function createMcpServer() {
|
|
|
374
418
|
return server;
|
|
375
419
|
}
|
|
376
420
|
|
|
377
|
-
async function dispatchMcpMethod(responder, method, params, id) {
|
|
378
|
-
try {
|
|
379
|
-
if (method === 'initialize') {
|
|
380
|
-
responder({ jsonrpc: '2.0', id, result: SERVER_INFO });
|
|
381
|
-
return;
|
|
382
|
-
}
|
|
383
|
-
|
|
384
|
-
const methodHandlers = {
|
|
385
|
-
'tools/list': async () => ({ tools }),
|
|
386
|
-
'resources/list': async () => ({ resources: await listResources(subMgr.list()) }),
|
|
387
|
-
'resources/read': async () => {
|
|
388
|
-
const uri = params?.uri;
|
|
389
|
-
if (!uri) throw createError('INVALID_PARAMS', { detail: '缺少 uri 参数' });
|
|
390
|
-
const data = await readResource(uri);
|
|
391
|
-
return { contents: [{ uri, mimeType: 'application/json', text: JSON.stringify(data) }] };
|
|
392
|
-
},
|
|
393
|
-
'resources/subscribe': async () => {
|
|
394
|
-
const uri = params?.uri;
|
|
395
|
-
if (!uri) throw createError('INVALID_PARAMS', { detail: '缺少 uri 参数' });
|
|
396
|
-
subMgr.subscribe(uri);
|
|
397
|
-
return { success: true };
|
|
398
|
-
},
|
|
399
|
-
'resources/unsubscribe': async () => {
|
|
400
|
-
const uri = params?.uri;
|
|
401
|
-
if (!uri) throw createError('INVALID_PARAMS', { detail: '缺少 uri 参数' });
|
|
402
|
-
subMgr.unsubscribe(uri);
|
|
403
|
-
return { success: true };
|
|
404
|
-
},
|
|
405
|
-
'tools/call': async () => {
|
|
406
|
-
const toolName = params?.name;
|
|
407
|
-
if (!toolName) throw createError('INVALID_PARAMS', { detail: 'missing tool name' });
|
|
408
|
-
return await handleToolCall(toolName, params?.arguments || {});
|
|
409
|
-
},
|
|
410
|
-
'prompts/list': async () => ({ prompts: await listPrompts() }),
|
|
411
|
-
'prompts/get': async () => {
|
|
412
|
-
const promptName = params?.name;
|
|
413
|
-
if (!promptName) throw createError('INVALID_PARAMS', { detail: '缺少 prompt name 参数' });
|
|
414
|
-
return await getPrompt(promptName, params?.arguments || {});
|
|
415
|
-
}
|
|
416
|
-
};
|
|
417
|
-
|
|
418
|
-
const handler = methodHandlers[method];
|
|
419
|
-
if (handler) {
|
|
420
|
-
const result = await handler();
|
|
421
|
-
responder({ jsonrpc: '2.0', id, result });
|
|
422
|
-
} else {
|
|
423
|
-
responder({ jsonrpc: '2.0', id, error: { code: -32601, message: 'Method not found' } });
|
|
424
|
-
}
|
|
425
|
-
} catch (e) {
|
|
426
|
-
const mcpError = toMcpError(e);
|
|
427
|
-
log(`MCP error [${method}]: ${mcpError.message}`);
|
|
428
|
-
responder({ jsonrpc: '2.0', id, error: { code: -32603, message: mcpError.message } });
|
|
429
|
-
}
|
|
430
|
-
}
|
|
431
421
|
|
|
432
|
-
async function handleMcpRequest(session, method, params, id) {
|
|
433
|
-
await dispatchMcpMethod(
|
|
434
|
-
(response) => session.sendResponse(response),
|
|
435
|
-
method, params, id
|
|
436
|
-
);
|
|
437
|
-
}
|
|
438
422
|
|
|
439
423
|
export async function startServer() {
|
|
440
424
|
if (config.transport === 'stdio') {
|
|
441
425
|
await initCadConnection();
|
|
442
426
|
const mcpServer = createMcpServer();
|
|
443
427
|
const transport = new StdioServerTransport();
|
|
444
|
-
|
|
428
|
+
const sessionId = 'stdio-session';
|
|
429
|
+
sessionManager.createSession(sessionId);
|
|
430
|
+
await mcpContext.run({ sessionId }, async () => {
|
|
431
|
+
await mcpServer.connect(transport);
|
|
432
|
+
});
|
|
445
433
|
console.error(`${SERVER_NAME} 运行在 stdio 模式`);
|
|
446
434
|
|
|
447
435
|
subMgr.setNotify((uri, data) => {
|
|
@@ -502,129 +490,115 @@ export async function startServer() {
|
|
|
502
490
|
});
|
|
503
491
|
}
|
|
504
492
|
|
|
505
|
-
const mcpServer = createMcpServer();
|
|
506
|
-
|
|
507
|
-
const transport = new StreamableHTTPServerTransport({
|
|
508
|
-
sessionIdGenerator: () => randomUUID(),
|
|
509
|
-
});
|
|
510
|
-
|
|
511
|
-
await mcpServer.connect(transport);
|
|
512
|
-
log('MCP Server connected to StreamableHTTPServerTransport');
|
|
513
|
-
|
|
514
493
|
subMgr.setNotify((uri, data) => {
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
494
|
+
broadcastToAllSessions(uri);
|
|
495
|
+
for (const session of sseSessionManager.listActive()) {
|
|
496
|
+
sseSessionManager.sendToClient(session.clientId, 'resource_updated', { uri });
|
|
497
|
+
}
|
|
518
498
|
});
|
|
519
499
|
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
res.status(500).json({ error: 'Internal server error' });
|
|
527
|
-
}
|
|
500
|
+
async function handleMcpPost(req, res) {
|
|
501
|
+
const sessionId = req.headers['mcp-session-id'] || req.query.sessionId || randomUUID();
|
|
502
|
+
sessionManager.createSession(sessionId);
|
|
503
|
+
const body = req.body;
|
|
504
|
+
if (!body || !body.method) {
|
|
505
|
+
return res.status(400).json({ error: 'Invalid request' });
|
|
528
506
|
}
|
|
529
|
-
});
|
|
530
507
|
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
}
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
508
|
+
const { server, transport } = getOrCreateSessionServer(sessionId);
|
|
509
|
+
transport.resetCleanupTimer();
|
|
510
|
+
|
|
511
|
+
await mcpContext.run({ sessionId }, async () => {
|
|
512
|
+
try {
|
|
513
|
+
const response = await transport.handleRequest(body);
|
|
514
|
+
if (response) {
|
|
515
|
+
const headers = { 'Content-Type': 'application/json' };
|
|
516
|
+
if (transport.sessionId) {
|
|
517
|
+
headers['mcp-session-id'] = transport.sessionId;
|
|
518
|
+
}
|
|
519
|
+
res.writeHead(200, headers);
|
|
520
|
+
res.end(JSON.stringify(response));
|
|
521
|
+
} else {
|
|
522
|
+
if (!res.headersSent) {
|
|
523
|
+
res.status(202).end();
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
} catch (error) {
|
|
527
|
+
log(`Transport error [${sessionId}]: ${error.message}`);
|
|
528
|
+
if (!res.headersSent) {
|
|
529
|
+
res.status(500).json({ error: 'Internal server error', message: error.message });
|
|
530
|
+
}
|
|
538
531
|
}
|
|
539
|
-
}
|
|
540
|
-
}
|
|
532
|
+
});
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
app.all('/mcp', mcpLimiter, handleMcpPost);
|
|
536
|
+
app.all('/mcp/:path', mcpLimiter, handleMcpPost);
|
|
537
|
+
app.get('/mcp/stream', mcpLimiter, handleMcpPost);
|
|
541
538
|
|
|
542
539
|
const sseEndpoint = '/sse';
|
|
543
540
|
app.get(sseEndpoint, async (req, res) => {
|
|
544
|
-
const
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
await sseTransport.handleRequest(req, res);
|
|
548
|
-
} catch (error) {
|
|
549
|
-
log(`SSE transport error: ${error.message}`);
|
|
550
|
-
if (!res.headersSent) {
|
|
551
|
-
res.status(500).json({ error: 'Internal server error' });
|
|
552
|
-
}
|
|
553
|
-
}
|
|
554
|
-
});
|
|
555
|
-
app.post(sseEndpoint, mcpLimiter, async (req, res) => {
|
|
556
|
-
const sessionId = req.headers['mcp-session-id'];
|
|
557
|
-
const sseTransport = sessionId ? sseEndpoint + '?sessionId=' + sessionId : sseEndpoint;
|
|
558
|
-
try {
|
|
559
|
-
const transport = new SSEServerTransport(sseEndpoint, res);
|
|
560
|
-
await mcpServer.connect(transport);
|
|
561
|
-
await transport.handleRequest(req, res);
|
|
562
|
-
} catch (error) {
|
|
563
|
-
log(`SSE POST error: ${error.message}`);
|
|
564
|
-
if (!res.headersSent) {
|
|
565
|
-
res.status(500).json({ error: 'Internal server error' });
|
|
566
|
-
}
|
|
567
|
-
}
|
|
568
|
-
});
|
|
541
|
+
const sessionId = randomUUID();
|
|
542
|
+
sessionManager.createSession(sessionId);
|
|
543
|
+
getOrCreateSessionServer(sessionId);
|
|
569
544
|
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
try {
|
|
573
|
-
await mcpServer.connect(sseTransport);
|
|
574
|
-
await sseTransport.handleRequest(req, res);
|
|
575
|
-
} catch (error) {
|
|
576
|
-
log(`/mcp/sse transport error: ${error.message}`);
|
|
577
|
-
if (!res.headersSent) {
|
|
578
|
-
res.status(500).json({ error: 'Internal server error' });
|
|
579
|
-
}
|
|
580
|
-
}
|
|
581
|
-
});
|
|
582
|
-
app.post('/mcp/sse', mcpLimiter, async (req, res) => {
|
|
583
|
-
const sseTransport = new SSEServerTransport('/mcp/sse', res);
|
|
584
|
-
try {
|
|
585
|
-
await mcpServer.connect(sseTransport);
|
|
586
|
-
await sseTransport.handleRequest(req, res);
|
|
587
|
-
} catch (error) {
|
|
588
|
-
log(`/mcp/sse POST error: ${error.message}`);
|
|
589
|
-
if (!res.headersSent) {
|
|
590
|
-
res.status(500).json({ error: 'Internal server error' });
|
|
591
|
-
}
|
|
592
|
-
}
|
|
593
|
-
});
|
|
545
|
+
const sseSession = new SseSession(res, { clientId: sessionId });
|
|
546
|
+
sseSessionManager.add(sessionId, sseSession);
|
|
594
547
|
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
res.status(500).json({ error: 'Internal server error' });
|
|
602
|
-
}
|
|
603
|
-
}
|
|
548
|
+
sseSession.sendEndpoint(`/message?sessionId=${sessionId}`);
|
|
549
|
+
|
|
550
|
+
req.on('close', () => {
|
|
551
|
+
sseSessionManager.remove(sessionId);
|
|
552
|
+
removeSessionServer(sessionId);
|
|
553
|
+
});
|
|
604
554
|
});
|
|
605
555
|
|
|
606
556
|
app.post('/message', mcpLimiter, async (req, res) => {
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
557
|
+
const sessionId = req.query.sessionId;
|
|
558
|
+
if (!sessionId) {
|
|
559
|
+
return res.status(400).json({ error: 'Missing sessionId' });
|
|
560
|
+
}
|
|
561
|
+
const sseSession = sseSessionManager.get(sessionId);
|
|
562
|
+
if (!sseSession) {
|
|
563
|
+
return res.status(404).json({ error: 'Session not found' });
|
|
564
|
+
}
|
|
565
|
+
sessionManager.createSession(sessionId);
|
|
566
|
+
const { server, transport } = getOrCreateSessionServer(sessionId);
|
|
567
|
+
transport.resetCleanupTimer();
|
|
568
|
+
|
|
569
|
+
const body = req.body;
|
|
570
|
+
if (body && body.method) {
|
|
571
|
+
try {
|
|
572
|
+
const response = await transport.handleRequest(body);
|
|
573
|
+
if (response) {
|
|
574
|
+
sseSession.sendResponse(response);
|
|
575
|
+
}
|
|
576
|
+
if (!res.headersSent) {
|
|
577
|
+
res.status(202).end();
|
|
578
|
+
}
|
|
579
|
+
} catch (error) {
|
|
580
|
+
log(`SSE message error [${sessionId}]: ${error.message}`);
|
|
581
|
+
if (!res.headersSent) {
|
|
582
|
+
res.status(500).json({ error: 'Internal server error' });
|
|
583
|
+
}
|
|
616
584
|
}
|
|
585
|
+
} else {
|
|
586
|
+
res.status(400).json({ error: 'Invalid request' });
|
|
617
587
|
}
|
|
618
588
|
});
|
|
619
589
|
|
|
620
590
|
const httpServer = app.listen(config.port, config.host, async () => {
|
|
621
591
|
await initCadConnection();
|
|
622
|
-
console.error(`@lisp MCP Server 启动 - http://${config.host}:${config.port}
|
|
592
|
+
console.error(`@lisp MCP Server 启动 - http://${config.host}:${config.port} (多会话模式)`);
|
|
623
593
|
});
|
|
624
594
|
|
|
625
595
|
async function shutdown(signal) {
|
|
626
596
|
console.error(`\n收到 ${signal},正在优雅关闭...`);
|
|
627
|
-
|
|
597
|
+
sseSessionManager.closeAll();
|
|
598
|
+
sessionManager.clear();
|
|
599
|
+
for (const [sid] of sessionServers) {
|
|
600
|
+
removeSessionServer(sid);
|
|
601
|
+
}
|
|
628
602
|
await cad.disconnect();
|
|
629
603
|
closeLog();
|
|
630
604
|
httpServer.close(() => process.exit(0));
|
package/src/cad-worker.js
CHANGED
|
@@ -244,10 +244,11 @@ process.stdin.on('data', (data) => {
|
|
|
244
244
|
if (!line.trim()) continue;
|
|
245
245
|
try {
|
|
246
246
|
const msg = JSON.parse(line);
|
|
247
|
+
const requestId = msg.requestId;
|
|
247
248
|
handleMessage(msg).then(result => {
|
|
248
|
-
process.stdout.write(JSON.stringify(result) + '\n');
|
|
249
|
+
process.stdout.write(JSON.stringify({ ...result, requestId }) + '\n');
|
|
249
250
|
}).catch(err => {
|
|
250
|
-
process.stdout.write(JSON.stringify({ error: err.message }) + '\n');
|
|
251
|
+
process.stdout.write(JSON.stringify({ error: err.message, requestId }) + '\n');
|
|
251
252
|
});
|
|
252
253
|
} catch (e) {
|
|
253
254
|
process.stdout.write(JSON.stringify({ error: 'invalid message' }) + '\n');
|
package/src/cad.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { spawn } from 'child_process';
|
|
2
2
|
import path from 'path';
|
|
3
3
|
import { fileURLToPath } from 'url';
|
|
4
|
+
import { randomUUID } from 'crypto';
|
|
4
5
|
import { CAD_PLATFORMS, FILE_EXTENSIONS } from './constants.js';
|
|
5
6
|
import config from './config.js';
|
|
6
7
|
|
|
@@ -15,8 +16,19 @@ let _platform = null;
|
|
|
15
16
|
let _version = null;
|
|
16
17
|
|
|
17
18
|
let heartbeatTimer = null;
|
|
19
|
+
const pendingRequests = new Map();
|
|
20
|
+
let stdoutHandlerSetup = false;
|
|
21
|
+
|
|
22
|
+
const MAX_BUFFER_SIZE = 1024 * 1024;
|
|
18
23
|
|
|
19
24
|
export function resetWorker() {
|
|
25
|
+
for (const [id, { reject, timeout }] of pendingRequests) {
|
|
26
|
+
clearTimeout(timeout);
|
|
27
|
+
reject(new Error('Worker reset'));
|
|
28
|
+
}
|
|
29
|
+
pendingRequests.clear();
|
|
30
|
+
stdoutHandlerSetup = false;
|
|
31
|
+
|
|
20
32
|
if (worker) {
|
|
21
33
|
worker.kill();
|
|
22
34
|
worker = null;
|
|
@@ -27,6 +39,43 @@ export function resetWorker() {
|
|
|
27
39
|
}
|
|
28
40
|
}
|
|
29
41
|
|
|
42
|
+
function setupStdoutHandler(w) {
|
|
43
|
+
let buffer = '';
|
|
44
|
+
stdoutHandlerSetup = true;
|
|
45
|
+
|
|
46
|
+
w.stdout.on('data', (data) => {
|
|
47
|
+
try {
|
|
48
|
+
buffer += data.toString();
|
|
49
|
+
if (buffer.length > MAX_BUFFER_SIZE) {
|
|
50
|
+
buffer = buffer.slice(-MAX_BUFFER_SIZE);
|
|
51
|
+
}
|
|
52
|
+
const lines = buffer.split('\n');
|
|
53
|
+
buffer = lines.pop() || '';
|
|
54
|
+
for (const line of lines) {
|
|
55
|
+
if (!line.trim()) continue;
|
|
56
|
+
try {
|
|
57
|
+
const result = JSON.parse(line);
|
|
58
|
+
const rid = result.requestId;
|
|
59
|
+
if (rid && pendingRequests.has(rid)) {
|
|
60
|
+
const { resolve, reject, timeout } = pendingRequests.get(rid);
|
|
61
|
+
clearTimeout(timeout);
|
|
62
|
+
pendingRequests.delete(rid);
|
|
63
|
+
if (result.error) {
|
|
64
|
+
reject(new Error(result.error));
|
|
65
|
+
} else {
|
|
66
|
+
resolve(result);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
} catch (parseErr) {
|
|
70
|
+
console.error('JSON parse error in worker output:', parseErr.message);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
} catch (e) {
|
|
74
|
+
console.error('Unexpected error in worker stdout handler:', e.message);
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
30
79
|
export function getWorker() {
|
|
31
80
|
if (!worker || !worker.connected) {
|
|
32
81
|
if (worker) worker.kill();
|
|
@@ -34,18 +83,27 @@ export function getWorker() {
|
|
|
34
83
|
stdio: ['pipe', 'pipe', 'pipe']
|
|
35
84
|
});
|
|
36
85
|
worker.connected = true;
|
|
37
|
-
|
|
86
|
+
|
|
38
87
|
worker.stderr.on('data', (d) => console.error('worker stderr:', d.toString()));
|
|
39
88
|
const w = worker;
|
|
40
89
|
w.on('exit', (code) => {
|
|
41
90
|
console.error('worker exited:', code);
|
|
42
91
|
if (w !== worker) return;
|
|
43
92
|
w.connected = false;
|
|
93
|
+
|
|
94
|
+
for (const [id, { reject, timeout }] of pendingRequests) {
|
|
95
|
+
clearTimeout(timeout);
|
|
96
|
+
reject(new Error(`Worker exited with code ${code}`));
|
|
97
|
+
}
|
|
98
|
+
pendingRequests.clear();
|
|
99
|
+
stdoutHandlerSetup = false;
|
|
100
|
+
|
|
44
101
|
if (heartbeatTimer) {
|
|
45
102
|
clearInterval(heartbeatTimer);
|
|
46
103
|
heartbeatTimer = null;
|
|
47
104
|
}
|
|
48
105
|
});
|
|
106
|
+
setupStdoutHandler(w);
|
|
49
107
|
startHeartbeat();
|
|
50
108
|
}
|
|
51
109
|
return worker;
|
|
@@ -68,53 +126,26 @@ function startHeartbeat() {
|
|
|
68
126
|
}
|
|
69
127
|
|
|
70
128
|
export function sendMessage(msg) {
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
let responded = false;
|
|
74
|
-
let buffer = '';
|
|
129
|
+
const w = getWorker();
|
|
130
|
+
const requestId = randomUUID();
|
|
75
131
|
|
|
132
|
+
return new Promise((resolve, reject) => {
|
|
76
133
|
const timeout = setTimeout(() => {
|
|
77
|
-
|
|
78
|
-
|
|
134
|
+
const pending = pendingRequests.get(requestId);
|
|
135
|
+
if (pending) {
|
|
136
|
+
pendingRequests.delete(requestId);
|
|
79
137
|
reject(new Error('Timeout waiting for worker'));
|
|
80
138
|
}
|
|
81
139
|
}, MESSAGE_TIMEOUT);
|
|
82
140
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
const result = JSON.parse(line);
|
|
92
|
-
if (result.error) {
|
|
93
|
-
if (!responded) {
|
|
94
|
-
responded = true;
|
|
95
|
-
clearTimeout(timeout);
|
|
96
|
-
w.stdout.off('data', handler);
|
|
97
|
-
reject(new Error(result.error));
|
|
98
|
-
}
|
|
99
|
-
} else {
|
|
100
|
-
if (!responded) {
|
|
101
|
-
responded = true;
|
|
102
|
-
clearTimeout(timeout);
|
|
103
|
-
w.stdout.off('data', handler);
|
|
104
|
-
resolve(result);
|
|
105
|
-
}
|
|
106
|
-
}
|
|
107
|
-
} catch (parseErr) {
|
|
108
|
-
console.error('JSON parse error in worker output:', parseErr.message, 'line:', line.slice(0, 200));
|
|
109
|
-
}
|
|
110
|
-
}
|
|
111
|
-
} catch (e) {
|
|
112
|
-
console.error('Unexpected error in worker stdout handler:', e.message);
|
|
113
|
-
}
|
|
114
|
-
};
|
|
115
|
-
|
|
116
|
-
w.stdout.on('data', handler);
|
|
117
|
-
w.stdin.write(JSON.stringify(msg) + '\n');
|
|
141
|
+
pendingRequests.set(requestId, { resolve, reject, timeout });
|
|
142
|
+
try {
|
|
143
|
+
w.stdin.write(JSON.stringify({ ...msg, requestId }) + '\n');
|
|
144
|
+
} catch (e) {
|
|
145
|
+
clearTimeout(timeout);
|
|
146
|
+
pendingRequests.delete(requestId);
|
|
147
|
+
reject(new Error(`Failed to write to worker: ${e.message}`));
|
|
148
|
+
}
|
|
118
149
|
});
|
|
119
150
|
}
|
|
120
151
|
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
export class Session {
|
|
2
|
+
constructor(clientId) {
|
|
3
|
+
this.clientId = clientId;
|
|
4
|
+
this.subscriptions = new Set();
|
|
5
|
+
this.createdAt = Date.now();
|
|
6
|
+
this.connected = false;
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export class SessionManager {
|
|
11
|
+
#sessions = new Map();
|
|
12
|
+
|
|
13
|
+
createSession(clientId) {
|
|
14
|
+
let session = this.#sessions.get(clientId);
|
|
15
|
+
if (!session) {
|
|
16
|
+
session = new Session(clientId);
|
|
17
|
+
this.#sessions.set(clientId, session);
|
|
18
|
+
}
|
|
19
|
+
return session;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
getSession(clientId) {
|
|
23
|
+
return this.#sessions.get(clientId) || null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
hasSession(clientId) {
|
|
27
|
+
return this.#sessions.has(clientId);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
removeSession(clientId) {
|
|
31
|
+
return this.#sessions.delete(clientId);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
subscribe(clientId, uri) {
|
|
35
|
+
const session = this.#sessions.get(clientId);
|
|
36
|
+
if (session) {
|
|
37
|
+
session.subscriptions.add(uri);
|
|
38
|
+
return true;
|
|
39
|
+
}
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
unsubscribe(clientId, uri) {
|
|
44
|
+
const session = this.#sessions.get(clientId);
|
|
45
|
+
if (session) {
|
|
46
|
+
return session.subscriptions.delete(uri);
|
|
47
|
+
}
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
getSubscriptions(clientId) {
|
|
52
|
+
const session = this.#sessions.get(clientId);
|
|
53
|
+
return session ? Array.from(session.subscriptions) : [];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
isSubscribed(clientId, uri) {
|
|
57
|
+
const session = this.#sessions.get(clientId);
|
|
58
|
+
return session ? session.subscriptions.has(uri) : false;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
getAllSubscribedUris() {
|
|
62
|
+
const all = new Set();
|
|
63
|
+
for (const session of this.#sessions.values()) {
|
|
64
|
+
for (const uri of session.subscriptions) {
|
|
65
|
+
all.add(uri);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return all;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
list() {
|
|
72
|
+
return Array.from(this.#sessions.entries()).map(([id, s]) => ({
|
|
73
|
+
clientId: id,
|
|
74
|
+
subscriptions: Array.from(s.subscriptions),
|
|
75
|
+
createdAt: s.createdAt,
|
|
76
|
+
connected: s.connected
|
|
77
|
+
}));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
count() {
|
|
81
|
+
return this.#sessions.size;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
clear() {
|
|
85
|
+
this.#sessions.clear();
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export default SessionManager;
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { randomUUID } from 'crypto';
|
|
2
|
+
|
|
3
|
+
const SESSION_TIMEOUT = 30 * 60 * 1000;
|
|
4
|
+
|
|
5
|
+
export class SessionTransport {
|
|
6
|
+
constructor(sessionId) {
|
|
7
|
+
this.sessionId = sessionId || randomUUID();
|
|
8
|
+
this.onclose = null;
|
|
9
|
+
this.onerror = null;
|
|
10
|
+
this.onmessage = null;
|
|
11
|
+
this._pendingRequest = null;
|
|
12
|
+
this._started = false;
|
|
13
|
+
this._closed = false;
|
|
14
|
+
this._cleanupTimer = null;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
async start() {
|
|
18
|
+
if (this._started) {
|
|
19
|
+
throw new Error('Transport already started');
|
|
20
|
+
}
|
|
21
|
+
this._started = true;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async send(message) {
|
|
25
|
+
if (this._closed) return;
|
|
26
|
+
if (this._pendingRequest) {
|
|
27
|
+
const { resolve, reject, timeout } = this._pendingRequest;
|
|
28
|
+
clearTimeout(timeout);
|
|
29
|
+
this._pendingRequest = null;
|
|
30
|
+
resolve(message);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async close() {
|
|
35
|
+
if (this._closed) return;
|
|
36
|
+
this._closed = true;
|
|
37
|
+
if (this._pendingRequest) {
|
|
38
|
+
const { reject, timeout } = this._pendingRequest;
|
|
39
|
+
clearTimeout(timeout);
|
|
40
|
+
this._pendingRequest = null;
|
|
41
|
+
reject(new Error('Transport closed'));
|
|
42
|
+
}
|
|
43
|
+
this._clearCleanupTimer();
|
|
44
|
+
this.onclose?.();
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async handleRequest(message, timeoutMs = 60000) {
|
|
48
|
+
if (this._closed) {
|
|
49
|
+
throw new Error('Transport closed');
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (!message || !message.method) {
|
|
53
|
+
throw new Error('Invalid message');
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (!message.id) {
|
|
57
|
+
this.onmessage?.(message);
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return new Promise((resolve, reject) => {
|
|
62
|
+
const timeout = setTimeout(() => {
|
|
63
|
+
if (this._pendingRequest?.resolve === resolve) {
|
|
64
|
+
this._pendingRequest = null;
|
|
65
|
+
reject(new Error('Request timeout'));
|
|
66
|
+
}
|
|
67
|
+
}, timeoutMs);
|
|
68
|
+
|
|
69
|
+
this._pendingRequest = { resolve, reject, timeout };
|
|
70
|
+
this.onmessage?.(message);
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
resetCleanupTimer() {
|
|
75
|
+
this._clearCleanupTimer();
|
|
76
|
+
this._cleanupTimer = setTimeout(() => {
|
|
77
|
+
this.close();
|
|
78
|
+
}, SESSION_TIMEOUT);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
_clearCleanupTimer() {
|
|
82
|
+
if (this._cleanupTimer) {
|
|
83
|
+
clearTimeout(this._cleanupTimer);
|
|
84
|
+
this._cleanupTimer = null;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export default SessionTransport;
|
|
@@ -1,25 +1,38 @@
|
|
|
1
|
-
|
|
1
|
+
import { SessionManager } from './session-manager.js';
|
|
2
|
+
|
|
3
|
+
const sessionManager = new SessionManager();
|
|
2
4
|
let _notifyFn = null;
|
|
3
5
|
|
|
4
6
|
export function setNotify(fn) {
|
|
5
7
|
_notifyFn = fn;
|
|
6
8
|
}
|
|
7
9
|
|
|
8
|
-
export function subscribe(uri) {
|
|
9
|
-
|
|
10
|
+
export function subscribe(uri, sessionId) {
|
|
11
|
+
if (sessionId) {
|
|
12
|
+
return sessionManager.subscribe(sessionId, uri);
|
|
13
|
+
}
|
|
10
14
|
return true;
|
|
11
15
|
}
|
|
12
16
|
|
|
13
|
-
export function unsubscribe(uri) {
|
|
14
|
-
|
|
17
|
+
export function unsubscribe(uri, sessionId) {
|
|
18
|
+
if (sessionId) {
|
|
19
|
+
return sessionManager.unsubscribe(sessionId, uri);
|
|
20
|
+
}
|
|
21
|
+
return true;
|
|
15
22
|
}
|
|
16
23
|
|
|
17
|
-
export function list() {
|
|
18
|
-
|
|
24
|
+
export function list(sessionId) {
|
|
25
|
+
if (sessionId) {
|
|
26
|
+
return sessionManager.getSubscriptions(sessionId);
|
|
27
|
+
}
|
|
28
|
+
return Array.from(sessionManager.getAllSubscribedUris());
|
|
19
29
|
}
|
|
20
30
|
|
|
21
|
-
export function isSubscribed(uri) {
|
|
22
|
-
|
|
31
|
+
export function isSubscribed(uri, sessionId) {
|
|
32
|
+
if (sessionId) {
|
|
33
|
+
return sessionManager.isSubscribed(sessionId, uri);
|
|
34
|
+
}
|
|
35
|
+
return sessionManager.getAllSubscribedUris().has(uri);
|
|
23
36
|
}
|
|
24
37
|
|
|
25
38
|
export function notify(uri, data) {
|
|
@@ -29,9 +42,11 @@ export function notify(uri, data) {
|
|
|
29
42
|
}
|
|
30
43
|
|
|
31
44
|
export function clear() {
|
|
32
|
-
|
|
45
|
+
sessionManager.clear();
|
|
33
46
|
}
|
|
34
47
|
|
|
35
48
|
export function size() {
|
|
36
|
-
return
|
|
37
|
-
}
|
|
49
|
+
return sessionManager.count();
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export { sessionManager };
|