browserwire 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/cli/server.js ADDED
@@ -0,0 +1,539 @@
1
+ import { createServer } from "node:http";
2
+ import { mkdir, writeFile } from "node:fs/promises";
3
+ import { dirname, resolve } from "node:path";
4
+ import { WebSocketServer } from "ws";
5
+ import {
6
+ createEnvelope,
7
+ MessageType,
8
+ parseEnvelope,
9
+ PROTOCOL_VERSION
10
+ } from "../extension/shared/protocol.js";
11
+ import { classifyInteractables } from "./discovery/classify.js";
12
+ import { groupEntities } from "./discovery/entities.js";
13
+ import { synthesizeAllLocators } from "./discovery/locators.js";
14
+ import { compileManifest } from "./discovery/compile.js";
15
+ import { enrichManifest } from "./discovery/enrich.js";
16
+ import { DiscoverySession } from "./discovery/session.js";
17
+ import { createBridge } from "./api/bridge.js";
18
+ import { createHttpHandler } from "./api/router.js";
19
+ import { ManifestStore } from "./manifest-store.js";
20
+
21
+ const CLIENT_STATUS_INTERVAL_MS = 15000;
22
+
23
+ /** Active discovery sessions keyed by sessionId */
24
+ const activeSessions = new Map();
25
+
26
+ /** Module-level state shared between HTTP and WS */
27
+ const manifestStore = new ManifestStore();
28
+ const siteManifests = new Map(); // origin → manifest (in-memory cache)
29
+ let extensionSocket = null;
30
+ const bridge = createBridge();
31
+
32
+ export const startServer = async ({
33
+ host = "127.0.0.1",
34
+ port = 8787,
35
+ debug = false
36
+ } = {}) => {
37
+ // Load persisted site manifests on startup
38
+ const sites = await manifestStore.listSites();
39
+ for (const site of sites) {
40
+ const m = await manifestStore.load(site.origin);
41
+ if (m) {
42
+ siteManifests.set(site.origin, m);
43
+ const slug = ManifestStore.originSlug(site.origin);
44
+ console.log(`[browserwire-cli] loaded manifest for ${site.origin} → http://${host}:${port}/api/sites/${slug}/docs`);
45
+ }
46
+ }
47
+ if (sites.length > 0) {
48
+ console.log(`[browserwire-cli] loaded ${sites.length} site manifest(s)`);
49
+ }
50
+
51
+ const httpHandler = createHttpHandler({
52
+ getManifestBySlug: (slug) => {
53
+ for (const [origin, m] of siteManifests) {
54
+ if (ManifestStore.originSlug(origin) === slug) return m;
55
+ }
56
+ return null;
57
+ },
58
+ listSites: () => [...siteManifests.entries()].map(([origin, m]) => ({
59
+ origin,
60
+ slug: ManifestStore.originSlug(origin),
61
+ domain: m.domain || null,
62
+ entityCount: m.entities?.length || 0,
63
+ actionCount: m.actions?.length || 0,
64
+ viewCount: m.views?.length || 0,
65
+ updatedAt: m.metadata?.updatedAt || m.metadata?.createdAt || null
66
+ })),
67
+ bridge,
68
+ getSocket: () => extensionSocket,
69
+ host,
70
+ port
71
+ });
72
+
73
+ const httpServer = createServer(httpHandler);
74
+ const wss = new WebSocketServer({ server: httpServer });
75
+
76
+ httpServer.listen(port, host, () => {
77
+ console.log(`[browserwire-cli] listening on http://${host}:${port}`);
78
+ console.log(`[browserwire-cli] site index at http://${host}:${port}/api/docs`);
79
+ });
80
+
81
+ wss.on("connection", (socket, req) => {
82
+ const clientId = `${Date.now()}-${Math.random().toString(16).slice(2)}`;
83
+ const source = req.socket.remoteAddress || "unknown";
84
+
85
+ console.log(`[browserwire-cli] client connected ${clientId} from ${source}`);
86
+
87
+ const statusTimer = setInterval(() => {
88
+ if (socket.readyState === 1) {
89
+ socket.send(
90
+ JSON.stringify(
91
+ createEnvelope(MessageType.STATUS, {
92
+ state: "connected",
93
+ serverTime: new Date().toISOString()
94
+ })
95
+ )
96
+ );
97
+ }
98
+ }, CLIENT_STATUS_INTERVAL_MS);
99
+
100
+ socket.on("message", async (data) => {
101
+ const message = parseEnvelope(data.toString());
102
+
103
+ if (!message) {
104
+ socket.send(
105
+ JSON.stringify(
106
+ createEnvelope(MessageType.ERROR, {
107
+ code: "invalid_message",
108
+ message: "Expected JSON message with a string 'type' field."
109
+ })
110
+ )
111
+ );
112
+ return;
113
+ }
114
+
115
+ if (message.type === MessageType.HELLO) {
116
+ // Track as extension socket
117
+ extensionSocket = socket;
118
+
119
+ socket.send(
120
+ JSON.stringify(
121
+ createEnvelope(
122
+ MessageType.HELLO_ACK,
123
+ {
124
+ accepted: true,
125
+ server: "browserwire-cli",
126
+ protocolVersion: PROTOCOL_VERSION
127
+ },
128
+ message.requestId
129
+ )
130
+ )
131
+ );
132
+ return;
133
+ }
134
+
135
+ if (message.type === MessageType.PING) {
136
+ socket.send(
137
+ JSON.stringify(
138
+ createEnvelope(
139
+ MessageType.PONG,
140
+ {
141
+ serverTime: new Date().toISOString()
142
+ },
143
+ message.requestId
144
+ )
145
+ )
146
+ );
147
+ return;
148
+ }
149
+
150
+ // ─── Bridge result messages (from extension executing REST API commands) ──
151
+
152
+ if (
153
+ message.type === MessageType.EXECUTE_RESULT ||
154
+ message.type === MessageType.READ_RESULT ||
155
+ message.type === MessageType.WORKFLOW_RESULT
156
+ ) {
157
+ if (bridge.handleWsResult(message)) return;
158
+ // Not matched — fall through to log
159
+ }
160
+
161
+ // ─── Discovery Session Messages ─────────────────────────
162
+
163
+ if (message.type === MessageType.DISCOVERY_SESSION_START) {
164
+ const payload = message.payload || {};
165
+ const sessionId = payload.sessionId || crypto.randomUUID();
166
+ const site = payload.url || "unknown";
167
+
168
+ const session = new DiscoverySession(sessionId, site);
169
+ activeSessions.set(sessionId, session);
170
+
171
+ // Derive origin and seed with prior manifest if available
172
+ let origin = null;
173
+ try { origin = new URL(site).origin; } catch { /* ignore */ }
174
+ session._siteOrigin = origin;
175
+
176
+ if (origin) {
177
+ const prior = siteManifests.get(origin) || await manifestStore.load(origin);
178
+ if (prior) {
179
+ session.seedWithManifest(prior);
180
+ console.log(`[browserwire-cli] session seeded with prior manifest for ${origin}`);
181
+ }
182
+ }
183
+
184
+ console.log(
185
+ `[browserwire-cli] session started: ${sessionId} site=${site}`
186
+ );
187
+
188
+ socket.send(
189
+ JSON.stringify(
190
+ createEnvelope(
191
+ MessageType.DISCOVERY_SESSION_STATUS,
192
+ session.getStats(),
193
+ message.requestId
194
+ )
195
+ )
196
+ );
197
+ return;
198
+ }
199
+
200
+ if (message.type === MessageType.DISCOVERY_SESSION_STOP) {
201
+ const payload = message.payload || {};
202
+ const sessionId = payload.sessionId;
203
+ const session = activeSessions.get(sessionId);
204
+
205
+ if (!session) {
206
+ console.warn(`[browserwire-cli] session stop for unknown session: ${sessionId}`);
207
+ socket.send(
208
+ JSON.stringify(
209
+ createEnvelope(
210
+ MessageType.ERROR,
211
+ { code: "unknown_session", message: `Session ${sessionId} not found` },
212
+ message.requestId
213
+ )
214
+ )
215
+ );
216
+ return;
217
+ }
218
+
219
+ console.log(`[browserwire-cli] session stopping: ${sessionId}`);
220
+
221
+ // Process any remaining buffered snapshots sent with the stop payload
222
+ const remainingSnapshots = Array.isArray(payload.pendingSnapshots) ? payload.pendingSnapshots : [];
223
+ const finalizeSession = remainingSnapshots.length > 0
224
+ ? (async () => {
225
+ console.log(`[browserwire-cli] processing ${remainingSnapshots.length} remaining buffered snapshots before finalize`);
226
+ for (const snap of remainingSnapshots) {
227
+ await session.addSnapshot(snap);
228
+ }
229
+ return session.finalize();
230
+ })()
231
+ : session.finalize();
232
+
233
+ finalizeSession
234
+ .then((result) => {
235
+ const { manifest, draftManifest, enrichedManifest } = result;
236
+
237
+ if (!manifest) {
238
+ console.log(`[browserwire-cli] session ${sessionId} produced no manifest`);
239
+ return;
240
+ }
241
+
242
+ // Write output files
243
+ const sessionDir = resolve(process.cwd(), `logs/session-${sessionId}`);
244
+
245
+ return mkdir(sessionDir, { recursive: true })
246
+ .then(() => Promise.all([
247
+ writeFile(
248
+ resolve(sessionDir, "manifest-draft.json"),
249
+ JSON.stringify(draftManifest || manifest, null, 2),
250
+ "utf8"
251
+ ),
252
+ writeFile(
253
+ resolve(sessionDir, "manifest.json"),
254
+ JSON.stringify(manifest, null, 2),
255
+ "utf8"
256
+ ),
257
+ writeFile(
258
+ resolve(sessionDir, "session.json"),
259
+ JSON.stringify({
260
+ sessionId: session.sessionId,
261
+ site: session.site,
262
+ startedAt: session.startedAt,
263
+ stoppedAt: new Date().toISOString(),
264
+ snapshotCount: session.snapshots.length,
265
+ snapshots: session.snapshots.map((s) => ({
266
+ snapshotId: s.snapshotId,
267
+ trigger: s.trigger,
268
+ url: s.url,
269
+ title: s.title,
270
+ capturedAt: s.capturedAt,
271
+ stats: s.stats
272
+ }))
273
+ }, null, 2),
274
+ "utf8"
275
+ )
276
+ ]))
277
+ .then(async () => {
278
+ console.log(`[browserwire-cli] session ${sessionId} output written to ${sessionDir}`);
279
+
280
+ // Save to site-centric manifest store
281
+ const origin = session._siteOrigin;
282
+ if (origin) {
283
+ siteManifests.set(origin, enrichedManifest);
284
+ await manifestStore.save(origin, enrichedManifest, sessionId);
285
+ const slug = ManifestStore.originSlug(origin);
286
+ console.log(`[browserwire-cli] REST API ready at http://${host}:${port}/api/sites/${slug}/docs`);
287
+ }
288
+
289
+ // Send manifest to extension immediately so sidepanel can render
290
+ socket.send(JSON.stringify(createEnvelope(
291
+ MessageType.MANIFEST_READY,
292
+ { sessionId, manifest: enrichedManifest }
293
+ )));
294
+ });
295
+ })
296
+ .then(() => {
297
+ socket.send(
298
+ JSON.stringify(
299
+ createEnvelope(
300
+ MessageType.DISCOVERY_SESSION_STATUS,
301
+ { ...session.getStats(), finalized: true },
302
+ message.requestId
303
+ )
304
+ )
305
+ );
306
+ })
307
+ .catch((error) => {
308
+ console.error(`[browserwire-cli] session finalization failed:`, error);
309
+ })
310
+ .finally(() => {
311
+ activeSessions.delete(sessionId);
312
+ });
313
+
314
+ return;
315
+ }
316
+
317
+ if (message.type === MessageType.CHECKPOINT) {
318
+ const payload = message.payload || {};
319
+ const { sessionId, snapshots = [], note } = payload;
320
+ const session = activeSessions.get(sessionId);
321
+
322
+ if (!session) {
323
+ console.warn(`[browserwire-cli] checkpoint for unknown session: ${sessionId}`);
324
+ socket.send(JSON.stringify(createEnvelope(MessageType.ERROR, {
325
+ code: "unknown_session", message: `Session ${sessionId} not found`
326
+ }, message.requestId)));
327
+ return;
328
+ }
329
+
330
+ console.log(`[browserwire-cli] checkpoint: sessionId=${sessionId} snapshots=${snapshots.length}${note ? ` note="${note}"` : ""}`);
331
+
332
+ const processCheckpoint = async () => {
333
+ for (const snap of snapshots) {
334
+ await session.addSnapshot(snap);
335
+ }
336
+ return session.compileCheckpoint(note);
337
+ };
338
+
339
+ processCheckpoint()
340
+ .then(async (result) => {
341
+ const { manifest, checkpointIndex } = result;
342
+
343
+ const sessionDir = resolve(process.cwd(), `logs/session-${sessionId}`);
344
+
345
+ if (manifest) {
346
+ await mkdir(sessionDir, { recursive: true });
347
+ await writeFile(
348
+ resolve(sessionDir, `checkpoint-${checkpointIndex}.json`),
349
+ JSON.stringify(manifest, null, 2),
350
+ "utf8"
351
+ );
352
+ console.log(`[browserwire-cli] checkpoint-${checkpointIndex} written for session ${sessionId}`);
353
+
354
+ // Save to site-centric manifest store
355
+ if (session._siteOrigin) {
356
+ siteManifests.set(session._siteOrigin, manifest);
357
+ manifestStore.save(session._siteOrigin, manifest, sessionId).catch(console.error);
358
+ }
359
+ }
360
+
361
+ socket.send(JSON.stringify(createEnvelope(
362
+ MessageType.CHECKPOINT_COMPLETE,
363
+ { sessionId, manifest: manifest || null, checkpointIndex },
364
+ message.requestId
365
+ )));
366
+ })
367
+ .catch((error) => {
368
+ console.error(`[browserwire-cli] checkpoint processing failed:`, error);
369
+ socket.send(JSON.stringify(createEnvelope(MessageType.ERROR, {
370
+ code: "checkpoint_failed", message: error.message
371
+ }, message.requestId)));
372
+ });
373
+
374
+ return;
375
+ }
376
+
377
+ if (message.type === MessageType.DISCOVERY_INCREMENTAL) {
378
+ const payload = message.payload || {};
379
+ const sessionId = payload.sessionId;
380
+ const session = activeSessions.get(sessionId);
381
+
382
+ if (!session) {
383
+ console.warn(`[browserwire-cli] incremental snapshot for unknown session: ${sessionId}`);
384
+ return;
385
+ }
386
+
387
+ const skeletonCount = Array.isArray(payload.skeleton) ? payload.skeleton.length : 0;
388
+ const screenshotKB = payload.screenshot ? Math.round(payload.screenshot.length * 0.75 / 1024) : 0;
389
+ console.log(
390
+ `[browserwire-cli] incremental snapshot: sessionId=${sessionId} skeleton=${skeletonCount} trigger=${payload.trigger?.kind || "unknown"} screenshot=${payload.screenshot ? screenshotKB + "KB" : "null"}`
391
+ );
392
+
393
+ session.addSnapshot(payload)
394
+ .then((stats) => {
395
+ // Broadcast updated stats back to extension
396
+ socket.send(
397
+ JSON.stringify(
398
+ createEnvelope(
399
+ MessageType.DISCOVERY_SESSION_STATUS,
400
+ stats
401
+ )
402
+ )
403
+ );
404
+
405
+ // Write screenshot as JPEG (always) + full snapshot JSON (debug only)
406
+ const snapName = payload.snapshotId || `snap_${stats.snapshotCount}`;
407
+ const snapDir = resolve(process.cwd(), `logs/session-${sessionId}`);
408
+
409
+ if (payload.screenshot) {
410
+ mkdir(snapDir, { recursive: true })
411
+ .then(() => writeFile(
412
+ resolve(snapDir, `${snapName}.jpg`),
413
+ Buffer.from(payload.screenshot, "base64")
414
+ ))
415
+ .catch((err) => {
416
+ console.error(`[browserwire-cli] failed to write screenshot:`, err);
417
+ });
418
+ }
419
+
420
+ if (debug) {
421
+ mkdir(snapDir, { recursive: true })
422
+ .then(() => writeFile(
423
+ resolve(snapDir, `${snapName}.json`),
424
+ JSON.stringify({ ...payload, screenshot: payload.screenshot ? "<base64>" : null }, null, 2),
425
+ "utf8"
426
+ ))
427
+ .catch((err) => {
428
+ console.error(`[browserwire-cli] failed to write snapshot:`, err);
429
+ });
430
+ }
431
+ })
432
+ .catch((error) => {
433
+ console.error(`[browserwire-cli] snapshot processing failed:`, error);
434
+ });
435
+
436
+ return;
437
+ }
438
+
439
+ // ─── Legacy One-Shot Discovery (kept for backward compat) ──
440
+
441
+ if (message.type === MessageType.DISCOVERY_SNAPSHOT) {
442
+ const payload = message.payload || {};
443
+ const elements = Array.isArray(payload.elements) ? payload.elements : [];
444
+ const a11y = Array.isArray(payload.a11y) ? payload.a11y : [];
445
+ const url = payload.url || "unknown";
446
+ const title = payload.title || "unknown";
447
+ const pageText = typeof payload.pageText === "string" ? payload.pageText : "";
448
+
449
+ console.log(
450
+ `[browserwire-cli] discovery snapshot url=${url} title="${title}" elements=${elements.length} a11y=${a11y.length}`
451
+ );
452
+
453
+ const { interactables, stats } = classifyInteractables(elements, a11y);
454
+ const { entities, stats: entityStats } = groupEntities(elements, a11y, interactables);
455
+ const { locators, stats: locatorStats } = synthesizeAllLocators(elements, a11y, interactables);
456
+ const { manifest, stats: manifestStats } = compileManifest({
457
+ url, title,
458
+ capturedAt: payload.capturedAt,
459
+ elements, a11y, interactables, entities, locators
460
+ });
461
+
462
+ console.log(
463
+ `[browserwire-cli] manifest compiled: ${manifestStats.entityCount} entities, ${manifestStats.actionCount} actions`
464
+ );
465
+
466
+ const manifestLogPath = resolve(process.cwd(), "logs/discovery-manifest.json");
467
+ mkdir(dirname(manifestLogPath), { recursive: true })
468
+ .then(() => enrichManifest(manifest, pageText, payload.capturedAt))
469
+ .then((result) => {
470
+ const finalManifest = result ? result.enriched : manifest;
471
+ return writeFile(manifestLogPath, JSON.stringify(finalManifest, null, 2), "utf8");
472
+ })
473
+ .then(() => {
474
+ console.log(`[browserwire-cli] manifest written to ${manifestLogPath}`);
475
+ })
476
+ .catch((error) => {
477
+ console.error(`[browserwire-cli] manifest write failed:`, error);
478
+ return writeFile(manifestLogPath, JSON.stringify(manifest, null, 2), "utf8").catch(() => {});
479
+ });
480
+
481
+ socket.send(
482
+ JSON.stringify(
483
+ createEnvelope(
484
+ MessageType.DISCOVERY_ACK,
485
+ {
486
+ elementCount: elements.length,
487
+ a11yCount: a11y.length,
488
+ interactableCount: interactables.length,
489
+ entityCount: manifestStats.entityCount,
490
+ actionCount: manifestStats.actionCount,
491
+ url,
492
+ ackedAt: new Date().toISOString()
493
+ },
494
+ message.requestId
495
+ )
496
+ )
497
+ );
498
+ return;
499
+ }
500
+
501
+ // ─── Unsupported ──────────────────────────────────────────
502
+
503
+ socket.send(
504
+ JSON.stringify(
505
+ createEnvelope(
506
+ MessageType.ERROR,
507
+ {
508
+ code: "unsupported_type",
509
+ message: `Unsupported message type '${message.type}'.`
510
+ },
511
+ message.requestId
512
+ )
513
+ )
514
+ );
515
+ });
516
+
517
+ socket.on("close", () => {
518
+ clearInterval(statusTimer);
519
+ console.log(`[browserwire-cli] client disconnected ${clientId}`);
520
+
521
+ // If this was the extension socket, reject all pending bridge requests
522
+ if (socket === extensionSocket) {
523
+ extensionSocket = null;
524
+ bridge.rejectAll("Extension disconnected");
525
+ }
526
+ });
527
+
528
+ socket.on("error", (error) => {
529
+ clearInterval(statusTimer);
530
+ console.error(`[browserwire-cli] socket error ${clientId}`, error);
531
+ });
532
+ });
533
+
534
+ wss.on("close", () => {
535
+ console.log("[browserwire-cli] server stopped");
536
+ });
537
+
538
+ return httpServer;
539
+ };