wrangler 3.12.0 → 3.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wrangler",
3
- "version": "3.12.0",
3
+ "version": "3.13.0",
4
4
  "description": "Command-line interface for all things Cloudflare Workers",
5
5
  "keywords": [
6
6
  "wrangler",
@@ -200,7 +200,7 @@
200
200
  "dev": "pnpm run clean && concurrently -c black,blue --kill-others-on-fail false 'pnpm run bundle --watch' 'pnpm run check:type --watch --preserveWatchOutput'",
201
201
  "emit-types": "tsc -p tsconfig.emit.json && node -r esbuild-register scripts/emit-types.ts",
202
202
  "start": "pnpm run bundle && cross-env NODE_OPTIONS=--enable-source-maps ./bin/wrangler.js",
203
- "test": "pnpm run assert-git-version && jest",
203
+ "test": "pnpm run assert-git-version && jest --runInBand",
204
204
  "test:ci": "pnpm run test --coverage",
205
205
  "test:debug": "pnpm run test --silent=false --verbose=true",
206
206
  "test:e2e": "vitest --test-timeout 240000 --single-thread --dir ./e2e run",
@@ -0,0 +1,526 @@
1
+ import assert from "node:assert";
2
+ import {
3
+ DevToolsCommandRequest,
4
+ DevToolsCommandRequests,
5
+ DevToolsCommandResponses,
6
+ DevToolsEvent,
7
+ DevToolsEvents,
8
+ type InspectorProxyWorkerIncomingWebSocketMessage,
9
+ type InspectorProxyWorkerOutgoingRequestBody,
10
+ type InspectorProxyWorkerOutgoingWebsocketMessage,
11
+ type ProxyData,
12
+ serialiseError,
13
+ } from "../../src/api/startDevWorker/events";
14
+ import {
15
+ assertNever,
16
+ createDeferred,
17
+ DeferredPromise,
18
+ MaybePromise,
19
+ urlFromParts,
20
+ } from "../../src/api/startDevWorker/utils";
21
+
22
+ interface Env {
23
+ PROXY_CONTROLLER: Fetcher;
24
+ PROXY_CONTROLLER_AUTH_SECRET: string;
25
+ WRANGLER_VERSION: string;
26
+ DURABLE_OBJECT: DurableObjectNamespace;
27
+ }
28
+
29
+ export default {
30
+ fetch(req, env) {
31
+ const singleton = env.DURABLE_OBJECT.idFromName("");
32
+ const inspectorProxy = env.DURABLE_OBJECT.get(singleton);
33
+
34
+ return inspectorProxy.fetch(req);
35
+ },
36
+ } as ExportedHandler<Env>;
37
+
38
+ function isDevToolsEvent<Method extends DevToolsEvents["method"]>(
39
+ event: unknown,
40
+ name: Method
41
+ ): event is DevToolsEvent<Method> {
42
+ return (
43
+ typeof event === "object" &&
44
+ event !== null &&
45
+ "method" in event &&
46
+ event.method === name
47
+ );
48
+ }
49
+
50
+ export class InspectorProxyWorker implements DurableObject {
51
+ constructor(_state: DurableObjectState, readonly env: Env) {}
52
+
53
+ websockets: {
54
+ proxyController?: WebSocket;
55
+ runtime?: WebSocket;
56
+ devtools?: WebSocket;
57
+
58
+ // Browser DevTools cannot read the filesystem,
59
+ // instead they fetch via `Network.loadNetworkResource` messages.
60
+ // IDE DevTools can read the filesystem and expect absolute paths.
61
+ devtoolsHasFileSystemAccess?: boolean;
62
+
63
+ // We want to be able to delay devtools connection response
64
+ // until we've connected to the runtime inspector server
65
+ // so this deferred holds a promise to websockets.runtime
66
+ runtimeDeferred: DeferredPromise<WebSocket>;
67
+ } = {
68
+ runtimeDeferred: createDeferred<WebSocket>(),
69
+ };
70
+ proxyData?: ProxyData;
71
+ runtimeMessageBuffer: (DevToolsCommandResponses | DevToolsEvents)[] = [];
72
+
73
+ async fetch(req: Request) {
74
+ if (
75
+ req.headers.get("Authorization") === this.env.PROXY_CONTROLLER_AUTH_SECRET
76
+ ) {
77
+ return this.handleProxyControllerRequest(req);
78
+ }
79
+
80
+ if (req.headers.get("Upgrade") === "websocket") {
81
+ return this.handleDevToolsWebSocketUpgradeRequest(req);
82
+ }
83
+
84
+ return this.handleDevToolsJsonRequest(req);
85
+ }
86
+
87
+ // ************************
88
+ // ** PROXY CONTROLLER **
89
+ // ************************
90
+
91
+ handleProxyControllerRequest(req: Request) {
92
+ assert(
93
+ req.headers.get("Upgrade") === "websocket",
94
+ "Expected proxy controller data request to be WebSocket upgrade"
95
+ );
96
+
97
+ const { 0: response, 1: proxyController } = new WebSocketPair();
98
+ proxyController.accept();
99
+ proxyController.addEventListener("close", () => {
100
+ // don't reconnect the proxyController websocket
101
+ // ProxyController can detect this event and reconnect itself
102
+
103
+ if (this.websockets.proxyController === proxyController) {
104
+ this.websockets.proxyController = undefined;
105
+ }
106
+ });
107
+ proxyController.addEventListener("error", () => {
108
+ // don't reconnect the proxyController websocket
109
+ // ProxyController can detect this event and reconnect itself
110
+
111
+ if (this.websockets.proxyController === proxyController) {
112
+ this.websockets.proxyController = undefined;
113
+ }
114
+ });
115
+ proxyController.addEventListener(
116
+ "message",
117
+ this.handleProxyControllerIncomingMessage
118
+ );
119
+
120
+ this.websockets.proxyController = proxyController;
121
+
122
+ return new Response(null, {
123
+ status: 101,
124
+ webSocket: response,
125
+ });
126
+ }
127
+
128
+ sendProxyControllerMessage(
129
+ message: string | InspectorProxyWorkerOutgoingWebsocketMessage
130
+ ) {
131
+ message = typeof message === "string" ? message : JSON.stringify(message);
132
+
133
+ // if the proxyController websocket is disconnected, throw away the message
134
+ this.websockets.proxyController?.send(message);
135
+ }
136
+
137
+ async sendProxyControllerRequest(
138
+ message: InspectorProxyWorkerOutgoingRequestBody
139
+ ) {
140
+ try {
141
+ const res = await this.env.PROXY_CONTROLLER.fetch("http://dummy", {
142
+ method: "POST",
143
+ body: JSON.stringify(message),
144
+ });
145
+ return res.ok ? await res.text() : undefined;
146
+ } catch (e) {
147
+ this.sendDebugLog(
148
+ "FAILED TO SEND PROXY CONTROLLER REQUEST",
149
+ serialiseError(e)
150
+ );
151
+ return undefined;
152
+ }
153
+ }
154
+
155
+ sendDebugLog: typeof console.debug = (...args) => {
156
+ this.sendProxyControllerRequest({ type: "debug-log", args });
157
+ };
158
+
159
+ // ***************
160
+ // ** RUNTIME **
161
+ // ***************
162
+
163
+ handleRuntimeIncomingMessage = (event: MessageEvent) => {
164
+ assert(typeof event.data === "string");
165
+
166
+ const msg = JSON.parse(event.data) as
167
+ | DevToolsCommandResponses
168
+ | DevToolsEvents;
169
+ this.sendDebugLog("RUNTIME INCOMING MESSAGE", msg);
170
+
171
+ if (
172
+ isDevToolsEvent(msg, "Runtime.exceptionThrown") ||
173
+ isDevToolsEvent(msg, "Runtime.consoleAPICalled")
174
+ ) {
175
+ this.sendProxyControllerMessage(event.data);
176
+ }
177
+
178
+ this.runtimeMessageBuffer.push(msg);
179
+ this.tryDrainRuntimeMessageBuffer();
180
+ };
181
+
182
+ handleRuntimeScriptParsed(msg: DevToolsEvent<"Debugger.scriptParsed">) {
183
+ // If the devtools does not have filesystem access,
184
+ // rewrite the sourceMapURL to use a special scheme.
185
+ // This special scheme is used to indicate whether
186
+ // to intercept each loadNetworkResource message.
187
+
188
+ if (
189
+ !this.websockets.devtoolsHasFileSystemAccess &&
190
+ msg.params.sourceMapURL !== undefined
191
+ ) {
192
+ const url = new URL(msg.params.sourceMapURL, msg.params.url);
193
+ if (url.protocol === "file:") {
194
+ msg.params.sourceMapURL = url.href.replace("file:", "wrangler-file:");
195
+ }
196
+ }
197
+
198
+ void this.sendDevToolsMessage(msg);
199
+ }
200
+
201
+ tryDrainRuntimeMessageBuffer = () => {
202
+ // If we don't have a DevTools WebSocket, try again later
203
+ if (this.websockets.devtools === undefined) return;
204
+
205
+ // clear the buffer and replay each message to devtools
206
+ for (const msg of this.runtimeMessageBuffer.splice(0)) {
207
+ if (isDevToolsEvent(msg, "Debugger.scriptParsed")) {
208
+ this.handleRuntimeScriptParsed(msg);
209
+ } else {
210
+ void this.sendDevToolsMessage(msg);
211
+ }
212
+ }
213
+ };
214
+
215
+ handleProxyControllerIncomingMessage = (event: MessageEvent) => {
216
+ assert(
217
+ typeof event.data === "string",
218
+ "Expected event.data from proxy controller to be string"
219
+ );
220
+
221
+ const message: InspectorProxyWorkerIncomingWebSocketMessage = JSON.parse(
222
+ event.data
223
+ );
224
+
225
+ this.sendDebugLog("handleProxyControllerIncomingMessage", event.data);
226
+
227
+ switch (message.type) {
228
+ case "reloadComplete": {
229
+ this.proxyData = message.proxyData;
230
+
231
+ this.reconnectRuntimeWebSocket();
232
+
233
+ break;
234
+ }
235
+ default: {
236
+ assertNever(message.type);
237
+ }
238
+ }
239
+ };
240
+
241
+ runtimeKeepAliveInterval: number | null = null;
242
+ reconnectRuntimeWebSocket() {
243
+ assert(this.proxyData, "Expected this.proxyData to be defined");
244
+
245
+ this.sendDebugLog("reconnectRuntimeWebSocket");
246
+
247
+ this.websockets.runtimeDeferred = createDeferred<WebSocket>(
248
+ this.websockets.runtimeDeferred
249
+ );
250
+
251
+ const runtimeWebSocketUrl = urlFromParts(
252
+ this.proxyData.userWorkerInspectorUrl
253
+ ).href;
254
+ this.sendDebugLog("NEW RUNTIME WEBSOCKET", runtimeWebSocketUrl);
255
+ const runtime = new WebSocket(runtimeWebSocketUrl);
256
+
257
+ this.websockets.runtime?.close();
258
+ this.websockets.runtime = runtime;
259
+
260
+ runtime.addEventListener("message", this.handleRuntimeIncomingMessage);
261
+
262
+ runtime.addEventListener("close", (event) => {
263
+ this.sendDebugLog("RUNTIME WEBSOCKET CLOSED", event.code, event.reason);
264
+
265
+ clearInterval(this.runtimeKeepAliveInterval);
266
+
267
+ if (this.websockets.runtime === runtime) {
268
+ this.websockets.runtime = undefined;
269
+ }
270
+
271
+ // don't reconnect the runtime websocket
272
+ // if it closes unexpectedly (very rare or a case where reconnecting won't succeed anyway)
273
+ // wait for a new proxy-data message or manual restart
274
+ });
275
+
276
+ runtime.addEventListener("error", (event) => {
277
+ clearInterval(this.runtimeKeepAliveInterval);
278
+
279
+ if (this.websockets.runtime === runtime) {
280
+ this.websockets.runtime = undefined;
281
+ }
282
+
283
+ this.sendProxyControllerRequest({
284
+ type: "runtime-websocket-error",
285
+ error: {
286
+ message: event.message,
287
+ cause: event.error,
288
+ },
289
+ });
290
+
291
+ // don't reconnect the runtime websocket
292
+ // if it closes unexpectedly (very rare or a case where reconnecting won't succeed anyway)
293
+ // wait for a new proxy-data message or manual restart
294
+ });
295
+
296
+ runtime.addEventListener("open", () => {
297
+ this.handleRuntimeWebSocketOpen(runtime);
298
+ });
299
+ }
300
+
301
+ #runtimeMessageCounter = 1e8;
302
+ nextCounter() {
303
+ return ++this.#runtimeMessageCounter;
304
+ }
305
+ handleRuntimeWebSocketOpen(runtime: WebSocket) {
306
+ this.sendDebugLog("RUNTIME WEBSOCKET OPENED");
307
+
308
+ this.sendRuntimeMessage(
309
+ { method: "Runtime.enable", id: this.nextCounter() },
310
+ runtime
311
+ );
312
+ this.sendRuntimeMessage(
313
+ { method: "Debugger.enable", id: this.nextCounter() },
314
+ runtime
315
+ );
316
+ this.sendRuntimeMessage(
317
+ { method: "Network.enable", id: this.nextCounter() },
318
+ runtime
319
+ );
320
+
321
+ clearInterval(this.runtimeKeepAliveInterval);
322
+ this.runtimeKeepAliveInterval = setInterval(() => {
323
+ this.sendRuntimeMessage(
324
+ { method: "Runtime.getIsolateId", id: this.nextCounter() },
325
+ runtime
326
+ );
327
+ }, 10_000) as any;
328
+
329
+ this.websockets.runtimeDeferred.resolve(runtime);
330
+ }
331
+
332
+ async sendRuntimeMessage(
333
+ message: string | DevToolsCommandRequests,
334
+ runtime: MaybePromise<WebSocket> = this.websockets.runtimeDeferred.promise
335
+ ) {
336
+ runtime = await runtime;
337
+ message = typeof message === "string" ? message : JSON.stringify(message);
338
+
339
+ this.sendDebugLog("SEND TO RUNTIME", message);
340
+
341
+ runtime.send(message);
342
+ }
343
+
344
+ // ****************
345
+ // ** DEVTOOLS **
346
+ // ****************
347
+
348
+ #inspectorId = crypto.randomUUID();
349
+ async handleDevToolsJsonRequest(req: Request) {
350
+ const url = new URL(req.url);
351
+
352
+ if (url.pathname === "/json/version") {
353
+ return Response.json({
354
+ Browser: `wrangler/v${this.env.WRANGLER_VERSION}`,
355
+ // TODO: (someday): The DevTools protocol should match that of workerd.
356
+ // This could be exposed by the preview API.
357
+ "Protocol-Version": "1.3",
358
+ });
359
+ }
360
+
361
+ if (url.pathname === "/json" || url.pathname === "/json/list") {
362
+ // TODO: can we remove the `/ws` here if we only have a single worker?
363
+ const localHost = `${url.host}/ws`;
364
+ const devtoolsFrontendUrl = `https://devtools.devprod.cloudflare.dev/js_app?theme=systemPreferred&debugger=true&ws=${localHost}`;
365
+
366
+ return Response.json([
367
+ {
368
+ id: this.#inspectorId,
369
+ type: "node", // TODO: can we specify different type?
370
+ description: "workers",
371
+ webSocketDebuggerUrl: `ws://${localHost}`,
372
+ devtoolsFrontendUrl,
373
+ devtoolsFrontendUrlCompat: devtoolsFrontendUrl,
374
+ // Below are fields that are visible in the DevTools UI.
375
+ title: "Cloudflare Worker",
376
+ faviconUrl: "https://workers.cloudflare.com/favicon.ico",
377
+ // url: "http://" + localHost, // looks unnecessary
378
+ },
379
+ ]);
380
+ }
381
+
382
+ return new Response(null, { status: 404 });
383
+ }
384
+
385
+ async handleDevToolsWebSocketUpgradeRequest(req: Request) {
386
+ // DevTools attempting to connect
387
+ this.sendDebugLog("DEVTOOLS WEBSOCKET TRYING TO CONNECT");
388
+
389
+ // Delay devtools connection response until we've connected to the runtime inspector server
390
+ await this.websockets.runtimeDeferred.promise;
391
+
392
+ this.sendDebugLog("DEVTOOLS WEBSOCKET CAN NOW CONNECT");
393
+
394
+ assert(
395
+ req.headers.get("Upgrade") === "websocket",
396
+ "Expected DevTools connection to be WebSocket upgrade"
397
+ );
398
+ const { 0: response, 1: devtools } = new WebSocketPair();
399
+ devtools.accept();
400
+
401
+ if (this.websockets.devtools !== undefined) {
402
+ /** We only want to have one active Devtools instance at a time. */
403
+ // TODO(consider): prioritise new websocket over previous
404
+ devtools.close(
405
+ 1013,
406
+ "Too many clients; only one can be connected at a time"
407
+ );
408
+ } else {
409
+ devtools.addEventListener("message", this.handleDevToolsIncomingMessage);
410
+ devtools.addEventListener("close", () => {
411
+ if (this.websockets.devtools === devtools) {
412
+ this.websockets.devtools = undefined;
413
+ }
414
+ });
415
+ devtools.addEventListener("error", (event) => {
416
+ if (this.websockets.devtools === devtools) {
417
+ this.websockets.devtools = undefined;
418
+ }
419
+ });
420
+
421
+ // Since Wrangler proxies the inspector, reloading Chrome DevTools won't trigger debugger initialisation events (because it's connecting to an extant session).
422
+ // This sends a `Debugger.disable` message to the remote when a new WebSocket connection is initialised,
423
+ // with the assumption that the new connection will shortly send a `Debugger.enable` event and trigger re-initialisation.
424
+ // The key initialisation messages that are needed are the `Debugger.scriptParsed events`.
425
+ this.sendRuntimeMessage({
426
+ id: this.nextCounter(),
427
+ method: "Debugger.disable",
428
+ });
429
+
430
+ this.sendDebugLog("DEVTOOLS WEBSOCKET CONNECTED");
431
+
432
+ // Our patched DevTools are hosted on a `https://` URL. These cannot
433
+ // access `file://` URLs, meaning local source maps cannot be fetched.
434
+ // To get around this, we can rewrite `Debugger.scriptParsed` events to
435
+ // include a special `worker:` scheme for source maps, and respond to
436
+ // `Network.loadNetworkResource` commands for these. Unfortunately, this
437
+ // breaks IDE's built-in debuggers (e.g. VSCode and WebStorm), so we only
438
+ // want to enable this transformation when we detect hosted DevTools has
439
+ // connected. We do this by looking at the WebSocket handshake headers:
440
+ //
441
+ // DevTools
442
+ //
443
+ // Upgrade: websocket
444
+ // Host: localhost:9229
445
+ // (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
446
+ // (from Firefox) User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:109.0) Gecko/20100101 Firefox/116.0
447
+ // Origin: https://devtools.devprod.cloudflare.dev
448
+ // ...
449
+ //
450
+ // VSCode
451
+ //
452
+ // Upgrade: websocket
453
+ // Host: localhost
454
+ // ...
455
+ //
456
+ // WebStorm
457
+ //
458
+ // Upgrade: websocket
459
+ // Host: localhost:9229
460
+ // Origin: http://localhost:9229
461
+ // ...
462
+ //
463
+ // From this, we could just use the presence of a `User-Agent` header to
464
+ // determine if DevTools connected, but VSCode/WebStorm could very well
465
+ // add this in future versions. We could also look for an `Origin` header
466
+ // matching the hosted DevTools URL, but this would prevent preview/local
467
+ // versions working. Instead, we look for a browser-like `User-Agent`.
468
+ const userAgent = req.headers.get("User-Agent") ?? "";
469
+ const hasFileSystemAccess = !/mozilla/i.test(userAgent);
470
+
471
+ this.websockets.devtools = devtools;
472
+ this.websockets.devtoolsHasFileSystemAccess = hasFileSystemAccess;
473
+
474
+ this.tryDrainRuntimeMessageBuffer();
475
+ }
476
+
477
+ return new Response(null, { status: 101, webSocket: response });
478
+ }
479
+
480
+ handleDevToolsIncomingMessage = (event: MessageEvent) => {
481
+ assert(
482
+ typeof event.data === "string",
483
+ "Expected devtools incoming message to be of type string"
484
+ );
485
+
486
+ const message = JSON.parse(event.data) as DevToolsCommandRequests;
487
+ this.sendDebugLog("DEVTOOLS INCOMING MESSAGE", message);
488
+
489
+ if (message.method === "Network.loadNetworkResource") {
490
+ return void this.handleDevToolsLoadNetworkResource(message);
491
+ }
492
+
493
+ this.sendRuntimeMessage(JSON.stringify(message));
494
+ };
495
+
496
+ async handleDevToolsLoadNetworkResource(
497
+ message: DevToolsCommandRequest<"Network.loadNetworkResource">
498
+ ) {
499
+ const response = await this.sendProxyControllerRequest({
500
+ type: "load-network-resource",
501
+ url: message.params.url,
502
+ });
503
+ if (response === undefined) {
504
+ this.sendRuntimeMessage(JSON.stringify(message));
505
+ } else {
506
+ // this.websockets.devtools can be undefined here
507
+ // the incoming message implies we have a devtools connection, but after
508
+ // the await it could've dropped in which case we can safely not respond
509
+ this.sendDevToolsMessage({
510
+ id: message.id,
511
+ // @ts-expect-error DevTools Protocol type does not match our patched devtools -- result.resource.text was added
512
+ result: { resource: { success: true, text: response } },
513
+ });
514
+ }
515
+ }
516
+
517
+ sendDevToolsMessage(
518
+ message: string | DevToolsCommandResponses | DevToolsEvents
519
+ ) {
520
+ message = typeof message === "string" ? message : JSON.stringify(message);
521
+
522
+ this.sendDebugLog("SEND TO DEVTOOLS", message);
523
+
524
+ this.websockets.devtools?.send(message);
525
+ }
526
+ }