@digital-gravy/etch-connector 0.1.1
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 +26 -0
- package/README.md +253 -0
- package/dist/bridge-server.js +242 -0
- package/dist/cdp-client.js +285 -0
- package/dist/cli.js +452 -0
- package/dist/control-server.js +108 -0
- package/dist/index.js +11 -0
- package/dist/manual.js +2 -0
- package/dist/protocol.js +38 -0
- package/package.json +70 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
Copyright (c) 2026 Digital Gravy LLC. All rights reserved.
|
|
2
|
+
|
|
3
|
+
Permission to install and use this software (the "Software") for its intended
|
|
4
|
+
purpose, including via package managers such as npm, is granted free of charge
|
|
5
|
+
solely to persons who hold a valid and active "Etch" license. This permission
|
|
6
|
+
is conditional upon, and continues only for so long as, the user maintains a
|
|
7
|
+
valid and active Etch license; it terminates automatically if that license
|
|
8
|
+
lapses, expires, or is revoked.
|
|
9
|
+
|
|
10
|
+
All other rights are reserved by Digital Gravy LLC. Without the prior written
|
|
11
|
+
permission of Digital Gravy LLC, no person may copy, modify, merge, publish,
|
|
12
|
+
distribute, sublicense, sell, reverse engineer, decompile, disassemble, or
|
|
13
|
+
create derivative works of the Software or its source code, in whole or in
|
|
14
|
+
part.
|
|
15
|
+
|
|
16
|
+
Unauthorized reproduction, modification, or distribution of this software, or
|
|
17
|
+
any portion of it, may result in civil and criminal penalties, and will be
|
|
18
|
+
prosecuted to the maximum extent possible under the law.
|
|
19
|
+
|
|
20
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
21
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
22
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
23
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
24
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
25
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
26
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
# etch-connector
|
|
2
|
+
|
|
3
|
+
A tiny local bridge that lets an AI harness — Claude Code, Cowork, or any tool
|
|
4
|
+
that can run a shell command — **drive a live Etch builder tab** by evaluating
|
|
5
|
+
scripts against the [`etch-public-api`](https://www.npmjs.com/package/@digital-gravy/etch-public-api)
|
|
6
|
+
surface (`window.etch.*`).
|
|
7
|
+
|
|
8
|
+
Etch's scriptable API only exists _inside the browser_. There is no server you
|
|
9
|
+
can call directly. So this connector inverts the usual direction: the browser
|
|
10
|
+
**dials out** to the connector over a WebSocket (the same way Chrome remote
|
|
11
|
+
debugging works, but in reverse), and the connector relays scripts into the tab
|
|
12
|
+
and streams the result back.
|
|
13
|
+
|
|
14
|
+
```
|
|
15
|
+
Claude Code / Cowork
|
|
16
|
+
│ shell: etch-connector eval -t homepage "return ..."
|
|
17
|
+
▼
|
|
18
|
+
etch-connector (the `serve` daemon)
|
|
19
|
+
│ ▲ WebSocket (tab dials in via etch.connectTo)
|
|
20
|
+
▼ │
|
|
21
|
+
Etch builder tab ──► window.etch.*
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## Status
|
|
25
|
+
|
|
26
|
+
This is an **eval relay**: the harness sends a JavaScript snippet, the page runs
|
|
27
|
+
it as an async function, and the return value + console output come back. It is
|
|
28
|
+
intentionally _not_ an MCP server yet, but it is built so that becoming one is a
|
|
29
|
+
thin add — see [Going to MCP](#going-to-mcp-later).
|
|
30
|
+
|
|
31
|
+
Scripts run in one of two modes, enforced by the page:
|
|
32
|
+
|
|
33
|
+
- **`safe`** (default) — the script sees only `etch` and the standard JS
|
|
34
|
+
built-ins. `window`, `document`, `fetch`, storage, `eval`, … are withheld.
|
|
35
|
+
- **`unsafe`** — the script runs with full access to the page. Opt in
|
|
36
|
+
explicitly.
|
|
37
|
+
|
|
38
|
+
## Install
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
bun install # or npm install
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Use
|
|
45
|
+
|
|
46
|
+
### 1. Start the daemon (once)
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
bun run serve
|
|
50
|
+
# bridge ws://127.0.0.1:7331
|
|
51
|
+
# control http://127.0.0.1:7332
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
### 2. Connect a tab
|
|
55
|
+
|
|
56
|
+
Open an Etch builder page and, in the browser console:
|
|
57
|
+
|
|
58
|
+
```js
|
|
59
|
+
etch.connectTo('homepage');
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
(`connectTo` is part of the public API; it opens the WebSocket back to the
|
|
63
|
+
daemon and registers this tab under the given name. Pass `{ port: <n> }` if you
|
|
64
|
+
started the daemon on a non-default WebSocket port.)
|
|
65
|
+
|
|
66
|
+
### 3. Drive it
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
# list connected tabs
|
|
70
|
+
etch-connector tabs
|
|
71
|
+
|
|
72
|
+
# run a script (value → stdout, console output → stderr).
|
|
73
|
+
# Default is safe mode: reference the API as `etch.*`.
|
|
74
|
+
etch-connector eval -t homepage "return etch.apiVersion"
|
|
75
|
+
|
|
76
|
+
# multi-line / from a file / from stdin
|
|
77
|
+
etch-connector eval -t homepage -f ./script.js
|
|
78
|
+
echo "return await etch.blocks.getTree()" | etch-connector eval -t homepage
|
|
79
|
+
|
|
80
|
+
# full page access (opt in)
|
|
81
|
+
etch-connector eval -t homepage --unsafe "return document.title"
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
The script body runs as an **async function**, so it can `return` a value and
|
|
85
|
+
use top-level `await`. In safe mode write scripts against the `etch-public-api`
|
|
86
|
+
surface via the `etch` binding (`window` is not available). Examples:
|
|
87
|
+
|
|
88
|
+
```js
|
|
89
|
+
return etch.apiVersion;
|
|
90
|
+
return await etch.blocks.getTree();
|
|
91
|
+
etch.blocks.select('block-id-123');
|
|
92
|
+
const groups = await etch.fields.listGroupsAsync();
|
|
93
|
+
return groups.length;
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
### Modes
|
|
97
|
+
|
|
98
|
+
| Mode | In scope | Use for |
|
|
99
|
+
| ---------------- | ------------------------------------ | ------------------------------- |
|
|
100
|
+
| `safe` (default) | `etch` + standard JS built-ins | Driving the builder via the API |
|
|
101
|
+
| `unsafe` | everything (`window`, `document`, …) | Debugging, page internals |
|
|
102
|
+
|
|
103
|
+
Pick per call with `--safe` / `--unsafe`, or change the daemon default with
|
|
104
|
+
`serve --unsafe`. A per-call flag overrides the daemon default.
|
|
105
|
+
|
|
106
|
+
### Output contract (for agents)
|
|
107
|
+
|
|
108
|
+
`eval` keeps stdout clean so a harness can capture it as the result:
|
|
109
|
+
|
|
110
|
+
- **stdout** — the script's return value, as pretty JSON.
|
|
111
|
+
- **stderr** — forwarded `console.*` output, prefixed with the level.
|
|
112
|
+
- **exit code** — `0` success · `2` the script threw · `1` operational failure
|
|
113
|
+
(no such tab, ambiguous target, timeout, daemon unreachable).
|
|
114
|
+
|
|
115
|
+
## Documentation (for AI agents)
|
|
116
|
+
|
|
117
|
+
Before writing scripts against `etch.*`, **always pull the current Etch
|
|
118
|
+
documentation first** — the `etch-public-api` surface changes and training data
|
|
119
|
+
may be stale. Fetch the docs, _then_ write scripts.
|
|
120
|
+
|
|
121
|
+
### Preferred: Context7 MCP
|
|
122
|
+
|
|
123
|
+
If a Context7 MCP server is available, fetch the Etch docs through it:
|
|
124
|
+
|
|
125
|
+
1. Call `resolve-library-id` with the library name **`Etch`** (pass the user's
|
|
126
|
+
question alongside it).
|
|
127
|
+
2. From the matches, pick the entry for Etch / the Etch public API — prefer an
|
|
128
|
+
exact name match, and a version-specific id if the user named a version.
|
|
129
|
+
3. Call `query-docs` with the selected library id and the specific question
|
|
130
|
+
(e.g. _"how do I create a block with etch.blocks.create"_).
|
|
131
|
+
4. Write the script using the fetched docs, referencing `etch.*` exactly as the
|
|
132
|
+
docs show.
|
|
133
|
+
|
|
134
|
+
### Fallback: pull the docs manually
|
|
135
|
+
|
|
136
|
+
If no Context7 server is available, fetch the documentation directly from the
|
|
137
|
+
Etch docs site:
|
|
138
|
+
|
|
139
|
+
- **Public API reference:** <https://docs.etchwp.com/public-api> — the
|
|
140
|
+
authoritative surface for `window.etch.*` (`etch.blocks`, `etch.styles`,
|
|
141
|
+
`etch.components`, `etch.fields`, `etch.stylesheets`, …).
|
|
142
|
+
- **Main docs:** <https://docs.etchwp.com> — for everything else about Etch
|
|
143
|
+
(concepts, blocks, components, ACSS, units, …). Follow the links from there to
|
|
144
|
+
the specific page that answers the question.
|
|
145
|
+
|
|
146
|
+
Read the relevant page(s), then write scripts against the documented API. On top
|
|
147
|
+
of the API docs, follow the project-specific conventions in
|
|
148
|
+
[`AGENTS.md`](AGENTS.md).
|
|
149
|
+
|
|
150
|
+
## CLI
|
|
151
|
+
|
|
152
|
+
```
|
|
153
|
+
etch-connector — drive a live Etch tab from the command line
|
|
154
|
+
|
|
155
|
+
Usage:
|
|
156
|
+
etch-connector serve [--ws-port 7331] [--control-port 7332] [--ws-host 127.0.0.1] [--unsafe]
|
|
157
|
+
etch-connector tabs [--json] [--control-port 7332] [--cdp]
|
|
158
|
+
etch-connector eval [code] [-t|--tab name] [-f|--file path] [--timeout ms] [--safe|--unsafe|--cdp]
|
|
159
|
+
etch-connector shot [-t name] [-s|--selector css] [--full] [--jpeg] [-o|--out file] [--freeze=false] (CDP)
|
|
160
|
+
etch-connector html <selector> [-t name] (CDP)
|
|
161
|
+
etch-connector computed <selector> [-t name] [--props a,b,c] (CDP)
|
|
162
|
+
|
|
163
|
+
Two backends:
|
|
164
|
+
bridge (default) the tab dials in via etch.connectTo(); page-sandboxed.
|
|
165
|
+
safe = script sees only "etch" + JS built-ins (default)
|
|
166
|
+
unsafe = full page access (--unsafe per eval, or serve --unsafe)
|
|
167
|
+
cdp connect OUT to a Chrome started with --remote-debugging-port=9222.
|
|
168
|
+
No daemon. Runs at DevTools privilege: full "window", plus screenshots,
|
|
169
|
+
rendered HTML, and computed styles. Opt in with --cdp (or --cdp-port /
|
|
170
|
+
--remote-debugging-port / --cdp-host). Target a tab with -t <url|title substring>.
|
|
171
|
+
|
|
172
|
+
Examples:
|
|
173
|
+
etch-connector serve
|
|
174
|
+
etch-connector tabs
|
|
175
|
+
etch-connector eval -t homepage "return etch.apiVersion"
|
|
176
|
+
echo "return await etch.blocks.getTree()" | etch-connector eval -t homepage
|
|
177
|
+
etch-connector eval -t homepage -f ./script.js
|
|
178
|
+
# CDP backend (Chrome launched with --remote-debugging-port=9222):
|
|
179
|
+
etch-connector tabs --cdp
|
|
180
|
+
etch-connector eval --cdp -t etch "return document.title"
|
|
181
|
+
etch-connector shot --cdp -t etch -s .orbit -o orbit.png
|
|
182
|
+
etch-connector computed --cdp -t etch -s .orbit__chip --props transform,animation-duration
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
When exactly one tab is connected, `-t` is optional. With several tabs you must
|
|
186
|
+
name one.
|
|
187
|
+
|
|
188
|
+
## HTTP control API
|
|
189
|
+
|
|
190
|
+
The daemon also exposes a loopback HTTP API (this is the seam the CLI uses, and
|
|
191
|
+
the same one a future MCP server would call):
|
|
192
|
+
|
|
193
|
+
| Method | Path | Body | Returns |
|
|
194
|
+
| ------ | --------- | ----------------------------------- | ------------ |
|
|
195
|
+
| GET | `/health` | — | `{ ok }` |
|
|
196
|
+
| GET | `/tabs` | — | `{ tabs }` |
|
|
197
|
+
| POST | `/eval` | `{ tab?, code, timeoutMs?, mode? }` | `EvalResult` |
|
|
198
|
+
|
|
199
|
+
## Architecture
|
|
200
|
+
|
|
201
|
+
- [`src/protocol.ts`](src/protocol.ts) — the wire format, mirrored from the
|
|
202
|
+
Etch-side `bridge.ts`. Keep the two in sync.
|
|
203
|
+
- [`src/bridge-server.ts`](src/bridge-server.ts) — `BridgeServer`, the
|
|
204
|
+
transport-agnostic core: owns the sockets, tracks tabs by name, correlates
|
|
205
|
+
requests, handles timeouts and disconnects. **This is the reusable piece.**
|
|
206
|
+
- [`src/control-server.ts`](src/control-server.ts) — the HTTP control API over
|
|
207
|
+
the core.
|
|
208
|
+
- [`src/cli.ts`](src/cli.ts) — the `serve` / `tabs` / `eval` commands.
|
|
209
|
+
|
|
210
|
+
## Going to MCP later
|
|
211
|
+
|
|
212
|
+
Nothing in the transport changes. An MCP server is `new BridgeServer()` plus two
|
|
213
|
+
tool definitions over the same core:
|
|
214
|
+
|
|
215
|
+
```ts
|
|
216
|
+
import { BridgeServer } from 'etch-connector';
|
|
217
|
+
|
|
218
|
+
const bridge = new BridgeServer();
|
|
219
|
+
await bridge.listen();
|
|
220
|
+
|
|
221
|
+
// list_etch_tabs -> bridge.listTabs()
|
|
222
|
+
// eval_etch_script -> bridge.evalInTab(code, { tab })
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
When you want first-class per-tool MCP tools (rather than one eval tool), have
|
|
226
|
+
the Etch tab advertise a tool manifest in its `hello` and register those
|
|
227
|
+
dynamically — keeping a single source of truth in the browser registry.
|
|
228
|
+
|
|
229
|
+
## Security
|
|
230
|
+
|
|
231
|
+
Two layers, neither a substitute for the other:
|
|
232
|
+
|
|
233
|
+
**Transport.** Everything binds to `127.0.0.1`, so any local process can reach
|
|
234
|
+
the daemon. Run it only on a trusted machine and do not expose the ports beyond
|
|
235
|
+
loopback.
|
|
236
|
+
|
|
237
|
+
**Execution.** `safe` mode (the default) runs the script inside a `with`-bound
|
|
238
|
+
Proxy that exposes only `etch` plus the standard JS built-ins and throws on
|
|
239
|
+
`window`, `document`, `fetch`, storage, `eval`, `Function`, … This is a
|
|
240
|
+
**guardrail, not a hardened sandbox**: because the script shares the page's JS
|
|
241
|
+
realm, a _determined_ author can still reach globals through prototype /
|
|
242
|
+
constructor chains (e.g. `({}).constructor.constructor`). Safe mode reliably
|
|
243
|
+
keeps casual and AI-authored scripts on the `etch` surface and closes the
|
|
244
|
+
obvious escape hatches; it does **not** contain hostile code.
|
|
245
|
+
|
|
246
|
+
`unsafe` mode runs the script with full, unconstrained access to the page — only
|
|
247
|
+
use it for trusted debugging.
|
|
248
|
+
|
|
249
|
+
For a true boundary against adversarial scripts you would need to harden the
|
|
250
|
+
realm (SES `lockdown()`), which freezes all intrinsics app-wide and is out of
|
|
251
|
+
scope for this relay. The enforcement lives in the Etch page (`bridge.ts`), not
|
|
252
|
+
this connector — the connector only carries the requested mode; a string filter
|
|
253
|
+
here would be theater.
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The interface-agnostic core of etch-connector.
|
|
3
|
+
*
|
|
4
|
+
* `BridgeServer` owns the WebSocket endpoint that Etch tabs dial into via
|
|
5
|
+
* `etch.connectTo(...)`, tracks every connected tab by the name it registered
|
|
6
|
+
* under, and exposes a single capability: {@link BridgeServer.evalInTab}.
|
|
7
|
+
*
|
|
8
|
+
* Everything that drives the connector — the HTTP control API and CLI today, an
|
|
9
|
+
* MCP server tomorrow — is a thin adapter over this class. Keeping the transport
|
|
10
|
+
* concerns here (sockets, correlation, timeouts, cleanup) means a future MCP
|
|
11
|
+
* server is just `new BridgeServer()` plus tool definitions; no protocol code
|
|
12
|
+
* moves.
|
|
13
|
+
*/
|
|
14
|
+
import { EventEmitter } from 'node:events';
|
|
15
|
+
import { randomUUID } from 'node:crypto';
|
|
16
|
+
import { WebSocketServer } from 'ws';
|
|
17
|
+
import { DEFAULT_HOST, DEFAULT_WS_PORT, parsePageMessage } from './protocol.js';
|
|
18
|
+
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
19
|
+
/**
|
|
20
|
+
* Raised for operational failures (no matching tab, ambiguous target, timeout,
|
|
21
|
+
* disconnect) so callers can distinguish them from a script that threw.
|
|
22
|
+
*/
|
|
23
|
+
export class BridgeError extends Error {
|
|
24
|
+
code;
|
|
25
|
+
constructor(message, code) {
|
|
26
|
+
super(message);
|
|
27
|
+
this.code = code;
|
|
28
|
+
this.name = 'BridgeError';
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
export class BridgeServer extends EventEmitter {
|
|
32
|
+
/* eslint-enable @typescript-eslint/no-unsafe-declaration-merging */
|
|
33
|
+
host;
|
|
34
|
+
port;
|
|
35
|
+
wss = null;
|
|
36
|
+
sessions = new Set();
|
|
37
|
+
constructor(options = {}) {
|
|
38
|
+
super();
|
|
39
|
+
this.host = options.host ?? DEFAULT_HOST;
|
|
40
|
+
this.port = options.port ?? DEFAULT_WS_PORT;
|
|
41
|
+
}
|
|
42
|
+
/** Start listening. Resolves with the bound address (useful when port is 0). */
|
|
43
|
+
listen() {
|
|
44
|
+
return new Promise((resolve, reject) => {
|
|
45
|
+
const wss = new WebSocketServer({
|
|
46
|
+
host: this.host,
|
|
47
|
+
port: this.port
|
|
48
|
+
});
|
|
49
|
+
wss.on('connection', (ws) => this.onConnection(ws));
|
|
50
|
+
wss.on('error', reject);
|
|
51
|
+
wss.on('listening', () => {
|
|
52
|
+
const address = wss.address();
|
|
53
|
+
const port = typeof address === 'object' && address
|
|
54
|
+
? address.port
|
|
55
|
+
: this.port;
|
|
56
|
+
resolve({ host: this.host, port });
|
|
57
|
+
});
|
|
58
|
+
this.wss = wss;
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
/** Stop listening and tear down every session. */
|
|
62
|
+
close() {
|
|
63
|
+
return new Promise((resolve) => {
|
|
64
|
+
for (const session of this.sessions) {
|
|
65
|
+
this.failPending(session, new BridgeError('connector shutting down', 'DISCONNECTED'));
|
|
66
|
+
try {
|
|
67
|
+
session.ws.terminate();
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
/* already gone */
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
this.sessions.clear();
|
|
74
|
+
if (!this.wss)
|
|
75
|
+
return resolve();
|
|
76
|
+
this.wss.close(() => resolve());
|
|
77
|
+
this.wss = null;
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
/** Snapshot of every tab that has completed its handshake. */
|
|
81
|
+
listTabs() {
|
|
82
|
+
const tabs = [];
|
|
83
|
+
for (const session of this.sessions) {
|
|
84
|
+
if (session.info)
|
|
85
|
+
tabs.push(session.info);
|
|
86
|
+
}
|
|
87
|
+
return tabs.sort((a, b) => b.connectedAt - a.connectedAt);
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Evaluate `code` in a tab as an async function body (so it can `return` and
|
|
91
|
+
* use top-level `await`, and reach `window.etch`).
|
|
92
|
+
*
|
|
93
|
+
* @throws {BridgeError} when no tab matches, the target is ambiguous, the tab
|
|
94
|
+
* disconnects, or the eval times out.
|
|
95
|
+
*/
|
|
96
|
+
evalInTab(code, options = {}) {
|
|
97
|
+
const id = randomUUID();
|
|
98
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
99
|
+
return new Promise((resolve, reject) => {
|
|
100
|
+
// Inside the executor so a bad target rejects the promise rather than
|
|
101
|
+
// throwing synchronously to the caller.
|
|
102
|
+
const session = this.resolveTarget(options.tab);
|
|
103
|
+
const timer = setTimeout(() => {
|
|
104
|
+
session.pending.delete(id);
|
|
105
|
+
reject(new BridgeError(`eval timed out after ${timeoutMs}ms`, 'TIMEOUT'));
|
|
106
|
+
}, timeoutMs);
|
|
107
|
+
session.pending.set(id, { resolve, reject, logs: [], timer });
|
|
108
|
+
const request = {
|
|
109
|
+
type: 'eval',
|
|
110
|
+
id,
|
|
111
|
+
code,
|
|
112
|
+
mode: options.mode ?? 'safe'
|
|
113
|
+
};
|
|
114
|
+
try {
|
|
115
|
+
session.ws.send(JSON.stringify(request));
|
|
116
|
+
}
|
|
117
|
+
catch (err) {
|
|
118
|
+
session.pending.delete(id);
|
|
119
|
+
clearTimeout(timer);
|
|
120
|
+
reject(new BridgeError(`failed to send eval: ${String(err)}`, 'DISCONNECTED'));
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
resolveTarget(name) {
|
|
125
|
+
const ready = [...this.sessions].filter((s) => s.info !== null);
|
|
126
|
+
if (name !== undefined) {
|
|
127
|
+
// Most-recently connected wins, so a reconnected tab supersedes a stale one.
|
|
128
|
+
const matches = ready
|
|
129
|
+
.filter((s) => s.info.name === name)
|
|
130
|
+
.sort((a, b) => b.info.connectedAt - a.info.connectedAt);
|
|
131
|
+
if (matches.length === 0) {
|
|
132
|
+
throw new BridgeError(`no connected tab named "${name}"`, 'NO_TAB');
|
|
133
|
+
}
|
|
134
|
+
return matches[0];
|
|
135
|
+
}
|
|
136
|
+
if (ready.length === 0) {
|
|
137
|
+
throw new BridgeError('no Etch tab is connected', 'NO_TAB');
|
|
138
|
+
}
|
|
139
|
+
if (ready.length > 1) {
|
|
140
|
+
const names = ready.map((s) => s.info.name).join(', ');
|
|
141
|
+
throw new BridgeError(`multiple tabs connected (${names}); pass a tab name to disambiguate`, 'AMBIGUOUS_TAB');
|
|
142
|
+
}
|
|
143
|
+
return ready[0];
|
|
144
|
+
}
|
|
145
|
+
onConnection(ws) {
|
|
146
|
+
const session = { ws, info: null, pending: new Map() };
|
|
147
|
+
this.sessions.add(session);
|
|
148
|
+
ws.on('message', (data) => this.onMessage(session, data.toString()));
|
|
149
|
+
ws.on('close', () => this.onClose(session));
|
|
150
|
+
ws.on('error', () => {
|
|
151
|
+
try {
|
|
152
|
+
ws.terminate();
|
|
153
|
+
}
|
|
154
|
+
catch {
|
|
155
|
+
/* already gone */
|
|
156
|
+
}
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
onMessage(session, raw) {
|
|
160
|
+
const msg = parsePageMessage(raw);
|
|
161
|
+
if (!msg)
|
|
162
|
+
return;
|
|
163
|
+
switch (msg.type) {
|
|
164
|
+
case 'hello':
|
|
165
|
+
this.onHello(session, msg);
|
|
166
|
+
return;
|
|
167
|
+
case 'log': {
|
|
168
|
+
const pending = session.pending.get(msg.id);
|
|
169
|
+
pending?.logs.push({ level: msg.level, args: msg.args });
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
case 'result': {
|
|
173
|
+
const pending = session.pending.get(msg.id);
|
|
174
|
+
if (!pending)
|
|
175
|
+
return;
|
|
176
|
+
session.pending.delete(msg.id);
|
|
177
|
+
clearTimeout(pending.timer);
|
|
178
|
+
pending.resolve({
|
|
179
|
+
ok: true,
|
|
180
|
+
value: msg.value,
|
|
181
|
+
logs: pending.logs
|
|
182
|
+
});
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
case 'error': {
|
|
186
|
+
const pending = session.pending.get(msg.id);
|
|
187
|
+
if (!pending)
|
|
188
|
+
return;
|
|
189
|
+
session.pending.delete(msg.id);
|
|
190
|
+
clearTimeout(pending.timer);
|
|
191
|
+
pending.resolve({
|
|
192
|
+
ok: false,
|
|
193
|
+
error: msg.error,
|
|
194
|
+
stack: msg.stack,
|
|
195
|
+
logs: pending.logs
|
|
196
|
+
});
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
case 'pong':
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
onHello(session, hello) {
|
|
204
|
+
session.info = {
|
|
205
|
+
name: hello.name,
|
|
206
|
+
url: hello.url,
|
|
207
|
+
title: hello.title,
|
|
208
|
+
hasEtch: hello.hasEtch,
|
|
209
|
+
apiVersion: hello.apiVersion,
|
|
210
|
+
version: hello.version,
|
|
211
|
+
meta: hello.meta ?? {},
|
|
212
|
+
connectedAt: Date.now()
|
|
213
|
+
};
|
|
214
|
+
this.emit('connect', session.info);
|
|
215
|
+
}
|
|
216
|
+
onClose(session) {
|
|
217
|
+
this.failPending(session, new BridgeError('tab disconnected', 'DISCONNECTED'));
|
|
218
|
+
this.sessions.delete(session);
|
|
219
|
+
if (session.info)
|
|
220
|
+
this.emit('disconnect', session.info);
|
|
221
|
+
}
|
|
222
|
+
failPending(session, error) {
|
|
223
|
+
for (const [id, pending] of session.pending) {
|
|
224
|
+
clearTimeout(pending.timer);
|
|
225
|
+
session.pending.delete(id);
|
|
226
|
+
pending.reject(error);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
/** Send a liveness probe to every connected tab (the page replies `pong`). */
|
|
230
|
+
pingAll() {
|
|
231
|
+
const ping = { type: 'ping' };
|
|
232
|
+
const frame = JSON.stringify(ping);
|
|
233
|
+
for (const session of this.sessions) {
|
|
234
|
+
try {
|
|
235
|
+
session.ws.send(frame);
|
|
236
|
+
}
|
|
237
|
+
catch {
|
|
238
|
+
/* will surface via close/error */
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
}
|