diff --git a/.changeset/perfect-plants-compete.md b/.changeset/perfect-plants-compete.md new file mode 100644 index 000000000000..1207542a8fd4 --- /dev/null +++ b/.changeset/perfect-plants-compete.md @@ -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. diff --git a/packages/miniflare/src/index.ts b/packages/miniflare/src/index.ts index 294504892185..14d2aa21b9f4 100644 --- a/packages/miniflare/src/index.ts +++ b/packages/miniflare/src/index.ts @@ -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", @@ -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) { diff --git a/packages/miniflare/src/runtime/index.ts b/packages/miniflare/src/runtime/index.ts index cd5070e5ab09..97e02ed4d78a 100644 --- a/packages/miniflare/src/runtime/index.ts +++ b/packages/miniflare/src/runtime/index.ts @@ -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"; @@ -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() { + 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; async updateConfig( configBuffer: Buffer, - options: Abortable & RuntimeOptions + options: Abortable & RuntimeOptions, + workerNames: string[] ): Promise { // 1. Stop existing process (if any) and wait for exit await this.dispose(); @@ -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], { + 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 { diff --git a/packages/vite-plugin-cloudflare/e2e/helpers.ts b/packages/vite-plugin-cloudflare/e2e/helpers.ts index 056c36d55649..542f644e9a17 100644 --- a/packages/vite-plugin-cloudflare/e2e/helpers.ts +++ b/packages/vite-plugin-cloudflare/e2e/helpers.ts @@ -64,7 +64,7 @@ export function seed(fixture: string, pm: "pnpm" | "yarn" | "npm") { maxRetries: 10, }); } - }); + }, 40_000); return projectPath; } diff --git a/packages/vite-plugin-cloudflare/src/index.ts b/packages/vite-plugin-cloudflare/src/index.ts index 36ba3cfb0f05..68a546fce8af 100644 --- a/packages/vite-plugin-cloudflare/src/index.ts +++ b/packages/vite-plugin-cloudflare/src/index.ts @@ -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" && @@ -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 && diff --git a/packages/wrangler/e2e/startWorker.test.ts b/packages/wrangler/e2e/startWorker.test.ts index 2037f339190d..ef68852f4103 100644 --- a/packages/wrangler/e2e/startWorker.test.ts +++ b/packages/wrangler/e2e/startWorker.test.ts @@ -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, { diff --git a/packages/wrangler/src/__tests__/sentry.test.ts b/packages/wrangler/src/__tests__/sentry.test.ts index 9a92a130f401..9d38af82d14a 100644 --- a/packages/wrangler/src/__tests__/sentry.test.ts +++ b/packages/wrangler/src/__tests__/sentry.test.ts @@ -227,206 +227,206 @@ describe("sentry", () => { // If more data is included in the Sentry request, we'll need to verify it // couldn't contain PII and update this snapshot - expect(event).toMatchInlineSnapshot(` - Object { - "data": Object { - "breadcrumbs": Array [ - Object { - "level": "log", - "message": "wrangler whoami", - "timestamp": 0, - }, - ], - "contexts": Object { - "app": Object { - "app_memory": 0, - "app_start_time": "", - }, - "cloud_resource": Object {}, - "device": Object {}, - "os": Object {}, - "runtime": Object { - "name": "node", - "version": "", - }, - "trace": Object { - "span_id": "", - "trace_id": "", - }, - }, - "environment": "production", - "event_id": "", - "exception": Object { - "values": Array [ - Object { - "mechanism": Object { - "handled": true, - "type": "generic", - }, - "stacktrace": Object { - "frames": Array [ - Object { - "colno": 0, - "context_line": "", - "filename": "/wrangler/packages/wrangler/src/core/register-yargs-command.ts", - "function": "", - "in_app": false, - "lineno": 0, - "module": "register-yargs-command.ts", - "post_context": Array [], - "pre_context": Array [], - }, - Object { - "colno": 0, - "context_line": "", - "filename": "/wrangler/packages/wrangler/src/user/commands.ts", - "function": "", - "in_app": false, - "lineno": 0, - "module": "commands.ts", - "post_context": Array [], - "pre_context": Array [], - }, - Object { - "colno": 0, - "context_line": "", - "filename": "/wrangler/packages/wrangler/src/user/whoami.ts", - "function": "", - "in_app": false, - "lineno": 0, - "module": "whoami.ts", - "post_context": Array [], - "pre_context": Array [], - }, - Object { - "colno": 0, - "context_line": "", - "filename": "/wrangler/packages/wrangler/src/user/whoami.ts", - "function": "", - "in_app": false, - "lineno": 0, - "module": "whoami.ts", - "post_context": Array [], - "pre_context": Array [], - }, - Object { - "colno": 0, - "context_line": "", - "filename": "/wrangler/packages/wrangler/src/user/whoami.ts", - "function": "", - "in_app": false, - "lineno": 0, - "module": "whoami.ts", - "post_context": Array [], - "pre_context": Array [], - }, - Object { - "colno": 0, - "context_line": "", - "filename": "/wrangler/packages/wrangler/src/cfetch/index.ts", - "function": "", - "in_app": false, - "lineno": 0, - "module": "index.ts", - "post_context": Array [], - "pre_context": Array [], - }, - Object { - "colno": 0, - "context_line": "", - "filename": "/wrangler/packages/wrangler/src/cfetch/internal.ts", - "function": "", - "in_app": false, - "lineno": 0, - "module": "internal.ts", - "post_context": Array [], - "pre_context": Array [], - }, - Object { - "colno": 0, - "context_line": "", - "filename": "/wrangler/packages/wrangler/src/cfetch/internal.ts", - "function": "", - "in_app": false, - "lineno": 0, - "module": "internal.ts", - "post_context": Array [], - "pre_context": Array [], - }, - Object { - "colno": 0, - "context_line": "", - "filename": "/project/...", - "function": "", - "in_app": false, - "lineno": 0, - "module": "@mswjs.interceptors.src.interceptors.fetch:index.ts", - "post_context": Array [], - "pre_context": Array [], - }, - Object { - "colno": 0, - "context_line": "", - "filename": "/project/...", - "function": "", - "in_app": false, - "lineno": 0, - "module": "@mswjs.interceptors.src.interceptors.fetch:index.ts", - "post_context": Array [], - "pre_context": Array [], - }, - ], - }, - "type": "TypeError", - "value": "Failed to fetch", - }, - ], - }, - "modules": Object {}, - "platform": "node", - "release": "", - "sdk": Object { - "integrations": Array [ - "InboundFilters", - "FunctionToString", - "LinkedErrors", - "Console", - "OnUncaughtException", - "OnUnhandledRejection", - "ContextLines", - "Context", - "Modules", - ], - "name": "sentry.javascript.node", - "packages": Array [ - Object { - "name": "npm:@sentry/node", - "version": "7.87.0", - }, - ], - "version": "7.87.0", - }, - "timestamp": 0, - }, - "header": Object { - "event_id": "", - "sdk": Object { - "name": "sentry.javascript.node", - "version": "7.87.0", - }, - "sent_at": "", - "trace": Object { - "environment": "production", - "public_key": "9edbb8417b284aa2bbead9b4c318918b", - "release": "", - "trace_id": "", - }, - }, - "type": Object { - "type": "event", - }, - } - `); + expect(event).toStrictEqual({ + data: { + breadcrumbs: [ + { + level: "log", + message: "wrangler whoami", + timestamp: 0, + }, + ], + contexts: { + app: { + app_memory: 0, + app_start_time: "", + }, + cloud_resource: {}, + device: {}, + os: {}, + runtime: { + name: "node", + version: "", + }, + trace: { + span_id: "", + trace_id: "", + }, + }, + environment: "production", + event_id: "", + exception: { + values: [ + { + mechanism: { + handled: true, + type: "generic", + }, + stacktrace: { + frames: [ + { + colno: 0, + context_line: "", + filename: expect.any(String), + function: "", + in_app: false, + lineno: 0, + module: expect.any(String), + post_context: [], + pre_context: [], + }, + { + colno: 0, + context_line: "", + filename: expect.any(String), + function: "", + in_app: false, + lineno: 0, + module: expect.any(String), + post_context: [], + pre_context: [], + }, + { + colno: 0, + context_line: "", + filename: expect.any(String), + function: "", + in_app: false, + lineno: 0, + module: expect.any(String), + post_context: [], + pre_context: [], + }, + { + colno: 0, + context_line: "", + filename: expect.any(String), + function: "", + in_app: false, + lineno: 0, + module: expect.any(String), + post_context: [], + pre_context: [], + }, + { + colno: 0, + context_line: "", + filename: expect.any(String), + function: "", + in_app: false, + lineno: 0, + module: expect.any(String), + post_context: [], + pre_context: [], + }, + { + colno: 0, + context_line: "", + filename: expect.any(String), + function: "", + in_app: false, + lineno: 0, + module: expect.any(String), + post_context: [], + pre_context: [], + }, + { + colno: 0, + context_line: "", + filename: expect.any(String), + function: "", + in_app: false, + lineno: 0, + module: expect.any(String), + post_context: [], + pre_context: [], + }, + { + colno: 0, + context_line: "", + filename: expect.any(String), + function: "", + in_app: false, + lineno: 0, + module: expect.any(String), + post_context: [], + pre_context: [], + }, + { + colno: 0, + context_line: "", + filename: "/project/...", + function: "", + in_app: false, + lineno: 0, + module: + "@mswjs.interceptors.src.interceptors.fetch:index.ts", + post_context: [], + pre_context: [], + }, + { + colno: 0, + context_line: "", + filename: "/project/...", + function: "", + in_app: false, + lineno: 0, + module: + "@mswjs.interceptors.src.interceptors.fetch:index.ts", + post_context: [], + pre_context: [], + }, + ], + }, + type: "TypeError", + value: "Failed to fetch", + }, + ], + }, + modules: {}, + platform: "node", + release: "", + sdk: { + integrations: [ + "InboundFilters", + "FunctionToString", + "LinkedErrors", + "Console", + "OnUncaughtException", + "OnUnhandledRejection", + "ContextLines", + "Context", + "Modules", + ], + name: "sentry.javascript.node", + packages: [ + { + name: "npm:@sentry/node", + version: "7.87.0", + }, + ], + version: "7.87.0", + }, + timestamp: 0, + }, + header: { + event_id: "", + sdk: { + name: "sentry.javascript.node", + version: "7.87.0", + }, + sent_at: "", + trace: { + environment: "production", + public_key: "9edbb8417b284aa2bbead9b4c318918b", + release: "", + trace_id: "", + }, + }, + type: { + type: "event", + }, + }); }); }); }); diff --git a/packages/wrangler/src/api/startDevWorker/ProxyController.ts b/packages/wrangler/src/api/startDevWorker/ProxyController.ts index 004f5c3776dc..5184e01e74f7 100644 --- a/packages/wrangler/src/api/startDevWorker/ProxyController.ts +++ b/packages/wrangler/src/api/startDevWorker/ProxyController.ts @@ -65,6 +65,11 @@ export class ProxyController extends Controller { } assert(this.latestConfig !== undefined); + // 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; + const cert = this.latestConfig.dev?.server?.secure || (this.latestConfig.dev.inspector !== false && @@ -130,7 +135,7 @@ export class ProxyController extends Controller { liveReload: false, }; - if (this.latestConfig.dev.inspector !== false) { + if (this.latestConfig.dev.inspector !== false && !inVscodeJsDebugTerminal) { proxyWorkerOptions.workers.push({ name: "InspectorProxyWorker", compatibilityDate: "2023-12-18", @@ -198,12 +203,12 @@ export class ProxyController extends Controller { if (willInstantiateMiniflareInstance) { void Promise.all([ proxyWorker.ready, - this.latestConfig.dev.inspector === false + this.latestConfig.dev.inspector === false || inVscodeJsDebugTerminal ? Promise.resolve(undefined) : proxyWorker.unsafeGetDirectURL("InspectorProxyWorker"), ]) .then(([url, inspectorUrl]) => { - if (!inspectorUrl) { + if (!inspectorUrl || inVscodeJsDebugTerminal) { return [url, undefined]; } // Don't connect the inspector proxy worker until we have a valid ready Miniflare instance. diff --git a/packages/wrangler/src/dev/hotkeys.ts b/packages/wrangler/src/dev/hotkeys.ts index adf3da957602..0be5d709ceb4 100644 --- a/packages/wrangler/src/dev/hotkeys.ts +++ b/packages/wrangler/src/dev/hotkeys.ts @@ -1,4 +1,3 @@ -import assert from "assert"; import { randomUUID } from "crypto"; import { LocalRuntimeController } from "../api/startDevWorker/LocalRuntimeController"; import registerHotKeys from "../cli-hotkeys"; @@ -23,16 +22,20 @@ export default function registerDevHotKeys( { keys: ["d"], label: "open devtools", + // Don't display this hotkey if we're in a VSCode debug session + disabled: !!process.env.VSCODE_INSPECTOR_OPTIONS, handler: async () => { const { inspectorUrl } = await devEnv.proxy.ready.promise; - assert(inspectorUrl, "Error: no inspectorUrl available"); - - // TODO: refactor this function to accept a whole URL (not just .port and assuming .hostname) - await openInspector( - parseInt(inspectorUrl.port), - devEnv.config.latestConfig?.name - ); + if (!inspectorUrl) { + logger.warn("DevTools is not available while in a debug terminal"); + } else { + // TODO: refactor this function to accept a whole URL (not just .port and assuming .hostname) + await openInspector( + parseInt(inspectorUrl.port), + devEnv.config.latestConfig?.name + ); + } }, }, { diff --git a/turbo.json b/turbo.json index d5ac0504699a..52e8eb4ef93c 100644 --- a/turbo.json +++ b/turbo.json @@ -4,7 +4,11 @@ "signature": true }, "globalEnv": ["CI_OS", "NODE_VERSION"], - "globalPassThroughEnv": ["NODE_EXTRA_CA_CERTS", "CI"], + "globalPassThroughEnv": [ + "NODE_EXTRA_CA_CERTS", + "CI", + "VSCODE_INSPECTOR_OPTIONS" + ], "tasks": { "dev": { "persistent": true,