@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.
Files changed (152) hide show
  1. package/README.md +55 -20
  2. package/lib/authoring/backend.d.ts +5 -4
  3. package/lib/authoring/tools.js +30 -30
  4. package/lib/cdp/browser-proxy.d.ts +78 -0
  5. package/lib/cdp/browser-proxy.js +186 -0
  6. package/lib/cdp/capture-engine.js +8 -7
  7. package/lib/cdp/client.d.ts +10 -0
  8. package/lib/cdp/geo.d.ts +10 -0
  9. package/lib/cdp/geo.js +36 -0
  10. package/lib/cdp/index.d.ts +1 -0
  11. package/lib/cdp/index.js +1 -0
  12. package/lib/live/cloudflared.d.ts +15 -0
  13. package/lib/live/cloudflared.js +268 -0
  14. package/lib/live/page.d.ts +20 -0
  15. package/lib/live/page.js +134 -0
  16. package/lib/live/tunnel.d.ts +23 -0
  17. package/lib/live/tunnel.js +120 -0
  18. package/lib/live/viewer.d.ts +28 -0
  19. package/lib/live/viewer.js +232 -0
  20. package/lib/mcp/api/operations.gen.js +34 -26
  21. package/lib/mcp/api/to-zod.d.ts +1 -1
  22. package/lib/mcp/api/to-zod.js +1 -1
  23. package/lib/mcp/api/tools.js +1 -1
  24. package/lib/mcp/instructions.d.ts +15 -2
  25. package/lib/mcp/instructions.js +101 -38
  26. package/lib/mcp/start.js +4 -3
  27. package/lib/mcp/tools/agent/analyze.js +5 -1
  28. package/lib/mcp/tools/agent/attach.d.ts +8 -1
  29. package/lib/mcp/tools/agent/attach.js +166 -22
  30. package/lib/mcp/tools/agent/build.d.ts +13 -8
  31. package/lib/mcp/tools/agent/build.js +22 -11
  32. package/lib/mcp/tools/agent/capture.js +5 -1
  33. package/lib/mcp/tools/agent/eval.js +3 -1
  34. package/lib/mcp/tools/agent/find.js +6 -5
  35. package/lib/mcp/tools/agent/how-it-works.d.ts +15 -7
  36. package/lib/mcp/tools/agent/how-it-works.js +57 -17
  37. package/lib/mcp/tools/agent/live-view.d.ts +21 -0
  38. package/lib/mcp/tools/agent/live-view.js +153 -0
  39. package/lib/mcp/tools/agent/pod-proof.d.ts +8 -0
  40. package/lib/mcp/tools/agent/pod-proof.js +64 -0
  41. package/lib/mcp/tools/agent/propose.js +4 -2
  42. package/lib/mcp/tools/agent/proxy.d.ts +3 -0
  43. package/lib/mcp/tools/agent/proxy.js +199 -0
  44. package/lib/mcp/tools/agent/publish.js +47 -77
  45. package/lib/mcp/tools/agent/session.d.ts +2 -1
  46. package/lib/mcp/tools/agent/session.js +26 -7
  47. package/lib/mcp/tools/authenticate/authenticate.js +26 -13
  48. package/lib/mcp/tools/mode/mode.d.ts +1 -1
  49. package/lib/mcp/tools/mode/mode.js +10 -22
  50. package/lib/old/client.d.ts +1 -1
  51. package/lib/old/client.js +1 -1
  52. package/lib/old/instructions.d.ts +9 -1
  53. package/lib/old/instructions.js +4 -6
  54. package/lib/old/login-cdp.d.ts +2 -2
  55. package/lib/old/login-cdp.js +46 -6
  56. package/lib/old/to-register.d.ts +3 -2
  57. package/lib/old/to-register.js +1 -1
  58. package/lib/old/tools/authenticate.js +9 -1
  59. package/lib/old/tools/get-provider-info.js +6 -5
  60. package/lib/old/tools/issue-app-credentials.js +3 -2
  61. package/lib/old/tools/publish.d.ts +1 -1
  62. package/lib/old/tools/publish.js +90 -131
  63. package/lib/old/tools/update-provider-metadata.d.ts +1 -1
  64. package/lib/old/tools/update-provider-metadata.js +1 -1
  65. package/lib/old/tools/verify-sdk-setup.js +1 -1
  66. package/lib/proof/attestor.d.ts +3 -3
  67. package/lib/proof/debug-response-dump.js +4 -4
  68. package/lib/proof/index.d.ts +1 -0
  69. package/lib/proof/index.js +1 -0
  70. package/lib/proof/page-replay.d.ts +1 -1
  71. package/lib/proof/page-replay.js +1 -1
  72. package/lib/proof/pod-prover.d.ts +47 -0
  73. package/lib/proof/pod-prover.js +155 -0
  74. package/lib/provider/dom-xpath.js +11 -9
  75. package/lib/provider/draft.d.ts +10 -7
  76. package/lib/provider/draft.js +11 -9
  77. package/lib/provider/injection-doc.d.ts +23 -1
  78. package/lib/provider/injection-doc.js +34 -21
  79. package/lib/provider/match-redaction-warning.d.ts +2 -2
  80. package/lib/provider/match-redaction-warning.js +3 -3
  81. package/lib/provider/matcher.js +13 -12
  82. package/lib/provider/secrets.d.ts +3 -3
  83. package/lib/provider/secrets.js +3 -3
  84. package/lib/provider/to-version.d.ts +7 -6
  85. package/lib/provider/to-version.js +3 -3
  86. package/lib/tools/registry.d.ts +5 -5
  87. package/lib/tools/registry.js +5 -5
  88. package/package.json +3 -3
  89. package/samples/demo-server/README.md +7 -6
  90. package/skill/SKILL.builder.md +356 -189
  91. package/skill/SKILL.common.md +323 -97
  92. package/skill/SKILL.old.md +340 -155
  93. package/skill/builder-mode.md +58 -17
  94. package/skill/hash-validation.md +170 -0
  95. package/skill/interception.md +235 -0
  96. package/skill/js-sdk-integration.md +13 -7
  97. package/skill/old-mode.md +60 -21
  98. package/skill/user-script.md +61 -0
  99. package/src/authoring/backend.ts +5 -4
  100. package/src/authoring/tools.ts +31 -30
  101. package/src/cdp/capture-engine.ts +8 -7
  102. package/src/cdp/client.ts +8 -0
  103. package/src/cdp/geo.ts +52 -0
  104. package/src/cdp/index.ts +1 -0
  105. package/src/consts.ts +1 -0
  106. package/src/live/cloudflared.ts +348 -0
  107. package/src/live/page.ts +145 -0
  108. package/src/live/tunnel.ts +157 -0
  109. package/src/live/viewer.ts +293 -0
  110. package/src/mcp/api/operations.gen.ts +34 -26
  111. package/src/mcp/api/to-zod.ts +1 -1
  112. package/src/mcp/api/tools.ts +1 -1
  113. package/src/mcp/instructions.ts +121 -48
  114. package/src/mcp/start.ts +4 -3
  115. package/src/mcp/tools/agent/analyze.ts +5 -1
  116. package/src/mcp/tools/agent/attach.ts +194 -22
  117. package/src/mcp/tools/agent/build.ts +26 -15
  118. package/src/mcp/tools/agent/capture.ts +5 -1
  119. package/src/mcp/tools/agent/eval.ts +4 -1
  120. package/src/mcp/tools/agent/find.ts +6 -5
  121. package/src/mcp/tools/agent/how-it-works.ts +62 -21
  122. package/src/mcp/tools/agent/live-view.ts +198 -0
  123. package/src/mcp/tools/agent/pod-proof.ts +90 -0
  124. package/src/mcp/tools/agent/propose.ts +4 -2
  125. package/src/mcp/tools/agent/publish.ts +52 -77
  126. package/src/mcp/tools/agent/session.ts +26 -5
  127. package/src/mcp/tools/authenticate/authenticate.ts +28 -13
  128. package/src/mcp/tools/mode/mode.ts +10 -22
  129. package/src/old/client.ts +2 -2
  130. package/src/old/instructions.ts +4 -10
  131. package/src/old/login-cdp.ts +50 -6
  132. package/src/old/to-register.ts +3 -2
  133. package/src/old/tools/authenticate.ts +9 -1
  134. package/src/old/tools/get-provider-info.ts +6 -5
  135. package/src/old/tools/issue-app-credentials.ts +3 -2
  136. package/src/old/tools/publish.ts +95 -131
  137. package/src/old/tools/update-provider-metadata.ts +1 -1
  138. package/src/old/tools/verify-sdk-setup.ts +1 -1
  139. package/src/proof/attestor.ts +3 -3
  140. package/src/proof/debug-response-dump.ts +4 -4
  141. package/src/proof/index.ts +8 -0
  142. package/src/proof/page-replay.ts +1 -1
  143. package/src/proof/pod-prover.ts +231 -0
  144. package/src/provider/dom-xpath.ts +11 -9
  145. package/src/provider/draft.ts +13 -10
  146. package/src/provider/injection-doc.ts +37 -24
  147. package/src/provider/match-redaction-warning.ts +3 -3
  148. package/src/provider/matcher.ts +13 -12
  149. package/src/provider/secrets.ts +3 -3
  150. package/src/provider/to-version.ts +11 -9
  151. package/src/tools/registry.ts +5 -5
  152. package/skill/injection-api.md +0 -364
@@ -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
+ }