From 21573f4fd3484145405c5666b4dc9f7338f56887 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Somhairle=20MacLe=C3=B2id?= Date: Wed, 5 Jun 2024 16:16:37 +0100 Subject: [PATCH] Remote runtime controller (#5939) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Squashed commit of the following: commit 7d0f2d693443b9f00391d29e75e19a1dc124d641 Author: Somhairle MacLeòid Date: Thu May 30 13:35:53 2024 +0100 Update index.ts commit 4fac9796642ec2d2c224d54f9f875cfc55b0aac7 Author: Somhairle MacLeòid Date: Thu May 30 13:10:39 2024 +0100 Create flat-days-punch.md commit 2c7bc56889ea26f830bba18a3f3459871c5d10b4 Author: Samuel Macleod Date: Thu May 30 12:52:32 2024 +0100 Remove cirular dependency between logger and env var factory which Vitest/Vite can't understand commit e4f2efb47b7aca21efb0b068c0190af76867083f Author: Samuel Macleod Date: Thu May 30 02:48:47 2024 +0100 lint commit c678631e94bd4ce55726da910257c265f5242705 Author: Samuel Macleod Date: Thu May 30 02:35:29 2024 +0100 restore logging & enable tests commit 4a87a51c71874676e2d0ef7c3238bbf0d25796c9 Author: Samuel Macleod Date: Thu May 30 01:53:33 2024 +0100 bindings passthrough + persistence path commit f1ff5462e8eece62477308c8c7deb01acbd4f498 Author: Samuel Macleod Date: Thu May 30 01:25:26 2024 +0100 handle empty routes array in getInferredHost commit cc1c2f0da331d92614d596a6a34b8eeca16d7a58 Author: Samuel Macleod Date: Thu May 30 01:20:08 2024 +0100 ignore https options in LocalRuntimeController (handled in Proxy commit 87b5a98a9e481f0e08e31ad84ca761c80d570853 Author: Samuel Macleod Date: Thu May 30 00:56:21 2024 +0100 starting to get tests passing commit 6b6204dd4f727896c1dc9ec93e0016ce0301b2ee Author: Samuel Macleod Date: Wed May 29 23:06:26 2024 +0100 Separate preview sessions and tokens commit 6912a495ee11800a4631d63eb336e43d2da1c455 Author: Samuel Macleod Date: Wed May 29 21:33:17 2024 +0100 fix build + lint + types commit c9d70b638e81851dc156a752df3c5d89f5c1e8bd Merge: db6eadac9 8033afbdd Author: Samuel Macleod Date: Wed May 29 21:14:51 2024 +0100 Merge branch 'startDevWorker-milestone-2-local' into remote-runtime-controller commit db6eadac988738a4157ceaa48804d1318897ade1 Merge: 10a9495dd 90d6be713 Author: Somhairle MacLeòid Date: Wed May 29 21:09:43 2024 +0100 Merge branch 'startDevWorker-milestone-2-local' into remote-runtime-controller commit 10a9495dd4451112d42e655a24e0877c1db34606 Merge: 0842071cf be715bd3f Author: Samuel Macleod Date: Wed May 29 20:57:04 2024 +0100 Merge branch 'startDevWorker-milestone-2-local' into remote-runtime-controller commit 0842071cf9fa59898da0f90d58c64818cf0cc1b3 Author: Rahul Sethi <5822355+RamIdeas@users.noreply.github.com> Date: Tue May 21 16:16:48 2024 +0100 chore: pull in changes from LocalRuntimeController PR commit a2a6ba128bccba0bc5f6bbd2dffb59931e76ddb6 Author: Rahul Sethi <5822355+RamIdeas@users.noreply.github.com> Date: Tue May 21 15:15:12 2024 +0100 update startDevWorker types from LocalRuntimeController PR commit 6e38a28c6eb78e9953579e38253c6daf92971386 Author: Rahul Sethi <5822355+RamIdeas@users.noreply.github.com> Date: Sun May 19 21:25:28 2024 +0100 fix: make unstable_dev respect experimentalDevenvRuntime commit 82ce0a10f795648bc117d47bf355869dfd72d65c Author: Rahul Sethi <5822355+RamIdeas@users.noreply.github.com> Date: Sun May 19 21:50:37 2024 +0100 fix: unstable_dev with node v18 by defaulting .ip to 127.0.0.1 + enable unstable_dev (previously: wrangler-dev-api-app) fixture tests to run in ci commit 341d212e4eeecccd3ab3d1fc3e21dc053d117bdb Author: Rahul Sethi <5822355+RamIdeas@users.noreply.github.com> Date: Sun May 19 20:32:48 2024 +0100 implement --experimental-devenv-runtime for conditional use of DevEnv-{Local|Remote}RuntimeController commit db353b2f6abddec02f9dfd5d6ef4e76ff3d47380 Author: Rahul Sethi <5822355+RamIdeas@users.noreply.github.com> Date: Sun May 19 20:08:12 2024 +0100 chore: refactor bundle ref back to normal variable commit dd55a2436d6e74b54f1228189193dc230af80545 Author: Rahul Sethi <5822355+RamIdeas@users.noreply.github.com> Date: Sun May 19 19:58:57 2024 +0100 fix: auth + accountId selection from React flow with RemoteRuntimeController commit c0c88f2896585303e99a81ac46232e14e0183f2c Author: Rahul Sethi <5822355+RamIdeas@users.noreply.github.com> Date: Sun May 19 19:39:26 2024 +0100 use an actual onBundleComplete callback instead of relying on useEffect + deps tracking commit 2f8b98ca8aba6eb3b367366601ca0d520eccb455 Author: Rahul Sethi <5822355+RamIdeas@users.noreply.github.com> Date: Fri May 17 16:57:20 2024 +0100 wip: RemoteRuntimeController + integration * Refactor errors to use shared definition * Rename flag * Remove extraneous console logs * Fix spacing * Actually teardown * Address comments * Fix flags * Remove ip overriding * Hardcode IP in unstable_dev * Cleanup unstable options * Error on invalid text_blobs * Teardown on change from remote -> local and vice-versa * add volta * e2e test for switching runtime race conditions * Update snapshots * immutable config * more testing * file url --- .changeset/flat-days-punch.md | 5 + fixtures/dev-env/tests/index.test.ts | 8 +- .../package.json | 6 +- .../src/index.js | 0 .../src/wrangler-dev.mjs | 0 .../e2e/__snapshots__/dev.test.ts.snap | 8 + packages/wrangler/e2e/dev-env.test.ts | 126 +++++++++ .../wrangler/e2e/dev-with-resources.test.ts | 51 ++-- packages/wrangler/e2e/dev.test.ts | 170 ++++++------ packages/wrangler/e2e/helpers/wrangler.ts | 6 +- packages/wrangler/src/api/dev.ts | 5 +- .../wrangler/src/api/startDevWorker/DevEnv.ts | 1 - .../startDevWorker/LocalRuntimeController.ts | 159 +++--------- .../startDevWorker/RemoteRuntimeController.ts | 241 +++++++++++++++++- .../wrangler/src/api/startDevWorker/events.ts | 2 +- .../wrangler/src/api/startDevWorker/types.ts | 23 +- .../wrangler/src/api/startDevWorker/utils.ts | 163 ++++++++++++ packages/wrangler/src/dev.tsx | 20 +- packages/wrangler/src/dev/dev.tsx | 175 ++++++++++--- packages/wrangler/src/dev/local.tsx | 23 +- packages/wrangler/src/dev/miniflare.ts | 14 - packages/wrangler/src/dev/remote.tsx | 168 +++++++----- packages/wrangler/src/dev/start-server.ts | 52 +++- packages/wrangler/src/dev/use-esbuild.ts | 9 + .../src/environment-variables/factory.ts | 5 +- packages/wrangler/src/errors.ts | 6 + pnpm-lock.yaml | 8 +- 27 files changed, 1062 insertions(+), 392 deletions(-) create mode 100644 .changeset/flat-days-punch.md rename fixtures/{wrangler-dev-api-app => unstable_dev}/package.json (57%) rename fixtures/{wrangler-dev-api-app => unstable_dev}/src/index.js (100%) rename fixtures/{wrangler-dev-api-app => unstable_dev}/src/wrangler-dev.mjs (100%) create mode 100644 packages/wrangler/e2e/dev-env.test.ts diff --git a/.changeset/flat-days-punch.md b/.changeset/flat-days-punch.md new file mode 100644 index 000000000000..ae045b91a9c4 --- /dev/null +++ b/.changeset/flat-days-punch.md @@ -0,0 +1,5 @@ +--- +"wrangler": patch +--- + +feat: Adds the experimental flag `--x-dev-env` which opts in to using an experimental code path for `wrangler dev` and `wrangler dev --remote`. There should be no observable behaviour changes when this flag is enabled. diff --git a/fixtures/dev-env/tests/index.test.ts b/fixtures/dev-env/tests/index.test.ts index 70dd40bb82bf..eb8e229e5ad1 100644 --- a/fixtures/dev-env/tests/index.test.ts +++ b/fixtures/dev-env/tests/index.test.ts @@ -181,8 +181,8 @@ function fakeReloadComplete( pathname: `/core:user:${config.name}`, }, userWorkerInnerUrlOverrides: { - protocol: config?.dev?.urlOverrides?.secure ? "https:" : "http:", - hostname: config?.dev?.urlOverrides?.hostname, + protocol: config?.dev?.origin?.secure ? "https:" : "http:", + hostname: config?.dev?.origin?.hostname, }, headers: {}, liveReload: config.dev?.liveReload, @@ -569,7 +569,7 @@ describe("startDevWorker: ProxyController", () => { `, config: { dev: { - urlOverrides: { + origin: { hostname: "www.google.com", }, }, @@ -585,7 +585,7 @@ describe("startDevWorker: ProxyController", () => { ...run.config, dev: { ...run.config.dev, - urlOverrides: { + origin: { secure: true, hostname: "mybank.co.uk", }, diff --git a/fixtures/wrangler-dev-api-app/package.json b/fixtures/unstable_dev/package.json similarity index 57% rename from fixtures/wrangler-dev-api-app/package.json rename to fixtures/unstable_dev/package.json index 6a8d50b6e6b4..93a1b61e274e 100644 --- a/fixtures/wrangler-dev-api-app/package.json +++ b/fixtures/unstable_dev/package.json @@ -5,7 +5,11 @@ "license": "ISC", "author": "", "scripts": { - "dev": "node --no-warnings ./src/wrangler-dev.mjs" + "dev": "node --no-warnings ./src/wrangler-dev.mjs", + "test:ci": "npm run dev" + }, + "dependencies": { + "wrangler": "workspace:*" }, "volta": { "extends": "../../package.json" diff --git a/fixtures/wrangler-dev-api-app/src/index.js b/fixtures/unstable_dev/src/index.js similarity index 100% rename from fixtures/wrangler-dev-api-app/src/index.js rename to fixtures/unstable_dev/src/index.js diff --git a/fixtures/wrangler-dev-api-app/src/wrangler-dev.mjs b/fixtures/unstable_dev/src/wrangler-dev.mjs similarity index 100% rename from fixtures/wrangler-dev-api-app/src/wrangler-dev.mjs rename to fixtures/unstable_dev/src/wrangler-dev.mjs diff --git a/packages/wrangler/e2e/__snapshots__/dev.test.ts.snap b/packages/wrangler/e2e/__snapshots__/dev.test.ts.snap index 9f336355bec5..e669f5743c19 100644 --- a/packages/wrangler/e2e/__snapshots__/dev.test.ts.snap +++ b/packages/wrangler/e2e/__snapshots__/dev.test.ts.snap @@ -1,9 +1,17 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html +exports[`basic js dev: 'wrangler dev --remote --x-dev-env' > can modify worker during wrangler dev --remote --x-dev-env 1`] = `"Hello World!"`; + +exports[`basic js dev: 'wrangler dev --remote --x-dev-env' > can modify worker during wrangler dev --remote --x-dev-env 2`] = `"Updated Worker! value"`; + exports[`basic js dev: 'wrangler dev --remote' > can modify worker during wrangler dev --remote 1`] = `"Hello World!"`; exports[`basic js dev: 'wrangler dev --remote' > can modify worker during wrangler dev --remote 2`] = `"Updated Worker! value"`; +exports[`basic js dev: 'wrangler dev --x-dev-env' > can modify worker during wrangler dev --x-dev-env 1`] = `"Hello World!"`; + +exports[`basic js dev: 'wrangler dev --x-dev-env' > can modify worker during wrangler dev --x-dev-env 2`] = `"Updated Worker! value"`; + exports[`basic js dev: 'wrangler dev' > can modify worker during wrangler dev 1`] = `"Hello World!"`; exports[`basic js dev: 'wrangler dev' > can modify worker during wrangler dev 2`] = `"Updated Worker! value"`; diff --git a/packages/wrangler/e2e/dev-env.test.ts b/packages/wrangler/e2e/dev-env.test.ts new file mode 100644 index 000000000000..7866a6141c5e --- /dev/null +++ b/packages/wrangler/e2e/dev-env.test.ts @@ -0,0 +1,126 @@ +import shellac from "shellac"; +import dedent from "ts-dedent"; +import { beforeEach, describe, expect, it } from "vitest"; +import { CLOUDFLARE_ACCOUNT_ID } from "./helpers/account-id"; +import { makeRoot, seed } from "./helpers/setup"; +import { WRANGLER_IMPORT } from "./helpers/wrangler"; + +// TODO(DEVX-1262): re-enable when we have set an API token with the proper AI permissions +describe("switching runtimes", () => { + let run: typeof shellac; + beforeEach(async () => { + const root = await makeRoot(); + + run = shellac.in(root).env(process.env); + await seed(root, { + "wrangler.toml": dedent` + name = "dev-env-app" + account_id = "${CLOUDFLARE_ACCOUNT_ID}" + compatibility_date = "2023-01-01" + `, + "index.mjs": dedent/*javascript*/ ` + const firstRemote = process.argv[2] === "remote" + import { unstable_DevEnv as DevEnv } from "${WRANGLER_IMPORT}"; + + const devEnv = new DevEnv() + + let config = { + name: "worker", + script: "", + compatibilityFlags: ["nodejs_compat"], + compatibilityDate: "2023-10-01", + dev: { + remote: firstRemote, + auth: { + accountId: process.env.CLOUDFLARE_ACCOUNT_ID, + apiToken: process.env.CLOUDFLARE_API_TOKEN + } + } + }; + let bundle = { + type: "esm", + modules: [], + id: 0, + path: "/virtual/esm/index.mjs", + entrypointSource: "export default { fetch() { return new Response('Hello World " + (firstRemote ? 'local' : 'remote') + " runtime') } }", + entry: { + file: "esm/index.mjs", + directory: "/virtual/", + format: "modules", + moduleRoot: "/virtual", + name: undefined, + }, + dependencies: {}, + sourceMapPath: undefined, + sourceMapMetadata: undefined, + }; + + devEnv.proxy.onConfigUpdate({ + type: "configUpdate", + config, + }); + + devEnv.runtimes.forEach((runtime) => + runtime.onBundleStart({ + type: "bundleStart", + config, + }) + ); + + devEnv.runtimes.forEach((runtime) => + runtime.onBundleComplete({ + type: "bundleComplete", + config, + bundle, + }) + ); + + // Immediately switch runtime + config = { ...config, dev: { ...config.dev, remote: !firstRemote } }; + bundle = {...bundle, entrypointSource: "export default { fetch() { return new Response('Hello World " + (firstRemote ? 'local' : 'remote') + " runtime') } }"} + + devEnv.proxy.onConfigUpdate({ + type: "configUpdate", + config, + }); + + devEnv.runtimes.forEach((runtime) => + runtime.onBundleStart({ + type: "bundleStart", + config, + }) + ); + + devEnv.runtimes.forEach((runtime) => + runtime.onBundleComplete({ + type: "bundleComplete", + config, + bundle, + }) + ); + + const { proxyWorker } = await devEnv.proxy.ready.promise; + await devEnv.proxy.runtimeMessageMutex.drained(); + + console.log(await proxyWorker.dispatchFetch("http://example.com").then(r => r.text())) + + process.exit(0); + `, + "package.json": dedent` + { + "name": "ai-app", + "version": "0.0.0", + "private": true + } + `, + }); + }); + it("can switch from local to remote, with first fetch returning remote", async () => { + const { stdout } = await run`$ node index.mjs local`; + expect(stdout).toContain("Hello World remote runtime"); + }); + it("can switch from remote to local, with first fetch returning local", async () => { + const { stdout } = await run`$ node index.mjs remote`; + expect(stdout).toContain("Hello World local runtime"); + }); +}); diff --git a/packages/wrangler/e2e/dev-with-resources.test.ts b/packages/wrangler/e2e/dev-with-resources.test.ts index 6518dfcac69c..c171cf467ac9 100644 --- a/packages/wrangler/e2e/dev-with-resources.test.ts +++ b/packages/wrangler/e2e/dev-with-resources.test.ts @@ -12,7 +12,12 @@ import { killAllWranglerDev } from "./helpers/wrangler"; beforeEach(killAllWranglerDev); afterEach(killAllWranglerDev); -const RUNTIMES = [{ runtime: "local" }, { runtime: "remote" }] as const; +const RUNTIMES = [ + { flags: "", runtime: "local" }, + { flags: "--remote", runtime: "remote" }, + { flags: "--x-dev-env", runtime: "local" }, + { flags: "--remote --x-dev-env", runtime: "remote" }, +] as const; // WebAssembly module containing single `func add(i32, i32): i32` export. // Generated using https://webassembly.github.io/wabt/demo/wat2wasm/. @@ -21,9 +26,8 @@ const WASM_ADD_MODULE = Buffer.from( "base64" ); -describe.each(RUNTIMES)("Core: $runtime", ({ runtime }) => { +describe.each(RUNTIMES)("Core: $flags", ({ runtime, flags }) => { const isLocal = runtime === "local"; - const runtimeFlags = isLocal ? [] : ["--remote"]; e2eTest( "works with basic modules format worker", @@ -50,7 +54,7 @@ describe.each(RUNTIMES)("Core: $runtime", ({ runtime }) => { } `, }); - const worker = run(`wrangler dev ${runtimeFlags}`); + const worker = run(`wrangler dev ${flags}`); const { url } = await waitForReady(worker); let res = await fetch(url); @@ -92,7 +96,7 @@ describe.each(RUNTIMES)("Core: $runtime", ({ runtime }) => { }); `, }); - const worker = run(`wrangler dev ${runtimeFlags}`); + const worker = run(`wrangler dev ${flags}`); const { url } = await waitForReady(worker); let res = await fetch(url); expect(await res.text()).toBe("service worker"); @@ -145,7 +149,7 @@ describe.each(RUNTIMES)("Core: $runtime", ({ runtime }) => { } `, }); - const worker = run(`wrangler dev ${runtimeFlags}`); + const worker = run(`wrangler dev ${flags}`); const { url } = await waitForReady(worker); const res = await fetch(url); expect(await res.json()).toEqual({ @@ -174,7 +178,7 @@ describe.each(RUNTIMES)("Core: $runtime", ({ runtime }) => { `, }); const worker = run( - `wrangler dev ${runtimeFlags} --inspector-port=${inspectorPort}` + `wrangler dev ${flags} --inspector-port=${inspectorPort}` ); await waitForReady(worker); const inspectorUrl = new URL(`ws://127.0.0.1:${inspectorPort}`); @@ -200,7 +204,7 @@ describe.each(RUNTIMES)("Core: $runtime", ({ runtime }) => { } `, }); - const worker = run(`wrangler dev ${runtimeFlags} --local-protocol=https`); + const worker = run(`wrangler dev ${flags} --local-protocol=https`); const { url } = await waitForReady(worker); const parsedURL = new URL(url); expect(parsedURL.protocol).toBe("https:"); @@ -227,9 +231,7 @@ describe.each(RUNTIMES)("Core: $runtime", ({ runtime }) => { `, }); // TODO(soon): explore using `--host` for remote mode in this test - const worker = run( - `wrangler dev ${runtimeFlags} --local-upstream=example.com` - ); + const worker = run(`wrangler dev ${flags} --local-upstream=example.com`); const { url } = await waitForReady(worker); const res = await fetch(url); expect(await res.text()).toBe("http://example.com/"); @@ -237,10 +239,10 @@ describe.each(RUNTIMES)("Core: $runtime", ({ runtime }) => { ); }); -describe.each(RUNTIMES)("Bindings: $runtime", ({ runtime }) => { +describe.each(RUNTIMES)("Bindings: $flags", ({ runtime, flags }) => { const isLocal = runtime === "local"; - const runtimeFlags = isLocal ? "" : "--remote"; const resourceFlags = isLocal ? "--local" : ""; + const d1ResourceFlags = isLocal ? "" : "--remote"; e2eTest( "exposes basic bindings in service workers", @@ -273,7 +275,7 @@ describe.each(RUNTIMES)("Bindings: $runtime", ({ runtime }) => { }); `, }); - const worker = run(`wrangler dev ${runtimeFlags}`); + const worker = run(`wrangler dev ${flags}`); const { url } = await waitForReady(worker); const res = await fetch(url); expect(await res.json()).toEqual({ @@ -305,7 +307,7 @@ describe.each(RUNTIMES)("Bindings: $runtime", ({ runtime }) => { }); `, }); - const worker = run(`wrangler dev ${runtimeFlags}`); + const worker = run(`wrangler dev ${flags}`); const { url } = await waitForReady(worker); const res = await fetch(url); expect(await res.text()).toBe("3"); @@ -333,6 +335,7 @@ describe.each(RUNTIMES)("Bindings: $runtime", ({ runtime }) => { "src/index.ts": dedent` export default { async fetch(request, env, ctx) { + console.log(await env.NAMESPACE.list()) const value = await env.NAMESPACE.get("existing-key"); await env.NAMESPACE.put("new-key", "new-value"); return new Response(value); @@ -340,7 +343,7 @@ describe.each(RUNTIMES)("Bindings: $runtime", ({ runtime }) => { } `, }); - const worker = run(`wrangler dev ${runtimeFlags}`); + const worker = run(`wrangler dev ${flags}`); const { url } = await waitForReady(worker); const res = await fetch(url); expect(await res.text()).toBe("existing-value"); @@ -390,7 +393,7 @@ describe.each(RUNTIMES)("Bindings: $runtime", ({ runtime }) => { `, }); - const worker = run(`wrangler dev ${runtimeFlags}`); + const worker = run(`wrangler dev ${flags}`); const { url } = await waitForReady(worker); const res = await fetch(url); expect(await res.text()).toBe("

👋

"); @@ -446,7 +449,7 @@ describe.each(RUNTIMES)("Bindings: $runtime", ({ runtime }) => { } `, }); - const worker = run(`wrangler dev ${runtimeFlags}`); + const worker = run(`wrangler dev ${flags}`); const { url } = await waitForReady(worker); const res = await fetch(url); expect(await res.text()).toBe("existing-value"); @@ -494,16 +497,16 @@ describe.each(RUNTIMES)("Bindings: $runtime", ({ runtime }) => { `, }); - // D1 defaults to `--local`, so we deliberately use `runtimeFlags`, not `resourceFlags` - await run(`wrangler d1 execute ${runtimeFlags} DB --file schema.sql`); + // D1 defaults to `--local`, so we deliberately use `flags`, not `resourceFlags` + await run(`wrangler d1 execute ${d1ResourceFlags} DB --file schema.sql`); - const worker = run(`wrangler dev ${runtimeFlags}`); + const worker = run(`wrangler dev ${flags}`); const { url } = await waitForReady(worker); const res = await fetch(url); expect(await res.json()).toEqual([{ key: "key1", value: "value1" }]); const result = await run( - `wrangler d1 execute ${runtimeFlags} DB --command "SELECT * FROM entries WHERE key = 'key2'"` + `wrangler d1 execute ${d1ResourceFlags} DB --command "SELECT * FROM entries WHERE key = 'key2'"` ); expect(result).toContain("value2"); } @@ -538,7 +541,7 @@ describe.each(RUNTIMES)("Bindings: $runtime", ({ runtime }) => { } `, }); - const worker = run(`wrangler dev ${runtimeFlags}`); + const worker = run(`wrangler dev ${flags}`); const { url } = await waitForReady(worker); await fetch(url); await worker.readUntil(/✉️/); @@ -558,7 +561,7 @@ describe.each(RUNTIMES)("Bindings: $runtime", ({ runtime }) => { describe.each(RUNTIMES)("Multi-Worker Bindings: $runtime", ({ runtime }) => { const isLocal = runtime === "local"; - const _runtimeFlags = isLocal ? [] : ["--remote"]; + const _flags = isLocal ? [] : ["--remote"]; // TODO(soon): we already have tests for service bindings in `dev.test.ts`, // but would be good to get some more for Durable Objects diff --git a/packages/wrangler/e2e/dev.test.ts b/packages/wrangler/e2e/dev.test.ts index c3e5c57e9940..5bd884e6afe9 100644 --- a/packages/wrangler/e2e/dev.test.ts +++ b/packages/wrangler/e2e/dev.test.ts @@ -46,14 +46,17 @@ e2eTest( } ); -describe.each([{ cmd: "wrangler dev" }, { cmd: "wrangler dev --remote" }])( - "basic js dev: $cmd", - ({ cmd }) => { - e2eTest( - `can modify worker during ${cmd}`, - async ({ run, seed, waitForReady, waitForReload }) => { - await seed({ - "wrangler.toml": dedent` +describe.each([ + { cmd: "wrangler dev" }, + { cmd: "wrangler dev --remote" }, + { cmd: "wrangler dev --x-dev-env" }, + { cmd: "wrangler dev --remote --x-dev-env" }, +])("basic js dev: $cmd", ({ cmd }) => { + e2eTest( + `can modify worker during ${cmd}`, + async ({ run, seed, waitForReady, waitForReload }) => { + await seed({ + "wrangler.toml": dedent` name = "worker" main = "src/index.ts" compatibility_date = "2023-01-01" @@ -62,158 +65,157 @@ describe.each([{ cmd: "wrangler dev" }, { cmd: "wrangler dev --remote" }])( [vars] KEY = "value" `, - "src/index.ts": dedent` + "src/index.ts": dedent` export default { fetch(request) { return new Response("Hello World!") } }`, - "package.json": dedent` + "package.json": dedent` { "name": "worker", "version": "0.0.0", "private": true } `, - }); - const worker = run(cmd); + }); + const worker = run(cmd); - const { url } = await waitForReady(worker); + const { url } = await waitForReady(worker); - await expect( - fetch(url).then((r) => r.text()) - ).resolves.toMatchSnapshot(); + await expect(fetch(url).then((r) => r.text())).resolves.toMatchSnapshot(); - await seed({ - "src/index.ts": dedent` + await seed({ + "src/index.ts": dedent` export default { fetch(request, env) { return new Response("Updated Worker! " + env.KEY) } }`, - }); + }); - await waitForReload(worker); + await waitForReload(worker); - await expect(fetchText(url)).resolves.toMatchSnapshot(); - } - ); - } -); + await expect(fetchText(url)).resolves.toMatchSnapshot(); + } + ); +}); -describe.each([{ cmd: "wrangler dev" }, { cmd: "wrangler dev --remote" }])( - "basic python dev: $cmd", - ({ cmd }) => { - e2eTest( - `can modify entrypoint during ${cmd}`, - async ({ run, seed, waitForReady, waitForReload }) => { - await seed({ - "wrangler.toml": dedent` +describe.each([ + { cmd: "wrangler dev" }, + { cmd: "wrangler dev --remote" }, + { cmd: "wrangler dev --x-dev-env" }, + { cmd: "wrangler dev --remote --x-dev-env" }, +])("basic python dev: $cmd", ({ cmd }) => { + e2eTest( + `can modify entrypoint during ${cmd}`, + async ({ run, seed, waitForReady, waitForReload }) => { + await seed({ + "wrangler.toml": dedent` name = "worker" main = "index.py" compatibility_date = "2023-01-01" compatibility_flags = ["python_workers"] `, - "arithmetic.py": dedent` + "arithmetic.py": dedent` def mul(a,b): return a*b`, - "index.py": dedent` + "index.py": dedent` from arithmetic import mul from js import Response def on_fetch(request): return Response.new(f"py hello world {mul(2,3)}")`, - "package.json": dedent` + "package.json": dedent` { "name": "worker", "version": "0.0.0", "private": true } `, - }); - const worker = run(cmd); + }); + const worker = run(cmd); - const { url } = await waitForReady(worker); + const { url } = await waitForReady(worker); - await expect(fetchText(url)).resolves.toBe("py hello world 6"); + await expect(fetchText(url)).resolves.toBe("py hello world 6"); - await seed({ - "index.py": dedent` + await seed({ + "index.py": dedent` from js import Response def on_fetch(request): return Response.new('Updated Python Worker value')`, - }); + }); - await waitForReload(worker); + await waitForReload(worker); - // TODO(soon): work out why python workers need this retry before returning new content - const { text } = await retry( - (s) => s.status !== 200 || s.text === "py hello world 6", - async () => { - const r = await fetch(url); - return { text: await r.text(), status: r.status }; - } - ); + // TODO(soon): work out why python workers need this retry before returning new content + const { text } = await retry( + (s) => s.status !== 200 || s.text === "py hello world 6", + async () => { + const r = await fetch(url); + return { text: await r.text(), status: r.status }; + } + ); - expect(text).toBe("Updated Python Worker value"); - } - ); + expect(text).toBe("Updated Python Worker value"); + } + ); - e2eTest( - `can modify imports during ${cmd}`, - async ({ run, seed, waitForReady, waitForReload }) => { - await seed({ - "wrangler.toml": dedent` + e2eTest( + `can modify imports during ${cmd}`, + async ({ run, seed, waitForReady, waitForReload }) => { + await seed({ + "wrangler.toml": dedent` name = "worker" main = "index.py" compatibility_date = "2023-01-01" compatibility_flags = ["python_workers"] `, - "arithmetic.py": dedent` + "arithmetic.py": dedent` def mul(a,b): return a*b`, - "index.py": dedent` + "index.py": dedent` from arithmetic import mul from js import Response def on_fetch(request): return Response.new(f"py hello world {mul(2,3)}")`, - "package.json": dedent` + "package.json": dedent` { "name": "worker", "version": "0.0.0", "private": true } `, - }); - const worker = run(cmd); + }); + const worker = run(cmd); - const { url } = await waitForReady(worker); + const { url } = await waitForReady(worker); - await expect(fetchText(url)).resolves.toBe("py hello world 6"); + await expect(fetchText(url)).resolves.toBe("py hello world 6"); - await seed({ - "arithmetic.py": dedent` + await seed({ + "arithmetic.py": dedent` def mul(a,b): return a+b`, - }); + }); - await waitForReload(worker); + await waitForReload(worker); - // TODO(soon): work out why python workers need this retry before returning new content - const { text } = await retry( - (s) => s.status !== 200 || s.text === "py hello world 6", - async () => { - const r = await fetch(url); - return { text: await r.text(), status: r.status }; - } - ); + // TODO(soon): work out why python workers need this retry before returning new content + const { text } = await retry( + (s) => s.status !== 200 || s.text === "py hello world 6", + async () => { + const r = await fetch(url); + return { text: await r.text(), status: r.status }; + } + ); - expect(text).toBe("py hello world 5"); - } - ); - } -); + expect(text).toBe("py hello world 5"); + } + ); +}); describe("dev registry", () => { let a: string; diff --git a/packages/wrangler/e2e/helpers/wrangler.ts b/packages/wrangler/e2e/helpers/wrangler.ts index 13917baa2eb4..81814623cef1 100644 --- a/packages/wrangler/e2e/helpers/wrangler.ts +++ b/packages/wrangler/e2e/helpers/wrangler.ts @@ -3,6 +3,7 @@ import events from "node:events"; import rl from "node:readline"; import { PassThrough } from "node:stream"; import { ReadableStream } from "node:stream/web"; +import { pathToFileURL } from "node:url"; import psList from "ps-list"; import { readUntil } from "./read-until"; import type { ChildProcess } from "node:child_process"; @@ -10,8 +11,9 @@ import type { ChildProcess } from "node:child_process"; // Replace all backslashes with forward slashes to ensure that their use // in shellac scripts doesn't break. export const WRANGLER = process.env.WRANGLER?.replaceAll("\\", "/") ?? ""; -export const WRANGLER_IMPORT = - process.env.WRANGLER_IMPORT?.replaceAll("\\", "/") ?? ""; +export const WRANGLER_IMPORT = pathToFileURL( + process.env.WRANGLER_IMPORT?.replaceAll("\\", "/") ?? "" +); export function runWrangler( wranglerCommand: string, diff --git a/packages/wrangler/src/api/dev.ts b/packages/wrangler/src/api/dev.ts index ad94e4a49552..a770cf2a49aa 100644 --- a/packages/wrangler/src/api/dev.ts +++ b/packages/wrangler/src/api/dev.ts @@ -78,6 +78,7 @@ export interface UnstableDevOptions { testMode?: boolean; // This option shouldn't be used - We plan on removing it eventually testScheduled?: boolean; // Test scheduled events by visiting /__scheduled in browser watch?: boolean; // unstable_dev doesn't support watch-mode yet in testMode + devEnv?: boolean; }; } @@ -121,6 +122,7 @@ export async function unstable_dev( showInteractiveDevSession, testMode, testScheduled, + devEnv = false, // 2. options for alpha/beta products/libs d1Databases, enablePagesAssetsServiceBinding, @@ -176,7 +178,7 @@ export async function unstable_dev( bundle: options?.bundle, compatibilityDate: options?.compatibilityDate, compatibilityFlags: options?.compatibilityFlags, - ip: options?.ip, + ip: "127.0.0.1", inspectorPort: options?.inspectorPort ?? 0, v: undefined, localProtocol: options?.localProtocol, @@ -213,6 +215,7 @@ export async function unstable_dev( port: options?.port ?? 0, updateCheck: options?.updateCheck ?? false, experimentalVersions: undefined, + experimentalDevEnv: devEnv, }; //due to Pages adoption of unstable_dev, we can't *just* disable rebuilds and watching. instead, we'll have two versions of startDev, which will converge. diff --git a/packages/wrangler/src/api/startDevWorker/DevEnv.ts b/packages/wrangler/src/api/startDevWorker/DevEnv.ts index 24929ab463bf..45b49eaedc7d 100644 --- a/packages/wrangler/src/api/startDevWorker/DevEnv.ts +++ b/packages/wrangler/src/api/startDevWorker/DevEnv.ts @@ -118,7 +118,6 @@ export class DevEnv extends EventEmitter { const inspectorPort = config.dev?.inspector?.port; const randomPorts = [0, undefined]; - // console.log({ port, inspectorPort, ev }); if (!randomPorts.includes(port) || !randomPorts.includes(inspectorPort)) { // emit the event here while the ConfigController is unimplemented // this will cause the ProxyController to try reinstantiating the ProxyWorker(s) diff --git a/packages/wrangler/src/api/startDevWorker/LocalRuntimeController.ts b/packages/wrangler/src/api/startDevWorker/LocalRuntimeController.ts index 48b55c903660..fc1449d0f9b4 100644 --- a/packages/wrangler/src/api/startDevWorker/LocalRuntimeController.ts +++ b/packages/wrangler/src/api/startDevWorker/LocalRuntimeController.ts @@ -1,13 +1,13 @@ import { randomUUID } from "node:crypto"; import { readFile } from "node:fs/promises"; -import getPort from "get-port"; +import chalk from "chalk"; import { Miniflare, Mutex } from "miniflare"; -import { DEFAULT_INSPECTOR_PORT } from "../.."; import { getLocalPersistencePath } from "../../dev/get-local-persistence-path"; import * as MF from "../../dev/miniflare"; +import { logger } from "../../logger"; import { RuntimeController } from "./BaseController"; import { castErrorCause } from "./events"; -import type { CfWorkerInit } from "../../deployment-bundle/worker"; +import { convertBindingsToCfWorkerInitBindings } from "./utils"; import type { WorkerEntrypointsDefinition } from "../../dev-registry"; import type { BundleCompleteEvent, @@ -16,7 +16,7 @@ import type { ReloadCompleteEvent, ReloadStartEvent, } from "./events"; -import type { File, ServiceFetch, StartDevWorkerOptions } from "./types"; +import type { File, StartDevWorkerOptions } from "./types"; async function getBinaryFileContents(file: File) { if ("contents" in file) { @@ -48,120 +48,20 @@ function getName(config: StartDevWorkerOptions) { async function convertToConfigBundle( event: BundleCompleteEvent ): Promise { - const bindings: CfWorkerInit["bindings"] = { - vars: undefined, - kv_namespaces: undefined, - send_email: undefined, - wasm_modules: undefined, - text_blobs: undefined, - browser: undefined, - ai: undefined, - version_metadata: undefined, - data_blobs: undefined, - durable_objects: undefined, - queues: undefined, - r2_buckets: undefined, - d1_databases: undefined, - vectorize: undefined, - constellation: undefined, - hyperdrive: undefined, - services: undefined, - analytics_engine_datasets: undefined, - dispatch_namespaces: undefined, - mtls_certificates: undefined, - logfwdr: undefined, - unsafe: undefined, - }; + const { bindings: convertedBindings, fetchers } = + await convertBindingsToCfWorkerInitBindings(event.config.bindings); - const fetchers: Record = {}; + // TODO: Remove this passthrough + const bindings = event.config._bindings + ? event.config._bindings + : convertedBindings; - for (const [name, binding] of Object.entries(event.config.bindings ?? {})) { - if (binding.type === "plain_text") { - bindings.vars ??= {}; - bindings.vars[name] = binding.value; - } else if (binding.type === "json") { - bindings.vars ??= {}; - bindings.vars[name] = binding.value; - } else if (binding.type === "kv_namespace") { - bindings.kv_namespaces ??= []; - bindings.kv_namespaces.push({ ...binding, binding: name }); - } else if (binding.type === "send_email") { - bindings.send_email ??= []; - bindings.send_email.push({ ...binding, name: name }); - } else if (binding.type === "wasm_module") { - bindings.wasm_modules ??= {}; - bindings.wasm_modules[name] = await getBinaryFileContents(binding.source); - } else if (binding.type === "text_blob") { - bindings.text_blobs ??= {}; - bindings.text_blobs[name] = binding.source.path as string; - } else if (binding.type === "data_blob") { - bindings.data_blobs ??= {}; - bindings.data_blobs[name] = await getBinaryFileContents(binding.source); - } else if (binding.type === "browser") { - bindings.browser = { binding: name }; - } else if (binding.type === "ai") { - bindings.ai = { binding: name }; - } else if (binding.type === "version_metadata") { - bindings.version_metadata = { binding: name }; - } else if (binding.type === "durable_object_namespace") { - bindings.durable_objects ??= { bindings: [] }; - bindings.durable_objects.bindings.push({ ...binding, name: name }); - } else if (binding.type === "queue") { - bindings.queues ??= []; - bindings.queues.push({ ...binding, binding: name }); - } else if (binding.type === "r2_bucket") { - bindings.r2_buckets ??= []; - bindings.r2_buckets.push({ ...binding, binding: name }); - } else if (binding.type === "d1") { - bindings.d1_databases ??= []; - bindings.d1_databases.push({ ...binding, binding: name }); - } else if (binding.type === "vectorize") { - bindings.vectorize ??= []; - bindings.vectorize.push({ ...binding, binding: name }); - } else if (binding.type === "constellation") { - bindings.constellation ??= []; - bindings.constellation.push({ ...binding, binding: name }); - } else if (binding.type === "hyperdrive") { - bindings.hyperdrive ??= []; - bindings.hyperdrive.push({ ...binding, binding: name }); - } else if (binding.type === "service") { - bindings.services ??= []; - bindings.services.push({ ...binding, binding: name }); - } else if (binding.type === "fetcher") { - fetchers[name] = binding.fetcher; - } else if (binding.type === "analytics_engine") { - bindings.analytics_engine_datasets ??= []; - bindings.analytics_engine_datasets.push({ ...binding, binding: name }); - } else if (binding.type === "dispatch_namespace") { - bindings.dispatch_namespaces ??= []; - bindings.dispatch_namespaces.push({ ...binding, binding: name }); - } else if (binding.type === "mtls_certificate") { - bindings.mtls_certificates ??= []; - bindings.mtls_certificates.push({ ...binding, binding: name }); - } else if (binding.type === "logfwdr") { - bindings.logfwdr ??= { bindings: [] }; - bindings.logfwdr.bindings.push({ ...binding, name: name }); - } else if (binding.type.startsWith("unsafe_")) { - bindings.unsafe ??= { - bindings: [], - metadata: undefined, - capnp: undefined, - }; - bindings.unsafe.bindings?.push({ - type: binding.type.slice("unsafe_".length), - name: name, - }); - } - } - - const persistence = event.config.dev?.persist - ? getLocalPersistencePath( - typeof event.config.dev?.persist === "object" - ? event.config.dev?.persist.path - : undefined, - event.config.config?.path - ) - : null; + const persistence = getLocalPersistencePath( + typeof event.config.dev?.persist === "object" + ? event.config.dev?.persist.path + : undefined, + event.config.config?.path + ); const crons = []; const queueConsumers = []; @@ -222,9 +122,7 @@ async function convertToConfigBundle( initialPort: undefined, initialIp: "127.0.0.1", rules: [], - inspectorPort: - event.config.dev?.inspector?.port ?? - (await getPort({ port: DEFAULT_INSPECTOR_PORT })), + inspectorPort: 0, localPersistencePath: persistence, liveReload: event.config.dev?.liveReload ?? false, crons, @@ -232,8 +130,8 @@ async function convertToConfigBundle( localProtocol: event.config.dev?.server?.secure ? "https" : "http", httpsCertPath: event.config.dev?.server?.httpsCertPath, httpsKeyPath: event.config.dev?.server?.httpsKeyPath, - localUpstream: event.config.dev?.urlOverrides?.hostname, - upstreamProtocol: event.config.dev?.urlOverrides?.secure ? "https" : "http", + localUpstream: event.config.dev?.origin?.hostname, + upstreamProtocol: event.config.dev?.origin?.secure ? "https" : "http", inspect: true, services: bindings.services, serviceBindings: fetchers, @@ -272,8 +170,12 @@ export class LocalRuntimeController extends RuntimeController { this.#proxyToUserWorkerAuthenticationSecret ); if (this.#mf === undefined) { + logger.log(chalk.dim("⎔ Starting local server...")); + this.#mf = new Miniflare(options); } else { + logger.log(chalk.dim("⎔ Reloading local server...")); + await this.#mf.setOptions(options); } // All asynchronous `Miniflare` methods will wait for all `setOptions()` @@ -287,6 +189,7 @@ export class LocalRuntimeController extends RuntimeController { if (id !== this.#currentBundleId) { return; } + // Get entrypoint addresses const entrypointAddresses: WorkerEntrypointsDefinition = {}; for (const name of entrypointNames) { @@ -311,10 +214,8 @@ export class LocalRuntimeController extends RuntimeController { pathname: `/core:user:${getName(data.config)}`, }, userWorkerInnerUrlOverrides: { - protocol: data.config?.dev?.urlOverrides?.secure - ? "https:" - : "http:", - hostname: data.config?.dev?.urlOverrides?.hostname, + protocol: data.config?.dev?.origin?.secure ? "https:" : "http:", + hostname: data.config?.dev?.origin?.hostname, }, headers: { // Passing this signature from Proxy Worker allows the User Worker to trust the request. @@ -339,6 +240,12 @@ export class LocalRuntimeController extends RuntimeController { } onBundleComplete(data: BundleCompleteEvent) { const id = ++this.#currentBundleId; + + if (data.config.dev?.remote) { + void this.teardown(); + return; + } + this.emitReloadStartEvent({ type: "reloadStart", config: data.config, @@ -351,6 +258,8 @@ export class LocalRuntimeController extends RuntimeController { } #teardown = async (): Promise => { + logger.log(chalk.dim("⎔ Shutting down local server...")); + await this.#mf?.dispose(); this.#mf = undefined; }; diff --git a/packages/wrangler/src/api/startDevWorker/RemoteRuntimeController.ts b/packages/wrangler/src/api/startDevWorker/RemoteRuntimeController.ts index d5793df7183d..0a223c842ff4 100644 --- a/packages/wrangler/src/api/startDevWorker/RemoteRuntimeController.ts +++ b/packages/wrangler/src/api/startDevWorker/RemoteRuntimeController.ts @@ -1,5 +1,26 @@ +import chalk from "chalk"; +import { Mutex } from "miniflare"; +import { + createPreviewSession, + createWorkerPreview, +} from "../../dev/create-worker-preview"; +import { + createRemoteWorkerInit, + getWorkerAccountAndContext, + handlePreviewSessionCreationError, + handlePreviewSessionUploadError, +} from "../../dev/remote"; +import { MissingConfigError } from "../../errors"; +import { logger } from "../../logger"; +import { getAccessToken } from "../../user/access"; import { RuntimeController } from "./BaseController"; +import { castErrorCause } from "./events"; import { notImplemented } from "./NotImplementedError"; +import { convertBindingsToCfWorkerInitBindings, unwrapHook } from "./utils"; +import type { + CfPreviewSession, + CfPreviewToken, +} from "../../dev/create-worker-preview"; import type { BundleCompleteEvent, BundleStartEvent, @@ -7,24 +28,236 @@ import type { ReloadCompleteEvent, ReloadStartEvent, } from "./events"; +import type { Trigger } from "./types"; export class RemoteRuntimeController extends RuntimeController { + #abortController = new AbortController(); + + #currentBundleId = 0; + #mutex = new Mutex(); + + #session?: CfPreviewSession; + + async #previewSession( + props: Parameters[0] + ): Promise { + try { + const { workerAccount, workerContext } = + await getWorkerAccountAndContext(props); + + return await createPreviewSession( + workerAccount, + workerContext, + this.#abortController.signal + ); + } catch (err: unknown) { + handlePreviewSessionCreationError(err, props.accountId); + } + } + + async #previewToken( + props: Parameters[0] & + Parameters[0] + ): Promise { + try { + const init = await createRemoteWorkerInit({ + bundle: props.bundle, + modules: props.modules, + accountId: props.accountId, + name: props.name, + legacyEnv: props.legacyEnv, + env: props.env, + isWorkersSite: props.isWorkersSite, + assetPaths: props.assetPaths, + format: props.format, + bindings: props.bindings, + compatibilityDate: props.compatibilityDate, + compatibilityFlags: props.compatibilityFlags, + usageModel: props.usageModel, + }); + + const { workerAccount, workerContext } = await getWorkerAccountAndContext( + { + accountId: props.accountId, + env: props.env, + legacyEnv: props.legacyEnv, + host: props.host, + routes: props.routes, + sendMetrics: props.sendMetrics, + } + ); + if (!this.#session) { + return; + } + + const workerPreviewToken = await createWorkerPreview( + init, + workerAccount, + workerContext, + this.#session, + this.#abortController.signal + ); + + return workerPreviewToken; + } catch (err: unknown) { + const shouldRestartSession = handlePreviewSessionUploadError( + err, + props.accountId + ); + if (shouldRestartSession) { + this.#session = await this.#previewSession(props); + return this.#previewToken(props); + } + } + } + + async #onBundleComplete({ config, bundle }: BundleCompleteEvent, id: number) { + try { + const routes = config.triggers + ?.filter( + (trigger): trigger is Extract => + trigger.type === "route" + ) + .map((trigger) => { + const { type: _, ...route } = trigger; + if ( + "custom_domain" in route || + "zone_id" in route || + "zone_name" in route + ) { + return route; + } else { + return route.pattern; + } + }); + + if (!config.dev?.auth) { + throw new MissingConfigError("config.dev.auth"); + } + const auth = await unwrapHook(config.dev.auth); + + if (this.#session) { + logger.log(chalk.dim("⎔ Detected changes, restarted server.")); + } + + this.#session ??= await this.#previewSession({ + accountId: auth.accountId, + env: config.env, // deprecated service environments -- just pass it through for now + legacyEnv: config.legacyEnv, // wrangler environment -- just pass it through for now + host: config.dev.origin?.hostname, + routes, + sendMetrics: config.sendMetrics, + }); + + const bindings = ( + await convertBindingsToCfWorkerInitBindings(config.bindings) + ).bindings; + + const token = await this.#previewToken({ + bundle, + modules: bundle.modules, + accountId: auth.accountId, + name: config.name, + legacyEnv: config.legacyEnv, + env: config.env, + isWorkersSite: config.site !== undefined, + assetPaths: config.site?.path + ? { + baseDirectory: config.site.path, + assetDirectory: "", + excludePatterns: config.site.exclude ?? [], + includePatterns: config.site.include ?? [], + } + : undefined, + format: bundle.entry.format, + // TODO: Remove this passthrough + bindings: config._bindings ? config._bindings : bindings, + compatibilityDate: config.compatibilityDate, + compatibilityFlags: config.compatibilityFlags, + usageModel: config.usageModel, + routes, + }); + + // If we received a new `bundleComplete` event before we were able to + // dispatch a `reloadComplete` for this bundle, ignore this bundle. + // If `token` is undefined, we've surfaced a relevant error to the user above, so ignore this bundle + if (id !== this.#currentBundleId || !token) { + return; + } + + const accessToken = await getAccessToken(token.host); + + this.emitReloadCompleteEvent({ + type: "reloadComplete", + bundle, + config, + proxyData: { + userWorkerUrl: { + protocol: "https:", + hostname: token.host, + port: "443", + }, + userWorkerInspectorUrl: { + protocol: token.inspectorUrl.protocol, + hostname: token.inspectorUrl.hostname, + port: token.inspectorUrl.port.toString(), + pathname: token.inspectorUrl.pathname, + }, + headers: { + "cf-workers-preview-token": token.value, + ...(accessToken + ? { Cookie: `CF_Authorization=${accessToken}` } + : {}), + }, + liveReload: config.dev.liveReload, + proxyLogsToController: true, + internalDurableObjects: [], + entrypointAddresses: {}, + }, + }); + } catch (error) { + this.emitErrorEvent({ + type: "error", + reason: "Error reloading remote server", + cause: castErrorCause(error), + source: "RemoteRuntimeController", + data: undefined, + }); + } + } + // ****************** // Event Handlers // ****************** onBundleStart(_: BundleStartEvent) { - notImplemented(this.onBundleStart.name, this.constructor.name); + // Abort any previous operations when a new bundle is started + this.#abortController.abort(); + this.#abortController = new AbortController(); } - onBundleComplete(_: BundleCompleteEvent) { - notImplemented(this.onBundleComplete.name, this.constructor.name); + onBundleComplete(ev: BundleCompleteEvent) { + const id = ++this.#currentBundleId; + + if (!ev.config.dev?.remote) { + void this.teardown(); + return; + } + + this.emitReloadStartEvent({ + type: "reloadStart", + config: ev.config, + bundle: ev.bundle, + }); + + void this.#mutex.runWith(() => this.#onBundleComplete(ev, id)); } onPreviewTokenExpired(_: PreviewTokenExpiredEvent): void { notImplemented(this.onPreviewTokenExpired.name, this.constructor.name); } async teardown() { - notImplemented(this.teardown.name, this.constructor.name); + this.#session = undefined; + this.#abortController.abort(); } // ********************* diff --git a/packages/wrangler/src/api/startDevWorker/events.ts b/packages/wrangler/src/api/startDevWorker/events.ts index 983c9314785a..ccb30cb5cd72 100644 --- a/packages/wrangler/src/api/startDevWorker/events.ts +++ b/packages/wrangler/src/api/startDevWorker/events.ts @@ -146,7 +146,7 @@ export type UrlOriginAndPathnameParts = Pick< export type ProxyData = { userWorkerUrl: UrlOriginParts; userWorkerInspectorUrl: UrlOriginAndPathnameParts; - userWorkerInnerUrlOverrides: Partial; + userWorkerInnerUrlOverrides?: Partial; headers: Record; liveReload?: boolean; proxyLogsToController?: boolean; diff --git a/packages/wrangler/src/api/startDevWorker/types.ts b/packages/wrangler/src/api/startDevWorker/types.ts index b72e8e867687..355562894cc7 100644 --- a/packages/wrangler/src/api/startDevWorker/types.ts +++ b/packages/wrangler/src/api/startDevWorker/types.ts @@ -1,4 +1,4 @@ -import type { Config, RawConfig } from "../../config"; +import type { Config } from "../../config"; import type { CustomDomainRoute, ZoneIdRoute, @@ -19,6 +19,7 @@ import type { CfSendEmailBindings, CfService, CfVectorize, + CfWorkerInit, } from "../../deployment-bundle/worker"; import type { WorkerDefinition } from "../../dev-registry"; import type { CfAccount } from "../../dev/create-worker-preview"; @@ -47,7 +48,7 @@ export interface StartDevWorkerOptions { */ script: File; /** The configuration of the worker. */ - config?: File & { env?: string }; + config?: File; /** The compatibility date for the workerd runtime. */ compatibilityDate?: string; /** The compatibility flags for the workerd runtime. */ @@ -65,6 +66,22 @@ export interface StartDevWorkerOptions { exclude?: string[]; }; + // -- PASSTHROUGH -- FROM OLD CONFIG TO NEW CONFIG (TEMP) + /** Service environments. Providing support for existing workers with this property. Don't use this for new workers. */ + env?: string; + /** Wrangler environments, defaults to true. */ + legacyEnv?: boolean; + /** + * Whether Wrangler should send usage metrics to Cloudflare for this project. + * + * When defined this will override any user settings. + * Otherwise, Wrangler will use the user's preference. + */ + sendMetrics?: boolean; + usageModel?: "bundled" | "unbound"; + _bindings?: CfWorkerInit["bindings"]; // Type level constraint for bindings not sharing names + // --/ PASSTHROUGH -- + /** Options applying to the worker's build step. Applies to deploy and dev. */ build?: { /** Whether the worker and its dependencies are bundled. Defaults to true. */ @@ -112,7 +129,7 @@ export interface StartDevWorkerOptions { httpsCertPath?: string; }; /** Controls what request.url looks like inside the worker. */ - urlOverrides?: { hostname?: string; secure?: boolean }; // hostname: --host (remote)/--local-upstream (local), port: doesn't make sense in remote/=== server.port in local, secure: --upstream-protocol + origin?: { hostname?: string; secure?: boolean }; // hostname: --host (remote)/--local-upstream (local), port: doesn't make sense in remote/=== server.port in local, secure: --upstream-protocol /** A hook for outbound fetch calls from within the worker. */ outboundService?: ServiceFetch; /** An undici MockAgent to declaratively mock fetch calls to particular resources. */ diff --git a/packages/wrangler/src/api/startDevWorker/utils.ts b/packages/wrangler/src/api/startDevWorker/utils.ts index 694f0405d310..5d241e31ba2d 100644 --- a/packages/wrangler/src/api/startDevWorker/utils.ts +++ b/packages/wrangler/src/api/startDevWorker/utils.ts @@ -1,4 +1,7 @@ import assert from "node:assert"; +import { readFile } from "node:fs/promises"; +import type { CfWorkerInit } from "../../deployment-bundle/worker"; +import type { File, Hook, ServiceFetch, StartDevWorkerOptions } from "./types"; export type MaybePromise = T | Promise; export type DeferredPromise = { @@ -41,3 +44,163 @@ export function urlFromParts( return url; } + +export function unwrapHook( + hook: Hook +): T | Promise; +export function unwrapHook( + hook: Hook | undefined +): T | Promise | undefined; +export function unwrapHook(hook: Hook) { + return typeof hook === "function" ? hook() : hook; +} + +export async function getBinaryFileContents(file: File) { + if ("contents" in file) { + if (file.contents instanceof Buffer) { + return file.contents; + } + return Buffer.from(file.contents); + } + return readFile(file.path); +} + +export async function getTextFileContents(file: File) { + if ("contents" in file) { + if (typeof file.contents === "string") { + return file.contents; + } + if (file.contents instanceof Buffer) { + return file.contents.toString(); + } + return Buffer.from(file.contents).toString(); + } + return readFile(file.path, "utf8"); +} + +export async function convertBindingsToCfWorkerInitBindings( + inputBindings: StartDevWorkerOptions["bindings"] +): Promise<{ + bindings: CfWorkerInit["bindings"]; + fetchers: Record; +}> { + const bindings: CfWorkerInit["bindings"] = { + vars: undefined, + kv_namespaces: undefined, + send_email: undefined, + wasm_modules: undefined, + text_blobs: undefined, + browser: undefined, + ai: undefined, + version_metadata: undefined, + data_blobs: undefined, + durable_objects: undefined, + queues: undefined, + r2_buckets: undefined, + d1_databases: undefined, + vectorize: undefined, + constellation: undefined, + hyperdrive: undefined, + services: undefined, + analytics_engine_datasets: undefined, + dispatch_namespaces: undefined, + mtls_certificates: undefined, + logfwdr: undefined, + unsafe: undefined, + }; + + const fetchers: Record = {}; + + for (const [name, binding] of Object.entries(inputBindings ?? {})) { + if (binding.type === "plain_text") { + bindings.vars ??= {}; + bindings.vars[name] = binding.value; + } else if (binding.type === "json") { + bindings.vars ??= {}; + bindings.vars[name] = binding.value; + } else if (binding.type === "kv_namespace") { + bindings.kv_namespaces ??= []; + bindings.kv_namespaces.push({ ...binding, binding: name }); + } else if (binding.type === "send_email") { + bindings.send_email ??= []; + bindings.send_email.push({ ...binding, name: name }); + } else if (binding.type === "wasm_module") { + bindings.wasm_modules ??= {}; + bindings.wasm_modules[name] = await getBinaryFileContents(binding.source); + } else if (binding.type === "text_blob") { + bindings.text_blobs ??= {}; + + if (typeof binding.source.path === "string") { + bindings.text_blobs[name] = binding.source.path; + } else if ("contents" in binding.source) { + // TODO(maybe): write file contents to disk and set path + throw new Error( + "Cannot provide text_blob contents directly in CfWorkerInitBindings" + ); + } + } else if (binding.type === "data_blob") { + bindings.data_blobs ??= {}; + bindings.data_blobs[name] = await getBinaryFileContents(binding.source); + } else if (binding.type === "browser") { + bindings.browser = { binding: name }; + } else if (binding.type === "ai") { + bindings.ai = { binding: name }; + } else if (binding.type === "version_metadata") { + bindings.version_metadata = { binding: name }; + } else if (binding.type === "durable_object_namespace") { + bindings.durable_objects ??= { bindings: [] }; + bindings.durable_objects.bindings.push({ ...binding, name: name }); + } else if (binding.type === "queue") { + bindings.queues ??= []; + bindings.queues.push({ ...binding, binding: name }); + } else if (binding.type === "r2_bucket") { + bindings.r2_buckets ??= []; + bindings.r2_buckets.push({ ...binding, binding: name }); + } else if (binding.type === "d1") { + bindings.d1_databases ??= []; + bindings.d1_databases.push({ ...binding, binding: name }); + } else if (binding.type === "vectorize") { + bindings.vectorize ??= []; + bindings.vectorize.push({ ...binding, binding: name }); + } else if (binding.type === "constellation") { + bindings.constellation ??= []; + bindings.constellation.push({ ...binding, binding: name }); + } else if (binding.type === "hyperdrive") { + bindings.hyperdrive ??= []; + bindings.hyperdrive.push({ ...binding, binding: name }); + } else if (binding.type === "service") { + bindings.services ??= []; + bindings.services.push({ ...binding, binding: name }); + } else if (binding.type === "fetcher") { + fetchers[name] = binding.fetcher; + } else if (binding.type === "analytics_engine") { + bindings.analytics_engine_datasets ??= []; + bindings.analytics_engine_datasets.push({ ...binding, binding: name }); + } else if (binding.type === "dispatch_namespace") { + bindings.dispatch_namespaces ??= []; + bindings.dispatch_namespaces.push({ ...binding, binding: name }); + } else if (binding.type === "mtls_certificate") { + bindings.mtls_certificates ??= []; + bindings.mtls_certificates.push({ ...binding, binding: name }); + } else if (binding.type === "logfwdr") { + bindings.logfwdr ??= { bindings: [] }; + bindings.logfwdr.bindings.push({ ...binding, name: name }); + } else if (isUnsafeBindingType(binding.type)) { + bindings.unsafe ??= { + bindings: [], + metadata: undefined, + capnp: undefined, + }; + bindings.unsafe.bindings?.push({ + type: binding.type.slice("unsafe_".length), + name: name, + }); + } + } + + return { bindings, fetchers }; +} + +function isUnsafeBindingType(type: string): type is `unsafe_${string}` { + return type.startsWith("unsafe_"); +} diff --git a/packages/wrangler/src/dev.tsx b/packages/wrangler/src/dev.tsx index 9648e53d7517..e075fb7f3c53 100644 --- a/packages/wrangler/src/dev.tsx +++ b/packages/wrangler/src/dev.tsx @@ -287,6 +287,13 @@ export function devOptions(yargs: CommonYargsArgv) { "Show interactive dev session (defaults to true if the terminal supports interactivity)", type: "boolean", }) + .option("experimental-dev-env", { + alias: ["x-dev-env"], + type: "boolean", + describe: + "Use the experimental DevEnv instantiation (unified across wrangler dev and unstable_dev)", + default: false, + }) ); } @@ -512,6 +519,7 @@ export async function startDev(args: StartDevOptions) { sendMetrics={configParam.send_metrics} testScheduled={args.testScheduled} projectRoot={projectRoot} + experimentalDevEnv={args.experimentalDevEnv} /> ); } @@ -602,6 +610,7 @@ export async function startApiDev(args: StartDevOptions) { httpsKeyPath: args.httpsKeyPath, httpsCertPath: args.httpsCertPath, localUpstream: args.localUpstream ?? host ?? getInferredHost(routes), + local: args.local ?? !args.remote, localPersistencePath, liveReload: args.liveReload ?? false, accountId: @@ -633,12 +642,12 @@ export async function startApiDev(args: StartDevOptions) { showInteractiveDevSession: args.showInteractiveDevSession, forceLocal: args.forceLocal, enablePagesAssetsServiceBinding: args.enablePagesAssetsServiceBinding, - local: !args.remote, firstPartyWorker: configParam.first_party_worker, sendMetrics: configParam.send_metrics, testScheduled: args.testScheduled, disableDevRegistry: args.disableDevRegistry ?? false, projectRoot, + experimentalDevEnv: args.experimentalDevEnv, }); } @@ -684,7 +693,12 @@ function maskVars(bindings: CfWorkerInit["bindings"], configParam: Config) { return maskedVars; } -async function getHostAndRoutes(args: StartDevOptions, config: Config) { +async function getHostAndRoutes( + args: Pick, + config: Pick & { + dev: Pick; + } +) { // TODO: if worker_dev = false and no routes, then error (only for dev) // Compute zone info from the `host` and `route` args and config; const host = args.host || config.dev.host; @@ -695,7 +709,7 @@ async function getHostAndRoutes(args: StartDevOptions, config: Config) { } export function getInferredHost(routes: Route[] | undefined) { - if (routes) { + if (routes?.length) { const firstRoute = routes[0]; const host = getHostFromRoute(firstRoute); diff --git a/packages/wrangler/src/dev/dev.tsx b/packages/wrangler/src/dev/dev.tsx index 0496710dded0..3572c06b7082 100644 --- a/packages/wrangler/src/dev/dev.tsx +++ b/packages/wrangler/src/dev/dev.tsx @@ -16,6 +16,7 @@ import { useErrorHandler, withErrorBoundary } from "react-error-boundary"; import onExit from "signal-exit"; import { fetch } from "undici"; import { DevEnv } from "../api"; +import { createDeferred } from "../api/startDevWorker/utils"; import { runCustomBuild } from "../deployment-bundle/run-custom-build"; import { getBoundRegisteredWorkers, @@ -27,6 +28,7 @@ import { logger } from "../logger"; import { isNavigatorDefined } from "../navigator-user-agent"; import openInBrowser from "../open-in-browser"; import { getWranglerTmpDir } from "../paths"; +import { requireApiToken } from "../user"; import { openInspector } from "./inspect"; import { Local, maybeRegisterLocalWorker } from "./local"; import { Remote } from "./remote"; @@ -36,6 +38,7 @@ import type { ProxyData, ReloadCompleteEvent, StartDevWorkerOptions, + Trigger, } from "../api"; import type { Config } from "../config"; import type { Route } from "../config/environment"; @@ -183,6 +186,7 @@ export type DevProps = { sendMetrics: boolean | undefined; testScheduled: boolean | undefined; projectRoot: string | undefined; + experimentalDevEnv: boolean; }; export function DevImplementation(props: DevProps): JSX.Element { @@ -274,17 +278,84 @@ type DevSessionProps = DevProps & { }; function DevSession(props: DevSessionProps) { + const [accountId, setAccountIdStateOnly] = useState(props.accountId); + const accountIdDeferred = useMemo(() => createDeferred(), []); + const setAccountIdAndResolveDeferred = useCallback( + (newAccountId: string) => { + setAccountIdStateOnly(newAccountId); + accountIdDeferred.resolve(newAccountId); + }, + [setAccountIdStateOnly, accountIdDeferred] + ); + + useEffect(() => { + if (props.accountId) { + setAccountIdAndResolveDeferred(props.accountId); + } + + // run once on mount only (to synchronize the deferred value with the pre-selected props.accountId) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + const [devEnv] = useState(() => new DevEnv()); useEffect(() => { return () => { void devEnv.teardown(); }; }, [devEnv]); - const startDevWorkerOptions: StartDevWorkerOptions = useMemo( - () => ({ + const startDevWorkerOptions: StartDevWorkerOptions = useMemo(() => { + const routes = + props.routes?.map>((r) => + typeof r === "string" + ? { + type: "route", + pattern: r, + } + : { type: "route", ...r } + ) ?? []; + const queueConsumers = + props.queueConsumers?.map>( + (c) => ({ + ...c, + type: "queue-consumer", + }) + ) ?? []; + + const crons = + props.crons?.map>((c) => ({ + cron: c, + type: "cron", + })) ?? []; + return { name: props.name ?? "worker", + compatibilityDate: props.compatibilityDate, + compatibilityFlags: props.compatibilityFlags, script: { contents: "" }, + _bindings: props.bindings, + triggers: [...routes, ...queueConsumers, ...crons], + env: props.env, + legacyEnv: props.legacyEnv, + sendMetrics: props.sendMetrics, + usageModel: props.usageModel, + site: + props.isWorkersSite && props.assetPaths + ? { + path: path.join( + props.assetPaths.baseDirectory, + props.assetPaths?.assetDirectory + ), + include: props.assetPaths.includePatterns, + exclude: props.assetPaths.excludePatterns, + } + : undefined, dev: { + auth: async () => { + return { + accountId: await accountIdDeferred.promise, + apiToken: requireApiToken(), + }; + }, + remote: !props.local, server: { hostname: props.initialIp, port: props.initialPort, @@ -295,25 +366,38 @@ function DevSession(props: DevSessionProps) { inspector: { port: props.inspectorPort, }, - urlOverrides: { + origin: { secure: props.localProtocol === "https", hostname: props.localUpstream, }, liveReload: props.liveReload, }, - }), - [ - props.name, - props.initialIp, - props.initialPort, - props.localProtocol, - props.httpsKeyPath, - props.httpsCertPath, - props.localUpstream, - props.inspectorPort, - props.liveReload, - ] - ); + } satisfies StartDevWorkerOptions; + }, [ + props.name, + props.compatibilityDate, + props.compatibilityFlags, + props.bindings, + props.routes, + props.env, + props.legacyEnv, + props.sendMetrics, + props.usageModel, + props.isWorkersSite, + props.assetPaths, + accountIdDeferred, + props.local, + props.initialIp, + props.initialPort, + props.localProtocol, + props.httpsKeyPath, + props.httpsCertPath, + props.localUpstream, + props.inspectorPort, + props.liveReload, + props.queueConsumers, + props.crons, + ]); const onBundleStart = useCallback(() => { devEnv.proxy.onBundleStart({ @@ -321,9 +405,28 @@ function DevSession(props: DevSessionProps) { config: startDevWorkerOptions, }); }, [devEnv, startDevWorkerOptions]); + const onBundleComplete = useCallback( + (bundle: EsbuildBundle) => { + if (props.experimentalDevEnv) { + devEnv.runtimes.forEach((runtime) => + runtime.onBundleComplete({ + type: "bundleComplete", + config: startDevWorkerOptions, + bundle, + }) + ); + } else { + devEnv.proxy.onReloadStart({ + type: "reloadStart", + config: startDevWorkerOptions, + bundle, + }); + } + }, + [devEnv, startDevWorkerOptions, props.experimentalDevEnv] + ); const esbuildStartTimeoutRef = useRef>(); const latestReloadCompleteEvent = useRef(); - const bundle = useRef>(); const onCustomBuildEnd = useCallback(() => { const TIMEOUT = 300; // TODO: find a lower bound for this value @@ -354,16 +457,6 @@ function DevSession(props: DevSessionProps) { // also, if the timeout fired before esbuild started, for some reason, firing this event again is needed onBundleStart(); }, [esbuildStartTimeoutRef, onBundleStart]); - const onReloadStart = useCallback( - (esbuildBundle: EsbuildBundle) => { - devEnv.proxy.onReloadStart({ - type: "reloadStart", - config: startDevWorkerOptions, - bundle: esbuildBundle, - }); - }, - [devEnv, startDevWorkerOptions] - ); useCustomBuild(props.entry, props.build, onBundleStart, onCustomBuildEnd); @@ -383,7 +476,7 @@ function DevSession(props: DevSessionProps) { }); }, [devEnv, startDevWorkerOptions]); - bundle.current = useEsbuild({ + const bundle = useEsbuild({ entry: props.entry, destination: directory, jsxFactory: props.jsxFactory, @@ -412,19 +505,13 @@ function DevSession(props: DevSessionProps) { experimentalLocal: props.experimentalLocal, projectRoot: props.projectRoot, onStart: onEsbuildStart, + onComplete: onBundleComplete, defineNavigatorUserAgent: isNavigatorDefined( props.compatibilityDate, props.compatibilityFlags ), }); - // this suffices as an onEsbuildEnd callback - useEffect(() => { - if (bundle.current) { - onReloadStart(bundle.current); - } - }, [onReloadStart, bundle]); - // TODO(queues) support remote wrangler dev if ( !props.local && @@ -466,11 +553,11 @@ function DevSession(props: DevSessionProps) { ); } - if (bundle.current) { + if (bundle) { latestReloadCompleteEvent.current = { type: "reloadComplete", config: startDevWorkerOptions, - bundle: bundle.current, + bundle, proxyData, }; @@ -485,7 +572,7 @@ function DevSession(props: DevSessionProps) { return props.local ? ( ) : ( ); } diff --git a/packages/wrangler/src/dev/local.tsx b/packages/wrangler/src/dev/local.tsx index 791a9b120f14..8e3a3e1596e2 100644 --- a/packages/wrangler/src/dev/local.tsx +++ b/packages/wrangler/src/dev/local.tsx @@ -53,6 +53,7 @@ export interface LocalProps { testScheduled?: boolean; sourceMapPath: string | undefined; services: Config["services"] | undefined; + experimentalDevEnv: boolean; } // TODO(soon): we should be able to remove this function when we fully migrate @@ -141,15 +142,6 @@ export function maybeRegisterLocalWorker( } export function Local(props: LocalProps) { - useLocalWorker(props); - - return null; -} - -function useLocalWorker(props: LocalProps) { - const miniflareServerRef = useRef(); - const removeMiniflareServerExitListenerRef = useRef<() => void>(); - useEffect(() => { if (props.bindings.services && props.bindings.services.length > 0) { logger.warn( @@ -170,6 +162,19 @@ function useLocalWorker(props: LocalProps) { } }, [props.bindings.durable_objects?.bindings]); + if (!props.experimentalDevEnv) { + // this condition WILL be static and therefore safe to wrap around a hook + // eslint-disable-next-line react-hooks/rules-of-hooks + useLocalWorker(props); + } + + return null; +} + +function useLocalWorker(props: LocalProps) { + const miniflareServerRef = useRef(); + const removeMiniflareServerExitListenerRef = useRef<() => void>(); + useEffect(() => { const abortController = new AbortController(); diff --git a/packages/wrangler/src/dev/miniflare.ts b/packages/wrangler/src/dev/miniflare.ts index 91c1f53512c6..513c8dbafd42 100644 --- a/packages/wrangler/src/dev/miniflare.ts +++ b/packages/wrangler/src/dev/miniflare.ts @@ -19,7 +19,6 @@ import { import { ModuleTypeToRuleType } from "../deployment-bundle/module-collection"; import { withSourceURLs } from "../deployment-bundle/source-url"; import { UserError } from "../errors"; -import { getHttpsOptions } from "../https-options"; import { logger } from "../logger"; import { getSourceMappedString } from "../sourcemap"; import { updateCheck } from "../update-check"; @@ -832,18 +831,6 @@ export async function buildMiniflareOptions( const sitesOptions = buildSitesOptions(config); const persistOptions = buildPersistOptions(config.localPersistencePath); - let httpsOptions: { httpsKey: string; httpsCert: string } | undefined; - if (config.localProtocol === "https") { - const cert = await getHttpsOptions( - config.httpsKeyPath, - config.httpsCertPath - ); - httpsOptions = { - httpsKey: cert.key, - httpsCert: cert.cert, - }; - } - const options: MiniflareOptions = { host: config.initialIp, port: config.initialPort, @@ -856,7 +843,6 @@ export async function buildMiniflareOptions( verbose: logger.loggerLevel === "debug", handleRuntimeStdio, - ...httpsOptions, ...persistOptions, workers: [ { diff --git a/packages/wrangler/src/dev/remote.tsx b/packages/wrangler/src/dev/remote.tsx index e0acd5955748..ebc59230cff2 100644 --- a/packages/wrangler/src/dev/remote.tsx +++ b/packages/wrangler/src/dev/remote.tsx @@ -1,3 +1,4 @@ +import assert from "node:assert"; import path from "node:path"; import { Text } from "ink"; import SelectInput from "ink-select-input"; @@ -41,6 +42,63 @@ import type { } from "./create-worker-preview"; import type { EsbuildBundle } from "./use-esbuild"; +export function handlePreviewSessionUploadError( + err: unknown, + accountId: string +): boolean { + assert(err && typeof err === "object"); + // we want to log the error, but not end the process + // since it could recover after the developer fixes whatever's wrong + // instead of logging the raw API error to the user, + // give them friendly instructions + if ((err as unknown as { code: string }).code !== "ABORT_ERR") { + // code 10049 happens when the preview token expires + if ("code" in err && err.code === 10049) { + logger.log("Preview token expired, fetching a new one"); + + // since we want a new preview token when this happens, + // lets increment the counter, and trigger a rerun of + // the useEffect above + return true; + } else if (!handleUserFriendlyError(err as ParseError, accountId)) { + logger.error("Error on remote worker:", err); + } + } + return false; +} + +export function handlePreviewSessionCreationError( + err: unknown, + accountId: string +) { + assert(err && typeof err === "object"); + // instead of logging the raw API error to the user, + // give them friendly instructions + // for error 10063 (workers.dev subdomain required) + if ("code" in err && err.code === 10063) { + const errorMessage = + "Error: You need to register a workers.dev subdomain before running the dev command in remote mode"; + const solutionMessage = + "You can either enable local mode by pressing l, or register a workers.dev subdomain here:"; + const onboardingLink = `https://dash.cloudflare.com/${accountId}/workers/onboarding`; + logger.error(`${errorMessage}\n${solutionMessage}\n${onboardingLink}`); + } else if ( + "cause" in err && + (err.cause as { code: string; hostname: string })?.code === "ENOTFOUND" + ) { + logger.error( + `Could not access \`${(err.cause as { code: string; hostname: string }).hostname}\`. Make sure the domain is set up to be proxied by Cloudflare.\nFor more details, refer to https://developers.cloudflare.com/workers/configuration/routing/routes/#set-up-a-route` + ); + } else if (err instanceof UserError) { + logger.error(err.message); + } + // we want to log the error, but not end the process + // since it could recover after the developer fixes whatever's wrong + else if ((err as { code: string }).code !== "ABORT_ERR") { + logger.error("Error while creating remote dev session:", err); + } +} + interface RemoteProps { name: string | undefined; bundle: EsbuildBundle | undefined; @@ -68,33 +126,39 @@ interface RemoteProps { | undefined; sourceMapPath: string | undefined; sendMetrics: boolean | undefined; + + setAccountId: (accountId: string) => void; + experimentalDevEnv: boolean; } export function Remote(props: RemoteProps) { - const [accountId, setAccountId] = useState(props.accountId); const accountChoicesRef = useRef>(); const [accountChoices, setAccountChoices] = useState(); - useWorker({ - name: props.name, - bundle: props.bundle, - format: props.format, - modules: props.bundle ? props.bundle.modules : [], - accountId, - bindings: props.bindings, - assetPaths: props.assetPaths, - isWorkersSite: props.isWorkersSite, - compatibilityDate: props.compatibilityDate, - compatibilityFlags: props.compatibilityFlags, - usageModel: props.usageModel, - env: props.env, - legacyEnv: props.legacyEnv, - host: props.host, - routes: props.routes, - onReady: props.onReady, - sendMetrics: props.sendMetrics, - port: props.port, - }); + if (!props.experimentalDevEnv) { + // this condition WILL be static and therefore safe to wrap around a hook + // eslint-disable-next-line react-hooks/rules-of-hooks + useWorker({ + name: props.name, + bundle: props.bundle, + format: props.format, + modules: props.bundle ? props.bundle.modules : [], + accountId: props.accountId, + bindings: props.bindings, + assetPaths: props.assetPaths, + isWorkersSite: props.isWorkersSite, + compatibilityDate: props.compatibilityDate, + compatibilityFlags: props.compatibilityFlags, + usageModel: props.usageModel, + env: props.env, + legacyEnv: props.legacyEnv, + host: props.host, + routes: props.routes, + onReady: props.onReady, + sendMetrics: props.sendMetrics, + port: props.port, + }); + } const errorHandler = useErrorHandler(); @@ -115,7 +179,7 @@ export function Remote(props: RemoteProps) { id: accounts[0].id, name: accounts[0].name, }); - setAccountId(accounts[0].id); + props.setAccountId(accounts[0].id); } else { setAccountChoices(accounts); } @@ -128,12 +192,12 @@ export function Remote(props: RemoteProps) { // If we have not already chosen an account and there are multiple accounts available // allow the users to select one. - return accountId === undefined && accountChoices !== undefined ? ( + return props.accountId === undefined && accountChoices !== undefined ? ( { saveAccountToCache(selectedAccount); - setAccountId(selectedAccount.id); + props.setAccountId(selectedAccount.id); }} onError={(err) => errorHandler(err)} > @@ -203,30 +267,8 @@ export function useWorker( } start().catch((err) => { - // instead of logging the raw API error to the user, - // give them friendly instructions - // for error 10063 (workers.dev subdomain required) - if (err.code === 10063) { - const errorMessage = - "Error: You need to register a workers.dev subdomain before running the dev command in remote mode"; - const solutionMessage = - "You can either enable local mode by pressing l, or register a workers.dev subdomain here:"; - const onboardingLink = `https://dash.cloudflare.com/${props.accountId}/workers/onboarding`; - logger.error(`${errorMessage}\n${solutionMessage}\n${onboardingLink}`); - } else if ( - (err.cause as { code: string; hostname: string })?.code === "ENOTFOUND" - ) { - logger.error( - `Could not access \`${err.cause.hostname}\`. Make sure the domain is set up to be proxied by Cloudflare.\nFor more details, refer to https://developers.cloudflare.com/workers/configuration/routing/routes/#set-up-a-route` - ); - } else if (err instanceof UserError) { - logger.error(err.message); - } - // we want to log the error, but not end the process - // since it could recover after the developer fixes whatever's wrong - else if ((err as { code: string }).code !== "ABORT_ERR") { - logger.error("Error while creating remote dev session:", err); - } + assert(props.accountId); + handlePreviewSessionCreationError(err, props.accountId); }); return () => { @@ -355,22 +397,16 @@ export function useWorker( ); } start().catch((err) => { - // we want to log the error, but not end the process - // since it could recover after the developer fixes whatever's wrong - // instead of logging the raw API error to the user, - // give them friendly instructions - if ((err as unknown as { code: string }).code !== "ABORT_ERR") { - // code 10049 happens when the preview token expires - if (err.code === 10049) { - logger.log("Preview token expired, fetching a new one"); - - // since we want a new preview token when this happens, - // lets increment the counter, and trigger a rerun of - // the useEffect above - setRestartCounter((prevCount) => prevCount + 1); - } else if (!handleUserFriendlyError(err, props.accountId)) { - logger.error("Error on remote worker:", err); - } + assert(props.accountId); + const shouldRestartSession = handlePreviewSessionUploadError( + err, + props.accountId + ); + if (shouldRestartSession) { + // since we want a new preview token when this happens, + // lets increment the counter, and trigger a rerun of + // the useEffect above + setRestartCounter((prevCount) => prevCount + 1); } }); @@ -559,7 +595,7 @@ export async function getRemotePreviewToken(props: RemoteProps) { }); } -async function createRemoteWorkerInit(props: { +export async function createRemoteWorkerInit(props: { bundle: EsbuildBundle; modules: CfModule[]; accountId: string; @@ -710,7 +746,7 @@ function ChooseAccount(props: { * messages, does not perform any logic other than logging errors. * @returns if the error was handled or not */ -function handleUserFriendlyError(error: ParseError, accountId?: string) { +export function handleUserFriendlyError(error: ParseError, accountId?: string) { switch ((error as unknown as { code: number }).code) { // code 10021 is a validation error case 10021: { diff --git a/packages/wrangler/src/dev/start-server.ts b/packages/wrangler/src/dev/start-server.ts index 2449776c20f7..8d2374e683e9 100644 --- a/packages/wrangler/src/dev/start-server.ts +++ b/packages/wrangler/src/dev/start-server.ts @@ -22,6 +22,11 @@ import { import { logger } from "../logger"; import { isNavigatorDefined } from "../navigator-user-agent"; import { getWranglerTmpDir } from "../paths"; +import { + getAccountChoices, + requireApiToken, + saveAccountToCache, +} from "../user"; import { localPropsToConfigBundle, maybeRegisterLocalWorker } from "./local"; import { DEFAULT_WORKER_NAME, MiniflareServer } from "./miniflare"; import { startRemoteServer } from "./remote"; @@ -99,12 +104,31 @@ export async function startDevServer( inspector: { port: props.inspectorPort, }, - urlOverrides: { + origin: { secure: props.upstreamProtocol === "https", hostname: props.localUpstream, }, liveReload: props.liveReload, remote: !props.local, + auth: async () => { + let accountId = props.accountId; + if (accountId === undefined) { + const accountChoices = await getAccountChoices(); + if (accountChoices.length === 1) { + saveAccountToCache({ + id: accountChoices[0].id, + name: accountChoices[0].name, + }); + accountId = accountChoices[0].id; + } else { + throw logger.error( + "In a non-interactive environment, it is mandatory to specify an account ID, either by assigning its value to CLOUDFLARE_ACCOUNT_ID, or as `account_id` in your `wrangler.toml` file." + ); + } + } + + return { accountId, apiToken: requireApiToken() }; + }, }, }; @@ -148,6 +172,26 @@ export async function startDevServer( ), }); + if (props.experimentalDevEnv) { + devEnv.runtimes.forEach((runtime) => { + runtime.onBundleComplete({ + type: "bundleComplete", + config: startDevWorkerOptions, + bundle, + }); + }); + + // to comply with the current contract of this function, call props.onReady on reloadComplete + devEnv.runtimes.forEach((runtime) => { + runtime.on("reloadComplete", async (ev) => { + const { proxyWorker } = await devEnv.proxy.ready.promise; + const url = await proxyWorker.ready; + + props.onReady?.(url.hostname, parseInt(url.port), ev.proxyData); + }); + }); + } + if (props.local) { // temp: fake these events by calling the handler directly devEnv.proxy.onReloadStart({ @@ -209,13 +253,13 @@ export async function startDevServer( workerDefinitions, sourceMapPath: bundle?.sourceMapPath, services: props.bindings.services, + experimentalDevEnv: props.experimentalDevEnv, }); return { stop: async () => { await Promise.all([stop(), stopWorkerRegistry(), devEnv.teardown()]); }, - // TODO: inspectorUrl, }; } else { const { stop } = await startRemoteServer({ @@ -260,12 +304,14 @@ export async function startDevServer( }, sourceMapPath: bundle?.sourceMapPath, sendMetrics: props.sendMetrics, + experimentalDevEnv: props.experimentalDevEnv, + setAccountId: /* noop */ () => {}, }); + return { stop: async () => { await Promise.all([stop(), stopWorkerRegistry(), devEnv.teardown()]); }, - // TODO: inspectorUrl, }; } } diff --git a/packages/wrangler/src/dev/use-esbuild.ts b/packages/wrangler/src/dev/use-esbuild.ts index 6975a4dd73fd..549c4723ffbe 100644 --- a/packages/wrangler/src/dev/use-esbuild.ts +++ b/packages/wrangler/src/dev/use-esbuild.ts @@ -60,6 +60,7 @@ export function useEsbuild({ experimentalLocal, projectRoot, onStart, + onComplete, defineNavigatorUserAgent, }: { entry: Entry; @@ -87,6 +88,7 @@ export function useEsbuild({ experimentalLocal: boolean | undefined; projectRoot: string | undefined; onStart: () => void; + onComplete: (bundle: EsbuildBundle) => void; defineNavigatorUserAgent: boolean; }): EsbuildBundle | undefined { const [bundle, setBundle] = useState(); @@ -288,5 +290,12 @@ export function useEsbuild({ onStart, defineNavigatorUserAgent, ]); + + useEffect(() => { + if (bundle) { + onComplete(bundle); + } + }, [onComplete, bundle]); + return bundle; } diff --git a/packages/wrangler/src/environment-variables/factory.ts b/packages/wrangler/src/environment-variables/factory.ts index faba2beb1705..e9367c632a50 100644 --- a/packages/wrangler/src/environment-variables/factory.ts +++ b/packages/wrangler/src/environment-variables/factory.ts @@ -1,5 +1,3 @@ -import { logger } from "../logger"; - type VariableNames = | "CLOUDFLARE_ACCOUNT_ID" | "CLOUDFLARE_API_BASE_URL" @@ -80,7 +78,8 @@ export function getEnvironmentVariableFactory({ if (!hasWarned) { // Only show the warning once. hasWarned = true; - logger.warn( + // Ideally we'd use `logger.warn` here, but that creates a circular dependency that Vitest is unable to resolve + console.warn( `Using "${deprecatedName}" environment variable. This is deprecated. Please use "${variableName}", instead.` ); } diff --git a/packages/wrangler/src/errors.ts b/packages/wrangler/src/errors.ts index 0f31354953e8..68d10df821eb 100644 --- a/packages/wrangler/src/errors.ts +++ b/packages/wrangler/src/errors.ts @@ -45,6 +45,12 @@ export class JsonFriendlyFatalError extends FatalError { } } +export class MissingConfigError extends Error { + constructor(key: string) { + super(`Missing config value for ${key}`); + } +} + /** * Create either a FatalError or JsonFriendlyFatalError depending upon `isJson` parameter. * diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3b99f5c19851..d6ac63f2848b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -668,6 +668,12 @@ importers: specifier: workspace:* version: link:../../packages/wrangler + fixtures/unstable_dev: + dependencies: + wrangler: + specifier: workspace:* + version: link:../../packages/wrangler + fixtures/vitest-pool-workers-examples: devDependencies: '@cloudflare/vitest-pool-workers': @@ -730,8 +736,6 @@ importers: fixtures/workers-chat-demo: {} - fixtures/wrangler-dev-api-app: {} - packages/cli: devDependencies: '@clack/core':