From e3fa7491c1e89449e1d413db400e0973109a936d Mon Sep 17 00:00:00 2001 From: Sunil Pai Date: Thu, 6 Jan 2022 23:27:53 +0000 Subject: [PATCH] refactor: inspect/debugging as `useInspector` This PR rewrites the logic for establishing a connection with the debugging socket, and adds some enhancements along the way. Part of solving https://github.com/cloudflare/wrangler2/issues/188. Some highlights - - I've installed `"devtools-protocol`, a convenient package that has the static types for the devtools protocol (duh) autogenerated from chrome's devtools codebase. - We now log messages and exceptions into the terminal directly, so you don't have to open devtools to see those messages. - Messages are now buffered until a devtools instance connects, so you won't lose any messages while devtools isn't connected. - We don't lose the connection on making changes to the worker, removing the need for the kludgy hack on the devtools side (where we refresh the whole page when there's a change) Some things that I still have to do, and will do so in followup PRs - - clear the console whenever we make a change to the worker - stay connected when we shift between local/remote mode - eventually, move to using the devtools hosted at cloudflareworkers.com. --- .changeset/slimy-suits-hope.md | 10 + package-lock.json | 14 + packages/wrangler/package.json | 1 + packages/wrangler/src/api/inspect.ts | 430 ---------------------- packages/wrangler/src/dev.tsx | 59 +-- packages/wrangler/src/inspect.ts | 524 +++++++++++++++++++++++++++ 6 files changed, 557 insertions(+), 481 deletions(-) create mode 100644 .changeset/slimy-suits-hope.md delete mode 100644 packages/wrangler/src/api/inspect.ts create mode 100644 packages/wrangler/src/inspect.ts diff --git a/.changeset/slimy-suits-hope.md b/.changeset/slimy-suits-hope.md new file mode 100644 index 000000000000..5d94143fc098 --- /dev/null +++ b/.changeset/slimy-suits-hope.md @@ -0,0 +1,10 @@ +--- +"wrangler": patch +--- + +Refactor inspection/debugging code - + +- I've installed devtools-protocol, a convenient package that has the static types for the devtools protocol (duh) autogenerated from chrome's devtools codebase. +- We now log messages and exceptions into the terminal directly, so you don't have to open devtools to see those messages. +- Messages are now buffered until a devtools instance connects, so you won't lose any messages while devtools isn't connected. +- We don't lose the connection on making changes to the worker, removing the need for the kludgy hack on the devtools side (where we refresh the whole page when there's a change) diff --git a/package-lock.json b/package-lock.json index b044bc831839..8d46af42d287 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4802,6 +4802,12 @@ "node": ">=8" } }, + "node_modules/devtools-protocol": { + "version": "0.0.955664", + "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.955664.tgz", + "integrity": "sha512-IfjiDwhl93zU1Bdx7tMJHG+r9xr7ES3CSOS/gtarTjk+dmI+uWMgCzHaMhWXqMlgWnHA0uINxhfKCqAmWjiFbw==", + "dev": true + }, "node_modules/dicer": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/dicer/-/dicer-0.3.0.tgz", @@ -15163,6 +15169,7 @@ "chokidar": "^3.5.2", "clipboardy": "^3.0.0", "command-exists": "^1.2.9", + "devtools-protocol": "^0.0.955664", "execa": "^6.0.0", "faye-websocket": "^0.11.4", "finalhandler": "^1.1.2", @@ -19059,6 +19066,12 @@ "resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz", "integrity": "sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==" }, + "devtools-protocol": { + "version": "0.0.955664", + "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.955664.tgz", + "integrity": "sha512-IfjiDwhl93zU1Bdx7tMJHG+r9xr7ES3CSOS/gtarTjk+dmI+uWMgCzHaMhWXqMlgWnHA0uINxhfKCqAmWjiFbw==", + "dev": true + }, "dicer": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/dicer/-/dicer-0.3.0.tgz", @@ -26517,6 +26530,7 @@ "chokidar": "^3.5.2", "clipboardy": "^3.0.0", "command-exists": "^1.2.9", + "devtools-protocol": "^0.0.955664", "esbuild": "0.14.1", "execa": "^6.0.0", "faye-websocket": "^0.11.4", diff --git a/packages/wrangler/package.json b/packages/wrangler/package.json index 21eef4e38e4c..927e07d5e0ee 100644 --- a/packages/wrangler/package.json +++ b/packages/wrangler/package.json @@ -59,6 +59,7 @@ "chokidar": "^3.5.2", "clipboardy": "^3.0.0", "command-exists": "^1.2.9", + "devtools-protocol": "^0.0.955664", "execa": "^6.0.0", "faye-websocket": "^0.11.4", "finalhandler": "^1.1.2", diff --git a/packages/wrangler/src/api/inspect.ts b/packages/wrangler/src/api/inspect.ts deleted file mode 100644 index dabff61783e3..000000000000 --- a/packages/wrangler/src/api/inspect.ts +++ /dev/null @@ -1,430 +0,0 @@ -import { Response, Request, Headers } from "node-fetch"; -import type { MessageEvent } from "ws"; -import WebSocket, { WebSocketServer } from "ws"; -import type { IncomingMessage, ServerResponse } from "http"; -import { createServer } from "http"; - -/** - * A call frame. - * - * @link https://chromedevtools.github.io/devtools-protocol/1-3/Runtime/#type-CallFrame - */ -export interface DtCallFrame { - /** - * The module path. - * - * @example - * 'worker.js' - */ - url: string; - /** - * The function name. - */ - functionName?: string; - /** - * The line number. (0-based) - */ - lineNumber: number; - /** - * The column number. (0-based) - */ - columnNumber: number; -} - -/** - * A stack trace. - * - * @link https://chromedevtools.github.io/devtools-protocol/1-3/Runtime/#type-StackTrace - */ -export interface DtStackTrace { - /** - * A description of the stack, only present in certain contexts. - */ - description?: string; - /** - * The call frames. - */ - callFrames: DtCallFrame[]; - /** - * The parent stack trace. - */ - parent?: DtStackTrace; -} - -/** - * A JavaScript object type. - */ -export type DtRemoteObjectType = - | "object" - | "function" - | "undefined" - | "string" - | "number" - | "boolean" - | "symbol" - | "bigint"; - -/** - * A view of a remote JavaScript object. - * - * @link https://chromedevtools.github.io/devtools-protocol/1-3/Runtime/#type-RemoteObject - */ -export interface DtRemoteObject { - /** - * The object type. - * - * @example - * 'string' - */ - type: DtRemoteObjectType; - /** - * The specific object type, if the type is `object`. - * - * @example - * 'arraybuffer' - */ - subtype?: string; - /** - * The class name, if the type if `object`. - */ - className?: string; - /** - * The object as a string. - * - * @example - * 'Array(1)' - * 'TypeError: Oops!\n at worker.js:5:15' - */ - description?: string; - /** - * The object. - */ - value?: T; - // TODO(soon): add a preview field for more complex types -} - -/** - * An event when `console.log()` is invoked. - * - * @link https://chromedevtools.github.io/devtools-protocol/1-3/Runtime/#event-consoleAPICalled - */ -export interface DtLogEvent { - timestamp: number; - type: string; - args: DtRemoteObject[]; - stackTrace?: DtStackTrace; -} - -/** - * An event when an uncaught `Error` is thrown. - * - * @link https://chromedevtools.github.io/devtools-protocol/1-3/Runtime/#event-exceptionThrown - */ -export interface DtExceptionEvent { - timestamp: number; - exceptionDetails: { - lineNumber: number; - columnNumber: number; - exception: DtRemoteObject; - stackTrace: DtStackTrace; - }; -} - -/** - * A DevTools event. - */ -export type DtEvent = DtLogEvent | DtExceptionEvent; - -/** - * A listener that receives DevTools events. - */ -export type DtListener = (event: DtEvent) => void; - -/** - * A DevTools inspector that listens to logs and debug events from a Worker. - * - * @example - * const worker: CfWorker - * const inspector: DtInspector = await worker.inspect() - * - * @link https://chromedevtools.github.io/devtools-protocol/ - */ -export class DtInspector { - #webSocket: WebSocket; - #keepAlive?: NodeJS.Timer; - - constructor(url: string) { - // this.#events = []; - // this.#listeners = []; - this.#webSocket = new WebSocket(url); - this.#webSocket.onopen = () => { - this.enable(); - }; - this.#webSocket.onclose = () => { - this.disable(); - }; - this.#webSocket.on("unexpected-response", () => { - console.log("504??"); // TODO: refactor this class to start again - }); - this.#webSocket.onmessage = (event: MessageEvent) => { - // TODO: this seems unnecessary, unless we're planning - // on logging to console. We'll see. - if (typeof event.data === "string") { - // this.recv(JSON.parse(event.data)); - } else { - // ?? - } - }; - } - - /** - * Exposes a websocket proxy on a localhost port. - */ - proxyTo(port: number): AbortController { - return bind(new DtInspectorBridge(this.#webSocket, port), port); - } - - /** - * If the inspector is closed. - */ - get closed(): boolean { - return this.#webSocket.readyState === WebSocket.CLOSED; - } - - /** - * Closes the inspector. - */ - close(): void { - if (!this.closed) { - try { - this.#webSocket.close(); - } catch (err) { - // Closing before the websocket is ready will throw an error. - } - } - // this.#events = []; - } - - private send(event: Record): void { - if (!this.closed) { - this.#webSocket.send(JSON.stringify(event)); - } - } - - private enable(): void { - let id = 1; - this.send({ method: "Runtime.enable", id }); - this.#keepAlive = setInterval(() => { - this.send({ method: "Runtime.getIsolateId", id: id++ }); - }, 10_000); - } - - private disable(): void { - if (this.#keepAlive) { - clearInterval(this.#keepAlive); - this.#keepAlive = undefined; - } - } -} - -/** - * A HTTP server that responds to `fetch()` requests. - */ -interface FetchServer { - /** - * Responds to a request. - */ - fetch(request: Request): Promise; - /** - * Accepts a websocket connection. - */ - upgrade?(webSocket: WebSocket): void; -} - -/** - * A bridge between a remote DevTools inspector and Chrome. - * - * Exposes a localhost HTTP server that responds to informational requests - * from Chrome about the DevTools inspector. Then, when it receives a - * WebSocket upgrade, forwards the connection to the remote inspector. - */ -class DtInspectorBridge implements FetchServer { - #webSocket: WebSocket; - #localPort: number; - #connected: boolean; - - constructor(webSocket: WebSocket, localPort?: number) { - this.#webSocket = webSocket; - this.#localPort = localPort; - this.#connected = false; - } - - async fetch(request: Request): Promise { - const { url } = request; - const { pathname } = new URL(url); - switch (pathname) { - case "/json/version": - return this.version; - case "/json": - case "/json/list": - return this.location; - default: - break; - } - return new Response("Not Found", { status: 404 }); - } - - upgrade(webSocket: WebSocket): void { - if (this.#connected) { - webSocket.close( - 1013, - "Too many clients; only one can be connected at a time" - ); - } else { - this.#connected = true; - webSocket.addEventListener("close", () => (this.#connected = false)); - proxyWebSocket(webSocket, this.#webSocket); - } - } - - private get version(): Response { - const headers = { "Content-Type": "application/json" }; - const body = { - Browser: "workers-run/v0.0.0", // TODO: this should pick up version from package.json - // TODO: (someday): The DevTools protocol should match that of edgeworker. - // This could be exposed by the preview API. - "Protocol-Version": "1.3", - }; - return new Response(JSON.stringify(body), { headers }); - } - - private get location(): Response { - const headers = { "Content-Type": "application/json" }; - const localHost = `localhost:${this.#localPort}/ws`; - const devtoolsFrontendUrl = `devtools://devtools/bundled/js_app.html?experiments=true&v8only=true&ws=${localHost}`; - const devtoolsFrontendUrlCompat = `devtools://devtools/bundled/inspector.html?experiments=true&v8only=true&ws=${localHost}`; - const body = [ - { - id: randomId(), - type: "node", - description: "workers", - webSocketDebuggerUrl: `ws://${localHost}`, - devtoolsFrontendUrl, - devtoolsFrontendUrlCompat, - // Below are fields that are visible in the DevTools UI. - title: "Cloudflare Worker", - faviconUrl: "https://workers.cloudflare.com/favicon.ico", - url: "https://" + new URL(this.#webSocket.url).host, - }, - ]; - return new Response(JSON.stringify(body), { headers }); - } -} - -function bind(fetcher: FetchServer, port?: number): AbortController { - const controller = new AbortController(); - - const server = createServer( - (input: IncomingMessage, output: ServerResponse) => - toRequest(input) - .then((request) => fetcher.fetch(request)) - .then((response) => toResponse(response, output)) - .catch((error) => console.error(error)) - ); - - if (fetcher.upgrade) { - const webSocket = new WebSocketServer({ server }); - webSocket.on("connection", (ws: WebSocket) => fetcher.upgrade(ws)); - } - - const socket = server.listen({ port }); - controller.signal.onabort = () => { - server.close(); - socket.close(); - }; - return controller; -} - -/** - * Converts a Node.js request to a Web standard `Request`. - */ -async function toRequest(request: IncomingMessage): Promise { - const host = request.headers.host ?? "localhost"; - const { href } = new URL(request.url, "http://" + host); - - const { rawHeaders, method } = request; - const headers = new Headers(); - for (let i = 0; i < rawHeaders.length; i += 2) { - headers.append(rawHeaders[i], rawHeaders[i + 1]); - } - - return new Promise((resolve, reject) => { - const chunks = []; - request.on("data", (chunk) => chunks.push(chunk)); - request.on("error", (error) => reject(error)); - request.on("end", () => { - const buffer = Buffer.concat(chunks); - const body = buffer.length === 0 ? undefined : buffer; - resolve(new Request(href, { method, headers, body })); - }); - }); -} - -/** - * Converts a Web standard `Response` into a Node.js response. - */ -async function toResponse( - input: Response, - response: ServerResponse -): Promise { - const { status, statusText, headers, body: hasBody } = input; - - for (const [name, value] of headers.entries()) { - response.setHeader(name, value); - } - - let body: Uint8Array; - if (hasBody) { - body = new Uint8Array(await input.arrayBuffer()); - response.setHeader("Content-Length", body.byteLength); - } - - response.writeHead(status, statusText); - response.write(body); -} - -/** - * Creates a proxy bridge between two websockets. - */ -function proxyWebSocket(webSocket: WebSocket, otherSocket: WebSocket): void { - webSocket.addEventListener("message", (event: MessageEvent) => { - try { - otherSocket.send(event.data); - } catch (e) { - if (e.message !== "WebSocket is not open: readyState 0 (CONNECTING)") { - // this just means we haven't opened a websocket yet - // usually happens until there's at least one request - // which is weird, because we may miss something that happens on - // the first request - console.error(e); - } - } - }); - otherSocket.addEventListener("message", (event: MessageEvent) => - webSocket.send(event.data) - ); - - // Some close codes are marked as 'reserved' and will throw an error if used. - // Therefore, it's not worth the effort to passthrough the close code and reason. - webSocket.addEventListener("close", () => otherSocket.close()); - otherSocket.addEventListener("close", () => webSocket.close()); -} - -// Credit: https://stackoverflow.com/a/2117523 -function randomId(): string { - return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, function (c) { - const r = (Math.random() * 16) | 0, - v = c == "x" ? r : (r & 0x3) | 0x8; - return v.toString(16); - }); -} diff --git a/packages/wrangler/src/dev.tsx b/packages/wrangler/src/dev.tsx index 38c90dbc15ea..429c1cd595e4 100644 --- a/packages/wrangler/src/dev.tsx +++ b/packages/wrangler/src/dev.tsx @@ -8,7 +8,7 @@ import { Box, Text, useApp, useInput } from "ink"; import React, { useState, useEffect, useRef } from "react"; import path from "path"; import open from "open"; -import { DtInspector } from "./api/inspect"; +import useInspector from "./inspect"; import type { CfModule } from "./api/worker"; import { createWorker } from "./api/worker"; import type { CfWorkerInit } from "./api/worker"; @@ -16,7 +16,6 @@ import { spawn } from "child_process"; import onExit from "signal-exit"; import { syncAssets } from "./sites"; import clipboardy from "clipboardy"; -import http from "node:http"; import commandExists from "command-exists"; import assert from "assert"; import { getAPIToken } from "./user"; @@ -79,9 +78,6 @@ function Dev(props: DevProps): JSX.Element { ); } - // @ts-expect-error whack - useDevtoolsRefresh(bundle?.id ?? 0); - const toggles = useHotkeys( { local: props.initialMode === "local", @@ -133,36 +129,6 @@ function Dev(props: DevProps): JSX.Element { ); } -function useDevtoolsRefresh(bundleId: number) { - // TODO: this is a hack while we figure out - // a better cleaner solution to get devtools to reconnect - // without having to do a full refresh - const ref = useRef(); - // @ts-expect-error whack - ref.current = bundleId; - - useEffect(() => { - const server = http.createServer((req, res) => { - res.setHeader("Access-Control-Allow-Origin", "*"); - res.setHeader("Access-Control-Request-Method", "*"); - res.setHeader("Access-Control-Allow-Methods", "OPTIONS, GET"); - res.setHeader("Access-Control-Allow-Headers", "*"); - if (req.method === "OPTIONS") { - res.writeHead(200); - res.end(); - return; - } - res.writeHead(200, { "Content-Type": "application/json" }); - res.end(JSON.stringify({ value: ref.current })); - }); - - server.listen(3142); - return () => { - server.close(); - }; - }, []); -} - function Remote(props: { name: void | string; bundle: EsbuildBundle | void; @@ -196,7 +162,11 @@ function Remote(props: { useProxy({ token, publicRoot: props.public, port: props.port }); - useInspector(token ? token.inspectorUrl.href : undefined); + useInspector({ + inspectorUrl: token ? token.inspectorUrl.href : undefined, + port: 9229, + logToTerminal: true, + }); return null; } function Local(props: { @@ -215,7 +185,7 @@ function Local(props: { bindings: props.bindings, port: props.port, }); - useInspector(inspectorUrl); + useInspector({ inspectorUrl, port: 9229, logToTerminal: false }); return null; } @@ -230,7 +200,7 @@ function useLocalWorker(props: { const { bundle, format, bindings, port } = props; const local = useRef>(); const removeSignalExitListener = useRef<() => void>(); - const [inspectorUrl, setInspectorUrl] = useState(); + const [inspectorUrl, setInspectorUrl] = useState(); useEffect(() => { async function startLocalWorker() { if (!bundle) return; @@ -720,19 +690,6 @@ function useProxy({ }, [token, publicRoot, port]); } -function useInspector(inspectorUrl: string | void) { - useEffect(() => { - if (!inspectorUrl) return; - - const inspector = new DtInspector(inspectorUrl); - const abortController = inspector.proxyTo(9229); - return () => { - inspector.close(); - abortController.abort(); - }; - }, [inspectorUrl]); -} - function sleep(period: number) { return new Promise((resolve) => setTimeout(resolve, period)); } diff --git a/packages/wrangler/src/inspect.ts b/packages/wrangler/src/inspect.ts new file mode 100644 index 000000000000..68c9865062e2 --- /dev/null +++ b/packages/wrangler/src/inspect.ts @@ -0,0 +1,524 @@ +import type { MessageEvent } from "ws"; +import WebSocket, { WebSocketServer } from "ws"; +import type { IncomingMessage, Server, ServerResponse } from "http"; +import { createServer } from "http"; +import { useEffect, useRef, useState } from "react"; +import { version } from "../package.json"; + +import type Protocol from "devtools-protocol"; + +/** + * `useInspector` is a hook for debugging Workers applications + * when using `wrangler dev`. + * + * When we start a session with `wrangler dev`, the Workers platform + * also exposes a debugging websocket that implements the DevTools + * Protocol. While we could just start up DevTools and connect to this + * URL, that URL changes every time we make a change to the + * worker, or when the session expires. Instead, we start up a proxy + * server locally that acts as a bridge between the remote DevTools + * server and the local DevTools instance. So whenever the URL changes, + * we can can silently connect to it and keep the local DevTools instance + * up to date. Further, we also intercept these messages and selectively + * log them directly to the terminal (namely, calls to `console.`, + * and exceptions) + */ + +/** + * TODO: + * - clear devtools whenever we save changes to the worker + * - clear devtools when we switch between local/remote modes + * - handle more methods from console + */ + +interface InspectorProps { + /** + * The port that the local proxy server should listen on. + */ + port: number; + /** + * The websocket URL exposed by Workers that the inspector should connect to. + */ + inspectorUrl: undefined | string; + /** + * Whether console statements and exceptions should be logged to the terminal. + * (We don't log them in local mode because they're already getting + * logged to the terminal by nature of them actually running in node locally.) + */ + logToTerminal: boolean; +} + +export default function useInspector(props: InspectorProps) { + /** A unique ID for this session. */ + const inspectorIdRef = useRef(randomId()); + /** + * The local proxy server that acts as the bridge between + * the remote websocket and the local DevTools instance. + */ + const serverRef = useRef(); + /** The websocket server that runs on top of the proxy server. */ + const wsServerRef = useRef(); + + /** The websocket from the devtools instance. */ + const [localWebSocket, setLocalWebSocket] = useState(); + /** The websocket from the edge */ + const [remoteWebSocket, setRemoteWebSocket] = useState< + WebSocket | undefined + >(); + + if (!serverRef.current) { + // Let's create the proxy server! + serverRef.current = createServer( + (req: IncomingMessage, res: ServerResponse) => { + switch (req.url) { + // We implement a couple of well known end points + // that are queried for metadata by chrome://inspect + case "/json/version": + res.setHeader("Content-Type", "application/json"); + res.end( + JSON.stringify({ + Browser: `wrangler/v${version}`, + // TODO: (someday): The DevTools protocol should match that of edgeworker. + // This could be exposed by the preview API. + "Protocol-Version": "1.3", + }) + ); + return; + case "/json": + case "/json/list": + { + res.setHeader("Content-Type", "application/json"); + const localHost = `localhost:${props.port}/ws`; + const devtoolsFrontendUrl = `devtools://devtools/bundled/js_app.html?experiments=true&v8only=true&ws=${localHost}`; + const devtoolsFrontendUrlCompat = `devtools://devtools/bundled/inspector.html?experiments=true&v8only=true&ws=${localHost}`; + res.end( + JSON.stringify([ + { + id: inspectorIdRef.current, + type: "node", + description: "workers", + webSocketDebuggerUrl: `ws://${localHost}`, + devtoolsFrontendUrl, + devtoolsFrontendUrlCompat, + // Below are fields that are visible in the DevTools UI. + title: "Cloudflare Worker", + faviconUrl: "https://workers.cloudflare.com/favicon.ico", + url: + "https://" + + (remoteWebSocket + ? new URL(remoteWebSocket.url).host + : "workers.dev"), + }, + ]) + ); + } + return; + default: + break; + } + } + ); + + // Let's create the websocket server on top of the proxy server + wsServerRef.current = new WebSocketServer({ + server: serverRef.current, + clientTracking: true, + }); + wsServerRef.current.on("connection", (ws: WebSocket) => { + if (wsServerRef.current.clients.size > 1) { + /** We only want to have one active Devtools instance at a time. */ + console.error( + "Tried to open a new devtools window when a previous one was already open." + ); + ws.close(1013, "Too many clients; only one can be connected at a time"); + } else { + // As promised, save the created websocket in a state hook + setLocalWebSocket(ws); + + ws.addEventListener("close", () => { + // And and cleanup when devtools closes + setLocalWebSocket(undefined); + }); + } + }); + } + + /** + * We start and stop the server in an effect to take advantage + * of the component lifecycle. Convenient. + */ + useEffect(() => { + serverRef.current.listen(props.port); + return () => { + serverRef.current.close(); + // Also disconnect any open websockets/devtools connections + wsServerRef.current.clients.forEach((ws) => ws.close()); + wsServerRef.current.close(); + }; + }, [props.port]); + + /** + * When connecting to the remote websocket, if we don't start either + * the devtools instance or make an actual request to the worker in time, + * then the connecting process can error out. When this happens, we + * want to simply retry the connection. We use a state hook to trigger retries + * of the effect that connects to the remote websocket. + */ + const [ + retryRemoteWebSocketConnectionSigil, + setRetryRemoteWebSocketConnectionSigil, + ] = useState(0); + function retryRemoteWebSocketConnection() { + setRetryRemoteWebSocketConnectionSigil((x) => x + 1); + } + + /** A simple incrementing id to attach to messages we send to devtools */ + const messageCounterRef = useRef(1); + + // This effect tracks the connection to the remote websocket + // (stored in, no surprises here, `remoteWebSocket`) + useEffect(() => { + if (!props.inspectorUrl) { + return; + } + // The actual websocket instance + const ws = new WebSocket(props.inspectorUrl); + setRemoteWebSocket(ws); + + /** + * A handle to the interval we run to keep the websocket alive + */ + let keepAliveInterval: NodeJS.Timer; + + /** + * Test if the websocket is closed + */ + function isClosed() { + return ( + ws.readyState === WebSocket.CLOSED || + ws.readyState === WebSocket.CLOSING + ); + } + + /** + * Send a message to the remote websocket + */ + function send(event: Record): void { + if (!isClosed()) { + ws.send(JSON.stringify(event)); + } + } + + /** + * Closes the inspector. + */ + function close(): void { + if (!isClosed()) { + try { + ws.close(); + } catch (err) { + // Closing before the websocket is ready will throw an error. + } + } + } + + /** + * Since we have a handle on the remote websocket, we can tap + * into its events, and log any pertinent ones directly to + * the terminal (which means you have insight into your worker + * without having to open the devtools). + */ + if (props.logToTerminal) { + ws.addEventListener("message", (event: MessageEvent) => { + if (typeof event.data === "string") { + const evt = JSON.parse(event.data); + if (evt.method === "Runtime.exceptionThrown") { + const params = evt.params as Protocol.Runtime.ExceptionThrownEvent; + console.error( + "🚨", // cheesy, but it works + // maybe we could use color here too. + params.exceptionDetails.text, + params.exceptionDetails.exception.description + ); + } + if (evt.method === "Runtime.consoleAPICalled") { + logConsoleMessage( + evt.params as Protocol.Runtime.ConsoleAPICalledEvent + ); + } + } else { + // We should never get here, but who know is 2022... + console.error("unrecognised devtools event:", event); + } + }); + } + + ws.addEventListener("open", () => { + send({ method: "Runtime.enable", id: messageCounterRef.current }); + // TODO: This doesn't actually work. Must fix. + send({ method: "Network.enable", id: messageCounterRef.current++ }); + + keepAliveInterval = setInterval(() => { + send({ + method: "Runtime.getIsolateId", + id: messageCounterRef.current++, + }); + }, 10_000); + }); + + ws.on("unexpected-response", () => { + console.log("waiting for connection..."); + /** + * This usually means the worker is not "ready" yet + * so we'll just retry the connection process + */ + retryRemoteWebSocketConnection(); + }); + + ws.addEventListener("close", () => { + clearInterval(keepAliveInterval); + }); + + return () => { + // clean up! Let's first stop the heartbeat interval + clearInterval(keepAliveInterval); + // Then we'll send a message to the devtools instance to + // tell it to clear the console. + wsServerRef.current.clients.forEach((client) => { + // We could've used `localSocket` here, but + // then we would have had to add it to the effect + // change detection array, which would have made a + // bunch of other stuff complicated. So we'll just + // cycle through all of the server's connected clients + // (in practice, there should only be one or zero) and send + // the Log.clear message. + client.send( + JSON.stringify({ + // TODO: This doesn't actually work. Must fix. + method: "Log.clear", + // we can disable the next eslint warning since + // we're referencing a ref that stays alive + // eslint-disable-next-line react-hooks/exhaustive-deps + id: messageCounterRef.current++, + params: {}, + }) + ); + }); + // Finally, we'll close the websocket + close(); + // And we'll clear `remoteWebsocket` + setRemoteWebSocket(undefined); + }; + }, [ + props.inspectorUrl, + retryRemoteWebSocketConnectionSigil, + props.logToTerminal, + ]); + + /** + * We want to make sure we don't lose any messages we receive from the + * remote websocket before devtools connects. So we use a ref to buffer + * messages, and flush them whenever devtools connects. + */ + const messageBufferRef = useRef([]); + + // This effect tracks the state changes _between_ the local + // and remote websockets, and handles how messages flow between them. + useEffect(() => { + /** + * This event listener is used for buffering messages from + * the remote websocket, and flushing them + * when the local websocket connects. + */ + function bufferMessageFromRemoteSocket(event: MessageEvent) { + messageBufferRef.current.push(event); + // TODO: maybe we should have a max limit on this? + // if so, we should be careful when removing messages + // from the front, because they could be critical for + // devtools (like execution context creation, etc) + } + + if (remoteWebSocket && !localWebSocket) { + // The local websocket hasn't connected yet, so we'll + // buffer messages until it does. + remoteWebSocket.addEventListener( + "message", + bufferMessageFromRemoteSocket + ); + } + + /** Send a message from the local websocket to the remote websocket */ + function sendMessageToRemoteWebSocket(event: MessageEvent) { + try { + remoteWebSocket.send(event.data); + } catch (e) { + if (e.message !== "WebSocket is not open: readyState 0 (CONNECTING)") { + /** + * ^ this just means we haven't opened a websocket yet + * usually happens until there's at least one request + * which is weird, because we may miss something that + * happens on the first request. Maybe we should buffer + * these messages too? + */ + console.error(e); + } + } + } + + /** Send a message from the local websocket to the remote websocket */ + function sendMessageToLocalWebSocket(event: MessageEvent) { + localWebSocket.send(event.data); + } + + if (localWebSocket && remoteWebSocket) { + // Both the remote and local websockets are connected, so let's + // start sending messages between them. + localWebSocket.addEventListener("message", sendMessageToRemoteWebSocket); + remoteWebSocket.addEventListener("message", sendMessageToLocalWebSocket); + + // Also, let's flush any buffered messages + messageBufferRef.current.forEach(sendMessageToLocalWebSocket); + messageBufferRef.current = []; + } + + return () => { + // Cleanup like good citizens + if (remoteWebSocket) { + remoteWebSocket.removeEventListener( + "message", + bufferMessageFromRemoteSocket + ); + remoteWebSocket.removeEventListener( + "message", + sendMessageToLocalWebSocket + ); + } + if (localWebSocket) { + localWebSocket.removeEventListener( + "message", + sendMessageToRemoteWebSocket + ); + } + }; + }, [localWebSocket, remoteWebSocket]); +} + +// Credit: https://stackoverflow.com/a/2117523 +function randomId(): string { + return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, function (c) { + const r = (Math.random() * 16) | 0, + v = c == "x" ? r : (r & 0x3) | 0x8; + return v.toString(16); + }); +} + +/** + * This function converts a message serialised as a devtools event + * into arguments suitable to be called by a console method, and + * then actually calls the method with those arguments. Effectively, + * we're just doing a little bit of the work of the devtools console, + * directly in the terminal. + */ +function logConsoleMessage(evt: Protocol.Runtime.ConsoleAPICalledEvent): void { + const args = []; + for (const ro of evt.args) { + switch (ro.type) { + case "string": + case "number": + case "boolean": + case "undefined": + case "symbol": + case "bigint": + args.push(ro.value); + break; + case "function": + args.push(`[Function: ${ro.description}]`); + break; + case "object": + if (!ro.preview) { + args.push(ro.description); + } else { + args.push(ro.preview.description); + + switch (ro.preview.subtype) { + case "array": + args.push( + "[ " + + ro.preview.properties + .map(({ value }) => { + return value; + }) + .join(", ") + + (ro.preview.overflow ? "..." : "") + + " ]" + ); + + break; + case "map": + args.push( + "{\n" + + ro.preview.entries + .map(({ key, value }) => { + return ` ${key.description} => ${value.description}`; + }) + .join(",\n") + + (ro.preview.overflow ? "\n ..." : "") + + "\n}" + ); + + break; + case "set": + args.push( + "{ " + + ro.preview.entries + .map(({ value }) => { + return `${value.description}`; + }) + .join(", ") + + (ro.preview.overflow ? ", ..." : "") + + " }" + ); + + break; + case "null": + args.push("null"); + break; + case "node": + case "regexp": + case "date": + case "weakmap": + case "weakset": + case "iterator": + case "generator": + case "error": + case "proxy": + case "promise": + case "typedarray": + case "arraybuffer": + case "dataview": + case "webassemblymemory": + case "wasmvalue": + break; + default: + // just a pojo + args.push( + "{\n" + + ro.preview.properties + .map(({ name, value }) => { + return ` ${name}: ${value}`; + }) + .join(",\n") + + (ro.preview.overflow ? "\n ..." : "") + + "\n}" + ); + } + } + break; + default: + args.push(ro.description || ro.unserializableValue || "🦋"); + break; + } + } + + console[evt.type](...args); +}