Repository navigation
Expand file tree
/
Copy pathtb-cdp.mjs
More file actions
141 lines (135 loc) · 5.32 KB
/
Copy pathtb-cdp.mjs
File metadata and controls
141 lines (135 loc) · 5.32 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
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
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
// Minimal Chrome DevTools Protocol client over Node's global WebSocket.
//
// Deliberately not puppeteer, for one reason that matters here: a pending
// javascript dialog blocks the renderer, and puppeteer's connect() handshake
// talks to the renderer, so it hangs on exactly the IDE state you most need to
// recover from. Browser-process commands -- `Page.handleJavaScriptDialog`
// among them -- keep working on a raw connection.
//
// Used by scripts/lib/tb-ide.mjs. See WIP.Harness.md, "Compiling a twinBASIC
// project without the IDE in front of you".
/**
* Attach to a DevTools page target.
*
* Every call has a time limit. A page blocked by a javascript dialog, or inside
* a synchronous host call, never answers `Runtime.evaluate` or an input event,
* and without a limit the caller waits forever, with nothing on screen to say
* why. A call that runs out rejects with a sentence saying so; a connection
* that closes rejects everything still waiting on it at once.
*
* @param {number} port the --remote-debugging-port the target was started with
* @param {string} match substring the target URL must contain
* @param {object} [o]
* @param {number} [o.timeout] milliseconds a call may take (default 30000); one
* call can pass its own
* @param {string} [o.type] the target's type (default "page"); "iframe" is a
* frame of another site, which has a target of its own
*/
export async function attach(port, match = "main.htm", { timeout = 30 * 1000, type = "page" } = {}) {
const list = await (
await fetch(`http://127.0.0.1:${port}/json/list`, { signal: AbortSignal.timeout(10 * 1000) })
).json();
const t = list.find((x) => x.type === type && x.url.includes(match));
if (!t) throw new Error(`no ${type} target matching ${JSON.stringify(match)} on port ${port}`);
return { url: t.url, ...(await connect(t.webSocketDebuggerUrl, { timeout, what: `port ${port}` })) };
}
/**
* Connect to a DevTools websocket: a page's, or the browser's own (the path on the
* second line of `DevToolsActivePort`), where `send` takes a `sessionId` to talk to a
* target the connection attached to. The time limits are those of `attach`.
*
* @param {string} wsUrl the `ws://` address
* @param {object} [o]
* @param {number} [o.timeout] milliseconds a call may take (default 30000)
* @param {string} [o.what] what the socket is for, in the message when it does not open
*/
export async function connect(wsUrl, { timeout = 30 * 1000, what = wsUrl } = {}) {
const ws = new WebSocket(wsUrl);
await new Promise((res, rej) => {
const timer = setTimeout(() => rej(new Error(`the DevTools socket on ${what} did not open`)), 10 * 1000);
ws.onopen = () => {
clearTimeout(timer);
res();
};
ws.onerror = (e) => {
clearTimeout(timer);
rej(e);
};
});
let id = 0;
const pending = new Map();
const listeners = [];
ws.onmessage = (ev) => {
// A frame that is not JSON is dropped. It is an event callback, so a throw
// here is an uncaught exception, which ends the whole process -- once a tool
// builds in-process, every lane's build and not one child's. A request it
// was the answer to times out through its own timer.
let m;
try {
m = JSON.parse(ev.data);
} catch {
return;
}
if (m.id && pending.has(m.id)) {
const { res, rej } = pending.get(m.id);
pending.delete(m.id);
m.error ? rej(new Error(JSON.stringify(m.error))) : res(m.result);
} else if (m.method) {
for (const l of listeners) l(m);
}
};
ws.onclose = () => {
for (const { rej } of pending.values()) rej(new Error("the DevTools connection closed"));
pending.clear();
};
const send = (method, params = {}, { timeout: ms = timeout, sessionId } = {}) =>
new Promise((res, rej) => {
// A WebSocket that is closing or closed discards what it is sent and does
// not throw, so the call would wait out its whole timeout.
if (ws.readyState !== WebSocket.OPEN) {
rej(new Error("the DevTools connection is closed"));
return;
}
const i = ++id;
const timer = setTimeout(() => {
pending.delete(i);
rej(
new Error(
`${method} had no answer in ${ms / 1000} s -- the page may be blocked ` +
"by a javascript dialog or a synchronous host call",
),
);
}, ms);
timer.unref?.();
pending.set(i, {
res: (v) => {
clearTimeout(timer);
res(v);
},
rej: (e) => {
clearTimeout(timer);
rej(e);
},
});
try {
ws.send(JSON.stringify({ id: i, method, params, ...(sessionId ? { sessionId } : {}) }));
} catch {
pending.delete(i);
clearTimeout(timer);
rej(new Error("the DevTools connection is closed"));
}
});
const evaluate = async (expression, { awaitPromise = false, timeout: ms = timeout } = {}) => {
const r = await send("Runtime.evaluate", { expression, returnByValue: true, awaitPromise }, { timeout: ms });
if (r.exceptionDetails) {
throw new Error(r.exceptionDetails.exception?.description ?? JSON.stringify(r.exceptionDetails));
}
return r.result.value;
};
return {
send,
evaluate,
on: (fn) => listeners.push(fn),
close: () => ws.close(),
};
}