Skip to content
8 changes: 8 additions & 0 deletions .changeset/perfect-plants-compete.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
"miniflare": patch
"wrangler": patch
---

In 2023 we announced [breakpoint debugging support](https://blog.cloudflare.com/debugging-cloudflare-workers/) for Workers, which meant that you could easily debug your Worker code in Wrangler's built-in devtools (accessible via the `[d]` hotkey) as well as multiple other devtools clients, [including VSCode](https://developers.cloudflare.com/workers/observability/dev-tools/breakpoints/). For most developers, breakpoint debugging via VSCode is the most natural flow, but until now it's required [manually configuring a `launch.json` file](https://developers.cloudflare.com/workers/observability/dev-tools/breakpoints/#setup-vs-code-to-use-breakpoints), running `wrangler dev`, and connecting via VSCode's built-in debugger.

Now, using VSCode's built-in [JavaScript Debug Terminals](https://code.visualstudio.com/docs/nodejs/nodejs-debugging#_javascript-debug-terminal), there are just two steps: open a JS debug terminal and run `wrangler dev` (or `vite dev`). VSCode will automatically connect to your running Worker (even if you're running multiple Workers at once!) and start a debugging session.
10 changes: 8 additions & 2 deletions packages/miniflare/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -966,7 +966,12 @@ export class Miniflare {

this.#log = this.#sharedOpts.core.log ?? new NoOpLog();

if (enableInspectorProxy) {
// If we're in a JavaScript Debug terminal, Miniflare will send the inspector ports directly to VSCode for registration
// As such, we don't need our inspector proxy and in fact including it causes issue with multiple clients connected to the
// inspector endpoint.
const inVscodeJsDebugTerminal = !!process.env.VSCODE_INSPECTOR_OPTIONS;

if (enableInspectorProxy && !inVscodeJsDebugTerminal) {
if (this.#sharedOpts.core.inspectorPort === undefined) {
throw new MiniflareCoreError(
"ERR_MISSING_INSPECTOR_PROXY_PORT",
Expand Down Expand Up @@ -1989,7 +1994,8 @@ export class Miniflare {
};
const maybeSocketPorts = await this.#runtime.updateConfig(
configBuffer,
runtimeOpts
runtimeOpts,
this.#workerOpts.flatMap((w) => w.core.name ?? [])
);
if (this.#disposeController.signal.aborted) return;
if (maybeSocketPorts === undefined) {
Expand Down
74 changes: 71 additions & 3 deletions packages/miniflare/src/runtime/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import assert from "assert";
import childProcess from "child_process";
import childProcess, { spawn } from "child_process";
import { randomBytes } from "crypto";
import { Abortable, once } from "events";
import path from "path";
import rl from "readline";
import { Readable } from "stream";
import { $ as $colors, red } from "kleur/colors";
Expand Down Expand Up @@ -119,13 +121,40 @@ function getRuntimeArgs(options: RuntimeOptions) {
return args;
}

/**
* Copied from https://github.com/microsoft/vscode-js-debug/blob/0b5e0dade997b3c702a98e1f58989afcb30612d6/src/targets/node/bootloader/environment.ts#L129
*
* This function returns the segment of process.env.VSCODE_INSPECTOR_OPTIONS that corresponds to the current process (rather than a parent process)
*/
function getInspectorOptions() {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Add JSDoc?

const value = process.env.VSCODE_INSPECTOR_OPTIONS;
if (!value) {
return undefined;
}

const ownOptions = value
.split(":::")
.reverse()
.find((v) => !!v);
if (!ownOptions) {
return;
}

try {
return JSON.parse(ownOptions);
} catch {
return undefined;
}
}

export class Runtime {
#process?: childProcess.ChildProcess;
#processExitPromise?: Promise<void>;

async updateConfig(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Add JSDoc?

configBuffer: Buffer,
options: Abortable & RuntimeOptions
options: Abortable & RuntimeOptions,
workerNames: string[]
): Promise<SocketPorts | undefined> {
// 1. Stop existing process (if any) and wait for exit
await this.dispose();
Expand Down Expand Up @@ -156,7 +185,46 @@ export class Runtime {
await once(runtimeProcess.stdin, "finish");

// 4. Wait for sockets to start listening
return waitForPorts(controlPipe, options);
const ports = await waitForPorts(controlPipe, options);
if (ports?.has(kInspectorSocket) && process.env.VSCODE_INSPECTOR_OPTIONS) {
// We have an inspector socket and we're in a VSCode Debug Terminal.
// Let's startup a watchdog service to register ourselves as a debuggable target

// First, we need to _find_ the watchdog script. It's located next to bootloader.js, which should be injected as a require hook
const bootloaderPath =
process.env.NODE_OPTIONS?.match(/--require "(.*?)"/)?.[1];

if (!bootloaderPath) {
return ports;
}
const watchdogPath = path.resolve(bootloaderPath, "../watchdog.js");

const info = getInspectorOptions();

for (const name of workerNames) {
// This is copied from https://github.com/microsoft/vscode-js-debug/blob/0b5e0dade997b3c702a98e1f58989afcb30612d6/src/targets/node/bootloader.ts#L284
// It spawns a detached "watchdog" process for each corresponding (user) Worker in workerd which will maintain the VSCode debug connection
const p = spawn(process.execPath, [watchdogPath], {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Add a comment on what the code is doing here?

env: {
NODE_INSPECTOR_INFO: JSON.stringify({
ipcAddress: info.inspectorIpc || "",
pid: String(this.#process.pid),
scriptName: name,
inspectorURL: `ws://127.0.0.1:${ports?.get(kInspectorSocket)}/core:user:${name}`,
waitForDebugger: true,
ownId: randomBytes(12).toString("hex"),
openerId: info.openerId,
}),
NODE_SKIP_PLATFORM_CHECK: process.env.NODE_SKIP_PLATFORM_CHECK,
ELECTRON_RUN_AS_NODE: "1",
},
stdio: "ignore",
detached: true,
});
p.unref();
}
}
return ports;
}

dispose(): Awaitable<void> {
Expand Down
2 changes: 1 addition & 1 deletion packages/vite-plugin-cloudflare/e2e/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ export function seed(fixture: string, pm: "pnpm" | "yarn" | "npm") {
maxRetries: 10,
});
}
});
}, 40_000);

return projectPath;
}
Expand Down
14 changes: 14 additions & 0 deletions packages/vite-plugin-cloudflare/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -740,6 +740,13 @@ export function cloudflare(pluginConfig: PluginConfig = {}): vite.Plugin[] {
enforce: "pre",
configureServer(viteDevServer) {
assertIsNotPreview(resolvedPluginConfig);
// If we're in a JavaScript Debug terminal, Miniflare will send the inspector ports directly to VSCode for registration
// As such, we don't need our inspector proxy and in fact including it causes issue with multiple clients connected to the
// inspector endpoint.
const inVscodeJsDebugTerminal = !!process.env.VSCODE_INSPECTOR_OPTIONS;
if (inVscodeJsDebugTerminal) {
return;
}

if (
resolvedPluginConfig.type === "workers" &&
Expand Down Expand Up @@ -772,6 +779,13 @@ export function cloudflare(pluginConfig: PluginConfig = {}): vite.Plugin[] {
},
async configurePreviewServer(vitePreviewServer) {
assertIsPreview(resolvedPluginConfig);
// If we're in a JavaScript Debug terminal, Miniflare will send the inspector ports directly to VSCode for registration
// As such, we don't need our inspector proxy and in fact including it causes issue with multiple clients connected to the
// inspector endpoint.
const inVscodeJsDebugTerminal = !!process.env.VSCODE_INSPECTOR_OPTIONS;
if (inVscodeJsDebugTerminal) {
return;
}

if (
resolvedPluginConfig.workers.length >= 1 &&
Expand Down
1 change: 1 addition & 0 deletions packages/wrangler/e2e/startWorker.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,7 @@ describe.each(OPTIONS)("DevEnv (remote: $remote)", ({ remote }) => {
});

const inspectorUrl = await worker.inspectorUrl;
assert(inspectorUrl);

assert(inspectorUrl, "missing inspectorUrl");
let ws = new WebSocket(inspectorUrl.href, {
Expand Down
Loading
Loading