@reclaimprotocol/agent 0.1.23 → 0.1.24
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 +55 -20
- package/lib/authoring/backend.d.ts +5 -4
- package/lib/authoring/tools.js +30 -30
- package/lib/cdp/browser-proxy.d.ts +78 -0
- package/lib/cdp/browser-proxy.js +186 -0
- package/lib/cdp/capture-engine.js +8 -7
- package/lib/cdp/client.d.ts +10 -0
- package/lib/cdp/geo.d.ts +10 -0
- package/lib/cdp/geo.js +36 -0
- package/lib/cdp/index.d.ts +1 -0
- package/lib/cdp/index.js +1 -0
- package/lib/live/cloudflared.d.ts +15 -0
- package/lib/live/cloudflared.js +268 -0
- package/lib/live/page.d.ts +20 -0
- package/lib/live/page.js +134 -0
- package/lib/live/tunnel.d.ts +23 -0
- package/lib/live/tunnel.js +120 -0
- package/lib/live/viewer.d.ts +28 -0
- package/lib/live/viewer.js +232 -0
- package/lib/mcp/api/operations.gen.js +34 -26
- package/lib/mcp/api/to-zod.d.ts +1 -1
- package/lib/mcp/api/to-zod.js +1 -1
- package/lib/mcp/api/tools.js +1 -1
- package/lib/mcp/instructions.d.ts +15 -2
- package/lib/mcp/instructions.js +101 -38
- package/lib/mcp/start.js +4 -3
- package/lib/mcp/tools/agent/analyze.js +5 -1
- package/lib/mcp/tools/agent/attach.d.ts +8 -1
- package/lib/mcp/tools/agent/attach.js +166 -22
- package/lib/mcp/tools/agent/build.d.ts +13 -8
- package/lib/mcp/tools/agent/build.js +22 -11
- package/lib/mcp/tools/agent/capture.js +5 -1
- package/lib/mcp/tools/agent/eval.js +3 -1
- package/lib/mcp/tools/agent/find.js +6 -5
- package/lib/mcp/tools/agent/how-it-works.d.ts +15 -7
- package/lib/mcp/tools/agent/how-it-works.js +57 -17
- package/lib/mcp/tools/agent/live-view.d.ts +21 -0
- package/lib/mcp/tools/agent/live-view.js +153 -0
- package/lib/mcp/tools/agent/pod-proof.d.ts +8 -0
- package/lib/mcp/tools/agent/pod-proof.js +64 -0
- package/lib/mcp/tools/agent/propose.js +4 -2
- package/lib/mcp/tools/agent/proxy.d.ts +3 -0
- package/lib/mcp/tools/agent/proxy.js +199 -0
- package/lib/mcp/tools/agent/publish.js +47 -77
- package/lib/mcp/tools/agent/session.d.ts +2 -1
- package/lib/mcp/tools/agent/session.js +26 -7
- package/lib/mcp/tools/authenticate/authenticate.js +26 -13
- package/lib/mcp/tools/mode/mode.d.ts +1 -1
- package/lib/mcp/tools/mode/mode.js +10 -22
- package/lib/old/client.d.ts +1 -1
- package/lib/old/client.js +1 -1
- package/lib/old/instructions.d.ts +9 -1
- package/lib/old/instructions.js +4 -6
- package/lib/old/login-cdp.d.ts +2 -2
- package/lib/old/login-cdp.js +46 -6
- package/lib/old/to-register.d.ts +3 -2
- package/lib/old/to-register.js +1 -1
- package/lib/old/tools/authenticate.js +9 -1
- package/lib/old/tools/get-provider-info.js +6 -5
- package/lib/old/tools/issue-app-credentials.js +3 -2
- package/lib/old/tools/publish.d.ts +1 -1
- package/lib/old/tools/publish.js +90 -131
- package/lib/old/tools/update-provider-metadata.d.ts +1 -1
- package/lib/old/tools/update-provider-metadata.js +1 -1
- package/lib/old/tools/verify-sdk-setup.js +1 -1
- package/lib/proof/attestor.d.ts +3 -3
- package/lib/proof/debug-response-dump.js +4 -4
- package/lib/proof/index.d.ts +1 -0
- package/lib/proof/index.js +1 -0
- package/lib/proof/page-replay.d.ts +1 -1
- package/lib/proof/page-replay.js +1 -1
- package/lib/proof/pod-prover.d.ts +47 -0
- package/lib/proof/pod-prover.js +155 -0
- package/lib/provider/dom-xpath.js +11 -9
- package/lib/provider/draft.d.ts +10 -7
- package/lib/provider/draft.js +11 -9
- package/lib/provider/injection-doc.d.ts +23 -1
- package/lib/provider/injection-doc.js +34 -21
- package/lib/provider/match-redaction-warning.d.ts +2 -2
- package/lib/provider/match-redaction-warning.js +3 -3
- package/lib/provider/matcher.js +13 -12
- package/lib/provider/secrets.d.ts +3 -3
- package/lib/provider/secrets.js +3 -3
- package/lib/provider/to-version.d.ts +7 -6
- package/lib/provider/to-version.js +3 -3
- package/lib/tools/registry.d.ts +5 -5
- package/lib/tools/registry.js +5 -5
- package/package.json +3 -3
- package/samples/demo-server/README.md +7 -6
- package/skill/SKILL.builder.md +356 -189
- package/skill/SKILL.common.md +323 -97
- package/skill/SKILL.old.md +340 -155
- package/skill/builder-mode.md +58 -17
- package/skill/hash-validation.md +170 -0
- package/skill/interception.md +235 -0
- package/skill/js-sdk-integration.md +13 -7
- package/skill/old-mode.md +60 -21
- package/skill/user-script.md +61 -0
- package/src/authoring/backend.ts +5 -4
- package/src/authoring/tools.ts +31 -30
- package/src/cdp/capture-engine.ts +8 -7
- package/src/cdp/client.ts +8 -0
- package/src/cdp/geo.ts +52 -0
- package/src/cdp/index.ts +1 -0
- package/src/consts.ts +1 -0
- package/src/live/cloudflared.ts +348 -0
- package/src/live/page.ts +145 -0
- package/src/live/tunnel.ts +157 -0
- package/src/live/viewer.ts +293 -0
- package/src/mcp/api/operations.gen.ts +34 -26
- package/src/mcp/api/to-zod.ts +1 -1
- package/src/mcp/api/tools.ts +1 -1
- package/src/mcp/instructions.ts +121 -48
- package/src/mcp/start.ts +4 -3
- package/src/mcp/tools/agent/analyze.ts +5 -1
- package/src/mcp/tools/agent/attach.ts +194 -22
- package/src/mcp/tools/agent/build.ts +26 -15
- package/src/mcp/tools/agent/capture.ts +5 -1
- package/src/mcp/tools/agent/eval.ts +4 -1
- package/src/mcp/tools/agent/find.ts +6 -5
- package/src/mcp/tools/agent/how-it-works.ts +62 -21
- package/src/mcp/tools/agent/live-view.ts +198 -0
- package/src/mcp/tools/agent/pod-proof.ts +90 -0
- package/src/mcp/tools/agent/propose.ts +4 -2
- package/src/mcp/tools/agent/publish.ts +52 -77
- package/src/mcp/tools/agent/session.ts +26 -5
- package/src/mcp/tools/authenticate/authenticate.ts +28 -13
- package/src/mcp/tools/mode/mode.ts +10 -22
- package/src/old/client.ts +2 -2
- package/src/old/instructions.ts +4 -10
- package/src/old/login-cdp.ts +50 -6
- package/src/old/to-register.ts +3 -2
- package/src/old/tools/authenticate.ts +9 -1
- package/src/old/tools/get-provider-info.ts +6 -5
- package/src/old/tools/issue-app-credentials.ts +3 -2
- package/src/old/tools/publish.ts +95 -131
- package/src/old/tools/update-provider-metadata.ts +1 -1
- package/src/old/tools/verify-sdk-setup.ts +1 -1
- package/src/proof/attestor.ts +3 -3
- package/src/proof/debug-response-dump.ts +4 -4
- package/src/proof/index.ts +8 -0
- package/src/proof/page-replay.ts +1 -1
- package/src/proof/pod-prover.ts +231 -0
- package/src/provider/dom-xpath.ts +11 -9
- package/src/provider/draft.ts +13 -10
- package/src/provider/injection-doc.ts +37 -24
- package/src/provider/match-redaction-warning.ts +3 -3
- package/src/provider/matcher.ts +13 -12
- package/src/provider/secrets.ts +3 -3
- package/src/provider/to-version.ts +11 -9
- package/src/tools/registry.ts +5 -5
- package/skill/injection-api.md +0 -364
package/src/live/page.ts
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The live-view page served to whoever opens the tunnel URL.
|
|
3
|
+
*
|
|
4
|
+
* Deliberately dependency-free and inline: it is delivered through a throwaway
|
|
5
|
+
* tunnel, so it cannot rely on a CDN, a bundler, or anything cached.
|
|
6
|
+
*
|
|
7
|
+
* Frames arrive as base64 JPEG over Server-Sent Events rather than a
|
|
8
|
+
* WebSocket. SSE is one plain GET, so it survives tunnels and proxies that
|
|
9
|
+
* mishandle upgrades, and it reconnects on its own. Input goes the other way
|
|
10
|
+
* as small POSTs.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/** Escape a value for embedding in a single-quoted JS string literal. */
|
|
14
|
+
function js(value: string): string {
|
|
15
|
+
return value
|
|
16
|
+
.replace(/\\/g, '\\\\')
|
|
17
|
+
.replace(/'/g, "\\'")
|
|
18
|
+
.replace(/</g, '\\x3c')
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface PageOptions {
|
|
22
|
+
/** Path prefix that carries the access token, for example `/v/<token>`. */
|
|
23
|
+
base: string
|
|
24
|
+
/** Whether to wire up mouse and keyboard forwarding. */
|
|
25
|
+
interactive: boolean
|
|
26
|
+
/** Shown in the header so a viewer knows which tab they are watching. */
|
|
27
|
+
title: string
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function renderViewerPage(opts: PageOptions): string {
|
|
31
|
+
return `<!doctype html>
|
|
32
|
+
<html lang="en">
|
|
33
|
+
<head>
|
|
34
|
+
<meta charset="utf-8">
|
|
35
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
36
|
+
<meta name="robots" content="noindex, nofollow">
|
|
37
|
+
<title>Reclaim live view</title>
|
|
38
|
+
<style>
|
|
39
|
+
:root { color-scheme: dark; }
|
|
40
|
+
* { box-sizing: border-box; }
|
|
41
|
+
body { margin: 0; background: #0b0b0c; color: #e7e7ea; display: flex;
|
|
42
|
+
flex-direction: column; height: 100vh;
|
|
43
|
+
font: 14px/1.4 ui-sans-serif, system-ui, sans-serif; }
|
|
44
|
+
header { display: flex; align-items: center; gap: .75rem;
|
|
45
|
+
padding: .5rem .75rem; border-bottom: 1px solid #26262b; }
|
|
46
|
+
.dot { width: .5rem; height: .5rem; border-radius: 50%; background: #f5a524; }
|
|
47
|
+
.dot.live { background: #17c964; }
|
|
48
|
+
.title { color: #9a9aa4; overflow: hidden; text-overflow: ellipsis;
|
|
49
|
+
white-space: nowrap; }
|
|
50
|
+
.badge { margin-left: auto; font-size: 12px; padding: .15rem .4rem;
|
|
51
|
+
border: 1px solid #26262b; border-radius: .25rem; color: #9a9aa4; }
|
|
52
|
+
main { flex: 1; min-height: 0; display: grid; place-items: center;
|
|
53
|
+
padding: .75rem; }
|
|
54
|
+
img { max-width: 100%; max-height: 100%; display: block;
|
|
55
|
+
background: #fff; border-radius: .25rem; }
|
|
56
|
+
img.interactive { cursor: crosshair; }
|
|
57
|
+
.msg { color: #9a9aa4; }
|
|
58
|
+
</style>
|
|
59
|
+
</head>
|
|
60
|
+
<body>
|
|
61
|
+
<header>
|
|
62
|
+
<span class="dot" id="dot"></span>
|
|
63
|
+
<span class="title" id="title">${js(opts.title)}</span>
|
|
64
|
+
<span class="badge">${opts.interactive ? 'interactive' : 'view only'}</span>
|
|
65
|
+
</header>
|
|
66
|
+
<main><img id="screen" alt="Live view of the browser tab"><p class="msg"
|
|
67
|
+
id="msg">Connecting…</p></main>
|
|
68
|
+
<script>
|
|
69
|
+
(function () {
|
|
70
|
+
var BASE = '${js(opts.base)}';
|
|
71
|
+
var INTERACTIVE = ${opts.interactive ? 'true' : 'false'};
|
|
72
|
+
var screen = document.getElementById('screen');
|
|
73
|
+
var msg = document.getElementById('msg');
|
|
74
|
+
var dot = document.getElementById('dot');
|
|
75
|
+
var title = document.getElementById('title');
|
|
76
|
+
|
|
77
|
+
var es = new EventSource(BASE + '/stream');
|
|
78
|
+
es.addEventListener('frame', function (e) {
|
|
79
|
+
var payload = JSON.parse(e.data);
|
|
80
|
+
screen.src = 'data:image/jpeg;base64,' + payload.data;
|
|
81
|
+
if (payload.title) { title.textContent = payload.title; }
|
|
82
|
+
msg.style.display = 'none';
|
|
83
|
+
dot.className = 'dot live';
|
|
84
|
+
});
|
|
85
|
+
es.onerror = function () {
|
|
86
|
+
dot.className = 'dot';
|
|
87
|
+
msg.style.display = '';
|
|
88
|
+
msg.textContent = 'Reconnecting…';
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
if (!INTERACTIVE) { return; }
|
|
92
|
+
screen.className = 'interactive';
|
|
93
|
+
|
|
94
|
+
// The <img> is letterboxed by object-fit, so a click at some point in the
|
|
95
|
+
// element maps to page coordinates by the ratio of natural to rendered size.
|
|
96
|
+
function toPage(ev) {
|
|
97
|
+
var r = screen.getBoundingClientRect();
|
|
98
|
+
if (!screen.naturalWidth || !r.width) { return null; }
|
|
99
|
+
return {
|
|
100
|
+
x: Math.round((ev.clientX - r.left) * (screen.naturalWidth / r.width)),
|
|
101
|
+
y: Math.round((ev.clientY - r.top) * (screen.naturalHeight / r.height))
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function post(body) {
|
|
106
|
+
return fetch(BASE + '/input', {
|
|
107
|
+
method: 'POST',
|
|
108
|
+
headers: { 'content-type': 'application/json' },
|
|
109
|
+
body: JSON.stringify(body)
|
|
110
|
+
}).catch(function () {});
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
screen.addEventListener('mousedown', function (ev) {
|
|
114
|
+
var p = toPage(ev); if (!p) { return; }
|
|
115
|
+
ev.preventDefault();
|
|
116
|
+
post({ kind: 'mouse', type: 'mousePressed', x: p.x, y: p.y,
|
|
117
|
+
button: 'left', clickCount: 1 });
|
|
118
|
+
});
|
|
119
|
+
screen.addEventListener('mouseup', function (ev) {
|
|
120
|
+
var p = toPage(ev); if (!p) { return; }
|
|
121
|
+
post({ kind: 'mouse', type: 'mouseReleased', x: p.x, y: p.y,
|
|
122
|
+
button: 'left', clickCount: 1 });
|
|
123
|
+
});
|
|
124
|
+
screen.addEventListener('wheel', function (ev) {
|
|
125
|
+
var p = toPage(ev); if (!p) { return; }
|
|
126
|
+
ev.preventDefault();
|
|
127
|
+
post({ kind: 'mouse', type: 'mouseWheel', x: p.x, y: p.y,
|
|
128
|
+
deltaX: ev.deltaX, deltaY: ev.deltaY });
|
|
129
|
+
}, { passive: false });
|
|
130
|
+
|
|
131
|
+
// Typing goes to the page, so the viewer never needs to focus anything.
|
|
132
|
+
window.addEventListener('keydown', function (ev) {
|
|
133
|
+
if (ev.metaKey || ev.ctrlKey) { return; }
|
|
134
|
+
ev.preventDefault();
|
|
135
|
+
post({ kind: 'key', type: 'keyDown', key: ev.key, code: ev.code });
|
|
136
|
+
});
|
|
137
|
+
window.addEventListener('keyup', function (ev) {
|
|
138
|
+
if (ev.metaKey || ev.ctrlKey) { return; }
|
|
139
|
+
post({ kind: 'key', type: 'keyUp', key: ev.key, code: ev.code });
|
|
140
|
+
});
|
|
141
|
+
})();
|
|
142
|
+
</script>
|
|
143
|
+
</body>
|
|
144
|
+
</html>`
|
|
145
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Public HTTPS front door for the local live-view server, via a `cloudflared`
|
|
3
|
+
* quick tunnel.
|
|
4
|
+
*
|
|
5
|
+
* A quick tunnel needs no Cloudflare account and no DNS: `cloudflared` dials
|
|
6
|
+
* out, Cloudflare hands back a throwaway `*.trycloudflare.com` hostname, and
|
|
7
|
+
* traffic arrives on the loopback port we pass in. That is the whole reason it
|
|
8
|
+
* is used here — the developer's machine keeps no inbound port open.
|
|
9
|
+
*
|
|
10
|
+
* The tunnel itself carries NO authorization. Anyone holding the hostname can
|
|
11
|
+
* reach the local server, so the server is the thing that must authenticate
|
|
12
|
+
* (see `viewer.ts`, which puts an unguessable token in every path).
|
|
13
|
+
*/
|
|
14
|
+
import { spawn } from 'node:child_process'
|
|
15
|
+
import { LOGGER } from '../logger.ts'
|
|
16
|
+
import { type CloudflaredBinary, resolveCloudflared } from './cloudflared.ts'
|
|
17
|
+
|
|
18
|
+
/** How long to wait for cloudflared to report its hostname before giving up. */
|
|
19
|
+
const URL_TIMEOUT_MS = 30_000
|
|
20
|
+
/** After the hostname is printed, Cloudflare needs a moment before the edge
|
|
21
|
+
* routes to it — measured at ~3s. Poll until it answers so the caller never
|
|
22
|
+
* hands out a URL that errors, but don't fail the share if it's slow. */
|
|
23
|
+
const READY_TIMEOUT_MS = 20_000
|
|
24
|
+
const READY_POLL_MS = 1_000
|
|
25
|
+
|
|
26
|
+
/** `https://<something>.trycloudflare.com`, as printed on cloudflared's log
|
|
27
|
+
* stream. Anchored to the scheme so a hostname inside prose can't match. */
|
|
28
|
+
const QUICK_TUNNEL_URL = /https:\/\/[a-z0-9-]+\.trycloudflare\.com/i
|
|
29
|
+
|
|
30
|
+
export interface Tunnel {
|
|
31
|
+
/** The public HTTPS origin Cloudflare assigned. */
|
|
32
|
+
url: string
|
|
33
|
+
/** Whether the edge answered before {@link READY_TIMEOUT_MS} elapsed. False
|
|
34
|
+
* means the tunnel is probably still propagating rather than broken — tell
|
|
35
|
+
* the viewer to retry rather than treating the share as failed. */
|
|
36
|
+
ready: boolean
|
|
37
|
+
/** Terminate the tunnel. Safe to call more than once. */
|
|
38
|
+
stop: () => Promise<void>
|
|
39
|
+
/** Which cloudflared ran, and whether we had to fetch it. */
|
|
40
|
+
binary: CloudflaredBinary
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Poll the public origin until Cloudflare's edge routes to it. ANY HTTP
|
|
45
|
+
* response counts, including a 404: the live-view server 404s unknown paths,
|
|
46
|
+
* so a status code at all proves the hop is live.
|
|
47
|
+
*/
|
|
48
|
+
async function waitUntilRouted(url: string): Promise<boolean> {
|
|
49
|
+
const deadline = Date.now() + READY_TIMEOUT_MS
|
|
50
|
+
while(Date.now() < deadline) {
|
|
51
|
+
try {
|
|
52
|
+
await fetch(url, {
|
|
53
|
+
method: 'GET',
|
|
54
|
+
redirect: 'manual',
|
|
55
|
+
signal: AbortSignal.timeout(READY_POLL_MS * 4),
|
|
56
|
+
})
|
|
57
|
+
return true
|
|
58
|
+
} catch{
|
|
59
|
+
await new Promise((r) => setTimeout(r, READY_POLL_MS))
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return false
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Open a quick tunnel to `http://127.0.0.1:<port>` and resolve once
|
|
68
|
+
* Cloudflare has assigned a hostname.
|
|
69
|
+
*
|
|
70
|
+
* Rejects with an actionable message when `cloudflared` is not installed,
|
|
71
|
+
* exits early, or never reports a hostname.
|
|
72
|
+
*/
|
|
73
|
+
export async function startTunnel(
|
|
74
|
+
port: number,
|
|
75
|
+
{ autoInstall = true }: { autoInstall?: boolean } = {},
|
|
76
|
+
): Promise<Tunnel> {
|
|
77
|
+
// Resolves an existing cloudflared, or fetches one on first use. Throws
|
|
78
|
+
// with an actionable message when neither is possible.
|
|
79
|
+
const binary = await resolveCloudflared({ autoInstall })
|
|
80
|
+
const child = spawn(binary.path, [
|
|
81
|
+
'tunnel',
|
|
82
|
+
'--no-autoupdate',
|
|
83
|
+
'--url', `http://127.0.0.1:${port}`,
|
|
84
|
+
], { stdio: ['ignore', 'pipe', 'pipe'] })
|
|
85
|
+
|
|
86
|
+
const stop = async() => {
|
|
87
|
+
if(!child.killed) {
|
|
88
|
+
child.kill('SIGTERM')
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return new Promise<Tunnel>((resolve, reject) => {
|
|
93
|
+
const timer = setTimeout(() => {
|
|
94
|
+
void stop()
|
|
95
|
+
reject(new Error(
|
|
96
|
+
'cloudflared did not report a tunnel hostname within '
|
|
97
|
+
+ `${URL_TIMEOUT_MS / 1000}s. Check network egress to Cloudflare, `
|
|
98
|
+
+ 'or run `cloudflared tunnel --url http://127.0.0.1:'
|
|
99
|
+
+ `${port}` + '` by hand to see its output.',
|
|
100
|
+
))
|
|
101
|
+
}, URL_TIMEOUT_MS)
|
|
102
|
+
|
|
103
|
+
let settled = false
|
|
104
|
+
const finish = (fn: () => void) => {
|
|
105
|
+
if(settled) {
|
|
106
|
+
return
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
settled = true
|
|
110
|
+
clearTimeout(timer)
|
|
111
|
+
fn()
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// cloudflared logs the assigned hostname to stderr, not stdout. Watch
|
|
115
|
+
// both so a future version that switches streams still works.
|
|
116
|
+
const onChunk = (chunk: Buffer) => {
|
|
117
|
+
const text = chunk.toString()
|
|
118
|
+
const match = QUICK_TUNNEL_URL.exec(text)
|
|
119
|
+
if(match) {
|
|
120
|
+
const url = match[0]
|
|
121
|
+
finish(() => {
|
|
122
|
+
void waitUntilRouted(url).then((ready) => {
|
|
123
|
+
if(!ready) {
|
|
124
|
+
LOGGER.warn({ url }, 'tunnel not routed yet; returning anyway')
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
resolve({ url, ready, stop, binary })
|
|
128
|
+
})
|
|
129
|
+
})
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
child.stdout?.on('data', onChunk)
|
|
134
|
+
child.stderr?.on('data', onChunk)
|
|
135
|
+
|
|
136
|
+
child.on('error', (err: NodeJS.ErrnoException) => {
|
|
137
|
+
// `resolveCloudflared` already proved the binary runs, so an ENOENT
|
|
138
|
+
// here means it vanished between resolving and spawning.
|
|
139
|
+
finish(() => reject(
|
|
140
|
+
err.code === 'ENOENT'
|
|
141
|
+
? new Error(
|
|
142
|
+
`cloudflared disappeared from ${binary.path} before it could `
|
|
143
|
+
+ 'start. Retry — the agent will fetch it again if needed.',
|
|
144
|
+
)
|
|
145
|
+
: err,
|
|
146
|
+
))
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
child.on('exit', (code) => {
|
|
150
|
+
LOGGER.debug({ code }, 'cloudflared exited')
|
|
151
|
+
finish(() => reject(new Error(
|
|
152
|
+
`cloudflared exited with code ${code} before reporting a `
|
|
153
|
+
+ 'hostname.',
|
|
154
|
+
)))
|
|
155
|
+
})
|
|
156
|
+
})
|
|
157
|
+
}
|
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Interactive live view of one attached tab, over a public URL.
|
|
3
|
+
*
|
|
4
|
+
* Two halves:
|
|
5
|
+
* - OUT: CDP `Page.startScreencast` pushes JPEG frames; each is forwarded to
|
|
6
|
+
* every connected browser as a Server-Sent Event and acked back to CDP.
|
|
7
|
+
* - IN: the viewer POSTs pointer and key events, which are replayed into the
|
|
8
|
+
* page with `Input.dispatch*`.
|
|
9
|
+
*
|
|
10
|
+
* Only ever bound to 127.0.0.1. Reaching it from outside is the tunnel's job
|
|
11
|
+
* (see `tunnel.ts`), and because a quick tunnel is unauthenticated, EVERY
|
|
12
|
+
* route sits behind an unguessable token in the path. A request without it
|
|
13
|
+
* gets a flat 404 — not a 401, which would confirm the server exists.
|
|
14
|
+
*
|
|
15
|
+
* Interactivity is the caller's choice. `interactive: false` still streams,
|
|
16
|
+
* but the input route refuses, which is the right setting for showing someone
|
|
17
|
+
* a run rather than handing them the wheel.
|
|
18
|
+
*/
|
|
19
|
+
import { randomBytes } from 'node:crypto'
|
|
20
|
+
import {
|
|
21
|
+
createServer,
|
|
22
|
+
type IncomingMessage,
|
|
23
|
+
type Server,
|
|
24
|
+
type ServerResponse,
|
|
25
|
+
} from 'node:http'
|
|
26
|
+
import type { AddressInfo } from 'node:net'
|
|
27
|
+
import type { CdpTabClient } from '../cdp/client.ts'
|
|
28
|
+
import { LOGGER } from '../logger.ts'
|
|
29
|
+
import { renderViewerPage } from './page.ts'
|
|
30
|
+
|
|
31
|
+
/** JPEG quality for streamed frames. 60 keeps a text-heavy page readable
|
|
32
|
+
* while staying small enough for a tunnel's uplink. */
|
|
33
|
+
const FRAME_QUALITY = 60
|
|
34
|
+
/** Cap the streamed frame size. Screencast scales to fit, so this bounds
|
|
35
|
+
* bandwidth without changing the page's own layout viewport. */
|
|
36
|
+
const MAX_FRAME_WIDTH = 1280
|
|
37
|
+
const MAX_FRAME_HEIGHT = 800
|
|
38
|
+
/** Bytes accepted on an input POST. Events are tiny; anything larger is
|
|
39
|
+
* either a bug or someone probing. */
|
|
40
|
+
const MAX_INPUT_BYTES = 4096
|
|
41
|
+
|
|
42
|
+
export interface LiveViewOptions {
|
|
43
|
+
/** The tab to stream. */
|
|
44
|
+
tab: CdpTabClient
|
|
45
|
+
/** Forward viewer input into the page. */
|
|
46
|
+
interactive: boolean
|
|
47
|
+
/** Shown in the viewer's header. */
|
|
48
|
+
title?: string
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface LiveView {
|
|
52
|
+
/** Loopback origin the tunnel should point at. */
|
|
53
|
+
localUrl: string
|
|
54
|
+
/** Port the server bound to. */
|
|
55
|
+
port: number
|
|
56
|
+
/** Unguessable path segment guarding every route. */
|
|
57
|
+
token: string
|
|
58
|
+
/** Path prefix, `/v/<token>`. Append this to the public origin. */
|
|
59
|
+
path: string
|
|
60
|
+
/** Number of viewers currently streaming. */
|
|
61
|
+
viewers: () => number
|
|
62
|
+
stop: () => Promise<void>
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Start the loopback live-view server for `tab`. Resolves once it is
|
|
67
|
+
* listening; the screencast starts with the first viewer and stops with the
|
|
68
|
+
* last, so an idle share costs the page nothing.
|
|
69
|
+
*/
|
|
70
|
+
export async function startLiveView(
|
|
71
|
+
opts: LiveViewOptions,
|
|
72
|
+
): Promise<LiveView> {
|
|
73
|
+
const token = randomBytes(24).toString('base64url')
|
|
74
|
+
const path = `/v/${token}`
|
|
75
|
+
const clients = new Set<ServerResponse>()
|
|
76
|
+
let streaming = false
|
|
77
|
+
let unsubscribe: (() => void) | undefined
|
|
78
|
+
|
|
79
|
+
const startScreencast = async() => {
|
|
80
|
+
if(streaming) {
|
|
81
|
+
return
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
streaming = true
|
|
85
|
+
unsubscribe = opts.tab.subscribe('Page.screencastFrame', (frame) => {
|
|
86
|
+
// Ack first: CDP sends at most one un-acked frame, so a slow or failed
|
|
87
|
+
// write must not stall the stream.
|
|
88
|
+
void opts.tab
|
|
89
|
+
.send('Page.screencastFrameAck', { sessionId: frame.sessionId })
|
|
90
|
+
.catch(() => {})
|
|
91
|
+
const payload = JSON.stringify({ data: frame.data })
|
|
92
|
+
for(const res of clients) {
|
|
93
|
+
res.write(`event: frame\ndata: ${payload}\n\n`)
|
|
94
|
+
}
|
|
95
|
+
})
|
|
96
|
+
await opts.tab.send('Page.enable')
|
|
97
|
+
await opts.tab.send('Page.startScreencast', {
|
|
98
|
+
format: 'jpeg',
|
|
99
|
+
quality: FRAME_QUALITY,
|
|
100
|
+
maxWidth: MAX_FRAME_WIDTH,
|
|
101
|
+
maxHeight: MAX_FRAME_HEIGHT,
|
|
102
|
+
everyNthFrame: 1,
|
|
103
|
+
})
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const stopScreencast = async() => {
|
|
107
|
+
if(!streaming) {
|
|
108
|
+
return
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
streaming = false
|
|
112
|
+
unsubscribe?.()
|
|
113
|
+
unsubscribe = undefined
|
|
114
|
+
await opts.tab.send('Page.stopScreencast').catch(() => {})
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const server = createServer((req, res) => {
|
|
118
|
+
const url = req.url ?? '/'
|
|
119
|
+
// Constant prefix check: everything is scoped under the token, so an
|
|
120
|
+
// unmatched path is indistinguishable from a server that isn't there.
|
|
121
|
+
if(!url.startsWith(path)) {
|
|
122
|
+
res.writeHead(404).end('not found')
|
|
123
|
+
return
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const route = url.slice(path.length) || '/'
|
|
127
|
+
if(req.method === 'GET' && (route === '/' || route.startsWith('/?'))) {
|
|
128
|
+
const body = renderViewerPage({
|
|
129
|
+
base: path,
|
|
130
|
+
interactive: opts.interactive,
|
|
131
|
+
title: opts.title ?? 'Attached tab',
|
|
132
|
+
})
|
|
133
|
+
res.writeHead(200, {
|
|
134
|
+
'content-type': 'text/html; charset=utf-8',
|
|
135
|
+
// A share URL must never be cached or indexed by anything between
|
|
136
|
+
// the viewer and here.
|
|
137
|
+
'cache-control': 'no-store',
|
|
138
|
+
'referrer-policy': 'no-referrer',
|
|
139
|
+
}).end(body)
|
|
140
|
+
return
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if(req.method === 'GET' && route === '/stream') {
|
|
144
|
+
res.writeHead(200, {
|
|
145
|
+
'content-type': 'text/event-stream',
|
|
146
|
+
'cache-control': 'no-store',
|
|
147
|
+
connection: 'keep-alive',
|
|
148
|
+
// Defeat proxy buffering, which would otherwise hold frames back.
|
|
149
|
+
'x-accel-buffering': 'no',
|
|
150
|
+
})
|
|
151
|
+
res.write(': connected\n\n')
|
|
152
|
+
clients.add(res)
|
|
153
|
+
void startScreencast().catch((err) => {
|
|
154
|
+
LOGGER.warn({ err }, 'live view: screencast failed to start')
|
|
155
|
+
})
|
|
156
|
+
req.on('close', () => {
|
|
157
|
+
clients.delete(res)
|
|
158
|
+
if(!clients.size) {
|
|
159
|
+
void stopScreencast()
|
|
160
|
+
}
|
|
161
|
+
})
|
|
162
|
+
return
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if(req.method === 'POST' && route === '/input') {
|
|
166
|
+
if(!opts.interactive) {
|
|
167
|
+
res.writeHead(403).end('view only')
|
|
168
|
+
return
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
readJson(req, MAX_INPUT_BYTES)
|
|
172
|
+
.then((body) => dispatchInput(opts.tab, body))
|
|
173
|
+
.then(() => res.writeHead(204).end())
|
|
174
|
+
.catch((err) => {
|
|
175
|
+
LOGGER.debug({ err }, 'live view: input rejected')
|
|
176
|
+
res.writeHead(400).end('bad input')
|
|
177
|
+
})
|
|
178
|
+
return
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
res.writeHead(404).end('not found')
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
await new Promise<void>((resolve) => {
|
|
185
|
+
server.listen(0, '127.0.0.1', resolve)
|
|
186
|
+
})
|
|
187
|
+
const port = (server.address() as AddressInfo).port
|
|
188
|
+
|
|
189
|
+
return {
|
|
190
|
+
localUrl: `http://127.0.0.1:${port}`,
|
|
191
|
+
port,
|
|
192
|
+
token,
|
|
193
|
+
path,
|
|
194
|
+
viewers: () => clients.size,
|
|
195
|
+
stop: async() => {
|
|
196
|
+
await stopScreencast()
|
|
197
|
+
for(const res of clients) {
|
|
198
|
+
res.end()
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
clients.clear()
|
|
202
|
+
await closeServer(server)
|
|
203
|
+
},
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function closeServer(server: Server): Promise<void> {
|
|
208
|
+
return new Promise((resolve) => {
|
|
209
|
+
server.close(() => resolve())
|
|
210
|
+
// A held-open SSE response would otherwise keep the server alive.
|
|
211
|
+
server.closeAllConnections?.()
|
|
212
|
+
})
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function readJson(
|
|
216
|
+
req: IncomingMessage,
|
|
217
|
+
limit: number,
|
|
218
|
+
): Promise<Record<string, unknown>> {
|
|
219
|
+
return new Promise((resolve, reject) => {
|
|
220
|
+
let size = 0
|
|
221
|
+
const chunks: Buffer[] = []
|
|
222
|
+
req.on('data', (chunk: Buffer) => {
|
|
223
|
+
size += chunk.length
|
|
224
|
+
if(size > limit) {
|
|
225
|
+
reject(new Error('input too large'))
|
|
226
|
+
req.destroy()
|
|
227
|
+
return
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
chunks.push(chunk)
|
|
231
|
+
})
|
|
232
|
+
req.on('end', () => {
|
|
233
|
+
try {
|
|
234
|
+
resolve(JSON.parse(Buffer.concat(chunks).toString('utf8')))
|
|
235
|
+
} catch(err) {
|
|
236
|
+
reject(err instanceof Error ? err : new Error('bad json'))
|
|
237
|
+
}
|
|
238
|
+
})
|
|
239
|
+
req.on('error', reject)
|
|
240
|
+
})
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** Replay one viewer event into the page. Unknown shapes throw, so the route
|
|
244
|
+
* answers 400 rather than forwarding something arbitrary to CDP. */
|
|
245
|
+
async function dispatchInput(
|
|
246
|
+
tab: CdpTabClient,
|
|
247
|
+
body: Record<string, unknown>,
|
|
248
|
+
): Promise<void> {
|
|
249
|
+
const num = (v: unknown) => (typeof v === 'number' && Number.isFinite(v)
|
|
250
|
+
? v
|
|
251
|
+
: 0)
|
|
252
|
+
|
|
253
|
+
if(body.kind === 'mouse') {
|
|
254
|
+
const type = String(body.type)
|
|
255
|
+
if(!['mousePressed', 'mouseReleased', 'mouseMoved', 'mouseWheel']
|
|
256
|
+
.includes(type)) {
|
|
257
|
+
throw new Error(`unsupported mouse type ${type}`)
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
await tab.send('Input.dispatchMouseEvent', {
|
|
261
|
+
type,
|
|
262
|
+
x: num(body.x),
|
|
263
|
+
y: num(body.y),
|
|
264
|
+
button: type === 'mouseWheel' ? 'none' : 'left',
|
|
265
|
+
clickCount: type === 'mouseWheel' ? 0 : 1,
|
|
266
|
+
...(type === 'mouseWheel'
|
|
267
|
+
? { deltaX: num(body.deltaX), deltaY: num(body.deltaY) }
|
|
268
|
+
: {}),
|
|
269
|
+
})
|
|
270
|
+
return
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
if(body.kind === 'key') {
|
|
274
|
+
const type = String(body.type)
|
|
275
|
+
if(type !== 'keyDown' && type !== 'keyUp') {
|
|
276
|
+
throw new Error(`unsupported key type ${type}`)
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
const key = String(body.key ?? '')
|
|
280
|
+
// A single printable character needs `text` as well, or the page sees the
|
|
281
|
+
// keypress but no character is inserted.
|
|
282
|
+
const text = key.length === 1 ? key : undefined
|
|
283
|
+
await tab.send('Input.dispatchKeyEvent', {
|
|
284
|
+
type,
|
|
285
|
+
key,
|
|
286
|
+
code: String(body.code ?? ''),
|
|
287
|
+
...(text && type === 'keyDown' ? { text } : {}),
|
|
288
|
+
})
|
|
289
|
+
return
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
throw new Error('unknown event kind')
|
|
293
|
+
}
|