twd-relay 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) 2026 BRIKEV
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,123 @@
1
+ # twd-relay
2
+
3
+ WebSocket relay for [TWD](https://github.com/nicolo-ribaudo/twd-js) — lets AI agents and external tools trigger and observe in-browser test runs.
4
+
5
+ Your app runs tests in the browser with twd-js. twd-relay adds a relay server and a browser client so that a **client** (script, CI, or AI agent) can send a “run” command over WebSocket; the relay forwards it to the browser, and test events are streamed back. No Vite or specific framework required: the relay can run standalone and work with any app that loads the browser client.
6
+
7
+ ---
8
+
9
+ ## Architecture
10
+
11
+ - **Relay server** — WebSocket server that accepts one browser connection and many client connections. Clients send commands (`run`, `status`); the relay forwards them to the browser. The browser runs tests and streams events back; the relay broadcasts those to all clients. A lock prevents concurrent runs.
12
+
13
+ - **Browser client** (`twd-relay/browser`) — Runs in your app. Connects to the relay, listens for commands, uses `twd-js/runner` to execute tests, and streams results back. Logs connection state in the console (e.g. `[twd-relay] Connected to relay`).
14
+
15
+ - **Vite plugin** (`twd-relay/vite`) — Optional. Attaches the relay to your Vite dev server so the WebSocket is on the same origin. Also available: a **standalone CLI** that runs the relay on its own HTTP server (default port 9876).
16
+
17
+ ### Protocol (summary)
18
+
19
+ 1. Browser connects → sends `{ type: 'hello', role: 'browser' }`
20
+ 2. Client connects → sends `{ type: 'hello', role: 'client' }`
21
+ 3. Client sends `{ type: 'run', scope: 'all' }` → relay forwards to browser
22
+ 4. Browser runs tests and streams events → relay broadcasts to clients
23
+ 5. `run:complete` clears the run lock (and the send-run script exits)
24
+
25
+ ---
26
+
27
+ ## Installation
28
+
29
+ ```bash
30
+ npm install twd-relay
31
+ ```
32
+
33
+ Peer dependency: **twd-js** (>=1.4.0). Your app must use twd-js for tests; the browser client imports `twd-js/runner` at runtime.
34
+
35
+ ---
36
+
37
+ ## Quick start (standalone relay)
38
+
39
+ Works with any framework. Run the relay on one port and your app on another.
40
+
41
+ **1. Start the relay** (from this repo, or use the CLI in your project):
42
+
43
+ ```bash
44
+ npm run relay
45
+ # or: npx twd-relay
46
+ # Listens on ws://localhost:9876/__twd/ws (use --port to change)
47
+ ```
48
+
49
+ **2. In your app**, connect the browser client and call `connect()`:
50
+
51
+ ```js
52
+ import { createBrowserClient } from 'twd-relay/browser';
53
+
54
+ const client = createBrowserClient({
55
+ url: 'ws://localhost:9876/__twd/ws',
56
+ });
57
+ client.connect();
58
+ ```
59
+
60
+ **3. Open your app in a browser** — the page connects to the relay as “browser”.
61
+
62
+ **4. Trigger a run** — something must connect as a **client** and send `run`:
63
+
64
+ - From this repo: `npm run send-run` (or `node scripts/send-run.js [--port 9876]`). The script exits when it receives `run:complete`.
65
+ - From another project (if `ws` is available, e.g. via twd-relay): use the one-liner below.
66
+
67
+ ### One-liner to trigger a run
68
+
69
+ Run from a directory where `ws` is installed (e.g. project with twd-relay):
70
+
71
+ ```bash
72
+ node -e 'const Ws=require("ws");const w=new Ws("ws://localhost:9876/__twd/ws");let s=false;w.on("open",()=>w.send(JSON.stringify({type:"hello",role:"client"})));w.on("message",d=>{const m=JSON.parse(d);console.log(m.type,m);if(m.type==="connected"&&m.browser&&!s){s=true;w.send(JSON.stringify({type:"run",scope:"all"}));}if(m.type==="run:complete"){w.close();}});w.on("close",()=>process.exit(0));'
73
+ ```
74
+
75
+ Change the URL if your relay uses another port or path.
76
+
77
+ ---
78
+
79
+ ## Vite plugin (optional)
80
+
81
+ If you use Vite, you can attach the relay to the dev server so the WebSocket is on the same host/port:
82
+
83
+ ```js
84
+ // vite.config.ts
85
+ import { twdRemote } from 'twd-relay/vite';
86
+
87
+ export default defineConfig({
88
+ plugins: [react(), twdRemote()],
89
+ });
90
+ ```
91
+
92
+ Then in your app you can omit the URL; the client defaults to `ws(s)://<current host>/__twd/ws`.
93
+
94
+ ---
95
+
96
+ ## Scripts (this repo)
97
+
98
+ | Script | Description |
99
+ |---------------|-------------|
100
+ | `npm run build` | Build relay, browser, vite entry points + CLI |
101
+ | `npm run relay` | Build and start the standalone relay (port 9876) |
102
+ | `npm run send-run`| Connect as client and send `run`; exits on `run:complete` |
103
+ | `npm run dev` | Start relay only (assumes already built) |
104
+ | `npm run test` | Run tests (watch) |
105
+ | `npm run test:ci` | Run tests with coverage |
106
+
107
+ ---
108
+
109
+ ## Exports
110
+
111
+ | Export | Use |
112
+ |--------|-----|
113
+ | `twd-relay` (main) | Relay server: `createTwdRelay(httpServer, options)` |
114
+ | `twd-relay/browser` | Browser client: `createBrowserClient(options)` |
115
+ | `twd-relay/vite` | Vite plugin: `twdRemote(options)` |
116
+
117
+ CLI: `twd-relay` (or `npx twd-relay`) runs the standalone relay; supports `--port`.
118
+
119
+ ---
120
+
121
+ ## License
122
+
123
+ MIT · [BRIKEV](https://github.com/BRIKEV/twd-relay)
@@ -0,0 +1 @@
1
+ "use strict";var k=Object.create;var T=Object.defineProperty;var b=Object.getOwnPropertyDescriptor;var D=Object.getOwnPropertyNames;var P=Object.getPrototypeOf,A=Object.prototype.hasOwnProperty;var L=(n,o,c,d)=>{if(o&&typeof o=="object"||typeof o=="function")for(let a of D(o))!A.call(n,a)&&a!==c&&T(n,a,{get:()=>o[a],enumerable:!(d=b(o,a))||d.enumerable});return n};var $=(n,o,c)=>(c=n!=null?k(P(n)):{},L(o||!n||!n.__esModule?T(c,"default",{value:n,enumerable:!0}):c,n));Object.defineProperty(exports,Symbol.toStringTag,{value:"Module"});function I(){return`${window.location.protocol==="https:"?"wss:":"ws:"}//${window.location.host}/__twd/ws`}function p(n,o){if(!n.parent)return"";const c=o.get(n.parent);return c?c.name:""}function M(n){const o=n?.url??I(),c=n?.reconnect??!0,d=n?.reconnectInterval??2e3,a="[twd-relay]";let t=null,m=!1,w=null;function i(r){t&&t.readyState===WebSocket.OPEN&&t.send(JSON.stringify(r))}function u(){window.dispatchEvent(new CustomEvent("twd:state-change"))}async function _(){const r=window.__TWD_STATE__;if(!r){i({type:"error",code:"NO_TWD",message:"TWD not initialized"});return}const s=r.handlers,y=Array.from(s.values()).filter(e=>e.type==="test").length;i({type:"run:start",testCount:y});let l=0,h=0,E=0;const g=performance.now(),O={onStart(e){e.status="running",u(),i({type:"test:start",id:e.id,name:e.name,suite:p(e,s)})},onPass(e){l++,e.status="pass",u(),i({type:"test:pass",id:e.id,name:e.name,suite:p(e,s),duration:performance.now()-g})},onFail(e,f){h++,e.status="fail",e.logs=[f.message],u(),i({type:"test:fail",id:e.id,name:e.name,suite:p(e,s),error:f.message,duration:performance.now()-g})},onSkip(e){E++,e.status="skip",u(),i({type:"test:skip",id:e.id,name:e.name,suite:p(e,s)})},onSuiteStart(e){e.status="running",u()},onSuiteEnd(e){e.status="idle",u()}};try{const{TestRunner:e}=await import("twd-js/runner");await new e(O).runAll()}catch(e){const f=e instanceof Error?e.message:String(e);i({type:"error",code:"RUNNER_ERROR",message:f})}const R=performance.now()-g;i({type:"run:complete",passed:l,failed:h,skipped:E,duration:R}),u()}function C(){const r=window.__TWD_STATE__;if(!r){i({type:"error",code:"NO_TWD",message:"TWD not initialized"});return}const s=r.handlers,y=[];for(const[,l]of s)l.type==="test"&&y.push({id:l.id,name:l.name,suite:p(l,s),status:l.status??"idle"});i({type:"status:result",tests:y})}function N(r){let s;try{s=JSON.parse(r.data)}catch{return}s.type==="run"?(console.info(a,"Received run command — running tests..."),_()):s.type==="status"&&C()}function v(){c&&!m&&(console.info(a,`Reconnecting in ${d}ms...`),w=setTimeout(()=>{S()},d))}function S(){t&&(t.readyState===WebSocket.OPEN||t.readyState===WebSocket.CONNECTING)||(m=!1,console.info(a,"Connecting to",o),t=new WebSocket(o),t.addEventListener("open",()=>{i({type:"hello",role:"browser"}),console.info(a,"Connected to relay — ready to receive run/status commands")}),t.addEventListener("message",N),t.addEventListener("close",r=>{t=null,m||console.info(a,"Disconnected",r.code?`(code ${r.code})`:"",r.reason||""),v()}),t.addEventListener("error",()=>{}))}function W(){m=!0,w&&(clearTimeout(w),w=null),t&&(t.close(1e3,"Client disconnecting"),t=null)}return{connect:S,disconnect:W,get connected(){return t!==null&&t.readyState===WebSocket.OPEN}}}exports.createBrowserClient=M;
@@ -0,0 +1,18 @@
1
+ export declare interface BrowserClient {
2
+ connect(): void;
3
+ disconnect(): void;
4
+ readonly connected: boolean;
5
+ }
6
+
7
+ export declare interface BrowserClientOptions {
8
+ /** WebSocket URL. Default: auto-detected from window.location */
9
+ url?: string;
10
+ /** Auto-reconnect on disconnect. Default: true */
11
+ reconnect?: boolean;
12
+ /** Milliseconds between reconnect attempts. Default: 2000 */
13
+ reconnectInterval?: number;
14
+ }
15
+
16
+ export declare function createBrowserClient(options?: BrowserClientOptions): BrowserClient;
17
+
18
+ export { }
@@ -0,0 +1,131 @@
1
+ function k() {
2
+ return `${window.location.protocol === "https:" ? "wss:" : "ws:"}//${window.location.host}/__twd/ws`;
3
+ }
4
+ function l(a, d) {
5
+ if (!a.parent) return "";
6
+ const f = d.get(a.parent);
7
+ return f ? f.name : "";
8
+ }
9
+ function O(a) {
10
+ const d = a?.url ?? k(), f = a?.reconnect ?? !0, g = a?.reconnectInterval ?? 2e3, c = "[twd-relay]";
11
+ let n = null, p = !1, m = null;
12
+ function r(t) {
13
+ n && n.readyState === WebSocket.OPEN && n.send(JSON.stringify(t));
14
+ }
15
+ function i() {
16
+ window.dispatchEvent(new CustomEvent("twd:state-change"));
17
+ }
18
+ async function T() {
19
+ const t = window.__TWD_STATE__;
20
+ if (!t) {
21
+ r({ type: "error", code: "NO_TWD", message: "TWD not initialized" });
22
+ return;
23
+ }
24
+ const o = t.handlers, w = Array.from(o.values()).filter((e) => e.type === "test").length;
25
+ r({ type: "run:start", testCount: w });
26
+ let s = 0, h = 0, E = 0;
27
+ const y = performance.now(), v = {
28
+ onStart(e) {
29
+ e.status = "running", i(), r({
30
+ type: "test:start",
31
+ id: e.id,
32
+ name: e.name,
33
+ suite: l(e, o)
34
+ });
35
+ },
36
+ onPass(e) {
37
+ s++, e.status = "pass", i(), r({
38
+ type: "test:pass",
39
+ id: e.id,
40
+ name: e.name,
41
+ suite: l(e, o),
42
+ duration: performance.now() - y
43
+ });
44
+ },
45
+ onFail(e, u) {
46
+ h++, e.status = "fail", e.logs = [u.message], i(), r({
47
+ type: "test:fail",
48
+ id: e.id,
49
+ name: e.name,
50
+ suite: l(e, o),
51
+ error: u.message,
52
+ duration: performance.now() - y
53
+ });
54
+ },
55
+ onSkip(e) {
56
+ E++, e.status = "skip", i(), r({
57
+ type: "test:skip",
58
+ id: e.id,
59
+ name: e.name,
60
+ suite: l(e, o)
61
+ });
62
+ },
63
+ onSuiteStart(e) {
64
+ e.status = "running", i();
65
+ },
66
+ onSuiteEnd(e) {
67
+ e.status = "idle", i();
68
+ }
69
+ };
70
+ try {
71
+ const { TestRunner: e } = await import("twd-js/runner");
72
+ await new e(v).runAll();
73
+ } catch (e) {
74
+ const u = e instanceof Error ? e.message : String(e);
75
+ r({ type: "error", code: "RUNNER_ERROR", message: u });
76
+ }
77
+ const R = performance.now() - y;
78
+ r({ type: "run:complete", passed: s, failed: h, skipped: E, duration: R }), i();
79
+ }
80
+ function _() {
81
+ const t = window.__TWD_STATE__;
82
+ if (!t) {
83
+ r({ type: "error", code: "NO_TWD", message: "TWD not initialized" });
84
+ return;
85
+ }
86
+ const o = t.handlers, w = [];
87
+ for (const [, s] of o)
88
+ s.type === "test" && w.push({
89
+ id: s.id,
90
+ name: s.name,
91
+ suite: l(s, o),
92
+ status: s.status ?? "idle"
93
+ });
94
+ r({ type: "status:result", tests: w });
95
+ }
96
+ function N(t) {
97
+ let o;
98
+ try {
99
+ o = JSON.parse(t.data);
100
+ } catch {
101
+ return;
102
+ }
103
+ o.type === "run" ? (console.info(c, "Received run command — running tests..."), T()) : o.type === "status" && _();
104
+ }
105
+ function C() {
106
+ f && !p && (console.info(c, `Reconnecting in ${g}ms...`), m = setTimeout(() => {
107
+ S();
108
+ }, g));
109
+ }
110
+ function S() {
111
+ n && (n.readyState === WebSocket.OPEN || n.readyState === WebSocket.CONNECTING) || (p = !1, console.info(c, "Connecting to", d), n = new WebSocket(d), n.addEventListener("open", () => {
112
+ r({ type: "hello", role: "browser" }), console.info(c, "Connected to relay — ready to receive run/status commands");
113
+ }), n.addEventListener("message", N), n.addEventListener("close", (t) => {
114
+ n = null, p || console.info(c, "Disconnected", t.code ? `(code ${t.code})` : "", t.reason || ""), C();
115
+ }), n.addEventListener("error", () => {
116
+ }));
117
+ }
118
+ function W() {
119
+ p = !0, m && (clearTimeout(m), m = null), n && (n.close(1e3, "Client disconnecting"), n = null);
120
+ }
121
+ return {
122
+ connect: S,
123
+ disconnect: W,
124
+ get connected() {
125
+ return n !== null && n.readyState === WebSocket.OPEN;
126
+ }
127
+ };
128
+ }
129
+ export {
130
+ O as createBrowserClient
131
+ };
package/dist/cli.js ADDED
@@ -0,0 +1,199 @@
1
+ #!/usr/bin/env node
2
+ import { createServer } from "http";
3
+ import { WebSocketServer, WebSocket } from "ws";
4
+ const DEFAULT_PATH = "/__twd/ws";
5
+ function createTwdRelay(server2, options) {
6
+ const path = options?.path ?? DEFAULT_PATH;
7
+ const onError = options?.onError;
8
+ const wss = new WebSocketServer({ noServer: true });
9
+ let browser = null;
10
+ const clients = /* @__PURE__ */ new Set();
11
+ let runInProgress = false;
12
+ function sendError(ws, code, message) {
13
+ const msg = { type: "error", code, message };
14
+ if (ws.readyState === WebSocket.OPEN) {
15
+ ws.send(JSON.stringify(msg));
16
+ }
17
+ }
18
+ function broadcastToClients(data) {
19
+ for (const client of clients) {
20
+ if (client.readyState === WebSocket.OPEN) {
21
+ client.send(data);
22
+ }
23
+ }
24
+ }
25
+ function sendConnectedStatus(ws) {
26
+ const msg = { type: "connected", browser: browser !== null && browser.readyState === WebSocket.OPEN };
27
+ if (ws.readyState === WebSocket.OPEN) {
28
+ ws.send(JSON.stringify(msg));
29
+ }
30
+ }
31
+ function notifyBrowserDisconnected() {
32
+ broadcastToClients(JSON.stringify({ type: "connected", browser: false }));
33
+ }
34
+ function handleBrowserMessage(data) {
35
+ let parsed;
36
+ try {
37
+ parsed = JSON.parse(data);
38
+ } catch {
39
+ return;
40
+ }
41
+ if (parsed.type === "run:complete") {
42
+ runInProgress = false;
43
+ }
44
+ broadcastToClients(data);
45
+ }
46
+ function handleClientMessage(ws, data) {
47
+ let parsed;
48
+ try {
49
+ parsed = JSON.parse(data);
50
+ } catch {
51
+ sendError(ws, "INVALID_MESSAGE", "Invalid JSON");
52
+ return;
53
+ }
54
+ if (!parsed.type) {
55
+ sendError(ws, "INVALID_MESSAGE", 'Missing "type" field');
56
+ return;
57
+ }
58
+ if (parsed.type === "run") {
59
+ if (!browser || browser.readyState !== WebSocket.OPEN) {
60
+ sendError(ws, "NO_BROWSER", "No browser connected");
61
+ return;
62
+ }
63
+ if (runInProgress) {
64
+ sendError(ws, "RUN_IN_PROGRESS", "A test run is already in progress");
65
+ return;
66
+ }
67
+ runInProgress = true;
68
+ browser.send(data);
69
+ return;
70
+ }
71
+ if (parsed.type === "status") {
72
+ if (!browser || browser.readyState !== WebSocket.OPEN) {
73
+ sendError(ws, "NO_BROWSER", "No browser connected");
74
+ return;
75
+ }
76
+ browser.send(data);
77
+ return;
78
+ }
79
+ sendError(ws, "UNKNOWN_COMMAND", `Unknown command: ${parsed.type}`);
80
+ }
81
+ function handleConnection(ws) {
82
+ let identified = false;
83
+ const identifyHandler = (raw) => {
84
+ const data = typeof raw === "string" ? raw : raw.toString();
85
+ if (!identified) {
86
+ let parsed;
87
+ try {
88
+ parsed = JSON.parse(data);
89
+ } catch {
90
+ sendError(ws, "INVALID_MESSAGE", "Invalid JSON");
91
+ return;
92
+ }
93
+ if (parsed.type !== "hello" || parsed.role !== "browser" && parsed.role !== "client") {
94
+ sendError(ws, "INVALID_MESSAGE", 'First message must be a hello with role "browser" or "client"');
95
+ return;
96
+ }
97
+ identified = true;
98
+ if (parsed.role === "browser") {
99
+ if (browser && browser.readyState === WebSocket.OPEN) {
100
+ browser.close(1e3, "Replaced by new browser");
101
+ }
102
+ browser = ws;
103
+ runInProgress = false;
104
+ ws.on("close", () => {
105
+ if (browser === ws) {
106
+ browser = null;
107
+ runInProgress = false;
108
+ notifyBrowserDisconnected();
109
+ }
110
+ });
111
+ broadcastToClients(JSON.stringify({ type: "connected", browser: true }));
112
+ ws.on("message", (raw2) => {
113
+ const msg = typeof raw2 === "string" ? raw2 : raw2.toString();
114
+ handleBrowserMessage(msg);
115
+ });
116
+ } else {
117
+ clients.add(ws);
118
+ ws.on("close", () => {
119
+ clients.delete(ws);
120
+ });
121
+ sendConnectedStatus(ws);
122
+ ws.on("message", (raw2) => {
123
+ const msg = typeof raw2 === "string" ? raw2 : raw2.toString();
124
+ handleClientMessage(ws, msg);
125
+ });
126
+ }
127
+ ws.removeListener("message", identifyHandler);
128
+ }
129
+ };
130
+ ws.on("message", identifyHandler);
131
+ ws.on("error", (err) => {
132
+ if (onError) onError(err);
133
+ });
134
+ }
135
+ const upgradeHandler = (request, socket, head) => {
136
+ const url = new URL(request.url ?? "/", `http://${request.headers.host ?? "localhost"}`);
137
+ if (url.pathname === path) {
138
+ wss.handleUpgrade(request, socket, head, (ws) => {
139
+ handleConnection(ws);
140
+ });
141
+ }
142
+ };
143
+ server2.on("upgrade", upgradeHandler);
144
+ wss.on("error", (err) => {
145
+ if (onError) onError(err);
146
+ });
147
+ return {
148
+ close() {
149
+ server2.removeListener("upgrade", upgradeHandler);
150
+ for (const client of clients) {
151
+ client.close(1e3, "Relay shutting down");
152
+ }
153
+ clients.clear();
154
+ if (browser && browser.readyState === WebSocket.OPEN) {
155
+ browser.close(1e3, "Relay shutting down");
156
+ }
157
+ browser = null;
158
+ runInProgress = false;
159
+ wss.close();
160
+ },
161
+ get browserConnected() {
162
+ return browser !== null && browser.readyState === WebSocket.OPEN;
163
+ },
164
+ get clientCount() {
165
+ return clients.size;
166
+ }
167
+ };
168
+ }
169
+ const args = process.argv.slice(2);
170
+ let port = 9876;
171
+ for (let i = 0; i < args.length; i++) {
172
+ if (args[i] === "--port" && args[i + 1]) {
173
+ port = parseInt(args[i + 1], 10);
174
+ if (isNaN(port)) {
175
+ console.error("Invalid port number:", args[i + 1]);
176
+ process.exit(1);
177
+ }
178
+ i++;
179
+ }
180
+ }
181
+ const server = createServer();
182
+ const relay = createTwdRelay(server, {
183
+ onError(err) {
184
+ console.error("[twd-relay] Error:", err.message);
185
+ }
186
+ });
187
+ server.listen(port, () => {
188
+ console.log(`TWD Relay running on ws://localhost:${port}/__twd/ws`);
189
+ console.log("Waiting for connections...");
190
+ });
191
+ function shutdown() {
192
+ console.log("\nShutting down...");
193
+ relay.close();
194
+ server.close(() => {
195
+ process.exit(0);
196
+ });
197
+ }
198
+ process.on("SIGINT", shutdown);
199
+ process.on("SIGTERM", shutdown);
@@ -0,0 +1,123 @@
1
+ import { WebSocketServer as P, WebSocket as o } from "ws";
2
+ const R = "/__twd/ws";
3
+ function J(p, N) {
4
+ const E = N?.path ?? R, f = N?.onError, u = new P({ noServer: !0 });
5
+ let t = null;
6
+ const c = /* @__PURE__ */ new Set();
7
+ let a = !1;
8
+ function s(e, n, r) {
9
+ const i = { type: "error", code: n, message: r };
10
+ e.readyState === o.OPEN && e.send(JSON.stringify(i));
11
+ }
12
+ function S(e) {
13
+ for (const n of c)
14
+ n.readyState === o.OPEN && n.send(e);
15
+ }
16
+ function h(e) {
17
+ const n = { type: "connected", browser: t !== null && t.readyState === o.OPEN };
18
+ e.readyState === o.OPEN && e.send(JSON.stringify(n));
19
+ }
20
+ function m() {
21
+ S(JSON.stringify({ type: "connected", browser: !1 }));
22
+ }
23
+ function b(e) {
24
+ let n;
25
+ try {
26
+ n = JSON.parse(e);
27
+ } catch {
28
+ return;
29
+ }
30
+ n.type === "run:complete" && (a = !1), S(e);
31
+ }
32
+ function A(e, n) {
33
+ let r;
34
+ try {
35
+ r = JSON.parse(n);
36
+ } catch {
37
+ s(e, "INVALID_MESSAGE", "Invalid JSON");
38
+ return;
39
+ }
40
+ if (!r.type) {
41
+ s(e, "INVALID_MESSAGE", 'Missing "type" field');
42
+ return;
43
+ }
44
+ if (r.type === "run") {
45
+ if (!t || t.readyState !== o.OPEN) {
46
+ s(e, "NO_BROWSER", "No browser connected");
47
+ return;
48
+ }
49
+ if (a) {
50
+ s(e, "RUN_IN_PROGRESS", "A test run is already in progress");
51
+ return;
52
+ }
53
+ a = !0, t.send(n);
54
+ return;
55
+ }
56
+ if (r.type === "status") {
57
+ if (!t || t.readyState !== o.OPEN) {
58
+ s(e, "NO_BROWSER", "No browser connected");
59
+ return;
60
+ }
61
+ t.send(n);
62
+ return;
63
+ }
64
+ s(e, "UNKNOWN_COMMAND", `Unknown command: ${r.type}`);
65
+ }
66
+ function I(e) {
67
+ let n = !1;
68
+ const r = (i) => {
69
+ const y = typeof i == "string" ? i : i.toString();
70
+ if (!n) {
71
+ let d;
72
+ try {
73
+ d = JSON.parse(y);
74
+ } catch {
75
+ s(e, "INVALID_MESSAGE", "Invalid JSON");
76
+ return;
77
+ }
78
+ if (d.type !== "hello" || d.role !== "browser" && d.role !== "client") {
79
+ s(e, "INVALID_MESSAGE", 'First message must be a hello with role "browser" or "client"');
80
+ return;
81
+ }
82
+ n = !0, d.role === "browser" ? (t && t.readyState === o.OPEN && t.close(1e3, "Replaced by new browser"), t = e, a = !1, e.on("close", () => {
83
+ t === e && (t = null, a = !1, m());
84
+ }), S(JSON.stringify({ type: "connected", browser: !0 })), e.on("message", (l) => {
85
+ const g = typeof l == "string" ? l : l.toString();
86
+ b(g);
87
+ })) : (c.add(e), e.on("close", () => {
88
+ c.delete(e);
89
+ }), h(e), e.on("message", (l) => {
90
+ const g = typeof l == "string" ? l : l.toString();
91
+ A(e, g);
92
+ })), e.removeListener("message", r);
93
+ }
94
+ };
95
+ e.on("message", r), e.on("error", (i) => {
96
+ f && f(i);
97
+ });
98
+ }
99
+ const O = (e, n, r) => {
100
+ new URL(e.url ?? "/", `http://${e.headers.host ?? "localhost"}`).pathname === E && u.handleUpgrade(e, n, r, (y) => {
101
+ I(y);
102
+ });
103
+ };
104
+ return p.on("upgrade", O), u.on("error", (e) => {
105
+ f && f(e);
106
+ }), {
107
+ close() {
108
+ p.removeListener("upgrade", O);
109
+ for (const e of c)
110
+ e.close(1e3, "Relay shutting down");
111
+ c.clear(), t && t.readyState === o.OPEN && t.close(1e3, "Relay shutting down"), t = null, a = !1, u.close();
112
+ },
113
+ get browserConnected() {
114
+ return t !== null && t.readyState === o.OPEN;
115
+ },
116
+ get clientCount() {
117
+ return c.size;
118
+ }
119
+ };
120
+ }
121
+ export {
122
+ J as c
123
+ };
@@ -0,0 +1 @@
1
+ "use strict";const o=require("ws"),I="/__twd/ws";function P(N,p){const b=p?.path??I,f=p?.onError,S=new o.WebSocketServer({noServer:!0});let t=null;const l=new Set;let a=!1;function s(e,n,r){const c={type:"error",code:n,message:r};e.readyState===o.WebSocket.OPEN&&e.send(JSON.stringify(c))}function u(e){for(const n of l)n.readyState===o.WebSocket.OPEN&&n.send(e)}function E(e){const n={type:"connected",browser:t!==null&&t.readyState===o.WebSocket.OPEN};e.readyState===o.WebSocket.OPEN&&e.send(JSON.stringify(n))}function h(){u(JSON.stringify({type:"connected",browser:!1}))}function m(e){let n;try{n=JSON.parse(e)}catch{return}n.type==="run:complete"&&(a=!1),u(e)}function W(e,n){let r;try{r=JSON.parse(n)}catch{s(e,"INVALID_MESSAGE","Invalid JSON");return}if(!r.type){s(e,"INVALID_MESSAGE",'Missing "type" field');return}if(r.type==="run"){if(!t||t.readyState!==o.WebSocket.OPEN){s(e,"NO_BROWSER","No browser connected");return}if(a){s(e,"RUN_IN_PROGRESS","A test run is already in progress");return}a=!0,t.send(n);return}if(r.type==="status"){if(!t||t.readyState!==o.WebSocket.OPEN){s(e,"NO_BROWSER","No browser connected");return}t.send(n);return}s(e,"UNKNOWN_COMMAND",`Unknown command: ${r.type}`)}function A(e){let n=!1;const r=c=>{const y=typeof c=="string"?c:c.toString();if(!n){let d;try{d=JSON.parse(y)}catch{s(e,"INVALID_MESSAGE","Invalid JSON");return}if(d.type!=="hello"||d.role!=="browser"&&d.role!=="client"){s(e,"INVALID_MESSAGE",'First message must be a hello with role "browser" or "client"');return}n=!0,d.role==="browser"?(t&&t.readyState===o.WebSocket.OPEN&&t.close(1e3,"Replaced by new browser"),t=e,a=!1,e.on("close",()=>{t===e&&(t=null,a=!1,h())}),u(JSON.stringify({type:"connected",browser:!0})),e.on("message",i=>{const g=typeof i=="string"?i:i.toString();m(g)})):(l.add(e),e.on("close",()=>{l.delete(e)}),E(e),e.on("message",i=>{const g=typeof i=="string"?i:i.toString();W(e,g)})),e.removeListener("message",r)}};e.on("message",r),e.on("error",c=>{f&&f(c)})}const O=(e,n,r)=>{new URL(e.url??"/",`http://${e.headers.host??"localhost"}`).pathname===b&&S.handleUpgrade(e,n,r,y=>{A(y)})};return N.on("upgrade",O),S.on("error",e=>{f&&f(e)}),{close(){N.removeListener("upgrade",O);for(const e of l)e.close(1e3,"Relay shutting down");l.clear(),t&&t.readyState===o.WebSocket.OPEN&&t.close(1e3,"Relay shutting down"),t=null,a=!1,S.close()},get browserConnected(){return t!==null&&t.readyState===o.WebSocket.OPEN},get clientCount(){return l.size}}}exports.c=P;
@@ -0,0 +1 @@
1
+ "use strict";Object.defineProperty(exports,Symbol.toStringTag,{value:"Module"});const e=require("./createTwdRelay-VRmjHboP.cjs");exports.createTwdRelay=e.c;
@@ -0,0 +1,100 @@
1
+ import { Server } from 'http';
2
+
3
+ export declare type BrowserEvent = ConnectedEvent | RunStartEvent | TestStartEvent | TestPassEvent | TestFailEvent | TestSkipEvent | RunCompleteEvent;
4
+
5
+ export declare type Command = RunCommand | StatusCommand;
6
+
7
+ export declare interface ConnectedEvent {
8
+ type: 'connected';
9
+ browser: boolean;
10
+ }
11
+
12
+ export declare function createTwdRelay(server: Server, options?: TwdRelayOptions): TwdRelay;
13
+
14
+ export declare interface HelloBrowserMessage {
15
+ type: 'hello';
16
+ role: 'browser';
17
+ }
18
+
19
+ export declare interface HelloClientMessage {
20
+ type: 'hello';
21
+ role: 'client';
22
+ }
23
+
24
+ export declare type HelloMessage = HelloBrowserMessage | HelloClientMessage;
25
+
26
+ export declare type InboundMessage = HelloMessage | Command | BrowserEvent;
27
+
28
+ export declare interface RunCommand {
29
+ type: 'run';
30
+ scope: 'all';
31
+ }
32
+
33
+ export declare interface RunCompleteEvent {
34
+ type: 'run:complete';
35
+ passed: number;
36
+ failed: number;
37
+ skipped: number;
38
+ duration: number;
39
+ }
40
+
41
+ export declare interface RunStartEvent {
42
+ type: 'run:start';
43
+ testCount: number;
44
+ }
45
+
46
+ export declare interface StatusCommand {
47
+ type: 'status';
48
+ }
49
+
50
+ export declare interface TestFailEvent {
51
+ type: 'test:fail';
52
+ id: string;
53
+ name: string;
54
+ suite: string;
55
+ error: string;
56
+ duration: number;
57
+ }
58
+
59
+ export declare interface TestPassEvent {
60
+ type: 'test:pass';
61
+ id: string;
62
+ name: string;
63
+ suite: string;
64
+ duration: number;
65
+ }
66
+
67
+ export declare interface TestSkipEvent {
68
+ type: 'test:skip';
69
+ id: string;
70
+ name: string;
71
+ suite: string;
72
+ }
73
+
74
+ export declare interface TestStartEvent {
75
+ type: 'test:start';
76
+ id: string;
77
+ name: string;
78
+ suite: string;
79
+ }
80
+
81
+ export declare type TwdErrorCode = 'NO_BROWSER' | 'RUN_IN_PROGRESS' | 'UNKNOWN_COMMAND' | 'INVALID_MESSAGE';
82
+
83
+ export declare interface TwdErrorMessage {
84
+ type: 'error';
85
+ code: TwdErrorCode;
86
+ message: string;
87
+ }
88
+
89
+ export declare interface TwdRelay {
90
+ close(): void;
91
+ readonly browserConnected: boolean;
92
+ readonly clientCount: number;
93
+ }
94
+
95
+ export declare interface TwdRelayOptions {
96
+ path?: string;
97
+ onError?: (error: Error) => void;
98
+ }
99
+
100
+ export { }
@@ -0,0 +1,4 @@
1
+ import { c as r } from "./createTwdRelay-22etCW_8.js";
2
+ export {
3
+ r as createTwdRelay
4
+ };
@@ -0,0 +1 @@
1
+ "use strict";Object.defineProperty(exports,Symbol.toStringTag,{value:"Module"});const o=require("./createTwdRelay-VRmjHboP.cjs");function n(t){return{name:"twd-relay",configureServer(e){if(!e.httpServer)return;const r=o.c(e.httpServer,{path:t?.path??"/__twd/ws"});e.httpServer.on("close",()=>r.close())}}}exports.twdRemote=n;
package/dist/vite.d.ts ADDED
@@ -0,0 +1,17 @@
1
+ import { Server } from 'http';
2
+
3
+ export declare function twdRemote(options?: TwdRemoteOptions): VitePlugin;
4
+
5
+ export declare interface TwdRemoteOptions {
6
+ /** WebSocket path. Default: '/__twd/ws' */
7
+ path?: string;
8
+ }
9
+
10
+ declare interface VitePlugin {
11
+ name: string;
12
+ configureServer?: (server: {
13
+ httpServer: Server | null;
14
+ }) => void;
15
+ }
16
+
17
+ export { }
@@ -0,0 +1,16 @@
1
+ import { c as o } from "./createTwdRelay-22etCW_8.js";
2
+ function n(e) {
3
+ return {
4
+ name: "twd-relay",
5
+ configureServer(t) {
6
+ if (!t.httpServer) return;
7
+ const r = o(t.httpServer, {
8
+ path: e?.path ?? "/__twd/ws"
9
+ });
10
+ t.httpServer.on("close", () => r.close());
11
+ }
12
+ };
13
+ }
14
+ export {
15
+ n as twdRemote
16
+ };
package/package.json ADDED
@@ -0,0 +1,73 @@
1
+ {
2
+ "name": "twd-relay",
3
+ "version": "0.1.0",
4
+ "description": "WebSocket relay for TWD — enables AI agents and external tools to run and observe in-browser tests",
5
+ "license": "MIT",
6
+ "author": "BRIKEV",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "https://github.com/BRIKEV/twd-relay.git"
10
+ },
11
+ "type": "module",
12
+ "main": "./dist/index.cjs.js",
13
+ "module": "./dist/index.es.js",
14
+ "types": "./dist/index.d.ts",
15
+ "bin": {
16
+ "twd-relay": "./dist/cli.js"
17
+ },
18
+ "exports": {
19
+ ".": {
20
+ "types": "./dist/index.d.ts",
21
+ "import": "./dist/index.es.js",
22
+ "require": "./dist/index.cjs.js"
23
+ },
24
+ "./browser": {
25
+ "types": "./dist/browser.d.ts",
26
+ "import": "./dist/browser.es.js",
27
+ "default": "./dist/browser.es.js"
28
+ },
29
+ "./vite": {
30
+ "types": "./dist/vite.d.ts",
31
+ "import": "./dist/vite.es.js",
32
+ "require": "./dist/vite.cjs.js"
33
+ }
34
+ },
35
+ "files": [
36
+ "dist",
37
+ "README.md",
38
+ "LICENSE"
39
+ ],
40
+ "engines": {
41
+ "node": ">=18"
42
+ },
43
+ "scripts": {
44
+ "build": "vite build && vite build -c vite.cli.config.ts && node -e \"const fs=require('fs');const f='dist/cli.js';fs.writeFileSync(f,'#!/usr/bin/env node\\n'+fs.readFileSync(f,'utf8'))\" && chmod +x dist/cli.js",
45
+ "dev": "node dist/cli.js",
46
+ "relay": "npm run build && node dist/cli.js",
47
+ "send-run": "node scripts/send-run.js",
48
+ "test": "vitest",
49
+ "test:ci": "vitest --run --coverage"
50
+ },
51
+ "peerDependencies": {
52
+ "twd-js": ">=1.4.0"
53
+ },
54
+ "peerDependenciesMeta": {
55
+ "twd-js": {
56
+ "optional": false
57
+ },
58
+ "vite": {
59
+ "optional": true
60
+ }
61
+ },
62
+ "dependencies": {
63
+ "ws": "^8.19.0"
64
+ },
65
+ "devDependencies": {
66
+ "@types/ws": "^8.18.1",
67
+ "@vitest/coverage-v8": "^4.0.18",
68
+ "typescript": "^5.9.3",
69
+ "vite": "^7.3.1",
70
+ "vite-plugin-dts": "^4.5.4",
71
+ "vitest": "^4.0.18"
72
+ }
73
+ }