Deploy.
This commit is contained in:
+124
@@ -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
@@ -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
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "@cloudflare/workers-tsconfig/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"types": ["node", "jest"]
|
||||
},
|
||||
"include": ["**/*.ts"],
|
||||
"exclude": []
|
||||
}
|
||||
+28
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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__]));
|
||||
});
|
||||
+18
@@ -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;
|
||||
+32
@@ -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;
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
declare module "config:middleware/patch-console-prefix" {
|
||||
export const prefix: string;
|
||||
}
|
||||
+21
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -0,0 +1,10 @@
|
||||
export default {
|
||||
fetch() {
|
||||
return new Response("Not found", {
|
||||
status: 404,
|
||||
headers: {
|
||||
"Content-Type": "text/html",
|
||||
},
|
||||
});
|
||||
},
|
||||
};
|
||||
+31
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"main": "./ProxyServerWorker.ts",
|
||||
"compatibility_date": "2025-04-28",
|
||||
}
|
||||
+713
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user