@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.
Files changed (112) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/LICENSE +21 -0
  3. package/README.md +78 -0
  4. package/dist/adapter-helpers.d.ts +64 -0
  5. package/dist/adapter-helpers.d.ts.map +1 -0
  6. package/dist/adapter-helpers.js +297 -0
  7. package/dist/adapter-helpers.js.map +1 -0
  8. package/dist/admin/assets/index-DEDI8OIx.css +2 -0
  9. package/dist/admin/assets/index-DaI40ww1.js +70 -0
  10. package/dist/admin/assets/tinykeys.module-CjuTRcEz.js +1 -0
  11. package/dist/admin/index.html +13 -0
  12. package/dist/admin-rpc.d.ts +27 -0
  13. package/dist/admin-rpc.d.ts.map +1 -0
  14. package/dist/admin-rpc.js +147 -0
  15. package/dist/admin-rpc.js.map +1 -0
  16. package/dist/admin.d.ts +10 -0
  17. package/dist/admin.d.ts.map +1 -0
  18. package/dist/admin.js +202 -0
  19. package/dist/admin.js.map +1 -0
  20. package/dist/auto-register.d.ts +10 -0
  21. package/dist/auto-register.d.ts.map +1 -0
  22. package/dist/auto-register.js +145 -0
  23. package/dist/auto-register.js.map +1 -0
  24. package/dist/cdp-relay.d.ts +110 -0
  25. package/dist/cdp-relay.d.ts.map +1 -0
  26. package/dist/cdp-relay.js +616 -0
  27. package/dist/cdp-relay.js.map +1 -0
  28. package/dist/cli.d.ts +3 -0
  29. package/dist/cli.d.ts.map +1 -0
  30. package/dist/cli.js +95 -0
  31. package/dist/cli.js.map +1 -0
  32. package/dist/doctor.d.ts +6 -0
  33. package/dist/doctor.d.ts.map +1 -0
  34. package/dist/doctor.js +149 -0
  35. package/dist/doctor.js.map +1 -0
  36. package/dist/element-grab-client.js +305 -0
  37. package/dist/element-grab.d.ts +15 -0
  38. package/dist/element-grab.d.ts.map +1 -0
  39. package/dist/element-grab.js +102 -0
  40. package/dist/element-grab.js.map +1 -0
  41. package/dist/gateway.d.ts +5 -0
  42. package/dist/gateway.d.ts.map +1 -0
  43. package/dist/gateway.js +534 -0
  44. package/dist/gateway.js.map +1 -0
  45. package/dist/installer.d.ts +48 -0
  46. package/dist/installer.d.ts.map +1 -0
  47. package/dist/installer.js +637 -0
  48. package/dist/installer.js.map +1 -0
  49. package/dist/libs/element-source.js +35 -0
  50. package/dist/libs/modern-screenshot.js +14 -0
  51. package/dist/log-reader.d.ts +30 -0
  52. package/dist/log-reader.d.ts.map +1 -0
  53. package/dist/log-reader.js +174 -0
  54. package/dist/log-reader.js.map +1 -0
  55. package/dist/mcp-server.d.ts +22 -0
  56. package/dist/mcp-server.d.ts.map +1 -0
  57. package/dist/mcp-server.js +115 -0
  58. package/dist/mcp-server.js.map +1 -0
  59. package/dist/mcp-tools-core.d.ts +30 -0
  60. package/dist/mcp-tools-core.d.ts.map +1 -0
  61. package/dist/mcp-tools-core.js +375 -0
  62. package/dist/mcp-tools-core.js.map +1 -0
  63. package/dist/mcp-tools-full.d.ts +4 -0
  64. package/dist/mcp-tools-full.d.ts.map +1 -0
  65. package/dist/mcp-tools-full.js +141 -0
  66. package/dist/mcp-tools-full.js.map +1 -0
  67. package/dist/playwright-commands.d.ts +33 -0
  68. package/dist/playwright-commands.d.ts.map +1 -0
  69. package/dist/playwright-commands.js +356 -0
  70. package/dist/playwright-commands.js.map +1 -0
  71. package/dist/registry.d.ts +83 -0
  72. package/dist/registry.d.ts.map +1 -0
  73. package/dist/registry.js +205 -0
  74. package/dist/registry.js.map +1 -0
  75. package/dist/rpc-server.d.ts +54 -0
  76. package/dist/rpc-server.d.ts.map +1 -0
  77. package/dist/rpc-server.js +207 -0
  78. package/dist/rpc-server.js.map +1 -0
  79. package/dist/session.d.ts +13 -0
  80. package/dist/session.d.ts.map +1 -0
  81. package/dist/session.js +61 -0
  82. package/dist/session.js.map +1 -0
  83. package/dist/types.d.ts +76 -0
  84. package/dist/types.d.ts.map +1 -0
  85. package/dist/types.js +2 -0
  86. package/dist/types.js.map +1 -0
  87. package/dist/webdev-client.js +20 -0
  88. package/dist/writers/base.d.ts +24 -0
  89. package/dist/writers/base.d.ts.map +1 -0
  90. package/dist/writers/base.js +98 -0
  91. package/dist/writers/base.js.map +1 -0
  92. package/dist/writers/console.d.ts +8 -0
  93. package/dist/writers/console.d.ts.map +1 -0
  94. package/dist/writers/console.js +14 -0
  95. package/dist/writers/console.js.map +1 -0
  96. package/dist/writers/dev-events.d.ts +28 -0
  97. package/dist/writers/dev-events.d.ts.map +1 -0
  98. package/dist/writers/dev-events.js +53 -0
  99. package/dist/writers/dev-events.js.map +1 -0
  100. package/dist/writers/errors.d.ts +8 -0
  101. package/dist/writers/errors.d.ts.map +1 -0
  102. package/dist/writers/errors.js +14 -0
  103. package/dist/writers/errors.js.map +1 -0
  104. package/dist/writers/network.d.ts +9 -0
  105. package/dist/writers/network.d.ts.map +1 -0
  106. package/dist/writers/network.js +17 -0
  107. package/dist/writers/network.js.map +1 -0
  108. package/dist/writers/server-console.d.ts +8 -0
  109. package/dist/writers/server-console.d.ts.map +1 -0
  110. package/dist/writers/server-console.js +14 -0
  111. package/dist/writers/server-console.js.map +1 -0
  112. 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"}
@@ -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 &rarr;</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