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
+124
View File
@@ -0,0 +1,124 @@
import { isRoutingRuleMatch } from "../pages-dev-util";
describe("isRoutingRuleMatch", () => {
it("should match rules referencing root level correctly", () => {
const routingRule = "/";
expect(isRoutingRuleMatch("/", routingRule)).toBeTruthy();
expect(isRoutingRuleMatch("/foo", routingRule)).toBeFalsy();
expect(isRoutingRuleMatch("/foo/", routingRule)).toBeFalsy();
expect(isRoutingRuleMatch("/foo/bar", routingRule)).toBeFalsy();
});
it("should match include-all rules correctly", () => {
const routingRule = "/*";
expect(isRoutingRuleMatch("/", routingRule)).toBeTruthy();
expect(isRoutingRuleMatch("/foo", routingRule)).toBeTruthy();
expect(isRoutingRuleMatch("/foo/", routingRule)).toBeTruthy();
expect(isRoutingRuleMatch("/foo/bar", routingRule)).toBeTruthy();
expect(isRoutingRuleMatch("/foo/bar/", routingRule)).toBeTruthy();
expect(isRoutingRuleMatch("/foo/bar/baz", routingRule)).toBeTruthy();
expect(isRoutingRuleMatch("/foo/bar/baz/", routingRule)).toBeTruthy();
});
it("should match `/*` suffix-ed rules correctly", () => {
let routingRule = "/foo/*";
expect(isRoutingRuleMatch("/foo", routingRule)).toBeTruthy();
expect(isRoutingRuleMatch("/foo/", routingRule)).toBeTruthy();
expect(isRoutingRuleMatch("/foobar", routingRule)).toBeFalsy();
expect(isRoutingRuleMatch("/foo/bar", routingRule)).toBeTruthy();
expect(isRoutingRuleMatch("/foo/bar/baz", routingRule)).toBeTruthy();
expect(isRoutingRuleMatch("/bar/foo", routingRule)).toBeFalsy();
expect(isRoutingRuleMatch("/bar/foo/baz", routingRule)).toBeFalsy();
routingRule = "/foo/bar/*";
expect(isRoutingRuleMatch("/foo", routingRule)).toBeFalsy();
expect(isRoutingRuleMatch("/foo/", routingRule)).toBeFalsy();
expect(isRoutingRuleMatch("/foo/bar", routingRule)).toBeTruthy();
expect(isRoutingRuleMatch("/foo/bar/baz", routingRule)).toBeTruthy();
expect(isRoutingRuleMatch("/foo/barfoo", routingRule)).toBeFalsy();
expect(isRoutingRuleMatch("baz/foo/bar", routingRule)).toBeFalsy();
expect(isRoutingRuleMatch("baz/foo/bar/", routingRule)).toBeFalsy();
});
it("should match `/` suffix-ed rules correctly", () => {
let routingRule = "/foo/";
expect(isRoutingRuleMatch("/foo/", routingRule)).toBeTruthy();
expect(isRoutingRuleMatch("/foo", routingRule)).toBeTruthy();
routingRule = "/foo/bar/";
expect(isRoutingRuleMatch("/foo/bar/", routingRule)).toBeTruthy();
expect(isRoutingRuleMatch("/foo/bar", routingRule)).toBeTruthy();
});
it("should match `*` suffix-ed rules correctly", () => {
let routingRule = "/foo*";
expect(isRoutingRuleMatch("/foo", routingRule)).toBeTruthy();
expect(isRoutingRuleMatch("/foo/", routingRule)).toBeTruthy();
expect(isRoutingRuleMatch("/foobar", routingRule)).toBeTruthy();
expect(isRoutingRuleMatch("/barfoo", routingRule)).toBeFalsy();
expect(isRoutingRuleMatch("/foo/bar", routingRule)).toBeTruthy();
expect(isRoutingRuleMatch("/bar/foo", routingRule)).toBeFalsy();
expect(isRoutingRuleMatch("/bar/foobar", routingRule)).toBeFalsy();
expect(isRoutingRuleMatch("/foo/bar/baz", routingRule)).toBeTruthy();
expect(isRoutingRuleMatch("/bar/foo/baz", routingRule)).toBeFalsy();
routingRule = "/foo/bar*";
expect(isRoutingRuleMatch("/foo/bar", routingRule)).toBeTruthy();
expect(isRoutingRuleMatch("/foo/bar/", routingRule)).toBeTruthy();
expect(isRoutingRuleMatch("/foo/barfoo", routingRule)).toBeTruthy();
expect(isRoutingRuleMatch("/bar/foo/barfoo", routingRule)).toBeFalsy();
expect(isRoutingRuleMatch("/foo/bar/baz", routingRule)).toBeTruthy();
expect(isRoutingRuleMatch("/bar/foo/bar/baz", routingRule)).toBeFalsy();
});
it("should match rules without wildcards correctly", () => {
let routingRule = "/foo";
expect(isRoutingRuleMatch("/foo", routingRule)).toBeTruthy();
expect(isRoutingRuleMatch("/foo/", routingRule)).toBeTruthy();
expect(isRoutingRuleMatch("/foo/bar", routingRule)).toBeFalsy();
expect(isRoutingRuleMatch("/bar/foo", routingRule)).toBeFalsy();
routingRule = "/foo/bar";
expect(isRoutingRuleMatch("/foo/bar", routingRule)).toBeTruthy();
expect(isRoutingRuleMatch("/foo/bar/", routingRule)).toBeTruthy();
expect(isRoutingRuleMatch("/foo/bar/baz", routingRule)).toBeFalsy();
expect(isRoutingRuleMatch("/baz/foo/bar", routingRule)).toBeFalsy();
});
it("should throw an error if pathname or routing rule params are missing", () => {
// MISSING PATHNAME
expect(() =>
// @ts-expect-error -- Intentionally testing invalid types
isRoutingRuleMatch(undefined, "/*")
).toThrow("Pathname is undefined.");
expect(() =>
// @ts-expect-error -- Intentionally testing invalid types
isRoutingRuleMatch(null, "/*")
).toThrow("Pathname is undefined.");
expect(() => isRoutingRuleMatch("", "/*")).toThrow(
"Pathname is undefined."
);
// MISSING ROUTING RULE
expect(() =>
// @ts-expect-error -- Intentionally testing invalid types
isRoutingRuleMatch("/foo", undefined)
).toThrow("Routing rule is undefined.");
expect(() =>
// @ts-expect-error -- Intentionally testing invalid types
isRoutingRuleMatch("/foo", null)
).toThrow("Routing rule is undefined.");
expect(() => isRoutingRuleMatch("/foo", "")).toThrow(
"Routing rule is undefined."
);
});
});
+12
View File
@@ -0,0 +1,12 @@
// `@types/node` should be included
Buffer.from("test");
// `@types/jest` should be included
test("test");
// @ts-expect-error `@cloudflare/workers-types` should NOT be included
const _handler: ExportedHandler = {};
// @ts-expect-error `@cloudflare/workers-types` should NOT be included
new HTMLRewriter();
export {};
+8
View File
@@ -0,0 +1,8 @@
{
"extends": "@cloudflare/workers-tsconfig/tsconfig.json",
"compilerOptions": {
"types": ["node", "jest"]
},
"include": ["**/*.ts"],
"exclude": []
}
+28
View File
@@ -0,0 +1,28 @@
const urls = new Set();
function checkURL(request, init) {
const url =
request instanceof URL
? request
: new URL(
(typeof request === "string" ? new Request(request, init) : request)
.url
);
if (url.port && url.port !== "443" && url.protocol === "https:") {
if (!urls.has(url.toString())) {
urls.add(url.toString());
console.warn(
`WARNING: known issue with \`fetch()\` requests to custom HTTPS ports in published Workers:\n` +
` - ${url.toString()} - the custom port will be ignored when the Worker is published using the \`wrangler deploy\` command.\n`
);
}
}
}
globalThis.fetch = new Proxy(globalThis.fetch, {
apply(target, thisArg, argArray) {
const [request, init] = argArray;
checkURL(request, init);
return Reflect.apply(target, thisArg, argArray);
},
});
+19
View File
@@ -0,0 +1,19 @@
declare module "__ENTRY_POINT__" {
import { WorkerEntrypoint } from "cloudflare:workers";
import { Middleware } from "./middleware/common";
export type WorkerEntrypointConstructor = typeof WorkerEntrypoint;
const worker: ExportedHandler | WorkerEntrypointConstructor;
export default worker;
export const __INTERNAL_WRANGLER_MIDDLEWARE__: Middleware[];
}
declare module "__KV_ASSET_HANDLER__" {
export * from "@cloudflare/kv-asset-handler";
}
declare module "__STATIC_CONTENT_MANIFEST" {
const manifest: string;
export default manifest;
}
+67
View File
@@ -0,0 +1,67 @@
export type Awaitable<T> = T | Promise<T>;
// TODO: allow dispatching more events?
export type Dispatcher = (
type: "scheduled",
init: { cron?: string }
) => Awaitable<void>;
export type IncomingRequest = Request<
unknown,
IncomingRequestCfProperties<unknown>
>;
export interface MiddlewareContext {
dispatch: Dispatcher;
next(request: IncomingRequest, env: any): Awaitable<Response>;
}
export type Middleware = (
request: IncomingRequest,
env: any,
ctx: ExecutionContext,
middlewareCtx: MiddlewareContext
) => Awaitable<Response>;
const __facade_middleware__: Middleware[] = [];
// The register functions allow for the insertion of one or many middleware,
// We register internal middleware first in the stack, but have no way of controlling
// the order that addMiddleware is run in service workers so need an internal function.
export function __facade_register__(...args: (Middleware | Middleware[])[]) {
__facade_middleware__.push(...args.flat());
}
export function __facade_registerInternal__(
...args: (Middleware | Middleware[])[]
) {
__facade_middleware__.unshift(...args.flat());
}
function __facade_invokeChain__(
request: IncomingRequest,
env: any,
ctx: ExecutionContext,
dispatch: Dispatcher,
middlewareChain: Middleware[]
): Awaitable<Response> {
const [head, ...tail] = middlewareChain;
const middlewareCtx: MiddlewareContext = {
dispatch,
next(newRequest, newEnv) {
return __facade_invokeChain__(newRequest, newEnv, ctx, dispatch, tail);
},
};
return head(request, env, ctx, middlewareCtx);
}
export function __facade_invoke__(
request: IncomingRequest,
env: any,
ctx: ExecutionContext,
dispatch: Dispatcher,
finalMiddleware: Middleware
): Awaitable<Response> {
return __facade_invokeChain__(request, env, ctx, dispatch, [
...__facade_middleware__,
finalMiddleware,
]);
}
+134
View File
@@ -0,0 +1,134 @@
// This loads all middlewares exposed on the middleware object and then starts
// the invocation chain. The big idea is that we can add these to the middleware
// export dynamically through wrangler, or we can potentially let users directly
// add them as a sort of "plugin" system.
import ENTRY, { __INTERNAL_WRANGLER_MIDDLEWARE__ } from "__ENTRY_POINT__";
import { __facade_invoke__, __facade_register__, Dispatcher } from "./common";
import type { WorkerEntrypointConstructor } from "__ENTRY_POINT__";
// Preserve all the exports from the worker
export * from "__ENTRY_POINT__";
class __Facade_ScheduledController__ implements ScheduledController {
readonly #noRetry: ScheduledController["noRetry"];
constructor(
readonly scheduledTime: number,
readonly cron: string,
noRetry: ScheduledController["noRetry"]
) {
this.#noRetry = noRetry;
}
noRetry() {
if (!(this instanceof __Facade_ScheduledController__)) {
throw new TypeError("Illegal invocation");
}
// Need to call native method immediately in case uncaught error thrown
this.#noRetry();
}
}
function wrapExportedHandler(worker: ExportedHandler): ExportedHandler {
// If we don't have any middleware defined, just return the handler as is
if (
__INTERNAL_WRANGLER_MIDDLEWARE__ === undefined ||
__INTERNAL_WRANGLER_MIDDLEWARE__.length === 0
) {
return worker;
}
// Otherwise, register all middleware once
for (const middleware of __INTERNAL_WRANGLER_MIDDLEWARE__) {
__facade_register__(middleware);
}
const fetchDispatcher: ExportedHandlerFetchHandler = function (
request,
env,
ctx
) {
if (worker.fetch === undefined) {
throw new Error("Handler does not export a fetch() function.");
}
return worker.fetch(request, env, ctx);
};
return {
...worker,
fetch(request, env, ctx) {
const dispatcher: Dispatcher = function (type, init) {
if (type === "scheduled" && worker.scheduled !== undefined) {
const controller = new __Facade_ScheduledController__(
Date.now(),
init.cron ?? "",
() => {}
);
return worker.scheduled(controller, env, ctx);
}
};
return __facade_invoke__(request, env, ctx, dispatcher, fetchDispatcher);
},
};
}
function wrapWorkerEntrypoint(
klass: WorkerEntrypointConstructor
): WorkerEntrypointConstructor {
// If we don't have any middleware defined, just return the handler as is
if (
__INTERNAL_WRANGLER_MIDDLEWARE__ === undefined ||
__INTERNAL_WRANGLER_MIDDLEWARE__.length === 0
) {
return klass;
}
// Otherwise, register all middleware once
for (const middleware of __INTERNAL_WRANGLER_MIDDLEWARE__) {
__facade_register__(middleware);
}
// `extend`ing `klass` here so other RPC methods remain callable
return class extends klass {
#fetchDispatcher: ExportedHandlerFetchHandler<Record<string, unknown>> = (
request,
env,
ctx
) => {
this.env = env;
this.ctx = ctx;
if (super.fetch === undefined) {
throw new Error("Entrypoint class does not define a fetch() function.");
}
return super.fetch(request);
};
#dispatcher: Dispatcher = (type, init) => {
if (type === "scheduled" && super.scheduled !== undefined) {
const controller = new __Facade_ScheduledController__(
Date.now(),
init.cron ?? "",
() => {}
);
return super.scheduled(controller);
}
};
fetch(request: Request<unknown, IncomingRequestCfProperties>) {
return __facade_invoke__(
request,
this.env,
this.ctx,
this.#dispatcher,
this.#fetchDispatcher
);
}
};
}
let WRAPPED_ENTRY: ExportedHandler | WorkerEntrypointConstructor | undefined;
if (typeof ENTRY === "object") {
WRAPPED_ENTRY = wrapExportedHandler(ENTRY);
} else if (typeof ENTRY === "function") {
WRAPPED_ENTRY = wrapWorkerEntrypoint(ENTRY);
}
export default WRAPPED_ENTRY;
+229
View File
@@ -0,0 +1,229 @@
import {
__facade_invoke__,
__facade_register__,
__facade_registerInternal__,
Awaitable,
Dispatcher,
IncomingRequest,
Middleware,
} from "./common";
export { __facade_register__, __facade_registerInternal__ };
// Miniflare 2's `EventTarget` follows the spec and doesn't allow exceptions to
// be caught by `dispatchEvent`. Instead it has a custom `ThrowingEventTarget`
// class that rethrows errors from event listeners in `dispatchEvent`.
// We'd like errors to be propagated to the top-level `addEventListener`, so
// we'd like to use `ThrowingEventTarget`. Unfortunately, `ThrowingEventTarget`
// isn't exposed on the global scope, but `WorkerGlobalScope` (which extends
// `ThrowingEventTarget`) is. Therefore, we get at it in this nasty way.
let __FACADE_EVENT_TARGET__: EventTarget;
if ((globalThis as any).MINIFLARE) {
__FACADE_EVENT_TARGET__ = new (Object.getPrototypeOf(WorkerGlobalScope))();
} else {
__FACADE_EVENT_TARGET__ = new EventTarget();
}
function __facade_isSpecialEvent__(
type: string
): type is "fetch" | "scheduled" {
return type === "fetch" || type === "scheduled";
}
const __facade__originalAddEventListener__ = globalThis.addEventListener;
const __facade__originalRemoveEventListener__ = globalThis.removeEventListener;
const __facade__originalDispatchEvent__ = globalThis.dispatchEvent;
globalThis.addEventListener = function (type, listener, options) {
if (__facade_isSpecialEvent__(type)) {
__FACADE_EVENT_TARGET__.addEventListener(
type,
listener as EventListenerOrEventListenerObject,
options
);
} else {
__facade__originalAddEventListener__(type, listener, options);
}
};
globalThis.removeEventListener = function (type, listener, options) {
if (__facade_isSpecialEvent__(type)) {
__FACADE_EVENT_TARGET__.removeEventListener(
type,
listener as EventListenerOrEventListenerObject,
options
);
} else {
__facade__originalRemoveEventListener__(type, listener, options);
}
};
globalThis.dispatchEvent = function (event) {
if (__facade_isSpecialEvent__(event.type)) {
return __FACADE_EVENT_TARGET__.dispatchEvent(event);
} else {
return __facade__originalDispatchEvent__(event);
}
};
declare global {
var addMiddleware: typeof __facade_register__;
var addMiddlewareInternal: typeof __facade_registerInternal__;
}
globalThis.addMiddleware = __facade_register__;
globalThis.addMiddlewareInternal = __facade_registerInternal__;
const __facade_waitUntil__ = Symbol("__facade_waitUntil__");
const __facade_response__ = Symbol("__facade_response__");
const __facade_dispatched__ = Symbol("__facade_dispatched__");
class __Facade_ExtendableEvent__ extends Event {
[__facade_waitUntil__]: Awaitable<unknown>[] = [];
waitUntil(promise: Awaitable<any>) {
if (!(this instanceof __Facade_ExtendableEvent__)) {
throw new TypeError("Illegal invocation");
}
this[__facade_waitUntil__].push(promise);
}
}
interface FetchEventInit extends EventInit {
request: Request;
passThroughOnException: FetchEvent["passThroughOnException"];
}
class __Facade_FetchEvent__ extends __Facade_ExtendableEvent__ {
#request: Request;
#passThroughOnException: FetchEvent["passThroughOnException"];
[__facade_response__]?: Awaitable<Response>;
[__facade_dispatched__] = false;
constructor(type: "fetch", init: FetchEventInit) {
super(type);
this.#request = init.request;
this.#passThroughOnException = init.passThroughOnException;
}
get request() {
return this.#request;
}
respondWith(response: Awaitable<Response>) {
if (!(this instanceof __Facade_FetchEvent__)) {
throw new TypeError("Illegal invocation");
}
if (this[__facade_response__] !== undefined) {
throw new DOMException(
"FetchEvent.respondWith() has already been called; it can only be called once.",
"InvalidStateError"
);
}
if (this[__facade_dispatched__]) {
throw new DOMException(
"Too late to call FetchEvent.respondWith(). It must be called synchronously in the event handler.",
"InvalidStateError"
);
}
this.stopImmediatePropagation();
this[__facade_response__] = response;
}
passThroughOnException() {
if (!(this instanceof __Facade_FetchEvent__)) {
throw new TypeError("Illegal invocation");
}
// Need to call native method immediately in case uncaught error thrown
this.#passThroughOnException();
}
}
interface ScheduledEventInit extends EventInit {
scheduledTime: number;
cron: string;
noRetry: ScheduledEvent["noRetry"];
}
class __Facade_ScheduledEvent__ extends __Facade_ExtendableEvent__ {
#scheduledTime: number;
#cron: string;
#noRetry: ScheduledEvent["noRetry"];
constructor(type: "scheduled", init: ScheduledEventInit) {
super(type);
this.#scheduledTime = init.scheduledTime;
this.#cron = init.cron;
this.#noRetry = init.noRetry;
}
get scheduledTime() {
return this.#scheduledTime;
}
get cron() {
return this.#cron;
}
noRetry() {
if (!(this instanceof __Facade_ScheduledEvent__)) {
throw new TypeError("Illegal invocation");
}
// Need to call native method immediately in case uncaught error thrown
this.#noRetry();
}
}
__facade__originalAddEventListener__("fetch", (event) => {
const ctx: ExecutionContext = {
waitUntil: event.waitUntil.bind(event),
passThroughOnException: event.passThroughOnException.bind(event),
};
const __facade_sw_dispatch__: Dispatcher = function (type, init) {
if (type === "scheduled") {
const facadeEvent = new __Facade_ScheduledEvent__("scheduled", {
scheduledTime: Date.now(),
cron: init.cron ?? "",
noRetry() {},
});
__FACADE_EVENT_TARGET__.dispatchEvent(facadeEvent);
event.waitUntil(Promise.all(facadeEvent[__facade_waitUntil__]));
}
};
const __facade_sw_fetch__: Middleware = function (request, _env, ctx) {
const facadeEvent = new __Facade_FetchEvent__("fetch", {
request,
passThroughOnException: ctx.passThroughOnException,
});
__FACADE_EVENT_TARGET__.dispatchEvent(facadeEvent);
facadeEvent[__facade_dispatched__] = true;
event.waitUntil(Promise.all(facadeEvent[__facade_waitUntil__]));
const response = facadeEvent[__facade_response__];
if (response === undefined) {
throw new Error("No response!"); // TODO: proper error message
}
return response;
};
event.respondWith(
__facade_invoke__(
event.request as IncomingRequest,
globalThis,
ctx,
__facade_sw_dispatch__,
__facade_sw_fetch__
)
);
});
__facade__originalAddEventListener__("scheduled", (event) => {
const facadeEvent = new __Facade_ScheduledEvent__("scheduled", {
scheduledTime: event.scheduledTime,
cron: event.cron,
noRetry: event.noRetry.bind(event),
});
__FACADE_EVENT_TARGET__.dispatchEvent(facadeEvent);
event.waitUntil(Promise.all(facadeEvent[__facade_waitUntil__]));
});
@@ -0,0 +1,18 @@
import type { Middleware } from "./common";
const drainBody: Middleware = async (request, env, _ctx, middlewareCtx) => {
try {
return await middlewareCtx.next(request, env);
} finally {
try {
if (request.body !== null && !request.bodyUsed) {
const reader = request.body.getReader();
while (!(await reader.read()).done) {}
}
} catch (e) {
console.error("Failed to drain the unused request body.", e);
}
}
};
export default drainBody;
@@ -0,0 +1,32 @@
import type { Middleware } from "./common";
interface JsonError {
message?: string;
name?: string;
stack?: string;
cause?: JsonError;
}
function reduceError(e: any): JsonError {
return {
name: e?.name,
message: e?.message ?? String(e),
stack: e?.stack,
cause: e?.cause === undefined ? undefined : reduceError(e.cause),
};
}
// See comment in `bundle.ts` for details on why this is needed
const jsonError: Middleware = async (request, env, _ctx, middlewareCtx) => {
try {
return await middlewareCtx.next(request, env);
} catch (e: any) {
const error = reduceError(e);
return Response.json(error, {
status: 500,
headers: { "MF-Experimental-Error-Stack": "true" },
});
}
};
export default jsonError;
@@ -0,0 +1,3 @@
declare module "config:middleware/patch-console-prefix" {
export const prefix: string;
}
@@ -0,0 +1,21 @@
/// <reference path="middleware-patch-console-prefix.d.ts"/>
import { prefix } from "config:middleware/patch-console-prefix";
import type { Middleware } from "./common";
// Directly patch console methods to add worker prefix.
// We capture the original method once and replace with a wrapper.
// This approach allows third-party code to wrap console methods
(["log", "debug", "info"] as const).forEach((method) => {
globalThis.console[method] = new Proxy(globalThis.console[method], {
apply(target, thisArg, argumentsList) {
return target.apply(thisArg, [prefix, ...argumentsList]);
},
});
});
const passthrough: Middleware = (request, env, _ctx, middlewareCtx) => {
return middlewareCtx.next(request, env);
};
export default passthrough;
+40
View File
@@ -0,0 +1,40 @@
import type { Middleware } from "./common";
// A middleware has to be a function of type Middleware
const prettyError: Middleware = async (request, env, _ctx, middlewareCtx) => {
try {
const response = await middlewareCtx.next(request, env);
return response;
} catch (e: any) {
const html = `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Error 🚨</title>
<style>
pre {
margin: 16px auto;
max-width: 600px;
background-color: #eeeeee;
border-radius: 4px;
padding: 16px;
}
</style>
</head>
<body>
<pre>${e.stack}</pre>
</body>
</html>
`;
return new Response(html, {
status: 500,
headers: { "Content-Type": "text/html;charset=utf-8" },
});
}
};
export default prettyError;
+29
View File
@@ -0,0 +1,29 @@
import type { Middleware } from "./common";
// A middleware has to be a function of type Middleware
const scheduled: Middleware = async (request, env, _ctx, middlewareCtx) => {
const url = new URL(request.url);
if (url.pathname === "/__scheduled") {
const cron = url.searchParams.get("cron") ?? "";
await middlewareCtx.dispatch("scheduled", { cron });
return new Response("Ran scheduled event");
}
const resp = await middlewareCtx.next(request, env);
// If you open the `/__scheduled` page in a browser, the browser will automatically make a request to `/favicon.ico`.
// For scheduled Workers _without_ a fetch handler, this will result in a 500 response that clutters the log with unhelpful error messages.
// To avoid this, inject a 404 response to favicon.ico loads on the `/__scheduled` page
if (
request.headers.get("referer")?.endsWith("/__scheduled") &&
url.pathname === "/favicon.ico" &&
resp.status === 500
) {
return new Response(null, { status: 404 });
}
return resp;
};
export default scheduled;
+4
View File
@@ -0,0 +1,4 @@
// `esbuild` doesn't support returning `watch*` options from `onStart()`
// plugin callbacks. Instead, we define an empty virtual module that is
// imported by this injected file. Importing the module registers watchers.
import "wrangler:modules-watch";
+17
View File
@@ -0,0 +1,17 @@
/**
* Welcome to Cloudflare Workers! This is your first scheduled worker.
*
* - Run `wrangler dev` in your terminal to start a development server
* - Run `curl "http://localhost:8787/cdn-cgi/handler/scheduled"` to trigger the scheduled event
* - Go back to the console to see what your worker has logged
* - Update the Cron trigger in wrangler.toml (see https://developers.cloudflare.com/workers/configuration/cron-triggers/)
* - Run `wrangler publish --name my-worker` to publish your worker
*
* Learn more at https://developers.cloudflare.com/workers/runtime-apis/scheduled-event/
*/
export default {
async scheduled(controller, env, ctx) {
console.log(`Hello World!`);
},
};
+32
View File
@@ -0,0 +1,32 @@
/**
* Welcome to Cloudflare Workers! This is your first scheduled worker.
*
* - Run `wrangler dev` in your terminal to start a development server
* - Run `curl "http://localhost:8787/cdn-cgi/handler/scheduled"` to trigger the scheduled event
* - Go back to the console to see what your worker has logged
* - Update the Cron trigger in wrangler.toml (see https://developers.cloudflare.com/workers/configuration/cron-triggers/)
* - Run `wrangler deploy --name my-worker` to deploy your worker
*
* Learn more at https://developers.cloudflare.com/workers/runtime-apis/scheduled-event/
*/
export interface Env {
// Example binding to KV. Learn more at https://developers.cloudflare.com/workers/runtime-apis/kv/
// MY_KV_NAMESPACE: KVNamespace;
//
// Example binding to Durable Object. Learn more at https://developers.cloudflare.com/workers/runtime-apis/durable-objects/
// MY_DURABLE_OBJECT: DurableObjectNamespace;
//
// Example binding to R2. Learn more at https://developers.cloudflare.com/workers/runtime-apis/r2/
// MY_BUCKET: R2Bucket;
}
export default {
async scheduled(
controller: ScheduledController,
env: Env,
ctx: ExecutionContext
): Promise<void> {
console.log(`Hello World!`);
},
};
+15
View File
@@ -0,0 +1,15 @@
/**
* Welcome to Cloudflare Workers! This is your first worker.
*
* - Run `npx wrangler dev src/index.js` in your terminal to start a development server
* - Open a browser tab at http://localhost:8787/ to see your worker in action
* - Run `npx wrangler publish src/index.js --name my-worker` to publish your worker
*
* Learn more at https://developers.cloudflare.com/workers/
*/
export default {
async fetch(request, env, ctx) {
return new Response("Hello World!");
},
};
+33
View File
@@ -0,0 +1,33 @@
/**
* Welcome to Cloudflare Workers! This is your first worker.
*
* - Run `wrangler dev src/index.ts` in your terminal to start a development server
* - Open a browser tab at http://localhost:8787/ to see your worker in action
* - Run `wrangler deploy src/index.ts --name my-worker` to deploy your worker
*
* Learn more at https://developers.cloudflare.com/workers/
*/
export interface Env {
// Example binding to KV. Learn more at https://developers.cloudflare.com/workers/runtime-apis/kv/
// MY_KV_NAMESPACE: KVNamespace;
//
// Example binding to Durable Object. Learn more at https://developers.cloudflare.com/workers/runtime-apis/durable-objects/
// MY_DURABLE_OBJECT: DurableObjectNamespace;
//
// Example binding to R2. Learn more at https://developers.cloudflare.com/workers/runtime-apis/r2/
// MY_BUCKET: R2Bucket;
//
// Example binding to a Service. Learn more at https://developers.cloudflare.com/workers/runtime-apis/service-bindings/
// MY_SERVICE: Fetcher;
}
export default {
async fetch(
request: Request,
env: Env,
ctx: ExecutionContext
): Promise<Response> {
return new Response("Hello World!");
},
};
+10
View File
@@ -0,0 +1,10 @@
export default {
fetch() {
return new Response("Not found", {
status: 404,
headers: {
"Content-Type": "text/html",
},
});
},
};
+31
View File
@@ -0,0 +1,31 @@
import worker from "__ENTRY_POINT__";
import { isRoutingRuleMatch } from "./pages-dev-util";
export * from "__ENTRY_POINT__";
// @ts-expect-error -- routes are injected
const routes = __ROUTES__;
export default <ExportedHandler<{ ASSETS: Fetcher }>>{
fetch(request, env, context) {
const { pathname } = new URL(request.url);
for (const exclude of routes.exclude) {
if (isRoutingRuleMatch(pathname, exclude)) {
return env.ASSETS.fetch(request);
}
}
for (const include of routes.include) {
if (isRoutingRuleMatch(pathname, include)) {
const workerAsHandler = worker as ExportedHandler;
if (workerAsHandler.fetch === undefined) {
throw new TypeError("Entry point missing `fetch` handler");
}
return workerAsHandler.fetch(request, env, context);
}
}
return env.ASSETS.fetch(request);
},
};
+55
View File
@@ -0,0 +1,55 @@
/**
* @param pathname A pathname string, such as `/foo` or `/foo/bar`
* @param routingRule The routing rule, such as `/foo/*`
* @returns True if pathname matches the routing rule
*
* / -> /
* /* -> /*
* /foo -> /foo
* /foo* -> /foo, /foo-bar, /foo/*
* /foo/* -> /foo, /foo/bar
*/
export function isRoutingRuleMatch(
pathname: string,
routingRule: string
): boolean {
// sanity checks
if (!pathname) {
throw new Error("Pathname is undefined.");
}
if (!routingRule) {
throw new Error("Routing rule is undefined.");
}
const ruleRegExp = transformRoutingRuleToRegExp(routingRule);
return pathname.match(ruleRegExp) !== null;
}
function transformRoutingRuleToRegExp(rule: string): RegExp {
let transformedRule;
if (rule === "/" || rule === "/*") {
transformedRule = rule;
} else if (rule.endsWith("/*")) {
// make `/*` an optional group so we can match both /foo/* and /foo
// /foo/* => /foo(/*)?
transformedRule = `${rule.substring(0, rule.length - 2)}(/*)?`;
} else if (rule.endsWith("/")) {
// make `/` an optional group so we can match both /foo/ and /foo
// /foo/ => /foo(/)?
transformedRule = `${rule.substring(0, rule.length - 1)}(/)?`;
} else if (rule.endsWith("*")) {
transformedRule = rule;
} else {
transformedRule = `${rule}(/)?`;
}
// /foo* => /foo.* => ^/foo.*$
// /*.* => /*\.* => /.*\..* => ^/.*\..*$
transformedRule = `^${transformedRule
.replaceAll(/\./g, "\\.")
.replaceAll(/\*/g, ".*")}$`;
// ^/foo.*$ => /^\/foo.*$/
return new RegExp(transformedRule);
}
+9
View File
@@ -0,0 +1,9 @@
// This Worker is used as a default when no Pages Functions are present.
// It proxies the request directly on to the asset server binding.
export default <ExportedHandler<{ ASSETS: Fetcher }>>{
async fetch(request, env, context) {
const response = await env.ASSETS.fetch(request.url, request);
return new Response(response.body, response);
},
};
+190
View File
@@ -0,0 +1,190 @@
import { match } from "path-to-regexp";
//note: this explicitly does not include the * character, as pages requires this
const escapeRegex = /[.+?^${}()|[\]\\]/g;
type HTTPMethod =
| "HEAD"
| "OPTIONS"
| "GET"
| "POST"
| "PUT"
| "PATCH"
| "DELETE";
/* TODO: Grab these from @cloudflare/workers-types instead */
type Params<P extends string = string> = Record<P, string | string[]>;
type EventContext<Env, P extends string, Data> = {
request: Request;
functionPath: string;
waitUntil: (promise: Promise<unknown>) => void;
passThroughOnException: () => void;
next: (input?: Request | string, init?: RequestInit) => Promise<Response>;
env: Env & { ASSETS: { fetch: typeof fetch } };
params: Params<P>;
data: Data;
};
type EventPluginContext<Env, P extends string, Data, PluginArgs> = {
request: Request;
functionPath: string;
waitUntil: (promise: Promise<unknown>) => void;
passThroughOnException: () => void;
next: (input?: Request | string, init?: RequestInit) => Promise<Response>;
env: Env & { ASSETS: { fetch: typeof fetch } };
params: Params<P>;
data: Data;
pluginArgs: PluginArgs;
};
declare type PagesFunction<
Env = unknown,
P extends string = string,
Data extends Record<string, unknown> = Record<string, unknown>,
> = (context: EventContext<Env, P, Data>) => Response | Promise<Response>;
declare type PagesPluginFunction<
Env = unknown,
P extends string = string,
Data extends Record<string, unknown> = Record<string, unknown>,
PluginArgs = unknown,
> = (
context: EventPluginContext<Env, P, Data, PluginArgs>
) => Response | Promise<Response>;
/* end @cloudflare/workers-types */
type RouteHandler = {
routePath: string;
mountPath: string;
method?: HTTPMethod;
modules: PagesFunction[];
middlewares: PagesFunction[];
};
// inject `routes` via ESBuild
declare const routes: RouteHandler[];
function* executeRequest(request: Request, relativePathname: string) {
// First, iterate through the routes (backwards) and execute "middlewares" on partial route matches
for (const route of [...routes].reverse()) {
if (route.method && route.method !== request.method) {
continue;
}
// replaces with "\\$&", this prepends a backslash to the matched string, e.g. "[" becomes "\["
const routeMatcher = match(route.routePath.replace(escapeRegex, "\\$&"), {
end: false,
});
const mountMatcher = match(route.mountPath.replace(escapeRegex, "\\$&"), {
end: false,
});
const matchResult = routeMatcher(relativePathname);
const mountMatchResult = mountMatcher(relativePathname);
if (matchResult && mountMatchResult) {
for (const handler of route.middlewares.flat()) {
yield {
handler,
params: matchResult.params as Params,
path: mountMatchResult.path,
};
}
}
}
// Then look for the first exact route match and execute its "modules"
for (const route of routes) {
if (route.method && route.method !== request.method) {
continue;
}
const routeMatcher = match(route.routePath.replace(escapeRegex, "\\$&"), {
end: true,
});
const mountMatcher = match(route.mountPath.replace(escapeRegex, "\\$&"), {
end: false,
});
const matchResult = routeMatcher(relativePathname);
const mountMatchResult = mountMatcher(relativePathname);
if (matchResult && mountMatchResult && route.modules.length) {
for (const handler of route.modules.flat()) {
yield {
handler,
params: matchResult.params as Params,
path: matchResult.path,
};
}
break;
}
}
}
export default function (pluginArgs: unknown) {
const onRequest: PagesPluginFunction = async (workerContext) => {
let { request } = workerContext;
const { env, next } = workerContext;
let { data } = workerContext;
const url = new URL(request.url);
// TODO: Replace this with something actually legible.
const relativePathname = `/${
url.pathname.replace(workerContext.functionPath, "") || ""
}`.replace(/^\/\//, "/");
const handlerIterator = executeRequest(request, relativePathname);
const pluginNext = async (input?: RequestInfo, init?: RequestInit) => {
if (input !== undefined) {
let url = input;
if (typeof input === "string") {
url = new URL(input, request.url).toString();
}
request = new Request(url, init);
}
const result = handlerIterator.next();
// Note we can't use `!result.done` because this doesn't narrow to the correct type
if (result.done === false) {
const { handler, params, path } = result.value;
const context = {
request: new Request(request.clone()),
functionPath: workerContext.functionPath + path,
next: pluginNext,
params,
get data() {
return data;
},
set data(value) {
if (typeof value !== "object" || value === null) {
throw new Error("context.data must be an object");
}
// user has overridden context.data, so we need to merge it with the existing data
data = value;
},
pluginArgs,
env,
waitUntil: workerContext.waitUntil.bind(workerContext),
passThroughOnException:
workerContext.passThroughOnException.bind(workerContext),
};
const response = await handler(context);
return cloneResponse(response);
} else {
return next(request);
}
};
return pluginNext();
};
return onRequest;
}
// This makes a Response mutable
const cloneResponse = (response: Response) =>
// https://fetch.spec.whatwg.org/#null-body-status
new Response(
[101, 204, 205, 304].includes(response.status) ? null : response.body,
response
);
+198
View File
@@ -0,0 +1,198 @@
import { match } from "path-to-regexp";
//note: this explicitly does not include the * character, as pages requires this
const escapeRegex = /[.+?^${}()|[\]\\]/g;
type HTTPMethod =
| "HEAD"
| "OPTIONS"
| "GET"
| "POST"
| "PUT"
| "PATCH"
| "DELETE";
/* TODO: Grab these from @cloudflare/workers-types instead */
type Params<P extends string = string> = Record<P, string | string[]>;
type EventContext<Env, P extends string, Data> = {
request: Request;
functionPath: string;
waitUntil: (promise: Promise<unknown>) => void;
passThroughOnException: () => void;
next: (input?: Request | string, init?: RequestInit) => Promise<Response>;
env: Env & { ASSETS: { fetch: typeof fetch } };
params: Params<P>;
data: Data;
};
declare type PagesFunction<
Env = unknown,
P extends string = string,
Data extends Record<string, unknown> = Record<string, unknown>,
> = (context: EventContext<Env, P, Data>) => Response | Promise<Response>;
/* end @cloudflare/workers-types */
type RouteHandler = {
routePath: string;
mountPath: string;
method?: HTTPMethod;
modules: PagesFunction[];
middlewares: PagesFunction[];
};
// inject `routes` via ESBuild
declare const routes: RouteHandler[];
// define `__FALLBACK_SERVICE__` via ESBuild
declare const __FALLBACK_SERVICE__: string;
// expect an ASSETS fetcher binding pointing to the asset-server stage
type FetchEnv = {
[name: string]: { fetch: typeof fetch };
ASSETS: { fetch: typeof fetch };
};
type WorkerContext = {
waitUntil: (promise: Promise<unknown>) => void;
passThroughOnException: () => void;
};
function* executeRequest(request: Request) {
const requestPath = new URL(request.url).pathname;
// First, iterate through the routes (backwards) and execute "middlewares" on partial route matches
for (const route of [...routes].reverse()) {
if (route.method && route.method !== request.method) {
continue;
}
// replaces with "\\$&", this prepends a backslash to the matched string, e.g. "[" becomes "\["
const routeMatcher = match(route.routePath.replace(escapeRegex, "\\$&"), {
end: false,
});
const mountMatcher = match(route.mountPath.replace(escapeRegex, "\\$&"), {
end: false,
});
const matchResult = routeMatcher(requestPath);
const mountMatchResult = mountMatcher(requestPath);
if (matchResult && mountMatchResult) {
for (const handler of route.middlewares.flat()) {
yield {
handler,
params: matchResult.params as Params,
path: mountMatchResult.path,
};
}
}
}
// Then look for the first exact route match and execute its "modules"
for (const route of routes) {
if (route.method && route.method !== request.method) {
continue;
}
const routeMatcher = match(route.routePath.replace(escapeRegex, "\\$&"), {
end: true,
});
const mountMatcher = match(route.mountPath.replace(escapeRegex, "\\$&"), {
end: false,
});
const matchResult = routeMatcher(requestPath);
const mountMatchResult = mountMatcher(requestPath);
if (matchResult && mountMatchResult && route.modules.length) {
for (const handler of route.modules.flat()) {
yield {
handler,
params: matchResult.params as Params,
path: matchResult.path,
};
}
break;
}
}
}
export default {
async fetch(
originalRequest: Request,
env: FetchEnv,
workerContext: WorkerContext
) {
let request = originalRequest;
const handlerIterator = executeRequest(request);
let data = {}; // arbitrary data the user can set between functions
let isFailOpen = false;
const next = async (input?: RequestInfo, init?: RequestInit) => {
if (input !== undefined) {
let url = input;
if (typeof input === "string") {
url = new URL(input, request.url).toString();
}
request = new Request(url, init);
}
const result = handlerIterator.next();
// Note we can't use `!result.done` because this doesn't narrow to the correct type
if (result.done === false) {
const { handler, params, path } = result.value;
const context = {
request: new Request(request.clone()),
functionPath: path,
next,
params,
get data() {
return data;
},
set data(value) {
if (typeof value !== "object" || value === null) {
throw new Error("context.data must be an object");
}
// user has overridden context.data, so we need to merge it with the existing data
data = value;
},
env,
waitUntil: workerContext.waitUntil.bind(workerContext),
passThroughOnException: () => {
isFailOpen = true;
},
};
const response = await handler(context);
if (!(response instanceof Response)) {
throw new Error("Your Pages function should return a Response");
}
return cloneResponse(response);
} else if (__FALLBACK_SERVICE__) {
// There are no more handlers so finish with the fallback service (`env.ASSETS.fetch` in Pages' case)
const response = await env[__FALLBACK_SERVICE__].fetch(request);
return cloneResponse(response);
} else {
// There was not fallback service so actually make the request to the origin.
const response = await fetch(request);
return cloneResponse(response);
}
};
try {
return await next();
} catch (error) {
if (isFailOpen) {
const response = await env[__FALLBACK_SERVICE__].fetch(request);
return cloneResponse(response);
}
throw error;
}
},
};
// This makes a Response mutable
const cloneResponse = (response: Response) =>
// https://fetch.spec.whatwg.org/#null-body-status
new Response(
[101, 204, 205, 304].includes(response.status) ? null : response.body,
response
);
+143
View File
@@ -0,0 +1,143 @@
import { newWorkersRpcResponse } from "capnweb";
import { EmailMessage } from "cloudflare:email";
interface Env extends Record<string, unknown> {}
class BindingNotFoundError extends Error {
constructor(name?: string) {
super(`Binding ${name ? `"${name}"` : ""} not found`);
}
}
/**
* For most bindings, we expose them as
* - RPC stubs directly to capnweb, or
* - HTTP based fetchers
* However, there are some special cases:
* - SendEmail bindings need to take EmailMessage as their first parameter,
* which is not serialisable. As such, we reconstruct it before sending it
* on to the binding. See packages/miniflare/src/workers/email/email.worker.ts
* - Dispatch Namespace bindings have a synchronous .get() method. Since we
* can't emulate that over an async boundary, we mock it locally and _actually_
* perform the .get() remotely at the first appropriate async point. See
* packages/miniflare/src/workers/dispatch-namespace/dispatch-namespace.worker.ts
*
* getExposedJSRPCBinding() and getExposedFetcher() perform the logic for figuring out
* which binding is being accessed, dependending on the request. Note: Both have logic
* for dispatch namespaces, because dispatch namespaces can use both fetch or RPC depending
* on context.
*/
function getExposedJSRPCBinding(request: Request, env: Env) {
const url = new URL(request.url);
const bindingName = url.searchParams.get("MF-Binding");
if (!bindingName) {
throw new BindingNotFoundError();
}
const targetBinding = env[bindingName];
if (!targetBinding) {
throw new BindingNotFoundError(bindingName);
}
if (targetBinding.constructor.name === "SendEmail") {
return {
async send(e: any) {
// Check if this is an EmailMessage (has EmailMessage::raw property) or MessageBuilder
if ("EmailMessage::raw" in e) {
// EmailMessage API - reconstruct the EmailMessage object
const message = new EmailMessage(
e.from,
e.to,
e["EmailMessage::raw"]
);
return (targetBinding as SendEmail).send(message);
} else {
// MessageBuilder API - pass through directly as a plain object
return (targetBinding as SendEmail).send(e);
}
},
};
}
if (url.searchParams.has("MF-Dispatch-Namespace-Options")) {
const { name, args, options } = JSON.parse(
url.searchParams.get("MF-Dispatch-Namespace-Options")!
);
return (targetBinding as DispatchNamespace).get(name, args, options);
}
return targetBinding;
}
function getExposedFetcher(request: Request, env: Env) {
const bindingName = request.headers.get("MF-Binding");
if (!bindingName) {
throw new BindingNotFoundError();
}
const targetBinding = env[bindingName];
if (!targetBinding) {
throw new BindingNotFoundError(bindingName);
}
// Special case the Dispatch Namespace binding because it has a top-level synchronous .get() call
const dispatchNamespaceOptions = request.headers.get(
"MF-Dispatch-Namespace-Options"
);
if (dispatchNamespaceOptions) {
const { name, args, options } = JSON.parse(dispatchNamespaceOptions);
return (targetBinding as DispatchNamespace).get(name, args, options);
}
return targetBinding as Fetcher;
}
/**
* This Worker can proxy two types of remote binding:
* 1. "raw" bindings, where this Worker has been configured to pass through the raw
* fetch from a local workerd instance to the relevant binding
* 2. JSRPC bindings, where this Worker uses capnweb to proxy RPC
* communication in userland. This is always over a WebSocket connection
*/
function isJSRPCBinding(request: Request): boolean {
const url = new URL(request.url);
return request.headers.has("Upgrade") && url.searchParams.has("MF-Binding");
}
export default {
async fetch(request, env) {
try {
if (isJSRPCBinding(request)) {
return await newWorkersRpcResponse(
request,
getExposedJSRPCBinding(request, env)
);
} else {
const fetcher = getExposedFetcher(request, env);
const originalHeaders = new Headers();
for (const [name, value] of request.headers) {
if (name.startsWith("mf-header-")) {
originalHeaders.set(name.slice("mf-header-".length), value);
} else if (name === "upgrade") {
// The `Upgrade` header needs to be special-cased to prevent:
// TypeError: Worker tried to return a WebSocket in a response to a request which did not contain the header "Upgrade: websocket"
originalHeaders.set(name, value);
}
}
return await fetcher.fetch(
request.headers.get("MF-URL") ?? "http://example.com",
new Request(request, {
redirect: "manual",
headers: originalHeaders,
})
);
}
} catch (e) {
if (e instanceof BindingNotFoundError) {
return new Response(e.message, { status: 400 });
}
return new Response((e as Error).message, { status: 500 });
}
},
} satisfies ExportedHandler<Env>;
+4
View File
@@ -0,0 +1,4 @@
{
"main": "./ProxyServerWorker.ts",
"compatibility_date": "2025-04-28",
}
+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`
)
);
}
}
+11
View File
@@ -0,0 +1,11 @@
// @ts-nocheck `@types/node` should NOT be included
Buffer.from("test");
// @ts-expect-error `@types/jest` should NOT be included
test("test");
// `@cloudflare/workers-types` should be included
const _handler: ExportedHandler = {};
new HTMLRewriter();
export {};
+22
View File
@@ -0,0 +1,22 @@
{
"compilerOptions": {
"target": "es2021" /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */,
"lib": [
"es2021"
] /* Specify a set of bundled library declaration files that describe the target runtime environment. */,
"jsx": "react-jsx" /* Specify what JSX code is generated. */,
"module": "ESNext" /* Specify what module code is generated. */,
"moduleResolution": "Bundler" /* Specify how TypeScript looks up a file from a given module specifier. */,
"types": [
"@cloudflare/workers-types"
] /* Specify type package names to be included without being referenced in a source file. */,
"resolveJsonModule": true /* Enable importing .json files */,
"allowJs": true /* Allow JavaScript files to be a part of your program. Use the `checkJS` option to get errors from these files. */,
"noEmit": true /* Disable emitting files from a compilation. */,
"isolatedModules": true /* Ensure that each file can be safely transpiled without relying on other imports. */,
"allowSyntheticDefaultImports": true /* Allow 'import x from y' when a module doesn't have a default export. */,
"strict": true /* Enable all strict type-checking options. */,
"noUncheckedIndexedAccess": true,
"skipLibCheck": true /* Skip type checking all .d.ts files. */
}
}
+14
View File
@@ -0,0 +1,14 @@
{
"extends": "@cloudflare/workers-tsconfig/tsconfig.json",
"compilerOptions": {
"types": ["@cloudflare/workers-types"]
},
"include": ["**/*.ts"],
"exclude": [
"__tests__",
// Note: `startDevWorker` and `middleware` should also be included but some work is needed
// for that first (see: https://github.com/cloudflare/workers-sdk/issues/8303)
"startDevWorker",
"middleware"
]
}