|
| 1 | +import crypto from "node:crypto"; |
| 2 | +import { createServer, IncomingMessage, Server } from "node:http"; |
| 3 | +import getPort from "get-port"; |
| 4 | +import { DeferredPromise } from "miniflare:shared"; |
| 5 | +import WebSocket, { WebSocketServer } from "ws"; |
| 6 | +import { version as miniflareVersion } from "../../../../package.json"; |
| 7 | +import { Log } from "../../../shared"; |
| 8 | +import { InspectorProxy } from "./inspector-proxy"; |
| 9 | + |
| 10 | +/** |
| 11 | + * An `InspectorProxyController` connects to the various runtime (/workerd) inspector servers and exposes through the user specified |
| 12 | + * inspector port the appropriate workers. |
| 13 | + * |
| 14 | + * The controller: |
| 15 | + * - implements the various discovery `/json/*` endpoints that inspector clients query (exposing only the appropriate workers) |
| 16 | + * - creates a proxy for each worker |
| 17 | + * - when a web socket connection is requested for a worker it passes such request to the appropriate proxy |
| 18 | + */ |
| 19 | +export class InspectorProxyController { |
| 20 | + #runtimeConnectionEstablished: DeferredPromise<void>; |
| 21 | + |
| 22 | + #proxies: InspectorProxy[] = []; |
| 23 | + |
| 24 | + #server: Promise<Server>; |
| 25 | + |
| 26 | + #inspectorPort: number | Promise<number>; |
| 27 | + |
| 28 | + constructor( |
| 29 | + userInspectorPort: number, |
| 30 | + private log: Log, |
| 31 | + private workerNamesToProxy: Set<string> |
| 32 | + ) { |
| 33 | + this.#inspectorPort = |
| 34 | + userInspectorPort !== 0 ? userInspectorPort : getPort(); |
| 35 | + this.#server = this.#initializeServer(); |
| 36 | + this.#runtimeConnectionEstablished = new DeferredPromise(); |
| 37 | + } |
| 38 | + |
| 39 | + async #initializeServer() { |
| 40 | + const server = createServer(async (req, res) => { |
| 41 | + const maybeJson = await this.#handleDevToolsJsonRequest( |
| 42 | + req.headers.host ?? "localhost", |
| 43 | + req.url ?? "/" |
| 44 | + ); |
| 45 | + |
| 46 | + if (maybeJson !== null) { |
| 47 | + res.setHeader("Content-Type", "application/json"); |
| 48 | + res.end(JSON.stringify(maybeJson)); |
| 49 | + return; |
| 50 | + } |
| 51 | + |
| 52 | + res.statusCode = 404; |
| 53 | + res.end(null); |
| 54 | + }); |
| 55 | + |
| 56 | + this.#initializeWebSocketServer(server); |
| 57 | + |
| 58 | + server.listen(await this.#inspectorPort); |
| 59 | + |
| 60 | + return server; |
| 61 | + } |
| 62 | + |
| 63 | + #initializeWebSocketServer(server: Server) { |
| 64 | + const devtoolsWebSocketServer = new WebSocketServer({ server }); |
| 65 | + |
| 66 | + devtoolsWebSocketServer.on("connection", (devtoolsWs, upgradeRequest) => { |
| 67 | + const validationError = |
| 68 | + this.#validateDevToolsWebSocketUpgradeRequest(upgradeRequest); |
| 69 | + if (validationError !== null) { |
| 70 | + devtoolsWs.close(); |
| 71 | + return; |
| 72 | + } |
| 73 | + |
| 74 | + const proxy = this.#proxies.find( |
| 75 | + ({ path }) => upgradeRequest.url === path |
| 76 | + ); |
| 77 | + |
| 78 | + if (!proxy) { |
| 79 | + this.log.warn( |
| 80 | + `Warning: An inspector connection was requested for the ${upgradeRequest.url} path but no such inspector exists` |
| 81 | + ); |
| 82 | + devtoolsWs.close(); |
| 83 | + return; |
| 84 | + } |
| 85 | + |
| 86 | + proxy.onDevtoolsConnected( |
| 87 | + devtoolsWs, |
| 88 | + this.#checkIfDevtoolsHaveFileSystemAccess(upgradeRequest) |
| 89 | + ); |
| 90 | + }); |
| 91 | + } |
| 92 | + |
| 93 | + #validateDevToolsWebSocketUpgradeRequest(req: IncomingMessage) { |
| 94 | + // Validate `Host` header |
| 95 | + const hostHeader = req.headers.host; |
| 96 | + if (hostHeader == null) return { statusText: null, status: 400 }; |
| 97 | + try { |
| 98 | + const host = new URL(`http://${hostHeader}`); |
| 99 | + if (!ALLOWED_HOST_HOSTNAMES.includes(host.hostname)) { |
| 100 | + return { statusText: "Disallowed `Host` header", status: 401 }; |
| 101 | + } |
| 102 | + } catch { |
| 103 | + return { statusText: "Expected `Host` header", status: 400 }; |
| 104 | + } |
| 105 | + // Validate `Origin` header |
| 106 | + let originHeader = req.headers.origin; |
| 107 | + if (!originHeader && !req.headers["user-agent"]) { |
| 108 | + // VSCode doesn't send an `Origin` header, but also doesn't send a |
| 109 | + // `User-Agent` header, so allow an empty origin in this case. |
| 110 | + originHeader = "http://localhost"; |
| 111 | + } |
| 112 | + if (!originHeader) { |
| 113 | + return { statusText: "Expected `Origin` header", status: 400 }; |
| 114 | + } |
| 115 | + try { |
| 116 | + const origin = new URL(originHeader); |
| 117 | + const allowed = ALLOWED_ORIGIN_HOSTNAMES.some((rule) => { |
| 118 | + if (typeof rule === "string") return origin.hostname === rule; |
| 119 | + else return rule.test(origin.hostname); |
| 120 | + }); |
| 121 | + if (!allowed) { |
| 122 | + return { statusText: "Disallowed `Origin` header", status: 401 }; |
| 123 | + } |
| 124 | + } catch { |
| 125 | + return { statusText: "Expected `Origin` header", status: 400 }; |
| 126 | + } |
| 127 | + |
| 128 | + return null; |
| 129 | + } |
| 130 | + |
| 131 | + #checkIfDevtoolsHaveFileSystemAccess(req: IncomingMessage) { |
| 132 | + // Our patched DevTools are hosted on a `https://` URL. These cannot |
| 133 | + // access `file://` URLs, meaning local source maps cannot be fetched. |
| 134 | + // To get around this, we can rewrite `Debugger.scriptParsed` events to |
| 135 | + // include a special `worker:` scheme for source maps, and respond to |
| 136 | + // `Network.loadNetworkResource` commands for these. Unfortunately, this |
| 137 | + // breaks IDE's built-in debuggers (e.g. VSCode and WebStorm), so we only |
| 138 | + // want to enable this transformation when we detect hosted DevTools has |
| 139 | + // connected. We do this by looking at the WebSocket handshake headers: |
| 140 | + // |
| 141 | + // DevTools |
| 142 | + // |
| 143 | + // Upgrade: websocket |
| 144 | + // Host: localhost:9229 |
| 145 | + // (from Chrome) User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.0.0 Safari/537.36 |
| 146 | + // (from Firefox) User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:109.0) Gecko/20100101 Firefox/116.0 |
| 147 | + // Origin: https://devtools.devprod.cloudflare.dev |
| 148 | + // ... |
| 149 | + // |
| 150 | + // VSCode |
| 151 | + // |
| 152 | + // Upgrade: websocket |
| 153 | + // Host: localhost |
| 154 | + // ... |
| 155 | + // |
| 156 | + // WebStorm |
| 157 | + // |
| 158 | + // Upgrade: websocket |
| 159 | + // Host: localhost:9229 |
| 160 | + // Origin: http://localhost:9229 |
| 161 | + // ... |
| 162 | + // |
| 163 | + // From this, we could just use the presence of a `User-Agent` header to |
| 164 | + // determine if DevTools connected, but VSCode/WebStorm could very well |
| 165 | + // add this in future versions. We could also look for an `Origin` header |
| 166 | + // matching the hosted DevTools URL, but this would prevent preview/local |
| 167 | + // versions working. Instead, we look for a browser-like `User-Agent`. |
| 168 | + const userAgent = req.headers["user-agent"] ?? ""; |
| 169 | + const hasFileSystemAccess = !/mozilla/i.test(userAgent); |
| 170 | + |
| 171 | + return hasFileSystemAccess; |
| 172 | + } |
| 173 | + |
| 174 | + #inspectorId = crypto.randomUUID(); |
| 175 | + async #handleDevToolsJsonRequest(host: string, path: string) { |
| 176 | + if (path === "/json/version") { |
| 177 | + return { |
| 178 | + Browser: `miniflare/v${miniflareVersion}`, |
| 179 | + // TODO: (someday): The DevTools protocol should match that of workerd. |
| 180 | + // This could be exposed by the preview API. |
| 181 | + "Protocol-Version": "1.3", |
| 182 | + }; |
| 183 | + } |
| 184 | + |
| 185 | + if (path === "/json" || path === "/json/list") { |
| 186 | + return this.#proxies.map(({ workerName }) => { |
| 187 | + const localHost = `${host}/${workerName}`; |
| 188 | + const devtoolsFrontendUrl = `https://devtools.devprod.cloudflare.dev/js_app?theme=systemPreferred&debugger=true&ws=${localHost}`; |
| 189 | + |
| 190 | + return { |
| 191 | + id: `${this.#inspectorId}-${workerName}`, |
| 192 | + type: "node", // TODO: can we specify different type? |
| 193 | + description: "workers", |
| 194 | + webSocketDebuggerUrl: `ws://${localHost}`, |
| 195 | + devtoolsFrontendUrl, |
| 196 | + devtoolsFrontendUrlCompat: devtoolsFrontendUrl, |
| 197 | + // Below are fields that are visible in the DevTools UI. |
| 198 | + title: |
| 199 | + workerName.length === 0 || this.#proxies.length === 1 |
| 200 | + ? `Cloudflare Worker` |
| 201 | + : `Cloudflare Worker: ${workerName}`, |
| 202 | + faviconUrl: "https://workers.cloudflare.com/favicon.ico", |
| 203 | + // url: "http://" + localHost, // looks unnecessary |
| 204 | + }; |
| 205 | + }); |
| 206 | + } |
| 207 | + |
| 208 | + return null; |
| 209 | + } |
| 210 | + |
| 211 | + async getInspectorURL(): Promise<URL> { |
| 212 | + return getWebsocketURL(await this.#inspectorPort); |
| 213 | + } |
| 214 | + |
| 215 | + async updateConnection(runtimeInspectorPort: number) { |
| 216 | + const workerdInspectorJson = (await fetch( |
| 217 | + `http://127.0.0.1:${runtimeInspectorPort}/json` |
| 218 | + ).then((resp) => resp.json())) as { |
| 219 | + id: string; |
| 220 | + }[]; |
| 221 | + |
| 222 | + this.#proxies = workerdInspectorJson |
| 223 | + .map(({ id }) => { |
| 224 | + if (!id.startsWith("core:user:")) { |
| 225 | + return; |
| 226 | + } |
| 227 | + |
| 228 | + const workerName = id.replace(/^core:user:/, ""); |
| 229 | + |
| 230 | + if (!this.workerNamesToProxy.has(workerName)) { |
| 231 | + return; |
| 232 | + } |
| 233 | + |
| 234 | + return new InspectorProxy( |
| 235 | + workerName, |
| 236 | + new WebSocket(`ws://127.0.0.1:${runtimeInspectorPort}/${id}`) |
| 237 | + ); |
| 238 | + }) |
| 239 | + .filter(Boolean) as InspectorProxy[]; |
| 240 | + |
| 241 | + this.#runtimeConnectionEstablished.resolve(); |
| 242 | + } |
| 243 | + |
| 244 | + async #waitForReady() { |
| 245 | + await this.#runtimeConnectionEstablished; |
| 246 | + } |
| 247 | + |
| 248 | + get ready(): Promise<void> { |
| 249 | + return this.#waitForReady(); |
| 250 | + } |
| 251 | + |
| 252 | + async dispose(): Promise<void> { |
| 253 | + await Promise.all(this.#proxies.map((proxy) => proxy.dispose())); |
| 254 | + |
| 255 | + const server = await this.#server; |
| 256 | + return new Promise((resolve, reject) => { |
| 257 | + server.close((err) => (err ? reject(err) : resolve())); |
| 258 | + }); |
| 259 | + } |
| 260 | +} |
| 261 | + |
| 262 | +function getWebsocketURL(port: number): URL { |
| 263 | + return new URL(`ws://127.0.0.1:${port}`); |
| 264 | +} |
| 265 | + |
| 266 | +const ALLOWED_HOST_HOSTNAMES = ["127.0.0.1", "[::1]", "localhost"]; |
| 267 | +const ALLOWED_ORIGIN_HOSTNAMES = [ |
| 268 | + "devtools.devprod.cloudflare.dev", |
| 269 | + "cloudflare-devtools.pages.dev", |
| 270 | + /^[a-z0-9]+\.cloudflare-devtools\.pages\.dev$/, |
| 271 | + "127.0.0.1", |
| 272 | + "[::1]", |
| 273 | + "localhost", |
| 274 | +]; |
0 commit comments