bridge-workspace 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.
package/src/gateway.js ADDED
@@ -0,0 +1,428 @@
1
+ const http = require('node:http');
2
+ const path = require('node:path');
3
+ const readline = require('node:readline/promises');
4
+ const express = require('express');
5
+ const WebSocket = require('ws');
6
+
7
+ const { loadBridgeConfig, parseIncludeInput, parsePort } = require('./config');
8
+ const { startMcpServer } = require('./mcp-server');
9
+
10
+ const DEFAULT_GATEWAY_PORT = 9999;
11
+
12
+ function withProtocol(address, protocol) {
13
+ if (/^https?:\/\//.test(address) || /^wss?:\/\//.test(address)) {
14
+ const url = new URL(address);
15
+ url.protocol = protocol === 'ws' ? (url.protocol === 'https:' ? 'wss:' : 'ws:') : (url.protocol === 'wss:' ? 'https:' : 'http:');
16
+ return url.toString().replace(/\/$/, '');
17
+ }
18
+ return `${protocol === 'ws' ? 'ws' : 'http'}://${address}`;
19
+ }
20
+
21
+ function createPromptSession(options = {}) {
22
+ const input = options.input || process.stdin;
23
+ const output = options.output || process.stdout;
24
+ return readline.createInterface({ input, output });
25
+ }
26
+
27
+ function getBackendNames(config) {
28
+ return Object.keys(config.all_backends || {});
29
+ }
30
+
31
+ function parseBackendSelection(input, config) {
32
+ const backendNames = getBackendNames(config);
33
+ const trimmed = String(input || '').trim();
34
+ if (!trimmed) {
35
+ return [];
36
+ }
37
+
38
+ const selected = [];
39
+ for (const token of trimmed.split(',').map((item) => item.trim()).filter(Boolean)) {
40
+ const numericIndex = Number(token);
41
+ const name = Number.isInteger(numericIndex) && numericIndex >= 1
42
+ ? backendNames[numericIndex - 1]
43
+ : token;
44
+ if (!name || !config.all_backends[name]) {
45
+ throw new Error(`Unknown backend selection: ${token}`);
46
+ }
47
+ if (!selected.includes(name)) {
48
+ selected.push(name);
49
+ }
50
+ }
51
+
52
+ return selected;
53
+ }
54
+
55
+ async function promptBackendSelection(config, options = {}) {
56
+ const output = options.output || process.stdout;
57
+ const rl = options.rl || createPromptSession(options);
58
+ const backendNames = getBackendNames(config);
59
+
60
+ output.write('请选择要连接的后端服务(可多选,逗号分隔;直接回车则不启动):\n\n');
61
+ backendNames.forEach((name, index) => {
62
+ output.write(`[${index + 1}] ${name} ${config.all_backends[name]}\n`);
63
+ });
64
+ output.write('\n输入编号或名称,例如:1,2 或 li-si-order,wang-wu-pay > ');
65
+
66
+ try {
67
+ const answer = await rl.question('');
68
+ return parseBackendSelection(answer, config);
69
+ } finally {
70
+ if (!options.rl && options.close !== false) {
71
+ rl.close();
72
+ }
73
+ }
74
+ }
75
+
76
+ async function promptBackendInclude(backendName, options = {}) {
77
+ const output = options.output || process.stdout;
78
+ const rl = options.rl || createPromptSession(options);
79
+ try {
80
+ output.write(`${backendName} 本次会话 include(可为空,表示使用后端开放范围;逗号分隔)\n`);
81
+ output.write('示例:src/main/java/**/dto/**,src/main/java/**/controller/**,README.md\n');
82
+ return rl.question('include > ');
83
+ } finally {
84
+ if (!options.rl && options.close !== false) {
85
+ rl.close();
86
+ }
87
+ }
88
+ }
89
+
90
+ function selectBackendTargets(config, selectedNames, includeByBackend = {}) {
91
+ const names = Array.isArray(selectedNames) ? selectedNames : getBackendNames(config);
92
+
93
+ return names.map((name) => {
94
+ const address = config.all_backends && config.all_backends[name];
95
+ if (!address) {
96
+ throw new Error(`Backend "${name}" is missing from all_backends`);
97
+ }
98
+ const target = { name, httpUrl: withProtocol(address, 'http'), wsUrl: withProtocol(address, 'ws') };
99
+ if (includeByBackend[name] && includeByBackend[name].length) {
100
+ target.include = includeByBackend[name];
101
+ }
102
+ return target;
103
+ });
104
+ }
105
+
106
+ function createBackendStore() {
107
+ const filesByBackend = new Map();
108
+
109
+ function ensureBackend(name) {
110
+ if (!filesByBackend.has(name)) {
111
+ filesByBackend.set(name, new Map());
112
+ }
113
+ return filesByBackend.get(name);
114
+ }
115
+
116
+ return {
117
+ replaceSnapshot(name, files) {
118
+ const backendFiles = new Map();
119
+ for (const file of files || []) {
120
+ backendFiles.set(file.path, {
121
+ path: file.path,
122
+ size: file.size,
123
+ mtimeMs: file.mtimeMs,
124
+ content: file.content,
125
+ });
126
+ }
127
+ filesByBackend.set(name, backendFiles);
128
+ },
129
+ applyFileUpdate(name, filePath, content) {
130
+ const backendFiles = ensureBackend(name);
131
+ const existing = backendFiles.get(filePath) || { path: filePath };
132
+ backendFiles.set(filePath, { ...existing, path: filePath, content });
133
+ },
134
+ listBackends() {
135
+ return Array.from(filesByBackend.keys()).sort();
136
+ },
137
+ listFiles(name) {
138
+ return Array.from(ensureBackend(name).values())
139
+ .map((file) => ({
140
+ backend: name,
141
+ path: file.path,
142
+ size: file.size,
143
+ mtimeMs: file.mtimeMs,
144
+ hasContent: typeof file.content === 'string',
145
+ }))
146
+ .sort((left, right) => left.path.localeCompare(right.path));
147
+ },
148
+ getFile(name, filePath) {
149
+ const file = ensureBackend(name).get(filePath);
150
+ return file === undefined ? null : {
151
+ backend: name,
152
+ path: filePath,
153
+ size: file.size,
154
+ mtimeMs: file.mtimeMs,
155
+ content: file.content,
156
+ };
157
+ },
158
+ toJSON() {
159
+ return this.listBackends().map((name) => ({ backend: name, files: this.listFiles(name) }));
160
+ },
161
+ toMcpText() {
162
+ return this.listBackends()
163
+ .flatMap((name) => this.listFiles(name).map((file) => `[${name}] ${file.path} (${file.size || 0} bytes)`))
164
+ .join('\n\n---\n\n');
165
+ },
166
+ };
167
+ }
168
+
169
+ function findBackendTarget(targets, backendName) {
170
+ const target = targets.find((item) => item.name === backendName);
171
+ if (!target) {
172
+ throw new Error(`Backend "${backendName}" is not connected`);
173
+ }
174
+ return target;
175
+ }
176
+
177
+ async function proposeBackendChange(target, filePath, newContent, fetchImpl = fetch) {
178
+ const body = { filePath, newContent };
179
+ if (target.include && target.include.length) {
180
+ body.include = target.include;
181
+ }
182
+ const response = await fetchImpl(`${target.httpUrl}/api/propose-change`, {
183
+ method: 'POST',
184
+ headers: { 'content-type': 'application/json' },
185
+ body: JSON.stringify(body),
186
+ });
187
+ const payload = await response.json();
188
+ if (!response.ok) {
189
+ throw new Error(payload.error || `Change proposal failed with status ${response.status}`);
190
+ }
191
+ return payload;
192
+ }
193
+
194
+ async function fetchJson(url) {
195
+ const response = await fetch(url);
196
+ if (!response.ok) {
197
+ throw new Error(`Request failed ${response.status}: ${url}`);
198
+ }
199
+ return response.json();
200
+ }
201
+
202
+ async function fetchBackendFileContent(target, filePath, fetchImpl = fetch) {
203
+ const url = new URL(`${target.httpUrl}/file-content`);
204
+ url.searchParams.set('path', filePath);
205
+ for (const includeRule of target.include || []) {
206
+ url.searchParams.append('include', includeRule);
207
+ }
208
+ const response = await fetchImpl(url.toString());
209
+ const payload = await response.json();
210
+ if (!response.ok) {
211
+ throw new Error(payload.error || `File content request failed with status ${response.status}`);
212
+ }
213
+ return payload.content;
214
+ }
215
+
216
+ function searchBackendStore(store, query, options = {}) {
217
+ const normalizedQuery = String(query || '').toLowerCase();
218
+ if (!normalizedQuery) {
219
+ return [];
220
+ }
221
+ const limit = Number(options.limit || 20);
222
+ const backendFilter = options.backendName;
223
+ const results = [];
224
+
225
+ for (const backend of store.listBackends()) {
226
+ if (backendFilter && backend !== backendFilter) {
227
+ continue;
228
+ }
229
+ for (const file of store.listFiles(backend)) {
230
+ const cached = store.getFile(backend, file.path);
231
+ if (!cached || typeof cached.content !== 'string') {
232
+ continue;
233
+ }
234
+ const lines = cached.content.split(/\r?\n/);
235
+ for (let index = 0; index < lines.length; index += 1) {
236
+ if (lines[index].toLowerCase().includes(normalizedQuery)) {
237
+ results.push({ backend, path: file.path, line: index + 1, snippet: lines[index].trim() });
238
+ if (results.length >= limit) {
239
+ return results;
240
+ }
241
+ }
242
+ }
243
+ }
244
+ }
245
+
246
+ return results;
247
+ }
248
+
249
+ async function syncBackendSnapshot(target, store) {
250
+ const url = new URL(`${target.httpUrl}/file-tree`);
251
+ for (const includeRule of target.include || []) {
252
+ url.searchParams.append('include', includeRule);
253
+ }
254
+ const snapshot = await fetchJson(url.toString());
255
+ store.replaceSnapshot(target.name, snapshot.files || []);
256
+ }
257
+
258
+ function connectBackendSocket(target, store) {
259
+ const ws = new WebSocket(target.wsUrl);
260
+ ws.on('message', (raw) => {
261
+ try {
262
+ const event = JSON.parse(raw.toString());
263
+ if (event.type === 'file:update') {
264
+ store.applyFileUpdate(target.name, event.path, event.content);
265
+ }
266
+ } catch (error) {
267
+ console.error(`Bridge gateway ignored invalid update from ${target.name}: ${error.message}`);
268
+ }
269
+ });
270
+ ws.on('error', (error) => {
271
+ console.error(`Bridge gateway socket error for ${target.name}: ${error.message}`);
272
+ });
273
+ return ws;
274
+ }
275
+
276
+ function canListen(port, host = '127.0.0.1') {
277
+ return new Promise((resolve, reject) => {
278
+ const probe = http.createServer();
279
+ probe.once('error', (error) => {
280
+ if (error.code === 'EADDRINUSE') {
281
+ resolve(false);
282
+ return;
283
+ }
284
+ reject(error);
285
+ });
286
+ probe.once('listening', () => {
287
+ probe.close(() => resolve(true));
288
+ });
289
+ probe.listen(port, host);
290
+ });
291
+ }
292
+
293
+ async function findAvailablePort(startPort, host = '127.0.0.1') {
294
+ let port = Number(startPort);
295
+ while (!(await canListen(port, host))) {
296
+ port += 1;
297
+ }
298
+ return port;
299
+ }
300
+
301
+ async function createGateway(options = {}) {
302
+ const rootDir = path.resolve(options.rootDir || process.cwd());
303
+ const requestedPort = Number(options.port || DEFAULT_GATEWAY_PORT);
304
+ const port = options.autoPort === false ? requestedPort : await findAvailablePort(requestedPort);
305
+ const config = options.config || await loadBridgeConfig(rootDir);
306
+ const targets = selectBackendTargets(config, options.selectedBackends, options.includeByBackend || {});
307
+ const store = createBackendStore();
308
+ const app = express();
309
+ const server = http.createServer(app);
310
+ const sockets = [];
311
+
312
+ app.get('/health', (req, res) => {
313
+ res.json({ ok: true, service: 'bridge-gateway', project: config.project });
314
+ });
315
+
316
+ app.get('/backends', (req, res) => {
317
+ res.json(store.toJSON());
318
+ });
319
+
320
+ app.get('/backends/:backend/files/*', (req, res) => {
321
+ const file = store.getFile(req.params.backend, req.params[0]);
322
+ if (!file) {
323
+ res.status(404).json({ error: 'File not found' });
324
+ return;
325
+ }
326
+ res.type('text/plain').send(file.content);
327
+ });
328
+
329
+ const mcp = startMcpServer({
330
+ app,
331
+ store,
332
+ project: config.project,
333
+ modifyBackendFile: (backendName, filePath, newContent) => proposeBackendChange(
334
+ findBackendTarget(targets, backendName),
335
+ filePath,
336
+ newContent,
337
+ ),
338
+ searchBackendCode: (query, options) => searchBackendStore(store, query, options),
339
+ readBackendFile: async (backendName, filePath) => {
340
+ const cached = store.getFile(backendName, filePath);
341
+ if (cached && typeof cached.content === 'string') {
342
+ return cached.content;
343
+ }
344
+ const target = findBackendTarget(targets, backendName);
345
+ const content = await fetchBackendFileContent(target, filePath);
346
+ store.applyFileUpdate(backendName, filePath, content);
347
+ return content;
348
+ },
349
+ });
350
+
351
+ return {
352
+ app,
353
+ server,
354
+ store,
355
+ targets,
356
+ port,
357
+ mcp,
358
+ async start() {
359
+ await Promise.all(targets.map((target) => syncBackendSnapshot(target, store).catch((error) => {
360
+ console.error(`Bridge gateway snapshot failed for ${target.name}: ${error.message}`);
361
+ })));
362
+ for (const target of targets) {
363
+ sockets.push(connectBackendSocket(target, store));
364
+ }
365
+ await new Promise((resolve) => server.listen(port, resolve));
366
+ return this;
367
+ },
368
+ async close() {
369
+ for (const socket of sockets) {
370
+ socket.close();
371
+ }
372
+ await mcp.close();
373
+ await new Promise((resolve) => server.close(resolve));
374
+ },
375
+ };
376
+ }
377
+
378
+ async function runGateway(argv = process.argv.slice(2)) {
379
+ const port = parsePort(argv, 'BRIDGE_GATEWAY_PORT', DEFAULT_GATEWAY_PORT);
380
+ const rootDir = process.cwd();
381
+ const config = await loadBridgeConfig(rootDir);
382
+ const rl = createPromptSession();
383
+ try {
384
+ const selectedBackends = await promptBackendSelection(config, { rl });
385
+ if (!selectedBackends.length) {
386
+ console.log('[Bridge] 未选择任何后端服务,gateway 未启动。');
387
+ return null;
388
+ }
389
+ const includeByBackend = {};
390
+ for (const backendName of selectedBackends) {
391
+ const answer = await promptBackendInclude(backendName, { rl });
392
+ const include = parseIncludeInput(answer);
393
+ if (include.length) {
394
+ includeByBackend[backendName] = include;
395
+ }
396
+ }
397
+ const gateway = await createGateway({ port, rootDir, config, selectedBackends, includeByBackend });
398
+ await gateway.start();
399
+ if (gateway.port !== port) {
400
+ console.log(`[Bridge] 端口 ${port} 已被占用,自动切换到 ${gateway.port}。`);
401
+ }
402
+ console.log(`Bridge frontend gateway listening on http://127.0.0.1:${gateway.port}`);
403
+ console.log(`MCP SSE endpoint: http://127.0.0.1:${gateway.port}/mcp/sse`);
404
+ return gateway;
405
+ } finally {
406
+ rl.close();
407
+ }
408
+ }
409
+
410
+ module.exports = {
411
+ DEFAULT_GATEWAY_PORT,
412
+ createBackendStore,
413
+ createGateway,
414
+ createPromptSession,
415
+ findBackendTarget,
416
+ findAvailablePort,
417
+ fetchBackendFileContent,
418
+ getBackendNames,
419
+ loadBridgeConfig,
420
+ parseBackendSelection,
421
+ parseIncludeInput,
422
+ promptBackendInclude,
423
+ promptBackendSelection,
424
+ proposeBackendChange,
425
+ runGateway,
426
+ searchBackendStore,
427
+ selectBackendTargets,
428
+ };
@@ -0,0 +1,108 @@
1
+ async function loadMcpSdk() {
2
+ const [{ McpServer }, { SSEServerTransport }, z] = await Promise.all([
3
+ import('@modelcontextprotocol/sdk/server/mcp.js'),
4
+ import('@modelcontextprotocol/sdk/server/sse.js'),
5
+ import('zod'),
6
+ ]);
7
+ return { McpServer, SSEServerTransport, z };
8
+ }
9
+
10
+ function startMcpServer({ app, store, project, modifyBackendFile, readBackendFile, searchBackendCode }) {
11
+ const transports = new Map();
12
+ let serverPromise;
13
+
14
+ async function getServer() {
15
+ if (!serverPromise) {
16
+ serverPromise = loadMcpSdk().then(({ McpServer, z }) => {
17
+ const server = new McpServer({ name: `bridge-workspace-${project || 'gateway'}`, version: '0.1.0' });
18
+
19
+ server.tool('list_backend_files', 'List synchronized backend files grouped by backend.', async () => ({
20
+ content: [{ type: 'text', text: JSON.stringify(store.toJSON(), null, 2) }],
21
+ }));
22
+
23
+ server.tool('read_backend_file', 'Read a synchronized backend file by backendName and filePath.', {
24
+ backendName: z.string(),
25
+ filePath: z.string(),
26
+ }, async ({ backendName, filePath }) => {
27
+ const file = store.getFile(backendName, filePath);
28
+ if (!file) {
29
+ return { content: [{ type: 'text', text: `File not found: ${backendName}/${filePath}` }], isError: true };
30
+ }
31
+ const text = typeof file.content === 'string'
32
+ ? file.content
33
+ : await readBackendFile(backendName, filePath);
34
+ return { content: [{ type: 'text', text }] };
35
+ });
36
+
37
+ server.tool('modify_backend_file', 'Propose a backend file change for human approval on the backend host.', {
38
+ backendName: z.string(),
39
+ filePath: z.string(),
40
+ newContent: z.string(),
41
+ }, async ({ backendName, filePath, newContent }) => {
42
+ if (!modifyBackendFile) {
43
+ return { content: [{ type: 'text', text: 'modify_backend_file is not configured.' }], isError: true };
44
+ }
45
+ const result = await modifyBackendFile(backendName, filePath, newContent);
46
+ return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
47
+ });
48
+
49
+ server.tool('search_backend_code', 'Search synchronized backend files by keyword and return lightweight snippets.', {
50
+ query: z.string(),
51
+ backendName: z.string().optional(),
52
+ limit: z.number().optional(),
53
+ }, async ({ query, backendName, limit }) => {
54
+ if (!searchBackendCode) {
55
+ return { content: [{ type: 'text', text: 'search_backend_code is not configured.' }], isError: true };
56
+ }
57
+ const results = await searchBackendCode(query, { backendName, limit });
58
+ return { content: [{ type: 'text', text: JSON.stringify(results, null, 2) }] };
59
+ });
60
+
61
+ server.resource('bridge_backend_context', 'bridge://backend-context', async (uri) => ({
62
+ contents: [{ uri: uri.href, mimeType: 'application/json', text: JSON.stringify(store.toJSON(), null, 2) }],
63
+ }));
64
+
65
+ return server;
66
+ });
67
+ }
68
+ return serverPromise;
69
+ }
70
+
71
+ app.get('/mcp/sse', async (req, res, next) => {
72
+ try {
73
+ const { SSEServerTransport } = await loadMcpSdk();
74
+ const transport = new SSEServerTransport('/mcp/messages', res);
75
+ transports.set(transport.sessionId, transport);
76
+ res.on('close', () => transports.delete(transport.sessionId));
77
+ const server = await getServer();
78
+ await server.connect(transport);
79
+ } catch (error) {
80
+ next(error);
81
+ }
82
+ });
83
+
84
+ app.post('/mcp/messages', async (req, res, next) => {
85
+ try {
86
+ const sessionId = req.query.sessionId;
87
+ const transport = transports.get(sessionId);
88
+ if (!transport) {
89
+ res.status(404).send('Unknown MCP session');
90
+ return;
91
+ }
92
+ await transport.handlePostMessage(req, res);
93
+ } catch (error) {
94
+ next(error);
95
+ }
96
+ });
97
+
98
+ return {
99
+ async close() {
100
+ for (const transport of transports.values()) {
101
+ await transport.close();
102
+ }
103
+ transports.clear();
104
+ },
105
+ };
106
+ }
107
+
108
+ module.exports = { startMcpServer };
package/src/stop.js ADDED
@@ -0,0 +1,77 @@
1
+ const { execFile } = require('node:child_process');
2
+ const { promisify } = require('node:util');
3
+
4
+ const { parsePort } = require('./config');
5
+
6
+ const execFileAsync = promisify(execFile);
7
+ const DEFAULT_STOP_PORT = 3001;
8
+
9
+ function uniqueNumbers(values) {
10
+ return Array.from(new Set(values.map(Number).filter((value) => Number.isInteger(value) && value > 0)));
11
+ }
12
+
13
+ function parseNetstatPids(output, port) {
14
+ const target = `:${port}`;
15
+ return uniqueNumbers(output
16
+ .split(/\r?\n/)
17
+ .filter((line) => line.includes(target) && /LISTENING/i.test(line))
18
+ .map((line) => line.trim().split(/\s+/).pop()));
19
+ }
20
+
21
+ function parseLsofPids(output) {
22
+ return uniqueNumbers(output.split(/\r?\n/).map((line) => line.trim()).filter(Boolean));
23
+ }
24
+
25
+ async function findPidsByPort(port, platform = process.platform) {
26
+ if (platform === 'win32') {
27
+ const { stdout } = await execFileAsync('netstat', ['-ano', '-p', 'tcp']);
28
+ return parseNetstatPids(stdout, port);
29
+ }
30
+
31
+ try {
32
+ const { stdout } = await execFileAsync('lsof', ['-ti', `:${port}`]);
33
+ return parseLsofPids(stdout);
34
+ } catch (error) {
35
+ if (error.code === 1) {
36
+ return [];
37
+ }
38
+ throw error;
39
+ }
40
+ }
41
+
42
+ async function killPid(pid, platform = process.platform) {
43
+ if (platform === 'win32') {
44
+ await execFileAsync('taskkill', ['/PID', String(pid), '/F']);
45
+ return;
46
+ }
47
+ process.kill(pid, 'SIGTERM');
48
+ }
49
+
50
+ async function stopPort(port, options = {}) {
51
+ const platform = options.platform || process.platform;
52
+ const pids = await findPidsByPort(port, platform);
53
+ for (const pid of pids) {
54
+ await killPid(pid, platform);
55
+ }
56
+ return pids;
57
+ }
58
+
59
+ async function runStop(argv = process.argv.slice(2), output = process.stdout) {
60
+ const port = parsePort(argv, 'BRIDGE_PORT', DEFAULT_STOP_PORT);
61
+ const pids = await stopPort(port);
62
+ if (!pids.length) {
63
+ output.write(`[Bridge] 端口 ${port} 未发现占用进程。\n`);
64
+ return [];
65
+ }
66
+ output.write(`[Bridge] 已清理端口 ${port}:${pids.join(', ')}\n`);
67
+ return pids;
68
+ }
69
+
70
+ module.exports = {
71
+ DEFAULT_STOP_PORT,
72
+ findPidsByPort,
73
+ parseLsofPids,
74
+ parseNetstatPids,
75
+ runStop,
76
+ stopPort,
77
+ };