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 +21 -0
- package/README.md +113 -0
- package/cli/api/bridge.js +64 -0
- package/cli/api/openapi.js +175 -0
- package/cli/api/router.js +280 -0
- package/cli/api/swagger-ui.js +26 -0
- package/cli/discovery/classify.js +304 -0
- package/cli/discovery/compile.js +392 -0
- package/cli/discovery/enrich.js +376 -0
- package/cli/discovery/entities.js +356 -0
- package/cli/discovery/llm-client.js +352 -0
- package/cli/discovery/locators.js +326 -0
- package/cli/discovery/perceive.js +476 -0
- package/cli/discovery/session.js +930 -0
- package/cli/discovery/synthesize-workflows.js +295 -0
- package/cli/index.js +63 -0
- package/cli/manifest-store.js +140 -0
- package/cli/server.js +539 -0
- package/extension/background.js +1512 -0
- package/extension/content-script.js +491 -0
- package/extension/discovery.js +495 -0
- package/extension/executor.js +392 -0
- package/extension/icons/icon-128.png +0 -0
- package/extension/icons/icon-16.png +0 -0
- package/extension/icons/icon-48.png +0 -0
- package/extension/manifest.json +33 -0
- package/extension/shared/protocol.js +50 -0
- package/extension/sidepanel.html +277 -0
- package/extension/sidepanel.js +211 -0
- package/extension/vendor/LICENSE +22 -0
- package/extension/vendor/rrweb-record.min.js +84 -0
- package/package.json +49 -0
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>`;
|