twd-relay 1.0.0 → 1.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/README.md +73 -3
- package/dist/browser.cjs.js +1 -1
- package/dist/browser.d.ts +7 -0
- package/dist/browser.es.js +266 -148
- package/dist/cli.js +75 -10
- package/dist/index.cjs.js +1 -1
- package/dist/index.d.ts +22 -2
- package/dist/index.es.js +1 -1
- package/dist/relay-CK82tv0w.cjs +1 -0
- package/dist/relay-NDt-wlXz.js +148 -0
- package/dist/vite.cjs.js +1 -1
- package/dist/vite.es.js +1 -1
- package/package.json +2 -1
- package/dist/relay-C8UTSEbG.js +0 -132
- package/dist/relay-CpUORBd7.cjs +0 -1
package/README.md
CHANGED
|
@@ -18,9 +18,20 @@ Your app runs tests in the browser with twd-js. twd-relay adds a relay server an
|
|
|
18
18
|
|
|
19
19
|
1. Browser connects → sends `{ type: 'hello', role: 'browser' }`
|
|
20
20
|
2. Client connects → sends `{ type: 'hello', role: 'client' }`
|
|
21
|
-
3. Client sends `{ type: 'run', scope: 'all' }` → relay forwards to browser
|
|
21
|
+
3. Client sends `{ type: 'run', scope: 'all' }` (optionally with `testNames` to filter) → relay forwards to browser
|
|
22
22
|
4. Browser runs tests and streams events → relay broadcasts to clients
|
|
23
|
-
5. `
|
|
23
|
+
5. Browser sends `{ type: 'heartbeat' }` every 3s during a run (relay consumes these, never forwarded to clients)
|
|
24
|
+
6. `run:complete` clears the run lock (and the send-run script exits)
|
|
25
|
+
|
|
26
|
+
### Heartbeat & frozen-tab recovery
|
|
27
|
+
|
|
28
|
+
During an active test run the browser sends a heartbeat every 3 seconds. The relay tracks the last heartbeat time and checks every 10 seconds. If no heartbeat arrives for **120 seconds** during an active run, the relay considers the run dead (browser tab frozen by the OS), resets the run lock, and broadcasts to all clients:
|
|
29
|
+
|
|
30
|
+
```json
|
|
31
|
+
{ "type": "run:abandoned", "reason": "heartbeat_timeout" }
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
The CLI prints a clear message — `Run abandoned — browser tab appears frozen. Refresh the browser tab and retry.` — and exits with code 1. This is especially useful for AI agent workflows, where the agent gets an actionable signal instead of a silent 180s timeout followed by a cryptic `RUN_IN_PROGRESS` error.
|
|
24
35
|
|
|
25
36
|
---
|
|
26
37
|
|
|
@@ -57,6 +68,32 @@ const client = createBrowserClient({
|
|
|
57
68
|
client.connect();
|
|
58
69
|
```
|
|
59
70
|
|
|
71
|
+
Once connected, the browser client sets a colored favicon and prefixes `document.title` so you can spot the active TWD tab at a glance:
|
|
72
|
+
|
|
73
|
+
| Favicon | Title prefix | State |
|
|
74
|
+
|---|---|---|
|
|
75
|
+
| Blue | `[TWD]` | Connected, idle |
|
|
76
|
+
| Orange | `[TWD ...]` | Tests running |
|
|
77
|
+
| Green | `[TWD ✓]` | Last run passed |
|
|
78
|
+
| Red | `[TWD ✗]` | Last run had failures |
|
|
79
|
+
|
|
80
|
+
On disconnect or eviction (another tab taking over), the original favicon and title are restored.
|
|
81
|
+
|
|
82
|
+
### Aborting throttled runs
|
|
83
|
+
|
|
84
|
+
Chrome aggressively throttles timers in backgrounded tabs, which can stretch a 1-second test run to 30+ seconds. To avoid AI/CI hangs, the browser client monitors per-test wall-clock time. If any single test runs longer than 10 seconds (configurable), the browser emits `run:aborted`, the CLI prints a clear error with recovery guidance, and the run ends with exit code 1.
|
|
85
|
+
|
|
86
|
+
Override the threshold with `--max-test-duration <ms>` on `twd-relay run`, or pass `maxTestDurationMs` to `createBrowserClient`. Set it to `0` to disable detection entirely:
|
|
87
|
+
|
|
88
|
+
```bash
|
|
89
|
+
twd-relay run --max-test-duration 20000 # raise to 20s for heavy multistep tests
|
|
90
|
+
twd-relay run --max-test-duration 0 # disable detection
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
The default of 10 s is chosen to sit above the Testing Library default `findBy*` timeout (3 s). A legitimately failing test with one or two missed selectors still completes under the threshold, while throttled runs — where tests typically cluster in the 10–30 s range — trip the abort reliably.
|
|
94
|
+
|
|
95
|
+
Recovery when an abort fires: foreground the TWD tab (identified by the `[TWD …]` title prefix set by the favicon indicator) and retry. For unattended runs (CI, agents), prefer `twd-cli`: it drives a headless browser where the tab is always focused and throttling doesn't apply.
|
|
96
|
+
|
|
60
97
|
**3. Open your app in a browser** — the page connects to the relay as “browser”.
|
|
61
98
|
|
|
62
99
|
**4. Trigger a run** — something must connect as a **client** and send `run`:
|
|
@@ -114,7 +151,40 @@ Then in your app you can omit the URL; the client defaults to `ws(s)://<current
|
|
|
114
151
|
| `twd-relay/browser` | Browser client: `createBrowserClient(options)` |
|
|
115
152
|
| `twd-relay/vite` | Vite plugin: `twdRemote(options)` |
|
|
116
153
|
|
|
117
|
-
CLI: `twd-relay` (or `npx twd-relay`)
|
|
154
|
+
CLI: `twd-relay` (or `npx twd-relay`) — two subcommands:
|
|
155
|
+
|
|
156
|
+
- `twd-relay serve` (default) — start the standalone relay
|
|
157
|
+
- `twd-relay run` — connect to a relay and trigger a test run
|
|
158
|
+
|
|
159
|
+
---
|
|
160
|
+
|
|
161
|
+
## CLI `run` command
|
|
162
|
+
|
|
163
|
+
Connect to an existing relay, trigger tests, stream output, and exit with 0 (all pass) or 1 (failures).
|
|
164
|
+
|
|
165
|
+
```bash
|
|
166
|
+
# Run all tests (connects to Vite dev server on port 5173 by default)
|
|
167
|
+
twd-relay run
|
|
168
|
+
|
|
169
|
+
# Run on a different port
|
|
170
|
+
twd-relay run --port 9876
|
|
171
|
+
|
|
172
|
+
# Run specific tests by name (substring match, case-insensitive)
|
|
173
|
+
twd-relay run --test "should show error"
|
|
174
|
+
|
|
175
|
+
# Multiple filters — runs tests matching any of them
|
|
176
|
+
twd-relay run --test "login" --test "signup"
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
| Flag | Description | Default |
|
|
180
|
+
|------|-------------|---------|
|
|
181
|
+
| `--port <port>` | Relay port | 5173 |
|
|
182
|
+
| `--host <host>` | Relay host | localhost |
|
|
183
|
+
| `--path <path>` | WebSocket path | `/__twd/ws` |
|
|
184
|
+
| `--timeout <ms>` | Timeout | 180000 |
|
|
185
|
+
| `--test <name>` | Filter tests by name substring (repeatable) | — |
|
|
186
|
+
|
|
187
|
+
When `--test` is used and no tests match, the CLI prints the available test names so you can correct the filter.
|
|
118
188
|
|
|
119
189
|
---
|
|
120
190
|
|
package/dist/browser.cjs.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
var
|
|
1
|
+
var z=Object.create;var R=Object.defineProperty;var U=Object.getOwnPropertyDescriptor;var q=Object.getOwnPropertyNames;var H=Object.getPrototypeOf,G=Object.prototype.hasOwnProperty;var V=(e,r,s,c)=>{if(r&&typeof r=="object"||typeof r=="function")for(let n of q(r))!G.call(e,n)&&n!==s&&R(e,n,{get:()=>r[n],enumerable:!(c=U(r,n))||c.enumerable});return e};var X=(e,r,s)=>(s=e!=null?z(H(e)):{},V(r||!e||!e.__esModule?R(s,"default",{value:e,enumerable:!0}):s,e));Object.defineProperty(exports,Symbol.toStringTag,{value:"Module"});var M=e=>`data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'><rect x='3' y='4' width='26' height='24' rx='4' fill='${e}'/><circle cx='7' cy='9' r='1' fill='white'/><circle cx='11' cy='9' r='1' fill='white'/><circle cx='15' cy='9' r='1' fill='white'/><path d='M9 20l4 4 10-10' stroke='white' stroke-width='3.5' fill='none' stroke-linecap='round' stroke-linejoin='round'/></svg>`,K={connected:M("%234A90D9"),running:M("%23F5A623"),pass:M("%237ED321"),fail:M("%23D0021B")},Q={connected:"[TWD] ",running:"[TWD ...] ",pass:"[TWD ✓] ",fail:"[TWD ✗] "};function Y(e){let r=null,s=null,c=!1,n=null;function b(){if(n&&n.isConnected)return n;const i=e.querySelector("link[rel='icon']");if(i)n=i;else{const f=e.createElement("link");f.rel="icon",e.head.appendChild(f),n=f}return n}return{save(){if(s!==null)return;const i=e.querySelector("link[rel='icon']");c=i!==null,r=i?.href??null,s=e.title,n=i},restore(){s!==null&&(e.title=s),!c&&n&&n.isConnected?n.remove():c&&r!==null&&n&&n.setAttribute("href",r),r=null,s=null,c=!1,n=null},set(i){b().setAttribute("href",K[i]);const f=s??e.title;e.title=Q[i]+f}}}function Z(e){const r=e.now??(()=>performance.now()),s=e.thresholdMs;let c=null,n=null,b=!1;return{onTestStart(i){c=r(),n=i},onTestEnd(){if(c===null||n===null)return null;const i=n,f=r()-c;return c=null,n=null,s<=0||f<=s?null:{testName:i,durationMs:f}},checkThreshold(){if(s<=0||c===null||n===null)return null;const i=r()-c;return i<=s?null:{testName:n,durationMs:i}},markAborted(){b=!0},isAborted(){return b}}}function ee(e){return`${window.location.protocol==="https:"?"wss:":"ws:"}//${window.location.host}${e}`}function E(e,r){if(!e.parent)return"";const s=r.get(e.parent);return s?s.name:""}function te(e){const r=e?.url??ee(e?.path??"/__twd/ws"),s=e?.reconnect??!0,c=e?.reconnectInterval??2e3,n=e?.log??!1,b=e?.maxTestDurationMs??1e4,i="[twd-relay]";function f(...o){n&&console.info(i,...o)}function _(...o){console.warn(i,...o)}const w=Y(document);let u=null,k=!1,N=null;function d(o){u&&u.readyState===WebSocket.OPEN&&u.send(JSON.stringify(o))}function g(){window.dispatchEvent(new CustomEvent("twd:state-change"))}async function I(o,m={}){const a=Z({thresholdMs:typeof m.maxTestDurationMs=="number"?m.maxTestDurationMs:b});let h=0,S=0,W=0;const C=performance.now();let x;function D(y){a.isAborted()||(a.markAborted(),d({type:"run:aborted",reason:"throttled",durationMs:y.durationMs,testName:y.testName}),d({type:"run:complete",passed:h,failed:S,skipped:W,duration:performance.now()-C}),w.set("fail"),g(),clearInterval(x))}x=setInterval(()=>{d({type:"heartbeat"});const y=a.checkThreshold();y&&D(y)},3e3),w.set("running");try{const y=window.__TWD_STATE__;if(!y){_("TWD not initialized — make sure twd-js is loaded before running tests"),d({type:"error",code:"NO_TWD",message:"TWD not initialized"});return}const T=y.handlers;let v;if(o&&o.length>0){const t=o.map(p=>p.toLowerCase()),l=[];for(const[,p]of T)if(p.type==="test"){const A=p.name.toLowerCase();t.some(j=>A.includes(j))&&l.push(p.id)}if(l.length===0){const p=Array.from(T.values()).filter(A=>A.type==="test").map(A=>A.name);d({type:"run:start",testCount:0}),d({type:"error",code:"NO_MATCH",message:`No tests matched: ${JSON.stringify(o)}. Available tests: ${JSON.stringify(p)}`}),d({type:"run:complete",passed:0,failed:0,skipped:0,duration:0}),w.set("pass");return}v=l}d({type:"run:start",testCount:v?v.length:Array.from(T.values()).filter(t=>t.type==="test").length});const F={onStart(t){a.isAborted()||(a.onTestStart(t.name),t.status="running",g(),d({type:"test:start",id:t.id,name:t.name,suite:E(t,T)}))},onPass(t){const l=a.onTestEnd();if(!a.isAborted()){if(l){D(l);return}h++,t.status="pass",g(),d({type:"test:pass",id:t.id,name:t.name,suite:E(t,T),duration:performance.now()-C})}},onFail(t,l){const p=a.onTestEnd();if(!a.isAborted()){if(p){D(p);return}S++,t.status="fail",t.logs=[l.message],g(),d({type:"test:fail",id:t.id,name:t.name,suite:E(t,T),error:l.message,duration:performance.now()-C})}},onSkip(t){const l=a.onTestEnd();if(!a.isAborted()){if(l){D(l);return}W++,t.status="skip",g(),d({type:"test:skip",id:t.id,name:t.name,suite:E(t,T)})}},onSuiteStart(t){a.isAborted()||(t.status="running",g())},onSuiteEnd(t){a.isAborted()||(t.status="idle",g())}};try{const{TestRunner:t}=await import("twd-js/runner"),l=new t(F);v?await l.runByIds(v):await l.runAll()}catch(t){const l=t instanceof Error?t.message:String(t);_("Runner error:",l),d({type:"error",code:"RUNNER_ERROR",message:l}),S++,a.onTestEnd()}const J=performance.now()-C;a.isAborted()?w.set("fail"):(d({type:"run:complete",passed:h,failed:S,skipped:W,duration:J}),w.set(S>0?"fail":"pass")),g()}finally{clearInterval(x)}}function L(){const o=window.__TWD_STATE__;if(!o){d({type:"error",code:"NO_TWD",message:"TWD not initialized"});return}const m=o.handlers,a=[];for(const[,h]of m)h.type==="test"&&a.push({id:h.id,name:h.name,suite:E(h,m),status:h.status??"idle"});d({type:"status:result",tests:a})}function $(o){let m;try{m=JSON.parse(o.data)}catch{return}m.type==="run"?(f("Received run command — running tests..."),I(Array.isArray(m.testNames)?m.testNames:void 0,m)):m.type==="status"&&L()}function P(){s&&!k&&(f(`Reconnecting in ${c}ms...`),N=setTimeout(()=>{O()},c))}function O(){u&&(u.readyState===WebSocket.OPEN||u.readyState===WebSocket.CONNECTING)||(k=!1,f("Connecting to",r),u=new WebSocket(r),u.addEventListener("open",()=>{d({type:"hello",role:"browser"}),w.save(),w.set("connected"),f("Connected to relay — ready to receive run/status commands")}),u.addEventListener("message",$),u.addEventListener("close",o=>{if(u=null,w.restore(),o.reason==="Replaced by new browser"){_("Another browser instance connected — this instance will not reconnect");return}k||f("Disconnected",o.code?`(code ${o.code})`:"",o.reason||""),P()}),u.addEventListener("error",()=>{}))}function B(){k=!0,N&&(clearTimeout(N),N=null),u&&(u.close(1e3,"Client disconnecting"),u=null)}return{connect:O,disconnect:B,get connected(){return u!==null&&u.readyState===WebSocket.OPEN}}}exports.createBrowserClient=te;
|
package/dist/browser.d.ts
CHANGED
|
@@ -15,6 +15,13 @@ export declare interface BrowserClientOptions {
|
|
|
15
15
|
reconnectInterval?: number;
|
|
16
16
|
/** Enable console logging. Default: false */
|
|
17
17
|
log?: boolean;
|
|
18
|
+
/**
|
|
19
|
+
* Maximum wall-clock ms any single test may run before the browser
|
|
20
|
+
* aborts the run with reason 'throttled'. Typically triggered when the
|
|
21
|
+
* tab is backgrounded and Chrome throttles timers. Default: 10000.
|
|
22
|
+
* Set to 0 to disable detection.
|
|
23
|
+
*/
|
|
24
|
+
maxTestDurationMs?: number;
|
|
18
25
|
}
|
|
19
26
|
|
|
20
27
|
export declare function createBrowserClient(options?: BrowserClientOptions): BrowserClient;
|
package/dist/browser.es.js
CHANGED
|
@@ -1,194 +1,312 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
1
|
+
var M = (t) => `data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'><rect x='3' y='4' width='26' height='24' rx='4' fill='${t}'/><circle cx='7' cy='9' r='1' fill='white'/><circle cx='11' cy='9' r='1' fill='white'/><circle cx='15' cy='9' r='1' fill='white'/><path d='M9 20l4 4 10-10' stroke='white' stroke-width='3.5' fill='none' stroke-linecap='round' stroke-linejoin='round'/></svg>`, z = {
|
|
2
|
+
connected: M("%234A90D9"),
|
|
3
|
+
running: M("%23F5A623"),
|
|
4
|
+
pass: M("%237ED321"),
|
|
5
|
+
fail: M("%23D0021B")
|
|
6
|
+
}, U = {
|
|
7
|
+
connected: "[TWD] ",
|
|
8
|
+
running: "[TWD ...] ",
|
|
9
|
+
pass: "[TWD ✓] ",
|
|
10
|
+
fail: "[TWD ✗] "
|
|
11
|
+
};
|
|
12
|
+
function j(t) {
|
|
13
|
+
let f = null, c = null, u = !1, n = null;
|
|
14
|
+
function b() {
|
|
15
|
+
if (n && n.isConnected) return n;
|
|
16
|
+
const s = t.querySelector("link[rel='icon']");
|
|
17
|
+
if (s) n = s;
|
|
18
|
+
else {
|
|
19
|
+
const d = t.createElement("link");
|
|
20
|
+
d.rel = "icon", t.head.appendChild(d), n = d;
|
|
21
|
+
}
|
|
22
|
+
return n;
|
|
23
|
+
}
|
|
24
|
+
return {
|
|
25
|
+
save() {
|
|
26
|
+
if (c !== null) return;
|
|
27
|
+
const s = t.querySelector("link[rel='icon']");
|
|
28
|
+
u = s !== null, f = s?.href ?? null, c = t.title, n = s;
|
|
29
|
+
},
|
|
30
|
+
restore() {
|
|
31
|
+
c !== null && (t.title = c), !u && n && n.isConnected ? n.remove() : u && f !== null && n && n.setAttribute("href", f), f = null, c = null, u = !1, n = null;
|
|
32
|
+
},
|
|
33
|
+
set(s) {
|
|
34
|
+
b().setAttribute("href", z[s]);
|
|
35
|
+
const d = c ?? t.title;
|
|
36
|
+
t.title = U[s] + d;
|
|
37
|
+
}
|
|
38
|
+
};
|
|
3
39
|
}
|
|
4
|
-
function
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
return
|
|
40
|
+
function q(t) {
|
|
41
|
+
const f = t.now ?? (() => performance.now()), c = t.thresholdMs;
|
|
42
|
+
let u = null, n = null, b = !1;
|
|
43
|
+
return {
|
|
44
|
+
onTestStart(s) {
|
|
45
|
+
u = f(), n = s;
|
|
46
|
+
},
|
|
47
|
+
onTestEnd() {
|
|
48
|
+
if (u === null || n === null) return null;
|
|
49
|
+
const s = n, d = f() - u;
|
|
50
|
+
return u = null, n = null, c <= 0 || d <= c ? null : {
|
|
51
|
+
testName: s,
|
|
52
|
+
durationMs: d
|
|
53
|
+
};
|
|
54
|
+
},
|
|
55
|
+
checkThreshold() {
|
|
56
|
+
if (c <= 0 || u === null || n === null) return null;
|
|
57
|
+
const s = f() - u;
|
|
58
|
+
return s <= c ? null : {
|
|
59
|
+
testName: n,
|
|
60
|
+
durationMs: s
|
|
61
|
+
};
|
|
62
|
+
},
|
|
63
|
+
markAborted() {
|
|
64
|
+
b = !0;
|
|
65
|
+
},
|
|
66
|
+
isAborted() {
|
|
67
|
+
return b;
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
function H(t) {
|
|
72
|
+
return `${window.location.protocol === "https:" ? "wss:" : "ws:"}//${window.location.host}${t}`;
|
|
8
73
|
}
|
|
9
|
-
function
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
74
|
+
function E(t, f) {
|
|
75
|
+
if (!t.parent) return "";
|
|
76
|
+
const c = f.get(t.parent);
|
|
77
|
+
return c ? c.name : "";
|
|
78
|
+
}
|
|
79
|
+
function G(t) {
|
|
80
|
+
const f = t?.url ?? H(t?.path ?? "/__twd/ws"), c = t?.reconnect ?? !0, u = t?.reconnectInterval ?? 2e3, n = t?.log ?? !1, b = t?.maxTestDurationMs ?? 1e4, s = "[twd-relay]";
|
|
81
|
+
function d(...r) {
|
|
82
|
+
n && console.info(s, ...r);
|
|
13
83
|
}
|
|
14
|
-
function
|
|
15
|
-
console.warn(
|
|
84
|
+
function _(...r) {
|
|
85
|
+
console.warn(s, ...r);
|
|
16
86
|
}
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
87
|
+
const w = j(document);
|
|
88
|
+
let a = null, k = !1, N = null;
|
|
89
|
+
function l(r) {
|
|
90
|
+
a && a.readyState === WebSocket.OPEN && a.send(JSON.stringify(r));
|
|
20
91
|
}
|
|
21
|
-
function
|
|
92
|
+
function g() {
|
|
22
93
|
window.dispatchEvent(new CustomEvent("twd:state-change"));
|
|
23
94
|
}
|
|
24
|
-
async function
|
|
25
|
-
const o =
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
95
|
+
async function I(r, m = {}) {
|
|
96
|
+
const o = q({ thresholdMs: typeof m.maxTestDurationMs == "number" ? m.maxTestDurationMs : b });
|
|
97
|
+
let h = 0, S = 0, x = 0;
|
|
98
|
+
const C = performance.now();
|
|
99
|
+
let W;
|
|
100
|
+
function D(y) {
|
|
101
|
+
o.isAborted() || (o.markAborted(), l({
|
|
102
|
+
type: "run:aborted",
|
|
103
|
+
reason: "throttled",
|
|
104
|
+
durationMs: y.durationMs,
|
|
105
|
+
testName: y.testName
|
|
106
|
+
}), l({
|
|
107
|
+
type: "run:complete",
|
|
108
|
+
passed: h,
|
|
109
|
+
failed: S,
|
|
110
|
+
skipped: x,
|
|
111
|
+
duration: performance.now() - C
|
|
112
|
+
}), w.set("fail"), g(), clearInterval(W));
|
|
33
113
|
}
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
const d = Array.from(c.values()).filter((f) => f.type === "test").map((f) => f.name);
|
|
44
|
-
r({
|
|
45
|
-
type: "run:start",
|
|
46
|
-
testCount: 0
|
|
47
|
-
}), r({
|
|
114
|
+
W = setInterval(() => {
|
|
115
|
+
l({ type: "heartbeat" });
|
|
116
|
+
const y = o.checkThreshold();
|
|
117
|
+
y && D(y);
|
|
118
|
+
}, 3e3), w.set("running");
|
|
119
|
+
try {
|
|
120
|
+
const y = window.__TWD_STATE__;
|
|
121
|
+
if (!y) {
|
|
122
|
+
_("TWD not initialized — make sure twd-js is loaded before running tests"), l({
|
|
48
123
|
type: "error",
|
|
49
|
-
code: "
|
|
50
|
-
message:
|
|
51
|
-
}), r({
|
|
52
|
-
type: "run:complete",
|
|
53
|
-
passed: 0,
|
|
54
|
-
failed: 0,
|
|
55
|
-
skipped: 0,
|
|
56
|
-
duration: 0
|
|
124
|
+
code: "NO_TWD",
|
|
125
|
+
message: "TWD not initialized"
|
|
57
126
|
});
|
|
58
127
|
return;
|
|
59
128
|
}
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
type: "test:fail",
|
|
88
|
-
id: e.id,
|
|
89
|
-
name: e.name,
|
|
90
|
-
suite: m(e, c),
|
|
91
|
-
error: s.message,
|
|
92
|
-
duration: performance.now() - S
|
|
93
|
-
});
|
|
94
|
-
},
|
|
95
|
-
onSkip(e) {
|
|
96
|
-
b++, e.status = "skip", l(), r({
|
|
97
|
-
type: "test:skip",
|
|
98
|
-
id: e.id,
|
|
99
|
-
name: e.name,
|
|
100
|
-
suite: m(e, c)
|
|
101
|
-
});
|
|
102
|
-
},
|
|
103
|
-
onSuiteStart(e) {
|
|
104
|
-
e.status = "running", l();
|
|
105
|
-
},
|
|
106
|
-
onSuiteEnd(e) {
|
|
107
|
-
e.status = "idle", l();
|
|
129
|
+
const T = y.handlers;
|
|
130
|
+
let v;
|
|
131
|
+
if (r && r.length > 0) {
|
|
132
|
+
const e = r.map((p) => p.toLowerCase()), i = [];
|
|
133
|
+
for (const [, p] of T) if (p.type === "test") {
|
|
134
|
+
const A = p.name.toLowerCase();
|
|
135
|
+
e.some((J) => A.includes(J)) && i.push(p.id);
|
|
136
|
+
}
|
|
137
|
+
if (i.length === 0) {
|
|
138
|
+
const p = Array.from(T.values()).filter((A) => A.type === "test").map((A) => A.name);
|
|
139
|
+
l({
|
|
140
|
+
type: "run:start",
|
|
141
|
+
testCount: 0
|
|
142
|
+
}), l({
|
|
143
|
+
type: "error",
|
|
144
|
+
code: "NO_MATCH",
|
|
145
|
+
message: `No tests matched: ${JSON.stringify(r)}. Available tests: ${JSON.stringify(p)}`
|
|
146
|
+
}), l({
|
|
147
|
+
type: "run:complete",
|
|
148
|
+
passed: 0,
|
|
149
|
+
failed: 0,
|
|
150
|
+
skipped: 0,
|
|
151
|
+
duration: 0
|
|
152
|
+
}), w.set("pass");
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
v = i;
|
|
108
156
|
}
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
a ? await s.runByIds(a) : await s.runAll();
|
|
113
|
-
} catch (e) {
|
|
114
|
-
const s = e instanceof Error ? e.message : String(e);
|
|
115
|
-
h("Runner error:", s), r({
|
|
116
|
-
type: "error",
|
|
117
|
-
code: "RUNNER_ERROR",
|
|
118
|
-
message: s
|
|
157
|
+
l({
|
|
158
|
+
type: "run:start",
|
|
159
|
+
testCount: v ? v.length : Array.from(T.values()).filter((e) => e.type === "test").length
|
|
119
160
|
});
|
|
161
|
+
const F = {
|
|
162
|
+
onStart(e) {
|
|
163
|
+
o.isAborted() || (o.onTestStart(e.name), e.status = "running", g(), l({
|
|
164
|
+
type: "test:start",
|
|
165
|
+
id: e.id,
|
|
166
|
+
name: e.name,
|
|
167
|
+
suite: E(e, T)
|
|
168
|
+
}));
|
|
169
|
+
},
|
|
170
|
+
onPass(e) {
|
|
171
|
+
const i = o.onTestEnd();
|
|
172
|
+
if (!o.isAborted()) {
|
|
173
|
+
if (i) {
|
|
174
|
+
D(i);
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
h++, e.status = "pass", g(), l({
|
|
178
|
+
type: "test:pass",
|
|
179
|
+
id: e.id,
|
|
180
|
+
name: e.name,
|
|
181
|
+
suite: E(e, T),
|
|
182
|
+
duration: performance.now() - C
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
},
|
|
186
|
+
onFail(e, i) {
|
|
187
|
+
const p = o.onTestEnd();
|
|
188
|
+
if (!o.isAborted()) {
|
|
189
|
+
if (p) {
|
|
190
|
+
D(p);
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
S++, e.status = "fail", e.logs = [i.message], g(), l({
|
|
194
|
+
type: "test:fail",
|
|
195
|
+
id: e.id,
|
|
196
|
+
name: e.name,
|
|
197
|
+
suite: E(e, T),
|
|
198
|
+
error: i.message,
|
|
199
|
+
duration: performance.now() - C
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
},
|
|
203
|
+
onSkip(e) {
|
|
204
|
+
const i = o.onTestEnd();
|
|
205
|
+
if (!o.isAborted()) {
|
|
206
|
+
if (i) {
|
|
207
|
+
D(i);
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
x++, e.status = "skip", g(), l({
|
|
211
|
+
type: "test:skip",
|
|
212
|
+
id: e.id,
|
|
213
|
+
name: e.name,
|
|
214
|
+
suite: E(e, T)
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
},
|
|
218
|
+
onSuiteStart(e) {
|
|
219
|
+
o.isAborted() || (e.status = "running", g());
|
|
220
|
+
},
|
|
221
|
+
onSuiteEnd(e) {
|
|
222
|
+
o.isAborted() || (e.status = "idle", g());
|
|
223
|
+
}
|
|
224
|
+
};
|
|
225
|
+
try {
|
|
226
|
+
const { TestRunner: e } = await import("twd-js/runner"), i = new e(F);
|
|
227
|
+
v ? await i.runByIds(v) : await i.runAll();
|
|
228
|
+
} catch (e) {
|
|
229
|
+
const i = e instanceof Error ? e.message : String(e);
|
|
230
|
+
_("Runner error:", i), l({
|
|
231
|
+
type: "error",
|
|
232
|
+
code: "RUNNER_ERROR",
|
|
233
|
+
message: i
|
|
234
|
+
}), S++, o.onTestEnd();
|
|
235
|
+
}
|
|
236
|
+
const B = performance.now() - C;
|
|
237
|
+
o.isAborted() ? w.set("fail") : (l({
|
|
238
|
+
type: "run:complete",
|
|
239
|
+
passed: h,
|
|
240
|
+
failed: S,
|
|
241
|
+
skipped: x,
|
|
242
|
+
duration: B
|
|
243
|
+
}), w.set(S > 0 ? "fail" : "pass")), g();
|
|
244
|
+
} finally {
|
|
245
|
+
clearInterval(W);
|
|
120
246
|
}
|
|
121
|
-
const L = performance.now() - S;
|
|
122
|
-
r({
|
|
123
|
-
type: "run:complete",
|
|
124
|
-
passed: E,
|
|
125
|
-
failed: _,
|
|
126
|
-
skipped: b,
|
|
127
|
-
duration: L
|
|
128
|
-
}), l();
|
|
129
247
|
}
|
|
130
|
-
function
|
|
131
|
-
const
|
|
132
|
-
if (!
|
|
133
|
-
|
|
248
|
+
function O() {
|
|
249
|
+
const r = window.__TWD_STATE__;
|
|
250
|
+
if (!r) {
|
|
251
|
+
l({
|
|
134
252
|
type: "error",
|
|
135
253
|
code: "NO_TWD",
|
|
136
254
|
message: "TWD not initialized"
|
|
137
255
|
});
|
|
138
256
|
return;
|
|
139
257
|
}
|
|
140
|
-
const
|
|
141
|
-
for (const [,
|
|
142
|
-
id:
|
|
143
|
-
name:
|
|
144
|
-
suite:
|
|
145
|
-
status:
|
|
258
|
+
const m = r.handlers, o = [];
|
|
259
|
+
for (const [, h] of m) h.type === "test" && o.push({
|
|
260
|
+
id: h.id,
|
|
261
|
+
name: h.name,
|
|
262
|
+
suite: E(h, m),
|
|
263
|
+
status: h.status ?? "idle"
|
|
146
264
|
});
|
|
147
|
-
|
|
265
|
+
l({
|
|
148
266
|
type: "status:result",
|
|
149
|
-
tests:
|
|
267
|
+
tests: o
|
|
150
268
|
});
|
|
151
269
|
}
|
|
152
|
-
function
|
|
153
|
-
let
|
|
270
|
+
function L(r) {
|
|
271
|
+
let m;
|
|
154
272
|
try {
|
|
155
|
-
|
|
273
|
+
m = JSON.parse(r.data);
|
|
156
274
|
} catch {
|
|
157
275
|
return;
|
|
158
276
|
}
|
|
159
|
-
|
|
277
|
+
m.type === "run" ? (d("Received run command — running tests..."), I(Array.isArray(m.testNames) ? m.testNames : void 0, m)) : m.type === "status" && O();
|
|
160
278
|
}
|
|
161
|
-
function
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
},
|
|
279
|
+
function $() {
|
|
280
|
+
c && !k && (d(`Reconnecting in ${u}ms...`), N = setTimeout(() => {
|
|
281
|
+
R();
|
|
282
|
+
}, u));
|
|
165
283
|
}
|
|
166
|
-
function
|
|
167
|
-
|
|
168
|
-
|
|
284
|
+
function R() {
|
|
285
|
+
a && (a.readyState === WebSocket.OPEN || a.readyState === WebSocket.CONNECTING) || (k = !1, d("Connecting to", f), a = new WebSocket(f), a.addEventListener("open", () => {
|
|
286
|
+
l({
|
|
169
287
|
type: "hello",
|
|
170
288
|
role: "browser"
|
|
171
|
-
}),
|
|
172
|
-
}),
|
|
173
|
-
if (
|
|
174
|
-
|
|
289
|
+
}), w.save(), w.set("connected"), d("Connected to relay — ready to receive run/status commands");
|
|
290
|
+
}), a.addEventListener("message", L), a.addEventListener("close", (r) => {
|
|
291
|
+
if (a = null, w.restore(), r.reason === "Replaced by new browser") {
|
|
292
|
+
_("Another browser instance connected — this instance will not reconnect");
|
|
175
293
|
return;
|
|
176
294
|
}
|
|
177
|
-
|
|
178
|
-
}),
|
|
295
|
+
k || d("Disconnected", r.code ? `(code ${r.code})` : "", r.reason || ""), $();
|
|
296
|
+
}), a.addEventListener("error", () => {
|
|
179
297
|
}));
|
|
180
298
|
}
|
|
181
|
-
function
|
|
182
|
-
|
|
299
|
+
function P() {
|
|
300
|
+
k = !0, N && (clearTimeout(N), N = null), a && (a.close(1e3, "Client disconnecting"), a = null);
|
|
183
301
|
}
|
|
184
302
|
return {
|
|
185
|
-
connect:
|
|
186
|
-
disconnect:
|
|
303
|
+
connect: R,
|
|
304
|
+
disconnect: P,
|
|
187
305
|
get connected() {
|
|
188
|
-
return
|
|
306
|
+
return a !== null && a.readyState === WebSocket.OPEN;
|
|
189
307
|
}
|
|
190
308
|
};
|
|
191
309
|
}
|
|
192
310
|
export {
|
|
193
|
-
|
|
311
|
+
G as createBrowserClient
|
|
194
312
|
};
|
package/dist/cli.js
CHANGED
|
@@ -10,6 +10,10 @@ function createTwdRelay(server, options) {
|
|
|
10
10
|
let browser = null;
|
|
11
11
|
const clients = /* @__PURE__ */ new Set();
|
|
12
12
|
let runInProgress = false;
|
|
13
|
+
let lastHeartbeat = null;
|
|
14
|
+
let heartbeatCheckTimer = null;
|
|
15
|
+
const HEARTBEAT_TIMEOUT_MS = 12e4;
|
|
16
|
+
const HEARTBEAT_CHECK_INTERVAL_MS = 1e4;
|
|
13
17
|
function sendError(ws, code, message) {
|
|
14
18
|
const msg = {
|
|
15
19
|
type: "error",
|
|
@@ -34,6 +38,28 @@ function createTwdRelay(server, options) {
|
|
|
34
38
|
browser: false
|
|
35
39
|
}));
|
|
36
40
|
}
|
|
41
|
+
function startHeartbeatTracking() {
|
|
42
|
+
lastHeartbeat = Date.now();
|
|
43
|
+
heartbeatCheckTimer = setInterval(() => {
|
|
44
|
+
if (runInProgress && lastHeartbeat !== null) {
|
|
45
|
+
if (Date.now() - lastHeartbeat > HEARTBEAT_TIMEOUT_MS) {
|
|
46
|
+
runInProgress = false;
|
|
47
|
+
stopHeartbeatTracking();
|
|
48
|
+
broadcastToClients(JSON.stringify({
|
|
49
|
+
type: "run:abandoned",
|
|
50
|
+
reason: "heartbeat_timeout"
|
|
51
|
+
}));
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}, HEARTBEAT_CHECK_INTERVAL_MS);
|
|
55
|
+
}
|
|
56
|
+
function stopHeartbeatTracking() {
|
|
57
|
+
lastHeartbeat = null;
|
|
58
|
+
if (heartbeatCheckTimer !== null) {
|
|
59
|
+
clearInterval(heartbeatCheckTimer);
|
|
60
|
+
heartbeatCheckTimer = null;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
37
63
|
function handleBrowserMessage(data) {
|
|
38
64
|
let parsed;
|
|
39
65
|
try {
|
|
@@ -41,7 +67,14 @@ function createTwdRelay(server, options) {
|
|
|
41
67
|
} catch {
|
|
42
68
|
return;
|
|
43
69
|
}
|
|
44
|
-
if (parsed.type === "
|
|
70
|
+
if (parsed.type === "heartbeat") {
|
|
71
|
+
lastHeartbeat = Date.now();
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
if (parsed.type === "run:complete") {
|
|
75
|
+
runInProgress = false;
|
|
76
|
+
stopHeartbeatTracking();
|
|
77
|
+
}
|
|
45
78
|
broadcastToClients(data);
|
|
46
79
|
}
|
|
47
80
|
function handleClientMessage(ws, data) {
|
|
@@ -62,10 +95,11 @@ function createTwdRelay(server, options) {
|
|
|
62
95
|
return;
|
|
63
96
|
}
|
|
64
97
|
if (runInProgress) {
|
|
65
|
-
sendError(ws, "RUN_IN_PROGRESS", "A test run is already in progress");
|
|
98
|
+
sendError(ws, "RUN_IN_PROGRESS", "A test run is already in progress. If the previous run appears stuck, the browser tab may be backgrounded and throttled — foreground the TWD tab (identified by the \"[TWD …]\" title prefix) or reload it. The relay also auto-clears the lock after 120s of heartbeat silence.");
|
|
66
99
|
return;
|
|
67
100
|
}
|
|
68
101
|
runInProgress = true;
|
|
102
|
+
startHeartbeatTracking();
|
|
69
103
|
browser.send(data);
|
|
70
104
|
return;
|
|
71
105
|
}
|
|
@@ -100,10 +134,12 @@ function createTwdRelay(server, options) {
|
|
|
100
134
|
if (browser && browser.readyState === WebSocket$1.OPEN) browser.close(1e3, "Replaced by new browser");
|
|
101
135
|
browser = ws;
|
|
102
136
|
runInProgress = false;
|
|
137
|
+
stopHeartbeatTracking();
|
|
103
138
|
ws.on("close", () => {
|
|
104
139
|
if (browser === ws) {
|
|
105
140
|
browser = null;
|
|
106
141
|
runInProgress = false;
|
|
142
|
+
stopHeartbeatTracking();
|
|
107
143
|
notifyBrowserDisconnected();
|
|
108
144
|
}
|
|
109
145
|
});
|
|
@@ -144,6 +180,7 @@ function createTwdRelay(server, options) {
|
|
|
144
180
|
return {
|
|
145
181
|
close() {
|
|
146
182
|
server.removeListener("upgrade", upgradeHandler);
|
|
183
|
+
stopHeartbeatTracking();
|
|
147
184
|
for (const client of clients) client.close(1e3, "Relay shutting down");
|
|
148
185
|
clients.clear();
|
|
149
186
|
if (browser && browser.readyState === WebSocket$1.OPEN) browser.close(1e3, "Relay shutting down");
|
|
@@ -162,7 +199,7 @@ function createTwdRelay(server, options) {
|
|
|
162
199
|
//#endregion
|
|
163
200
|
//#region src/cli/run.ts
|
|
164
201
|
function run(options) {
|
|
165
|
-
const { port, timeout, path, host, testNames } = options;
|
|
202
|
+
const { port, timeout, path, host, testNames, maxTestDurationMs } = options;
|
|
166
203
|
const url = `ws://${host}:${port}${path}`;
|
|
167
204
|
console.log(`Connecting to ${url}...`);
|
|
168
205
|
const ws = new WebSocket(url);
|
|
@@ -192,6 +229,7 @@ function run(options) {
|
|
|
192
229
|
scope: "all"
|
|
193
230
|
};
|
|
194
231
|
if (testNames?.length) runMsg.testNames = testNames;
|
|
232
|
+
if (maxTestDurationMs !== void 0) runMsg.maxTestDurationMs = maxTestDurationMs;
|
|
195
233
|
ws.send(JSON.stringify(runMsg));
|
|
196
234
|
} else if (!msg.browser) console.log("Waiting for browser to connect...");
|
|
197
235
|
break;
|
|
@@ -223,6 +261,18 @@ function run(options) {
|
|
|
223
261
|
process.exit(failed || msg.failed > 0 ? 1 : 0);
|
|
224
262
|
break;
|
|
225
263
|
}
|
|
264
|
+
case "run:aborted": {
|
|
265
|
+
failed = true;
|
|
266
|
+
const seconds = typeof msg.durationMs === "number" ? (msg.durationMs / 1e3).toFixed(1) : "?";
|
|
267
|
+
console.error(`\nRun aborted: test "${msg.testName ?? "?"}" ran for ${seconds}s — threshold exceeded.\nThe TWD browser tab is likely backgrounded and throttled by the browser.\nForeground the TWD tab (identified by the "[TWD …]" title prefix) and keep it active, then retry.\nFor unattended runs, prefer \`twd-cli\` which drives a headless browser with no tab throttling.`);
|
|
268
|
+
break;
|
|
269
|
+
}
|
|
270
|
+
case "run:abandoned":
|
|
271
|
+
console.error("\nRun abandoned — browser tab appears frozen. Refresh the browser tab and retry.");
|
|
272
|
+
clearTimeout(timer);
|
|
273
|
+
ws.close();
|
|
274
|
+
process.exit(1);
|
|
275
|
+
break;
|
|
226
276
|
case "error":
|
|
227
277
|
console.error(`Error [${msg.code}]: ${msg.message}`);
|
|
228
278
|
break;
|
|
@@ -266,11 +316,14 @@ Options for serve:
|
|
|
266
316
|
--path <path> WebSocket path (default: /__twd/ws)
|
|
267
317
|
|
|
268
318
|
Options for run:
|
|
269
|
-
--port <port>
|
|
270
|
-
--host <host>
|
|
271
|
-
--path <path>
|
|
272
|
-
--timeout <ms>
|
|
273
|
-
--test <name>
|
|
319
|
+
--port <port> Relay port to connect to (default: 5173)
|
|
320
|
+
--host <host> Relay host to connect to (default: localhost)
|
|
321
|
+
--path <path> WebSocket path (default: /__twd/ws)
|
|
322
|
+
--timeout <ms> Timeout in ms (default: 180000)
|
|
323
|
+
--test <name> Filter tests by name substring (repeatable)
|
|
324
|
+
--max-test-duration <ms> Abort if any single test exceeds this many
|
|
325
|
+
ms (default from browser client, typically
|
|
326
|
+
10000; 0 disables)
|
|
274
327
|
|
|
275
328
|
Examples:
|
|
276
329
|
twd-relay # start relay on port 9876
|
|
@@ -280,7 +333,9 @@ Examples:
|
|
|
280
333
|
twd-relay run --host 192.168.1.10 --path /app/__twd/ws
|
|
281
334
|
twd-relay run --timeout 30000 # custom timeout
|
|
282
335
|
twd-relay run --test "login" # run tests matching "login"
|
|
283
|
-
twd-relay run --test "login" --test "signup" # run multiple
|
|
336
|
+
twd-relay run --test "login" --test "signup" # run multiple
|
|
337
|
+
twd-relay run --max-test-duration 30000 # raise abort threshold to 30s
|
|
338
|
+
twd-relay run --max-test-duration 0 # disable abort detection`);
|
|
284
339
|
}
|
|
285
340
|
if (args.includes("--help") || args.includes("-h")) {
|
|
286
341
|
printHelp();
|
|
@@ -302,12 +357,22 @@ if (subcommand === "run") {
|
|
|
302
357
|
process.exit(1);
|
|
303
358
|
}
|
|
304
359
|
const testNames = parseFlagAll("--test");
|
|
360
|
+
const maxDurationStr = parseFlag("--max-test-duration");
|
|
361
|
+
let maxTestDurationMs;
|
|
362
|
+
if (maxDurationStr !== void 0) {
|
|
363
|
+
maxTestDurationMs = parseInt(maxDurationStr, 10);
|
|
364
|
+
if (isNaN(maxTestDurationMs)) {
|
|
365
|
+
console.error("Invalid --max-test-duration value:", maxDurationStr);
|
|
366
|
+
process.exit(1);
|
|
367
|
+
}
|
|
368
|
+
}
|
|
305
369
|
run({
|
|
306
370
|
port,
|
|
307
371
|
timeout,
|
|
308
372
|
path: pathFlag,
|
|
309
373
|
host: hostFlag,
|
|
310
|
-
testNames: testNames.length > 0 ? testNames : void 0
|
|
374
|
+
testNames: testNames.length > 0 ? testNames : void 0,
|
|
375
|
+
maxTestDurationMs
|
|
311
376
|
});
|
|
312
377
|
} else if (!subcommand || subcommand === "serve") {
|
|
313
378
|
const portStr = parseFlag("--port");
|
package/dist/index.cjs.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
Object.defineProperty(exports,Symbol.toStringTag,{value:"Module"});const e=require("./relay-
|
|
1
|
+
Object.defineProperty(exports,Symbol.toStringTag,{value:"Module"});const e=require("./relay-CK82tv0w.cjs");exports.createTwdRelay=e.createTwdRelay;
|
package/dist/index.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { Server } from 'http';
|
|
2
2
|
|
|
3
|
-
export declare type BrowserEvent = ConnectedEvent | RunStartEvent | TestStartEvent | TestPassEvent | TestFailEvent | TestSkipEvent | RunCompleteEvent;
|
|
3
|
+
export declare type BrowserEvent = ConnectedEvent | RunStartEvent | TestStartEvent | TestPassEvent | TestFailEvent | TestSkipEvent | RunCompleteEvent | RunAbandonedEvent | RunAbortedEvent;
|
|
4
4
|
|
|
5
5
|
export declare type Command = RunCommand | StatusCommand;
|
|
6
6
|
|
|
@@ -11,6 +11,10 @@ export declare interface ConnectedEvent {
|
|
|
11
11
|
|
|
12
12
|
export declare function createTwdRelay(server: Server, options?: TwdRelayOptions): TwdRelay;
|
|
13
13
|
|
|
14
|
+
declare interface HeartbeatMessage {
|
|
15
|
+
type: 'heartbeat';
|
|
16
|
+
}
|
|
17
|
+
|
|
14
18
|
export declare interface HelloBrowserMessage {
|
|
15
19
|
type: 'hello';
|
|
16
20
|
role: 'browser';
|
|
@@ -23,12 +27,28 @@ export declare interface HelloClientMessage {
|
|
|
23
27
|
|
|
24
28
|
export declare type HelloMessage = HelloBrowserMessage | HelloClientMessage;
|
|
25
29
|
|
|
26
|
-
export declare type InboundMessage = HelloMessage | Command | BrowserEvent;
|
|
30
|
+
export declare type InboundMessage = HelloMessage | Command | HeartbeatMessage | BrowserEvent;
|
|
31
|
+
|
|
32
|
+
declare interface RunAbandonedEvent {
|
|
33
|
+
type: 'run:abandoned';
|
|
34
|
+
reason: 'heartbeat_timeout';
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
declare interface RunAbortedEvent {
|
|
38
|
+
type: 'run:aborted';
|
|
39
|
+
reason: 'throttled';
|
|
40
|
+
durationMs: number;
|
|
41
|
+
testName: string;
|
|
42
|
+
}
|
|
27
43
|
|
|
28
44
|
export declare interface RunCommand {
|
|
29
45
|
type: 'run';
|
|
30
46
|
scope: 'all';
|
|
31
47
|
testNames?: string[];
|
|
48
|
+
/** Max wall-clock ms any single test may run before the browser aborts
|
|
49
|
+
* the run with reason 'throttled'. 0 disables detection. Omit to let
|
|
50
|
+
* the browser use its own default (10000). */
|
|
51
|
+
maxTestDurationMs?: number;
|
|
32
52
|
}
|
|
33
53
|
|
|
34
54
|
export declare interface RunCompleteEvent {
|
package/dist/index.es.js
CHANGED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
let o=require("ws");var P="/__twd/ws";function D(g,N){const E=N?.path??P,S=N?.onError,p=new o.WebSocketServer({noServer:!0});let t=null;const c=new Set;let l=!1,d=null,y=null;const O=12e4,m=1e4;function s(e,r,n){const a={type:"error",code:r,message:n};e.readyState===o.WebSocket.OPEN&&e.send(JSON.stringify(a))}function b(e){for(const r of c)r.readyState===o.WebSocket.OPEN&&r.send(e)}function T(e){const r={type:"connected",browser:t!==null&&t.readyState===o.WebSocket.OPEN};e.readyState===o.WebSocket.OPEN&&e.send(JSON.stringify(r))}function _(){b(JSON.stringify({type:"connected",browser:!1}))}function k(){d=Date.now(),y=setInterval(()=>{l&&d!==null&&Date.now()-d>O&&(l=!1,f(),b(JSON.stringify({type:"run:abandoned",reason:"heartbeat_timeout"})))},m)}function f(){d=null,y!==null&&(clearInterval(y),y=null)}function A(e){let r;try{r=JSON.parse(e)}catch{return}if(r.type==="heartbeat"){d=Date.now();return}r.type==="run:complete"&&(l=!1,f()),b(e)}function I(e,r){let n;try{n=JSON.parse(r)}catch{s(e,"INVALID_MESSAGE","Invalid JSON");return}if(!n.type){s(e,"INVALID_MESSAGE",'Missing "type" field');return}if(n.type==="run"){if(!t||t.readyState!==o.WebSocket.OPEN){s(e,"NO_BROWSER","No browser connected");return}if(l){s(e,"RUN_IN_PROGRESS",'A test run is already in progress. If the previous run appears stuck, the browser tab may be backgrounded and throttled — foreground the TWD tab (identified by the "[TWD …]" title prefix) or reload it. The relay also auto-clears the lock after 120s of heartbeat silence.');return}l=!0,k(),t.send(r);return}if(n.type==="status"){if(!t||t.readyState!==o.WebSocket.OPEN){s(e,"NO_BROWSER","No browser connected");return}t.send(r);return}s(e,"UNKNOWN_COMMAND",`Unknown command: ${n.type}`)}function R(e){let r=!1;const n=a=>{const W=typeof a=="string"?a:a.toString();if(!r){let u;try{u=JSON.parse(W)}catch{s(e,"INVALID_MESSAGE","Invalid JSON");return}if(u.type!=="hello"||u.role!=="browser"&&u.role!=="client"){s(e,"INVALID_MESSAGE",'First message must be a hello with role "browser" or "client"');return}r=!0,u.role==="browser"?(t&&t.readyState===o.WebSocket.OPEN&&t.close(1e3,"Replaced by new browser"),t=e,l=!1,f(),e.on("close",()=>{t===e&&(t=null,l=!1,f(),_())}),b(JSON.stringify({type:"connected",browser:!0})),e.on("message",i=>{A(typeof i=="string"?i:i.toString())})):(c.add(e),e.on("close",()=>{c.delete(e)}),T(e),e.on("message",i=>{I(e,typeof i=="string"?i:i.toString())})),e.removeListener("message",n)}};e.on("message",n),e.on("error",a=>{S&&S(a)})}const h=(e,r,n)=>{new URL(e.url??"/",`http://${e.headers.host??"localhost"}`).pathname===E&&p.handleUpgrade(e,r,n,a=>{R(a)})};return g.on("upgrade",h),p.on("error",e=>{S&&S(e)}),{close(){g.removeListener("upgrade",h),f();for(const e of c)e.close(1e3,"Relay shutting down");c.clear(),t&&t.readyState===o.WebSocket.OPEN&&t.close(1e3,"Relay shutting down"),t=null,l=!1,p.close()},get browserConnected(){return t!==null&&t.readyState===o.WebSocket.OPEN},get clientCount(){return c.size}}}Object.defineProperty(exports,"createTwdRelay",{enumerable:!0,get:function(){return D}});
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { WebSocket as a, WebSocketServer as P } from "ws";
|
|
2
|
+
var C = "/__twd/ws";
|
|
3
|
+
function k(N, h) {
|
|
4
|
+
const b = h?.path ?? C, y = h?.onError, g = new P({ noServer: !0 });
|
|
5
|
+
let t = null;
|
|
6
|
+
const c = /* @__PURE__ */ new Set();
|
|
7
|
+
let s = !1, f = null, S = null;
|
|
8
|
+
const O = 12e4, m = 1e4;
|
|
9
|
+
function l(e, r, n) {
|
|
10
|
+
const o = {
|
|
11
|
+
type: "error",
|
|
12
|
+
code: r,
|
|
13
|
+
message: n
|
|
14
|
+
};
|
|
15
|
+
e.readyState === a.OPEN && e.send(JSON.stringify(o));
|
|
16
|
+
}
|
|
17
|
+
function p(e) {
|
|
18
|
+
for (const r of c) r.readyState === a.OPEN && r.send(e);
|
|
19
|
+
}
|
|
20
|
+
function _(e) {
|
|
21
|
+
const r = {
|
|
22
|
+
type: "connected",
|
|
23
|
+
browser: t !== null && t.readyState === a.OPEN
|
|
24
|
+
};
|
|
25
|
+
e.readyState === a.OPEN && e.send(JSON.stringify(r));
|
|
26
|
+
}
|
|
27
|
+
function A() {
|
|
28
|
+
p(JSON.stringify({
|
|
29
|
+
type: "connected",
|
|
30
|
+
browser: !1
|
|
31
|
+
}));
|
|
32
|
+
}
|
|
33
|
+
function I() {
|
|
34
|
+
f = Date.now(), S = setInterval(() => {
|
|
35
|
+
s && f !== null && Date.now() - f > O && (s = !1, d(), p(JSON.stringify({
|
|
36
|
+
type: "run:abandoned",
|
|
37
|
+
reason: "heartbeat_timeout"
|
|
38
|
+
})));
|
|
39
|
+
}, m);
|
|
40
|
+
}
|
|
41
|
+
function d() {
|
|
42
|
+
f = null, S !== null && (clearInterval(S), S = null);
|
|
43
|
+
}
|
|
44
|
+
function T(e) {
|
|
45
|
+
let r;
|
|
46
|
+
try {
|
|
47
|
+
r = JSON.parse(e);
|
|
48
|
+
} catch {
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
if (r.type === "heartbeat") {
|
|
52
|
+
f = Date.now();
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
r.type === "run:complete" && (s = !1, d()), p(e);
|
|
56
|
+
}
|
|
57
|
+
function R(e, r) {
|
|
58
|
+
let n;
|
|
59
|
+
try {
|
|
60
|
+
n = JSON.parse(r);
|
|
61
|
+
} catch {
|
|
62
|
+
l(e, "INVALID_MESSAGE", "Invalid JSON");
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
if (!n.type) {
|
|
66
|
+
l(e, "INVALID_MESSAGE", 'Missing "type" field');
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
if (n.type === "run") {
|
|
70
|
+
if (!t || t.readyState !== a.OPEN) {
|
|
71
|
+
l(e, "NO_BROWSER", "No browser connected");
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
if (s) {
|
|
75
|
+
l(e, "RUN_IN_PROGRESS", 'A test run is already in progress. If the previous run appears stuck, the browser tab may be backgrounded and throttled — foreground the TWD tab (identified by the "[TWD …]" title prefix) or reload it. The relay also auto-clears the lock after 120s of heartbeat silence.');
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
s = !0, I(), t.send(r);
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
if (n.type === "status") {
|
|
82
|
+
if (!t || t.readyState !== a.OPEN) {
|
|
83
|
+
l(e, "NO_BROWSER", "No browser connected");
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
t.send(r);
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
l(e, "UNKNOWN_COMMAND", `Unknown command: ${n.type}`);
|
|
90
|
+
}
|
|
91
|
+
function D(e) {
|
|
92
|
+
let r = !1;
|
|
93
|
+
const n = (o) => {
|
|
94
|
+
const M = typeof o == "string" ? o : o.toString();
|
|
95
|
+
if (!r) {
|
|
96
|
+
let u;
|
|
97
|
+
try {
|
|
98
|
+
u = JSON.parse(M);
|
|
99
|
+
} catch {
|
|
100
|
+
l(e, "INVALID_MESSAGE", "Invalid JSON");
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
if (u.type !== "hello" || u.role !== "browser" && u.role !== "client") {
|
|
104
|
+
l(e, "INVALID_MESSAGE", 'First message must be a hello with role "browser" or "client"');
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
r = !0, u.role === "browser" ? (t && t.readyState === a.OPEN && t.close(1e3, "Replaced by new browser"), t = e, s = !1, d(), e.on("close", () => {
|
|
108
|
+
t === e && (t = null, s = !1, d(), A());
|
|
109
|
+
}), p(JSON.stringify({
|
|
110
|
+
type: "connected",
|
|
111
|
+
browser: !0
|
|
112
|
+
})), e.on("message", (i) => {
|
|
113
|
+
T(typeof i == "string" ? i : i.toString());
|
|
114
|
+
})) : (c.add(e), e.on("close", () => {
|
|
115
|
+
c.delete(e);
|
|
116
|
+
}), _(e), e.on("message", (i) => {
|
|
117
|
+
R(e, typeof i == "string" ? i : i.toString());
|
|
118
|
+
})), e.removeListener("message", n);
|
|
119
|
+
}
|
|
120
|
+
};
|
|
121
|
+
e.on("message", n), e.on("error", (o) => {
|
|
122
|
+
y && y(o);
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
const E = (e, r, n) => {
|
|
126
|
+
new URL(e.url ?? "/", `http://${e.headers.host ?? "localhost"}`).pathname === b && g.handleUpgrade(e, r, n, (o) => {
|
|
127
|
+
D(o);
|
|
128
|
+
});
|
|
129
|
+
};
|
|
130
|
+
return N.on("upgrade", E), g.on("error", (e) => {
|
|
131
|
+
y && y(e);
|
|
132
|
+
}), {
|
|
133
|
+
close() {
|
|
134
|
+
N.removeListener("upgrade", E), d();
|
|
135
|
+
for (const e of c) e.close(1e3, "Relay shutting down");
|
|
136
|
+
c.clear(), t && t.readyState === a.OPEN && t.close(1e3, "Relay shutting down"), t = null, s = !1, g.close();
|
|
137
|
+
},
|
|
138
|
+
get browserConnected() {
|
|
139
|
+
return t !== null && t.readyState === a.OPEN;
|
|
140
|
+
},
|
|
141
|
+
get clientCount() {
|
|
142
|
+
return c.size;
|
|
143
|
+
}
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
export {
|
|
147
|
+
k as t
|
|
148
|
+
};
|
package/dist/vite.cjs.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
Object.defineProperty(exports,Symbol.toStringTag,{value:"Module"});const l=require("./relay-
|
|
1
|
+
Object.defineProperty(exports,Symbol.toStringTag,{value:"Module"});const l=require("./relay-CK82tv0w.cjs");function n(r){let t="/";return{name:"twd-relay",configResolved(e){t=e.base},configureServer(e){if(!e.httpServer)return;const o=r?.path??t.replace(/\/$/,"")+"/__twd/ws",a=l.createTwdRelay(e.httpServer,{path:o});e.httpServer.on("close",()=>a.close())}}}exports.twdRemote=n;
|
package/dist/vite.es.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "twd-relay",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.1.1",
|
|
4
4
|
"description": "WebSocket relay for TWD — enables AI agents and external tools to run and observe in-browser tests",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "BRIKEV",
|
|
@@ -66,6 +66,7 @@
|
|
|
66
66
|
"@types/ws": "^8.18.1",
|
|
67
67
|
"@vitest/coverage-v8": "^4.1.0",
|
|
68
68
|
"esbuild": "^0.27.4",
|
|
69
|
+
"happy-dom": "^15.11.7",
|
|
69
70
|
"typescript": "^5.9.3",
|
|
70
71
|
"vite": "^8.0.1",
|
|
71
72
|
"vite-plugin-dts": "^4.5.4",
|
package/dist/relay-C8UTSEbG.js
DELETED
|
@@ -1,132 +0,0 @@
|
|
|
1
|
-
import { WebSocket as s, WebSocketServer as I } from "ws";
|
|
2
|
-
var P = "/__twd/ws";
|
|
3
|
-
function _(y, p) {
|
|
4
|
-
const g = p?.path ?? P, f = p?.onError, u = new I({ noServer: !0 });
|
|
5
|
-
let t = null;
|
|
6
|
-
const a = /* @__PURE__ */ new Set();
|
|
7
|
-
let c = !1;
|
|
8
|
-
function i(e, n, r) {
|
|
9
|
-
const o = {
|
|
10
|
-
type: "error",
|
|
11
|
-
code: n,
|
|
12
|
-
message: r
|
|
13
|
-
};
|
|
14
|
-
e.readyState === s.OPEN && e.send(JSON.stringify(o));
|
|
15
|
-
}
|
|
16
|
-
function S(e) {
|
|
17
|
-
for (const n of a) n.readyState === s.OPEN && n.send(e);
|
|
18
|
-
}
|
|
19
|
-
function O(e) {
|
|
20
|
-
const n = {
|
|
21
|
-
type: "connected",
|
|
22
|
-
browser: t !== null && t.readyState === s.OPEN
|
|
23
|
-
};
|
|
24
|
-
e.readyState === s.OPEN && e.send(JSON.stringify(n));
|
|
25
|
-
}
|
|
26
|
-
function E() {
|
|
27
|
-
S(JSON.stringify({
|
|
28
|
-
type: "connected",
|
|
29
|
-
browser: !1
|
|
30
|
-
}));
|
|
31
|
-
}
|
|
32
|
-
function h(e) {
|
|
33
|
-
let n;
|
|
34
|
-
try {
|
|
35
|
-
n = JSON.parse(e);
|
|
36
|
-
} catch {
|
|
37
|
-
return;
|
|
38
|
-
}
|
|
39
|
-
n.type === "run:complete" && (c = !1), S(e);
|
|
40
|
-
}
|
|
41
|
-
function b(e, n) {
|
|
42
|
-
let r;
|
|
43
|
-
try {
|
|
44
|
-
r = JSON.parse(n);
|
|
45
|
-
} catch {
|
|
46
|
-
i(e, "INVALID_MESSAGE", "Invalid JSON");
|
|
47
|
-
return;
|
|
48
|
-
}
|
|
49
|
-
if (!r.type) {
|
|
50
|
-
i(e, "INVALID_MESSAGE", 'Missing "type" field');
|
|
51
|
-
return;
|
|
52
|
-
}
|
|
53
|
-
if (r.type === "run") {
|
|
54
|
-
if (!t || t.readyState !== s.OPEN) {
|
|
55
|
-
i(e, "NO_BROWSER", "No browser connected");
|
|
56
|
-
return;
|
|
57
|
-
}
|
|
58
|
-
if (c) {
|
|
59
|
-
i(e, "RUN_IN_PROGRESS", "A test run is already in progress");
|
|
60
|
-
return;
|
|
61
|
-
}
|
|
62
|
-
c = !0, t.send(n);
|
|
63
|
-
return;
|
|
64
|
-
}
|
|
65
|
-
if (r.type === "status") {
|
|
66
|
-
if (!t || t.readyState !== s.OPEN) {
|
|
67
|
-
i(e, "NO_BROWSER", "No browser connected");
|
|
68
|
-
return;
|
|
69
|
-
}
|
|
70
|
-
t.send(n);
|
|
71
|
-
return;
|
|
72
|
-
}
|
|
73
|
-
i(e, "UNKNOWN_COMMAND", `Unknown command: ${r.type}`);
|
|
74
|
-
}
|
|
75
|
-
function m(e) {
|
|
76
|
-
let n = !1;
|
|
77
|
-
const r = (o) => {
|
|
78
|
-
const A = typeof o == "string" ? o : o.toString();
|
|
79
|
-
if (!n) {
|
|
80
|
-
let d;
|
|
81
|
-
try {
|
|
82
|
-
d = JSON.parse(A);
|
|
83
|
-
} catch {
|
|
84
|
-
i(e, "INVALID_MESSAGE", "Invalid JSON");
|
|
85
|
-
return;
|
|
86
|
-
}
|
|
87
|
-
if (d.type !== "hello" || d.role !== "browser" && d.role !== "client") {
|
|
88
|
-
i(e, "INVALID_MESSAGE", 'First message must be a hello with role "browser" or "client"');
|
|
89
|
-
return;
|
|
90
|
-
}
|
|
91
|
-
n = !0, d.role === "browser" ? (t && t.readyState === s.OPEN && t.close(1e3, "Replaced by new browser"), t = e, c = !1, e.on("close", () => {
|
|
92
|
-
t === e && (t = null, c = !1, E());
|
|
93
|
-
}), S(JSON.stringify({
|
|
94
|
-
type: "connected",
|
|
95
|
-
browser: !0
|
|
96
|
-
})), e.on("message", (l) => {
|
|
97
|
-
h(typeof l == "string" ? l : l.toString());
|
|
98
|
-
})) : (a.add(e), e.on("close", () => {
|
|
99
|
-
a.delete(e);
|
|
100
|
-
}), O(e), e.on("message", (l) => {
|
|
101
|
-
b(e, typeof l == "string" ? l : l.toString());
|
|
102
|
-
})), e.removeListener("message", r);
|
|
103
|
-
}
|
|
104
|
-
};
|
|
105
|
-
e.on("message", r), e.on("error", (o) => {
|
|
106
|
-
f && f(o);
|
|
107
|
-
});
|
|
108
|
-
}
|
|
109
|
-
const N = (e, n, r) => {
|
|
110
|
-
new URL(e.url ?? "/", `http://${e.headers.host ?? "localhost"}`).pathname === g && u.handleUpgrade(e, n, r, (o) => {
|
|
111
|
-
m(o);
|
|
112
|
-
});
|
|
113
|
-
};
|
|
114
|
-
return y.on("upgrade", N), u.on("error", (e) => {
|
|
115
|
-
f && f(e);
|
|
116
|
-
}), {
|
|
117
|
-
close() {
|
|
118
|
-
y.removeListener("upgrade", N);
|
|
119
|
-
for (const e of a) e.close(1e3, "Relay shutting down");
|
|
120
|
-
a.clear(), t && t.readyState === s.OPEN && t.close(1e3, "Relay shutting down"), t = null, c = !1, u.close();
|
|
121
|
-
},
|
|
122
|
-
get browserConnected() {
|
|
123
|
-
return t !== null && t.readyState === s.OPEN;
|
|
124
|
-
},
|
|
125
|
-
get clientCount() {
|
|
126
|
-
return a.size;
|
|
127
|
-
}
|
|
128
|
-
};
|
|
129
|
-
}
|
|
130
|
-
export {
|
|
131
|
-
_ as t
|
|
132
|
-
};
|
package/dist/relay-CpUORBd7.cjs
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
let o=require("ws");var R="/__twd/ws";function W(y,N){const p=N?.path??R,f=N?.onError,u=new o.WebSocketServer({noServer:!0});let t=null;const l=new Set;let a=!1;function c(e,n,r){const s={type:"error",code:n,message:r};e.readyState===o.WebSocket.OPEN&&e.send(JSON.stringify(s))}function S(e){for(const n of l)n.readyState===o.WebSocket.OPEN&&n.send(e)}function b(e){const n={type:"connected",browser:t!==null&&t.readyState===o.WebSocket.OPEN};e.readyState===o.WebSocket.OPEN&&e.send(JSON.stringify(n))}function O(){S(JSON.stringify({type:"connected",browser:!1}))}function E(e){let n;try{n=JSON.parse(e)}catch{return}n.type==="run:complete"&&(a=!1),S(e)}function h(e,n){let r;try{r=JSON.parse(n)}catch{c(e,"INVALID_MESSAGE","Invalid JSON");return}if(!r.type){c(e,"INVALID_MESSAGE",'Missing "type" field');return}if(r.type==="run"){if(!t||t.readyState!==o.WebSocket.OPEN){c(e,"NO_BROWSER","No browser connected");return}if(a){c(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){c(e,"NO_BROWSER","No browser connected");return}t.send(n);return}c(e,"UNKNOWN_COMMAND",`Unknown command: ${r.type}`)}function m(e){let n=!1;const r=s=>{const P=typeof s=="string"?s:s.toString();if(!n){let d;try{d=JSON.parse(P)}catch{c(e,"INVALID_MESSAGE","Invalid JSON");return}if(d.type!=="hello"||d.role!=="browser"&&d.role!=="client"){c(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,O())}),S(JSON.stringify({type:"connected",browser:!0})),e.on("message",i=>{E(typeof i=="string"?i:i.toString())})):(l.add(e),e.on("close",()=>{l.delete(e)}),b(e),e.on("message",i=>{h(e,typeof i=="string"?i:i.toString())})),e.removeListener("message",r)}};e.on("message",r),e.on("error",s=>{f&&f(s)})}const g=(e,n,r)=>{new URL(e.url??"/",`http://${e.headers.host??"localhost"}`).pathname===p&&u.handleUpgrade(e,n,r,s=>{m(s)})};return y.on("upgrade",g),u.on("error",e=>{f&&f(e)}),{close(){y.removeListener("upgrade",g);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,u.close()},get browserConnected(){return t!==null&&t.readyState===o.WebSocket.OPEN},get clientCount(){return l.size}}}Object.defineProperty(exports,"createTwdRelay",{enumerable:!0,get:function(){return W}});
|