import events from "node:events"; import path from "node:path"; import getPort from "get-port"; import dedent from "ts-dedent"; import undici from "undici"; import { assert, beforeEach, describe, it, onTestFinished, vi } from "vitest"; import WebSocket from "ws"; import { CLOUDFLARE_ACCOUNT_ID } from "./helpers/account-id"; import { importWrangler, WranglerE2ETestHelper, } from "./helpers/e2e-wrangler-test"; import { waitFor, waitForLong } from "./helpers/wait-for"; import type { DevToolsEvent } from "../src/api"; const OPTIONS = [ { remote: false }, ...(CLOUDFLARE_ACCOUNT_ID ? [{ remote: true }] : []), ]; const { unstable_startWorker: startWorker } = await importWrangler(); function waitForMessageContaining(ws: WebSocket, value: string): Promise { return new Promise((resolve) => { ws.addEventListener("message", (event) => { assert(typeof event.data === "string"); if (event.data.includes(value)) { resolve(JSON.parse(event.data)); } }); }); } function collectMessagesContaining( ws: WebSocket, value: string, collection: T[] = [] ) { ws.addEventListener("message", (event) => { assert(typeof event.data === "string"); if (event.data.includes(value)) { collection.push(JSON.parse(event.data)); } }); return collection; } describe("DevEnv", { sequential: true }, () => { let helper: WranglerE2ETestHelper; beforeEach(() => { helper = new WranglerE2ETestHelper(); }); describe.each(OPTIONS)("(remote: $remote)", ({ remote }) => { it("ProxyWorker buffers requests while runtime reloads", async ({ expect, }) => { const script = dedent` export default { fetch() { return new Response("body:1"); } } `; await helper.seed({ "src/index.ts": script, }); const worker = await startWorker({ entrypoint: path.resolve(helper.tmpPath, "src/index.ts"), dev: { remote, server: { port: 0 }, inspector: false, }, }); onTestFinished(worker?.dispose); let res = await worker.fetch("http://dummy"); await expect(res.text()).resolves.toBe("body:1"); await helper.seed({ "src/index.ts": script.replace("body:1", "body:2"), }); await waitForLong(async () => { res = await worker.fetch("http://dummy"); expect(await res.text()).toBe("body:2"); }); }); it.skipIf(remote)( "InspectorProxyWorker discovery endpoints + devtools websocket connection", async ({ expect }) => { const script = dedent` export default { fetch() { console.log('Inside mock user worker'); return new Response("body:1"); } } `; await helper.seed({ "src/index.ts": script, }); const worker = await startWorker({ name: "test-worker", entrypoint: path.resolve(helper.tmpPath, "src/index.ts"), dev: { remote, server: { port: 0 }, inspector: { port: 0 }, }, }); onTestFinished(worker?.dispose); const inspectorUrl = await worker.inspectorUrl; assert(inspectorUrl, "missing inspectorUrl"); const res = await undici.fetch(`http://${inspectorUrl.host}/json`); await expect(res.json()).resolves.toBeInstanceOf(Array); assert(inspectorUrl, "missing inspectorUrl"); const ws = new WebSocket(inspectorUrl.href); const openPromise = events.once(ws, "open"); const consoleApiMessages: DevToolsEvent<"Runtime.consoleAPICalled">[] = collectMessagesContaining(ws, "Runtime.consoleAPICalled"); const executionContextCreatedPromise = waitForMessageContaining( ws, "Runtime.executionContextCreated" ); await openPromise; await worker.fetch("http://dummy"); await expect(executionContextCreatedPromise).resolves.toMatchObject({ method: "Runtime.executionContextCreated", params: { context: { id: expect.any(Number) }, }, }); await waitFor(() => { expect(consoleApiMessages).toContainMatchingObject({ method: "Runtime.consoleAPICalled", params: expect.objectContaining({ args: [{ type: "string", value: "Inside mock user worker" }], }), }); }); // Ensure execution contexts cleared on reload const executionContextClearedPromise = waitForMessageContaining( ws, "Runtime.executionContextsCleared" ); await helper.seed({ "src/index.ts": script.replace("body:1", "body:2"), }); await executionContextClearedPromise; } ); it.skipIf(remote)( "InspectorProxyWorker rejects unauthorised requests", async ({ expect }) => { await helper.seed({ "src/index.ts": dedent` export default { fetch() { return new Response("body:1"); } } `, }); const worker = await startWorker({ name: "test-worker", entrypoint: path.resolve(helper.tmpPath, "src/index.ts"), dev: { remote, server: { port: 0 }, inspector: { port: 0 }, }, }); onTestFinished(worker?.dispose); const inspectorUrl = await worker.inspectorUrl; assert(inspectorUrl); assert(inspectorUrl, "missing inspectorUrl"); let ws = new WebSocket(inspectorUrl.href, { setHost: false, headers: { Host: "example.com" }, }); let openPromise = events.once(ws, "open"); await expect(openPromise).rejects.toThrow("Unexpected server response"); // Check validates `Origin` header assert(inspectorUrl, "missing inspectorUrl"); ws = new WebSocket(inspectorUrl.href, { origin: "https://example.com", }); openPromise = events.once(ws, "open"); await expect(openPromise).rejects.toThrow("Unexpected server response"); ws.close(); } ); // Regression test for https://github.com/cloudflare/workers-sdk/issues/5297 // The runtime inspector can send messages larger than 1MB limit websocket message permitted by UserWorkers. // In the real-world, this is encountered when debugging large source files (source maps) // or inspecting a variable that serializes to a large string. // Connecting devtools directly to the inspector would work fine, but we proxy the inspector messages // through a worker (InspectorProxyWorker) which hits the limit (without the fix, compatibilityFlags:["increase_websocket_message_size"]) // By logging a large string we can verify that the inspector messages are being proxied successfully. it.skipIf(remote)( "InspectorProxyWorker can proxy messages > 1MB", async ({ expect }) => { vi.spyOn(console, "info").mockImplementation(() => {}); vi.spyOn(console, "log").mockImplementation(() => {}); const LARGE_STRING = "This is a large string" + "z".repeat(2 ** 20); const script = dedent` export default { fetch() { console.log("${LARGE_STRING}"); return new Response("body:1"); } } `; await helper.seed({ "src/index.ts": script, }); const worker = await startWorker({ name: "test-worker", entrypoint: path.resolve(helper.tmpPath, "src/index.ts"), dev: { remote, server: { port: 0 }, inspector: { port: 0 }, }, }); onTestFinished(worker?.dispose); const inspectorUrl = await worker.inspectorUrl; assert(inspectorUrl, "missing inspectorUrl"); const ws = new WebSocket(inspectorUrl.href); const consoleApiMessages: DevToolsEvent<"Runtime.consoleAPICalled">[] = collectMessagesContaining(ws, "Runtime.consoleAPICalled"); await worker.fetch("http://dummy"); await waitFor(() => { expect(consoleApiMessages).toContainMatchingObject({ method: "Runtime.consoleAPICalled", params: expect.objectContaining({ args: [{ type: "string", value: LARGE_STRING }], }), }); }); } ); it("config.dev.{server,inspector} changes, restart the server instance", async ({ expect, }) => { await helper.seed({ "src/index.ts": dedent` export default { fetch() { return new Response("body:1"); } } `, }); const worker = await startWorker({ name: "test-worker", entrypoint: path.resolve(helper.tmpPath, "src/index.ts"), dev: { remote, server: { port: await getPort() }, inspector: false, }, }); onTestFinished(worker?.dispose); let res = await worker.fetch("http://dummy"); await expect(res.text()).resolves.toBe("body:1"); const oldPort = worker.config.dev?.server?.port; let undiciRes = await undici.fetch(`http://127.0.0.1:${oldPort}`); await expect(undiciRes.text()).resolves.toBe("body:1"); await worker.patchConfig({ dev: { ...worker.config.dev, remote, server: { port: await getPort() /* new port */ }, inspector: false, }, }); const newPort = worker.config.dev?.server?.port; res = await worker.fetch("http://dummy"); await expect(res.text()).resolves.toBe("body:1"); undiciRes = await undici.fetch(`http://127.0.0.1:${newPort}`); await expect(undiciRes.text()).resolves.toBe("body:1"); await expect(undici.fetch(`http://127.0.0.1:${oldPort}`)).rejects.toThrow( "fetch failed" ); }); it("liveReload", async ({ expect }) => { await helper.seed({ "src/index.ts": dedent` export default { fetch() { return new Response("body:1", { headers: { 'Content-Type': 'text/html' } }); } } `, }); const worker = await startWorker({ name: "test-worker", entrypoint: path.resolve(helper.tmpPath, "src/index.ts"), dev: { remote, liveReload: true, server: { port: 0 }, inspector: false, }, }); onTestFinished(worker?.dispose); const scriptRegex = / `); await helper.seed({ "src/index.ts": dedent` export default { fetch() { return new Response("body:2"); } } `, }); // In remote mode the edge needs time to propagate the new bundle, // so use the longer polling timeout. await waitForLong(async () => { // test liveReload does nothing when the response Content-Type is not html res = await worker.fetch("http://dummy"); resText = await res.text(); expect(resText).toBe("body:2"); }); await helper.seed({ "src/index.ts": dedent` export default { fetch() { return new Response("body:3", { headers: { 'Content-Type': 'text/html' } }); } } `, }); await worker.patchConfig({ dev: { ...worker.config.dev, liveReload: false, }, }); await waitForLong(async () => { // test liveReload: false does nothing even when the response Content-Type is html res = await worker.fetch("http://dummy"); resText = await res.text(); expect(resText).toBe("body:3"); }); }); }); describe("DevEnv (local-only)", () => { it("User worker exception", async ({ expect }) => { await helper.seed({ "src/index.ts": dedent` export default { fetch() { throw new Error('Boom!'); } } `, }); const worker = await startWorker({ name: "test-worker", entrypoint: path.resolve(helper.tmpPath, "src/index.ts"), dev: { server: { port: 0 }, inspector: false, }, }); onTestFinished(worker?.dispose); await expect(worker.fetch("http://dummy")).rejects.toThrow("Boom!"); await helper.seed({ "src/index.ts": dedent` export default { fetch() { throw new Error('Boom 2!'); } } `, }); await waitFor(async () => { await expect(worker.fetch("http://dummy")).rejects.toThrow("Boom 2!"); }); // test eyeball requests receive the pretty error page await helper.seed({ "src/index.ts": dedent` export default { fetch() { const e = new Error('Boom 3!'); // this is how errors are serialised after they are caught by wrangler/miniflare3 middlewares const error = { name: e.name, message: e.message, stack: e.stack }; return Response.json(error, { status: 500, headers: { "MF-Experimental-Error-Stack": "true" }, }); } } `, }); await waitFor(async () => { const undiciRes = await undici.fetch(await worker.url, { headers: { Accept: "text/html" }, }); await expect(undiciRes.text()).resolves.toEqual( expect.stringContaining(`Boom 3!`) // pretty error page html snippet ); }); // test further changes that fix the code await helper.seed({ "src/index.ts": dedent` export default { fetch() { return new Response("body:3"); } } `, }); await waitFor(async () => { const res = await worker.fetch("http://dummy"); await expect(res.text()).resolves.toBe("body:3"); }); }); it("origin override takes effect in the UserWorker", async ({ expect }) => { await helper.seed({ "src/index.ts": dedent` export default { fetch(request) { return new Response("URL: " + request.url); } } `, }); const worker = await startWorker({ name: "test-worker", entrypoint: path.resolve(helper.tmpPath, "src/index.ts"), dev: { origin: { hostname: "www.google.com", }, server: { port: 0 }, inspector: false, }, }); onTestFinished(worker?.dispose); let res = await worker.fetch("http://dummy/test/path/1"); await expect(res.text()).resolves.toBe( `URL: http://www.google.com/test/path/1` ); await worker.patchConfig({ dev: { ...worker.config.dev, origin: { secure: true, hostname: "mybank.co.uk", }, }, }); res = await worker.fetch("http://dummy/test/path/2"); await expect(res.text()).resolves.toBe( "URL: https://mybank.co.uk/test/path/2" ); }); it("inflight requests are retried during UserWorker reloads", async ({ expect, }) => { // to simulate inflight requests failing during UserWorker reloads, // we will use a UserWorker with a longish `await setTimeout(...)` // so that we can guarantee the race condition is hit when workerd is eventually terminated // this does not apply to remote workers as they are not terminated during reloads const script = dedent` export default { async fetch(request) { const url = new URL(request.url); if (url.pathname === '/long') { await new Promise(r => setTimeout(r, 30_000)); } return new Response("UserWorker:1"); } } `; await helper.seed({ "src/index.ts": script, }); const worker = await startWorker({ name: "test-worker", entrypoint: path.resolve(helper.tmpPath, "src/index.ts"), dev: { server: { port: 0 }, inspector: false, }, }); onTestFinished(worker?.dispose); let res = await worker.fetch("http://dummy/short"); await expect(res.text()).resolves.toBe("UserWorker:1"); const inflightDuringReloads = worker.fetch("http://dummy/long"); // NOTE: no await // this will cause workerd for UserWorker:1 to terminate (eventually, but soon) await helper.seed({ "src/index.ts": script.replace("UserWorker:1", "UserWorker:2"), }); await waitFor(async () => { res = await worker.fetch("http://dummy/short"); await expect(res.text()).resolves.toBe("UserWorker:2"); }); // this will cause workerd for UserWorker:2 to terminate (eventually, but soon) await helper.seed({ "src/index.ts": script .replace("UserWorker:1", "UserWorker:3") // change response so it can be identified .replace("30_000", "0"), // remove the long wait as we won't reload this UserWorker }); res = await inflightDuringReloads; await expect(res.text()).resolves.toBe("UserWorker:3"); }); it("vars from .env (next to config file) override vars from Wrangler config file", async ({ expect, }) => { await helper.seed({ "src/index.ts": dedent` export default { fetch(request, env) { return Response.json(env); } } `, "wrangler.jsonc": JSON.stringify({ vars: { WRANGLER_ENV_VAR_0: "default-0", WRANGLER_ENV_VAR_1: "default-1", WRANGLER_ENV_VAR_2: "default-2", WRANGLER_ENV_VAR_3: "default-3", }, }), ".env": dedent` WRANGLER_ENV_VAR_1=env-1 WRANGLER_ENV_VAR_2=env-2 `, ".env.local": dedent` WRANGLER_ENV_VAR_2=local-2 WRANGLER_ENV_VAR_3=local-3 `, ".env.staging": dedent` WRANGLER_ENV_VAR_3=staging-3 WRANGLER_ENV_VAR_4=staging-4 `, ".env.staging.local": dedent` WRANGLER_ENV_VAR_4=staging-local-4 WRANGLER_ENV_VAR_5=staging-local-5 `, }); const worker = await startWorker({ config: path.resolve(helper.tmpPath, "wrangler.jsonc"), name: "test-worker", entrypoint: path.resolve(helper.tmpPath, "src/index.ts"), dev: { server: { port: 0 }, inspector: false, }, }); onTestFinished(worker?.dispose); const res = await worker.fetch("http://dummy/test/path/1"); expect(await res.json()).toMatchInlineSnapshot(` { "WRANGLER_ENV_VAR_0": "default-0", "WRANGLER_ENV_VAR_1": "env-1", "WRANGLER_ENV_VAR_2": "local-2", "WRANGLER_ENV_VAR_3": "local-3", } `); }); it("vars are not loaded from .env if there is a .dev.vars file (next to config file)", async ({ expect, }) => { await helper.seed({ "src/index.ts": dedent` export default { fetch(request, env) { return Response.json(env); } } `, "wrangler.jsonc": JSON.stringify({ vars: { WRANGLER_ENV_VAR_0: "default-0", WRANGLER_ENV_VAR_1: "default-1", WRANGLER_ENV_VAR_2: "default-2", WRANGLER_ENV_VAR_3: "default-3", }, }), ".env": dedent` WRANGLER_ENV_VAR_1=env-1 WRANGLER_ENV_VAR_2=env-2 `, ".dev.vars": dedent` WRANGLER_ENV_VAR_2=dev-vars-2 WRANGLER_ENV_VAR_3=dev-vars-3 `, }); const worker = await startWorker({ config: path.resolve(helper.tmpPath, "wrangler.jsonc"), name: "test-worker", entrypoint: path.resolve(helper.tmpPath, "src/index.ts"), dev: { server: { port: 0 }, inspector: false, }, }); onTestFinished(worker?.dispose); const res = await worker.fetch("http://dummy/test/path/1"); expect(await res.json()).toMatchInlineSnapshot(` { "WRANGLER_ENV_VAR_0": "default-0", "WRANGLER_ENV_VAR_1": "default-1", "WRANGLER_ENV_VAR_2": "dev-vars-2", "WRANGLER_ENV_VAR_3": "dev-vars-3", } `); }); it("vars from inline config override vars from both .env and config file", async ({ expect, }) => { await helper.seed({ "src/index.ts": dedent` export default { fetch(request, env) { return Response.json(env); } } `, "wrangler.jsonc": JSON.stringify({ vars: { WRANGLER_ENV_VAR_0: "default-0", WRANGLER_ENV_VAR_1: "default-1", WRANGLER_ENV_VAR_2: "default-2", WRANGLER_ENV_VAR_3: "default-3", }, }), ".env": dedent` WRANGLER_ENV_VAR_1=env-1 WRANGLER_ENV_VAR_2=env-2 `, ".env.local": dedent` WRANGLER_ENV_VAR_2=local-2 WRANGLER_ENV_VAR_3=local-3 `, ".env.staging": dedent` WRANGLER_ENV_VAR_3=staging-3 WRANGLER_ENV_VAR_4=staging-4 `, ".env.staging.local": dedent` WRANGLER_ENV_VAR_4=staging-local-4 WRANGLER_ENV_VAR_5=staging-local-5 `, }); const worker = await startWorker({ config: path.resolve(helper.tmpPath, "wrangler.jsonc"), name: "test-worker", entrypoint: path.resolve(helper.tmpPath, "src/index.ts"), bindings: { WRANGLER_ENV_VAR_3: { type: "plain_text", value: "inline-3" }, WRANGLER_ENV_VAR_4: { type: "plain_text", value: "inline-4" }, }, dev: { server: { port: 0 }, inspector: false, }, }); onTestFinished(worker?.dispose); const res = await worker.fetch("http://dummy/test/path/1"); expect(await res.json()).toMatchInlineSnapshot(` { "WRANGLER_ENV_VAR_0": "default-0", "WRANGLER_ENV_VAR_1": "env-1", "WRANGLER_ENV_VAR_2": "local-2", "WRANGLER_ENV_VAR_3": "inline-3", "WRANGLER_ENV_VAR_4": "inline-4", } `); }); it("vars from .env pointed at by `envFile` override vars from Wrangler config file and .env files local to the config file", async ({ expect, }) => { await helper.seed({ "src/index.ts": dedent` export default { fetch(request, env) { return Response.json(env); } } `, "wrangler.jsonc": JSON.stringify({ vars: { WRANGLER_ENV_VAR_0: "default-0", WRANGLER_ENV_VAR_1: "default-1", WRANGLER_ENV_VAR_2: "default-2", WRANGLER_ENV_VAR_3: "default-3", }, }), ".env": dedent` WRANGLER_ENV_VAR_1=env-1 WRANGLER_ENV_VAR_2=env-2 `, ".env.local": dedent` WRANGLER_ENV_VAR_2=local-2 WRANGLER_ENV_VAR_3=local-3 `, "other/.env": dedent` WRANGLER_ENV_VAR_3=other-3 WRANGLER_ENV_VAR_4=other-4 `, "other/.env.local": dedent` WRANGLER_ENV_VAR_4=other-local-4 WRANGLER_ENV_VAR_5=other-local-5 `, }); const worker = await startWorker({ config: path.resolve(helper.tmpPath, "wrangler.jsonc"), name: "test-worker", entrypoint: path.resolve(helper.tmpPath, "src/index.ts"), envFiles: ["other/.env", "other/.env.local"], dev: { server: { port: 0 }, inspector: false, }, }); onTestFinished(worker?.dispose); const res = await worker.fetch("http://dummy/test/path/1"); expect(await res.json()).toMatchInlineSnapshot(` { "WRANGLER_ENV_VAR_0": "default-0", "WRANGLER_ENV_VAR_1": "default-1", "WRANGLER_ENV_VAR_2": "default-2", "WRANGLER_ENV_VAR_3": "other-3", "WRANGLER_ENV_VAR_4": "other-local-4", "WRANGLER_ENV_VAR_5": "other-local-5", } `); }); // Regression test for https://github.com/cloudflare/workers-sdk/issues/11038 // When envFiles is explicitly provided, .dev.vars should be completely ignored it(".dev.vars is ignored when envFiles is explicitly provided (PR #11195)", async ({ expect, }) => { await helper.seed({ "src/index.ts": dedent` export default { fetch(request, env) { return Response.json(env); } } `, "wrangler.jsonc": JSON.stringify({ vars: { WRANGLER_ENV_VAR_0: "default-0", }, }), ".dev.vars": dedent` WRANGLER_ENV_VAR_1=dev-vars-1 WRANGLER_ENV_VAR_2=dev-vars-2 `, "custom/.env": dedent` WRANGLER_ENV_VAR_2=custom-2 WRANGLER_ENV_VAR_3=custom-3 `, }); const worker = await startWorker({ config: path.resolve(helper.tmpPath, "wrangler.jsonc"), name: "test-worker", entrypoint: path.resolve(helper.tmpPath, "src/index.ts"), envFiles: ["custom/.env"], dev: { server: { port: 0 }, inspector: false, }, }); onTestFinished(worker?.dispose); const res = await worker.fetch("http://dummy/test/path/1"); expect(await res.json()).toMatchInlineSnapshot(` { "WRANGLER_ENV_VAR_0": "default-0", "WRANGLER_ENV_VAR_2": "custom-2", "WRANGLER_ENV_VAR_3": "custom-3", } `); }); // Regression test for https://github.com/cloudflare/workers-sdk/issues/11264 // When envFiles is an empty array, .dev.vars should still be loaded it(".dev.vars is loaded when envFiles is empty array (PR #11278)", async ({ expect, }) => { await helper.seed({ "src/index.ts": dedent` export default { fetch(request, env) { return Response.json(env); } } `, "wrangler.jsonc": JSON.stringify({ vars: { WRANGLER_ENV_VAR_0: "default-0", }, }), ".dev.vars": dedent` WRANGLER_ENV_VAR_1=dev-vars-1 WRANGLER_ENV_VAR_2=dev-vars-2 `, }); const worker = await startWorker({ config: path.resolve(helper.tmpPath, "wrangler.jsonc"), name: "test-worker", entrypoint: path.resolve(helper.tmpPath, "src/index.ts"), envFiles: [], dev: { server: { port: 0 }, inspector: false, }, }); onTestFinished(worker?.dispose); const res = await worker.fetch("http://dummy/test/path/1"); expect(await res.json()).toMatchInlineSnapshot(` { "WRANGLER_ENV_VAR_0": "default-0", "WRANGLER_ENV_VAR_1": "dev-vars-1", "WRANGLER_ENV_VAR_2": "dev-vars-2", } `); }); }); });