ingresseflow-bridge 1.0.1 → 1.0.2
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/dist/index.js +84 -34
- package/package.json +1 -1
- package/src/index.ts +90 -36
package/dist/index.js
CHANGED
|
@@ -14,10 +14,40 @@ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
|
|
|
14
14
|
import { WebSocketServer, WebSocket } from 'ws';
|
|
15
15
|
import { createServer } from 'http';
|
|
16
16
|
import { z } from 'zod';
|
|
17
|
-
|
|
17
|
+
const HTTP_PORT = 9242;
|
|
18
|
+
function log(msg) {
|
|
19
|
+
process.stderr.write(`[IngresseFlow] ${msg}\n`);
|
|
20
|
+
}
|
|
21
|
+
// ── Bridge state ──────────────────────────────────────────────────────────────
|
|
22
|
+
// Two boot modes:
|
|
23
|
+
// - 'server': this process owns the WebSocket bridge and HTTP command server.
|
|
24
|
+
// - 'client': another bridge already owns :9242 (typically the LaunchAgent).
|
|
25
|
+
// This process is just an MCP front-end and forwards calls via HTTP.
|
|
26
|
+
// The client mode prevents EADDRINUSE crashes when Claude Code spawns a stdio
|
|
27
|
+
// MCP child while the persistent bridge is already running.
|
|
28
|
+
let mode = 'server';
|
|
18
29
|
let pluginSocket = null;
|
|
19
30
|
const pending = new Map();
|
|
31
|
+
async function detectExistingBridge() {
|
|
32
|
+
// If :9242 is occupied, another bridge owns it — go into client mode.
|
|
33
|
+
// tryPort returns false when listen fails (EADDRINUSE), which is exactly
|
|
34
|
+
// the signal we need; works even against older bridges without /health.
|
|
35
|
+
return !(await tryPort(HTTP_PORT));
|
|
36
|
+
}
|
|
37
|
+
async function forwardViaHttp(type, payload) {
|
|
38
|
+
const r = await fetch(`http://localhost:${HTTP_PORT}/command`, {
|
|
39
|
+
method: 'POST',
|
|
40
|
+
headers: { 'Content-Type': 'application/json' },
|
|
41
|
+
body: JSON.stringify({ type, payload }),
|
|
42
|
+
});
|
|
43
|
+
const json = await r.json();
|
|
44
|
+
if (!json.success)
|
|
45
|
+
throw new Error(json.error ?? 'Erro desconhecido');
|
|
46
|
+
return json.data;
|
|
47
|
+
}
|
|
20
48
|
function sendToPlugin(type, payload) {
|
|
49
|
+
if (mode === 'client')
|
|
50
|
+
return forwardViaHttp(type, payload);
|
|
21
51
|
return new Promise((resolve, reject) => {
|
|
22
52
|
if (!pluginSocket || pluginSocket.readyState !== WebSocket.OPEN) {
|
|
23
53
|
return reject(new Error('Plugin não conectado. Abra o IngresseFlow no Figma Desktop (FigJam).'));
|
|
@@ -35,14 +65,14 @@ function sendToPlugin(type, payload) {
|
|
|
35
65
|
pluginSocket.send(JSON.stringify({ id, type, payload }));
|
|
36
66
|
});
|
|
37
67
|
}
|
|
38
|
-
async function tryPort(port) {
|
|
68
|
+
async function tryPort(port, host = '127.0.0.1') {
|
|
39
69
|
return new Promise(resolve => {
|
|
40
70
|
const srv = createServer();
|
|
41
71
|
srv.once('error', () => resolve(false));
|
|
42
|
-
srv.listen(port, () => srv.close(() => resolve(true)));
|
|
72
|
+
srv.listen(port, host, () => srv.close(() => resolve(true)));
|
|
43
73
|
});
|
|
44
74
|
}
|
|
45
|
-
async function
|
|
75
|
+
async function startWebSocketBridge() {
|
|
46
76
|
const BASE_PORT = 9243;
|
|
47
77
|
const MAX_PORT = 9250;
|
|
48
78
|
let port = BASE_PORT;
|
|
@@ -54,6 +84,9 @@ async function startBridge() {
|
|
|
54
84
|
if (port > MAX_PORT)
|
|
55
85
|
throw new Error('Nenhuma porta livre em 9243–9250');
|
|
56
86
|
const wss = new WebSocketServer({ port, host: '127.0.0.1' });
|
|
87
|
+
wss.on('error', (err) => {
|
|
88
|
+
log(`Erro no WebSocketServer: ${err.message}`);
|
|
89
|
+
});
|
|
57
90
|
wss.on('connection', (ws) => {
|
|
58
91
|
pluginSocket = ws;
|
|
59
92
|
log(`Plugin conectado`);
|
|
@@ -82,6 +115,44 @@ async function startBridge() {
|
|
|
82
115
|
log(`Abra o plugin IngresseFlow no Figma Desktop para conectar`);
|
|
83
116
|
return port;
|
|
84
117
|
}
|
|
118
|
+
// ── HTTP command server ──────────────────────────────────────────────────────
|
|
119
|
+
// Exposes:
|
|
120
|
+
// GET /health — used by other bridge instances to detect us and switch
|
|
121
|
+
// to client mode instead of crashing on EADDRINUSE.
|
|
122
|
+
// POST /command — { type, payload } forwarded to the plugin via WebSocket.
|
|
123
|
+
function startCommandServer() {
|
|
124
|
+
const http = createServer(async (req, res) => {
|
|
125
|
+
if (req.method === 'GET' && req.url === '/health') {
|
|
126
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
127
|
+
res.end(JSON.stringify({ status: 'ok', plugin: !!pluginSocket }));
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
if (req.method !== 'POST' || req.url !== '/command') {
|
|
131
|
+
res.writeHead(404).end();
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
let body = '';
|
|
135
|
+
req.on('data', (c) => { body += c; });
|
|
136
|
+
req.on('end', async () => {
|
|
137
|
+
try {
|
|
138
|
+
const { type, payload } = JSON.parse(body);
|
|
139
|
+
const result = await sendToPlugin(type, payload);
|
|
140
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
141
|
+
res.end(JSON.stringify({ success: true, data: result }));
|
|
142
|
+
}
|
|
143
|
+
catch (err) {
|
|
144
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
145
|
+
res.end(JSON.stringify({ success: false, error: err.message }));
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
http.on('error', (err) => {
|
|
150
|
+
log(`Erro no HTTP server: ${err.message}`);
|
|
151
|
+
});
|
|
152
|
+
http.listen(HTTP_PORT, '127.0.0.1', () => {
|
|
153
|
+
log(`Comando HTTP em http://localhost:${HTTP_PORT}/command`);
|
|
154
|
+
});
|
|
155
|
+
}
|
|
85
156
|
// ── MCP tools ─────────────────────────────────────────────────────────────────
|
|
86
157
|
const ColorEnum = z.enum([
|
|
87
158
|
'YELLOW', 'BLUE', 'GREEN', 'PINK', 'ORANGE',
|
|
@@ -126,37 +197,16 @@ server.tool('figjam_create_section', 'Cria uma seção nomeada no FigJam para ag
|
|
|
126
197
|
const result = await sendToPlugin('CREATE_SECTION', { name, x, y, width, height });
|
|
127
198
|
return { content: [{ type: 'text', text: JSON.stringify(result) }] };
|
|
128
199
|
});
|
|
129
|
-
// ── HTTP command server (port 9242) ───────────────────────────────────────────
|
|
130
|
-
// Accepts POST /command { type, payload } — lets any local client send operations
|
|
131
|
-
// without needing an MCP session. Used by scripts and tests.
|
|
132
|
-
function startCommandServer() {
|
|
133
|
-
const http = createServer(async (req, res) => {
|
|
134
|
-
if (req.method !== 'POST' || req.url !== '/command') {
|
|
135
|
-
res.writeHead(404).end();
|
|
136
|
-
return;
|
|
137
|
-
}
|
|
138
|
-
let body = '';
|
|
139
|
-
req.on('data', (c) => { body += c; });
|
|
140
|
-
req.on('end', async () => {
|
|
141
|
-
try {
|
|
142
|
-
const { type, payload } = JSON.parse(body);
|
|
143
|
-
const result = await sendToPlugin(type, payload);
|
|
144
|
-
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
145
|
-
res.end(JSON.stringify({ success: true, data: result }));
|
|
146
|
-
}
|
|
147
|
-
catch (err) {
|
|
148
|
-
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
149
|
-
res.end(JSON.stringify({ success: false, error: err.message }));
|
|
150
|
-
}
|
|
151
|
-
});
|
|
152
|
-
});
|
|
153
|
-
http.listen(9242, '127.0.0.1', () => log('Comando HTTP em http://localhost:9242/command'));
|
|
154
|
-
}
|
|
155
200
|
// ── Boot ──────────────────────────────────────────────────────────────────────
|
|
156
|
-
|
|
157
|
-
|
|
201
|
+
const existing = await detectExistingBridge();
|
|
202
|
+
if (existing) {
|
|
203
|
+
mode = 'client';
|
|
204
|
+
log(`Bridge persistente detectada em :${HTTP_PORT} — operando em modo cliente.`);
|
|
205
|
+
}
|
|
206
|
+
else {
|
|
207
|
+
mode = 'server';
|
|
208
|
+
await startWebSocketBridge();
|
|
209
|
+
startCommandServer();
|
|
158
210
|
}
|
|
159
|
-
await startBridge();
|
|
160
|
-
startCommandServer();
|
|
161
211
|
const transport = new StdioServerTransport();
|
|
162
212
|
await server.connect(transport);
|
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -11,8 +11,21 @@ import { WebSocketServer, WebSocket } from 'ws';
|
|
|
11
11
|
import { createServer } from 'http';
|
|
12
12
|
import { z } from 'zod';
|
|
13
13
|
|
|
14
|
-
|
|
14
|
+
const HTTP_PORT = 9242;
|
|
15
15
|
|
|
16
|
+
function log(msg: string) {
|
|
17
|
+
process.stderr.write(`[IngresseFlow] ${msg}\n`);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// ── Bridge state ──────────────────────────────────────────────────────────────
|
|
21
|
+
// Two boot modes:
|
|
22
|
+
// - 'server': this process owns the WebSocket bridge and HTTP command server.
|
|
23
|
+
// - 'client': another bridge already owns :9242 (typically the LaunchAgent).
|
|
24
|
+
// This process is just an MCP front-end and forwards calls via HTTP.
|
|
25
|
+
// The client mode prevents EADDRINUSE crashes when Claude Code spawns a stdio
|
|
26
|
+
// MCP child while the persistent bridge is already running.
|
|
27
|
+
|
|
28
|
+
let mode: 'server' | 'client' = 'server';
|
|
16
29
|
let pluginSocket: WebSocket | null = null;
|
|
17
30
|
|
|
18
31
|
const pending = new Map<string, {
|
|
@@ -21,7 +34,27 @@ const pending = new Map<string, {
|
|
|
21
34
|
timer: ReturnType<typeof setTimeout>;
|
|
22
35
|
}>();
|
|
23
36
|
|
|
37
|
+
async function detectExistingBridge(): Promise<boolean> {
|
|
38
|
+
// If :9242 is occupied, another bridge owns it — go into client mode.
|
|
39
|
+
// tryPort returns false when listen fails (EADDRINUSE), which is exactly
|
|
40
|
+
// the signal we need; works even against older bridges without /health.
|
|
41
|
+
return !(await tryPort(HTTP_PORT));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function forwardViaHttp<T>(type: string, payload: unknown): Promise<T> {
|
|
45
|
+
const r = await fetch(`http://localhost:${HTTP_PORT}/command`, {
|
|
46
|
+
method: 'POST',
|
|
47
|
+
headers: { 'Content-Type': 'application/json' },
|
|
48
|
+
body: JSON.stringify({ type, payload }),
|
|
49
|
+
});
|
|
50
|
+
const json = await r.json() as { success: boolean; data?: T; error?: string };
|
|
51
|
+
if (!json.success) throw new Error(json.error ?? 'Erro desconhecido');
|
|
52
|
+
return json.data as T;
|
|
53
|
+
}
|
|
54
|
+
|
|
24
55
|
function sendToPlugin<T>(type: string, payload: unknown): Promise<T> {
|
|
56
|
+
if (mode === 'client') return forwardViaHttp<T>(type, payload);
|
|
57
|
+
|
|
25
58
|
return new Promise((resolve, reject) => {
|
|
26
59
|
if (!pluginSocket || pluginSocket.readyState !== WebSocket.OPEN) {
|
|
27
60
|
return reject(new Error(
|
|
@@ -44,15 +77,15 @@ function sendToPlugin<T>(type: string, payload: unknown): Promise<T> {
|
|
|
44
77
|
});
|
|
45
78
|
}
|
|
46
79
|
|
|
47
|
-
async function tryPort(port: number): Promise<boolean> {
|
|
80
|
+
async function tryPort(port: number, host = '127.0.0.1'): Promise<boolean> {
|
|
48
81
|
return new Promise(resolve => {
|
|
49
82
|
const srv = createServer();
|
|
50
83
|
srv.once('error', () => resolve(false));
|
|
51
|
-
srv.listen(port, () => srv.close(() => resolve(true)));
|
|
84
|
+
srv.listen(port, host, () => srv.close(() => resolve(true)));
|
|
52
85
|
});
|
|
53
86
|
}
|
|
54
87
|
|
|
55
|
-
async function
|
|
88
|
+
async function startWebSocketBridge(): Promise<number> {
|
|
56
89
|
const BASE_PORT = 9243;
|
|
57
90
|
const MAX_PORT = 9250;
|
|
58
91
|
let port = BASE_PORT;
|
|
@@ -65,6 +98,10 @@ async function startBridge(): Promise<number> {
|
|
|
65
98
|
|
|
66
99
|
const wss = new WebSocketServer({ port, host: '127.0.0.1' });
|
|
67
100
|
|
|
101
|
+
wss.on('error', (err) => {
|
|
102
|
+
log(`Erro no WebSocketServer: ${(err as Error).message}`);
|
|
103
|
+
});
|
|
104
|
+
|
|
68
105
|
wss.on('connection', (ws) => {
|
|
69
106
|
pluginSocket = ws;
|
|
70
107
|
log(`Plugin conectado`);
|
|
@@ -95,6 +132,47 @@ async function startBridge(): Promise<number> {
|
|
|
95
132
|
return port;
|
|
96
133
|
}
|
|
97
134
|
|
|
135
|
+
// ── HTTP command server ──────────────────────────────────────────────────────
|
|
136
|
+
// Exposes:
|
|
137
|
+
// GET /health — used by other bridge instances to detect us and switch
|
|
138
|
+
// to client mode instead of crashing on EADDRINUSE.
|
|
139
|
+
// POST /command — { type, payload } forwarded to the plugin via WebSocket.
|
|
140
|
+
|
|
141
|
+
function startCommandServer() {
|
|
142
|
+
const http = createServer(async (req, res) => {
|
|
143
|
+
if (req.method === 'GET' && req.url === '/health') {
|
|
144
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
145
|
+
res.end(JSON.stringify({ status: 'ok', plugin: !!pluginSocket }));
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
if (req.method !== 'POST' || req.url !== '/command') {
|
|
149
|
+
res.writeHead(404).end();
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
let body = '';
|
|
153
|
+
req.on('data', (c: Buffer) => { body += c; });
|
|
154
|
+
req.on('end', async () => {
|
|
155
|
+
try {
|
|
156
|
+
const { type, payload } = JSON.parse(body) as { type: string; payload: unknown };
|
|
157
|
+
const result = await sendToPlugin(type, payload);
|
|
158
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
159
|
+
res.end(JSON.stringify({ success: true, data: result }));
|
|
160
|
+
} catch (err) {
|
|
161
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
162
|
+
res.end(JSON.stringify({ success: false, error: (err as Error).message }));
|
|
163
|
+
}
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
http.on('error', (err) => {
|
|
168
|
+
log(`Erro no HTTP server: ${(err as Error).message}`);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
http.listen(HTTP_PORT, '127.0.0.1', () => {
|
|
172
|
+
log(`Comando HTTP em http://localhost:${HTTP_PORT}/command`);
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
|
|
98
176
|
// ── MCP tools ─────────────────────────────────────────────────────────────────
|
|
99
177
|
|
|
100
178
|
const ColorEnum = z.enum([
|
|
@@ -172,41 +250,17 @@ server.tool(
|
|
|
172
250
|
}
|
|
173
251
|
);
|
|
174
252
|
|
|
175
|
-
// ── HTTP command server (port 9242) ───────────────────────────────────────────
|
|
176
|
-
// Accepts POST /command { type, payload } — lets any local client send operations
|
|
177
|
-
// without needing an MCP session. Used by scripts and tests.
|
|
178
|
-
|
|
179
|
-
function startCommandServer() {
|
|
180
|
-
const http = createServer(async (req, res) => {
|
|
181
|
-
if (req.method !== 'POST' || req.url !== '/command') {
|
|
182
|
-
res.writeHead(404).end();
|
|
183
|
-
return;
|
|
184
|
-
}
|
|
185
|
-
let body = '';
|
|
186
|
-
req.on('data', (c: Buffer) => { body += c; });
|
|
187
|
-
req.on('end', async () => {
|
|
188
|
-
try {
|
|
189
|
-
const { type, payload } = JSON.parse(body) as { type: string; payload: unknown };
|
|
190
|
-
const result = await sendToPlugin(type, payload);
|
|
191
|
-
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
192
|
-
res.end(JSON.stringify({ success: true, data: result }));
|
|
193
|
-
} catch (err) {
|
|
194
|
-
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
195
|
-
res.end(JSON.stringify({ success: false, error: (err as Error).message }));
|
|
196
|
-
}
|
|
197
|
-
});
|
|
198
|
-
});
|
|
199
|
-
http.listen(9242, '127.0.0.1', () => log('Comando HTTP em http://localhost:9242/command'));
|
|
200
|
-
}
|
|
201
|
-
|
|
202
253
|
// ── Boot ──────────────────────────────────────────────────────────────────────
|
|
203
254
|
|
|
204
|
-
|
|
205
|
-
|
|
255
|
+
const existing = await detectExistingBridge();
|
|
256
|
+
if (existing) {
|
|
257
|
+
mode = 'client';
|
|
258
|
+
log(`Bridge persistente detectada em :${HTTP_PORT} — operando em modo cliente.`);
|
|
259
|
+
} else {
|
|
260
|
+
mode = 'server';
|
|
261
|
+
await startWebSocketBridge();
|
|
262
|
+
startCommandServer();
|
|
206
263
|
}
|
|
207
264
|
|
|
208
|
-
await startBridge();
|
|
209
|
-
startCommandServer();
|
|
210
|
-
|
|
211
265
|
const transport = new StdioServerTransport();
|
|
212
266
|
await server.connect(transport);
|