This commit is contained in:
2026-07-08 18:29:36 +08:00
parent ad51ced86c
commit 3af8ff9b80
1613 changed files with 701644 additions and 2 deletions
+713
View File
@@ -0,0 +1,713 @@
import assert from "node:assert";
import {
DevToolsCommandRequest,
DevToolsCommandRequests,
DevToolsCommandResponses,
DevToolsEvent,
DevToolsEvents,
serialiseError,
} from "../../src/api/startDevWorker/events";
import {
createDeferred,
DeferredPromise,
MaybePromise,
urlFromParts,
} from "../../src/api/startDevWorker/utils";
import { assertNever } from "../../src/utils/assert-never";
import type {
InspectorProxyWorkerIncomingWebSocketMessage,
InspectorProxyWorkerOutgoingRequestBody,
InspectorProxyWorkerOutgoingWebsocketMessage,
ProxyData,
} from "../../src/api/startDevWorker/events";
const ALLOWED_HOST_HOSTNAMES = ["127.0.0.1", "[::1]", "localhost"];
const ALLOWED_ORIGIN_HOSTNAMES = [
"devtools.devprod.cloudflare.dev",
// Workers + Assets (current deployment)
"cloudflare-devtools.devprod.workers.dev",
/^[a-z0-9]+-cloudflare-devtools\.devprod\.workers\.dev$/,
// Cloudflare Pages (legacy deployment)
"cloudflare-devtools.pages.dev",
/^[a-z0-9]+\.cloudflare-devtools\.pages\.dev$/,
"127.0.0.1",
"[::1]",
"localhost",
];
interface Env {
PROXY_CONTROLLER: Fetcher;
PROXY_CONTROLLER_AUTH_SECRET: string;
WRANGLER_VERSION: string;
DURABLE_OBJECT: DurableObjectNamespace;
}
export default {
fetch(req, env) {
const singleton = env.DURABLE_OBJECT.idFromName("");
const inspectorProxy = env.DURABLE_OBJECT.get(singleton);
return inspectorProxy.fetch(req);
},
} as ExportedHandler<Env>;
function isDevToolsEvent<Method extends DevToolsEvents["method"]>(
event: unknown,
name: Method
): event is DevToolsEvent<Method> {
return (
typeof event === "object" &&
event !== null &&
"method" in event &&
event.method === name
);
}
export class InspectorProxyWorker implements DurableObject {
constructor(
_state: DurableObjectState,
readonly env: Env
) {}
websockets: {
proxyController?: WebSocket;
runtime?: WebSocket;
devtools?: WebSocket;
// Browser DevTools cannot read the filesystem,
// instead they fetch via `Network.loadNetworkResource` messages.
// IDE DevTools can read the filesystem and expect absolute paths.
devtoolsHasFileSystemAccess?: boolean;
// We want to be able to delay devtools connection response
// until we've connected to the runtime inspector server
// so this deferred holds a promise to websockets.runtime
runtimeDeferred: DeferredPromise<WebSocket>;
} = {
runtimeDeferred: createDeferred<WebSocket>(),
};
proxyData?: ProxyData;
runtimeMessageBuffer: (DevToolsCommandResponses | DevToolsEvents)[] = [];
// Only allow a limited number of error-based reconnections, so as not to infinite loop
reconnectionsRemaining = 3;
async fetch(req: Request) {
if (
req.headers.get("Authorization") === this.env.PROXY_CONTROLLER_AUTH_SECRET
) {
return this.handleProxyControllerRequest(req);
}
if (req.headers.get("Upgrade") === "websocket") {
return this.handleDevToolsWebSocketUpgradeRequest(req);
}
return this.handleDevToolsJsonRequest(req);
}
// ************************
// ** PROXY CONTROLLER **
// ************************
handleProxyControllerRequest(req: Request) {
assert(
req.headers.get("Upgrade") === "websocket",
"Expected proxy controller data request to be WebSocket upgrade"
);
const { 0: response, 1: proxyController } = new WebSocketPair();
proxyController.accept();
proxyController.addEventListener("close", (event) => {
// don't reconnect the proxyController websocket
// ProxyController can detect this event and reconnect itself
this.sendDebugLog(
"PROXY CONTROLLER WEBSOCKET CLOSED",
event.code,
event.reason
);
if (this.websockets.proxyController === proxyController) {
this.websockets.proxyController = undefined;
}
});
proxyController.addEventListener("error", (event) => {
// don't reconnect the proxyController websocket
// ProxyController can detect this event and reconnect itself
const error = serialiseError(event.error);
this.sendDebugLog("PROXY CONTROLLER WEBSOCKET ERROR", error);
if (this.websockets.proxyController === proxyController) {
this.websockets.proxyController = undefined;
}
});
proxyController.addEventListener(
"message",
this.handleProxyControllerIncomingMessage
);
this.websockets.proxyController = proxyController;
return new Response(null, {
status: 101,
webSocket: response,
});
}
handleProxyControllerIncomingMessage = (event: MessageEvent) => {
assert(
typeof event.data === "string",
"Expected event.data from proxy controller to be string"
);
const message: InspectorProxyWorkerIncomingWebSocketMessage = JSON.parse(
event.data
);
this.sendDebugLog("handleProxyControllerIncomingMessage", event.data);
switch (message.type) {
case "reloadStart": {
this.sendRuntimeDiscardConsoleEntries();
break;
}
case "reloadComplete": {
this.proxyData = message.proxyData;
this.reconnectRuntimeWebSocket();
break;
}
default: {
assertNever(message);
}
}
};
sendProxyControllerMessage(
message: string | InspectorProxyWorkerOutgoingWebsocketMessage
) {
message = typeof message === "string" ? message : JSON.stringify(message);
// if the proxyController websocket is disconnected, throw away the message
this.websockets.proxyController?.send(message);
}
async sendProxyControllerRequest(
message: InspectorProxyWorkerOutgoingRequestBody
) {
try {
const res = await this.env.PROXY_CONTROLLER.fetch("http://dummy", {
method: "POST",
body: JSON.stringify(message),
});
return res.ok ? await res.text() : undefined;
} catch (e) {
this.sendDebugLog(
"FAILED TO SEND PROXY CONTROLLER REQUEST",
serialiseError(e)
);
return undefined;
}
}
sendDebugLog: typeof console.debug = (...args) => {
this.sendProxyControllerRequest({ type: "debug-log", args });
};
// ***************
// ** RUNTIME **
// ***************
handleRuntimeIncomingMessage = (event: MessageEvent) => {
assert(typeof event.data === "string");
const msg = JSON.parse(event.data) as
| DevToolsCommandResponses
| DevToolsEvents;
this.sendDebugLog("RUNTIME INCOMING MESSAGE", msg);
if (isDevToolsEvent(msg, "Runtime.exceptionThrown")) {
this.sendProxyControllerMessage(event.data);
}
if (
this.proxyData?.proxyLogsToController &&
isDevToolsEvent(msg, "Runtime.consoleAPICalled")
) {
this.sendProxyControllerMessage(event.data);
}
this.runtimeMessageBuffer.push(msg);
this.tryDrainRuntimeMessageBuffer();
};
handleRuntimeScriptParsed(msg: DevToolsEvent<"Debugger.scriptParsed">) {
// If the devtools does not have filesystem access,
// rewrite the sourceMapURL to use a special scheme.
// This special scheme is used to indicate whether
// to intercept each loadNetworkResource message.
if (
!this.websockets.devtoolsHasFileSystemAccess &&
msg.params.sourceMapURL !== undefined &&
// Don't try to find a sourcemap for e.g. node-internal: scripts
msg.params.url.startsWith("file:")
) {
const url = new URL(msg.params.sourceMapURL, msg.params.url);
// Check for file: in case msg.params.sourceMapURL has a different
// protocol (e.g. data). In that case we should ignore this file
if (url.protocol === "file:") {
msg.params.sourceMapURL = url.href.replace("file:", "wrangler-file:");
}
}
void this.sendDevToolsMessage(msg);
}
tryDrainRuntimeMessageBuffer = () => {
// If we don't have a DevTools WebSocket, try again later
if (this.websockets.devtools === undefined) return;
// clear the buffer and replay each message to devtools
for (const msg of this.runtimeMessageBuffer.splice(0)) {
if (isDevToolsEvent(msg, "Debugger.scriptParsed")) {
this.handleRuntimeScriptParsed(msg);
} else {
void this.sendDevToolsMessage(msg);
}
}
};
runtimeAbortController = new AbortController(); // will abort the in-flight websocket upgrade request to the remote runtime
runtimeKeepAliveInterval: number | null = null;
async reconnectRuntimeWebSocket() {
assert(this.proxyData, "Expected this.proxyData to be defined");
this.sendDebugLog("reconnectRuntimeWebSocket");
this.websockets.runtime?.close();
this.websockets.runtime = undefined;
this.runtimeAbortController.abort();
this.runtimeAbortController = new AbortController();
this.websockets.runtimeDeferred = createDeferred<WebSocket>(
this.websockets.runtimeDeferred
);
const runtimeWebSocketUrl = urlFromParts(
this.proxyData.userWorkerInspectorUrl
);
runtimeWebSocketUrl.protocol = this.proxyData.userWorkerUrl.protocol; // http: or https:
this.sendDebugLog("NEW RUNTIME WEBSOCKET", runtimeWebSocketUrl);
// Make sure DevTools re-fetches script contents,
// and uses the newly created execution context
this.sendDevToolsMessage({
method: "Runtime.executionContextsCleared",
params: undefined,
});
const upgrade = await fetch(runtimeWebSocketUrl, {
headers: {
...this.proxyData.headers,
"User-Agent": `wrangler/${this.env.WRANGLER_VERSION}`,
Upgrade: "websocket",
},
signal: this.runtimeAbortController.signal,
});
const runtime = upgrade.webSocket;
if (!runtime) {
const error = new Error(
`Failed to establish the WebSocket connection: expected server to reply with HTTP status code 101 (switching protocols), but received ${upgrade.status} instead.`
);
// Sometimes the backend will fail to connect the runtime websocket with a 502 error. These are usually transient, so try and reconnect
if (upgrade.status === 502 && this.reconnectionsRemaining >= 0) {
await scheduler.wait((3 - this.reconnectionsRemaining) * 1000);
this.sendDebugLog(
"RECONNECTING RUNTIME WEBSOCKET after 502. Reconnections remaining:",
this.reconnectionsRemaining
);
return this.reconnectRuntimeWebSocket();
}
this.websockets.runtimeDeferred.reject(error);
this.sendProxyControllerRequest({
type: "runtime-websocket-error",
error: serialiseError(error),
});
return;
}
this.websockets.runtime = runtime;
runtime.addEventListener("message", this.handleRuntimeIncomingMessage);
runtime.addEventListener("close", (event) => {
this.sendDebugLog("RUNTIME WEBSOCKET CLOSED", event.code, event.reason);
clearInterval(this.runtimeKeepAliveInterval);
if (this.websockets.runtime === runtime) {
this.websockets.runtime = undefined;
}
// don't reconnect the runtime websocket
// if it closes unexpectedly (very rare or a case where reconnecting won't succeed anyway)
// wait for a new proxy-data message or manual restart
});
runtime.addEventListener("error", (event) => {
const error = serialiseError(event.error);
this.sendDebugLog("RUNTIME WEBSOCKET ERROR", error);
clearInterval(this.runtimeKeepAliveInterval);
if (this.websockets.runtime === runtime) {
this.websockets.runtime = undefined;
}
this.sendProxyControllerRequest({
type: "runtime-websocket-error",
error,
});
// don't reconnect the runtime websocket
// if it closes unexpectedly (very rare or a case where reconnecting won't succeed anyway)
// wait for a new proxy-data message or manual restart
});
runtime.accept();
// fetch(Upgrade: websocket) resolves when the websocket is open
// therefore the open event will not fire, so just trigger the handler
this.handleRuntimeWebSocketOpen(runtime);
}
#runtimeMessageCounter = 1e8;
nextCounter() {
return ++this.#runtimeMessageCounter;
}
handleRuntimeWebSocketOpen(runtime: WebSocket) {
this.sendDebugLog("RUNTIME WEBSOCKET OPENED");
this.reconnectionsRemaining = 3;
this.sendRuntimeMessage(
{ method: "Runtime.enable", id: this.nextCounter() },
runtime
);
// Only send Debugger.enable if DevTools is already attached.
// When DevTools first connects, it sends its own Debugger.enable message.
// However, on runtime reconnect (e.g., after worker reload), DevTools won't
// re-send Debugger.enable since it considers the session still active.
// Without this, Debugger.scriptParsed events won't be emitted on the new
// runtime connection, breaking source maps, breakpoints, and debugger pausing.
if (this.websockets.devtools !== undefined) {
this.sendRuntimeMessage(
{ method: "Debugger.enable", id: this.nextCounter() },
runtime
);
}
// Only enable the Network domain when DevTools is attached. Otherwise the
// runtime streams a Network.dataReceived per response body chunk into a
// buffer that is never drained without a client (headless `wrangler dev`),
// flooding the inspector until the dev server stops accepting connections.
if (this.websockets.devtools !== undefined) {
this.sendRuntimeMessage(
{ method: "Network.enable", id: this.nextCounter() },
runtime
);
}
clearInterval(this.runtimeKeepAliveInterval);
this.runtimeKeepAliveInterval = setInterval(() => {
this.sendRuntimeMessage(
{ method: "Runtime.getIsolateId", id: this.nextCounter() },
runtime
);
}, 10_000) as any;
this.websockets.runtimeDeferred.resolve(runtime);
}
sendRuntimeDiscardConsoleEntries() {
// by default, sendRuntimeMessage waits for the runtime websocket to connect
// but we only want to send this message now or never
// if we schedule it to send later (like waiting for the websocket, by default)
// then we risk clearing logs that have occurred since we scheduled it too
// which is worse than leaving logs from the previous version on screen
if (this.websockets.runtime) {
this.sendRuntimeMessage(
{
method: "Runtime.discardConsoleEntries",
id: this.nextCounter(),
},
this.websockets.runtime
);
}
}
async sendRuntimeMessage(
message: string | DevToolsCommandRequests,
runtime: MaybePromise<WebSocket> = this.websockets.runtimeDeferred.promise
) {
runtime = await runtime;
message = typeof message === "string" ? message : JSON.stringify(message);
this.sendDebugLog("SEND TO RUNTIME", message);
runtime.send(message);
}
// ****************
// ** DEVTOOLS **
// ****************
#inspectorId = crypto.randomUUID();
async handleDevToolsJsonRequest(req: Request) {
const url = new URL(req.url);
if (url.pathname === "/json/version") {
return Response.json({
Browser: `wrangler/v${this.env.WRANGLER_VERSION}`,
// TODO: (someday): The DevTools protocol should match that of workerd.
// This could be exposed by the preview API.
"Protocol-Version": "1.3",
});
}
if (url.pathname === "/json" || url.pathname === "/json/list") {
// TODO: can we remove the `/ws` here if we only have a single worker?
const localHost = `${url.host}/ws`;
const devtoolsFrontendUrl = `https://devtools.devprod.cloudflare.dev/js_app?theme=systemPreferred&debugger=true&ws=${localHost}`;
return Response.json([
{
id: this.#inspectorId,
type: "node", // TODO: can we specify different type?
description: "workers",
webSocketDebuggerUrl: `ws://${localHost}`,
devtoolsFrontendUrl,
devtoolsFrontendUrlCompat: devtoolsFrontendUrl,
// Below are fields that are visible in the DevTools UI.
title: "Cloudflare Worker",
faviconUrl: "https://workers.cloudflare.com/favicon.ico",
// url: "http://" + localHost, // looks unnecessary
},
]);
}
return new Response(null, { status: 404 });
}
async handleDevToolsWebSocketUpgradeRequest(req: Request) {
// Validate `Host` header
let hostHeader = req.headers.get("Host");
if (hostHeader == null) return new Response(null, { status: 400 });
try {
const host = new URL(`http://${hostHeader}`);
if (!ALLOWED_HOST_HOSTNAMES.includes(host.hostname)) {
return new Response("Disallowed `Host` header", { status: 401 });
}
} catch {
return new Response("Expected `Host` header", { status: 400 });
}
// Validate `Origin` header
let originHeader = req.headers.get("Origin");
if (originHeader === null && !req.headers.has("User-Agent")) {
// VSCode doesn't send an `Origin` header, but also doesn't send a
// `User-Agent` header, so allow an empty origin in this case.
originHeader = "http://localhost";
}
if (originHeader === null) {
return new Response("Expected `Origin` header", { status: 400 });
}
try {
const origin = new URL(originHeader);
const allowed = ALLOWED_ORIGIN_HOSTNAMES.some((rule) => {
if (typeof rule === "string") return origin.hostname === rule;
else return rule.test(origin.hostname);
});
if (!allowed) {
return new Response("Disallowed `Origin` header", { status: 401 });
}
} catch {
return new Response("Expected `Origin` header", { status: 400 });
}
// DevTools attempting to connect
this.sendDebugLog("DEVTOOLS WEBSOCKET TRYING TO CONNECT");
// Delay devtools connection response until we've connected to the runtime inspector server
await this.websockets.runtimeDeferred.promise;
this.sendDebugLog("DEVTOOLS WEBSOCKET CAN NOW CONNECT");
assert(
req.headers.get("Upgrade") === "websocket",
"Expected DevTools connection to be WebSocket upgrade"
);
const { 0: response, 1: devtools } = new WebSocketPair();
devtools.accept();
if (this.websockets.devtools !== undefined) {
/** We only want to have one active Devtools instance at a time. */
// TODO(consider): prioritise new websocket over previous
devtools.close(
1013,
"Too many clients; only one can be connected at a time"
);
} else {
devtools.addEventListener("message", this.handleDevToolsIncomingMessage);
const disconnectDevtools = () => {
if (this.websockets.devtools === devtools) {
this.websockets.devtools = undefined;
// Notify the runtime to disable the Debugger and Network domains when
// DevTools disconnects. Without disabling Network the runtime keeps
// streaming Network.dataReceived into runtimeMessageBuffer, which is no
// longer drained once devtools is undefined, reintroducing the same
// headless flood this proxy otherwise avoids.
if (this.websockets.runtime) {
this.sendRuntimeMessage({
id: this.nextCounter(),
method: "Debugger.disable",
});
this.sendRuntimeMessage({
id: this.nextCounter(),
method: "Network.disable",
});
}
}
};
devtools.addEventListener("close", (event) => {
this.sendDebugLog(
"DEVTOOLS WEBSOCKET CLOSED",
event.code,
event.reason
);
disconnectDevtools();
});
devtools.addEventListener("error", (event) => {
const error = serialiseError(event.error);
this.sendDebugLog("DEVTOOLS WEBSOCKET ERROR", error);
disconnectDevtools();
});
// Since Wrangler proxies the inspector, reloading Chrome DevTools won't trigger debugger initialisation events (because it's connecting to an extant session).
// This sends a `Debugger.disable` message to the remote when a new WebSocket connection is initialised,
// with the assumption that the new connection will shortly send a `Debugger.enable` event and trigger re-initialisation.
// The key initialisation messages that are needed are the `Debugger.scriptParsed events`.
this.sendRuntimeMessage({
id: this.nextCounter(),
method: "Debugger.disable",
});
this.sendDebugLog("DEVTOOLS WEBSOCKET CONNECTED");
// Our patched DevTools are hosted on a `https://` URL. These cannot
// access `file://` URLs, meaning local source maps cannot be fetched.
// To get around this, we can rewrite `Debugger.scriptParsed` events to
// include a special `worker:` scheme for source maps, and respond to
// `Network.loadNetworkResource` commands for these. Unfortunately, this
// breaks IDE's built-in debuggers (e.g. VSCode and WebStorm), so we only
// want to enable this transformation when we detect hosted DevTools has
// connected. We do this by looking at the WebSocket handshake headers:
//
// DevTools
//
// Upgrade: websocket
// Host: localhost:9229
// (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
// (from Firefox) User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:109.0) Gecko/20100101 Firefox/116.0
// Origin: https://devtools.devprod.cloudflare.dev
// ...
//
// VSCode
//
// Upgrade: websocket
// Host: localhost
// ...
//
// WebStorm
//
// Upgrade: websocket
// Host: localhost:9229
// Origin: http://localhost:9229
// ...
//
// From this, we could just use the presence of a `User-Agent` header to
// determine if DevTools connected, but VSCode/WebStorm could very well
// add this in future versions. We could also look for an `Origin` header
// matching the hosted DevTools URL, but this would prevent preview/local
// versions working. Instead, we look for a browser-like `User-Agent`.
const userAgent = req.headers.get("User-Agent") ?? "";
const hasFileSystemAccess = !/mozilla/i.test(userAgent);
this.websockets.devtools = devtools;
this.websockets.devtoolsHasFileSystemAccess = hasFileSystemAccess;
this.tryDrainRuntimeMessageBuffer();
}
return new Response(null, { status: 101, webSocket: response });
}
handleDevToolsIncomingMessage = (event: MessageEvent) => {
assert(
typeof event.data === "string",
"Expected devtools incoming message to be of type string"
);
const message = JSON.parse(event.data) as DevToolsCommandRequests;
this.sendDebugLog("DEVTOOLS INCOMING MESSAGE", message);
if (message.method === "Network.loadNetworkResource") {
return void this.handleDevToolsLoadNetworkResource(message);
}
this.sendRuntimeMessage(JSON.stringify(message));
};
async handleDevToolsLoadNetworkResource(
message: DevToolsCommandRequest<"Network.loadNetworkResource">
) {
const response = await this.sendProxyControllerRequest({
type: "load-network-resource",
url: message.params.url,
});
if (response === undefined) {
this.sendDebugLog(
`ProxyController could not resolve Network.loadNetworkResource for "${message.params.url}"`
);
// When the ProxyController cannot resolve a resource, let the runtime handle the request
this.sendRuntimeMessage(JSON.stringify(message));
} else {
// this.websockets.devtools can be undefined here
// the incoming message implies we have a devtools connection, but after
// the await it could've dropped in which case we can safely not respond
this.sendDevToolsMessage({
id: message.id,
// @ts-expect-error DevTools Protocol type does not match our patched devtools -- result.resource.text was added
result: { resource: { success: true, text: response } },
});
}
}
sendDevToolsMessage(
message: string | DevToolsCommandResponses | DevToolsEvents
) {
message = typeof message === "string" ? message : JSON.stringify(message);
this.sendDebugLog("SEND TO DEVTOOLS", message);
this.websockets.devtools?.send(message);
}
}
+368
View File
@@ -0,0 +1,368 @@
import {
createDeferred,
DeferredPromise,
urlFromParts,
} from "../../src/api/startDevWorker/utils";
import type {
ProxyData,
ProxyWorkerIncomingRequestBody,
ProxyWorkerOutgoingRequestBody,
} from "../../src/api/startDevWorker/events";
interface Env {
PROXY_CONTROLLER: Fetcher;
PROXY_CONTROLLER_AUTH_SECRET: string;
DURABLE_OBJECT: DurableObjectNamespace;
}
// request.cf.hostMetadata is verbose to type using the workers-types Request -- this allows us to have Request correctly typed in this scope
type Request = Parameters<
NonNullable<
ExportedHandler<Env, unknown, ProxyWorkerIncomingRequestBody>["fetch"]
>
>[0];
const LIVE_RELOAD_PROTOCOL = "WRANGLER_PROXYWORKER_LIVE_RELOAD_PROTOCOL";
const LIVE_RELOAD_PATHNAME = "/cdn-cgi/live-reload";
export default {
fetch(req, env) {
const singleton = env.DURABLE_OBJECT.idFromName("");
const inspectorProxy = env.DURABLE_OBJECT.get(singleton);
return inspectorProxy.fetch(req);
},
} as ExportedHandler<Env, unknown, ProxyWorkerIncomingRequestBody>;
export class ProxyWorker implements DurableObject {
constructor(
readonly state: DurableObjectState,
readonly env: Env
) {}
proxyData?: ProxyData;
requestQueue = new Map<Request, DeferredPromise<Response>>();
requestRetryQueue = new Map<Request, DeferredPromise<Response>>();
fetch(request: Request) {
if (isRequestForLiveReloadWebsocket(request)) {
// requests for live-reload websocket
return this.handleLiveReloadWebSocket(request);
}
if (isRequestFromProxyController(request, this.env)) {
// requests from ProxyController
return this.processProxyControllerRequest(request);
}
// regular requests to be proxied
const deferred = createDeferred<Response>();
this.requestQueue.set(request, deferred);
this.processQueue();
return deferred.promise;
}
handleLiveReloadWebSocket(request: Request) {
const { 0: response, 1: liveReload } = new WebSocketPair();
const websocketProtocol =
request.headers.get("Sec-WebSocket-Protocol") ?? "";
this.state.acceptWebSocket(liveReload, ["live-reload"]);
return new Response(null, {
status: 101,
webSocket: response,
headers: { "Sec-WebSocket-Protocol": websocketProtocol },
});
}
processProxyControllerRequest(request: Request) {
const event = request.cf?.hostMetadata;
switch (event?.type) {
case "pause":
this.proxyData = undefined;
break;
case "play":
this.proxyData = event.proxyData;
this.processQueue();
this.state
.getWebSockets("live-reload")
.forEach((ws) => ws.send("reload"));
break;
}
return new Response(null, { status: 204 });
}
/**
* Process requests that are being retried first, then process newer requests.
* Requests that are being retried are, by definition, older than requests which haven't been processed yet.
* We don't need to be more accurate than this re ordering, since the requests are being fired off synchronously.
*/
*getOrderedQueue() {
yield* this.requestRetryQueue;
yield* this.requestQueue;
}
processQueue() {
const { proxyData } = this; // store proxyData at the moment this function was called
if (proxyData === undefined) return;
for (const [request, deferredResponse] of this.getOrderedQueue()) {
this.requestRetryQueue.delete(request);
this.requestQueue.delete(request);
const outerUrl = new URL(request.url);
const headers = new Headers(request.headers);
// override url parts for proxying
const userWorkerUrl = new URL(request.url);
Object.assign(userWorkerUrl, proxyData.userWorkerUrl);
// set request.url in the UserWorker
const innerUrl = urlFromParts(
proxyData.userWorkerInnerUrlOverrides ?? {},
request.url
);
// Preserve client `Accept-Encoding`, rather than using Worker's default
// of `Accept-Encoding: br, gzip`
const encoding = request.cf?.clientAcceptEncoding;
if (encoding !== undefined) headers.set("Accept-Encoding", encoding);
rewriteUrlRelatedHeaders(headers, outerUrl, innerUrl);
// Set after `rewriteUrlRelatedHeaders` so that any occurrences of the
// outer host inside the URL's query string (e.g. `?redirect_uri=`)
// are preserved in `request.url` inside the user worker.
headers.set("MF-Original-URL", innerUrl.href);
// merge proxyData headers with the request headers
for (const [key, value] of Object.entries(proxyData.headers ?? {})) {
if (value === undefined) continue;
if (key.toLowerCase() === "cookie") {
const existing = request.headers.get("cookie") ?? "";
headers.set("cookie", `${existing};${value}`);
} else {
headers.set(key, value);
}
}
// explicitly NOT await-ing this promise, we are in a loop and want to process the whole queue quickly + synchronously
void fetch(userWorkerUrl, new Request(request, { headers }))
.then(async (res) => {
res = new Response(res.body, res);
rewriteUrlRelatedHeaders(res.headers, innerUrl, outerUrl);
await checkForPreviewTokenError(res, this.env, proxyData);
if (isHtmlResponse(res)) {
res = insertLiveReloadScript(request, res, this.env, proxyData);
}
if (isSseResponse(res)) {
void sendMessageToProxyController(this.env, {
type: "sseResponseDetected",
});
}
deferredResponse.resolve(res);
})
.catch((error: Error) => {
// errors here are network errors or from response post-processing
// to catch only network errors, use the 2nd param of the fetch.then()
// we have crossed an async boundary, so proxyData may have changed
// if proxyData.userWorkerUrl has changed, it means there is a new downstream UserWorker
// and that this error is stale since it was for a request to the old UserWorker
// so here we construct a newUserWorkerUrl so we can compare it to the (old) userWorkerUrl
const newUserWorkerUrl =
this.proxyData && urlFromParts(this.proxyData.userWorkerUrl);
// only report errors if the downstream proxy has NOT changed
if (userWorkerUrl.href === newUserWorkerUrl?.href) {
void sendMessageToProxyController(this.env, {
type: "error",
error: {
name: error.name,
message: error.message,
stack: error.stack,
cause: error.cause,
},
});
deferredResponse.reject(error);
}
// if the request can be retried (subset of idempotent requests which have no body), requeue it
else if (request.method === "GET" || request.method === "HEAD") {
this.requestRetryQueue.set(request, deferredResponse);
// we would only end up here if the downstream UserWorker is chang*ing*
// i.e. we are in a `pause`d state and expecting a `play` message soon
// this request will be processed (retried) when the `play` message arrives
// for that reason, we do not need to call `this.processQueue` here
// (but, also, it can't hurt to call it since it bails when
// in a `pause`d state i.e. `this.proxyData` is undefined)
}
// if the request cannot be retried, respond with 503 Service Unavailable
// important to note, this is not an (unexpected) error -- it is an acceptable flow of local development
// it would be incorrect to retry non-idempotent requests
// and would require cloning all body streams to avoid stream reuse (which is inefficient but not out of the question in the future)
// this is a good enough UX for now since it solves the most common GET use-case
else {
deferredResponse.resolve(
new Response(
"Your worker restarted mid-request. Please try sending the request again. Only GET or HEAD requests are retried automatically.",
{
status: 503,
headers: { "Retry-After": "0" },
}
)
);
}
});
}
}
}
function isRequestFromProxyController(req: Request, env: Env): boolean {
return req.headers.get("Authorization") === env.PROXY_CONTROLLER_AUTH_SECRET;
}
function isHtmlResponse(res: Response): boolean {
return res.headers.get("content-type")?.startsWith("text/html") ?? false;
}
function isSseResponse(res: Response): boolean {
return (
res.headers.get("content-type")?.startsWith("text/event-stream") ?? false
);
}
function isRequestForLiveReloadWebsocket(req: Request): boolean {
if (new URL(req.url).pathname !== LIVE_RELOAD_PATHNAME) {
return false;
}
const websocketProtocol = req.headers.get("Sec-WebSocket-Protocol");
const isWebSocketUpgrade = req.headers.get("Upgrade") === "websocket";
return isWebSocketUpgrade && websocketProtocol === LIVE_RELOAD_PROTOCOL;
}
function sendMessageToProxyController(
env: Env,
message: ProxyWorkerOutgoingRequestBody
) {
return env.PROXY_CONTROLLER.fetch("http://dummy", {
method: "POST",
body: JSON.stringify(message),
});
}
async function checkForPreviewTokenError(
response: Response,
env: Env,
proxyData: ProxyData
) {
if (response.status !== 400) {
return;
}
// At this point HTMLRewriter tries to parse the compressed stream,
// so we clone and read the text instead.
const clone = response.clone();
const text = await clone.text();
// Naive string match should be good enough when combined with status code check.
// "Invalid Workers Preview configuration" is the HTML error returned when the
// preview token has expired. "error code: 1031" is a text/plain error returned
// by remote bindings (e.g. Workers AI) when their underlying session has timed out.
// Both indicate the preview session needs to be refreshed.
if (
text.includes("Invalid Workers Preview configuration") ||
text.includes("error code: 1031")
) {
void sendMessageToProxyController(env, {
type: "previewTokenExpired",
proxyData,
});
}
}
function insertLiveReloadScript(
request: Request,
response: Response,
env: Env,
proxyData: ProxyData
) {
const htmlRewriter = new HTMLRewriter();
htmlRewriter.onDocument({
end(end) {
// if liveReload enabled, append a script tag
// TODO: compare to existing nodejs implementation
if (proxyData.liveReload) {
const websocketUrl = new URL(request.url);
websocketUrl.protocol =
websocketUrl.protocol === "http:" ? "ws:" : "wss:";
end.append(liveReloadScript, { html: true });
}
},
});
return htmlRewriter.transform(response);
}
const liveReloadScript = `
<script defer type="application/javascript">
(function() {
var ws;
function recover() {
ws = null;
setTimeout(initLiveReload, 100);
}
function initLiveReload() {
if (ws) return;
var origin = (location.protocol === "http:" ? "ws://" : "wss://") + location.host;
ws = new WebSocket(origin + "${LIVE_RELOAD_PATHNAME}", "${LIVE_RELOAD_PROTOCOL}");
ws.onclose = recover;
ws.onerror = recover;
ws.onmessage = location.reload.bind(location);
}
initLiveReload();
})();
</script>
`;
/**
* Rewrite references to URLs in request/response headers.
*
* This function is used to map the URLs in headers like Origin and Access-Control-Allow-Origin
* so that this proxy is transparent to the Client Browser and User Worker.
*/
function rewriteUrlRelatedHeaders(headers: Headers, from: URL, to: URL) {
const setCookie = headers.getAll("Set-Cookie");
headers.delete("Set-Cookie");
headers.forEach((value, key) => {
if (typeof value === "string" && value.includes(from.host)) {
headers.set(
key,
value.replaceAll(from.origin, to.origin).replaceAll(from.host, to.host)
);
}
});
for (const cookie of setCookie) {
headers.append(
"Set-Cookie",
cookie.replace(
new RegExp(`Domain=${from.hostname}($|;|,)`),
`Domain=${to.hostname}$1`
)
);
}
}