@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 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
+ }