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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 GearSec
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,113 @@
1
+ # BrowserWire
2
+
3
+ A contract layer between AI agents and websites. BrowserWire auto-discovers typed browser APIs from live pages so agents never touch the DOM directly — they call versioned, validated, scoped operations like `open_ticket(id: "1234")` through a manifest that defines what exists, what's callable, and how to find targets.
4
+
5
+ ## How it works
6
+
7
+ ```
8
+ Chrome Extension (discovers) CLI Backend (builds manifest) REST API (serves)
9
+ ┌─────────────────────────┐ ┌──────────────────────────┐ ┌──────────────────┐
10
+ │ Content script scans │────▶│ Vision LLM perceives │────▶│ GET /api/sites │
11
+ │ page skeleton + │ WS │ entities & actions │ │ GET /api/sites/ │
12
+ │ screenshot annotation │ │ Locator synthesis │ │ :slug/docs │
13
+ │ │◀────│ Manifest compilation │ │ POST execute │
14
+ │ Sidepanel UI shows │ │ Checkpoint merging │ │ │
15
+ │ discovered API │ │ │ │ │
16
+ └─────────────────────────┘ └──────────────────────────┘ └──────────────────┘
17
+ ```
18
+
19
+ 1. **Extension** runs a skeleton scan on each page, captures an annotated screenshot, and sends both to the CLI backend over WebSocket
20
+ 2. **CLI** uses a vision LLM to perceive entities and actions from the screenshot + skeleton, synthesizes locators, and compiles a typed `BrowserWireManifest`
21
+ 3. **REST API** serves the discovered manifests so agents can query available actions and execute them
22
+
23
+ ## Quick start
24
+
25
+ ```bash
26
+ # Install dependencies
27
+ npm install
28
+
29
+ # Configure your LLM provider
30
+ cp .env.example .env
31
+ # Edit .env with your API key and preferred provider
32
+
33
+ # Load the Chrome extension
34
+ # 1. Open chrome://extensions
35
+ # 2. Enable "Developer mode"
36
+ # 3. Click "Load unpacked" → select the extension/ directory
37
+
38
+ # Start the CLI server
39
+ npm run cli:dev
40
+
41
+ # Browse to any site, click "Start Exploring" in the BrowserWire sidepanel
42
+ # The CLI will discover and build a manifest for the site
43
+
44
+ # View discovered APIs
45
+ open http://localhost:8787/api/sites
46
+ ```
47
+
48
+ ## Extension permissions
49
+
50
+ BrowserWire requires the `<all_urls>` permission because it needs to inspect whatever site the user navigates to during discovery. The extension only activates when you explicitly start an exploration session — it does not run in the background or send data anywhere except the local CLI server.
51
+
52
+ ## Project structure
53
+
54
+ | Directory | Description |
55
+ |-----------|-------------|
56
+ | `cli/` | Node.js CLI server — WebSocket handler, discovery pipeline, REST API |
57
+ | `cli/discovery/` | Discovery stages: perception, locator synthesis, compilation, enrichment |
58
+ | `cli/api/` | REST API router and bridge to discovery sessions |
59
+ | `extension/` | Chrome extension — content script, background worker, sidepanel UI |
60
+ | `extension/shared/` | Shared protocol definitions (message types, envelopes) |
61
+ | `src/contract-dsl/` | TypeScript contract DSL — manifest types, validation, compatibility, migration |
62
+ | `tests/` | Test suite (vitest) |
63
+ | `docs/` | Documentation — architecture (implemented subsystems) and design (speculative) |
64
+
65
+ ## Configuration
66
+
67
+ BrowserWire uses environment variables for LLM configuration. Copy `.env.example` to `.env` and configure:
68
+
69
+ | Variable | Description | Required |
70
+ |----------|-------------|----------|
71
+ | `BROWSERWIRE_LLM_PROVIDER` | LLM provider: `openai`, `anthropic`, `gemini`, `ollama` | Yes |
72
+ | `BROWSERWIRE_LLM_API_KEY` | API key for the provider | Yes (except ollama) |
73
+ | `BROWSERWIRE_LLM_MODEL` | Model name (default varies by provider) | No |
74
+ | `BROWSERWIRE_LLM_BASE_URL` | Custom endpoint URL (for ollama or proxies) | No |
75
+
76
+ ### Provider defaults
77
+
78
+ | Provider | Default model | Default endpoint |
79
+ |----------|--------------|-----------------|
80
+ | `openai` | `gpt-4o` | `https://api.openai.com/v1` |
81
+ | `anthropic` | `claude-sonnet-4-20250514` | `https://api.anthropic.com` |
82
+ | `gemini` | `gemini-2.5-flash` | `https://generativelanguage.googleapis.com/v1beta/openai` |
83
+ | `ollama` | `llama3` | `http://localhost:11434` |
84
+
85
+ ## API usage
86
+
87
+ Once the CLI server is running and you've explored a site:
88
+
89
+ ```bash
90
+ # List all discovered sites
91
+ curl http://localhost:8787/api/sites
92
+
93
+ # Get the manifest/docs for a specific site
94
+ curl http://localhost:8787/api/sites/example-com/docs
95
+
96
+ # Execute an action (via the extension bridge)
97
+ curl -X POST http://localhost:8787/api/sites/example-com/execute \
98
+ -H "Content-Type: application/json" \
99
+ -d '{"actionId": "action_submit_login", "inputs": {"email": "user@example.com"}}'
100
+ ```
101
+
102
+ ## Contributing
103
+
104
+ PRs welcome! Please run tests before submitting:
105
+
106
+ ```bash
107
+ npm test
108
+ npm run typecheck
109
+ ```
110
+
111
+ ## License
112
+
113
+ [MIT](LICENSE)
@@ -0,0 +1,64 @@
1
+ /**
2
+ * bridge.js — HTTP-to-WebSocket request/response bridge
3
+ *
4
+ * Mirrors the request-response pattern from the old SDK runtime.
5
+ * Each HTTP request gets a unique requestId, sends a WS message,
6
+ * and awaits a matching response.
7
+ */
8
+
9
+ import { createEnvelope } from "../../extension/shared/protocol.js";
10
+
11
+ const DEFAULT_TIMEOUT_MS = 30000;
12
+
13
+ export const createBridge = () => {
14
+ /** @type {Map<string, { resolve: Function, reject: Function, timer: ReturnType<typeof setTimeout> }>} */
15
+ const pending = new Map();
16
+
17
+ /**
18
+ * Send a WS message and await a matching response by requestId.
19
+ */
20
+ const sendAndAwait = (socket, type, payload, timeoutMs = DEFAULT_TIMEOUT_MS) =>
21
+ new Promise((resolve, reject) => {
22
+ if (!socket || socket.readyState !== 1) {
23
+ reject(new Error("Extension not connected"));
24
+ return;
25
+ }
26
+
27
+ const requestId = crypto.randomUUID();
28
+
29
+ const timer = setTimeout(() => {
30
+ pending.delete(requestId);
31
+ reject(new Error(`Request ${type} timed out after ${timeoutMs}ms`));
32
+ }, timeoutMs);
33
+
34
+ pending.set(requestId, { resolve, reject, timer });
35
+ socket.send(JSON.stringify(createEnvelope(type, payload, requestId)));
36
+ });
37
+
38
+ /**
39
+ * Check if an incoming WS message matches a pending request.
40
+ * Returns true if it was consumed.
41
+ */
42
+ const handleWsResult = (message) => {
43
+ if (!message.requestId || !pending.has(message.requestId)) return false;
44
+
45
+ const req = pending.get(message.requestId);
46
+ pending.delete(message.requestId);
47
+ clearTimeout(req.timer);
48
+ req.resolve(message.payload);
49
+ return true;
50
+ };
51
+
52
+ /**
53
+ * Reject all pending requests (e.g. on disconnect).
54
+ */
55
+ const rejectAll = (reason) => {
56
+ for (const [, req] of pending) {
57
+ clearTimeout(req.timer);
58
+ req.reject(new Error(reason));
59
+ }
60
+ pending.clear();
61
+ };
62
+
63
+ return { sendAndAwait, handleWsResult, rejectAll };
64
+ };
@@ -0,0 +1,175 @@
1
+ /**
2
+ * openapi.js — OpenAPI 3.0.3 spec generator from BrowserWire manifests
3
+ */
4
+
5
+ const sanitizeName = (name) =>
6
+ (name || "unknown")
7
+ .toLowerCase()
8
+ .replace(/[^a-z0-9_]+/g, "_")
9
+ .replace(/^_+|_+$/g, "");
10
+
11
+ const inputsToSchema = (inputs) => {
12
+ if (!inputs || inputs.length === 0) return null;
13
+ const properties = {};
14
+ for (const input of inputs) {
15
+ properties[input.name] = {
16
+ type: input.type || "string",
17
+ description: input.description || ""
18
+ };
19
+ }
20
+ return { type: "object", properties };
21
+ };
22
+
23
+ export const generateOpenApiSpec = (manifest, { host = "127.0.0.1", port = 8787, pathPrefix = "" } = {}) => {
24
+ const actions = manifest.actions || [];
25
+ const views = manifest.views || [];
26
+ const workflows = manifest.workflowActions || [];
27
+ const entities = manifest.entities || [];
28
+
29
+ const paths = {};
30
+
31
+ // Manifest endpoint
32
+ paths[`${pathPrefix}/manifest`] = {
33
+ get: {
34
+ summary: "Raw manifest JSON",
35
+ operationId: "getManifest",
36
+ tags: ["System"],
37
+ responses: {
38
+ 200: { description: "Full manifest", content: { "application/json": { schema: { type: "object" } } } }
39
+ }
40
+ }
41
+ };
42
+
43
+ // Actions
44
+ for (const action of actions) {
45
+ const name = sanitizeName(action.semanticName || action.name);
46
+ const path = `${pathPrefix}/actions/${name}`;
47
+ const bodySchema = inputsToSchema(action.inputs);
48
+
49
+ paths[path] = {
50
+ post: {
51
+ summary: action.description || action.name,
52
+ operationId: `action_${name}`,
53
+ tags: ["Actions"],
54
+ ...(bodySchema ? {
55
+ requestBody: {
56
+ required: true,
57
+ content: { "application/json": { schema: bodySchema } }
58
+ }
59
+ } : {}),
60
+ responses: {
61
+ 200: {
62
+ description: "Action result",
63
+ content: { "application/json": { schema: {
64
+ type: "object",
65
+ properties: {
66
+ ok: { type: "boolean" },
67
+ result: { type: "object" },
68
+ error: { type: "string" }
69
+ }
70
+ }}}
71
+ }
72
+ }
73
+ }
74
+ };
75
+ }
76
+
77
+ // Views
78
+ for (const view of views) {
79
+ const name = sanitizeName(view.semanticName || view.name);
80
+ const path = `${pathPrefix}/views/${name}`;
81
+
82
+ paths[path] = {
83
+ get: {
84
+ summary: view.description || view.name,
85
+ operationId: `view_${name}`,
86
+ tags: ["Views"],
87
+ responses: {
88
+ 200: {
89
+ description: "View data",
90
+ content: { "application/json": { schema: {
91
+ type: "object",
92
+ properties: {
93
+ ok: { type: "boolean" },
94
+ data: view.isList ? { type: "array", items: { type: "object" } } : { type: "object" },
95
+ count: { type: "integer" }
96
+ }
97
+ }}}
98
+ }
99
+ }
100
+ }
101
+ };
102
+ }
103
+
104
+ // Workflows
105
+ for (const workflow of workflows) {
106
+ const name = sanitizeName(workflow.name);
107
+ const path = `${pathPrefix}/workflows/${name}`;
108
+ const bodySchema = inputsToSchema(workflow.inputs);
109
+
110
+ paths[path] = {
111
+ post: {
112
+ summary: workflow.description || workflow.name,
113
+ operationId: `workflow_${name}`,
114
+ tags: ["Workflows"],
115
+ ...(bodySchema ? {
116
+ requestBody: {
117
+ required: true,
118
+ content: { "application/json": { schema: bodySchema } }
119
+ }
120
+ } : {}),
121
+ responses: {
122
+ 200: {
123
+ description: "Workflow result",
124
+ content: { "application/json": { schema: {
125
+ type: "object",
126
+ properties: {
127
+ ok: { type: "boolean" },
128
+ data: { type: "object" },
129
+ outcome: { type: "string" },
130
+ error: { type: "string" }
131
+ }
132
+ }}}
133
+ }
134
+ }
135
+ }
136
+ };
137
+ }
138
+
139
+ // Entities
140
+ for (const entity of entities) {
141
+ const name = sanitizeName(entity.semanticName || entity.name);
142
+ const path = `${pathPrefix}/entities/${name}`;
143
+
144
+ paths[path] = {
145
+ get: {
146
+ summary: `Read state of ${entity.semanticName || entity.name}`,
147
+ operationId: `entity_${name}`,
148
+ tags: ["Entities"],
149
+ responses: {
150
+ 200: {
151
+ description: "Entity state",
152
+ content: { "application/json": { schema: {
153
+ type: "object",
154
+ properties: {
155
+ ok: { type: "boolean" },
156
+ state: { type: "object" }
157
+ }
158
+ }}}
159
+ }
160
+ }
161
+ }
162
+ };
163
+ }
164
+
165
+ return {
166
+ openapi: "3.0.3",
167
+ info: {
168
+ title: `BrowserWire API — ${manifest.domain || "Unknown"}`,
169
+ description: manifest.domainDescription || "Auto-discovered browser API",
170
+ version: manifest.manifestVersion || "1.0.0"
171
+ },
172
+ servers: [{ url: `http://${host}:${port}` }],
173
+ paths
174
+ };
175
+ };
@@ -0,0 +1,280 @@
1
+ /**
2
+ * router.js — Lightweight HTTP router for the BrowserWire REST API
3
+ *
4
+ * All operational routes live under /api/sites/:slug/...
5
+ * No implicit "active site" concept.
6
+ */
7
+
8
+ import { MessageType } from "../../extension/shared/protocol.js";
9
+ import { generateOpenApiSpec } from "./openapi.js";
10
+ import { swaggerUiHtml } from "./swagger-ui.js";
11
+
12
+ const CORS_HEADERS = {
13
+ "Access-Control-Allow-Origin": "*",
14
+ "Access-Control-Allow-Methods": "GET, POST, OPTIONS",
15
+ "Access-Control-Allow-Headers": "Content-Type"
16
+ };
17
+
18
+ const json = (res, status, body) => {
19
+ const data = JSON.stringify(body);
20
+ res.writeHead(status, { ...CORS_HEADERS, "Content-Type": "application/json" });
21
+ res.end(data);
22
+ };
23
+
24
+ const html = (res, status, body) => {
25
+ res.writeHead(status, { ...CORS_HEADERS, "Content-Type": "text/html" });
26
+ res.end(body);
27
+ };
28
+
29
+ const readBody = (req) =>
30
+ new Promise((resolve, reject) => {
31
+ const chunks = [];
32
+ req.on("data", (c) => chunks.push(c));
33
+ req.on("end", () => {
34
+ try {
35
+ const raw = Buffer.concat(chunks).toString();
36
+ resolve(raw.length > 0 ? JSON.parse(raw) : {});
37
+ } catch {
38
+ reject(new Error("Invalid JSON body"));
39
+ }
40
+ });
41
+ req.on("error", reject);
42
+ });
43
+
44
+ /**
45
+ * Build name->definition lookup maps from a manifest.
46
+ */
47
+ const buildLookups = (manifest) => {
48
+ const actionMap = new Map();
49
+ for (const action of manifest.actions || []) {
50
+ const name = sanitize(action.semanticName || action.name);
51
+ actionMap.set(name, action);
52
+ }
53
+
54
+ const viewMap = new Map();
55
+ for (const view of manifest.views || []) {
56
+ const name = sanitize(view.semanticName || view.name);
57
+ viewMap.set(name, view);
58
+ }
59
+
60
+ const workflowMap = new Map();
61
+ for (const workflow of manifest.workflowActions || []) {
62
+ const name = sanitize(workflow.name);
63
+ workflowMap.set(name, workflow);
64
+ }
65
+
66
+ const entityMap = new Map();
67
+ for (const entity of manifest.entities || []) {
68
+ const name = sanitize(entity.semanticName || entity.name);
69
+ entityMap.set(name, entity);
70
+ }
71
+
72
+ return { actionMap, viewMap, workflowMap, entityMap };
73
+ };
74
+
75
+ const sanitize = (name) =>
76
+ (name || "unknown")
77
+ .toLowerCase()
78
+ .replace(/[^a-z0-9_]+/g, "_")
79
+ .replace(/^_+|_+$/g, "");
80
+
81
+ /**
82
+ * Render an HTML landing page listing all known sites with links to their docs.
83
+ */
84
+ const landingPageHtml = (sites, host, port) => {
85
+ const rows = sites.map((s) => {
86
+ const docsUrl = `/api/sites/${s.slug}/docs`;
87
+ return `<tr>
88
+ <td><a href="${docsUrl}">${s.slug}</a></td>
89
+ <td>${s.origin}</td>
90
+ <td>${s.entityCount || 0}</td>
91
+ <td>${s.actionCount || 0}</td>
92
+ <td>${s.viewCount || 0}</td>
93
+ </tr>`;
94
+ }).join("\n");
95
+
96
+ return `<!DOCTYPE html>
97
+ <html><head><title>BrowserWire API</title>
98
+ <style>
99
+ body { font-family: system-ui, sans-serif; max-width: 800px; margin: 40px auto; padding: 0 20px; }
100
+ table { border-collapse: collapse; width: 100%; margin-top: 20px; }
101
+ th, td { text-align: left; padding: 8px 12px; border-bottom: 1px solid #ddd; }
102
+ th { background: #f5f5f5; }
103
+ a { color: #0066cc; }
104
+ .empty { color: #888; margin-top: 20px; }
105
+ </style></head>
106
+ <body>
107
+ <h1>BrowserWire API</h1>
108
+ ${sites.length > 0 ? `<table>
109
+ <thead><tr><th>Site</th><th>Origin</th><th>Entities</th><th>Actions</th><th>Views</th></tr></thead>
110
+ <tbody>${rows}</tbody>
111
+ </table>` : `<p class="empty">No sites discovered yet. Run discovery from the browser extension to get started.</p>`}
112
+ </body></html>`;
113
+ };
114
+
115
+ /**
116
+ * Create the HTTP request handler.
117
+ *
118
+ * @param {{ getManifestBySlug: (slug: string) => object|null, listSites: () => Array, bridge: object, getSocket: () => WebSocket|null, host: string, port: number }} deps
119
+ * @returns {(req: import("http").IncomingMessage, res: import("http").ServerResponse) => void}
120
+ */
121
+ export const createHttpHandler = ({ getManifestBySlug, listSites, bridge, getSocket, host, port }) => {
122
+ return async (req, res) => {
123
+ // CORS preflight
124
+ if (req.method === "OPTIONS") {
125
+ res.writeHead(204, CORS_HEADERS);
126
+ res.end();
127
+ return;
128
+ }
129
+
130
+ const url = new URL(req.url, `http://${req.headers.host || "localhost"}`);
131
+ const path = url.pathname;
132
+
133
+ // ── System routes ──
134
+
135
+ if (path === "/api/health" && req.method === "GET") {
136
+ const socket = getSocket();
137
+ return json(res, 200, {
138
+ ok: true,
139
+ extensionConnected: socket !== null && socket.readyState === 1
140
+ });
141
+ }
142
+
143
+ if (path === "/api/sites" && req.method === "GET") {
144
+ const sites = listSites();
145
+ return json(res, 200, { ok: true, sites });
146
+ }
147
+
148
+ // ── Landing page listing all sites ──
149
+
150
+ if (path === "/api/docs" && req.method === "GET") {
151
+ const sites = listSites();
152
+ return html(res, 200, landingPageHtml(sites, host, port));
153
+ }
154
+
155
+ // ── Site-scoped routes: /api/sites/:slug/... ──
156
+
157
+ const siteMatch = path.match(/^\/api\/sites\/([^/]+)(\/.*)?$/);
158
+ if (siteMatch) {
159
+ const slug = siteMatch[1];
160
+ const subPath = siteMatch[2] || "";
161
+
162
+ const manifest = getManifestBySlug(slug);
163
+ if (!manifest) {
164
+ return json(res, 404, { ok: false, error: `No manifest found for site '${slug}'` });
165
+ }
166
+
167
+ // GET /api/sites/:slug/manifest
168
+ if (subPath === "/manifest" && req.method === "GET") {
169
+ return json(res, 200, manifest);
170
+ }
171
+
172
+ // GET /api/sites/:slug/openapi.json
173
+ if (subPath === "/openapi.json" && req.method === "GET") {
174
+ return json(res, 200, generateOpenApiSpec(manifest, { host, port, pathPrefix: `/api/sites/${slug}` }));
175
+ }
176
+
177
+ // GET /api/sites/:slug/docs
178
+ if (subPath === "/docs" && req.method === "GET") {
179
+ return html(res, 200, swaggerUiHtml(`/api/sites/${slug}/openapi.json`));
180
+ }
181
+
182
+ // Routes below require an extension connection
183
+ const socket = getSocket();
184
+ if (!socket || socket.readyState !== 1) {
185
+ return json(res, 503, { ok: false, error: "Extension not connected" });
186
+ }
187
+
188
+ const lookups = buildLookups(manifest);
189
+
190
+ // POST /api/sites/:slug/actions/:name
191
+ const actionMatch = subPath.match(/^\/actions\/([^/]+)$/);
192
+ if (actionMatch && req.method === "POST") {
193
+ const action = lookups.actionMap.get(actionMatch[1]);
194
+ if (!action) return json(res, 404, { ok: false, error: `Action '${actionMatch[1]}' not found` });
195
+
196
+ let body = {};
197
+ try { body = await readBody(req); } catch { return json(res, 400, { ok: false, error: "Invalid JSON body" }); }
198
+
199
+ try {
200
+ const result = await bridge.sendAndAwait(socket, MessageType.EXECUTE_ACTION, {
201
+ actionId: action.id,
202
+ strategies: action.locatorSet?.strategies || [],
203
+ interactionKind: action.interactionKind || "click",
204
+ inputs: body
205
+ }, 30000);
206
+ return json(res, 200, result);
207
+ } catch (err) {
208
+ return json(res, 500, { ok: false, error: err.message });
209
+ }
210
+ }
211
+
212
+ // GET /api/sites/:slug/views/:name
213
+ const viewMatch = subPath.match(/^\/views\/([^/]+)$/);
214
+ if (viewMatch && req.method === "GET") {
215
+ const view = lookups.viewMap.get(viewMatch[1]);
216
+ if (!view) return json(res, 404, { ok: false, error: `View '${viewMatch[1]}' not found` });
217
+
218
+ try {
219
+ const result = await bridge.sendAndAwait(socket, MessageType.READ_ENTITY, {
220
+ viewId: view.id,
221
+ containerLocator: view.containerLocator?.strategies || [],
222
+ itemLocator: view.itemLocator || null,
223
+ fields: view.fields || [],
224
+ isList: view.isList || false
225
+ }, 30000);
226
+ return json(res, 200, result);
227
+ } catch (err) {
228
+ return json(res, 500, { ok: false, error: err.message });
229
+ }
230
+ }
231
+
232
+ // POST /api/sites/:slug/workflows/:name
233
+ const workflowMatch = subPath.match(/^\/workflows\/([^/]+)$/);
234
+ if (workflowMatch && req.method === "POST") {
235
+ const workflow = lookups.workflowMap.get(workflowMatch[1]);
236
+ if (!workflow) return json(res, 404, { ok: false, error: `Workflow '${workflowMatch[1]}' not found` });
237
+
238
+ let body = {};
239
+ try { body = await readBody(req); } catch { return json(res, 400, { ok: false, error: "Invalid JSON body" }); }
240
+
241
+ try {
242
+ const result = await bridge.sendAndAwait(socket, MessageType.EXECUTE_WORKFLOW, {
243
+ steps: workflow.steps || [],
244
+ outcomes: workflow.outcomes || {},
245
+ inputs: body
246
+ }, 60000);
247
+ return json(res, 200, result);
248
+ } catch (err) {
249
+ return json(res, 500, { ok: false, error: err.message });
250
+ }
251
+ }
252
+
253
+ // GET /api/sites/:slug/entities/:name
254
+ const entityMatch = subPath.match(/^\/entities\/([^/]+)$/);
255
+ if (entityMatch && req.method === "GET") {
256
+ const entity = lookups.entityMap.get(entityMatch[1]);
257
+ if (!entity) return json(res, 404, { ok: false, error: `Entity '${entityMatch[1]}' not found` });
258
+
259
+ const entityAction = (manifest.actions || []).find((a) => a.entityId === entity.id);
260
+ const strategies = entityAction?.locatorSet?.strategies || [];
261
+
262
+ try {
263
+ const result = await bridge.sendAndAwait(socket, MessageType.READ_ENTITY, {
264
+ entityId: entity.id,
265
+ strategies
266
+ }, 30000);
267
+ return json(res, 200, result);
268
+ } catch (err) {
269
+ return json(res, 500, { ok: false, error: err.message });
270
+ }
271
+ }
272
+
273
+ // Unknown sub-path under a valid site
274
+ return json(res, 404, { ok: false, error: "Not found" });
275
+ }
276
+
277
+ // ── Fallback ──
278
+ json(res, 404, { ok: false, error: "Not found" });
279
+ };
280
+ };
@@ -0,0 +1,26 @@
1
+ /**
2
+ * swagger-ui.js — Returns an HTML string that renders Swagger UI
3
+ * pointing at the local OpenAPI spec. Zero npm dependencies.
4
+ */
5
+
6
+ export const swaggerUiHtml = (specUrl = "/api/openapi.json") => `<!doctype html>
7
+ <html lang="en">
8
+ <head>
9
+ <meta charset="utf-8" />
10
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
11
+ <title>BrowserWire API Docs</title>
12
+ <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/swagger-ui-dist@5/swagger-ui.css" />
13
+ </head>
14
+ <body>
15
+ <div id="swagger-ui"></div>
16
+ <script src="https://cdn.jsdelivr.net/npm/swagger-ui-dist@5/swagger-ui-bundle.js"></script>
17
+ <script>
18
+ SwaggerUIBundle({
19
+ url: "${specUrl}",
20
+ dom_id: "#swagger-ui",
21
+ presets: [SwaggerUIBundle.presets.apis, SwaggerUIBundle.SwaggerUIStandalonePreset],
22
+ layout: "BaseLayout"
23
+ });
24
+ </script>
25
+ </body>
26
+ </html>`;