@winstonfassett/webdev-gateway 0.1.0-alpha.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/CHANGELOG.md +16 -0
- package/LICENSE +21 -0
- package/README.md +78 -0
- package/dist/adapter-helpers.d.ts +64 -0
- package/dist/adapter-helpers.d.ts.map +1 -0
- package/dist/adapter-helpers.js +297 -0
- package/dist/adapter-helpers.js.map +1 -0
- package/dist/admin/assets/index-DEDI8OIx.css +2 -0
- package/dist/admin/assets/index-DaI40ww1.js +70 -0
- package/dist/admin/assets/tinykeys.module-CjuTRcEz.js +1 -0
- package/dist/admin/index.html +13 -0
- package/dist/admin-rpc.d.ts +27 -0
- package/dist/admin-rpc.d.ts.map +1 -0
- package/dist/admin-rpc.js +147 -0
- package/dist/admin-rpc.js.map +1 -0
- package/dist/admin.d.ts +10 -0
- package/dist/admin.d.ts.map +1 -0
- package/dist/admin.js +202 -0
- package/dist/admin.js.map +1 -0
- package/dist/auto-register.d.ts +10 -0
- package/dist/auto-register.d.ts.map +1 -0
- package/dist/auto-register.js +145 -0
- package/dist/auto-register.js.map +1 -0
- package/dist/cdp-relay.d.ts +110 -0
- package/dist/cdp-relay.d.ts.map +1 -0
- package/dist/cdp-relay.js +616 -0
- package/dist/cdp-relay.js.map +1 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +95 -0
- package/dist/cli.js.map +1 -0
- package/dist/doctor.d.ts +6 -0
- package/dist/doctor.d.ts.map +1 -0
- package/dist/doctor.js +149 -0
- package/dist/doctor.js.map +1 -0
- package/dist/element-grab-client.js +305 -0
- package/dist/element-grab.d.ts +15 -0
- package/dist/element-grab.d.ts.map +1 -0
- package/dist/element-grab.js +102 -0
- package/dist/element-grab.js.map +1 -0
- package/dist/gateway.d.ts +5 -0
- package/dist/gateway.d.ts.map +1 -0
- package/dist/gateway.js +534 -0
- package/dist/gateway.js.map +1 -0
- package/dist/installer.d.ts +48 -0
- package/dist/installer.d.ts.map +1 -0
- package/dist/installer.js +637 -0
- package/dist/installer.js.map +1 -0
- package/dist/libs/element-source.js +35 -0
- package/dist/libs/modern-screenshot.js +14 -0
- package/dist/log-reader.d.ts +30 -0
- package/dist/log-reader.d.ts.map +1 -0
- package/dist/log-reader.js +174 -0
- package/dist/log-reader.js.map +1 -0
- package/dist/mcp-server.d.ts +22 -0
- package/dist/mcp-server.d.ts.map +1 -0
- package/dist/mcp-server.js +115 -0
- package/dist/mcp-server.js.map +1 -0
- package/dist/mcp-tools-core.d.ts +30 -0
- package/dist/mcp-tools-core.d.ts.map +1 -0
- package/dist/mcp-tools-core.js +375 -0
- package/dist/mcp-tools-core.js.map +1 -0
- package/dist/mcp-tools-full.d.ts +4 -0
- package/dist/mcp-tools-full.d.ts.map +1 -0
- package/dist/mcp-tools-full.js +141 -0
- package/dist/mcp-tools-full.js.map +1 -0
- package/dist/playwright-commands.d.ts +33 -0
- package/dist/playwright-commands.d.ts.map +1 -0
- package/dist/playwright-commands.js +356 -0
- package/dist/playwright-commands.js.map +1 -0
- package/dist/registry.d.ts +83 -0
- package/dist/registry.d.ts.map +1 -0
- package/dist/registry.js +205 -0
- package/dist/registry.js.map +1 -0
- package/dist/rpc-server.d.ts +54 -0
- package/dist/rpc-server.d.ts.map +1 -0
- package/dist/rpc-server.js +207 -0
- package/dist/rpc-server.js.map +1 -0
- package/dist/session.d.ts +13 -0
- package/dist/session.d.ts.map +1 -0
- package/dist/session.js +61 -0
- package/dist/session.js.map +1 -0
- package/dist/types.d.ts +76 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/dist/webdev-client.js +20 -0
- package/dist/writers/base.d.ts +24 -0
- package/dist/writers/base.d.ts.map +1 -0
- package/dist/writers/base.js +98 -0
- package/dist/writers/base.js.map +1 -0
- package/dist/writers/console.d.ts +8 -0
- package/dist/writers/console.d.ts.map +1 -0
- package/dist/writers/console.js +14 -0
- package/dist/writers/console.js.map +1 -0
- package/dist/writers/dev-events.d.ts +28 -0
- package/dist/writers/dev-events.d.ts.map +1 -0
- package/dist/writers/dev-events.js +53 -0
- package/dist/writers/dev-events.js.map +1 -0
- package/dist/writers/errors.d.ts +8 -0
- package/dist/writers/errors.d.ts.map +1 -0
- package/dist/writers/errors.js +14 -0
- package/dist/writers/errors.js.map +1 -0
- package/dist/writers/network.d.ts +9 -0
- package/dist/writers/network.d.ts.map +1 -0
- package/dist/writers/network.js +17 -0
- package/dist/writers/network.js.map +1 -0
- package/dist/writers/server-console.d.ts +8 -0
- package/dist/writers/server-console.d.ts.map +1 -0
- package/dist/writers/server-console.js +14 -0
- package/dist/writers/server-console.js.map +1 -0
- package/package.json +79 -0
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// element-grab server-side: selection store + MCP tool + HTTP endpoint
|
|
2
|
+
// Push-then-pull model: browser pushes selection via HTTP POST, agent pulls via MCP tool.
|
|
3
|
+
// Selections have a 5-minute TTL.
|
|
4
|
+
import { z } from 'zod';
|
|
5
|
+
const SELECTION_TTL_MS = 5 * 60 * 1000; // 5 minutes
|
|
6
|
+
// Store last N selections (ring buffer, newest first)
|
|
7
|
+
const MAX_SELECTIONS = 10;
|
|
8
|
+
const selections = [];
|
|
9
|
+
function pruneExpired() {
|
|
10
|
+
const now = Date.now();
|
|
11
|
+
while (selections.length > 0 && now - selections[selections.length - 1].timestamp > SELECTION_TTL_MS) {
|
|
12
|
+
selections.pop();
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
export function pushSelection(sel) {
|
|
16
|
+
pruneExpired();
|
|
17
|
+
selections.unshift(sel);
|
|
18
|
+
if (selections.length > MAX_SELECTIONS)
|
|
19
|
+
selections.pop();
|
|
20
|
+
}
|
|
21
|
+
export function getLatestSelection() {
|
|
22
|
+
pruneExpired();
|
|
23
|
+
return selections[0] ?? null;
|
|
24
|
+
}
|
|
25
|
+
export function getAllSelections() {
|
|
26
|
+
pruneExpired();
|
|
27
|
+
return [...selections];
|
|
28
|
+
}
|
|
29
|
+
// --- MCP Tool Registration ---
|
|
30
|
+
export function registerElementGrabTool(mcp) {
|
|
31
|
+
mcp.tool('get_element_context', `Get the latest UI element selected by the user in the browser. The user holds Cmd+C to activate element-grab, hovers over an element, and clicks to select it. Returns a compact component card with: component name, source file location, CSS selector, and a live ref hint (window.__LAST_GRABBED__.element) that can be used with eval_js for live DOM manipulation.`, {
|
|
32
|
+
all: z.boolean().optional().describe('Return all recent selections (max 10) instead of just the latest'),
|
|
33
|
+
}, async (args) => {
|
|
34
|
+
if (args.all) {
|
|
35
|
+
const all = getAllSelections();
|
|
36
|
+
if (all.length === 0) {
|
|
37
|
+
return {
|
|
38
|
+
content: [{ type: 'text', text: 'No element has been selected yet. Ask the user to hold Cmd+C in the browser, hover an element, and click to select it.' }],
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
return {
|
|
42
|
+
content: [{ type: 'text', text: all.map(s => `[${Math.round((Date.now() - s.timestamp) / 1000)}s ago] ${s.url}\n${s.card}`).join('\n\n---\n\n') }],
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
const latest = getLatestSelection();
|
|
46
|
+
if (!latest) {
|
|
47
|
+
return {
|
|
48
|
+
content: [{ type: 'text', text: 'No element has been selected yet. Ask the user to hold Cmd+C in the browser, hover an element, and click to select it.' }],
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
return {
|
|
52
|
+
content: [{ type: 'text', text: latest.card }],
|
|
53
|
+
};
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
// --- HTTP Endpoint ---
|
|
57
|
+
export function handleElementGrabRequest(req, res, url) {
|
|
58
|
+
if (url === '/__element-grab/selection' && req.method === 'POST') {
|
|
59
|
+
let body = '';
|
|
60
|
+
req.on('data', (chunk) => { body += chunk.toString(); });
|
|
61
|
+
req.on('end', () => {
|
|
62
|
+
try {
|
|
63
|
+
const data = JSON.parse(body);
|
|
64
|
+
const payload = data.payload || data;
|
|
65
|
+
pushSelection({
|
|
66
|
+
card: payload.card || '',
|
|
67
|
+
timestamp: payload.timestamp || Date.now(),
|
|
68
|
+
url: payload.url || '',
|
|
69
|
+
browserId: data.browserId,
|
|
70
|
+
});
|
|
71
|
+
res.writeHead(200, {
|
|
72
|
+
'Content-Type': 'application/json',
|
|
73
|
+
'Access-Control-Allow-Origin': '*',
|
|
74
|
+
});
|
|
75
|
+
res.end(JSON.stringify({ ok: true }));
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
res.writeHead(400, {
|
|
79
|
+
'Content-Type': 'application/json',
|
|
80
|
+
'Access-Control-Allow-Origin': '*',
|
|
81
|
+
});
|
|
82
|
+
res.end(JSON.stringify({ error: 'Invalid JSON' }));
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
return true;
|
|
86
|
+
}
|
|
87
|
+
if (url === '/__element-grab/selection' && req.method === 'GET') {
|
|
88
|
+
res.writeHead(200, {
|
|
89
|
+
'Content-Type': 'application/json',
|
|
90
|
+
'Access-Control-Allow-Origin': '*',
|
|
91
|
+
});
|
|
92
|
+
const latest = getLatestSelection();
|
|
93
|
+
res.end(JSON.stringify(latest ? {
|
|
94
|
+
card: latest.card,
|
|
95
|
+
url: latest.url,
|
|
96
|
+
age_sec: Math.round((Date.now() - latest.timestamp) / 1000),
|
|
97
|
+
} : { status: 'no_selection' }, null, 2));
|
|
98
|
+
return true;
|
|
99
|
+
}
|
|
100
|
+
return false;
|
|
101
|
+
}
|
|
102
|
+
//# sourceMappingURL=element-grab.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"element-grab.js","sourceRoot":"","sources":["../src/element-grab.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import http from 'node:http';
|
|
2
|
+
import https from 'node:https';
|
|
3
|
+
import type { GatewayOptions } from './types.js';
|
|
4
|
+
export declare function startGateway(options: GatewayOptions): Promise<http.Server<typeof http.IncomingMessage, typeof http.ServerResponse> | https.Server<typeof http.IncomingMessage, typeof http.ServerResponse>>;
|
|
5
|
+
//# sourceMappingURL=gateway.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"gateway.d.ts","sourceRoot":"","sources":["../src/gateway.ts"],"names":[],"mappings":"AAAA,OAAO,IAAI,MAAM,WAAW,CAAA;AAC5B,OAAO,KAAK,MAAM,YAAY,CAAA;AAO9B,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,YAAY,CAAA;AAwDhD,wBAAsB,YAAY,CAAC,OAAO,EAAE,cAAc,yJAqfzD"}
|
package/dist/gateway.js
ADDED
|
@@ -0,0 +1,534 @@
|
|
|
1
|
+
import http from 'node:http';
|
|
2
|
+
import https from 'node:https';
|
|
3
|
+
import { readFileSync, mkdirSync, existsSync } from 'node:fs';
|
|
4
|
+
import { join, dirname } from 'node:path';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
import { homedir } from 'node:os';
|
|
7
|
+
import { execSync } from 'node:child_process';
|
|
8
|
+
import { WebSocketServer } from 'ws';
|
|
9
|
+
import { initSession } from './session.js';
|
|
10
|
+
import { ConsoleWriter } from './writers/console.js';
|
|
11
|
+
import { ErrorsWriter } from './writers/errors.js';
|
|
12
|
+
import { NetworkWriter } from './writers/network.js';
|
|
13
|
+
import { DevEventsWriter } from './writers/dev-events.js';
|
|
14
|
+
import { ServerConsoleWriter } from './writers/server-console.js';
|
|
15
|
+
import { createMcpMiddleware, sendNotificationToAll } from './mcp-server.js';
|
|
16
|
+
import { setupRpcWebSocket, emitLogEvent } from './rpc-server.js';
|
|
17
|
+
import { handleAdmin } from './admin.js';
|
|
18
|
+
import { ServerRegistry, makeServerId, makeProjectId, initProjectLogDir } from './registry.js';
|
|
19
|
+
import { handleElementGrabRequest } from './element-grab.js';
|
|
20
|
+
import { CDPRelay } from './cdp-relay.js';
|
|
21
|
+
import { initAdminRpc, setupAdminWebSocket } from './admin-rpc.js';
|
|
22
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
23
|
+
function generateSelfSignedCert() {
|
|
24
|
+
const certDir = join(homedir(), '.webdev', 'certs');
|
|
25
|
+
const certPath = join(certDir, 'cert.pem');
|
|
26
|
+
const keyPath = join(certDir, 'key.pem');
|
|
27
|
+
if (existsSync(certPath) && existsSync(keyPath)) {
|
|
28
|
+
return {
|
|
29
|
+
cert: readFileSync(certPath, 'utf-8'),
|
|
30
|
+
key: readFileSync(keyPath, 'utf-8'),
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
mkdirSync(certDir, { recursive: true });
|
|
34
|
+
execSync(`openssl req -x509 -newkey rsa:2048 -keyout "${keyPath}" -out "${certPath}" -days 365 -nodes -subj "/CN=localhost"`, { stdio: 'pipe' });
|
|
35
|
+
return {
|
|
36
|
+
cert: readFileSync(certPath, 'utf-8'),
|
|
37
|
+
key: readFileSync(keyPath, 'utf-8'),
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
function addCorsHeaders(res) {
|
|
41
|
+
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
42
|
+
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
|
|
43
|
+
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
|
|
44
|
+
}
|
|
45
|
+
export async function startGateway(options) {
|
|
46
|
+
const port = options.port ?? 3333;
|
|
47
|
+
const mcpPath = '/__mcp';
|
|
48
|
+
const useHttps = options.https ?? false;
|
|
49
|
+
// Load bundled client script
|
|
50
|
+
let clientScript;
|
|
51
|
+
try {
|
|
52
|
+
clientScript = readFileSync(join(__dirname, 'webdev-client.js'), 'utf-8');
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
console.error('[webdev] Could not load webdev-client.js bundle. Run `npm run build` first.');
|
|
56
|
+
process.exit(1);
|
|
57
|
+
}
|
|
58
|
+
// Optional proxy plugin — if @winstonfassett/webdev-proxy is installed, mount it
|
|
59
|
+
let proxyMiddleware = null;
|
|
60
|
+
try {
|
|
61
|
+
const { createProxyMiddleware } = await import('@winstonfassett/webdev-proxy');
|
|
62
|
+
proxyMiddleware = createProxyMiddleware(clientScript);
|
|
63
|
+
console.log(' [webdev] Proxy plugin loaded');
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
// Not installed — no proxy, that's fine
|
|
67
|
+
}
|
|
68
|
+
// Create server registry for hybrid architecture
|
|
69
|
+
const registry = new ServerRegistry();
|
|
70
|
+
// Heartbeat: clean up dead endpoints (process died without unregistering)
|
|
71
|
+
// Server entries are never removed by heartbeat — only endpoints.
|
|
72
|
+
// Browsers are never evicted — they disconnect naturally when tabs close.
|
|
73
|
+
const heartbeatInterval = setInterval(() => {
|
|
74
|
+
const cleaned = registry.cleanupDeadEndpoints();
|
|
75
|
+
if (cleaned.length > 0) {
|
|
76
|
+
console.log(`[registry] Cleaned up ${cleaned.length} dead endpoint(s)`);
|
|
77
|
+
}
|
|
78
|
+
}, 5000);
|
|
79
|
+
// Initialize session
|
|
80
|
+
const protocol = useHttps ? 'https' : 'http';
|
|
81
|
+
const serverUrl = `${protocol}://localhost:${port}`;
|
|
82
|
+
const session = initSession(options, serverUrl, mcpPath);
|
|
83
|
+
// Initialize writers
|
|
84
|
+
const writers = {
|
|
85
|
+
console: new ConsoleWriter(session.files.console, options.maxFileSizeMb),
|
|
86
|
+
errors: new ErrorsWriter(session.files.errors, options.maxFileSizeMb),
|
|
87
|
+
devEvents: new DevEventsWriter(session.files['dev-events'], options.maxFileSizeMb),
|
|
88
|
+
serverConsole: new ServerConsoleWriter(session.files['server-console'], options.maxFileSizeMb),
|
|
89
|
+
};
|
|
90
|
+
if (options.network && session.files.network) {
|
|
91
|
+
writers.network = new NetworkWriter(session.files.network, options.maxFileSizeMb);
|
|
92
|
+
}
|
|
93
|
+
// Per-project writers (populated when servers register)
|
|
94
|
+
const projectWriters = new Map();
|
|
95
|
+
// MCP context
|
|
96
|
+
const mcpCtx = {
|
|
97
|
+
session,
|
|
98
|
+
connectedClients: 0,
|
|
99
|
+
devEventsWriter: writers.devEvents,
|
|
100
|
+
registry,
|
|
101
|
+
};
|
|
102
|
+
// Initialize admin RPC (capnweb over WS)
|
|
103
|
+
initAdminRpc({ registry, session, startedAt: session.startedAt });
|
|
104
|
+
const mcpMiddleware = createMcpMiddleware(mcpPath, mcpCtx);
|
|
105
|
+
// Request handler
|
|
106
|
+
function handleRequest(req, res) {
|
|
107
|
+
const url = req.url ?? '';
|
|
108
|
+
// CDP control actions (release debugging, status)
|
|
109
|
+
const cdpAction = cdpRelay.handleAction(url);
|
|
110
|
+
if (cdpAction !== null) {
|
|
111
|
+
addCorsHeaders(res);
|
|
112
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
113
|
+
res.end(JSON.stringify(cdpAction));
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
// CDP discovery endpoints (for Playwright connectOverCDP)
|
|
117
|
+
const cdpResponse = cdpRelay.handleHttp(url);
|
|
118
|
+
if (cdpResponse !== null) {
|
|
119
|
+
addCorsHeaders(res);
|
|
120
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
121
|
+
res.end(JSON.stringify(cdpResponse));
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
// CORS preflight
|
|
125
|
+
if (req.method === 'OPTIONS') {
|
|
126
|
+
addCorsHeaders(res);
|
|
127
|
+
res.writeHead(204);
|
|
128
|
+
res.end();
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
// Serve client script
|
|
132
|
+
if (url === '/__webdev.js' || url === '/__client.js') {
|
|
133
|
+
addCorsHeaders(res);
|
|
134
|
+
res.writeHead(200, {
|
|
135
|
+
'Content-Type': 'application/javascript',
|
|
136
|
+
'Cache-Control': 'no-cache',
|
|
137
|
+
});
|
|
138
|
+
res.end(clientScript);
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
// Serve lazy-loaded libs (screenshot library, etc.)
|
|
142
|
+
if (url.startsWith('/__libs/')) {
|
|
143
|
+
const libName = url.slice('/__libs/'.length);
|
|
144
|
+
try {
|
|
145
|
+
const libPath = join(__dirname, 'libs', libName);
|
|
146
|
+
const content = readFileSync(libPath, 'utf-8');
|
|
147
|
+
addCorsHeaders(res);
|
|
148
|
+
res.writeHead(200, {
|
|
149
|
+
'Content-Type': 'application/javascript',
|
|
150
|
+
'Cache-Control': 'public, max-age=31536000, immutable',
|
|
151
|
+
});
|
|
152
|
+
res.end(content);
|
|
153
|
+
}
|
|
154
|
+
catch {
|
|
155
|
+
res.writeHead(404);
|
|
156
|
+
res.end('Not found');
|
|
157
|
+
}
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
// Element-grab: serve script + HTTP selection endpoints
|
|
161
|
+
if (url === '/__element-grab.js') {
|
|
162
|
+
addCorsHeaders(res);
|
|
163
|
+
try {
|
|
164
|
+
const script = readFileSync(join(__dirname, 'element-grab-client.js'), 'utf-8');
|
|
165
|
+
res.writeHead(200, { 'Content-Type': 'application/javascript', 'Cache-Control': 'no-cache' });
|
|
166
|
+
res.end(script);
|
|
167
|
+
}
|
|
168
|
+
catch {
|
|
169
|
+
res.writeHead(404);
|
|
170
|
+
res.end('element-grab not built');
|
|
171
|
+
}
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
if (url.startsWith('/__element-grab/')) {
|
|
175
|
+
addCorsHeaders(res);
|
|
176
|
+
if (handleElementGrabRequest(req, res, url))
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
// Gateway registration endpoints
|
|
180
|
+
if (url === '/__gateway/register' && req.method === 'POST') {
|
|
181
|
+
addCorsHeaders(res);
|
|
182
|
+
let body = '';
|
|
183
|
+
req.on('data', chunk => { body += chunk.toString(); });
|
|
184
|
+
req.on('end', () => {
|
|
185
|
+
try {
|
|
186
|
+
const data = JSON.parse(body);
|
|
187
|
+
if (!data.type || !data.port || !data.pid || !data.directory) {
|
|
188
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
189
|
+
res.end(JSON.stringify({ error: 'Missing required fields: type, port, pid, directory' }));
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
// Create per-project log directory
|
|
193
|
+
const channels = ['console', 'errors', 'dev-events', 'server-console'];
|
|
194
|
+
if (options.network)
|
|
195
|
+
channels.push('network');
|
|
196
|
+
const { logDir, logPaths } = initProjectLogDir(data.directory, channels);
|
|
197
|
+
// Compute stable server identity
|
|
198
|
+
const serverId = data.serverId || makeServerId(data.directory, data.type, data.key);
|
|
199
|
+
const projectId = makeProjectId(data.directory);
|
|
200
|
+
const serverInfo = {
|
|
201
|
+
id: serverId,
|
|
202
|
+
projectId,
|
|
203
|
+
directory: data.directory,
|
|
204
|
+
type: data.type,
|
|
205
|
+
key: data.key,
|
|
206
|
+
name: data.name,
|
|
207
|
+
logPaths,
|
|
208
|
+
logDir,
|
|
209
|
+
};
|
|
210
|
+
const endpoint = {
|
|
211
|
+
port: data.port,
|
|
212
|
+
pid: data.pid,
|
|
213
|
+
registeredAt: Date.now(),
|
|
214
|
+
};
|
|
215
|
+
registry.addEndpoint(serverId, serverInfo, endpoint);
|
|
216
|
+
// Create per-project writers (keyed by directory — logs are project-scoped)
|
|
217
|
+
if (!projectWriters.has(data.directory)) {
|
|
218
|
+
projectWriters.set(data.directory, {
|
|
219
|
+
console: new ConsoleWriter(logPaths.console, options.maxFileSizeMb),
|
|
220
|
+
errors: new ErrorsWriter(logPaths.errors, options.maxFileSizeMb),
|
|
221
|
+
devEvents: new DevEventsWriter(logPaths['dev-events'], options.maxFileSizeMb),
|
|
222
|
+
serverConsole: new ServerConsoleWriter(logPaths['server-console'], options.maxFileSizeMb),
|
|
223
|
+
network: logPaths.network ? new NetworkWriter(logPaths.network, options.maxFileSizeMb) : undefined,
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
227
|
+
res.end(JSON.stringify({
|
|
228
|
+
success: true,
|
|
229
|
+
serverId,
|
|
230
|
+
projectId,
|
|
231
|
+
logDir,
|
|
232
|
+
gatewayMcpUrl: `${serverUrl}${mcpPath}/sse`,
|
|
233
|
+
gatewayRpcUrl: `${serverUrl.replace('http', 'ws')}/__rpc`,
|
|
234
|
+
}));
|
|
235
|
+
}
|
|
236
|
+
catch (err) {
|
|
237
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
238
|
+
res.end(JSON.stringify({ error: `Invalid request: ${err}` }));
|
|
239
|
+
}
|
|
240
|
+
});
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
// Browser init endpoint — returns serverId + gatewayUrl for runtime client config
|
|
244
|
+
if (url.startsWith('/__gateway/init') && req.method === 'GET') {
|
|
245
|
+
addCorsHeaders(res);
|
|
246
|
+
const urlObj = new URL(url, 'http://localhost');
|
|
247
|
+
res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-cache' });
|
|
248
|
+
res.end(JSON.stringify({
|
|
249
|
+
serverId: urlObj.searchParams.get('server') || null,
|
|
250
|
+
gatewayUrl: urlObj.searchParams.get('gateway') || serverUrl,
|
|
251
|
+
}));
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
if (url === '/__gateway/servers' && req.method === 'GET') {
|
|
255
|
+
addCorsHeaders(res);
|
|
256
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
257
|
+
res.end(JSON.stringify({
|
|
258
|
+
servers: registry.getAll(),
|
|
259
|
+
count: registry.size(),
|
|
260
|
+
}, null, 2));
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
if (url.startsWith('/__gateway/unregister/') && req.method === 'POST') {
|
|
264
|
+
addCorsHeaders(res);
|
|
265
|
+
const serverId = url.split('/').pop();
|
|
266
|
+
if (serverId && registry.has(serverId)) {
|
|
267
|
+
const server = registry.get(serverId);
|
|
268
|
+
registry.remove(serverId);
|
|
269
|
+
// Don't remove browsers — they disconnect naturally
|
|
270
|
+
if (server)
|
|
271
|
+
projectWriters.delete(server.directory);
|
|
272
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
273
|
+
res.end(JSON.stringify({ success: true }));
|
|
274
|
+
}
|
|
275
|
+
else {
|
|
276
|
+
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
277
|
+
res.end(JSON.stringify({ error: 'Server not found' }));
|
|
278
|
+
}
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
// MCP endpoints
|
|
282
|
+
if (url.startsWith(mcpPath)) {
|
|
283
|
+
addCorsHeaders(res);
|
|
284
|
+
mcpMiddleware(req, res, () => {
|
|
285
|
+
res.writeHead(404);
|
|
286
|
+
res.end('Not found');
|
|
287
|
+
});
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
// Gateway status page
|
|
291
|
+
if (url === '/__status') {
|
|
292
|
+
addCorsHeaders(res);
|
|
293
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
294
|
+
res.end(JSON.stringify({
|
|
295
|
+
gateway: 'webdev',
|
|
296
|
+
mode: registry.size() > 0 ? 'hybrid' : 'hub',
|
|
297
|
+
session: session.info,
|
|
298
|
+
registered_servers: registry.getAll(),
|
|
299
|
+
uptime_ms: Date.now() - session.startedAt,
|
|
300
|
+
}, null, 2));
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
// Admin UI
|
|
304
|
+
if (handleAdmin(req, res, url, { startedAt: session.startedAt, registry, port, session }))
|
|
305
|
+
return;
|
|
306
|
+
// Landing page
|
|
307
|
+
if (url === '/') {
|
|
308
|
+
res.writeHead(200, { 'Content-Type': 'text/html' });
|
|
309
|
+
res.end(`<!DOCTYPE html>
|
|
310
|
+
<html><head>
|
|
311
|
+
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
|
312
|
+
<title>webdev</title>
|
|
313
|
+
<style>
|
|
314
|
+
*{box-sizing:border-box;margin:0}
|
|
315
|
+
body{font-family:system-ui,-apple-system,sans-serif;background:#0a0a0a;color:#e0e0e0;display:flex;align-items:center;justify-content:center;min-height:100vh}
|
|
316
|
+
.wrap{text-align:center;max-width:520px;width:100%;padding:2rem}
|
|
317
|
+
h1{font-size:1.4rem;font-weight:500;margin-bottom:.5rem;color:#fff}
|
|
318
|
+
.section{margin-top:2rem}
|
|
319
|
+
.section h2{font-size:.95rem;font-weight:500;color:#888;margin-bottom:.75rem}
|
|
320
|
+
form{display:flex;gap:.5rem}
|
|
321
|
+
input{flex:1;padding:.6rem .8rem;border-radius:6px;border:1px solid #333;background:#141414;color:#e0e0e0;font-size:.9rem;outline:none}
|
|
322
|
+
input:focus{border-color:#555}
|
|
323
|
+
input::placeholder{color:#444}
|
|
324
|
+
button{padding:.6rem 1.2rem;border-radius:6px;border:none;background:#fff;color:#000;font-size:.9rem;font-weight:500;cursor:pointer}
|
|
325
|
+
button:hover{background:#ddd}
|
|
326
|
+
a.link{display:inline-block;padding:.6rem 1.2rem;border-radius:6px;border:1px solid #333;color:#e0e0e0;text-decoration:none;font-size:.9rem}
|
|
327
|
+
a.link:hover{border-color:#555;background:#141414}
|
|
328
|
+
</style>
|
|
329
|
+
</head><body>
|
|
330
|
+
<div class="wrap">
|
|
331
|
+
<h1>webdev</h1>
|
|
332
|
+
<div class="section">
|
|
333
|
+
<a class="link" href="/__admin">Admin Dashboard →</a>
|
|
334
|
+
</div>${proxyMiddleware ? `
|
|
335
|
+
<div class="section">
|
|
336
|
+
<h2>Proxy</h2>
|
|
337
|
+
<form onsubmit="event.preventDefault();var u=this.url.value.trim();if(u){if(!/^https?:\\/\\//.test(u))u='http://'+u;location.href='/'+u}">
|
|
338
|
+
<input name="url" type="text" placeholder="http://localhost:3000" autofocus>
|
|
339
|
+
<button type="submit">Go</button>
|
|
340
|
+
</form>
|
|
341
|
+
</div>` : ''}
|
|
342
|
+
</div>
|
|
343
|
+
</body></html>`);
|
|
344
|
+
return;
|
|
345
|
+
}
|
|
346
|
+
// Try optional proxy plugin (npm install @winstonfassett/webdev-proxy)
|
|
347
|
+
if (proxyMiddleware) {
|
|
348
|
+
proxyMiddleware(req, res, () => {
|
|
349
|
+
res.writeHead(404, { 'Content-Type': 'text/plain' });
|
|
350
|
+
res.end('Not found');
|
|
351
|
+
});
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
res.writeHead(404, { 'Content-Type': 'text/plain' });
|
|
355
|
+
res.end('Not found');
|
|
356
|
+
}
|
|
357
|
+
// Create server (HTTP or HTTPS)
|
|
358
|
+
let server;
|
|
359
|
+
if (useHttps) {
|
|
360
|
+
let cert, key;
|
|
361
|
+
if (options.cert && options.key) {
|
|
362
|
+
cert = readFileSync(options.cert, 'utf-8');
|
|
363
|
+
key = readFileSync(options.key, 'utf-8');
|
|
364
|
+
}
|
|
365
|
+
else {
|
|
366
|
+
const generated = generateSelfSignedCert();
|
|
367
|
+
cert = generated.cert;
|
|
368
|
+
key = generated.key;
|
|
369
|
+
}
|
|
370
|
+
server = https.createServer({ cert, key }, handleRequest);
|
|
371
|
+
}
|
|
372
|
+
else {
|
|
373
|
+
server = http.createServer(handleRequest);
|
|
374
|
+
}
|
|
375
|
+
// Setup admin RPC WebSocket (capnweb, /__admin/ws)
|
|
376
|
+
setupAdminWebSocket(server);
|
|
377
|
+
// Setup events WebSocket (browser → server for console/errors/network)
|
|
378
|
+
const eventsWss = new WebSocketServer({ noServer: true });
|
|
379
|
+
// Setup dev-events WebSocket (adapters → server for HMR/build events)
|
|
380
|
+
const devEventsWss = new WebSocketServer({ noServer: true });
|
|
381
|
+
// Setup command WebSocket (browser ↔ gateway JSON protocol)
|
|
382
|
+
setupRpcWebSocket(server, '/__rpc');
|
|
383
|
+
// Setup CDP relay (extension ↔ Playwright bridge)
|
|
384
|
+
const cdpRelay = new CDPRelay({ gatewayPort: port });
|
|
385
|
+
mcpCtx.cdpRelay = cdpRelay;
|
|
386
|
+
// Upgrade handler for events + dev-events + proxy WS
|
|
387
|
+
server.on('upgrade', (request, socket, head) => {
|
|
388
|
+
const url = request.url ?? '';
|
|
389
|
+
// CDP relay handles /__cdp-extension and /devtools/browser/*
|
|
390
|
+
if (cdpRelay.handleUpgrade(request, socket, head)) {
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
393
|
+
if (url === '/__events' || url.startsWith('/__events?')) {
|
|
394
|
+
eventsWss.handleUpgrade(request, socket, head, (ws) => {
|
|
395
|
+
eventsWss.emit('connection', ws, request);
|
|
396
|
+
});
|
|
397
|
+
}
|
|
398
|
+
else if (url === '/__dev-events' || url.startsWith('/__dev-events?')) {
|
|
399
|
+
devEventsWss.handleUpgrade(request, socket, head, (ws) => {
|
|
400
|
+
devEventsWss.emit('connection', ws, request);
|
|
401
|
+
});
|
|
402
|
+
}
|
|
403
|
+
else if (url === '/__rpc' || url.startsWith('/__rpc?')) {
|
|
404
|
+
// Handled by setupRpcWebSocket upgrade listener
|
|
405
|
+
}
|
|
406
|
+
else if (url === '/__admin/ws' || url.startsWith('/__admin/ws?')) {
|
|
407
|
+
// Handled by setupAdminWebSocket upgrade listener
|
|
408
|
+
}
|
|
409
|
+
else {
|
|
410
|
+
socket.destroy();
|
|
411
|
+
}
|
|
412
|
+
});
|
|
413
|
+
eventsWss.on('connection', (ws, request) => {
|
|
414
|
+
// Parse stable server identity from query param, resolve to project directory for writer routing
|
|
415
|
+
const reqUrl = request.url ?? '';
|
|
416
|
+
const serverMatch = reqUrl.match(/[?&]server=([^&]+)/);
|
|
417
|
+
const serverId = serverMatch ? decodeURIComponent(serverMatch[1]) : null;
|
|
418
|
+
const server = serverId ? registry.get(serverId) : null;
|
|
419
|
+
const projectDir = server?.directory ?? null;
|
|
420
|
+
ws.on('message', (data) => {
|
|
421
|
+
try {
|
|
422
|
+
const msg = JSON.parse(data.toString());
|
|
423
|
+
const { channel, payload, browserId } = msg;
|
|
424
|
+
// Tag payload with browser ID for filtering
|
|
425
|
+
if (browserId)
|
|
426
|
+
payload.browserId = browserId;
|
|
427
|
+
// Use project-specific writers if available, fall back to gateway writers
|
|
428
|
+
const w = (projectDir && projectWriters.get(projectDir)) || writers;
|
|
429
|
+
if (channel === 'console') {
|
|
430
|
+
w.console.write(payload);
|
|
431
|
+
}
|
|
432
|
+
else if (channel === 'error') {
|
|
433
|
+
w.errors.write(payload);
|
|
434
|
+
const logFile = server?.logPaths?.errors ?? session.files.errors ?? '';
|
|
435
|
+
sendNotificationToAll('errors', payload.message ?? 'Error', logFile, `logs`);
|
|
436
|
+
}
|
|
437
|
+
else if (channel === 'server-console') {
|
|
438
|
+
w.serverConsole.write(payload);
|
|
439
|
+
if (payload.level === 'error') {
|
|
440
|
+
const logFile = server?.logPaths?.['server-console'] ?? session.files['server-console'] ?? '';
|
|
441
|
+
sendNotificationToAll('server', payload.args?.join(' ') ?? 'Server error', logFile, `logs`);
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
else if (channel === 'network' && w.network) {
|
|
445
|
+
w.network.write(payload);
|
|
446
|
+
}
|
|
447
|
+
// Push to admin WS stream subscribers
|
|
448
|
+
emitLogEvent({ channel, payload, browserId });
|
|
449
|
+
}
|
|
450
|
+
catch {
|
|
451
|
+
// Ignore malformed messages
|
|
452
|
+
}
|
|
453
|
+
});
|
|
454
|
+
});
|
|
455
|
+
devEventsWss.on('connection', (ws, request) => {
|
|
456
|
+
// Parse stable server identity from query param, resolve to project directory
|
|
457
|
+
const reqUrl = request.url ?? '';
|
|
458
|
+
const serverMatch = reqUrl.match(/[?&]server=([^&]+)/);
|
|
459
|
+
const serverId = serverMatch ? decodeURIComponent(serverMatch[1]) : null;
|
|
460
|
+
const server = serverId ? registry.get(serverId) : null;
|
|
461
|
+
const projectDir = server?.directory ?? null;
|
|
462
|
+
console.log(`[webdev] Dev adapter connected${serverId ? ` (server: ${serverId})` : ''}`);
|
|
463
|
+
ws.on('message', (data) => {
|
|
464
|
+
try {
|
|
465
|
+
const payload = JSON.parse(data.toString());
|
|
466
|
+
const w = (projectDir && projectWriters.get(projectDir)) || writers;
|
|
467
|
+
w.devEvents.write(payload);
|
|
468
|
+
if (payload.type === 'build:error') {
|
|
469
|
+
const logFile = server?.logPaths?.['dev-events'] ?? session.files['dev-events'] ?? '';
|
|
470
|
+
sendNotificationToAll('build', payload.error ?? 'Build error', logFile, `logs`);
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
catch {
|
|
474
|
+
// Ignore malformed messages
|
|
475
|
+
}
|
|
476
|
+
});
|
|
477
|
+
ws.on('close', () => {
|
|
478
|
+
console.log(`[webdev] Dev adapter disconnected${serverId ? ` (server: ${serverId})` : ''}`);
|
|
479
|
+
});
|
|
480
|
+
});
|
|
481
|
+
server.on('error', (err) => {
|
|
482
|
+
if (err.code === 'EADDRINUSE') {
|
|
483
|
+
const occupier = findPortOccupier(port);
|
|
484
|
+
console.error('');
|
|
485
|
+
console.error(` ✗ Port ${port} is already in use.`);
|
|
486
|
+
if (occupier) {
|
|
487
|
+
console.error(` Held by: PID ${occupier.pid}${occupier.cmd ? ` (${occupier.cmd})` : ''}`);
|
|
488
|
+
}
|
|
489
|
+
console.error('');
|
|
490
|
+
console.error(` Either stop that process, or run with a different port:`);
|
|
491
|
+
console.error(` npx webdev -p <other-port>`);
|
|
492
|
+
console.error('');
|
|
493
|
+
process.exit(1);
|
|
494
|
+
}
|
|
495
|
+
throw err;
|
|
496
|
+
});
|
|
497
|
+
server.listen(port, () => {
|
|
498
|
+
const proto = useHttps ? 'https' : 'http';
|
|
499
|
+
console.log('');
|
|
500
|
+
console.log(` webdev gateway`);
|
|
501
|
+
console.log(` ───────────────────────────────`);
|
|
502
|
+
console.log(` Listen: ${proto}://localhost:${port}`);
|
|
503
|
+
console.log(` MCP: ${proto}://localhost:${port}${mcpPath}/sse`);
|
|
504
|
+
console.log(` Logs: ${session.logDir}`);
|
|
505
|
+
console.log(` CDP: ${proto}://localhost:${port}/json/version (extension relay)`);
|
|
506
|
+
if (useHttps)
|
|
507
|
+
console.log(` HTTPS: enabled`);
|
|
508
|
+
console.log('');
|
|
509
|
+
});
|
|
510
|
+
return server;
|
|
511
|
+
}
|
|
512
|
+
/** Best-effort lookup of the process holding a port (lsof on unix, netstat on win). */
|
|
513
|
+
function findPortOccupier(port) {
|
|
514
|
+
try {
|
|
515
|
+
if (process.platform === 'win32') {
|
|
516
|
+
const out = execSync(`netstat -ano -p TCP`, { encoding: 'utf8' });
|
|
517
|
+
const line = out.split('\n').find((l) => l.includes(`:${port}`) && /LISTENING/i.test(l));
|
|
518
|
+
if (!line)
|
|
519
|
+
return null;
|
|
520
|
+
const cols = line.trim().split(/\s+/);
|
|
521
|
+
return { pid: cols[cols.length - 1] ?? '?', cmd: '' };
|
|
522
|
+
}
|
|
523
|
+
const out = execSync(`lsof -iTCP:${port} -sTCP:LISTEN -P -n`, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
|
|
524
|
+
const lines = out.trim().split('\n');
|
|
525
|
+
if (lines.length < 2)
|
|
526
|
+
return null;
|
|
527
|
+
const cols = lines[1].split(/\s+/);
|
|
528
|
+
return { pid: cols[1] ?? '?', cmd: cols[0] ?? '' };
|
|
529
|
+
}
|
|
530
|
+
catch {
|
|
531
|
+
return null;
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
//# sourceMappingURL=gateway.js.map
|