Skip to content

Commit ff26dc2

Browse files
dario-piotrowiczpetebacondarwin
andauthoredMar 18, 2025··
feat: add new unsafeInspectorProxy option to miniflare (#8357)
--------- Co-authored-by: Pete Bacon Darwin <pete@bacondarwin.com>
1 parent 03435cc commit ff26dc2

File tree

11 files changed

+1071
-6
lines changed

11 files changed

+1071
-6
lines changed
 

‎.changeset/little-spiders-sell.md

+49
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
---
2+
"miniflare": patch
3+
---
4+
5+
feat: add new `unsafeInspectorProxy` option to miniflare
6+
7+
Add a new `unsafeInspectorProxy` option to the miniflare worker options, if
8+
at least one worker has the option set then miniflare will establish a proxy
9+
between itself and workerd for the v8 inspector APIs which exposes only the
10+
requested workers to inspector clients. The inspector proxy communicates through
11+
miniflare's `inspectorPort` and exposes each requested worker via a path comprised
12+
of the worker's name
13+
14+
example:
15+
16+
```js
17+
import { Miniflare } from "miniflare";
18+
19+
const mf = new Miniflare({
20+
// the inspector proxy will be accessible through port 9229
21+
inspectorPort: 9229,
22+
workers: [
23+
{
24+
name: "worker-a",
25+
scriptPath: "./worker-a.js",
26+
// enable the inspector proxy for worker-a
27+
unsafeInspectorProxy: true,
28+
},
29+
{
30+
name: "worker-b",
31+
scriptPath: "./worker-b.js",
32+
// worker-b is not going to be proxied
33+
},
34+
{
35+
name: "worker-c",
36+
scriptPath: "./worker-c.js",
37+
// enable the inspector proxy for worker-c
38+
unsafeInspectorProxy: true,
39+
},
40+
],
41+
});
42+
```
43+
44+
In the above example an inspector proxy gets set up which exposes `worker-a` and `worker-b`,
45+
inspector clients can discover such workers via `http://localhost:9229` and communicate with
46+
them respectively via `ws://localhost:9229/worker-a` and `ws://localhost:9229/worker-b`
47+
48+
Note: this API is experimental, thus it's not being added to the public documentation and
49+
it's prefixed by `unsafe`

‎packages/miniflare/package.json

+1
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,7 @@
8383
"eslint-plugin-es": "^4.1.0",
8484
"eslint-plugin-prettier": "^5.0.1",
8585
"expect-type": "^0.15.0",
86+
"get-port": "^7.1.0",
8687
"heap-js": "^2.5.0",
8788
"http-cache-semantics": "^4.1.0",
8889
"kleur": "^4.1.5",

‎packages/miniflare/src/index.ts

+70-5
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@ import {
7878
reviveError,
7979
ServiceDesignatorSchema,
8080
} from "./plugins/core";
81+
import { InspectorProxyController } from "./plugins/core/inspector-proxy";
8182
import {
8283
Config,
8384
Extension,
@@ -744,12 +745,32 @@ export class Miniflare {
744745
readonly #webSocketServer: WebSocketServer;
745746
readonly #webSocketExtraHeaders: WeakMap<http.IncomingMessage, Headers>;
746747

748+
#maybeInspectorProxyController?: InspectorProxyController;
749+
#previousRuntimeInspectorPort?: number;
750+
747751
constructor(opts: MiniflareOptions) {
748752
// Split and validate options
749753
const [sharedOpts, workerOpts] = validateOptions(opts);
750754
this.#sharedOpts = sharedOpts;
751755
this.#workerOpts = workerOpts;
752756

757+
const workerNamesToProxy = new Set(
758+
this.#workerOpts
759+
.filter(({ core: { unsafeInspectorProxy } }) => !!unsafeInspectorProxy)
760+
.map((w) => w.core.name ?? "")
761+
);
762+
763+
const enableInspectorProxy = workerNamesToProxy.size > 0;
764+
765+
if (enableInspectorProxy) {
766+
if (this.#sharedOpts.core.inspectorPort === undefined) {
767+
throw new MiniflareCoreError(
768+
"ERR_MISSING_INSPECTOR_PROXY_PORT",
769+
"inspector proxy requested but without an inspectorPort specified"
770+
);
771+
}
772+
}
773+
753774
// Add to registry after initial options validation, before any servers/
754775
// child processes are started
755776
if (maybeInstanceRegistry !== undefined) {
@@ -760,6 +781,21 @@ export class Miniflare {
760781

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

784+
if (enableInspectorProxy) {
785+
if (this.#sharedOpts.core.inspectorPort === undefined) {
786+
throw new MiniflareCoreError(
787+
"ERR_MISSING_INSPECTOR_PROXY_PORT",
788+
"inspector proxy requested but without an inspectorPort specified"
789+
);
790+
}
791+
792+
this.#maybeInspectorProxyController = new InspectorProxyController(
793+
this.#sharedOpts.core.inspectorPort,
794+
this.#log,
795+
workerNamesToProxy
796+
);
797+
}
798+
763799
this.#liveReloadServer = new WebSocketServer({ noServer: true });
764800
this.#webSocketServer = new WebSocketServer({
765801
noServer: true,
@@ -1406,14 +1442,21 @@ export class Miniflare {
14061442
configuredHost,
14071443
this.#sharedOpts.core.port
14081444
);
1409-
let inspectorAddress: string | undefined;
1445+
let runtimeInspectorAddress: string | undefined;
14101446
if (this.#sharedOpts.core.inspectorPort !== undefined) {
1411-
inspectorAddress = this.#getSocketAddress(
1447+
let runtimeInspectorPort = this.#sharedOpts.core.inspectorPort;
1448+
if (this.#maybeInspectorProxyController !== undefined) {
1449+
// if we have an inspector proxy let's use a
1450+
// random port for the actual runtime inspector
1451+
runtimeInspectorPort = 0;
1452+
}
1453+
runtimeInspectorAddress = this.#getSocketAddress(
14121454
kInspectorSocket,
1413-
this.#previousSharedOpts?.core.inspectorPort,
1455+
this.#previousRuntimeInspectorPort,
14141456
"localhost",
1415-
this.#sharedOpts.core.inspectorPort
1457+
runtimeInspectorPort
14161458
);
1459+
this.#previousRuntimeInspectorPort = runtimeInspectorPort;
14171460
}
14181461
const loopbackAddress = `${
14191462
maybeGetLocallyAccessibleHost(configuredHost) ??
@@ -1424,7 +1467,7 @@ export class Miniflare {
14241467
entryAddress,
14251468
loopbackAddress,
14261469
requiredSockets,
1427-
inspectorAddress,
1470+
inspectorAddress: runtimeInspectorAddress,
14281471
verbose: this.#sharedOpts.core.verbose,
14291472
handleRuntimeStdio: this.#sharedOpts.core.handleRuntimeStdio,
14301473
};
@@ -1445,6 +1488,19 @@ export class Miniflare {
14451488
// all of `requiredSockets` as keys.
14461489
this.#socketPorts = maybeSocketPorts;
14471490

1491+
if (this.#maybeInspectorProxyController !== undefined) {
1492+
// Try to get inspector port for the workers
1493+
const maybePort = this.#socketPorts.get(kInspectorSocket);
1494+
if (maybePort === undefined) {
1495+
throw new MiniflareCoreError(
1496+
"ERR_RUNTIME_FAILURE",
1497+
"Unable to access the runtime inspector socket."
1498+
);
1499+
} else {
1500+
this.#maybeInspectorProxyController.updateConnection(maybePort);
1501+
}
1502+
}
1503+
14481504
const entrySocket = config.sockets?.[0];
14491505
const secure = entrySocket !== undefined && "https" in entrySocket;
14501506
const previousEntryURL = this.#runtimeEntryURL;
@@ -1527,6 +1583,8 @@ export class Miniflare {
15271583
// `dispose()`d synchronously, immediately after constructing a `Miniflare`
15281584
// instance. In this case, return a discard URL which we'll ignore.
15291585
if (disposing) return new URL("http://[100::]/");
1586+
// if there is an inspector proxy let's wait for it to be ready
1587+
await this.#maybeInspectorProxyController?.ready;
15301588
// Make sure `dispose()` wasn't called in the time we've been waiting
15311589
this.#checkDisposed();
15321590
// `#runtimeEntryURL` is assigned in `#assembleAndUpdateConfig()`, which is
@@ -1551,6 +1609,10 @@ export class Miniflare {
15511609
this.#checkDisposed();
15521610
await this.ready;
15531611

1612+
if (this.#maybeInspectorProxyController !== undefined) {
1613+
return this.#maybeInspectorProxyController.getInspectorURL();
1614+
}
1615+
15541616
// `#socketPorts` is assigned in `#assembleAndUpdateConfig()`, which is
15551617
// called by `#init()`, and `ready` doesn't resolve until `#init()` returns
15561618
assert(this.#socketPorts !== undefined);
@@ -1900,6 +1962,9 @@ export class Miniflare {
19001962
// `rm -rf ${#tmpPath}`, this won't throw if `#tmpPath` doesn't exist
19011963
await fs.promises.rm(this.#tmpPath, { force: true, recursive: true });
19021964

1965+
// Close the inspector proxy server if there is one
1966+
await this.#maybeInspectorProxyController?.dispose();
1967+
19031968
// Remove from instance registry as last step in `finally`, to make sure
19041969
// all dispose steps complete
19051970
maybeInstanceRegistry?.delete(this);

‎packages/miniflare/src/plugins/core/index.ts

+3
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,8 @@ const CoreOptionsSchemaInput = z.intersection(
126126
compatibilityDate: z.string().optional(),
127127
compatibilityFlags: z.string().array().optional(),
128128

129+
unsafeInspectorProxy: z.boolean().optional(),
130+
129131
routes: z.string().array().optional(),
130132

131133
bindings: z.record(JsonSchema).optional(),
@@ -191,6 +193,7 @@ export const CoreSharedOptionsSchema = z.object({
191193
httpsCertPath: z.string().optional(),
192194

193195
inspectorPort: z.number().optional(),
196+
194197
verbose: z.boolean().optional(),
195198

196199
log: z.instanceof(Log).optional(),
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import type Protocol from "devtools-protocol/types/protocol-mapping";
2+
3+
type _Params<ParamsArray extends [unknown?]> = ParamsArray extends [infer P]
4+
? P
5+
: undefined;
6+
7+
type _EventMethods = keyof Protocol.Events;
8+
export type DevToolsEvent<Method extends _EventMethods> = Method extends unknown
9+
? {
10+
method: Method;
11+
params: _Params<Protocol.Events[Method]>;
12+
}
13+
: never;
14+
15+
export type DevToolsEvents = DevToolsEvent<_EventMethods>;
16+
17+
type _CommandMethods = keyof Protocol.Commands;
18+
export type DevToolsCommandRequest<Method extends _CommandMethods> =
19+
Method extends unknown
20+
? _Params<Protocol.Commands[Method]["paramsType"]> extends undefined
21+
? {
22+
id: number;
23+
method: Method;
24+
}
25+
: {
26+
id: number;
27+
method: Method;
28+
params: _Params<Protocol.Commands[Method]["paramsType"]>;
29+
}
30+
: never;
31+
32+
export type DevToolsCommandRequests = DevToolsCommandRequest<_CommandMethods>;
33+
34+
export type DevToolsCommandResponse<Method extends _CommandMethods> =
35+
Method extends unknown
36+
? {
37+
id: number;
38+
result: Protocol.Commands[Method]["returnType"];
39+
}
40+
: never;
41+
export type DevToolsCommandResponses = DevToolsCommandResponse<_CommandMethods>;
42+
43+
export function isDevToolsEvent<
44+
Method extends DevToolsEvent<_EventMethods>["method"],
45+
>(event: unknown, name: Method): event is DevToolsEvent<Method> {
46+
return (
47+
typeof event === "object" &&
48+
event !== null &&
49+
"method" in event &&
50+
event.method === name
51+
);
52+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { InspectorProxyController } from "./inspector-proxy-controller";
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,274 @@
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+
];
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
import assert from "node:assert";
2+
import WebSocket from "ws";
3+
import { isDevToolsEvent } from "./devtools";
4+
import type {
5+
DevToolsCommandRequests,
6+
DevToolsEvent,
7+
DevToolsEvents,
8+
} from "./devtools";
9+
10+
/**
11+
* An `InspectorProxy` connects to a single runtime (/workerd) inspector server and proxies websocket communication
12+
* between the inspector server and potential inspector clients (/devtools).
13+
*
14+
* Each `InspectorProxy` has one and only one worker inspector server associated to it.
15+
*/
16+
export class InspectorProxy {
17+
#workerName: string;
18+
#runtimeWs: WebSocket;
19+
20+
#devtoolsWs?: WebSocket;
21+
#devtoolsHaveFileSystemAccess = false;
22+
23+
constructor(workerName: string, runtimeWs: WebSocket) {
24+
this.#workerName = workerName;
25+
this.#runtimeWs = runtimeWs;
26+
this.#runtimeWs.once("open", () => this.#handleRuntimeWebSocketOpen());
27+
}
28+
29+
get workerName() {
30+
return this.#workerName;
31+
}
32+
33+
get path() {
34+
return `/${this.#workerName}`;
35+
}
36+
37+
onDevtoolsConnected(
38+
devtoolsWs: WebSocket,
39+
devtoolsHaveFileSystemAccess: boolean
40+
) {
41+
if (this.#devtoolsWs) {
42+
/** We only want to have one active Devtools instance at a time. */
43+
// TODO(consider): prioritise new websocket over previous
44+
devtoolsWs.close(
45+
1013,
46+
"Too many clients; only one can be connected at a time"
47+
);
48+
return;
49+
}
50+
this.#devtoolsWs = devtoolsWs;
51+
this.#devtoolsHaveFileSystemAccess = devtoolsHaveFileSystemAccess;
52+
53+
assert(this.#devtoolsWs?.readyState === WebSocket.OPEN);
54+
55+
this.#devtoolsWs.on("error", console.error);
56+
57+
this.#devtoolsWs.once("close", () => {
58+
if (this.#runtimeWs?.OPEN) {
59+
// Since Miniflare proxies the inspector, reloading Chrome DevTools won't trigger debugger initialisation events (because it's connecting to an extant session).
60+
// This sends a `Debugger.disable` message to the remote when a new WebSocket connection is initialised,
61+
// with the assumption that the new connection will shortly send a `Debugger.enable` event and trigger re-initialisation.
62+
// The key initialisation messages that are needed are the `Debugger.scriptParsed` events.
63+
this.#sendMessageToRuntime({
64+
method: "Debugger.disable",
65+
id: this.#nextCounter(),
66+
});
67+
}
68+
this.#devtoolsWs = undefined;
69+
});
70+
71+
this.#devtoolsWs.on("message", (data) => {
72+
const message = JSON.parse(data.toString());
73+
assert(this.#runtimeWs?.OPEN);
74+
this.#sendMessageToRuntime(message);
75+
});
76+
}
77+
78+
#runtimeMessageCounter = 1e8;
79+
#nextCounter() {
80+
return ++this.#runtimeMessageCounter;
81+
}
82+
83+
#runtimeKeepAliveInterval: NodeJS.Timeout | undefined;
84+
85+
#handleRuntimeWebSocketOpen() {
86+
assert(this.#runtimeWs?.OPEN);
87+
88+
this.#runtimeWs.on("message", (data) => {
89+
const message = JSON.parse(data.toString());
90+
91+
if (!this.#devtoolsWs) {
92+
// there is no devtools connection established
93+
return;
94+
}
95+
96+
if (isDevToolsEvent(message, "Debugger.scriptParsed")) {
97+
return this.#handleRuntimeScriptParsed(message);
98+
}
99+
100+
return this.#sendMessageToDevtools(message);
101+
});
102+
103+
clearInterval(this.#runtimeKeepAliveInterval);
104+
this.#runtimeKeepAliveInterval = setInterval(() => {
105+
if (this.#runtimeWs?.OPEN) {
106+
this.#sendMessageToRuntime({
107+
method: "Runtime.getIsolateId",
108+
id: this.#nextCounter(),
109+
});
110+
}
111+
}, 10_000);
112+
}
113+
114+
#handleRuntimeScriptParsed(message: DevToolsEvent<"Debugger.scriptParsed">) {
115+
// If the devtools does not have filesystem access,
116+
// rewrite the sourceMapURL to use a special scheme.
117+
// This special scheme is used to indicate whether
118+
// to intercept each loadNetworkResource message.
119+
120+
if (
121+
!this.#devtoolsHaveFileSystemAccess &&
122+
message.params.sourceMapURL !== undefined &&
123+
// Don't try to find a sourcemap for e.g. node-internal: scripts
124+
message.params.url.startsWith("file:")
125+
) {
126+
const url = new URL(message.params.sourceMapURL, message.params.url);
127+
// Check for file: in case message.params.sourceMapURL has a different
128+
// protocol (e.g. data). In that case we should ignore this file
129+
if (url.protocol === "file:") {
130+
message.params.sourceMapURL = url.href.replace(
131+
"file:",
132+
"wrangler-file:"
133+
);
134+
}
135+
}
136+
137+
return this.#sendMessageToDevtools(message);
138+
}
139+
140+
#sendMessageToDevtools(message: DevToolsEvents) {
141+
assert(this.#devtoolsWs);
142+
143+
if (!this.#devtoolsWs.OPEN) {
144+
// the devtools web socket is established but not yet connected
145+
this.#devtoolsWs.once("open", () =>
146+
this.#devtoolsWs?.send(JSON.stringify(message))
147+
);
148+
return;
149+
}
150+
151+
this.#devtoolsWs.send(JSON.stringify(message));
152+
}
153+
154+
#sendMessageToRuntime(message: DevToolsCommandRequests) {
155+
assert(this.#runtimeWs?.OPEN);
156+
157+
this.#runtimeWs.send(JSON.stringify(message));
158+
}
159+
160+
async dispose(): Promise<void> {
161+
clearInterval(this.#runtimeKeepAliveInterval);
162+
163+
this.#devtoolsWs?.close();
164+
}
165+
}

‎packages/miniflare/src/shared/error.ts

+2-1
Original file line numberDiff line numberDiff line change
@@ -33,5 +33,6 @@ export type MiniflareCoreErrorCode =
3333
| "ERR_DIFFERENT_PREVENT_EVICTION" // Multiple Durable Object bindings declared for same class with different unsafe prevent eviction values
3434
| "ERR_MULTIPLE_OUTBOUNDS" // Both `outboundService` and `fetchMock` specified
3535
| "ERR_INVALID_WRAPPED" // Worker not allowed to be used as wrapped binding
36-
| "ERR_CYCLIC"; // Generate cyclic workerd config
36+
| "ERR_CYCLIC" // Generate cyclic workerd config
37+
| "ERR_MISSING_INSPECTOR_PROXY_PORT"; // An inspector proxy has been requested but no inspector port to use has been specified
3738
export class MiniflareCoreError extends MiniflareError<MiniflareCoreErrorCode> {}

‎packages/miniflare/test/plugins/core/inspector-proxy/index.spec.ts

+445
Large diffs are not rendered by default.

‎pnpm-lock.yaml

+9
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)
Please sign in to comment.