diff --git a/.changeset/khaki-oranges-obey.md b/.changeset/khaki-oranges-obey.md new file mode 100644 index 000000000000..959aaf049243 --- /dev/null +++ b/.changeset/khaki-oranges-obey.md @@ -0,0 +1,9 @@ +--- +"wrangler": major +--- + +feature: enable local development with Miniflare 3 and `workerd` by default + +`wrangler dev` now runs fully-locally by default, using the open-source Cloudflare Workers runtime [`workerd`](https://github.com/cloudflare/workerd). +To restore the previous behaviour of running on a remote machine with access to production data, use the new `--remote` flag. +The `--local` and `--experimental-local` flags have been deprecated, as this behaviour is now the default, and will be removed in the next major version. diff --git a/.gitignore b/.gitignore index 8665df367863..eb4a11561a33 100644 --- a/.gitignore +++ b/.gitignore @@ -197,3 +197,5 @@ packages/quick-edit/web # VSCode Theme *.vsix vendor/vscode + +.wrangler/ diff --git a/fixtures/d1-worker-app/package.json b/fixtures/d1-worker-app/package.json index c2d02fd7363e..116cf34ee7c9 100644 --- a/fixtures/d1-worker-app/package.json +++ b/fixtures/d1-worker-app/package.json @@ -7,7 +7,6 @@ "author": "", "main": "src/index.js", "scripts": { - "check:type": "tsc", "db:query": "wrangler d1 execute UPDATE_THIS_FOR_REMOTE_USE --local --command='SELECT * FROM Customers'", "db:query-json": "wrangler d1 execute UPDATE_THIS_FOR_REMOTE_USE --local --command='SELECT * FROM Customers' --json", "db:reset": "wrangler d1 execute UPDATE_THIS_FOR_REMOTE_USE --local --file=./schema.sql", diff --git a/fixtures/d1-worker-app/tsconfig.json b/fixtures/d1-worker-app/tsconfig.json deleted file mode 100644 index 6eb14e3584b7..000000000000 --- a/fixtures/d1-worker-app/tsconfig.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2020", - "esModuleInterop": true, - "module": "CommonJS", - "lib": ["ES2020"], - "types": ["node"], - "moduleResolution": "node", - "noEmit": true - }, - "include": ["tests", "../../node-types.d.ts"] -} diff --git a/fixtures/external-durable-objects-app/tests/tsconfig.json b/fixtures/external-durable-objects-app/tests/tsconfig.json index 81914cebf00c..e913d064d192 100644 --- a/fixtures/external-durable-objects-app/tests/tsconfig.json +++ b/fixtures/external-durable-objects-app/tests/tsconfig.json @@ -1,7 +1,8 @@ { "extends": "../tsconfig.json", "compilerOptions": { - "types": ["node"] + "types": ["node"], + "esModuleInterop": true }, "include": ["**/*.ts", "../../../node-types.d.ts"] } diff --git a/fixtures/no-bundle-import/src/index.test.ts b/fixtures/no-bundle-import/src/index.test.ts index 73ce94396806..492efe0c0a10 100644 --- a/fixtures/no-bundle-import/src/index.test.ts +++ b/fixtures/no-bundle-import/src/index.test.ts @@ -1,23 +1,14 @@ import path from "path"; import { describe, expect, test, beforeAll, afterAll } from "vitest"; -import { unstable_dev } from "../../../packages/wrangler/wrangler-dist/cli.js"; -import type { UnstableDevWorker } from "../../../packages/wrangler/wrangler-dist/cli.js"; +import { unstable_dev } from "wrangler"; +import type { UnstableDevWorker } from "wrangler"; describe("Worker", () => { let worker: UnstableDevWorker; - // TODO: Remove this when `workerd` has Windows support - if (process.env.RUNNER_OS === "Windows") { - test("dummy windows test", () => { - expect(process.env.RUNNER_OS).toStrictEqual("Windows"); - }); - return; - } - beforeAll(async () => { worker = await unstable_dev(path.resolve(__dirname, "index.js"), { bundle: false, - experimental: { experimentalLocal: true }, }); }, 30_000); diff --git a/fixtures/node-app-pages/package.json b/fixtures/node-app-pages/package.json index 58e5b9d078f3..bd43391c1c5c 100644 --- a/fixtures/node-app-pages/package.json +++ b/fixtures/node-app-pages/package.json @@ -15,7 +15,6 @@ }, "devDependencies": { "@cloudflare/workers-types": "^4.20221111.1", - "@types/node": "^17.0.33", "undici": "^5.9.1" }, "engines": { diff --git a/fixtures/pages-functions-app/tests/tsconfig.json b/fixtures/pages-functions-app/tests/tsconfig.json index 81914cebf00c..e913d064d192 100644 --- a/fixtures/pages-functions-app/tests/tsconfig.json +++ b/fixtures/pages-functions-app/tests/tsconfig.json @@ -1,7 +1,8 @@ { "extends": "../tsconfig.json", "compilerOptions": { - "types": ["node"] + "types": ["node"], + "esModuleInterop": true }, "include": ["**/*.ts", "../../../node-types.d.ts"] } diff --git a/fixtures/pages-functions-with-routes-app/tests/tsconfig.json b/fixtures/pages-functions-with-routes-app/tests/tsconfig.json index 81914cebf00c..e913d064d192 100644 --- a/fixtures/pages-functions-with-routes-app/tests/tsconfig.json +++ b/fixtures/pages-functions-with-routes-app/tests/tsconfig.json @@ -1,7 +1,8 @@ { "extends": "../tsconfig.json", "compilerOptions": { - "types": ["node"] + "types": ["node"], + "esModuleInterop": true }, "include": ["**/*.ts", "../../../node-types.d.ts"] } diff --git a/fixtures/pages-plugin-mounted-on-root-app/tests/tsconfig.json b/fixtures/pages-plugin-mounted-on-root-app/tests/tsconfig.json index 81914cebf00c..e913d064d192 100644 --- a/fixtures/pages-plugin-mounted-on-root-app/tests/tsconfig.json +++ b/fixtures/pages-plugin-mounted-on-root-app/tests/tsconfig.json @@ -1,7 +1,8 @@ { "extends": "../tsconfig.json", "compilerOptions": { - "types": ["node"] + "types": ["node"], + "esModuleInterop": true }, "include": ["**/*.ts", "../../../node-types.d.ts"] } diff --git a/fixtures/pages-simple-assets/tests/tsconfig.json b/fixtures/pages-simple-assets/tests/tsconfig.json index 81914cebf00c..e913d064d192 100644 --- a/fixtures/pages-simple-assets/tests/tsconfig.json +++ b/fixtures/pages-simple-assets/tests/tsconfig.json @@ -1,7 +1,8 @@ { "extends": "../tsconfig.json", "compilerOptions": { - "types": ["node"] + "types": ["node"], + "esModuleInterop": true }, "include": ["**/*.ts", "../../../node-types.d.ts"] } diff --git a/fixtures/pages-ws-app/package.json b/fixtures/pages-ws-app/package.json index d3864ecbf43a..a19837e83a88 100644 --- a/fixtures/pages-ws-app/package.json +++ b/fixtures/pages-ws-app/package.json @@ -13,6 +13,7 @@ "test:ci": "npx vitest" }, "devDependencies": { + "miniflare": "^3.0.0", "ws": "^8.8.0" }, "engines": { diff --git a/fixtures/pages-ws-app/tests/index.test.ts b/fixtures/pages-ws-app/tests/index.test.ts index 37c5473c6457..c889c13af439 100644 --- a/fixtures/pages-ws-app/tests/index.test.ts +++ b/fixtures/pages-ws-app/tests/index.test.ts @@ -1,6 +1,6 @@ import { fork, spawnSync } from "child_process"; import * as path from "path"; -import { upgradingFetch } from "@miniflare/web-sockets"; +import { fetch } from "miniflare"; import { describe, expect, it, beforeAll, afterAll } from "vitest"; import type { ChildProcess } from "child_process"; @@ -50,7 +50,7 @@ describe.concurrent.skip("Pages Functions", () => { it("understands normal fetches", async () => { await readyPromise; - const response = await upgradingFetch(`http://${ip}:${port}/`); + const response = await fetch(`http://${ip}:${port}/`); expect(response.headers.get("x-proxied")).toBe("true"); const text = await response.text(); expect(text).toContain("Hello, world!"); @@ -58,7 +58,7 @@ describe.concurrent.skip("Pages Functions", () => { it("understands websocket fetches", async () => { await readyPromise; - const response = await upgradingFetch(`http://${ip}:${port}/ws`, { + const response = await fetch(`http://${ip}:${port}/ws`, { headers: { Upgrade: "websocket" }, }); expect(response.status).toBe(101); diff --git a/fixtures/worker-app/src/index.js b/fixtures/worker-app/src/index.js index 4462f2654b41..a110bb489381 100644 --- a/fixtures/worker-app/src/index.js +++ b/fixtures/worker-app/src/index.js @@ -20,9 +20,9 @@ export default { request.cf ); - await fetch(new URL("https://example.com")); + await fetch(new URL("http://example.com")); await fetch( - new Request("https://example.com", { method: "POST", body: "foo" }) + new Request("http://example.com", { method: "POST", body: "foo" }) ); return new Response(`${request.url} ${now()}`); diff --git a/node-types.d.ts b/node-types.d.ts index 36b941565111..b57e69dc1fa9 100644 --- a/node-types.d.ts +++ b/node-types.d.ts @@ -1,36 +1,16 @@ // https://github.com/cloudflare/workers-sdk/pull/2496#discussion_r1062516883 -import { - Event as WorkerEvent, - WebAssembly as WorkerWebAssembly, -} from "@cloudflare/workers-types"; +import { WebAssembly as WorkerWebAssembly } from "@cloudflare/workers-types"; import type { - EventListenerOrEventListenerObject, EventTargetAddEventListenerOptions, EventTargetEventListenerOptions, } from "@cloudflare/workers-types"; declare global { - // `Event` and `EventTarget` have been global since Node 15, but aren't - // included in `@types/node`. - class Event extends WorkerEvent {} type EventListenerOptions = EventTargetEventListenerOptions; type AddEventListenerOptions = EventTargetAddEventListenerOptions; // (can't use EventTarget from "@cloudflare/workers-types" as it's event map // type parameters are incompatible with `tinybench`, a `vitest` dependency) - class EventTarget { - addEventListener( - type: string, - callback: EventListenerOrEventListenerObject | null, - options?: EventTargetAddEventListenerOptions | boolean - ): void; - dispatchEvent(event: Event): boolean; - removeEventListener( - type: string, - callback: EventListenerOrEventListenerObject | null, - options?: EventTargetEventListenerOptions | boolean - ): void; - } // `WebAssembly` has been global since Node 8, but isn't included in // `@types/node`. diff --git a/package.json b/package.json index ae05d5c60bf6..c2337fa4271d 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,7 @@ "@changesets/changelog-github": "^0.4.5", "@changesets/cli": "^2.22.0", "@types/jest": "^28.1.6", - "@types/node": "^16.11.11", + "@types/node": "^20.0.0", "@typescript-eslint/eslint-plugin": "^5.46.0", "@typescript-eslint/parser": "^5.46.0", "cross-env": "^7.0.3", @@ -45,7 +45,7 @@ "rimraf": "^3.0.2", "typescript": "^4.8.4", "vite": "^4.0.4", - "vitest": "^0.26.3" + "vitest": "^0.31.0" }, "devDependencies": { "@cloudflare/workers-types": "^4.20221111.1", diff --git a/packages/edge-preview-authenticated-proxy/package.json b/packages/edge-preview-authenticated-proxy/package.json index 23a7443e2eb5..3743a87d7479 100644 --- a/packages/edge-preview-authenticated-proxy/package.json +++ b/packages/edge-preview-authenticated-proxy/package.json @@ -11,7 +11,7 @@ "@cloudflare/workers-types": "^4.20230321.0", "cookie": "^0.5.0", "toucan-js": "^3.1.0", - "vitest": "^0.29.8", + "vitest": "^0.31.0", "wrangler": "*" } } diff --git a/packages/edge-preview-authenticated-proxy/tests/index.test.ts b/packages/edge-preview-authenticated-proxy/tests/index.test.ts index 2cb4f1994957..6922fe1e8327 100644 --- a/packages/edge-preview-authenticated-proxy/tests/index.test.ts +++ b/packages/edge-preview-authenticated-proxy/tests/index.test.ts @@ -102,6 +102,7 @@ describe("Preview Worker", () => { } ); expect(Object.fromEntries([...resp.headers.entries()])).toMatchObject({ + "content-length": "0", location: "/hello?world", "set-cookie": "token=%7B%22token%22%3A%22TEST_TOKEN%22%2C%22remote%22%3A%22http%3A%2F%2F127.0.0.1%3A6756%22%7D; Domain=preview.devprod.cloudflare.dev; HttpOnly; Secure; SameSite=None", @@ -119,7 +120,7 @@ describe("Preview Worker", () => { } ); - const json = await resp.json(); + const json = (await resp.json()) as { headers: string[][]; url: string }; expect(Object.fromEntries([...json.headers])).toMatchObject({ "cf-workers-preview-token": "TEST_TOKEN", }); diff --git a/packages/pages-shared/asset-server/responses.ts b/packages/pages-shared/asset-server/responses.ts index 74a08a4e521d..1cf33f8c5076 100644 --- a/packages/pages-shared/asset-server/responses.ts +++ b/packages/pages-shared/asset-server/responses.ts @@ -1,12 +1,12 @@ type HeadersInit = ConstructorParameters[0]; function mergeHeaders(base: HeadersInit, extra: HeadersInit) { - base = new Headers(base ?? {}); - extra = new Headers(extra ?? {}); + const baseHeaders = new Headers(base ?? {}); + const extraHeaders = new Headers(extra ?? {}); return new Headers({ - ...Object.fromEntries(base.entries()), - ...Object.fromEntries(extra.entries()), + ...Object.fromEntries(baseHeaders.entries()), + ...Object.fromEntries(extraHeaders.entries()), }); } diff --git a/packages/pages-shared/environment-polyfills/miniflare-tre.ts b/packages/pages-shared/environment-polyfills/miniflare-tre.ts deleted file mode 100644 index 1d6d0ebc5d18..000000000000 --- a/packages/pages-shared/environment-polyfills/miniflare-tre.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { polyfill } from "."; - -export default async () => { - const mf = await import("@miniflare/tre"); - - polyfill({ - fetch: mf.fetch, - Headers: mf.Headers, - Request: mf.Request, - Response: mf.Response, - }); -}; diff --git a/packages/pages-shared/environment-polyfills/miniflare.ts b/packages/pages-shared/environment-polyfills/miniflare.ts index 6f819951fc36..ad050cbe144e 100644 --- a/packages/pages-shared/environment-polyfills/miniflare.ts +++ b/packages/pages-shared/environment-polyfills/miniflare.ts @@ -1,8 +1,7 @@ import { polyfill } from "."; export default async () => { - const mf = await import("@miniflare/core"); - + const mf = await import("miniflare"); polyfill({ fetch: mf.fetch, Headers: mf.Headers, diff --git a/packages/pages-shared/environment-polyfills/types.ts b/packages/pages-shared/environment-polyfills/types.ts index 91ff51340e02..216fd46666f6 100644 --- a/packages/pages-shared/environment-polyfills/types.ts +++ b/packages/pages-shared/environment-polyfills/types.ts @@ -1,35 +1,25 @@ import { - Headers as MiniflareHeaders, - Request as MiniflareRequest, - Response as MiniflareResponse, -} from "@miniflare/core"; -import { HTMLRewriter as MiniflareHTMLRewriter } from "@miniflare/html-rewriter"; -import type { CacheInterface as MiniflareCacheInterface } from "@miniflare/cache"; -import type { fetch as miniflareFetch } from "@miniflare/core"; -import type { ReadableStream as SimilarReadableStream } from "stream/web"; + Headers as WorkerHeaders, + Request as WorkerRequest, + Response as WorkerResponse, + HTMLRewriter as WorkerHTMLRewriter, +} from "@cloudflare/workers-types/experimental"; +import type { + fetch as workerFetch, + ReadableStream as WorkerReadableStream, + CacheStorage as WorkerCacheStorage, +} from "@cloudflare/workers-types/experimental"; declare global { - const fetch: typeof miniflareFetch; - class Headers extends MiniflareHeaders {} - class Request extends MiniflareRequest {} - class Response extends MiniflareResponse {} + const fetch: typeof workerFetch; + class Headers extends WorkerHeaders {} + class Request extends WorkerRequest {} + class Response extends WorkerResponse {} - type CacheInterface = Omit & { - match( - ...args: Parameters - ): Promise; - }; - - class CacheStorage { - get default(): CacheInterface; - open(cacheName: string): Promise; - } - - class HTMLRewriter extends MiniflareHTMLRewriter { - transform(response: Response): Response; - } - - type ReadableStream = SimilarReadableStream; + // Not polyfilled + type ReadableStream = WorkerReadableStream; + type CacheStorage = WorkerCacheStorage; + class HTMLRewriter extends WorkerHTMLRewriter {} } export type PolyfilledRuntimeEnvironment = { diff --git a/packages/pages-shared/package.json b/packages/pages-shared/package.json index 071aa2da79a1..423276316382 100644 --- a/packages/pages-shared/package.json +++ b/packages/pages-shared/package.json @@ -43,11 +43,10 @@ ] }, "dependencies": { - "@miniflare/core": "2.14.0" + "miniflare": "^3.0.0" }, "devDependencies": { - "@miniflare/cache": "2.14.0", - "@miniflare/html-rewriter": "2.14.0", + "@cloudflare/workers-types": "^4.20230511.0", "@types/service-worker-mock": "^2.0.1", "concurrently": "^7.3.0", "glob": "^8.0.3", diff --git a/packages/wrangler/package.json b/packages/wrangler/package.json index f9018825c5f1..5aefb269bfed 100644 --- a/packages/wrangler/package.json +++ b/packages/wrangler/package.json @@ -98,13 +98,10 @@ "@cloudflare/kv-asset-handler": "^0.2.0", "@esbuild-plugins/node-globals-polyfill": "^0.1.1", "@esbuild-plugins/node-modules-polyfill": "^0.1.4", - "@miniflare/core": "2.14.0", - "@miniflare/d1": "2.14.0", - "@miniflare/durable-objects": "2.14.0", "blake3-wasm": "^2.1.5", "chokidar": "^3.5.3", "esbuild": "0.16.3", - "miniflare": "2.14.0", + "miniflare": "^3.0.0", "nanoid": "^3.3.3", "path-to-regexp": "^6.2.0", "selfsigned": "^2.0.1", @@ -113,10 +110,9 @@ }, "devDependencies": { "@cloudflare/types": "^6.18.4", - "@cloudflare/workers-types": "^4.20221111.1", + "@cloudflare/workers-types": "^4.20230511.0", "@iarna/toml": "^3.0.0", "@microsoft/api-extractor": "^7.28.3", - "@miniflare/tre": "3.0.0-next.13", "@types/better-sqlite3": "^7.6.0", "@types/busboy": "^1.5.0", "@types/command-exists": "^1.2.0", @@ -181,7 +177,7 @@ "ts-dedent": "^2.2.0", "undici": "5.20.0", "update-check": "^1.5.4", - "vitest": "^0.29.2", + "vitest": "^0.31.0", "ws": "^8.5.0", "xdg-app-paths": "^7.3.0", "yargs": "^17.4.1", diff --git a/packages/wrangler/scripts/bundle.ts b/packages/wrangler/scripts/bundle.ts index 6b492ed3e5c8..d921a2fb457a 100644 --- a/packages/wrangler/scripts/bundle.ts +++ b/packages/wrangler/scripts/bundle.ts @@ -79,36 +79,14 @@ async function buildMain(flags: BuildFlags = {}) { await fs.copyFile(wasmSrc, wasmDst); } -async function buildMiniflareCLI(flags: BuildFlags = {}) { - await build({ - entryPoints: ["./src/miniflare-cli/index.ts"], - bundle: true, - outfile: "./miniflare-dist/index.mjs", - platform: "node", - format: "esm", - external: EXTERNAL_DEPENDENCIES, - sourcemap: process.env.SOURCEMAPS !== "false", - define: { - "process.env.NODE_ENV": `'${process.env.NODE_ENV || "production"}'`, - }, - watch: flags.watch ? watchLogger("./miniflare-dist/index.mjs") : false, - }); -} - async function run() { // main cli await buildMain(); - // custom miniflare cli - await buildMiniflareCLI(); - // After built once completely, rerun them both in watch mode if (process.argv.includes("--watch")) { console.log("Built. Watching for changes..."); - await Promise.all([ - buildMain({ watch: true }), - buildMiniflareCLI({ watch: true }), - ]); + await buildMain({ watch: true }); } } diff --git a/packages/wrangler/scripts/deps.ts b/packages/wrangler/scripts/deps.ts index 7eff339111ca..bf86b0dd4e0a 100644 --- a/packages/wrangler/scripts/deps.ts +++ b/packages/wrangler/scripts/deps.ts @@ -10,10 +10,6 @@ export const EXTERNAL_DEPENDENCIES = [ "esbuild", "blake3-wasm", "miniflare", - "@miniflare/core", - "@miniflare/durable-objects", - "@miniflare/tre", // TODO: remove once Miniflare 3 moved in miniflare package - "@miniflare/web-sockets", // todo - bundle miniflare too "selfsigned", "source-map", diff --git a/packages/wrangler/src/__tests__/api-dev.test.ts b/packages/wrangler/src/__tests__/api-dev.test.ts index 61ddeab303af..885a8b3cae69 100644 --- a/packages/wrangler/src/__tests__/api-dev.test.ts +++ b/packages/wrangler/src/__tests__/api-dev.test.ts @@ -3,6 +3,7 @@ import { Request } from "undici"; import { unstable_dev } from "../api"; import { runInTempDir } from "./helpers/run-in-tmp"; +jest.unmock("child_process"); jest.unmock("undici"); describe("unstable_dev", () => { @@ -42,14 +43,13 @@ describe("unstable_dev", () => { const worker = await unstable_dev( "src/__tests__/helpers/worker-scripts/hello-world-worker.js", { - port: 9191, experimental: { disableExperimentalWarning: true, disableDevRegistry: true, }, } ); - expect(worker.port).toBe(9191); + expect(worker.port).not.toBe(0); await worker.stop(); }); }); @@ -113,15 +113,13 @@ describe("unstable dev fetch input parsing", () => { }; `; fs.writeFileSync("index.js", scriptContent); - const port = 21213; const worker = await unstable_dev("index.js", { - port, experimental: { disableExperimentalWarning: true, disableDevRegistry: true, }, }); - const req = new Request("http://0.0.0.0:21213/test", { + const req = new Request(`http://127.0.0.1:${worker.port}/test`, { method: "POST", }); const resp = await worker.fetch(req); diff --git a/packages/wrangler/src/__tests__/api-devregistry.test.ts b/packages/wrangler/src/__tests__/api-devregistry.test.ts index a7cefe6f41e4..dc69c6f75e63 100644 --- a/packages/wrangler/src/__tests__/api-devregistry.test.ts +++ b/packages/wrangler/src/__tests__/api-devregistry.test.ts @@ -1,6 +1,7 @@ import { fetch } from "undici"; import { unstable_dev } from "../api"; +jest.unmock("child_process"); jest.unmock("undici"); /** @@ -70,17 +71,21 @@ describe("multi-worker testing", () => { it("should be able to stop and start the server with no warning logs", async () => { // Spy on all the console methods let logs = ""; + // Resolve when we see `[mf:inf] GET / 200 OK` message. This log is sent in + // a `waitUntil()`, which may execute after tests complete. To stop Jest + // complaining about logging after a test, wait for this log. + let requestResolve: () => void; + const requestPromise = new Promise( + (resolve) => (requestResolve = resolve) + ); (["debug", "info", "log", "warn", "error"] as const).forEach((method) => - jest - .spyOn(console, method) - .mockImplementation((...args: unknown[]) => (logs += `\n${args}`)) + jest.spyOn(console, method).mockImplementation((...args: unknown[]) => { + logs += `\n${args}`; + // Regexp ignores colour codes + if (/\[mf:inf].+GET.+\/.+200.+OK/.test(String(args))) requestResolve(); + }) ); - // Spy on the std out that is written to by Miniflare 2 - jest - .spyOn(process.stdout, "write") - .mockImplementation((chunk: unknown) => ((logs += `\n${chunk}`), true)); - async function startWorker() { return await unstable_dev( "src/__tests__/helpers/worker-scripts/hello-world-worker.js", @@ -114,6 +119,8 @@ describe("multi-worker testing", () => { expect(logs).not.toMatch( /Failed to register worker in local service registry/ ); + + await requestPromise; } finally { await worker?.stop(); } diff --git a/packages/wrangler/src/__tests__/api.test.ts b/packages/wrangler/src/__tests__/api.test.ts index 1bbf27786f2b..44224ef80746 100644 --- a/packages/wrangler/src/__tests__/api.test.ts +++ b/packages/wrangler/src/__tests__/api.test.ts @@ -1,3 +1,4 @@ +import assert from "node:assert"; import { Request } from "undici"; import { parseRequestInput } from "../api/dev"; @@ -70,16 +71,17 @@ describe("parseRequestInput for fetch on unstable dev", () => { const [input, init] = parseRequestInput( "0.0.0.0", 8080, - new Request("http://cloudflare.com/test?q=testparam", { method: "POST" }) + new Request("https://cloudflare.com/test?q=testparam", { method: "POST" }) ); - expect(init).toBeUndefined(); - expect(input).toBeInstanceOf(Request); - // We don't expect the request to be modified - expect((input as Request).url).toMatchInlineSnapshot( - `"http://cloudflare.com/test?q=testparam"` + expect(input).toMatchInlineSnapshot( + `"http://0.0.0.0:8080/test?q=testparam"` + ); + assert(init instanceof Request); + expect(init.method).toBe("POST"); + expect(init.headers.get("MF-Original-URL")).toMatchInlineSnapshot( + `"https://cloudflare.com/test?q=testparam"` ); - expect((input as Request).method).toMatchInlineSnapshot(`"POST"`); }); it("should parse to give https url with localProtocol = https", () => { diff --git a/packages/wrangler/src/__tests__/dev.test.tsx b/packages/wrangler/src/__tests__/dev.test.tsx index 052a531aaa72..66697720e1c3 100644 --- a/packages/wrangler/src/__tests__/dev.test.tsx +++ b/packages/wrangler/src/__tests__/dev.test.tsx @@ -43,7 +43,7 @@ describe("wrangler dev", () => { it("should kick you to the login flow when running wrangler dev in remote mode without authorization", async () => { fs.writeFileSync("index.js", `export default {};`); await expect( - runWrangler("dev index.js") + runWrangler("dev --remote index.js") ).rejects.toThrowErrorMatchingInlineSnapshot( `"You must be logged in to use wrangler dev in remote mode. Try logging in, or run wrangler dev --local."` ); @@ -119,7 +119,7 @@ describe("wrangler dev", () => { usage_model: "unbound", }); fs.writeFileSync("index.js", `export default {};`); - await runWrangler("dev --local"); + await runWrangler("dev"); expect((Dev as jest.Mock).mock.calls[0][0].usageModel).toEqual("unbound"); }); }); @@ -207,7 +207,7 @@ describe("wrangler dev", () => { main: "index.js", routes: ["http://5.some-host.com/some/path/*"], }); - await runWrangler("dev"); + await runWrangler("dev --remote"); expect((Dev as jest.Mock).mock.calls[0][0]).toEqual( expect.objectContaining({ host: "5.some-host.com", @@ -224,7 +224,7 @@ describe("wrangler dev", () => { }); fs.writeFileSync("index.js", `export default {};`); mockGetZones("some-host.com", [{ id: "some-zone-id" }]); - await runWrangler("dev --host some-host.com"); + await runWrangler("dev --remote --host some-host.com"); expect((Dev as jest.Mock).mock.calls[0][0]).toEqual( expect.objectContaining({ host: "some-host.com", @@ -322,7 +322,7 @@ describe("wrangler dev", () => { ], }); fs.writeFileSync("index.js", `export default {};`); - await runWrangler("dev"); + await runWrangler("dev --remote"); expect((Dev as jest.Mock).mock.calls[0][0]).toEqual( expect.objectContaining({ host: "some-domain.com", @@ -343,7 +343,7 @@ describe("wrangler dev", () => { }); fs.writeFileSync("index.js", `export default {};`); mockGetZones("some-zone.com", [{ id: "a-zone-id" }]); - await runWrangler("dev"); + await runWrangler("dev --remote"); expect((Dev as jest.Mock).mock.calls[0][0]).toEqual( expect.objectContaining({ // note that it uses the provided zone_name as a host too @@ -380,7 +380,7 @@ describe("wrangler dev", () => { ], }); await fs.promises.writeFile("index.js", `export default {};`); - await expect(runWrangler("dev")).rejects.toEqual( + await expect(runWrangler("dev --remote")).rejects.toEqual( new Error("Could not find zone for subdomain.does-not-exist.com") ); }); @@ -396,7 +396,7 @@ describe("wrangler dev", () => { ], }); await fs.promises.writeFile("index.js", `export default {};`); - await expect(runWrangler("dev")).rejects.toEqual( + await expect(runWrangler("dev --remote")).rejects.toEqual( new Error("Could not find zone for does-not-exist.com") ); }); @@ -467,7 +467,7 @@ describe("wrangler dev", () => { }) ); - await runWrangler("dev --host 111.222.333.some-host.com"); + await runWrangler("dev --remote --host 111.222.333.some-host.com"); const devMockCall = (Dev as jest.Mock).mock.calls[0][0]; @@ -490,7 +490,7 @@ describe("wrangler dev", () => { main: "index.js", routes: ["http://5.some-host.com/some/path/*"], }); - await runWrangler("dev"); + await runWrangler("dev --remote"); expect((Dev as jest.Mock).mock.calls[0][0]).toEqual( expect.objectContaining({ host: "5.some-host.com", @@ -505,7 +505,7 @@ describe("wrangler dev", () => { main: "index.js", route: "https://4.some-host.com/some/path/*", }); - await runWrangler("dev"); + await runWrangler("dev --remote"); expect((Dev as jest.Mock).mock.calls[0][0]).toEqual( expect.objectContaining({ host: "4.some-host.com", @@ -520,7 +520,9 @@ describe("wrangler dev", () => { main: "index.js", route: "https://4.some-host.com/some/path/*", }); - await runWrangler("dev --routes http://3.some-host.com/some/path/*"); + await runWrangler( + "dev --remote --routes http://3.some-host.com/some/path/*" + ); expect((Dev as jest.Mock).mock.calls[0][0]).toEqual( expect.objectContaining({ host: "3.some-host.com", @@ -538,7 +540,9 @@ describe("wrangler dev", () => { }, route: "4.some-host.com/some/path/*", }); - await runWrangler("dev --routes http://3.some-host.com/some/path/*"); + await runWrangler( + "dev --remote --routes http://3.some-host.com/some/path/*" + ); expect((Dev as jest.Mock).mock.calls[0][0]).toEqual( expect.objectContaining({ host: "2.some-host.com", @@ -557,7 +561,7 @@ describe("wrangler dev", () => { route: "4.some-host.com/some/path/*", }); await runWrangler( - "dev --routes http://3.some-host.com/some/path/* --host 1.some-host.com" + "dev --remote --routes http://3.some-host.com/some/path/* --host 1.some-host.com" ); expect((Dev as jest.Mock).mock.calls[0][0]).toEqual( expect.objectContaining({ @@ -575,7 +579,7 @@ describe("wrangler dev", () => { fs.writeFileSync("index.js", `export default {};`); mockGetZones("some-host.com", []); await expect( - runWrangler("dev --host some-host.com") + runWrangler("dev --remote --host some-host.com") ).rejects.toThrowErrorMatchingInlineSnapshot( `"Could not find zone for some-host.com"` ); @@ -586,7 +590,7 @@ describe("wrangler dev", () => { main: "index.js", }); fs.writeFileSync("index.js", `export default {};`); - await runWrangler("dev --host some-host.com --local"); + await runWrangler("dev --host some-host.com"); expect((Dev as jest.Mock).mock.calls[0][0].zone).toEqual(undefined); }); }); @@ -600,7 +604,7 @@ describe("wrangler dev", () => { }, }); fs.writeFileSync("index.js", `export default {};`); - await runWrangler("dev --local"); + await runWrangler("dev"); expect((Dev as jest.Mock).mock.calls[0][0].localUpstream).toEqual( "2.some-host.com" ); @@ -612,7 +616,7 @@ describe("wrangler dev", () => { route: "https://4.some-host.com/some/path/*", }); fs.writeFileSync("index.js", `export default {};`); - await runWrangler("dev --local"); + await runWrangler("dev"); expect((Dev as jest.Mock).mock.calls[0][0].host).toEqual( "4.some-host.com" ); @@ -624,7 +628,7 @@ describe("wrangler dev", () => { route: `2.some-host.com`, }); fs.writeFileSync("index.js", `export default {};`); - await runWrangler("dev --local-upstream some-host.com --local"); + await runWrangler("dev --local-upstream some-host.com"); expect((Dev as jest.Mock).mock.calls[0][0].localUpstream).toEqual( "some-host.com" ); @@ -875,32 +879,6 @@ describe("wrangler dev", () => { }); describe("inspector port", () => { - it("should connect WebSocket server with --experimental-local", async () => { - writeWranglerToml({ - main: "./index.js", - }); - fs.writeFileSync( - "index.js", - `export default { - async fetch(request, env, ctx ){ - console.log('Hello World LOGGING'); - }, - };` - ); - await runWrangler("dev --experimental-local"); - - expect((Dev as jest.Mock).mock.calls[0][0].inspectorPort).toEqual(9229); - expect(std).toMatchInlineSnapshot(` - Object { - "debug": "", - "err": "", - "info": "", - "out": "", - "warn": "", - } - `); - }); - it("should use 9229 as the default port", async () => { writeWranglerToml({ main: "index.js", @@ -1252,9 +1230,7 @@ describe("wrangler dev", () => { --jsx-factory The function that is called for each JSX element [string] --jsx-fragment The function that is called for each JSX fragment [string] --tsconfig Path to a custom tsconfig.json file [string] - -l, --local Run on my machine [boolean] [default: false] - --experimental-local Run on my machine using the Cloudflare Workers runtime [boolean] [default: false] - --experimental-local-remote-kv Read/write KV data from/to real namespaces on the Cloudflare network [boolean] [default: false] + -r, --remote Run on the global Cloudflare network with access to production resources [boolean] [default: false] --minify Minify the script [boolean] --node-compat Enable Node.js compatibility [boolean] --persist-to Specify directory to use for local persistence (defaults to .wrangler/state) [string] diff --git a/packages/wrangler/src/__tests__/helpers/run-in-tmp.ts b/packages/wrangler/src/__tests__/helpers/run-in-tmp.ts index 72ce345f9507..1204c264bd4f 100644 --- a/packages/wrangler/src/__tests__/helpers/run-in-tmp.ts +++ b/packages/wrangler/src/__tests__/helpers/run-in-tmp.ts @@ -15,6 +15,7 @@ export function runInTempDir({ homedir } = { homedir: "./home" }) { ); process.chdir(tmpDir); + process.env.PWD = tmpDir; // The path that is returned from `homedir()` should be absolute. const absHomedir = path.resolve(tmpDir, homedir); // Override where the home directory is so that we can write our own user config, @@ -32,6 +33,7 @@ export function runInTempDir({ homedir } = { homedir: "./home" }) { afterEach(() => { if (fs.existsSync(tmpDir)) { process.chdir(originalCwd); + process.env.PWD = originalCwd; fs.rmSync(tmpDir, { recursive: true }); } }); diff --git a/packages/wrangler/src/__tests__/jest.setup.ts b/packages/wrangler/src/__tests__/jest.setup.ts index 47cfca19de86..f4bfd943a07c 100644 --- a/packages/wrangler/src/__tests__/jest.setup.ts +++ b/packages/wrangler/src/__tests__/jest.setup.ts @@ -42,10 +42,33 @@ jest.mock("child_process", () => { }); jest.mock("ws", () => { - return { + // `miniflare` needs to use the real `ws` module, but tail tests require us + // to mock `ws`. `esbuild-jest` won't let us use type annotations in our tests + // if those files contain `jest.mock()` calls, so we mock here, pass-through + // by default, and allow mocking conditionally. + const realModule = jest.requireActual("ws"); + const module = { __esModule: true, - default: MockWebSocket, + useOriginal: true, }; + Object.defineProperties(module, { + default: { + get() { + return module.useOriginal ? realModule.default : MockWebSocket; + }, + }, + WebSocket: { + get() { + return module.useOriginal ? realModule.WebSocket : MockWebSocket; + }, + }, + WebSocketServer: { + get() { + return realModule.WebSocketServer; + }, + }, + }); + return module; }); jest.mock("undici", () => { diff --git a/packages/wrangler/src/__tests__/middleware.scheduled.test.ts b/packages/wrangler/src/__tests__/middleware.scheduled.test.ts index ab557cb7d756..8d67034640cc 100644 --- a/packages/wrangler/src/__tests__/middleware.scheduled.test.ts +++ b/packages/wrangler/src/__tests__/middleware.scheduled.test.ts @@ -2,6 +2,7 @@ import * as fs from "node:fs"; import { unstable_dev } from "../api"; import { runInTempDir } from "./helpers/run-in-tmp"; +jest.unmock("child_process"); jest.unmock("undici"); describe("run scheduled events with middleware", () => { diff --git a/packages/wrangler/src/__tests__/middleware.test.ts b/packages/wrangler/src/__tests__/middleware.test.ts index edde99e1ecc1..30940c98cf6f 100644 --- a/packages/wrangler/src/__tests__/middleware.test.ts +++ b/packages/wrangler/src/__tests__/middleware.test.ts @@ -2,6 +2,7 @@ import * as fs from "node:fs"; import { unstable_dev } from "../api"; import { runInTempDir } from "./helpers/run-in-tmp"; +jest.unmock("child_process"); jest.unmock("undici"); describe("workers change behaviour with middleware with wrangler dev", () => { diff --git a/packages/wrangler/src/__tests__/pages-deployment-tail.test.ts b/packages/wrangler/src/__tests__/pages-deployment-tail.test.ts index 14dd73c40a85..e4aa6750cf47 100644 --- a/packages/wrangler/src/__tests__/pages-deployment-tail.test.ts +++ b/packages/wrangler/src/__tests__/pages-deployment-tail.test.ts @@ -29,6 +29,13 @@ describe("pages deployment tail", () => { process.env.CF_PAGES = "1"; }); + beforeEach(() => { + // You may be inclined to change this to `jest.requireMock ()`. Do it, I + // dare you... Have fun fixing this tests :) + // (hint: https://github.com/aelbore/esbuild-jest/blob/daa5847b3b382d9ddf6cc26e60ad949d202c4461/src/index.ts#L33) + const mockWs = jest["requireMock"]("ws"); + mockWs.useOriginal = false; + }); afterAll(() => { delete process.env.CF_PAGES; }); diff --git a/packages/wrangler/src/__tests__/pages/functions-build.test.ts b/packages/wrangler/src/__tests__/pages/functions-build.test.ts index d2ca3233b85e..e0c0084971cf 100644 --- a/packages/wrangler/src/__tests__/pages/functions-build.test.ts +++ b/packages/wrangler/src/__tests__/pages/functions-build.test.ts @@ -1,5 +1,10 @@ -import { execSync } from "node:child_process"; -import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { + existsSync, + mkdirSync, + readFileSync, + writeFileSync, + readdirSync, +} from "node:fs"; import { endEventLoop } from "../helpers/end-event-loop"; import { mockConsoleMethods } from "../helpers/mock-console"; import { runInTempDir } from "../helpers/run-in-tmp"; @@ -162,11 +167,12 @@ describe("functions build", () => { ✨ Compiled Worker successfully" `); - expect(execSync("ls dist", { encoding: "utf-8" })).toMatchInlineSnapshot(` - "e8f0f80fe25d71a0fc2b9a08c877020211192308-name.wasm - f7ff9e8b7bb2e09b70935a5d785e0cc5d9d0abf0-greeting.wasm - index.js - " + expect(readdirSync("dist").sort()).toMatchInlineSnapshot(` + Array [ + "e8f0f80fe25d71a0fc2b9a08c877020211192308-name.wasm", + "f7ff9e8b7bb2e09b70935a5d785e0cc5d9d0abf0-greeting.wasm", + "index.js", + ] `); }); diff --git a/packages/wrangler/src/__tests__/tail.test.ts b/packages/wrangler/src/__tests__/tail.test.ts index f98d2058f7d2..c637fdfdb8df 100644 --- a/packages/wrangler/src/__tests__/tail.test.ts +++ b/packages/wrangler/src/__tests__/tail.test.ts @@ -20,6 +20,14 @@ import type { RequestInit } from "undici"; import type WebSocket from "ws"; describe("tail", () => { + beforeEach(() => { + // You may be inclined to change this to `jest.requireMock ()`. Do it, I + // dare you... Have fun fixing this tests :) + // (hint: https://github.com/aelbore/esbuild-jest/blob/daa5847b3b382d9ddf6cc26e60ad949d202c4461/src/index.ts#L33) + const mockWs = jest["requireMock"]("ws"); + mockWs.useOriginal = false; + }); + beforeEach(() => msw.use(...mswSucessScriptHandlers)); runInTempDir(); mockAccountId(); diff --git a/packages/wrangler/src/api/dev.ts b/packages/wrangler/src/api/dev.ts index dc2df6076e67..fec2cdb70b85 100644 --- a/packages/wrangler/src/api/dev.ts +++ b/packages/wrangler/src/api/dev.ts @@ -4,6 +4,7 @@ import { logger } from "../logger"; import type { Environment } from "../config"; import type { Rule } from "../config/environment"; +import type { StartDevOptions } from "../dev"; import type { EnablePagesAssetsServiceBindingOptions } from "../miniflare-cli/types"; import type { CfModule } from "../worker"; import type { RequestInit, Response, RequestInfo } from "undici"; @@ -57,8 +58,6 @@ export interface UnstableDevOptions { disableExperimentalWarning?: boolean; // Disables wrangler's warning when unstable APIs are used. disableDevRegistry?: boolean; // Disables wrangler's support multi-worker setups. May reduce flakiness when used in tests in CI. enablePagesAssetsServiceBinding?: EnablePagesAssetsServiceBindingOptions; - experimentalLocal?: boolean; // Use Miniflare 3 instead of Miniflare 2 - experimentalLocalRemoteKv?: boolean; forceLocal?: boolean; liveReload?: boolean; // Auto reload HTML pages when change is detected in local mode showInteractiveDevSession?: boolean; @@ -109,8 +108,6 @@ export async function unstable_dev( testScheduled, // 2. options for alpha/beta products/libs d1Databases, - experimentalLocal, - experimentalLocalRemoteKv, enablePagesAssetsServiceBinding, } = experimentalOptions; @@ -125,188 +122,122 @@ export async function unstable_dev( `unstable_dev() is experimental\nunstable_dev()'s behaviour will likely change in future releases` ); } - let readyPort: number; - let readyAddress: string; + + type ReadyInformation = { address: string; port: number }; + let readyResolve: (info: ReadyInformation) => void; + const readyPromise = new Promise((resolve) => { + readyResolve = resolve; + }); + + const defaultLogLevel = testMode ? "none" : "log"; + + const devOptions: StartDevOptions = { + script: script, + inspect: false, + logLevel: options?.logLevel ?? defaultLogLevel, + _: [], + $0: "", + port: options?.port ?? 0, + remote: false, + local: undefined, + experimentalLocal: undefined, + d1Databases, + disableDevRegistry, + testScheduled: testScheduled ?? false, + enablePagesAssetsServiceBinding, + forceLocal, + liveReload, + showInteractiveDevSession, + onReady: (address, port) => { + readyResolve({ address, port }); + }, + config: options?.config, + env: options?.env, + processEntrypoint, + additionalModules, + bundle: options?.bundle, + compatibilityDate: options?.compatibilityDate, + compatibilityFlags: options?.compatibilityFlags, + ip: options?.ip, + inspectorPort: options?.inspectorPort, + v: undefined, + localProtocol: options?.localProtocol, + assets: options?.assets, + site: options?.site, // Root folder of static assets for Workers Sites + siteInclude: options?.siteInclude, // Array of .gitignore-style patterns that match file or directory names from the sites directory. Only matched items will be uploaded. + siteExclude: options?.siteExclude, // Array of .gitignore-style patterns that match file or directory names from the sites directory. Matched items will not be uploaded. + nodeCompat: options?.nodeCompat, // Enable Node.js compatibility + persist: options?.persist, // Enable persistence for local mode, using default path: .wrangler/state + persistTo: options?.persistTo, // Specify directory to use for local persistence (implies --persist) + experimentalJsonConfig: undefined, + name: undefined, + noBundle: false, + format: undefined, + latest: false, + routes: undefined, + host: undefined, + localUpstream: undefined, + experimentalPublic: undefined, + upstreamProtocol: undefined, + var: undefined, + define: undefined, + jsxFactory: undefined, + jsxFragment: undefined, + tsconfig: undefined, + minify: undefined, + experimentalEnableLocalPersistence: undefined, + legacyEnv: undefined, + public: undefined, + ...options, + }; + //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. if (testMode) { - //in testMode, we can run multiple wranglers in parallel, but rebuilds might not work out of the box - return new Promise((resolve) => { - //lmao - return new Promise>>((ready) => { - // once the devServer is ready for requests, we resolve the inner promise - // (where we've named the resolve function "ready") - const devServer = startApiDev({ - script: script, - inspect: false, - logLevel: "none", - _: [], - $0: "", - port: options?.port ?? 0, - local: true, - d1Databases, - disableDevRegistry, - testScheduled: testScheduled ?? false, - experimentalLocal: experimentalLocal ?? false, - experimentalLocalRemoteKv: experimentalLocalRemoteKv ?? false, - enablePagesAssetsServiceBinding, - liveReload, - showInteractiveDevSession, - onReady: (address, port) => { - readyPort = port; - readyAddress = address; - ready(devServer); - }, - config: options?.config, - env: options?.env, - processEntrypoint, - additionalModules, - bundle: options?.bundle, - compatibilityDate: options?.compatibilityDate, - compatibilityFlags: options?.compatibilityFlags, - ip: options?.ip, - inspectorPort: options?.inspectorPort, - v: undefined, - localProtocol: options?.localProtocol, - assets: options?.assets, - site: options?.site, // Root folder of static assets for Workers Sites - siteInclude: options?.siteInclude, // Array of .gitignore-style patterns that match file or directory names from the sites directory. Only matched items will be uploaded. - siteExclude: options?.siteExclude, // Array of .gitignore-style patterns that match file or directory names from the sites directory. Matched items will not be uploaded. - nodeCompat: options?.nodeCompat, // Enable Node.js compatibility - persist: options?.persist, // Enable persistence for local mode, using default path: .wrangler/state - persistTo: options?.persistTo, // Specify directory to use for local persistence (implies --persist) - experimentalJsonConfig: undefined, - name: undefined, - noBundle: false, - format: undefined, - latest: false, - routes: undefined, - host: undefined, - localUpstream: undefined, - experimentalPublic: undefined, - upstreamProtocol: undefined, - var: undefined, - define: undefined, - jsxFactory: undefined, - jsxFragment: undefined, - tsconfig: undefined, - minify: undefined, - experimentalEnableLocalPersistence: undefined, - legacyEnv: undefined, - public: undefined, - ...options, - }); - }).then((devServer) => { - // now that the inner promise has resolved, we can resolve the outer promise - // with an object that lets you fetch and stop the dev server - resolve({ - port: readyPort, - address: readyAddress, - stop: devServer.stop, - fetch: async (input?: RequestInfo, init?: RequestInit) => { - return await fetch( - ...parseRequestInput( - readyAddress, - readyPort, - input, - init, - options?.localProtocol - ) - ); - }, - //no-op, does nothing in tests - waitUntilExit: async () => { - return; - }, - }); - }); - }); + // in testMode, we can run multiple wranglers in parallel, but rebuilds might not work out of the box + // once the devServer is ready for requests, we resolve the ready promise + const devServer = await startApiDev(devOptions); + const { port, address } = await readyPromise; + return { + port, + address, + stop: devServer.stop, + fetch: async (input?: RequestInfo, init?: RequestInit) => { + return await fetch( + ...parseRequestInput( + address, + port, + input, + init, + options?.localProtocol + ) + ); + }, + //no-op, does nothing in tests + waitUntilExit: async () => { + return; + }, + }; } else { //outside of test mode, rebuilds work fine, but only one instance of wrangler will work at a time - - return new Promise((resolve) => { - //lmao - return new Promise>>((ready) => { - const devServer = startDev({ - script: script, - inspect: false, - _: [], - $0: "", - logLevel: options?.logLevel ?? "log", - port: options?.port ?? 0, - local: true, - showInteractiveDevSession, - d1Databases, - disableDevRegistry, - testScheduled: testScheduled ?? false, - experimentalLocal: experimentalLocal ?? false, - experimentalLocalRemoteKv: experimentalLocalRemoteKv ?? false, - enablePagesAssetsServiceBinding, - forceLocal, - liveReload, - onReady: (address, port) => { - readyPort = port; - readyAddress = address; - ready(devServer); - }, - config: options?.config, - env: options?.env, - processEntrypoint, - additionalModules, - bundle: options?.bundle, - compatibilityDate: options?.compatibilityDate, - compatibilityFlags: options?.compatibilityFlags, - ip: options?.ip, - inspectorPort: options?.inspectorPort, - v: undefined, - localProtocol: options?.localProtocol, - assets: options?.assets, - site: options?.site, // Root folder of static assets for Workers Sites - siteInclude: options?.siteInclude, // Array of .gitignore-style patterns that match file or directory names from the sites directory. Only matched items will be uploaded. - siteExclude: options?.siteExclude, // Array of .gitignore-style patterns that match file or directory names from the sites directory. Matched items will not be uploaded. - nodeCompat: options?.nodeCompat, // Enable Node.js compatibility - persist: options?.persist, // Enable persistence for local mode, using default path: .wrangler/state - persistTo: options?.persistTo, // Specify directory to use for local persistence (implies --persist) - experimentalJsonConfig: undefined, - name: undefined, - noBundle: false, - format: undefined, - latest: false, - routes: undefined, - host: undefined, - localUpstream: undefined, - experimentalPublic: undefined, - upstreamProtocol: undefined, - var: undefined, - define: undefined, - jsxFactory: undefined, - jsxFragment: undefined, - tsconfig: undefined, - minify: undefined, - experimentalEnableLocalPersistence: undefined, - legacyEnv: undefined, - public: undefined, - ...options, - }); - }).then((devServer) => { - resolve({ - port: readyPort, - address: readyAddress, - stop: devServer.stop, - fetch: async (input?: RequestInfo, init?: RequestInit) => { - return await fetch( - ...parseRequestInput( - readyAddress, - readyPort, - input, - init, - options?.localProtocol - ) - ); - }, - waitUntilExit: devServer.devReactElement.waitUntilExit, - }); - }); - }); + const devServer = await startDev(devOptions); + const { port, address } = await readyPromise; + return { + port, + address, + stop: devServer.stop, + fetch: async (input?: RequestInfo, init?: RequestInit) => { + return await fetch( + ...parseRequestInput( + address, + port, + input, + init, + options?.localProtocol + ) + ); + }, + waitUntilExit: devServer.devReactElement.waitUntilExit, + }; } } @@ -316,13 +247,20 @@ export function parseRequestInput( input: RequestInfo = "/", init?: RequestInit, protocol: "http" | "https" = "http" -): [RequestInfo, RequestInit | undefined] { - if (input instanceof Request) { - return [input, undefined]; - } - const url = new URL(`${input}`, `${protocol}://${readyAddress}:${readyPort}`); +): [RequestInfo, RequestInit] { + // Make sure URL is absolute + if (typeof input === "string") input = new URL(input, "http://placeholder"); + // Adapted from Miniflare 3's `dispatchFetch()` function + const forward = new Request(input, init); + const url = new URL(forward.url); + forward.headers.set("MF-Original-URL", url.toString()); url.protocol = protocol; url.hostname = readyAddress; url.port = readyPort.toString(); - return [url, init]; + // Remove `Content-Length: 0` headers from requests when a body is set to + // avoid `RequestContentLengthMismatch` errors + if (forward.body !== null && forward.headers.get("Content-Length") === "0") { + forward.headers.delete("Content-Length"); + } + return [url, forward as RequestInit]; } diff --git a/packages/wrangler/src/bundle.ts b/packages/wrangler/src/bundle.ts index 0d9ebe6c50b4..427f13d3ad2b 100644 --- a/packages/wrangler/src/bundle.ts +++ b/packages/wrangler/src/bundle.ts @@ -152,7 +152,6 @@ export async function bundleWorker( targetConsumer: "dev" | "deploy"; local: boolean; testScheduled?: boolean; - experimentalLocal?: boolean; inject?: string[]; loader?: Record; sourcemap?: esbuild.CommonOptions["sourcemap"]; @@ -186,7 +185,6 @@ export async function bundleWorker( firstPartyWorkerDevFacade, targetConsumer, testScheduled, - experimentalLocal, inject: injectOption, loader, sourcemap, @@ -262,7 +260,7 @@ export async function bundleWorker( path: "templates/middleware/middleware-scheduled.ts", }); } - if (experimentalLocal) { + if (local) { // In Miniflare 3, we bind the user's worker as a service binding in a // special entry worker that handles things like injecting `Request.cf`, // live-reload, and the pretty-error page. @@ -325,7 +323,6 @@ export async function bundleWorker( currentEntry, tmpDir.path, betaD1Shims, - local && !experimentalLocal, doBindings ); }), @@ -866,7 +863,6 @@ async function applyD1BetaFacade( entry: Entry, tmpDirPath: string, betaD1Shims: string[], - miniflare2: boolean, doBindings: DurableObjectBindings ): Promise { let entrypointPath = path.resolve( @@ -917,7 +913,7 @@ async function applyD1BetaFacade( ], define: { __D1_IMPORTS__: JSON.stringify(betaD1Shims), - __LOCAL_MODE__: JSON.stringify(miniflare2), + __LOCAL_MODE__: "false", // TODO: remove }, outfile: targetPath, }); diff --git a/packages/wrangler/src/d1/execute.tsx b/packages/wrangler/src/d1/execute.tsx index fe94b001f81d..85ddaa474b2a 100644 --- a/packages/wrangler/src/d1/execute.tsx +++ b/packages/wrangler/src/d1/execute.tsx @@ -1,10 +1,10 @@ +import assert from "node:assert"; import { existsSync } from "node:fs"; import { mkdir } from "node:fs/promises"; import path from "node:path"; import chalk from "chalk"; import { Static, Text } from "ink"; import Table from "ink-table"; -import { npxImport } from "npx-import"; import React from "react"; import { fetchResult } from "../cfetch"; import { readConfig } from "../config"; @@ -29,6 +29,7 @@ import type { StrictYargsOptionsToInterface, } from "../yargs-types"; import type { Database } from "./types"; +import type { D1SuccessResponse } from "miniflare"; export type QueryResult = { results: Record[]; @@ -236,14 +237,14 @@ async function executeLocally({ ); } + const id = localDB.previewDatabaseUuid ?? localDB.uuid; const persistencePath = getLocalPersistencePath(persistTo, config.configPath); + const dbDir = path.join(persistencePath, "v3", "d1", id); + const dbPath = path.join(dbDir, `db.sqlite`); - const dbDir = path.join(persistencePath, "d1"); - const dbPath = path.join(dbDir, `${localDB.binding}.sqlite3`); - const [{ D1Database, D1DatabaseAPI }, { createSQLiteDB }] = await npxImport< - // eslint-disable-next-line @typescript-eslint/consistent-type-imports - [typeof import("@miniflare/d1"), typeof import("@miniflare/shared")] - >(["@miniflare/d1", "@miniflare/shared"], logger.log); + // eslint-disable-next-line @typescript-eslint/no-var-requires + const { D1Gateway, NoOpLog, createFileStorage } = require("miniflare"); + const storage = createFileStorage(dbDir); if (!existsSync(dbDir)) { const ok = @@ -256,10 +257,27 @@ async function executeLocally({ logger.log(`🌀 Loading DB at ${readableRelative(dbPath)}`); - const sqliteDb = await createSQLiteDB(dbPath); - const db = new D1Database(new D1DatabaseAPI(sqliteDb)); - const stmts = queries.map((query) => db.prepare(query)); - return (await db.batch(stmts)) as QueryResult[]; + const db = new D1Gateway(new NoOpLog(), storage); + let results: D1SuccessResponse | D1SuccessResponse[]; + try { + results = db.query(queries.map((query) => ({ sql: query }))); + } catch (e: unknown) { + throw (e as { cause?: unknown })?.cause ?? e; + } + assert(Array.isArray(results)); + return results.map((result) => ({ + results: (result.results ?? []).map((row) => + Object.fromEntries( + Object.entries(row).map(([key, value]) => { + if (Array.isArray(value)) value = `[${value.join(", ")}]`; + if (value === null) value = "null"; + return [key, value]; + }) + ) + ), + success: result.success, + meta: { duration: result.meta?.duration }, + })); } async function executeRemotely({ diff --git a/packages/wrangler/src/deploy/deploy.ts b/packages/wrangler/src/deploy/deploy.ts index c4a646c43d0b..03db75802a8f 100644 --- a/packages/wrangler/src/deploy/deploy.ts +++ b/packages/wrangler/src/deploy/deploy.ts @@ -499,7 +499,6 @@ See https://developers.cloudflare.com/workers/platform/compatibility-dates for m // This could potentially cause issues as we no longer have identical behaviour between dev and deploy? targetConsumer: "deploy", local: false, - experimentalLocal: false, } ); diff --git a/packages/wrangler/src/dev.tsx b/packages/wrangler/src/dev.tsx index 28fc1d6b9ab1..2dbb0eb86194 100644 --- a/packages/wrangler/src/dev.tsx +++ b/packages/wrangler/src/dev.tsx @@ -191,39 +191,25 @@ export function devOptions(yargs: CommonYargsArgv) { type: "string", requiresArg: true, }) + .option("remote", { + alias: "r", + describe: + "Run on the global Cloudflare network with access to production resources", + type: "boolean", + default: false, + }) .option("local", { alias: "l", describe: "Run on my machine", type: "boolean", - default: false, // I bet this will a point of contention. We'll revisit it. + deprecated: true, + hidden: true, }) .option("experimental-local", { describe: "Run on my machine using the Cloudflare Workers runtime", type: "boolean", - default: false, - }) - .option("experimental-local-remote-kv", { - describe: - "Read/write KV data from/to real namespaces on the Cloudflare network", - type: "boolean", - default: false, - }) - .check((argv) => { - if (argv.local && argv["experimental-local"]) { - throw new Error( - "--local and --experimental-local are mutually exclusive. " + - "Please enable one or the other." - ); - } - if ( - argv["experimental-local-remote-kv"] && - !argv["experimental-local"] - ) { - throw new Error( - "--experimental-local-remote-kv requires --experimental-local to be enabled." - ); - } - return true; + deprecated: true, + hidden: true, }) .option("minify", { describe: "Minify the script", @@ -252,11 +238,9 @@ export function devOptions(yargs: CommonYargsArgv) { type: "boolean", }) .check((argv) => { - const local = argv["local"] || argv["experimental-local"]; - if (argv["live-reload"] && !local) { + if (argv["live-reload"] && argv.remote) { throw new Error( - "--live-reload is only supported in local mode. " + - "Please enable either --local or --experimental-local." + "--live-reload is only supported in local mode. Please just use one of either --remote or --live-reload." ); } return true; @@ -298,7 +282,7 @@ This is currently not supported 😭, but we think that we'll get it to work soo return; } - if (!(args.local || args.experimentalLocal)) { + if (args.remote) { const isLoggedIn = await loginOrRefreshIfRequired(); if (!isLoggedIn) { throw new Error( @@ -346,7 +330,7 @@ export type AdditionalDevProps = { constellation?: Environment["constellation"]; }; -type StartDevOptions = DevArguments & +export type StartDevOptions = DevArguments & // These options can be passed in directly when called with the `wrangler.dev()` API. // They aren't exposed as CLI arguments. AdditionalDevProps & { @@ -365,14 +349,25 @@ export async function startDev(args: StartDevOptions) { logger.loggerLevel = args.logLevel; } await printWranglerBanner(); - - if (args.local && process.platform !== "win32") { - logger.info( - chalk.magenta( - `Want to try out the next version of local mode using the open-source Workers runtime?\nSwitch out --local for ${chalk.bold( - "--experimental-local" - )} and let us know what you think at https://discord.gg/cloudflaredev !` - ) + // TODO(v3.1): remove this message + if (!args.remote && typeof jest === "undefined") { + logger.log( + chalk.blue(`${chalk.green( + `wrangler dev` + )} now uses local mode by default, powered by 🔥 Miniflare and 👷 workerd. +To run an edge preview session for your Worker, use ${chalk.green( + `wrangler dev --remote` + )}`) + ); + } + if (args.local) { + logger.warn( + "--local is no longer required and will be removed in a future version.\n`wrangler dev` now uses the local Cloudflare Workers runtime by default. 🎉" + ); + } + if (args.experimentalLocal) { + logger.warn( + "--experimental-local is no longer required and will be removed in a future version.\n`wrangler dev` now uses the local Cloudflare Workers runtime by default. 🎉" ); } @@ -405,6 +400,7 @@ export async function startDev(args: StartDevOptions) { routes, getLocalPort, getInspectorPort, + getRuntimeInspectorPort, cliDefines, localPersistencePath, processEntrypoint, @@ -414,10 +410,10 @@ export async function startDev(args: StartDevOptions) { await metrics.sendMetricsEvent( "run dev", { - local: args.local, + local: !args.remote, usesTypeScript: /\.tsx?$/.test(entry.file), }, - { sendMetrics: config.send_metrics, offline: args.local } + { sendMetrics: config.send_metrics, offline: !args.remote } ); // eslint-disable-next-line no-inner-declarations @@ -445,9 +441,7 @@ export async function startDev(args: StartDevOptions) { nodejsCompat={nodejsCompat} build={configParam.build || {}} define={{ ...configParam.define, ...cliDefines }} - initialMode={ - args.local || args.experimentalLocal ? "local" : "remote" - } + initialMode={args.remote ? "remote" : "local"} jsxFactory={args.jsxFactory || configParam.jsx_factory} jsxFragment={args.jsxFragment || configParam.jsx_fragment} tsconfig={args.tsconfig ?? configParam.tsconfig} @@ -468,6 +462,7 @@ export async function startDev(args: StartDevOptions) { configParam.dev.inspector_port ?? (await getInspectorPort()) } + runtimeInspectorPort={await getRuntimeInspectorPort()} isWorkersSite={Boolean(args.site || configParam.site)} compatibilityDate={getDevCompatibilityDate( configParam, @@ -488,8 +483,6 @@ export async function startDev(args: StartDevOptions) { firstPartyWorker={configParam.first_party_worker} sendMetrics={configParam.send_metrics} testScheduled={args.testScheduled} - experimentalLocal={args.experimentalLocal} - experimentalLocalRemoteKv={args.experimentalLocalRemoteKv} /> ); } @@ -545,6 +538,7 @@ export async function startApiDev(args: StartDevOptions) { routes, getLocalPort, getInspectorPort, + getRuntimeInspectorPort, cliDefines, localPersistencePath, processEntrypoint, @@ -553,8 +547,8 @@ export async function startApiDev(args: StartDevOptions) { await metrics.sendMetricsEvent( "run dev (api)", - { local: args.local }, - { sendMetrics: config.send_metrics, offline: args.local } + { local: !args.remote }, + { sendMetrics: config.send_metrics, offline: !args.remote } ); // eslint-disable-next-line no-inner-declarations @@ -585,7 +579,7 @@ export async function startApiDev(args: StartDevOptions) { nodejsCompat, build: configParam.build || {}, define: { ...config.define, ...cliDefines }, - initialMode: args.local ? "local" : "remote", + initialMode: args.remote ? "remote" : "local", jsxFactory: args.jsxFactory ?? configParam.jsx_factory, jsxFragment: args.jsxFragment ?? configParam.jsx_fragment, tsconfig: args.tsconfig ?? configParam.tsconfig, @@ -604,6 +598,7 @@ export async function startApiDev(args: StartDevOptions) { args.inspectorPort ?? configParam.dev.inspector_port ?? (await getInspectorPort()), + runtimeInspectorPort: await getRuntimeInspectorPort(), isWorkersSite: Boolean(args.site || configParam.site), compatibilityDate: getDevCompatibilityDate( config, @@ -621,12 +616,10 @@ export async function startApiDev(args: StartDevOptions) { showInteractiveDevSession: args.showInteractiveDevSession, forceLocal: args.forceLocal, enablePagesAssetsServiceBinding: args.enablePagesAssetsServiceBinding, - local: args.local ?? true, + local: !args.remote, firstPartyWorker: configParam.first_party_worker, sendMetrics: configParam.send_metrics, testScheduled: args.testScheduled, - experimentalLocal: args.experimentalLocal, - experimentalLocalRemoteKv: args.experimentalLocalRemoteKv, disableDevRegistry: args.disableDevRegistry ?? false, }); } @@ -645,7 +638,7 @@ export async function startApiDev(args: StartDevOptions) { /** * Avoiding calling `getPort()` multiple times by memoizing the first result. */ -function memoizeGetPort(defaultPort: number) { +function memoizeGetPort(defaultPort?: number) { let portValue: number; return async () => { return portValue || (portValue = await getPort({ port: defaultPort })); @@ -675,11 +668,7 @@ async function getZoneIdHostAndRoutes(args: StartDevOptions, config: Config) { const routes: Route[] | undefined = args.routes || (config.route && [config.route]) || config.routes; - if (args.forceLocal) { - args.local = true; - } - - if (!args.local && !args.experimentalLocal) { + if (args.remote) { if (host) { zoneId = await getZoneIdFromHost(host); } @@ -714,6 +703,12 @@ async function validateDevServerSettings( const getLocalPort = memoizeGetPort(DEFAULT_LOCAL_PORT); const getInspectorPort = memoizeGetPort(DEFAULT_INSPECTOR_PORT); + // Our inspector proxy server will be binding to the result of + // `getInspectorPort`. If we attempted to bind workerd to the same inspector + // port, we'd get a port already in use error. Therefore, generate a new port + // for our runtime to bind its inspector service to. + const getRuntimeInspectorPort = memoizeGetPort(); + if (config.services && config.services.length > 0) { logger.warn( `This worker is bound to live services: ${config.services @@ -799,6 +794,7 @@ async function validateDevServerSettings( nodejsCompat, getLocalPort, getInspectorPort, + getRuntimeInspectorPort, zoneId, host, routes, @@ -813,7 +809,7 @@ function getBindingsAndAssetPaths(args: StartDevOptions, configParam: Config) { const cliVars = collectKeyValues(args.var); // now log all available bindings into the terminal - const bindings = getBindings(configParam, args.env, args.local ?? false, { + const bindings = getBindings(configParam, args.env, !args.remote, { kv: args.kv, vars: { ...args.vars, ...cliVars }, durableObjects: args.durableObjects, @@ -921,12 +917,12 @@ function getBindings( logfwdr: configParam.logfwdr, d1_databases: identifyD1BindingsAsBeta([ ...(configParam.d1_databases ?? []).map((d1Db) => { - //in local dev, bindings don't matter + const database_id = d1Db.preview_database_id + ? d1Db.preview_database_id + : d1Db.database_id; + if (local) { - return { - ...d1Db, - database_id: "local", - }; + return { ...d1Db, database_id }; } // if you have a preview_database_id, we'll use it, but we shouldn't force people to use it. if (!d1Db.preview_database_id && !process.env.NO_D1_WARNING) { @@ -934,12 +930,7 @@ function getBindings( `--------------------\n💡 Recommendation: for development, use a preview D1 database rather than the one you'd use in production.\n💡 Create a new D1 database with "wrangler d1 create " and add its id as preview_database_id to the d1_database "${d1Db.binding}" in your wrangler.toml\n--------------------\n` ); } - return { - ...d1Db, - database_id: d1Db.preview_database_id - ? d1Db.preview_database_id - : d1Db.database_id, - }; + return { ...d1Db, database_id }; }), ...(args.d1Databases || []), ]), diff --git a/packages/wrangler/src/dev/dev.tsx b/packages/wrangler/src/dev/dev.tsx index 52d2bc33eebc..262db0a47490 100644 --- a/packages/wrangler/src/dev/dev.tsx +++ b/packages/wrangler/src/dev/dev.tsx @@ -116,6 +116,7 @@ export type DevProps = { initialPort: number; initialIp: string; inspectorPort: number; + runtimeInspectorPort: number; processEntrypoint: boolean; additionalModules: CfModule[]; rules: Config["rules"]; @@ -156,8 +157,6 @@ export type DevProps = { firstPartyWorker: boolean | undefined; sendMetrics: boolean | undefined; testScheduled: boolean | undefined; - experimentalLocal: boolean | undefined; - experimentalLocalRemoteKv: boolean | undefined; }; export function DevImplementation(props: DevProps): JSX.Element { @@ -341,6 +340,7 @@ function DevSession(props: DevSessionProps) { initialIp={props.initialIp} rules={props.rules} inspectorPort={props.inspectorPort} + runtimeInspectorPort={props.runtimeInspectorPort} localPersistencePath={props.localPersistencePath} liveReload={props.liveReload} crons={props.crons} @@ -350,9 +350,6 @@ function DevSession(props: DevSessionProps) { inspect={props.inspect} onReady={announceAndOnReady} enablePagesAssetsServiceBinding={props.enablePagesAssetsServiceBinding} - experimentalLocal={props.experimentalLocal} - accountId={props.accountId} - experimentalLocalRemoteKv={props.experimentalLocalRemoteKv} sourceMapPath={bundle?.sourceMapPath} /> ) : ( diff --git a/packages/wrangler/src/dev/local.tsx b/packages/wrangler/src/dev/local.tsx index bd995b0ea318..fce5e89964cc 100644 --- a/packages/wrangler/src/dev/local.tsx +++ b/packages/wrangler/src/dev/local.tsx @@ -1,52 +1,19 @@ import assert from "node:assert"; -import { fork } from "node:child_process"; -import { realpathSync } from "node:fs"; -import { readFile } from "node:fs/promises"; -import path from "node:path"; import chalk from "chalk"; -import getPort from "get-port"; -import { npxImport } from "npx-import"; -import { useState, useEffect, useRef } from "react"; +import { useEffect, useRef, useState } from "react"; import onExit from "signal-exit"; import { fetch } from "undici"; -import { performApiFetch } from "../cfetch/internal"; import { registerWorker } from "../dev-registry"; import useInspector from "../inspect"; import { logger } from "../logger"; -import { - DEFAULT_MODULE_RULES, - ModuleTypeToRuleType, -} from "../module-collection"; -import { getBasePath } from "../paths"; -import { waitForPortToBeAvailable } from "../proxy"; -import { requireAuth } from "../user"; +import { MiniflareServer } from "./miniflare"; import type { Config } from "../config"; import type { WorkerRegistry } from "../dev-registry"; -import type { LoggerLevel } from "../logger"; import type { EnablePagesAssetsServiceBindingOptions } from "../miniflare-cli/types"; import type { AssetPaths } from "../sites"; -import type { - CfWorkerInit, - CfScriptFormat, - CfWasmModuleBindings, - CfTextBlobBindings, - CfDataBlobBindings, - CfDurableObject, - CfKvNamespace, - CfR2Bucket, - CfVars, - CfQueue, - CfD1Database, -} from "../worker"; +import type { CfWorkerInit, CfScriptFormat } from "../worker"; +import type { ConfigBundle, ReloadedEvent } from "./miniflare"; import type { EsbuildBundle } from "./use-esbuild"; -import type { - Miniflare as Miniflare3Type, - MiniflareOptions as Miniflare3Options, - Log as Miniflare3LogType, - CloudflareFetch, -} from "@miniflare/tre"; -import type { MiniflareOptions } from "miniflare"; -import type { ChildProcess } from "node:child_process"; export interface LocalProps { name: string | undefined; @@ -62,6 +29,7 @@ export interface LocalProps { initialIp: string; rules: Config["rules"]; inspectorPort: number; + runtimeInspectorPort: number; localPersistencePath: string | null; liveReload: boolean; crons: Config["triggers"]["crons"]; @@ -72,13 +40,83 @@ export interface LocalProps { onReady: ((ip: string, port: number) => void) | undefined; enablePagesAssetsServiceBinding?: EnablePagesAssetsServiceBindingOptions; testScheduled?: boolean; - experimentalLocal: boolean | undefined; - accountId: string | undefined; // Account ID? In local mode??? :exploding_head: - experimentalLocalRemoteKv: boolean | undefined; sourceMapPath: string | undefined; } -type InspectorJSON = { +// TODO(soon): we should be able to remove this function when we fully migrate +// to the new proposed Wrangler architecture. The `Bundler` component should +// emit events containing a `ConfigBundle` we can feed into the dev server +// components. +export async function localPropsToConfigBundle( + props: LocalProps +): Promise { + assert(props.bundle !== undefined); + const serviceBindings: ConfigBundle["serviceBindings"] = {}; + if (props.enablePagesAssetsServiceBinding !== undefined) { + // `../miniflare-cli/assets` dynamically imports`@cloudflare/pages-shared/environment-polyfills`. + // `@cloudflare/pages-shared/environment-polyfills/types.ts` defines `global` + // augmentations that pollute the `import`-site's typing environment. + // + // We `require` instead of `import`ing here to avoid polluting the main + // `wrangler` TypeScript project with the `global` augmentations. This + // relies on the fact that `require` is untyped. + // + // eslint-disable-next-line @typescript-eslint/no-var-requires + const generateASSETSBinding = require("../miniflare-cli/assets").default; + serviceBindings.ASSETS = await generateASSETSBinding({ + log: logger, + ...props.enablePagesAssetsServiceBinding, + }); + } + return { + name: props.name, + bundle: props.bundle, + format: props.format, + compatibilityDate: props.compatibilityDate, + compatibilityFlags: props.compatibilityFlags, + inspectorPort: props.runtimeInspectorPort, + usageModel: props.usageModel, + bindings: props.bindings, + workerDefinitions: props.workerDefinitions, + assetPaths: props.assetPaths, + initialPort: props.initialPort, + initialIp: props.initialIp, + rules: props.rules, + localPersistencePath: props.localPersistencePath, + liveReload: props.liveReload, + crons: props.crons, + queueConsumers: props.queueConsumers, + localProtocol: props.localProtocol, + localUpstream: props.localUpstream, + inspect: props.inspect, + serviceBindings, + }; +} + +export function maybeRegisterLocalWorker(event: ReloadedEvent, name?: string) { + if (name === undefined) return; + + let protocol = event.url.protocol; + protocol = protocol.substring(0, event.url.protocol.length - 1); + if (protocol !== "http" && protocol !== "https") return; + + const port = parseInt(event.url.port); + return registerWorker(name, { + protocol, + mode: "local", + port, + host: event.url.hostname, + durableObjects: event.internalDurableObjects.map((binding) => ({ + name: binding.name, + className: binding.class_name, + })), + durableObjectsHost: event.url.hostname, + durableObjectsPort: port, + }); +} + +// https://chromedevtools.github.io/devtools-protocol/#endpoints +interface InspectorWebSocketTarget { id: string; title: string; type: "node"; @@ -88,14 +126,14 @@ type InspectorJSON = { devtoolsFrontendUrlCompat: string; faviconUrl: string; url: string; -}[]; +} export function Local(props: LocalProps) { const { inspectorUrl } = useLocalWorker(props); useInspector({ inspectorUrl, port: props.inspectorPort, - logToTerminal: props.experimentalLocal ?? false, + logToTerminal: true, sourceMapPath: props.sourceMapPath, name: props.name, sourceMapMetadata: props.bundle?.sourceMapMetadata, @@ -103,55 +141,22 @@ export function Local(props: LocalProps) { return null; } -function useLocalWorker({ - name: workerName, - bundle, - format, - compatibilityDate, - compatibilityFlags, - usageModel, - bindings, - workerDefinitions, - assetPaths, - initialPort, - rules, - localPersistencePath, - liveReload, - initialIp, - crons, - queueConsumers, - localProtocol, - localUpstream, - inspect, - onReady, - enablePagesAssetsServiceBinding, - experimentalLocal, - accountId, - experimentalLocalRemoteKv, -}: LocalProps) { - // TODO: pass vars via command line - const local = useRef(); - const experimentalLocalRef = useRef(); - const removeSignalExitListener = useRef<() => void>(); - const removeExperimentalLocalSignalExitListener = useRef<() => void>(); +function useLocalWorker(props: LocalProps) { + const miniflareServerRef = useRef(); + const removeMiniflareServerExitListenerRef = useRef<() => void>(); const [inspectorUrl, setInspectorUrl] = useState(); - // Our inspector proxy server will be binding to `LocalProps`'s `inspectorPort`. - // If we attempted to bind Node.js/workerd to the same inspector port, we'd get a port already in use error. - // Therefore, generate a new random port for our runtime's to bind their inspector service to. - const runtimeInspectorPortRef = useRef(); - useEffect(() => { - if (bindings.services && bindings.services.length > 0) { + if (props.bindings.services && props.bindings.services.length > 0) { logger.warn( "⎔ Support for service bindings in local mode is experimental and may change." ); } - }, [bindings.services]); + }, [props.bindings.services]); useEffect(() => { const externalDurableObjects = ( - bindings.durable_objects?.bindings || [] + props.bindings.durable_objects?.bindings || [] ).filter((binding) => binding.script_name); if (externalDurableObjects.length > 0) { @@ -159,120 +164,33 @@ function useLocalWorker({ "⎔ Support for external Durable Objects in local mode is experimental and may change." ); } - }, [bindings.durable_objects?.bindings]); + }, [props.bindings.durable_objects?.bindings]); useEffect(() => { const abortController = new AbortController(); - async function startLocalWorker() { - if (!bundle || !format) return; - const scriptPath = realpathSync(bundle.path); - - const upstream = - typeof localUpstream === "string" - ? `${localProtocol}://${localUpstream}` - : undefined; - - const { - externalDurableObjects, - internalDurableObjects, - wasmBindings, - textBlobBindings, - dataBlobBindings, - } = setupBindings({ - wasm_modules: bindings.wasm_modules, - text_blobs: bindings.text_blobs, - data_blobs: bindings.data_blobs, - durable_objects: bindings.durable_objects, - format, - bundle, - }); - - runtimeInspectorPortRef.current ??= await getPort(); - const runtimeInspectorPort = runtimeInspectorPortRef.current; - - const { forkOptions, miniflareCLIPath, options } = setupMiniflareOptions({ - workerName, - port: initialPort, - scriptPath, - localProtocol, - ip: initialIp, - format, - rules, - compatibilityDate, - compatibilityFlags, - usageModel, - kv_namespaces: bindings?.kv_namespaces, - r2_buckets: bindings?.r2_buckets, - queueBindings: bindings?.queues, - queueConsumers: queueConsumers, - d1_databases: bindings?.d1_databases, - internalDurableObjects, - externalDurableObjects, - localPersistencePath, - liveReload, - assetPaths, - vars: bindings?.vars, - wasmBindings, - textBlobBindings, - dataBlobBindings, - crons, - upstream, - workerDefinitions, - enablePagesAssetsServiceBinding, - }); - - if (experimentalLocal) { - const log = await buildMiniflare3Logger(); - const mf3Options = await transformMf2OptionsToMf3Options({ - miniflare2Options: options, - format, - bundle, - log, - enablePagesAssetsServiceBinding, - kvNamespaces: bindings?.kv_namespaces, - r2Buckets: bindings?.r2_buckets, - d1Databases: bindings?.d1_databases, - authenticatedAccountId: accountId, - kvRemote: experimentalLocalRemoteKv, - inspectorPort: runtimeInspectorPort, - }); - - const current = experimentalLocalRef.current; - - if (current === undefined) { - // If we don't have an active Miniflare instance, create a new one - const { Miniflare } = await getMiniflare3(); - if (abortController.signal.aborted) return; - const mf = new Miniflare(mf3Options); - experimentalLocalRef.current = mf; - removeExperimentalLocalSignalExitListener.current = onExit(() => { - logger.log("⎔ Shutting down experimental local server."); - void mf.dispose(); - experimentalLocalRef.current = undefined; - }); - await mf.ready; - } else { - // Otherwise, reuse the existing instance with its loopback server - // and just update the options - if (abortController.signal.aborted) return; - logger.log("⎔ Reloading experimental local server."); - await current.setOptions(mf3Options); - } + if (!props.bundle || !props.format) return; + let server = miniflareServerRef.current; + if (server === undefined) { + logger.log(chalk.dim("⎔ Starting local server...")); + const newServer = new MiniflareServer(); + miniflareServerRef.current = server = newServer; + server.addEventListener("reloaded", async (event) => { + await maybeRegisterLocalWorker(event, props.name); + props.onReady?.(event.url.hostname, parseInt(event.url.port)); try { - // fetch the inspector JSON response from the DevTools Inspector protocol - const inspectorJSONArr = (await ( - await fetch(`http://127.0.0.1:${runtimeInspectorPort}/json`) - ).json()) as InspectorJSON; - - const foundInspectorURL = inspectorJSONArr?.find((inspectorJSON) => - inspectorJSON.id.startsWith("core:user") + // Fetch the inspector JSON response from the DevTools Inspector protocol + const jsonUrl = `http://127.0.0.1:${props.runtimeInspectorPort}/json`; + const res = await fetch(jsonUrl); + const body = (await res.json()) as InspectorWebSocketTarget[]; + const debuggerUrl = body?.find(({ id }) => + id.startsWith("core:user") )?.webSocketDebuggerUrl; - if (foundInspectorURL === undefined) { + if (debuggerUrl === undefined) { setInspectorUrl(undefined); } else { - const url = new URL(foundInspectorURL); + const url = new URL(debuggerUrl); // Force inspector URL to be different on each reload so `useEffect` // in `useInspector` is re-run to connect to newly restarted // `workerd` server when updating options. Can't use a query param @@ -284,669 +202,59 @@ function useLocalWorker({ setInspectorUrl(url.toString()); } } catch (error: unknown) { - logger.error("Error attempting to retrieve Debugger URL:", error); - } - - return; - } - - // Wait for the Worker port to be available. We don't want to do this in experimental local - // mode, as we only `dispose()` the Miniflare 3 instance, and shutdown the server when - // unmounting the component, not when props change. If we did, we'd just timeout every time. - await waitForPortToBeAvailable(initialPort, { - retryPeriod: 200, - timeout: 2000, - abortSignal: abortController.signal, - }); - - const nodeOptions = setupNodeOptions({ - inspect, - inspectorPort: runtimeInspectorPort, - }); - logger.log("⎔ Starting a local server..."); - - const hasColourSupport = - chalk.supportsColor.hasBasic && process.env.FORCE_COLOR !== "0"; - const child = (local.current = fork(miniflareCLIPath, forkOptions, { - cwd: path.dirname(scriptPath), - execArgv: nodeOptions, - stdio: "pipe", - env: { - ...process.env, - FORCE_COLOR: hasColourSupport ? "1" : undefined, - }, - })); - - child.on("message", async (messageString) => { - const message = JSON.parse(messageString as string); - if (message.ready) { - // Let's register our presence in the dev registry - if (workerName) { - await registerWorker(workerName, { - protocol: localProtocol, - mode: "local", - port: message.port, - host: initialIp, - durableObjects: internalDurableObjects.map((binding) => ({ - name: binding.name, - className: binding.class_name, - })), - ...(message.durableObjectsPort - ? { - durableObjectsHost: initialIp, - durableObjectsPort: message.durableObjectsPort, - } - : {}), - }); - } - onReady?.(initialIp, message.port); - } - }); - - child.on("close", (code) => { - if (code) { - logger.log(`Miniflare process exited with code ${code}`); + logger.error("Error attempting to retrieve debugger URL:", error); } }); - - child.stdout?.on("data", (data: Buffer) => { - process.stdout.write(data); - }); - - // parse the node inspector url (which may be received in chunks) from stderr - let stderrData = ""; - let inspectorUrlFound = false; - child.stderr?.on("data", (data: Buffer) => { - if (!inspectorUrlFound) { - stderrData += data.toString(); - const matches = - /Debugger listening on (ws:\/\/127\.0\.0\.1:\d+\/[A-Za-z0-9-]+)[\r|\n]/.exec( - stderrData - ); - if (matches) { - inspectorUrlFound = true; - setInspectorUrl(matches[1]); - } + server.addEventListener("error", ({ error }) => { + if ( + typeof error === "object" && + error !== null && + "code" in error && + // @ts-expect-error `error.code` should be typed `unknown`, fixed in TS 4.9 + error.code === "ERR_RUNTIME_FAILURE" + ) { + // Don't log a full verbose stack-trace when Miniflare 3's workerd instance fails to start. + // workerd will log its own errors, and our stack trace won't have any useful information. + logger.error(String(error)); + } else { + logger.error("Error reloading local server:", error); } - - process.stderr.write(data); }); - - child.on("exit", (code) => { - if (code) { - logger.error(`Miniflare process exited with code ${code}`); - } - }); - - child.on("error", (error: Error) => { - logger.error(`Miniflare process failed to spawn`); - logger.error(error); - }); - - removeSignalExitListener.current = onExit((_code, _signal) => { - logger.log("⎔ Shutting down local server."); - child.kill(); - local.current = undefined; + removeMiniflareServerExitListenerRef.current = onExit(() => { + logger.log(chalk.dim("⎔ Shutting down local server...")); + void newServer.onDispose(); + miniflareServerRef.current = undefined; }); + } else { + logger.log(chalk.dim("⎔ Reloading local server...")); } - startLocalWorker().catch((err) => { - if (err.code === "ERR_RUNTIME_FAILURE") { - // Don't log a full verbose stack-trace when Miniflare 3's workerd instance fails to start. - // workerd will log its own errors, and our stack trace won't have any useful information. - logger.error(err.message); - } else { - logger.error("local worker:", err); - } - }); + const currentServer = server; + void localPropsToConfigBundle(props).then((config) => + currentServer.onBundleUpdate(config, { signal: abortController.signal }) + ); - return () => { - abortController.abort(); - if (local.current) { - logger.log("⎔ Shutting down local server."); - local.current?.kill(); - local.current = undefined; - } - removeSignalExitListener.current?.(); - removeSignalExitListener.current = undefined; - }; - }, [ - bundle, - workerName, - format, - initialPort, - initialIp, - queueConsumers, - bindings.queues, - bindings.durable_objects, - bindings.kv_namespaces, - bindings.r2_buckets, - bindings.d1_databases, - bindings.vars, - bindings.services, - workerDefinitions, - compatibilityDate, - compatibilityFlags, - usageModel, - localPersistencePath, - liveReload, - assetPaths, - rules, - bindings.wasm_modules, - bindings.text_blobs, - bindings.data_blobs, - crons, - localProtocol, - localUpstream, - inspect, - onReady, - enablePagesAssetsServiceBinding, - experimentalLocal, - accountId, - experimentalLocalRemoteKv, - ]); + return () => abortController.abort(); + }, [props]); - // Rather than disposing the Miniflare instance on every reload, only dispose + // Rather than disposing the Miniflare server on every reload, only dispose // it if local mode is disabled and the `Local` component is unmounted. This // allows us to use the more efficient `Miniflare#setOptions` on reload which - // retains internal state (e.g. the Miniflare loopback server). + // retains internal state (e.g. in-memory data, the loopback server). useEffect( () => () => { - if (experimentalLocalRef.current) { - logger.log("⎔ Shutting down experimental local server."); + if (miniflareServerRef.current) { + logger.log(chalk.dim("⎔ Shutting down local server...")); // Initialisation errors are also thrown asynchronously by dispose(). - // The catch() above should've caught them though. - experimentalLocalRef.current?.dispose().catch(() => {}); - experimentalLocalRef.current = undefined; + // The `addEventListener("error")` above should've caught them though. + void miniflareServerRef.current.onDispose().catch(() => {}); + miniflareServerRef.current = undefined; } - removeExperimentalLocalSignalExitListener.current?.(); - removeExperimentalLocalSignalExitListener.current = undefined; + removeMiniflareServerExitListenerRef.current?.(); + removeMiniflareServerExitListenerRef.current = undefined; }, [] ); return { inspectorUrl }; } - -interface SetupBindingsProps { - wasm_modules: CfWasmModuleBindings | undefined; - text_blobs: CfTextBlobBindings | undefined; - data_blobs: CfDataBlobBindings | undefined; - durable_objects: { bindings: CfDurableObject[] } | undefined; - bundle: EsbuildBundle; - format: CfScriptFormat; -} - -export function setupBindings({ - wasm_modules, - text_blobs, - data_blobs, - durable_objects, - format, - bundle, -}: SetupBindingsProps) { - // the wasm_modules/text_blobs/data_blobs bindings are - // relative to process.cwd(), but the actual worker bundle - // is in the temp output directory; so we rewrite the paths to be absolute, - // letting miniflare resolve them correctly - - // wasm - const wasmBindings: Record = {}; - for (const [name, filePath] of Object.entries(wasm_modules || {})) { - wasmBindings[name] = path.join(process.cwd(), filePath); - } - - // text - const textBlobBindings: Record = {}; - for (const [name, filePath] of Object.entries(text_blobs || {})) { - textBlobBindings[name] = path.join(process.cwd(), filePath); - } - - // data - const dataBlobBindings: Record = {}; - for (const [name, filePath] of Object.entries(data_blobs || {})) { - dataBlobBindings[name] = path.join(process.cwd(), filePath); - } - - if (format === "service-worker") { - for (const { type, name } of bundle.modules) { - if (type === "compiled-wasm") { - // In service-worker format, .wasm modules are referenced by global identifiers, - // so we convert it here. - // This identifier has to be a valid JS identifier, so we replace all non alphanumeric - // characters with an underscore. - const identifier = name.replace(/[^a-zA-Z0-9_$]/g, "_"); - wasmBindings[identifier] = name; - } else if (type === "text") { - // In service-worker format, text modules are referenced by global identifiers, - // so we convert it here. - // This identifier has to be a valid JS identifier, so we replace all non alphanumeric - // characters with an underscore. - const identifier = name.replace(/[^a-zA-Z0-9_$]/g, "_"); - textBlobBindings[identifier] = name; - } else if (type === "buffer") { - // In service-worker format, data blobs are referenced by global identifiers, - // so we convert it here. - // This identifier has to be a valid JS identifier, so we replace all non alphanumeric - // characters with an underscore. - const identifier = name.replace(/[^a-zA-Z0-9_$]/g, "_"); - dataBlobBindings[identifier] = name; - } - } - } - - const internalDurableObjects = (durable_objects?.bindings || []).filter( - (binding) => !binding.script_name - ); - const externalDurableObjects = (durable_objects?.bindings || []).filter( - (binding) => binding.script_name - ); - return { - internalDurableObjects, - externalDurableObjects, - wasmBindings, - textBlobBindings, - dataBlobBindings, - }; -} - -interface SetupMiniflareOptionsProps { - workerName: string | undefined; - port: number; - scriptPath: string; - localProtocol: "http" | "https"; - ip: string; - format: CfScriptFormat; - rules: Config["rules"]; - compatibilityDate: string; - compatibilityFlags: string[] | undefined; - usageModel: "bundled" | "unbound" | undefined; - kv_namespaces: CfKvNamespace[] | undefined; - queueBindings: CfQueue[] | undefined; - queueConsumers: Config["queues"]["consumers"]; - r2_buckets: CfR2Bucket[] | undefined; - d1_databases: CfD1Database[] | undefined; - internalDurableObjects: CfDurableObject[]; - externalDurableObjects: CfDurableObject[]; - localPersistencePath: string | null; - liveReload: boolean; - assetPaths: AssetPaths | undefined; - vars: CfVars | undefined; - wasmBindings: Record; - textBlobBindings: Record; - dataBlobBindings: Record; - crons: Config["triggers"]["crons"]; - upstream: string | undefined; - workerDefinitions: WorkerRegistry | undefined; - enablePagesAssetsServiceBinding?: EnablePagesAssetsServiceBindingOptions; -} - -export function setupMiniflareOptions({ - workerName, - port, - scriptPath, - localProtocol, - ip, - format, - rules, - compatibilityDate, - compatibilityFlags, - usageModel, - kv_namespaces, - queueBindings, - queueConsumers, - r2_buckets, - d1_databases, - internalDurableObjects, - externalDurableObjects, - localPersistencePath, - liveReload, - assetPaths, - vars, - wasmBindings, - textBlobBindings, - dataBlobBindings, - crons, - upstream, - workerDefinitions, - enablePagesAssetsServiceBinding, -}: SetupMiniflareOptionsProps): { - miniflareCLIPath: string; - forkOptions: string[]; - options: MiniflareOptions; -} { - // It's now getting _really_ messy now with Pages ASSETS binding outside and the external Durable Objects inside. - const options: MiniflareOptions = { - name: workerName, - port, - scriptPath, - https: localProtocol === "https", - host: ip, - modules: format === "modules", - modulesRules: (rules || []) - .concat(DEFAULT_MODULE_RULES) - .map(({ type, globs: include, fallthrough }) => ({ - type, - include, - fallthrough, - })), - compatibilityDate, - compatibilityFlags, - usageModel, - kvNamespaces: kv_namespaces?.map((kv) => kv.binding), - queueBindings: queueBindings?.map((queue) => { - return { name: queue.binding, queueName: queue.queue_name }; - }), - queueConsumers: queueConsumers?.map((consumer) => { - const waitMs = consumer.max_batch_timeout - ? 1000 * consumer.max_batch_timeout - : undefined; - return { - queueName: consumer.queue, - maxBatchSize: consumer.max_batch_size, - maxWaitMs: waitMs, - maxRetries: consumer.max_retries, - deadLetterQueue: consumer.dead_letter_queue, - }; - }), - r2Buckets: r2_buckets?.map((r2) => r2.binding), - durableObjects: Object.fromEntries( - internalDurableObjects.map((binding) => [ - binding.name, - binding.class_name, - ]) - ), - externalDurableObjects: Object.fromEntries( - externalDurableObjects - .map((binding) => { - const service = - workerDefinitions && - workerDefinitions[binding.script_name as string]; - if (!service) return [binding.name, undefined]; - - const name = service.durableObjects.find( - (durableObject) => durableObject.className === binding.class_name - )?.name; - if (!name) return [binding.name, undefined]; - - return [ - binding.name, - { - name, - host: service.durableObjectsHost, - port: service.durableObjectsPort, - }, - ]; - }) - .filter(([_, details]) => !!details) - ), - d1Databases: d1_databases?.map((db) => db.binding), - ...(localPersistencePath - ? { - cachePersist: path.join(localPersistencePath, "cache"), - durableObjectsPersist: path.join(localPersistencePath, "do"), - kvPersist: path.join(localPersistencePath, "kv"), - r2Persist: path.join(localPersistencePath, "r2"), - d1Persist: path.join(localPersistencePath, "d1"), - } - : { - // We mark these as true, so that they'll - // persist in the temp directory. - // This means they'll persist across a dev session, - // even if we change source and reload, - // and be deleted when the dev session ends - cachePersist: true, - durableObjectsPersist: true, - kvPersist: true, - r2Persist: true, - d1Persist: true, - }), - - liveReload, - sitePath: assetPaths?.assetDirectory - ? path.join(assetPaths.baseDirectory, assetPaths.assetDirectory) - : undefined, - siteInclude: assetPaths?.includePatterns.length - ? assetPaths?.includePatterns - : undefined, - siteExclude: assetPaths?.excludePatterns.length - ? assetPaths.excludePatterns - : undefined, - bindings: vars, - wasmBindings, - textBlobBindings, - dataBlobBindings, - sourceMap: true, - logUnhandledRejections: true, - crons, - upstream, - logLevel: logger.loggerLevel, - enablePagesAssetsServiceBinding, - }; - // The path to the Miniflare CLI assumes that this file is being run from - // `wrangler-dist` and that the CLI is found in `miniflare-dist`. - // If either of those paths change this line needs updating. - const miniflareCLIPath = path.resolve( - getBasePath(), - "miniflare-dist/index.mjs" - ); - const miniflareOptions = JSON.stringify(options, null); - const forkOptions = [miniflareOptions]; - if (enablePagesAssetsServiceBinding) { - forkOptions.push(JSON.stringify(enablePagesAssetsServiceBinding)); - } - return { miniflareCLIPath, forkOptions, options }; -} - -export function setupNodeOptions({ - inspect, - inspectorPort, -}: { - inspect: boolean; - inspectorPort: number; -}) { - const nodeOptions = [ - "--experimental-vm-modules", // ensures that Miniflare can run ESM Workers - "--no-warnings", // hide annoying Node warnings - // "--log=VERBOSE", // uncomment this to Miniflare to log "everything"! - ]; - if (inspect) { - nodeOptions.push("--inspect=" + `127.0.0.1:${inspectorPort}`); // start Miniflare listening for a debugger to attach - } - return nodeOptions; -} - -export interface SetupMiniflare3Options { - // Regular Miniflare 2 options to transform - miniflare2Options: MiniflareOptions; - // Miniflare 3 requires all modules to be manually specified - format: CfScriptFormat; - bundle: EsbuildBundle; - - // Miniflare's logger - log: Miniflare3LogType; - - enablePagesAssetsServiceBinding?: EnablePagesAssetsServiceBindingOptions; - - // Miniflare 3 accepts namespace/bucket names in addition to binding names. - // This means multiple workers persisting to the same location can have - // different binding names for the same namespace/bucket. Therefore, we need - // the full KV/R2 arrays. This is also required for remote KV storage, as - // we need actual namespace IDs to connect to. - kvNamespaces: CfKvNamespace[] | undefined; - r2Buckets: CfR2Bucket[] | undefined; - d1Databases: CfD1Database[] | undefined; - - // Account ID to use for authenticated Cloudflare fetch. If true, prompt - // user for ID if multiple available. - authenticatedAccountId: string | true | undefined; - // Whether to read/write from/to real KV namespaces - kvRemote: boolean | undefined; - - // Port to start DevTools inspector server on - inspectorPort: number; -} - -export async function buildMiniflare3Logger(): Promise { - const { Log, NoOpLog, LogLevel } = await getMiniflare3(); - - let level = logger.loggerLevel.toUpperCase() as Uppercase; - if (level === "LOG") level = "INFO"; - const logLevel = LogLevel[level]; - - return logLevel === LogLevel.NONE ? new NoOpLog() : new Log(logLevel); -} - -function transformMf2PersistToMf3(persist?: boolean | string) { - // Wrangler reuses Miniflare 3 instances between reloads but not Miniflare 2 - // ones. We previously set `*Persist` options to `true` by default to - // persist data between reloads in the temporary script directory (Miniflare - // 2's working directory). However, with Miniflare 3, the working directory - // is the current working directory, so we want to set these to `false` - // and use Miniflare 3's native in-memory persistence. - // - // See https://github.com/cloudflare/workers-sdk/issues/2995. - return persist === true ? false : persist; -} - -export async function transformMf2OptionsToMf3Options({ - miniflare2Options, - format, - bundle, - log, - enablePagesAssetsServiceBinding, - kvNamespaces, - r2Buckets, - d1Databases, - authenticatedAccountId, - kvRemote, - inspectorPort, -}: SetupMiniflare3Options): Promise { - // Build authenticated Cloudflare API fetch function if required - let cloudflareFetch: CloudflareFetch | undefined; - if (kvRemote && authenticatedAccountId !== undefined) { - const { Response: Miniflare3Response } = await getMiniflare3(); - const preferredAccountId = - authenticatedAccountId === true ? undefined : authenticatedAccountId; - const accountId = await requireAuth({ account_id: preferredAccountId }); - cloudflareFetch = async (resource, searchParams, init) => { - resource = `/accounts/${accountId}/${resource}`; - const response = await performApiFetch(resource, init, searchParams); - return new Miniflare3Response(response.body, response); - }; - } - - let options: Partial = { - ...miniflare2Options, - - // Miniflare 3 distinguishes between binding name and namespace/bucket IDs. - kvNamespaces: Object.fromEntries( - kvNamespaces?.map(({ binding, id }) => [binding, id]) ?? [] - ), - r2Buckets: Object.fromEntries( - r2Buckets?.map(({ binding, bucket_name }) => [binding, bucket_name]) ?? [] - ), - d1Databases: Object.fromEntries( - d1Databases?.map(({ binding, database_id, preview_database_id }) => [ - binding, - preview_database_id ?? database_id, - ]) ?? [] - ), - - cachePersist: transformMf2PersistToMf3(miniflare2Options.cachePersist), - durableObjectsPersist: transformMf2PersistToMf3( - miniflare2Options.durableObjectsPersist - ), - kvPersist: transformMf2PersistToMf3(miniflare2Options.kvPersist), - r2Persist: transformMf2PersistToMf3(miniflare2Options.r2Persist), - d1Persist: transformMf2PersistToMf3(miniflare2Options.d1Persist), - - inspectorPort, - verbose: logger.loggerLevel === "debug", - cloudflareFetch, - log, - }; - - if (enablePagesAssetsServiceBinding !== undefined) { - // `../miniflare-cli/assets` dynamically imports`@cloudflare/pages-shared/environment-polyfills`. - // `@cloudflare/pages-shared/environment-polyfills/types.ts` defines `global` - // augmentations that pollute the `import`-site's typing environment. - // - // We `require` instead of `import`ing here to avoid polluting the main - // `wrangler` TypeScript project with the `global` augmentations. This - // relies on the fact that `require` is untyped. - // - // eslint-disable-next-line @typescript-eslint/no-var-requires - const generateASSETSBinding = require("../miniflare-cli/assets").default; - options.serviceBindings = { - ...options.serviceBindings, - ASSETS: (await generateASSETSBinding({ - log, - ...enablePagesAssetsServiceBinding, - tre: true, - // We can get rid of this `any` easily once we do experimental-local/tre by default - // eslint-disable-next-line @typescript-eslint/no-explicit-any - })) as any, - }; - } - - if (format === "modules") { - // Manually specify all modules from the bundle. If we didn't do this, - // Miniflare 3 would try collect them automatically again itself. - - // Resolve entrypoint relative to the temporary directory, ensuring - // path doesn't start with `..`, which causes issues in `workerd`. - // Also ensures other modules with relative names can be resolved. - const root = path.dirname(bundle.path); - - assert.strictEqual(bundle.type, "esm"); - options = { - // Creating a new options object ensures types check (Miniflare's - // options type requires source code to be specified) - ...options, - // Required for source mapped paths to resolve correctly - modulesRoot: root, - modules: [ - // Entrypoint - { - type: "ESModule", - path: bundle.path, - contents: await readFile(bundle.path, "utf-8"), - }, - // Misc (WebAssembly, etc, ...) - ...bundle.modules.map((module) => ({ - type: ModuleTypeToRuleType[module.type ?? "esm"], - path: path.resolve(root, module.name), - contents: module.content, - })), - ], - }; - } - - if (kvRemote) { - // `kvPersist` is always assigned a truthy value in `setupMiniflareOptions` - assert(options.kvPersist); - const kvRemoteCache = - options.kvPersist === true - ? // If storing in temporary directory, find this path from the bundle - // output path - path.join(path.dirname(bundle.path), ".mf", "kv-remote") - : // Otherwise, `kvPersist` looks like `.../kv`, so rewrite it to - // `kv-remote` since the expected metadata format for remote storage - // is different to local - path.join(path.dirname(options.kvPersist), "kv-remote"); - options.kvPersist = `remote:?cache=${encodeURIComponent(kvRemoteCache)}`; - } - - return options as Miniflare3Options; -} - -// Caching of the `npx-import`ed `@miniflare/tre` package -// eslint-disable-next-line @typescript-eslint/consistent-type-imports -let miniflare3Module: typeof import("@miniflare/tre"); -export async function getMiniflare3(): Promise< - // eslint-disable-next-line @typescript-eslint/consistent-type-imports - typeof import("@miniflare/tre") -> { - return (miniflare3Module ??= await npxImport("@miniflare/tre@3.0.0-next.13")); -} diff --git a/packages/wrangler/src/dev/miniflare.ts b/packages/wrangler/src/dev/miniflare.ts new file mode 100644 index 000000000000..3ee970c72122 --- /dev/null +++ b/packages/wrangler/src/dev/miniflare.ts @@ -0,0 +1,496 @@ +import assert from "node:assert"; +import { realpathSync } from "node:fs"; +import { readFile } from "node:fs/promises"; +import path from "node:path"; +import { + Log, + LogLevel, + NoOpLog, + TypedEventTarget, + Mutex, + Miniflare, +} from "miniflare"; +import { logger } from "../logger"; +import { ModuleTypeToRuleType } from "../module-collection"; +import type { Config } from "../config"; +import type { WorkerRegistry } from "../dev-registry"; +import type { LoggerLevel } from "../logger"; +import type { AssetPaths } from "../sites"; +import type { + CfD1Database, + CfDurableObject, + CfKvNamespace, + CfQueue, + CfR2Bucket, + CfScriptFormat, +} from "../worker"; +import type { CfWorkerInit } from "../worker"; +import type { EsbuildBundle } from "./use-esbuild"; +import type { + MiniflareOptions, + SourceOptions, + WorkerOptions, + Request, + Response, + QueueConsumerOptions, +} from "miniflare"; +import type { Abortable } from "node:events"; + +// This worker proxies all external Durable Objects to the Wrangler session +// where they're defined, and receives all requests from other Wrangler sessions +// for this session's Durable Objects. Note the original request URL may contain +// non-standard protocols, so we store it in a header to restore later. +const EXTERNAL_DURABLE_OBJECTS_WORKER_NAME = + "__WRANGLER_EXTERNAL_DURABLE_OBJECTS_WORKER"; +// noinspection HttpUrlsUsage +const EXTERNAL_DURABLE_OBJECTS_WORKER_SCRIPT = ` +const HEADER_URL = "X-Miniflare-Durable-Object-URL"; +const HEADER_NAME = "X-Miniflare-Durable-Object-Name"; +const HEADER_ID = "X-Miniflare-Durable-Object-Id"; + +function createClass({ className, proxyUrl }) { + return class { + constructor(state) { + this.id = state.id.toString(); + } + fetch(request) { + if (proxyUrl === undefined) { + return new Response(\`[wrangler] Couldn't find \\\`wrangler dev\\\` session for class "\${className}" to proxy to\`, { status: 503 }); + } + const proxyRequest = new Request(proxyUrl, request); + proxyRequest.headers.set(HEADER_URL, request.url); + proxyRequest.headers.set(HEADER_NAME, className); + proxyRequest.headers.set(HEADER_ID, this.id); + return fetch(proxyRequest); + } + } +} + +export default { + async fetch(request, env) { + const originalUrl = request.headers.get(HEADER_URL); + const className = request.headers.get(HEADER_NAME); + const idString = request.headers.get(HEADER_ID); + if (originalUrl === null || className === null || idString === null) { + return new Response("[wrangler] Received Durable Object proxy request with missing headers", { status: 400 }); + } + request = new Request(originalUrl, request); + request.headers.delete(HEADER_URL); + request.headers.delete(HEADER_NAME); + request.headers.delete(HEADER_ID); + const ns = env[className]; + const id = ns.idFromString(idString); + const stub = ns.get(id); + return stub.fetch(request); + } +} +`; + +export interface ConfigBundle { + // TODO(soon): maybe rename some of these options, check proposed API Google Docs + name: string | undefined; + bundle: EsbuildBundle; + format: CfScriptFormat | undefined; + compatibilityDate: string; + compatibilityFlags: string[] | undefined; + usageModel: "bundled" | "unbound" | undefined; // TODO: do we need this? + bindings: CfWorkerInit["bindings"]; + workerDefinitions: WorkerRegistry | undefined; + assetPaths: AssetPaths | undefined; + initialPort: number; + initialIp: string; + rules: Config["rules"]; + inspectorPort: number; + localPersistencePath: string | null; + liveReload: boolean; + crons: Config["triggers"]["crons"]; + queueConsumers: Config["queues"]["consumers"]; + localProtocol: "http" | "https"; + localUpstream: string | undefined; + inspect: boolean; + serviceBindings: Record Promise>; +} + +class WranglerLog extends Log { + #warnedCompatibilityDateFallback = false; + + info(message: string) { + // Hide request logs for external Durable Objects proxy worker + if (message.includes(EXTERNAL_DURABLE_OBJECTS_WORKER_NAME)) return; + super.info(message); + } + + warn(message: string) { + // Only log warning about requesting a compatibility date after the workerd + // binary's version once + if (message.startsWith("The latest compatibility date supported by")) { + if (this.#warnedCompatibilityDateFallback) return; + this.#warnedCompatibilityDateFallback = true; + } + super.warn(message); + } +} + +function getName(config: ConfigBundle) { + return config.name ?? "worker"; +} +const IDENTIFIER_UNSAFE_REGEXP = /[^a-zA-Z0-9_$]/g; +function getIdentifier(name: string) { + return name.replace(IDENTIFIER_UNSAFE_REGEXP, "_"); +} + +function buildLog(): Log { + let level = logger.loggerLevel.toUpperCase() as Uppercase; + if (level === "LOG") level = "INFO"; + const logLevel = LogLevel[level]; + return logLevel === LogLevel.NONE ? new NoOpLog() : new WranglerLog(logLevel); +} + +async function buildSourceOptions( + config: ConfigBundle +): Promise { + const scriptPath = realpathSync(config.bundle.path); + if (config.format === "modules") { + const modulesRoot = path.dirname(scriptPath); + return { + modulesRoot, + modules: [ + // Entrypoint + { + type: "ESModule", + path: scriptPath, + contents: await readFile(scriptPath, "utf-8"), + }, + // Misc (WebAssembly, etc, ...) + ...config.bundle.modules.map((module) => ({ + type: ModuleTypeToRuleType[module.type ?? "esm"], + path: path.resolve(modulesRoot, module.name), + contents: module.content, + })), + ], + }; + } else { + return { scriptPath }; + } +} + +function kvNamespaceEntry({ binding, id }: CfKvNamespace): [string, string] { + return [binding, id]; +} +function r2BucketEntry({ binding, bucket_name }: CfR2Bucket): [string, string] { + return [binding, bucket_name]; +} +function d1DatabaseEntry(db: CfD1Database): [string, string] { + return [db.binding, db.preview_database_id ?? db.database_id]; +} +function queueProducerEntry(queue: CfQueue): [string, string] { + return [queue.binding, queue.queue_name]; +} +type QueueConsumer = NonNullable[number]; +function queueConsumerEntry( + consumer: QueueConsumer +): [string, QueueConsumerOptions] { + const options: QueueConsumerOptions = { + maxBatchSize: consumer.max_batch_size, + maxBatchTimeout: consumer.max_batch_timeout, + maxRetires: consumer.max_retries, + deadLetterQueue: consumer.dead_letter_queue, + }; + return [consumer.queue, options]; +} +// TODO(someday): would be nice to type these methods more, can we export types for +// each plugin options schema and use those +function buildBindingOptions(config: ConfigBundle) { + const bindings = config.bindings; + + // Setup blob and module bindings + // TODO: check all these blob bindings just work, they're relative to cwd + const textBlobBindings = { ...bindings.text_blobs }; + const dataBlobBindings = { ...bindings.data_blobs }; + const wasmBindings = { ...bindings.wasm_modules }; + if (config.format === "service-worker") { + // For the service-worker format, blobs are accessible on the global scope + const scriptPath = realpathSync(config.bundle.path); + const modulesRoot = path.dirname(scriptPath); + for (const { type, name } of config.bundle.modules) { + if (type === "text") { + textBlobBindings[getIdentifier(name)] = path.resolve(modulesRoot, name); + } else if (type === "buffer") { + dataBlobBindings[getIdentifier(name)] = path.resolve(modulesRoot, name); + } else if (type === "compiled-wasm") { + wasmBindings[getIdentifier(name)] = path.resolve(modulesRoot, name); + } + } + } + + // Partition Durable Objects based on whether they're internal (defined by + // this session's worker), or external (defined by another session's worker + // registered in the dev registry) + const internalObjects: CfDurableObject[] = []; + const externalObjects: CfDurableObject[] = []; + for (const binding of bindings.durable_objects?.bindings ?? []) { + const internal = + binding.script_name === undefined || binding.script_name === config.name; + (internal ? internalObjects : externalObjects).push(binding); + } + // Setup Durable Object bindings and proxy worker + const externalDurableObjectWorker: WorkerOptions = { + name: EXTERNAL_DURABLE_OBJECTS_WORKER_NAME, + // Bind all internal objects, so they're accessible by all other sessions + // that proxy requests for our objects to this worker + durableObjects: Object.fromEntries( + internalObjects.map(({ class_name }) => [ + class_name, + { className: class_name, scriptName: getName(config) }, + ]) + ), + // Use this worker instead of the user worker if the pathname is + // `/${EXTERNAL_DURABLE_OBJECTS_WORKER_NAME}` + routes: [`*/${EXTERNAL_DURABLE_OBJECTS_WORKER_NAME}`], + // Use in-memory storage for the stub object classes *declared* by this + // script. They don't need to persist anything, and would end up using the + // incorrect unsafe unique key. + unsafeEphemeralDurableObjects: true, + modules: true, + script: + EXTERNAL_DURABLE_OBJECTS_WORKER_SCRIPT + + // Add stub object classes that proxy requests to the correct session + externalObjects + .map(({ class_name, script_name }) => { + assert(script_name !== undefined); + const target = config.workerDefinitions?.[script_name]; + const targetHasClass = target?.durableObjects.some( + ({ className }) => className === class_name + ); + + const identifier = getIdentifier(`${script_name}_${class_name}`); + const classNameJson = JSON.stringify(class_name); + if ( + target?.host === undefined || + target.port === undefined || + !targetHasClass + ) { + // If we couldn't find the target or the class, create a stub object + // that just returns `503 Service Unavailable` responses. + return `export const ${identifier} = createClass({ className: ${classNameJson} });`; + } else { + // Otherwise, create a stub object that proxies request to the + // target session at `${hostname}:${port}`. + const proxyUrl = `http://${target.host}:${target.port}/${EXTERNAL_DURABLE_OBJECTS_WORKER_NAME}`; + const proxyUrlJson = JSON.stringify(proxyUrl); + return `export const ${identifier} = createClass({ className: ${classNameJson}, proxyUrl: ${proxyUrlJson} });`; + } + }) + .join("\n"), + }; + + const bindingOptions = { + bindings: bindings.vars, + textBlobBindings, + dataBlobBindings, + wasmBindings, + + kvNamespaces: Object.fromEntries( + bindings.kv_namespaces?.map(kvNamespaceEntry) ?? [] + ), + r2Buckets: Object.fromEntries( + bindings.r2_buckets?.map(r2BucketEntry) ?? [] + ), + d1Databases: Object.fromEntries( + bindings.d1_databases?.map(d1DatabaseEntry) ?? [] + ), + queueProducers: Object.fromEntries( + bindings.queues?.map(queueProducerEntry) ?? [] + ), + queueConsumers: Object.fromEntries( + config.queueConsumers?.map(queueConsumerEntry) ?? [] + ), + + durableObjects: Object.fromEntries([ + ...internalObjects.map(({ name, class_name }) => [name, class_name]), + ...externalObjects.map(({ name, class_name, script_name }) => { + const identifier = getIdentifier(`${script_name}_${class_name}`); + return [ + name, + { + className: identifier, + scriptName: EXTERNAL_DURABLE_OBJECTS_WORKER_NAME, + // Matches the unique key Miniflare will generate for this object in + // the target session. We need to do this so workerd generates the + // same IDs it would if this were part of the same process. workerd + // doesn't allow IDs from Durable Objects with different unique keys + // to be used with each other. + unsafeUniqueKey: `${script_name}-${class_name}`, + }, + ]; + }), + ]), + + serviceBindings: config.serviceBindings, + // TODO: check multi worker service bindings also supported + }; + + return { + bindingOptions, + internalObjects, + externalDurableObjectWorker, + }; +} + +type PickTemplate = { + [P in keyof T & K]: T[P]; +}; +type PersistOptions = PickTemplate; +function buildPersistOptions(config: ConfigBundle): PersistOptions | undefined { + const persist = config.localPersistencePath; + if (persist !== null) { + const v3Path = path.join(persist, "v3"); + return { + cachePersist: path.join(v3Path, "cache"), + durableObjectsPersist: path.join(v3Path, "do"), + kvPersist: path.join(v3Path, "kv"), + r2Persist: path.join(v3Path, "r2"), + d1Persist: path.join(v3Path, "d1"), + }; + } +} + +function buildSitesOptions({ assetPaths }: ConfigBundle) { + if (assetPaths !== undefined) { + const { baseDirectory, assetDirectory, includePatterns, excludePatterns } = + assetPaths; + return { + sitePath: path.join(baseDirectory, assetDirectory), + siteInclude: includePatterns.length > 0 ? includePatterns : undefined, + siteExclude: excludePatterns.length > 0 ? excludePatterns : undefined, + }; + } +} + +async function buildMiniflareOptions( + log: Log, + config: ConfigBundle +): Promise<{ options: MiniflareOptions; internalObjects: CfDurableObject[] }> { + if (config.localProtocol === "https") { + logger.warn( + "Miniflare 3 does not support HTTPS servers yet, starting an HTTP server instead..." + ); + } + if (config.crons.length > 0) { + logger.warn("Miniflare 3 does not support CRON triggers yet, ignoring..."); + } + + const upstream = + typeof config.localUpstream === "string" + ? `${config.localProtocol}://${config.localUpstream}` + : undefined; + + const sourceOptions = await buildSourceOptions(config); + const { bindingOptions, internalObjects, externalDurableObjectWorker } = + buildBindingOptions(config); + const sitesOptions = buildSitesOptions(config); + const persistOptions = buildPersistOptions(config); + + const options: MiniflareOptions = { + host: config.initialIp, + port: config.initialPort, + inspectorPort: config.inspect ? config.inspectorPort : undefined, + liveReload: config.liveReload, + upstream, + + log, + verbose: logger.loggerLevel === "debug", + + ...persistOptions, + workers: [ + { + name: getName(config), + compatibilityDate: config.compatibilityDate, + compatibilityFlags: config.compatibilityFlags, + + ...sourceOptions, + ...bindingOptions, + ...sitesOptions, + }, + externalDurableObjectWorker, + ], + }; + return { options, internalObjects }; +} + +export interface ReloadedEventOptions { + url: URL; + internalDurableObjects: CfDurableObject[]; +} +export class ReloadedEvent extends Event implements ReloadedEventOptions { + readonly url: URL; + readonly internalDurableObjects: CfDurableObject[]; + + constructor(type: "reloaded", options: ReloadedEventOptions) { + super(type); + this.url = options.url; + this.internalDurableObjects = options.internalDurableObjects; + } +} + +export interface ErrorEventOptions { + error: unknown; +} +export class ErrorEvent extends Event implements ErrorEventOptions { + readonly error: unknown; + + constructor(type: "error", options: ErrorEventOptions) { + super(type); + this.error = options.error; + } +} + +export type MiniflareServerEventMap = { + reloaded: ReloadedEvent; + error: ErrorEvent; +}; +export class MiniflareServer extends TypedEventTarget { + #log = buildLog(); + #mf?: Miniflare; + + // `buildMiniflareOptions()` is asynchronous, meaning if multiple bundle + // updates were submitted, the second may apply before the first. Therefore, + // wrap updates in a mutex, so they're always applied in invocation order. + #mutex = new Mutex(); + + async #onBundleUpdate(config: ConfigBundle, opts?: Abortable): Promise { + if (opts?.signal?.aborted) return; + try { + const { options, internalObjects } = await buildMiniflareOptions( + this.#log, + config + ); + if (opts?.signal?.aborted) return; + if (this.#mf === undefined) { + this.#mf = new Miniflare(options); + } else { + await this.#mf.setOptions(options); + } + const url = await this.#mf.ready; + if (opts?.signal?.aborted) return; + const event = new ReloadedEvent("reloaded", { + url, + internalDurableObjects: internalObjects, + }); + this.dispatchEvent(event); + } catch (error: unknown) { + this.dispatchEvent(new ErrorEvent("error", { error })); + } + } + onBundleUpdate(config: ConfigBundle, opts?: Abortable): Promise { + return this.#mutex.runWith(() => this.#onBundleUpdate(config, opts)); + } + + #onDispose = async (): Promise => { + await this.#mf?.dispose(); + this.#mf = undefined; + }; + onDispose(): Promise { + return this.#mutex.runWith(this.#onDispose); + } +} diff --git a/packages/wrangler/src/dev/start-server.ts b/packages/wrangler/src/dev/start-server.ts index 177e3862ad4e..e5f74fc72c90 100644 --- a/packages/wrangler/src/dev/start-server.ts +++ b/packages/wrangler/src/dev/start-server.ts @@ -1,31 +1,21 @@ -import { fork } from "node:child_process"; -import { realpathSync } from "node:fs"; import * as path from "node:path"; import * as util from "node:util"; +import chalk from "chalk"; import onExit from "signal-exit"; import tmp from "tmp-promise"; import { bundleWorker, dedupeModulesByName } from "../bundle"; import { getBoundRegisteredWorkers, - registerWorker, startWorkerRegistry, stopWorkerRegistry, } from "../dev-registry"; import { runCustomBuild } from "../entry"; import { logger } from "../logger"; -import { waitForPortToBeAvailable } from "../proxy"; import traverseModuleGraph from "../traverse-module-graph"; -import { - setupBindings, - getMiniflare3, - buildMiniflare3Logger, - setupMiniflareOptions, - setupNodeOptions, - transformMf2OptionsToMf3Options, -} from "./local"; +import { localPropsToConfigBundle, maybeRegisterLocalWorker } from "./local"; +import { MiniflareServer } from "./miniflare"; import { startRemoteServer } from "./remote"; import { validateDevProps } from "./validate-dev-props"; - import type { Config } from "../config"; import type { DurableObjectBindings } from "../config/environment"; import type { WorkerRegistry } from "../dev-registry"; @@ -34,9 +24,6 @@ import type { CfModule } from "../worker"; import type { DevProps, DirectorySyncResult } from "./dev"; import type { LocalProps } from "./local"; import type { EsbuildBundle } from "./use-esbuild"; -import type { Miniflare as Miniflare3Type } from "@miniflare/tre"; - -import type { ChildProcess } from "node:child_process"; export async function startDevServer( props: DevProps & { @@ -44,152 +31,144 @@ export async function startDevServer( disableDevRegistry: boolean; } ) { - try { - let workerDefinitions: WorkerRegistry = {}; - validateDevProps(props); - - if (props.build.command) { - const relativeFile = - path.relative(props.entry.directory, props.entry.file) || "."; - await runCustomBuild(props.entry.file, relativeFile, props.build).catch( - (err) => { - logger.error("Custom build failed:", err); - } - ); - } - - //implement a react-free version of useTmpDir - const directory = setupTempDir(); - if (!directory) { - throw new Error("Failed to create temporary directory."); - } + let workerDefinitions: WorkerRegistry = {}; + validateDevProps(props); + + if (props.build.command) { + const relativeFile = + path.relative(props.entry.directory, props.entry.file) || "."; + await runCustomBuild(props.entry.file, relativeFile, props.build).catch( + (err) => { + logger.error("Custom build failed:", err); + } + ); + } - //start the worker registry - logger.log("disableDevRegistry: ", props.disableDevRegistry); - if (!props.disableDevRegistry) { - try { - await startWorkerRegistry(); - if (props.local) { - const boundRegisteredWorkers = await getBoundRegisteredWorkers({ - services: props.bindings.services, - durableObjects: props.bindings.durable_objects, - }); + //implement a react-free version of useTmpDir + const directory = setupTempDir(); + if (!directory) { + throw new Error("Failed to create temporary directory."); + } - if ( - !util.isDeepStrictEqual(boundRegisteredWorkers, workerDefinitions) - ) { - workerDefinitions = boundRegisteredWorkers || {}; - } + //start the worker registry + logger.log("disableDevRegistry: ", props.disableDevRegistry); + if (!props.disableDevRegistry) { + try { + await startWorkerRegistry(); + if (props.local) { + const boundRegisteredWorkers = await getBoundRegisteredWorkers({ + services: props.bindings.services, + durableObjects: props.bindings.durable_objects, + }); + + if ( + !util.isDeepStrictEqual(boundRegisteredWorkers, workerDefinitions) + ) { + workerDefinitions = boundRegisteredWorkers || {}; } - } catch (err) { - logger.error("failed to start worker registry", err); } + } catch (err) { + logger.error("failed to start worker registry", err); } + } - const betaD1Shims = props.bindings.d1_databases?.map((db) => db.binding); + const betaD1Shims = props.bindings.d1_databases?.map((db) => db.binding); + + //implement a react-free version of useEsbuild + const bundle = await runEsbuild({ + entry: props.entry, + destination: directory.name, + jsxFactory: props.jsxFactory, + processEntrypoint: props.processEntrypoint, + additionalModules: props.additionalModules,rules: props.rules, + jsxFragment: props.jsxFragment, + serveAssetsFromWorker: Boolean( + props.assetPaths && !props.isWorkersSite && props.local + ), + tsconfig: props.tsconfig, + minify: props.minify, + legacyNodeCompat: props.legacyNodeCompat, + nodejsCompat: props.nodejsCompat, + define: props.define, + noBundle: props.noBundle, + assets: props.assetsConfig, + betaD1Shims, + workerDefinitions, + services: props.bindings.services, + firstPartyWorkerDevFacade: props.firstPartyWorker, + testScheduled: props.testScheduled, + local: props.local, + doBindings: props.bindings.durable_objects?.bindings ?? [], + }); - //implement a react-free version of useEsbuild - const bundle = await runEsbuild({ - entry: props.entry, - destination: directory.name, - jsxFactory: props.jsxFactory, - processEntrypoint: props.processEntrypoint, - additionalModules: props.additionalModules, + if (props.local) { + const { stop } = await startLocalServer({ + name: props.name, + bundle: bundle, + format: props.entry.format, + compatibilityDate: props.compatibilityDate, + compatibilityFlags: props.compatibilityFlags, + bindings: props.bindings, + assetPaths: props.assetPaths, + initialPort: props.initialPort, + initialIp: props.initialIp, rules: props.rules, - jsxFragment: props.jsxFragment, - serveAssetsFromWorker: Boolean( - props.assetPaths && !props.isWorkersSite && props.local - ), - tsconfig: props.tsconfig, - minify: props.minify, - legacyNodeCompat: props.legacyNodeCompat, - nodejsCompat: props.nodejsCompat, - define: props.define, - noBundle: props.noBundle, - assets: props.assetsConfig, - betaD1Shims, + inspectorPort: props.inspectorPort, + runtimeInspectorPort: props.runtimeInspectorPort, + localPersistencePath: props.localPersistencePath, + liveReload: props.liveReload, + crons: props.crons, + queueConsumers: props.queueConsumers, + localProtocol: props.localProtocol, + localUpstream: props.localUpstream, + inspect: props.inspect, + onReady: props.onReady, + enablePagesAssetsServiceBinding: props.enablePagesAssetsServiceBinding, + usageModel: props.usageModel, workerDefinitions, - services: props.bindings.services, - firstPartyWorkerDevFacade: props.firstPartyWorker, - testScheduled: props.testScheduled, - local: props.local, - experimentalLocal: props.experimentalLocal, - doBindings: props.bindings.durable_objects?.bindings ?? [], + sourceMapPath: bundle?.sourceMapPath, }); - if (props.local) { - const { stop, inspectorUrl } = await startLocalServer({ - name: props.name, - bundle: bundle, - format: props.entry.format, - compatibilityDate: props.compatibilityDate, - compatibilityFlags: props.compatibilityFlags, - bindings: props.bindings, - assetPaths: props.assetPaths, - initialPort: props.initialPort, - initialIp: props.initialIp, - rules: props.rules, - inspectorPort: props.inspectorPort, - localPersistencePath: props.localPersistencePath, - liveReload: props.liveReload, - crons: props.crons, - queueConsumers: props.queueConsumers, - localProtocol: props.localProtocol, - localUpstream: props.localUpstream, - inspect: props.inspect, - onReady: props.onReady, - enablePagesAssetsServiceBinding: props.enablePagesAssetsServiceBinding, - usageModel: props.usageModel, - workerDefinitions, - experimentalLocal: props.experimentalLocal, - accountId: props.accountId, - experimentalLocalRemoteKv: props.experimentalLocalRemoteKv, - sourceMapPath: bundle?.sourceMapPath, - }); - - return { - stop: async () => { - stop(); - await stopWorkerRegistry(); - }, - inspectorUrl, - }; - } else { - const { stop } = await startRemoteServer({ - name: props.name, - bundle: bundle, - format: props.entry.format, - accountId: props.accountId, - bindings: props.bindings, - assetPaths: props.assetPaths, - isWorkersSite: props.isWorkersSite, - port: props.initialPort, - ip: props.initialIp, - localProtocol: props.localProtocol, - inspectorPort: props.inspectorPort, - inspect: props.inspect, - compatibilityDate: props.compatibilityDate, - compatibilityFlags: props.compatibilityFlags, - usageModel: props.usageModel, - env: props.env, - legacyEnv: props.legacyEnv, - zone: props.zone, - host: props.host, - routes: props.routes, - onReady: props.onReady, - sourceMapPath: bundle?.sourceMapPath, - sendMetrics: props.sendMetrics, - }); - return { - stop: async () => { - stop(); - await stopWorkerRegistry(); - }, - // TODO: inspectorUrl, - }; - } - } catch (err) { - logger.error(err); + return { + stop: async () => { + stop(); + await stopWorkerRegistry(); + }, + // TODO: inspectorUrl, + }; + } else { + const { stop } = await startRemoteServer({ + name: props.name, + bundle: bundle, + format: props.entry.format, + accountId: props.accountId, + bindings: props.bindings, + assetPaths: props.assetPaths, + isWorkersSite: props.isWorkersSite, + port: props.initialPort, + ip: props.initialIp, + localProtocol: props.localProtocol, + inspectorPort: props.inspectorPort, + inspect: props.inspect, + compatibilityDate: props.compatibilityDate, + compatibilityFlags: props.compatibilityFlags, + usageModel: props.usageModel, + env: props.env, + legacyEnv: props.legacyEnv, + zone: props.zone, + host: props.host, + routes: props.routes, + onReady: props.onReady, + sourceMapPath: bundle?.sourceMapPath, + sendMetrics: props.sendMetrics, + }); + return { + stop: async () => { + stop(); + await stopWorkerRegistry(); + }, + // TODO: inspectorUrl, + }; } } @@ -226,7 +205,6 @@ async function runEsbuild({ firstPartyWorkerDevFacade, testScheduled, local, - experimentalLocal, doBindings, }: { entry: Entry; @@ -250,7 +228,6 @@ async function runEsbuild({ firstPartyWorkerDevFacade: boolean | undefined; testScheduled?: boolean; local: boolean; - experimentalLocal: boolean | undefined; doBindings: DurableObjectBindings; }): Promise { if (!destination) return; @@ -289,7 +266,6 @@ async function runEsbuild({ targetConsumer: "dev", // We are starting a dev server testScheduled, local, - experimentalLocal, doBindings, additionalModules: dedupeModulesByName([ ...(traverseModuleGraphResult?.modules ?? []), @@ -317,242 +293,53 @@ async function runEsbuild({ }; } -export async function startLocalServer({ - name: workerName, - bundle, - format, - compatibilityDate, - compatibilityFlags, - usageModel, - bindings, - workerDefinitions, - assetPaths, - initialPort, - inspectorPort, - rules, - localPersistencePath, - liveReload, - initialIp, - crons, - queueConsumers, - localProtocol, - localUpstream, - inspect, - onReady, - enablePagesAssetsServiceBinding, - experimentalLocal, - accountId, - experimentalLocalRemoteKv, -}: LocalProps) { - let local: ChildProcess | undefined; - let experimentalLocalRef: Miniflare3Type | undefined; - let removeSignalExitListener: (() => void) | undefined; - let inspectorUrl: string | undefined; - const setInspectorUrl = (url: string) => { - inspectorUrl = url; - }; - - const abortController = new AbortController(); - async function startLocalWorker() { - if (!bundle || !format) return; - - // port for the worker - await waitForPortToBeAvailable(initialPort, { - retryPeriod: 200, - timeout: 2000, - abortSignal: abortController.signal, - }); - - if (bindings.services && bindings.services.length > 0) { - logger.warn( - "⎔ Support for service bindings in local mode is experimental and may change." - ); - } - - const scriptPath = realpathSync(bundle.path); - - const upstream = - typeof localUpstream === "string" - ? `${localProtocol}://${localUpstream}` - : undefined; - - const { - externalDurableObjects, - internalDurableObjects, - wasmBindings, - textBlobBindings, - dataBlobBindings, - } = setupBindings({ - wasm_modules: bindings.wasm_modules, - text_blobs: bindings.text_blobs, - data_blobs: bindings.data_blobs, - durable_objects: bindings.durable_objects, - format, - bundle, - }); +export async function startLocalServer(props: LocalProps) { + if (!props.bundle || !props.format) return Promise.resolve({ stop() {} }); - const { forkOptions, miniflareCLIPath, options } = setupMiniflareOptions({ - workerName, - port: initialPort, - scriptPath, - localProtocol, - ip: initialIp, - format, - rules, - compatibilityDate, - compatibilityFlags, - usageModel, - kv_namespaces: bindings?.kv_namespaces, - queueBindings: bindings?.queues, - queueConsumers, - r2_buckets: bindings?.r2_buckets, - d1_databases: bindings?.d1_databases, - internalDurableObjects, - externalDurableObjects, - localPersistencePath, - liveReload, - assetPaths, - vars: bindings?.vars, - wasmBindings, - textBlobBindings, - dataBlobBindings, - crons, - upstream, - workerDefinitions, - enablePagesAssetsServiceBinding, - }); + // Log warnings for experimental dev-registry-dependent options + if (props.bindings.services && props.bindings.services.length > 0) { + logger.warn( + "⎔ Support for service bindings in local mode is experimental and may change." + ); + } + const externalDurableObjects = ( + props.bindings.durable_objects?.bindings || [] + ).filter((binding) => binding.script_name); + if (externalDurableObjects.length > 0) { + logger.warn( + "⎔ Support for external Durable Objects in local mode is experimental and may change." + ); + } - if (experimentalLocal) { - const log = await buildMiniflare3Logger(); - const mf3Options = await transformMf2OptionsToMf3Options({ - miniflare2Options: options, - format, - bundle, - log, - kvNamespaces: bindings?.kv_namespaces, - r2Buckets: bindings?.r2_buckets, - d1Databases: bindings?.d1_databases, - authenticatedAccountId: accountId, - kvRemote: experimentalLocalRemoteKv, - inspectorPort, - }); - const { Miniflare } = await getMiniflare3(); - const mf = new Miniflare(mf3Options); - const runtimeURL = await mf.ready; - experimentalLocalRef = mf; - removeSignalExitListener = onExit((_code, _signal) => { - logger.log("⎔ Shutting down experimental local server."); - void mf.dispose(); - experimentalLocalRef = undefined; + logger.log(chalk.dim("⎔ Starting local server...")); + + const config = await localPropsToConfigBundle(props); + return new Promise<{ stop: () => void }>((resolve, reject) => { + const server = new MiniflareServer(); + server.addEventListener("reloaded", async (event) => { + await maybeRegisterLocalWorker(event, props.name); + props.onReady?.(event.url.hostname, parseInt(event.url.port)); + // Note `unstable_dev` doesn't do anything with the inspector URL yet + resolve({ + stop: () => { + abortController.abort(); + logger.log("⎔ Shutting down local server..."); + // Initialisation errors are also thrown asynchronously by dispose(). + // The `addEventListener("error")` above should've caught them though. + server.onDispose().catch(() => {}); + removeMiniflareServerExitListener(); + }, }); - onReady?.(runtimeURL.hostname, parseInt(runtimeURL.port ?? 8787)); - return; - } - - const nodeOptions = setupNodeOptions({ inspect, inspectorPort }); - logger.log("⎔ Starting a local server..."); - - const child = (local = fork(miniflareCLIPath, forkOptions, { - cwd: path.dirname(scriptPath), - execArgv: nodeOptions, - stdio: "pipe", - })); - - child.on("message", async (messageString) => { - const message = JSON.parse(messageString as string); - if (message.ready) { - // Let's register our presence in the dev registry - if (workerName) { - await registerWorker(workerName, { - protocol: localProtocol, - mode: "local", - port: message.port, - host: initialIp, - durableObjects: internalDurableObjects.map((binding) => ({ - name: binding.name, - className: binding.class_name, - })), - ...(message.durableObjectsPort - ? { - durableObjectsHost: initialIp, - durableObjectsPort: message.durableObjectsPort, - } - : {}), - }); - } - onReady?.(initialIp, message.port); - } - }); - - child.on("close", (code) => { - if (code) { - logger.log(`Miniflare process exited with code ${code}`); - } - }); - - child.stdout?.on("data", (data: Buffer) => { - process.stdout.write(data); - }); - - // parse the node inspector url (which may be received in chunks) from stderr - let stderrData = ""; - let inspectorUrlFound = false; - child.stderr?.on("data", (data: Buffer) => { - if (!inspectorUrlFound) { - stderrData += data.toString(); - const matches = - /Debugger listening on (ws:\/\/127\.0\.0\.1:\d+\/[A-Za-z0-9-]+)[\r|\n]/.exec( - stderrData - ); - if (matches) { - inspectorUrlFound = true; - setInspectorUrl(matches[1]); - } - } - - process.stderr.write(data); - }); - - child.on("exit", (code) => { - if (code) { - logger.error(`Miniflare process exited with code ${code}`); - } }); - - child.on("error", (error: Error) => { - logger.error(`Miniflare process failed to spawn`); - logger.error(error); + server.addEventListener("error", ({ error }) => { + logger.error("Error reloading local server:", error); + reject(error); }); - - removeSignalExitListener = onExit((_code, _signal) => { - logger.log("⎔ Shutting down local server."); - child.kill(); - local = undefined; + const removeMiniflareServerExitListener = onExit(() => { + logger.log(chalk.dim("⎔ Shutting down local server...")); + void server.onDispose(); }); - } - - startLocalWorker().catch((err) => { - logger.error("local worker:", err); + const abortController = new AbortController(); + void server.onBundleUpdate(config, { signal: abortController.signal }); }); - - return { - inspectorUrl, - stop: () => { - abortController.abort(); - if (local) { - logger.log("⎔ Shutting down local server."); - local.kill(); - local = undefined; - } - if (experimentalLocalRef) { - logger.log("⎔ Shutting down experimental local server."); - // Initialisation errors are also thrown asynchronously by dispose(). - // The catch() above should've caught them though. - experimentalLocalRef?.dispose().catch(() => {}); - experimentalLocalRef = undefined; - } - removeSignalExitListener?.(); - removeSignalExitListener = undefined; - }, - }; } diff --git a/packages/wrangler/src/dev/use-esbuild.ts b/packages/wrangler/src/dev/use-esbuild.ts index b6bddcf897ab..a4705ab33d8a 100644 --- a/packages/wrangler/src/dev/use-esbuild.ts +++ b/packages/wrangler/src/dev/use-esbuild.ts @@ -146,7 +146,6 @@ export function useEsbuild({ local, targetConsumer, testScheduled, - experimentalLocal, additionalModules: dedupeModulesByName([ ...(traverseModuleGraphResult?.modules ?? []), ...additionalModules, diff --git a/packages/wrangler/src/init.ts b/packages/wrangler/src/init.ts index b991c2ec60c0..ea77ab9f6504 100644 --- a/packages/wrangler/src/init.ts +++ b/packages/wrangler/src/init.ts @@ -207,6 +207,8 @@ export async function initHandler(args: InitArgs) { "--", "--type", "pre-existing", + "--name", + fromDashScriptName, ]; // Deprecate the `init --from-dash` command diff --git a/packages/wrangler/src/miniflare-cli/README.md b/packages/wrangler/src/miniflare-cli/README.md deleted file mode 100644 index b3f01672c3c5..000000000000 --- a/packages/wrangler/src/miniflare-cli/README.md +++ /dev/null @@ -1,30 +0,0 @@ -# Custom Miniflare CLI - -This directory contains a simple wrapper around the programmatic Miniflare API, -which Wrangler spawns when running `wrangler dev` in local mode. - -## Building - -This CLI is built at the same time as Wrangler by running - -``` -npm run -w wrangler build -``` - -The output of the build is `miniflare-dist/index.mjs`. - -## Running - -The CLI expects a single command line argument which is the Miniflare options formatted as a string of JSON. - -```bash -node --no-warnings ./packages/wrangler/miniflare-dist/index.mjs '{"watch": true, "script": ""}' --log VERBOSE -``` - -The `--log` argument is optional and takes one of Miniflare's LogLevels: "NONE", "ERROR", "WARN", "INFO", "DEBUG", "VERBOSE". -It defaults to `INFO`. - -## Debugging - -Simply place a breakpoint in the code and run the above command in the VS Code "JavaScript Debug Terminal". -The code will stop at the breakpoint as expected. diff --git a/packages/wrangler/src/miniflare-cli/assets.ts b/packages/wrangler/src/miniflare-cli/assets.ts index a0c43eeae7fa..ccfd861b7610 100644 --- a/packages/wrangler/src/miniflare-cli/assets.ts +++ b/packages/wrangler/src/miniflare-cli/assets.ts @@ -5,17 +5,15 @@ import { parseHeaders } from "@cloudflare/pages-shared/metadata-generator/parseH import { parseRedirects } from "@cloudflare/pages-shared/metadata-generator/parseRedirects"; import { watch } from "chokidar"; import { getType } from "mime"; +import { Request, Response, fetch } from "miniflare"; import { hashFile } from "../pages/hash"; import type { Metadata } from "@cloudflare/pages-shared/asset-server/metadata"; import type { ParsedRedirects, ParsedHeaders, } from "@cloudflare/pages-shared/metadata-generator/types"; -import type { - Request as MiniflareRequest, - RequestInfo as MiniflareRequestInfo, - RequestInit as MiniflareRequestInit, -} from "@miniflare/core"; +import type { Request as WorkersRequest } from "@cloudflare/workers-types/experimental"; +import type { RequestInit } from "miniflare"; interface Logger { log: (message: string) => void; @@ -27,39 +25,25 @@ export interface Options { log: Logger; proxyPort?: number; directory?: string; - tre: boolean; } export default async function generateASSETSBinding(options: Options) { const assetsFetch = options.directory !== undefined - ? await generateAssetsFetch(options.directory, options.log, options.tre) + ? await generateAssetsFetch(options.directory, options.log) : invalidAssetsFetch; - const miniflare = options.tre - ? await import("@miniflare/tre") - : await import("@miniflare/core"); - - const Request = miniflare.Request as typeof MiniflareRequest; - const Response = miniflare.Response; - // WebSockets won't work with `--experimental-local` until we expose something like `upgradingFetch` from `@miniflare/tre`. - const fetch = ( - options.tre - ? miniflare.fetch - : (await import("@miniflare/web-sockets")).upgradingFetch - ) as (request: Request) => Promise; - return async function (miniflareRequest: Request) { if (options.proxyPort) { try { const url = new URL(miniflareRequest.url); url.host = `localhost:${options.proxyPort}`; - const proxyRequest = new Request(url, miniflareRequest); + const proxyRequest = new Request(url, miniflareRequest as RequestInit); if (proxyRequest.headers.get("Upgrade") === "websocket") { proxyRequest.headers.delete("Sec-WebSocket-Accept"); proxyRequest.headers.delete("Sec-WebSocket-Key"); } - return await fetch(proxyRequest as Request); + return await fetch(proxyRequest); } catch (thrown) { options.log.error(new Error(`Could not proxy request: ${thrown}`)); @@ -86,8 +70,7 @@ export default async function generateASSETSBinding(options: Options) { async function generateAssetsFetch( directory: string, - log: Logger, - tre: boolean + log: Logger ): Promise { // Defer importing miniflare until we really need it @@ -95,22 +78,11 @@ async function generateAssetsFetch( // `@cloudflare/pages-shared/environment-polyfills/types.ts`, allowing us to // use `fetch`, `Headers`, `Request` and `Response` as globals in this file // and the *entire* `miniflare-cli` TypeScript project. - const polyfill = tre - ? ( - await import( - "@cloudflare/pages-shared/environment-polyfills/miniflare-tre" - ) - ).default - : (await import("@cloudflare/pages-shared/environment-polyfills/miniflare")) - .default; - + const polyfill = ( + await import("@cloudflare/pages-shared/environment-polyfills/miniflare") + ).default; await polyfill(); - const miniflare = tre - ? await import("@miniflare/tre") - : await import("@miniflare/core"); - const Request = miniflare.Request as typeof MiniflareRequest; - const { generateHandler, parseQualityWeightedList } = await import( "@cloudflare/pages-shared/asset-server/handler" ); @@ -169,7 +141,7 @@ async function generateAssetsFetch( const assetKeyEntryMap = new Map(); return await generateHandler({ - request, + request: request as unknown as WorkersRequest, metadata: metadata as Metadata, xServerEnvHeader: "dev", logError: console.error, @@ -238,9 +210,9 @@ async function generateAssetsFetch( }); }; - return async (input: MiniflareRequestInfo, init?: MiniflareRequestInit) => { - const request = new Request(input, init); - return await generateResponse(request as unknown as Request); + return async (input, init) => { + const request = new Request(input, init as RequestInit); + return (await generateResponse(request)) as unknown as Response; }; } diff --git a/packages/wrangler/src/miniflare-cli/index.ts b/packages/wrangler/src/miniflare-cli/index.ts deleted file mode 100644 index 6b5894a144e9..000000000000 --- a/packages/wrangler/src/miniflare-cli/index.ts +++ /dev/null @@ -1,210 +0,0 @@ -import { - DurableObjectNamespace, - DurableObjectStub, -} from "@miniflare/durable-objects"; -import { upgradingFetch } from "@miniflare/web-sockets"; -import { - Log as MiniflareLog, - LogLevel as MiniflareLogLevel, - Miniflare, - Request as MiniflareRequest, - Response as MiniflareResponse, -} from "miniflare"; -import yargs from "yargs"; -import { hideBin } from "yargs/helpers"; -import { FatalError } from "../errors"; -import generateASSETSBinding from "./assets"; -import { getRequestContextCheckOptions } from "./request-context"; -import type { LoggerLevel } from "../logger"; -import type { Options } from "./assets"; -import type { EnablePagesAssetsServiceBindingOptions } from "./types"; -import type { AddressInfo } from "net"; - -// miniflare defines this but importing it throws: -// Dynamic require of "path" is not supported -class MiniflareNoOpLog extends MiniflareLog { - log(): void {} - - error(message: Error): void { - throw message; - } -} - -async function main() { - const args = await yargs(hideBin(process.argv)).help(false).version(false) - .argv; - - const requestContextCheckOptions = await getRequestContextCheckOptions(); - const config = { - ...JSON.parse((args._[0] as string) ?? "{}"), - ...requestContextCheckOptions, - }; - - let logLevelString: Uppercase = config.logLevel.toUpperCase(); - if (logLevelString === "LOG") logLevelString = "INFO"; - const logLevel = MiniflareLogLevel[logLevelString]; - - config.log = - logLevel === MiniflareLogLevel.NONE - ? new MiniflareNoOpLog() - : new MiniflareLog(logLevel); - - if (logLevel === MiniflareLogLevel.DEBUG) { - console.log("MINIFLARE OPTIONS:\n", JSON.stringify(config, null, 2)); - } - - config.bindings = { - ...config.bindings, - ...(config.externalDurableObjects && - Object.fromEntries( - Object.entries( - config.externalDurableObjects as Record< - string, - { name: string; host: string; port: number } - > - ).map(([binding, { name, host, port }]) => { - const factory = () => { - throw new FatalError( - "An external Durable Object instance's state has somehow been attempted to be accessed.", - 1 - ); - }; - const namespace = new DurableObjectNamespace(name as string, factory); - namespace.get = (id) => { - const stub = new DurableObjectStub(factory, id); - stub.fetch = (...reqArgs) => { - const requestFromArgs = new MiniflareRequest(...reqArgs); - const url = new URL(requestFromArgs.url); - url.host = host; - if (port !== undefined) url.port = port.toString(); - const request = new MiniflareRequest( - url.toString(), - requestFromArgs - ); - request.headers.set("x-miniflare-durable-object-name", name); - request.headers.set( - "x-miniflare-durable-object-id", - id.toString() - ); - - return upgradingFetch(request); - }; - return stub; - }; - return [binding, namespace]; - }) - )), - }; - - let mf: Miniflare | undefined; - let durableObjectsMf: Miniflare | undefined = undefined; - let durableObjectsMfPort: number | undefined = undefined; - - try { - if (args._[1]) { - const opts: EnablePagesAssetsServiceBindingOptions = JSON.parse( - args._[1] as string - ); - - if (isNaN(opts.proxyPort || NaN) && !opts.directory) { - throw new Error( - "MiniflareCLIOptions: built in service bindings set to true, but no port or directory provided" - ); - } - - const options: Options = { - log: config.log, - proxyPort: opts.proxyPort, - directory: opts.directory, - tre: false, - }; - - config.serviceBindings = { - ...config.serviceBindings, - ASSETS: await generateASSETSBinding(options), - }; - } - mf = new Miniflare(config); - // Start Miniflare development server - const mfServer = await mf.startServer(); - const mfPort = (mfServer.address() as AddressInfo).port; - await mf.startScheduler(); - - const internalDurableObjectClassNames = Object.values( - config.durableObjects as Record - ); - - if (internalDurableObjectClassNames.length > 0) { - durableObjectsMf = new Miniflare({ - host: config.host, - port: 0, - script: ` - export default { - fetch(request, env) { - return env.DO.fetch(request) - } - }`, - serviceBindings: { - DO: async (request: MiniflareRequest) => { - request = new MiniflareRequest(request); - - const name = request.headers.get("x-miniflare-durable-object-name"); - const idString = request.headers.get( - "x-miniflare-durable-object-id" - ); - request.headers.delete("x-miniflare-durable-object-name"); - request.headers.delete("x-miniflare-durable-object-id"); - - if (!name || !idString) { - return new MiniflareResponse( - "[durable-object-proxy-err] Missing `x-miniflare-durable-object-name` or `x-miniflare-durable-object-id` headers.", - { status: 400 } - ); - } - - const namespace = await mf?.getDurableObjectNamespace(name); - const id = namespace?.idFromString(idString); - - if (!id) { - return new MiniflareResponse( - "[durable-object-proxy-err] Could not generate an ID. Possibly due to a mismatched DO name and ID?", - { status: 500 } - ); - } - - const stub = namespace?.get(id); - - if (!stub) { - return new MiniflareResponse( - "[durable-object-proxy-err] Could not generate a stub. Possibly due to a mismatched DO name and ID?", - { status: 500 } - ); - } - - return stub.fetch(request); - }, - }, - modules: true, - }); - const server = await durableObjectsMf.startServer(); - durableObjectsMfPort = (server.address() as AddressInfo).port; - } - - process.send && - process.send( - JSON.stringify({ - port: mfPort, - ready: true, - durableObjectsPort: durableObjectsMfPort, - }) - ); - } catch (e) { - mf?.log.error(e as Error); - process.exitCode = 1; - // Unmount any mounted workers - await mf?.dispose(); - await durableObjectsMf?.dispose(); - } -} - -await main(); diff --git a/packages/wrangler/src/miniflare-cli/request-context.ts b/packages/wrangler/src/miniflare-cli/request-context.ts deleted file mode 100644 index 8d1cf13cd1a3..000000000000 --- a/packages/wrangler/src/miniflare-cli/request-context.ts +++ /dev/null @@ -1,40 +0,0 @@ -import type { MiniflareOptions } from "miniflare"; - -/** - * Certain runtime APIs are only available to workers during the "request context", - * which is any code that returns after receiving a request and before returning - * a response. - * - * Miniflare emulates this behavior by using an [`AsyncLocalStorage`](https://nodejs.org/api/async_context.html#class-asynclocalstorage) and - * [checking at runtime](https://github.com/cloudflare/miniflare/blob/master/packages/shared/src/context.ts#L21-L36) - * to see if you're using those APIs during the request context. - * - * In certain environments `AsyncLocalStorage` is unavailable, such as in a - * [webcontainer](https://github.com/stackblitz/webcontainer-core). - * This function figures out if we're able to run those "request context" checks - * and returns [a set of options](https://miniflare.dev/core/standards#global-functionality-limits) - * that indicate to miniflare whether to run the checks or not. - */ -export const getRequestContextCheckOptions = async (): Promise< - Pick -> => { - // check that there's an implementation of AsyncLocalStorage - let hasAsyncLocalStorage = true; - try { - // ripped from the example here https://nodejs.org/api/async_context.html#class-asynclocalstorage - const { AsyncLocalStorage } = await import("node:async_hooks"); - const storage = new AsyncLocalStorage(); - - hasAsyncLocalStorage = storage.run("some-value", () => { - return storage.getStore() === "some-value"; - }); - } catch (e) { - hasAsyncLocalStorage = false; - } - - return { - globalAsyncIO: hasAsyncLocalStorage, - globalRandom: hasAsyncLocalStorage, - globalTimers: hasAsyncLocalStorage, - }; -}; diff --git a/packages/wrangler/src/pages/dev.ts b/packages/wrangler/src/pages/dev.ts index 29eef7f8fe8e..5a1feba197a3 100644 --- a/packages/wrangler/src/pages/dev.ts +++ b/packages/wrangler/src/pages/dev.ts @@ -49,6 +49,8 @@ export function Options(yargs: CommonYargsArgv) { type: "boolean", default: true, description: "Run on my machine", + deprecated: true, + hidden: true, }, "compatibility-date": { describe: "Date to use for compatibility checks", @@ -141,7 +143,8 @@ export function Options(yargs: CommonYargsArgv) { "experimental-local": { describe: "Run on my machine using the Cloudflare Workers runtime", type: "boolean", - default: false, + deprecated: true, + hidden: true, }, config: { describe: "Pages does not support wrangler.toml", @@ -157,7 +160,6 @@ export function Options(yargs: CommonYargsArgv) { } export const Handler = async ({ - local, directory, compatibilityDate, compatibilityFlags, @@ -189,8 +191,10 @@ export const Handler = async ({ logger.loggerLevel = logLevel; } - if (!local) { - throw new FatalError("Only local mode is supported at the moment.", 1); + if (experimentalLocal) { + logger.warn( + "--experimental-local is no longer required and will be removed in a future version.\n`wrangler pages dev` now uses the local Cloudflare Workers runtime by default." + ); } if (config) { @@ -522,7 +526,7 @@ export const Handler = async ({ ), kv: kvs.map((binding) => ({ binding: binding.toString(), - id: "", + id: binding.toString(), })), durableObjects: durableObjects .map((durableObject) => { @@ -546,7 +550,7 @@ export const Handler = async ({ }) .filter(Boolean) as AdditionalDevProps["durableObjects"], r2: r2s.map((binding) => { - return { binding: binding.toString(), bucket_name: "" }; + return { binding: binding.toString(), bucket_name: binding.toString() }; }), rules: usingWorkerDirectory ? [ @@ -573,7 +577,6 @@ export const Handler = async ({ proxyPort, directory, }, - experimentalLocal, liveReload, forceLocal: true, showInteractiveDevSession: undefined, diff --git a/packages/wrangler/src/pages/functions/buildPlugin.ts b/packages/wrangler/src/pages/functions/buildPlugin.ts index ce06ef19d384..857d7d170459 100644 --- a/packages/wrangler/src/pages/functions/buildPlugin.ts +++ b/packages/wrangler/src/pages/functions/buildPlugin.ts @@ -107,7 +107,6 @@ export function buildPlugin({ checkFetch: local, targetConsumer: local ? "dev" : "deploy", local, - experimentalLocal: false, forPages: true, } ); diff --git a/packages/wrangler/src/pages/functions/buildWorker.ts b/packages/wrangler/src/pages/functions/buildWorker.ts index d53926877674..45ab0505128c 100644 --- a/packages/wrangler/src/pages/functions/buildWorker.ts +++ b/packages/wrangler/src/pages/functions/buildWorker.ts @@ -153,7 +153,6 @@ export function buildWorker({ checkFetch: local, targetConsumer: local ? "dev" : "deploy", local, - experimentalLocal: false, forPages: true, } ); @@ -253,7 +252,6 @@ export function buildRawWorker({ checkFetch: local, targetConsumer: local ? "dev" : "deploy", local, - experimentalLocal: false, forPages: true, additionalModules, } diff --git a/packages/wrangler/src/traverse-module-graph.ts b/packages/wrangler/src/traverse-module-graph.ts index 2c9c8845c14b..1e1ad3dc3bd0 100644 --- a/packages/wrangler/src/traverse-module-graph.ts +++ b/packages/wrangler/src/traverse-module-graph.ts @@ -13,7 +13,13 @@ async function getFiles(root: string, relativeTo: string): Promise { if (file.isDirectory()) { files.push(...(await getFiles(path.join(root, file.name), relativeTo))); } else { - files.push(path.relative(relativeTo, path.join(root, file.name))); + // Module names should always use `/`. This is also required to match globs correctly on Windows. Later code will + // `path.resolve()` with these names to read contents which will perform appropriate normalisation. + files.push( + path + .relative(relativeTo, path.join(root, file.name)) + .replaceAll("\\", "/") + ); } } return files; @@ -24,7 +30,9 @@ export default async function traverseModuleGraph( rules: Config["rules"] ): Promise { const files = await getFiles(entry.moduleRoot, entry.moduleRoot); - const relativeEntryPoint = path.relative(entry.moduleRoot, entry.file); + const relativeEntryPoint = path + .relative(entry.moduleRoot, entry.file) + .replaceAll("\\", "/"); const modules = (await matchFiles(files, entry.moduleRoot, parseRules(rules))) .filter((m) => m.name !== relativeEntryPoint) diff --git a/packages/wrangler/src/user/generate-random-state.ts b/packages/wrangler/src/user/generate-random-state.ts index 196ee6e97f11..71a230765822 100644 --- a/packages/wrangler/src/user/generate-random-state.ts +++ b/packages/wrangler/src/user/generate-random-state.ts @@ -8,7 +8,6 @@ import { PKCE_CHARSET } from "../user"; */ export function generateRandomState(lengthOfState: number): string { const output = new Uint32Array(lengthOfState); - // @ts-expect-error crypto's types aren't there yet crypto.getRandomValues(output); return Array.from(output) .map((num: number) => PKCE_CHARSET[num % PKCE_CHARSET.length]) diff --git a/packages/wrangler/src/user/user.ts b/packages/wrangler/src/user/user.ts index 321c9b2cd6dc..be415d43b102 100644 --- a/packages/wrangler/src/user/user.ts +++ b/packages/wrangler/src/user/user.ts @@ -832,14 +832,12 @@ function base64urlEncode(value: string): string { async function generatePKCECodes(): Promise { const output = new Uint32Array(RECOMMENDED_CODE_VERIFIER_LENGTH); - // @ts-expect-error crypto's types aren't there yet crypto.getRandomValues(output); const codeVerifier = base64urlEncode( Array.from(output) .map((num: number) => PKCE_CHARSET[num % PKCE_CHARSET.length]) .join("") ); - // @ts-expect-error crypto's types aren't there yet const buffer = await crypto.subtle.digest( "SHA-256", new TextEncoder().encode(codeVerifier) diff --git a/packages/wrangler/templates/first-party-worker-module-facade.ts b/packages/wrangler/templates/first-party-worker-module-facade.ts index b8a5001ef567..ba5d0bb73eec 100644 --- a/packages/wrangler/templates/first-party-worker-module-facade.ts +++ b/packages/wrangler/templates/first-party-worker-module-facade.ts @@ -9,8 +9,8 @@ type Env = { * Setup globals/vars as required */ -export default { - async fetch(request: Request, env: Env, ctx: ExecutionContext) { +export default >{ + async fetch(request, env, ctx) { if (worker.fetch === undefined) { throw new TypeError("Entry point missing `fetch` handler"); } diff --git a/packages/wrangler/templates/middleware/common.ts b/packages/wrangler/templates/middleware/common.ts index a01c317f990d..1319e5d4f830 100644 --- a/packages/wrangler/templates/middleware/common.ts +++ b/packages/wrangler/templates/middleware/common.ts @@ -5,13 +5,18 @@ export type Dispatcher = ( init: { cron?: string } ) => Awaitable; +export type IncomingRequest = Request< + unknown, + IncomingRequestCfProperties +>; + export interface MiddlewareContext { dispatch: Dispatcher; - next(request: Request, env: any): Awaitable; + next(request: IncomingRequest, env: any): Awaitable; } export type Middleware = ( - request: Request, + request: IncomingRequest, env: any, ctx: ExecutionContext, middlewareCtx: MiddlewareContext @@ -32,7 +37,7 @@ export function __facade_registerInternal__( } function __facade_invokeChain__( - request: Request, + request: IncomingRequest, env: any, ctx: ExecutionContext, dispatch: Dispatcher, @@ -49,7 +54,7 @@ function __facade_invokeChain__( } export function __facade_invoke__( - request: Request, + request: IncomingRequest, env: any, ctx: ExecutionContext, dispatch: Dispatcher, diff --git a/packages/wrangler/templates/middleware/loader-sw.ts b/packages/wrangler/templates/middleware/loader-sw.ts index 507a9792331f..9e465f90d18d 100644 --- a/packages/wrangler/templates/middleware/loader-sw.ts +++ b/packages/wrangler/templates/middleware/loader-sw.ts @@ -1,6 +1,7 @@ import { Awaitable, Dispatcher, + IncomingRequest, Middleware, __facade_invoke__, __facade_register__, @@ -183,8 +184,6 @@ __facade__originalAddEventListener__("fetch", (event) => { }); __FACADE_EVENT_TARGET__.dispatchEvent(facadeEvent); - // @ts-expect-error `waitUntil` types are currently broken, blocked on - // https://github.com/cloudflare/workerd/pull/191 event.waitUntil(Promise.all(facadeEvent[__facade_waitUntil__])); } }; @@ -197,8 +196,6 @@ __facade__originalAddEventListener__("fetch", (event) => { __FACADE_EVENT_TARGET__.dispatchEvent(facadeEvent); facadeEvent[__facade_dispatched__] = true; - // @ts-expect-error `waitUntil` types are currently broken, blocked on - // https://github.com/cloudflare/workerd/pull/191 event.waitUntil(Promise.all(facadeEvent[__facade_waitUntil__])); const response = facadeEvent[__facade_response__]; @@ -210,7 +207,7 @@ __facade__originalAddEventListener__("fetch", (event) => { event.respondWith( __facade_invoke__( - event.request, + event.request as IncomingRequest, globalThis, ctx, __facade_sw_dispatch__, @@ -227,7 +224,5 @@ __facade__originalAddEventListener__("scheduled", (event) => { }); __FACADE_EVENT_TARGET__.dispatchEvent(facadeEvent); - // @ts-expect-error `waitUntil` types are currently broken, blocked on - // https://github.com/cloudflare/workerd/pull/191 event.waitUntil(Promise.all(facadeEvent[__facade_waitUntil__])); }); diff --git a/packages/wrangler/templates/pages-shim.ts b/packages/wrangler/templates/pages-shim.ts index 42d1aee96351..da09e76948f3 100644 --- a/packages/wrangler/templates/pages-shim.ts +++ b/packages/wrangler/templates/pages-shim.ts @@ -3,9 +3,6 @@ export default >{ async fetch(request, env, context) { - // @ts-expect-error due to a bug in `@cloudflare/workers-types`, the `cf` - // types from the `request` parameter and `RequestInit` are incompatible. - // We'll get this fixed very soon. const response = await env.ASSETS.fetch(request.url, request); return new Response(response.body, response); }, diff --git a/templates/worker-prospector/package.json b/templates/worker-prospector/package.json index ffa9d14ce9a3..4104e2396c7f 100644 --- a/templates/worker-prospector/package.json +++ b/templates/worker-prospector/package.json @@ -17,7 +17,7 @@ "@databases/sql": "^3.2.0", "better-sqlite3": "^7.6.2", "typescript": "^4.9.4", - "vitest": "^0.28.3", + "vitest": "^0.31.0", "wrangler": "^2.9.1" } } diff --git a/templates/worker-router/package.json b/templates/worker-router/package.json index ca6a105f5829..caea9b18ad1f 100644 --- a/templates/worker-router/package.json +++ b/templates/worker-router/package.json @@ -12,7 +12,7 @@ "itty-router": "^2.6.1" }, "devDependencies": { - "vitest": "^0.25.2", + "vitest": "^0.31.0", "wrangler": "^2.2.2" } } diff --git a/templates/worker-typescript/package.json b/templates/worker-typescript/package.json index dcedf163fe03..56b8ff49282d 100644 --- a/templates/worker-typescript/package.json +++ b/templates/worker-typescript/package.json @@ -10,7 +10,7 @@ }, "devDependencies": { "@cloudflare/workers-types": "^3.0.0", - "vitest": "^0.24.5", + "vitest": "^0.31.0", "wrangler": "^2.1.14" } } diff --git a/templates/worker/package.json b/templates/worker/package.json index 7539767a286d..8e10e2a46fd7 100644 --- a/templates/worker/package.json +++ b/templates/worker/package.json @@ -8,7 +8,7 @@ "test": "vitest" }, "devDependencies": { - "vitest": "^0.24.5", + "vitest": "^0.31.0", "wrangler": "^2.0.0" } }