diff --git a/fixtures/additional-modules/test/index.test.ts b/fixtures/additional-modules/test/index.test.ts index eb3c969b09..51a2fe129c 100644 --- a/fixtures/additional-modules/test/index.test.ts +++ b/fixtures/additional-modules/test/index.test.ts @@ -1,31 +1,31 @@ import childProcess from "node:child_process"; -import { existsSync } from "node:fs"; +import { existsSync, mkdtempSync } from "node:fs"; import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { removeDir } from "@fixture/shared/src/fs-helpers"; import { afterAll, assert, beforeAll, describe, test, vi } from "vitest"; -import { unstable_startWorker } from "wrangler"; +import { createServer, type WorkerServer } from "wrangler"; import { wranglerEntryPath } from "../../shared/src/run-wrangler-long-lived"; -async function getTmpDir() { - return fs.mkdtemp(path.join(os.tmpdir(), "wrangler-modules-")); -} - -type WranglerDev = Awaited>; -function get(worker: WranglerDev, pathname: string) { - const url = `http://example.com${pathname}`; - // Disable Miniflare's pretty error page, so we can parse errors as JSON - return worker.fetch(url, { headers: { "MF-Disable-Pretty-Error": "true" } }); -} - describe("find_additional_modules dev", () => { - let tmpDir: string; - let worker: WranglerDev; + const tmpDir = mkdtempSync(path.join(os.tmpdir(), "wrangler-modules-")); + const server = createServer({ + root: tmpDir, + workers: [{ configPath: "wrangler.jsonc" }], + watch: true, + }); + + function get(server: WorkerServer, pathname: string) { + const url = `http://example.com${pathname}`; + // Disable Miniflare's pretty error page, so we can parse errors as JSON + return server.fetch(url, { + headers: { "MF-Disable-Pretty-Error": "true" }, + }); + } beforeAll(async () => { // Copy over files to a temporary directory as we'll be modifying them - tmpDir = await getTmpDir(); await fs.cp( path.resolve(__dirname, "..", "src"), path.join(tmpDir, "src"), @@ -36,37 +36,35 @@ describe("find_additional_modules dev", () => { path.join(tmpDir, "wrangler.jsonc") ); - worker = await unstable_startWorker({ - config: path.join(tmpDir, "wrangler.jsonc"), - }); + await server.listen(); }); afterAll(async () => { - await worker.dispose(); + await server.close(); removeDir(tmpDir, { fireAndForget: true }); }); test("supports bundled modules", async ({ expect }) => { - const res = await get(worker, "/dep"); + const res = await get(server, "/dep"); expect(await res.text()).toBe("bundled"); }); test("supports text modules", async ({ expect }) => { - const res = await get(worker, "/text"); + const res = await get(server, "/text"); expect(await res.text()).toBe("test\n"); }); test("supports SQL modules", async ({ expect }) => { - const res = await get(worker, "/sql"); + const res = await get(server, "/sql"); expect(await res.text()).toBe("SELECT * FROM users;\n"); }); test("supports dynamic imports", async ({ expect }) => { - const res = await get(worker, "/dynamic"); + const res = await get(server, "/dynamic"); expect(await res.text()).toBe("dynamic"); }); test("supports commonjs lazy imports", async ({ expect }) => { - const res = await get(worker, "/common"); + const res = await get(server, "/common"); expect(await res.text()).toBe("common"); }); test("supports variable dynamic imports", async ({ expect }) => { - const res = await get(worker, "/lang/en"); + const res = await get(server, "/lang/en"); expect(await res.text()).toBe("hello"); }); @@ -79,7 +77,7 @@ describe("find_additional_modules dev", () => { 'export default "new dynamic";' ); await vi.waitFor(async () => { - const res = await get(worker, "/dynamic"); + const res = await get(server, "/dynamic"); assert.strictEqual(await res.text(), "new dynamic"); }); @@ -87,7 +85,7 @@ describe("find_additional_modules dev", () => { await fs.rm(path.join(srcDir, "lang", "en.js")); await vi.waitFor(async () => { - await expect(get(worker, "/lang/en")).rejects.toThrowError( + await expect(get(server, "/lang/en")).rejects.toThrowError( 'No such module "lang/en.js".' ); }); @@ -99,7 +97,7 @@ describe("find_additional_modules dev", () => { 'export default { hello: "hey" };' ); await vi.waitFor(async () => { - const res = await get(worker, "/lang/en/us"); + const res = await get(server, "/lang/en/us"); assert.strictEqual(await res.text(), "hey"); }); @@ -109,7 +107,7 @@ describe("find_additional_modules dev", () => { 'export default { hello: "bye" };' ); await vi.waitFor(async () => { - const res = await get(worker, "/lang/en/us"); + const res = await get(server, "/lang/en/us"); assert.strictEqual(await res.text(), "bye"); }); }); @@ -126,7 +124,7 @@ function build(cwd: string, outDir: string) { describe("find_additional_modules deploy", () => { let tmpDir: string; beforeAll(async () => { - tmpDir = await getTmpDir(); + tmpDir = mkdtempSync(path.join(os.tmpdir(), "wrangler-modules-")); }); afterAll(async () => await removeDir(tmpDir, { fireAndForget: true })); diff --git a/fixtures/create-server/package.json b/fixtures/create-server/package.json new file mode 100644 index 0000000000..e9a85f2096 --- /dev/null +++ b/fixtures/create-server/package.json @@ -0,0 +1,27 @@ +{ + "name": "@fixture/create-server", + "private": true, + "description": "Integration tests with createServer API", + "type": "module", + "scripts": { + "check:type": "tsc", + "build": "vite build", + "test:ci": "vitest run", + "type:tests": "tsc -p ./tests/tsconfig.json" + }, + "devDependencies": { + "@cloudflare/vite-plugin": "workspace:*", + "@cloudflare/workers-tsconfig": "workspace:*", + "@cloudflare/workers-types": "catalog:default", + "@fixture/shared": "workspace:*", + "@types/node": "catalog:default", + "msw": "catalog:default", + "typescript": "catalog:default", + "vite": "catalog:default", + "vitest": "catalog:default", + "wrangler": "workspace:*" + }, + "volta": { + "extends": "../../package.json" + } +} diff --git a/fixtures/create-server/src/auxiliary.ts b/fixtures/create-server/src/auxiliary.ts new file mode 100644 index 0000000000..a1b07e9cd6 --- /dev/null +++ b/fixtures/create-server/src/auxiliary.ts @@ -0,0 +1,18 @@ +let lastTriggeredCron: string | null = null; + +export default { + async fetch(request, env) { + const url = new URL(request.url); + + if (url.pathname === "/scheduled") { + return new Response(lastTriggeredCron ?? "no cron triggered", { + headers: { "Content-Type": "text/plain" }, + }); + } + + return fetch("http://example.com/auxiliary"); + }, + async scheduled(event) { + lastTriggeredCron = event.cron; + }, +} satisfies ExportedHandler<{ NAME: string }>; diff --git a/fixtures/create-server/src/primary.ts b/fixtures/create-server/src/primary.ts new file mode 100644 index 0000000000..24c6de648c --- /dev/null +++ b/fixtures/create-server/src/primary.ts @@ -0,0 +1,18 @@ +let lastTriggeredCron: string | null = null; + +export default { + async fetch(request, env) { + const url = new URL(request.url); + + if (url.pathname === "/scheduled") { + return new Response(lastTriggeredCron ?? "no cron triggered", { + headers: { "Content-Type": "text/plain" }, + }); + } + + return fetch("http://example.com/primary"); + }, + async scheduled(event) { + lastTriggeredCron = event.cron; + }, +} satisfies ExportedHandler<{ NAME: string }>; diff --git a/fixtures/create-server/tests/tsconfig.json b/fixtures/create-server/tests/tsconfig.json new file mode 100644 index 0000000000..d324c5d73b --- /dev/null +++ b/fixtures/create-server/tests/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "@cloudflare/workers-tsconfig/tsconfig.json", + "compilerOptions": { + "module": "esnext", + "types": ["node"] + }, + "include": ["**/*.ts"], + "exclude": [] +} diff --git a/fixtures/create-server/tests/vite-project.test.ts b/fixtures/create-server/tests/vite-project.test.ts new file mode 100644 index 0000000000..74e1cd277e --- /dev/null +++ b/fixtures/create-server/tests/vite-project.test.ts @@ -0,0 +1,70 @@ +import { http, HttpResponse } from "msw"; +import { setupServer } from "msw/node"; +import { afterAll, beforeAll, describe, it } from "vitest"; +import { createServer } from "wrangler"; + +const mockServer = setupServer( + http.get("http://example.com/:worker", ({ params }) => { + return HttpResponse.text(`mock:${params.worker}`); + }) +); +const workerServer = createServer({ + workers: [ + { configPath: "./dist/primary_worker/wrangler.json" }, + { configPath: "./dist/auxiliary_worker/wrangler.json" }, + ], +}); +const primaryWorker = workerServer.getWorker(); +const auxiliaryWorker = workerServer.getWorker("auxiliary-worker"); + +describe("createServer: vite project setup", () => { + beforeAll(async () => { + mockServer.listen({ onUnhandledRequest: "error" }); + await workerServer.listen(); + }); + + afterAll(async () => { + mockServer.close(); + await workerServer.close(); + }); + + it("could fetch workers with mocking support", async ({ expect }) => { + const primaryResponse = await primaryWorker.fetch("http://example.com"); + await expect(primaryResponse.text()).resolves.toBe("mock:primary"); + const auxiliaryResponse = await auxiliaryWorker.fetch("http://example.com"); + await expect(auxiliaryResponse.text()).resolves.toBe("mock:auxiliary"); + }); + + it("support triggering scheduled events with custom scheduledTime", async ({ + expect, + }) => { + const primaryScheduled = await primaryWorker.fetch( + "http://example.com/scheduled" + ); + await expect(primaryScheduled.text()).resolves.toBe("no cron triggered"); + + await expect( + primaryWorker.scheduled({ + cron: "* * * * *", + scheduledTime: new Date(1_700_000_100_000), + }) + ).resolves.toEqual({ outcome: "ok", noRetry: false }); + + const primaryResponse = await primaryWorker.fetch( + "http://example.com/scheduled" + ); + await expect(primaryResponse.text()).resolves.toBe("* * * * *"); + + await expect( + auxiliaryWorker.scheduled({ + cron: "*/5 * * * *", + scheduledTime: new Date(1_700_000_101_000), + }) + ).resolves.toEqual({ outcome: "ok", noRetry: false }); + + const auxiliaryResponse = await auxiliaryWorker.fetch( + "http://example.com/scheduled" + ); + await expect(auxiliaryResponse.text()).resolves.toBe("*/5 * * * *"); + }); +}); diff --git a/fixtures/create-server/tests/wrangler-project.test.ts b/fixtures/create-server/tests/wrangler-project.test.ts new file mode 100644 index 0000000000..8b4e7b565e --- /dev/null +++ b/fixtures/create-server/tests/wrangler-project.test.ts @@ -0,0 +1,70 @@ +import { http, HttpResponse } from "msw"; +import { setupServer } from "msw/node"; +import { afterAll, beforeAll, describe, it } from "vitest"; +import { createServer } from "wrangler"; + +const mockServer = setupServer( + http.get("http://example.com/:worker", ({ params }) => { + return HttpResponse.text(`mock:${params.worker}`); + }) +); +const workerServer = createServer({ + workers: [ + { configPath: "./wrangler.primary.jsonc" }, + { configPath: "./wrangler.auxiliary.jsonc" }, + ], +}); +const primaryWorker = workerServer.getWorker(); +const auxiliaryWorker = workerServer.getWorker("auxiliary-worker"); + +describe("createServer: wrangler project setup", () => { + beforeAll(async () => { + mockServer.listen({ onUnhandledRequest: "error" }); + await workerServer.listen(); + }); + + afterAll(async () => { + mockServer.close(); + await workerServer.close(); + }); + + it("could fetch workers with mocking support", async ({ expect }) => { + const primaryResponse = await primaryWorker.fetch("http://example.com"); + await expect(primaryResponse.text()).resolves.toBe("mock:primary"); + const auxiliaryResponse = await auxiliaryWorker.fetch("http://example.com"); + await expect(auxiliaryResponse.text()).resolves.toBe("mock:auxiliary"); + }); + + it("support triggering scheduled events with custom scheduledTime", async ({ + expect, + }) => { + const primaryScheduled = await primaryWorker.fetch( + "http://example.com/scheduled" + ); + await expect(primaryScheduled.text()).resolves.toBe("no cron triggered"); + + await expect( + primaryWorker.scheduled({ + cron: "* * * * *", + scheduledTime: new Date(1_700_000_100_000), + }) + ).resolves.toEqual({ outcome: "ok", noRetry: false }); + + const primaryResponse = await primaryWorker.fetch( + "http://example.com/scheduled" + ); + await expect(primaryResponse.text()).resolves.toBe("* * * * *"); + + await expect( + auxiliaryWorker.scheduled({ + cron: "*/5 * * * *", + scheduledTime: new Date(1_700_000_101_000), + }) + ).resolves.toEqual({ outcome: "ok", noRetry: false }); + + const auxiliaryResponse = await auxiliaryWorker.fetch( + "http://example.com/scheduled" + ); + await expect(auxiliaryResponse.text()).resolves.toBe("*/5 * * * *"); + }); +}); diff --git a/fixtures/create-server/tsconfig.json b/fixtures/create-server/tsconfig.json new file mode 100644 index 0000000000..e8e338a5dd --- /dev/null +++ b/fixtures/create-server/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "isolatedModules": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "moduleResolution": "bundler", + "resolveJsonModule": true, + "target": "esnext", + "strict": true, + "noEmit": true, + "types": ["@cloudflare/workers-types", "node"], + "lib": ["esnext"], + "skipLibCheck": true + }, + "include": ["**/*.ts"], + "exclude": ["tests"] +} diff --git a/fixtures/create-server/turbo.json b/fixtures/create-server/turbo.json new file mode 100644 index 0000000000..6556dcf3e5 --- /dev/null +++ b/fixtures/create-server/turbo.json @@ -0,0 +1,9 @@ +{ + "$schema": "http://turbo.build/schema.json", + "extends": ["//"], + "tasks": { + "build": { + "outputs": ["dist/**"] + } + } +} diff --git a/fixtures/create-server/vite.config.ts b/fixtures/create-server/vite.config.ts new file mode 100644 index 0000000000..4f1ebe2302 --- /dev/null +++ b/fixtures/create-server/vite.config.ts @@ -0,0 +1,13 @@ +import { cloudflare } from "@cloudflare/vite-plugin"; +import { defineConfig } from "vite"; + +export default defineConfig({ + plugins: [ + cloudflare({ + configPath: "./wrangler.primary.jsonc", + auxiliaryWorkers: [{ configPath: "./wrangler.auxiliary.jsonc" }], + inspectorPort: false, + persistState: false, + }), + ], +}); diff --git a/fixtures/create-server/vitest.config.ts b/fixtures/create-server/vitest.config.ts new file mode 100644 index 0000000000..846cddc419 --- /dev/null +++ b/fixtures/create-server/vitest.config.ts @@ -0,0 +1,9 @@ +import { defineProject, mergeConfig } from "vitest/config"; +import configShared from "../../vitest.shared"; + +export default mergeConfig( + configShared, + defineProject({ + test: {}, + }) +); diff --git a/fixtures/create-server/wrangler.auxiliary.jsonc b/fixtures/create-server/wrangler.auxiliary.jsonc new file mode 100644 index 0000000000..c02aea80c7 --- /dev/null +++ b/fixtures/create-server/wrangler.auxiliary.jsonc @@ -0,0 +1,5 @@ +{ + "name": "auxiliary-worker", + "main": "src/auxiliary.ts", + "compatibility_date": "2024-09-23", +} diff --git a/fixtures/create-server/wrangler.primary.jsonc b/fixtures/create-server/wrangler.primary.jsonc new file mode 100644 index 0000000000..c5aac72cae --- /dev/null +++ b/fixtures/create-server/wrangler.primary.jsonc @@ -0,0 +1,5 @@ +{ + "name": "primary-worker", + "main": "src/primary.ts", + "compatibility_date": "2024-09-23", +} diff --git a/fixtures/d1-read-replication-app/tests/index.test.ts b/fixtures/d1-read-replication-app/tests/index.test.ts index 5c40400403..f888cfb1f6 100644 --- a/fixtures/d1-read-replication-app/tests/index.test.ts +++ b/fixtures/d1-read-replication-app/tests/index.test.ts @@ -1,26 +1,26 @@ import { resolve } from "node:path"; import { afterAll, beforeAll, describe, it } from "vitest"; -import { runWranglerDev } from "../../shared/src/run-wrangler-long-lived"; +import { createServer } from "wrangler"; -describe("d1-sessions-api - getBookmark", () => { - describe("with wrangler dev", () => { - let ip: string, port: number, stop: (() => Promise) | undefined; +const server = createServer({ + root: resolve(__dirname, ".."), + workers: [{ configPath: "wrangler.jsonc" }], +}); +describe("d1-sessions-api - getBookmark", () => { + describe("with createServer", () => { beforeAll(async () => { - ({ ip, port, stop } = await runWranglerDev(resolve(__dirname, ".."), [ - "--port=0", - "--inspector-port=0", - ])); + await server.listen(); }); afterAll(async () => { - await stop?.(); + await server.close(); }); it("should respond with bookmarks before and after a session query", async ({ expect, }) => { - let response = await fetch(`http://${ip}:${port}`); + let response = await server.fetch("/"); let parsed = await response.json(); expect(response.status).toBe(200); expect(parsed).toMatchObject({ @@ -30,8 +30,8 @@ describe("d1-sessions-api - getBookmark", () => { }); it("should progress the bookmark after a write", async ({ expect }) => { - let response = await fetch( - `http://${ip}:${port}?q=${encodeURIComponent("create table if not exists users1(id text);")}` + let response = await server.fetch( + `/?q=${encodeURIComponent("create table if not exists users1(id text);")}` ); let parsed = (await response.json()) as { bookmarkAfter: string; @@ -54,8 +54,8 @@ describe("d1-sessions-api - getBookmark", () => { let responses = []; for (let i = 0; i < 10; i++) { - const resp = await fetch( - `http://${ip}:${port}?q=${encodeURIComponent(`create table if not exists users${i}(id text);`)}` + const resp = await server.fetch( + `/?q=${encodeURIComponent(`create table if not exists users${i}(id text);`)}` ); let parsed = (await resp.json()) as { bookmarkAfter: string; diff --git a/fixtures/dynamic-worker-loading/tests/index.test.ts b/fixtures/dynamic-worker-loading/tests/index.test.ts index 2266754bee..4412d98889 100644 --- a/fixtures/dynamic-worker-loading/tests/index.test.ts +++ b/fixtures/dynamic-worker-loading/tests/index.test.ts @@ -1,25 +1,24 @@ import { readFileSync } from "node:fs"; import { join, resolve } from "node:path"; -import { fetch } from "undici"; import { afterAll, beforeAll, describe, it } from "vitest"; -import { runWranglerDev } from "../../shared/src/run-wrangler-long-lived"; +import { createServer } from "wrangler"; -describe("dynamic worker loading", () => { - let ip: string, port: number, stop: (() => Promise) | undefined; +const server = createServer({ + root: resolve(__dirname, ".."), + workers: [{ configPath: "wrangler.jsonc" }], +}); +describe("dynamic worker loading", () => { beforeAll(async () => { - ({ ip, port, stop } = await runWranglerDev(resolve(__dirname, ".."), [ - "--port=0", - "--inspector-port=0", - ])); + await server.listen(); }); afterAll(async () => { - await stop?.(); + await server.close(); }); it("should respond with response from dynamic worker", async ({ expect }) => { - let response = await fetch(`http://${ip}:${port}/my-worker`); + let response = await server.fetch("/my-worker"); let text = await response.text(); expect(response.status).toBe(200); expect(text).toMatchInlineSnapshot( @@ -28,7 +27,7 @@ describe("dynamic worker loading", () => { }); it("should load different worker if ID changes", async ({ expect }) => { - let response = await fetch(`http://${ip}:${port}/my-other-worker`); + let response = await server.fetch("/my-other-worker"); let text = await response.text(); expect(response.status).toBe(200); expect(text).toMatchInlineSnapshot( diff --git a/fixtures/import-npm/packages/import-example/tests/index.test.ts b/fixtures/import-npm/packages/import-example/tests/index.test.ts index d95e5578fc..cac2d797a5 100644 --- a/fixtures/import-npm/packages/import-example/tests/index.test.ts +++ b/fixtures/import-npm/packages/import-example/tests/index.test.ts @@ -1,25 +1,24 @@ import { resolve } from "path"; -import { fetch } from "undici"; import { afterAll, beforeAll, describe, it } from "vitest"; -import { runWranglerDev } from "../../../../shared/src/run-wrangler-long-lived"; +import { createServer } from "wrangler"; -describe("wrangler correctly imports wasm files with npm resolution", () => { - let ip: string, port: number, stop: (() => Promise) | undefined; +const server = createServer({ + root: resolve(__dirname, ".."), + workers: [{ configPath: "wrangler.jsonc" }], +}); +describe("wrangler correctly imports wasm files with npm resolution", () => { beforeAll(async () => { - ({ ip, port, stop } = await runWranglerDev(resolve(__dirname, ".."), [ - "--port=0", - "--inspector-port=0", - ])); + await server.listen(); }); afterAll(async () => { - await stop?.(); + await server.close(); }); // if the worker compiles, is running, and returns 21 (7 * 3) we can assume that the wasm module was imported correctly it("responds", async ({ expect }) => { - const response = await fetch(`http://${ip}:${port}/`); + const response = await server.fetch("/"); const text = await response.text(); expect(text).toBe("21, 21"); }); diff --git a/fixtures/import-npm/turbo.json b/fixtures/import-npm/turbo.json index 8b7b7af1c1..f1c0109055 100644 --- a/fixtures/import-npm/turbo.json +++ b/fixtures/import-npm/turbo.json @@ -11,7 +11,7 @@ "dependsOn": ["_clean_install"] }, "type:tests": { - "dependsOn": ["_clean_install"] + "dependsOn": ["_clean_install", "wrangler#build"] }, "test:ci": { "dependsOn": ["_clean_install", "wrangler#build"] diff --git a/fixtures/import-wasm-example/tests/index.test.ts b/fixtures/import-wasm-example/tests/index.test.ts index f6d840e869..cac2d797a5 100644 --- a/fixtures/import-wasm-example/tests/index.test.ts +++ b/fixtures/import-wasm-example/tests/index.test.ts @@ -1,25 +1,24 @@ import { resolve } from "path"; -import { fetch } from "undici"; import { afterAll, beforeAll, describe, it } from "vitest"; -import { runWranglerDev } from "../../shared/src/run-wrangler-long-lived"; +import { createServer } from "wrangler"; -describe("wrangler correctly imports wasm files with npm resolution", () => { - let ip: string, port: number, stop: (() => Promise) | undefined; +const server = createServer({ + root: resolve(__dirname, ".."), + workers: [{ configPath: "wrangler.jsonc" }], +}); +describe("wrangler correctly imports wasm files with npm resolution", () => { beforeAll(async () => { - ({ ip, port, stop } = await runWranglerDev(resolve(__dirname, ".."), [ - "--port=0", - "--inspector-port=0", - ])); + await server.listen(); }); afterAll(async () => { - await stop?.(); + await server.close(); }); // if the worker compiles, is running, and returns 21 (7 * 3) we can assume that the wasm module was imported correctly it("responds", async ({ expect }) => { - const response = await fetch(`http://${ip}:${port}/`); + const response = await server.fetch("/"); const text = await response.text(); expect(text).toBe("21, 21"); }); diff --git a/fixtures/no-bundle-import/src/index.test.ts b/fixtures/no-bundle-import/src/index.test.ts index 17a640f5ff..5aee7386f0 100644 --- a/fixtures/no-bundle-import/src/index.test.ts +++ b/fixtures/no-bundle-import/src/index.test.ts @@ -1,25 +1,21 @@ import path from "path"; import { afterAll, beforeAll, describe, test } from "vitest"; -import { unstable_startWorker } from "wrangler"; +import { createServer } from "wrangler"; -describe("Worker", () => { - let worker: Awaited>; +const server = createServer({ + root: path.resolve(__dirname, ".."), + workers: [{ configPath: "wrangler.jsonc" }], +}); +describe("Worker", () => { beforeAll(async () => { - worker = await unstable_startWorker({ - entrypoint: path.resolve(__dirname, "index.js"), - dev: { - logLevel: "none", - server: { hostname: "127.0.0.1", port: 0 }, - inspector: false, - }, - }); + await server.listen(); }, 30_000); - afterAll(() => worker.dispose()); + afterAll(() => server.close()); test("module traversal results in correct response", async ({ expect }) => { - const resp = await worker.fetch("http://example.com/"); + const resp = await server.fetch("http://example.com/"); const text = await resp.text(); expect(text).toMatchInlineSnapshot( `"Hello Jane Smith and Hello John Smith"` @@ -29,7 +25,7 @@ describe("Worker", () => { test("module traversal results in correct response for CommonJS", async ({ expect, }) => { - const resp = await worker.fetch("http://example.com/cjs"); + const resp = await server.fetch("http://example.com/cjs"); const text = await resp.text(); expect(text).toMatchInlineSnapshot( `"CJS: Hello Jane Smith and Hello John Smith"` @@ -39,43 +35,43 @@ describe("Worker", () => { test("correct response for CommonJS which imports ESM", async ({ expect, }) => { - const resp = await worker.fetch("http://example.com/cjs-loop"); + const resp = await server.fetch("http://example.com/cjs-loop"); const text = await resp.text(); expect(text).toMatchInlineSnapshot('"CJS: cjs-string"'); }); test("support for dynamic imports", async ({ expect }) => { - const resp = await worker.fetch("http://example.com/dynamic"); + const resp = await server.fetch("http://example.com/dynamic"); const text = await resp.text(); expect(text).toMatchInlineSnapshot(`"dynamic"`); }); test("basic wasm support", async ({ expect }) => { - const resp = await worker.fetch("http://example.com/wasm"); + const resp = await server.fetch("http://example.com/wasm"); const text = await resp.text(); expect(text).toMatchInlineSnapshot('"42"'); }); test("resolves wasm import paths relative to root", async ({ expect }) => { - const resp = await worker.fetch("http://example.com/wasm-nested"); + const resp = await server.fetch("http://example.com/wasm-nested"); const text = await resp.text(); expect(text).toMatchInlineSnapshot('"nested42"'); }); test("wasm can be imported from a dynamic import", async ({ expect }) => { - const resp = await worker.fetch("http://example.com/wasm-dynamic"); + const resp = await server.fetch("http://example.com/wasm-dynamic"); const text = await resp.text(); expect(text).toMatchInlineSnapshot('"sibling42subdirectory42"'); }); test("text data can be imported", async ({ expect }) => { - const resp = await worker.fetch("http://example.com/txt"); + const resp = await server.fetch("http://example.com/txt"); const text = await resp.text(); expect(text).toMatchInlineSnapshot('"TEST DATA"'); }); test("binary data can be imported", async ({ expect }) => { - const resp = await worker.fetch("http://example.com/bin"); + const resp = await server.fetch("http://example.com/bin"); const bin = await resp.arrayBuffer(); const expected = new Uint8Array(new ArrayBuffer(4)); expected.set([0, 1, 2, 10]); @@ -85,7 +81,7 @@ describe("Worker", () => { test("actual dynamic import (that cannot be inlined by an esbuild run)", async ({ expect, }) => { - const resp = await worker.fetch("http://example.com/lang/fr.json"); + const resp = await server.fetch("http://example.com/lang/fr.json"); const text = await resp.text(); expect(text).toMatchInlineSnapshot('"Bonjour"'); }); diff --git a/fixtures/node-env/tests/node-env.test.ts b/fixtures/node-env/tests/node-env.test.ts index c7f53dccd1..30ea89d4b5 100644 --- a/fixtures/node-env/tests/node-env.test.ts +++ b/fixtures/node-env/tests/node-env.test.ts @@ -3,26 +3,27 @@ import * as fs from "node:fs"; import * as path from "node:path"; import { Miniflare } from "miniflare"; import { describe, it, vi } from "vitest"; -import { runWranglerDev } from "../../shared/src/run-wrangler-long-lived"; +import { createServer } from "wrangler"; describe("`process.env.NODE_ENV` replacement in development", () => { it("replaces `process.env.NODE_ENV` with `development` if it is `undefined`", async ({ expect, }) => { vi.stubEnv("NODE_ENV", undefined); + const server = createServer({ + root: path.resolve(__dirname, ".."), + workers: [{ configPath: "wrangler.jsonc" }], + }); - const { ip, port, stop } = await runWranglerDev( - path.resolve(__dirname, ".."), - ["--port=0", "--inspector-port=0"] - ); + await server.listen(); await vi.waitFor(async () => { - const response = await fetch(`http://${ip}:${port}/`); + const response = await server.fetch("/"); const text = await response.text(); expect(text).toBe(`The value of process.env.NODE_ENV is "development"`); }); - await stop(); + await server.close(); vi.unstubAllEnvs(); }); @@ -31,19 +32,20 @@ describe("`process.env.NODE_ENV` replacement in development", () => { expect, }) => { vi.stubEnv("NODE_ENV", "some-value"); + const server = createServer({ + root: path.resolve(__dirname, ".."), + workers: [{ configPath: "wrangler.jsonc" }], + }); - const { ip, port, stop } = await runWranglerDev( - path.resolve(__dirname, ".."), - ["--port=0", "--inspector-port=0"] - ); + await server.listen(); await vi.waitFor(async () => { - const response = await fetch(`http://${ip}:${port}/`); + const response = await server.fetch("/"); const text = await response.text(); expect(text).toBe(`The value of process.env.NODE_ENV is "some-value"`); }); - await stop(); + await server.close(); vi.unstubAllEnvs(); }); diff --git a/fixtures/ratelimit-app/tests/index.test.ts b/fixtures/ratelimit-app/tests/index.test.ts index 61e6e58ef9..0e6ad89dfa 100644 --- a/fixtures/ratelimit-app/tests/index.test.ts +++ b/fixtures/ratelimit-app/tests/index.test.ts @@ -1,50 +1,49 @@ import path, { resolve } from "path"; import { afterAll, beforeAll, describe, it } from "vitest"; -import { unstable_startWorker } from "wrangler"; +import { createServer } from "wrangler"; const basePath = resolve(__dirname, ".."); +const server = createServer({ + workers: [{ configPath: path.join(basePath, "wrangler.jsonc") }], +}); describe("Rate limiting bindings", () => { - let worker: Awaited>; - beforeAll(async () => { - worker = await unstable_startWorker({ - config: path.join(basePath, "wrangler.jsonc"), - }); + await server.listen(); }); afterAll(async () => { - await worker.dispose(); + await server.close(); }); it("ratelimit binding is defined ", async ({ expect }) => { - let response = await worker.fetch(`http://example.com`); + let response = await server.fetch(`http://example.com`); let content = await response.text(); expect(content).toEqual("Success"); - response = await worker.fetch(`http://example.com`); + response = await server.fetch(`http://example.com`); content = await response.text(); expect(content).toEqual("Success"); - response = await worker.fetch(`http://example.com`); + response = await server.fetch(`http://example.com`); content = await response.text(); expect(content).toEqual("Success"); - response = await worker.fetch(`http://example.com`); + response = await server.fetch(`http://example.com`); content = await response.text(); expect(content).toEqual("Slow down"); }); it("ratelimit unsafe binding is defined ", async ({ expect }) => { - let response = await worker.fetch(`http://example.com/unsafe`); + let response = await server.fetch(`http://example.com/unsafe`); let content = await response.text(); expect(content).toEqual("unsafe: Success"); - response = await worker.fetch(`http://example.com/unsafe`); + response = await server.fetch(`http://example.com/unsafe`); content = await response.text(); expect(content).toEqual("unsafe: Success"); - response = await worker.fetch(`http://example.com/unsafe`); + response = await server.fetch(`http://example.com/unsafe`); content = await response.text(); expect(content).toEqual("unsafe: Slow down"); }); diff --git a/fixtures/start-worker-node-test/src/index.test.js b/fixtures/start-worker-node-test/src/index.test.js index 451c705331..92546247bc 100644 --- a/fixtures/start-worker-node-test/src/index.test.js +++ b/fixtures/start-worker-node-test/src/index.test.js @@ -1,28 +1,24 @@ import assert from "node:assert"; import test, { after, before, describe } from "node:test"; -import { unstable_startWorker } from "wrangler"; +import { createServer } from "wrangler"; -describe("worker", () => { - /** - * @type {Awaited>} - */ - let worker; +const server = createServer({ + workers: [{ configPath: "wrangler.json" }], +}); +describe("worker", () => { before(async () => { - worker = await unstable_startWorker({ - config: "wrangler.json", - dev: { persist: false }, - }); + await server.listen(); }); test("hello world", async () => { assert.strictEqual( - await (await worker.fetch("http://example.com")).text(), + await (await server.fetch("http://example.com")).text(), "Hello from even" ); }); after(async () => { - await worker.dispose(); + await server.close(); }); }); diff --git a/fixtures/unbound-durable-object/tests/index.test.ts b/fixtures/unbound-durable-object/tests/index.test.ts index 9ee1910307..0b62fddd2b 100644 --- a/fixtures/unbound-durable-object/tests/index.test.ts +++ b/fixtures/unbound-durable-object/tests/index.test.ts @@ -1,29 +1,28 @@ import { join, resolve } from "path"; import { afterAll, beforeAll, describe, it } from "vitest"; -import { unstable_startWorker } from "wrangler"; +import { createServer } from "wrangler"; const basePath = resolve(__dirname, ".."); +const server = createServer({ + workers: [{ configPath: join(basePath, "wrangler.jsonc") }], +}); describe("Unbound DO is available through `ctx.exports`", () => { - let worker: Awaited>; - beforeAll(async () => { - worker = await unstable_startWorker({ - config: join(basePath, "wrangler.jsonc"), - }); + await server.listen(); }); afterAll(async () => { - await worker.dispose(); + await server.close(); }); it("can execute storage operations", async ({ expect }) => { const doName = crypto.randomUUID(); - let response = await worker.fetch(`http://example.com?name=${doName}`); + let response = await server.fetch(`http://example.com?name=${doName}`); let content = await response.text(); expect(content).toMatchInlineSnapshot(`"count: 0"`); - response = await worker.fetch( + response = await server.fetch( `http://example.com/increment?name=${doName}` ); content = await response.text(); diff --git a/fixtures/wildcard-modules/test/index.test.ts b/fixtures/wildcard-modules/test/index.test.ts index a5e2d12f2b..df99c93a1e 100644 --- a/fixtures/wildcard-modules/test/index.test.ts +++ b/fixtures/wildcard-modules/test/index.test.ts @@ -1,26 +1,24 @@ import childProcess from "node:child_process"; import { existsSync } from "node:fs"; import fs from "node:fs/promises"; -import os from "node:os"; +import os, { tmpdir } from "node:os"; import path from "node:path"; import { setTimeout } from "node:timers/promises"; import { removeDir } from "@fixture/shared/src/fs-helpers"; import { fetch } from "undici"; import { afterAll, assert, beforeAll, describe, test } from "vitest"; -import { - runWranglerDev, - wranglerEntryPath, -} from "../../shared/src/run-wrangler-long-lived"; +import { createServer, type WorkerServer } from "wrangler"; +import { wranglerEntryPath } from "../../shared/src/run-wrangler-long-lived"; async function getTmpDir() { return fs.mkdtemp(path.join(os.tmpdir(), "wrangler-modules-")); } -type WranglerDev = Awaited>; -function get(worker: WranglerDev, pathname: string) { - const url = `http://${worker.ip}:${worker.port}${pathname}`; +function get(server: WorkerServer, pathname: string) { // Disable Miniflare's pretty error page, so we can parse errors as JSON - return fetch(url, { headers: { "MF-Disable-Pretty-Error": "true" } }); + return server.fetch(pathname, { + headers: { "MF-Disable-Pretty-Error": "true" }, + }); } async function retry(closure: () => Promise, max = 30): Promise { @@ -37,7 +35,8 @@ async function retry(closure: () => Promise, max = 30): Promise { describe("wildcard imports: dev", () => { let tmpDir: string; - let worker: WranglerDev; + let server: WorkerServer; + let url: URL; beforeAll(async () => { // Copy over files to a temporary directory as we'll be modifying them @@ -52,31 +51,36 @@ describe("wildcard imports: dev", () => { path.join(tmpDir, "wrangler.jsonc") ); - worker = await runWranglerDev(tmpDir, ["--port=0", "--inspector-port=0"]); + server = createServer({ + root: tmpDir, + workers: [{ configPath: "wrangler.jsonc" }], + watch: true, + }); + ({ url } = await server.listen()); }); afterAll(async () => { - await worker.stop(); + await server.close(); removeDir(tmpDir, { fireAndForget: true }); }); test("supports bundled modules", async ({ expect }) => { - const res = await get(worker, "/dep"); + const res = await get(server, "/dep"); expect(await res.text()).toBe("bundled"); }); test("supports text modules", async ({ expect }) => { - const res = await get(worker, "/text"); + const res = await get(server, "/text"); expect(await res.text()).toBe("test\n"); }); test("supports dynamic imports", async ({ expect }) => { - const res = await get(worker, "/dynamic"); + const res = await get(server, "/dynamic"); expect(await res.text()).toBe("dynamic"); }); test("supports commonjs lazy imports", async ({ expect }) => { - const res = await get(worker, "/common"); + const res = await get(server, "/common"); expect(await res.text()).toBe("common"); }); test("supports variable dynamic imports", async ({ expect }) => { - const res = await get(worker, "/lang/en"); + const res = await get(server, "/lang/en"); expect(await res.text()).toBe("hello"); }); @@ -89,14 +93,16 @@ describe("wildcard imports: dev", () => { 'export default "new dynamic";' ); await retry(async () => { - const res = await get(worker, "/dynamic"); + const res = await get(server, "/dynamic"); assert.strictEqual(await res.text(), "new dynamic"); }); // Delete dynamically imported file await fs.rm(path.join(srcDir, "lang", "en.js")); const res = await retry(async () => { - const res = await get(worker, "/lang/en"); + const res = await fetch(new URL("/lang/en", url), { + headers: { "MF-Disable-Pretty-Error": "true" }, + }); assert.strictEqual(res.status, 500); return res; }); @@ -110,7 +116,7 @@ describe("wildcard imports: dev", () => { 'export default { hello: "hey" };' ); await retry(async () => { - const res = await get(worker, "/lang/en/us"); + const res = await get(server, "/lang/en/us"); assert.strictEqual(await res.text(), "hey"); }); @@ -120,7 +126,7 @@ describe("wildcard imports: dev", () => { 'export default { hello: "bye" };' ); await retry(async () => { - const res = await get(worker, "/lang/en/us"); + const res = await get(server, "/lang/en/us"); assert.strictEqual(await res.text(), "bye"); }); }); diff --git a/fixtures/worker-app/tests/undrained-body.test.ts b/fixtures/worker-app/tests/undrained-body.test.ts index 18da5d66a5..4017e440d0 100644 --- a/fixtures/worker-app/tests/undrained-body.test.ts +++ b/fixtures/worker-app/tests/undrained-body.test.ts @@ -1,20 +1,19 @@ import { resolve } from "path"; -import { fetch } from "undici"; import { afterAll, beforeAll, describe, it } from "vitest"; -import { runWranglerDev } from "../../shared/src/run-wrangler-long-lived"; +import { createServer } from "wrangler"; -describe("wrangler dev", () => { - let ip: string, port: number, stop: (() => Promise) | undefined; +const server = createServer({ + root: resolve(__dirname, ".."), + workers: [{ configPath: "wrangler.jsonc" }], +}); +describe("wrangler dev", () => { beforeAll(async () => { - ({ ip, port, stop } = await runWranglerDev(resolve(__dirname, ".."), [ - "--port=0", - "--inspector-port=0", - ])); + await server.listen(); }); afterAll(async () => { - await stop?.(); + await server.close(); }); // https://github.com/cloudflare/workers-sdk/issues/5095 @@ -27,7 +26,7 @@ describe("wrangler dev", () => { const body = new Uint8Array(2_000); for (let i = 0; i < COUNT; i++) { - const response = await fetch(`http://${ip}:${port}/random`, { + const response = await server.fetch("/random", { method: "POST", body, }); diff --git a/packages/wrangler/e2e/create-server.test.ts b/packages/wrangler/e2e/create-server.test.ts new file mode 100644 index 0000000000..8a691bc06a --- /dev/null +++ b/packages/wrangler/e2e/create-server.test.ts @@ -0,0 +1,663 @@ +import path from "node:path"; +import { setTimeout } from "node:timers/promises"; +import { http, HttpResponse } from "msw"; +import { setupServer } from "msw/node"; +import dedent from "ts-dedent"; +import { beforeEach, describe, it, onTestFinished, vi } from "vitest"; +import { + importWrangler, + WranglerE2ETestHelper, +} from "./helpers/e2e-wrangler-test"; + +const { createServer } = await importWrangler(); + +describe("createServer", { sequential: true }, () => { + let helper: WranglerE2ETestHelper; + + beforeEach(() => { + helper = new WranglerE2ETestHelper(); + }); + + it("starts with default server options", async ({ expect }) => { + await helper.seed({ + "wrangler.jsonc": dedent` + { + "name": "hello-worker", + "main": "src/index.ts", + "compatibility_date": "2026-05-20" + } + `, + "src/index.ts": dedent` + export default { + fetch(request) { + if (new URL(request.url).pathname === "/url") { + return new Response(request.url); + } + return new Response("Hello World"); + } + }; + `, + }); + + const server = createServer({ + workers: [ + { configPath: path.resolve(helper.tmpPath, "./wrangler.jsonc") }, + ], + }); + onTestFinished(server.close); + + const { url, inspectorUrl } = await server.listen(); + + expect(url.protocol).toBe("http:"); + expect(url.hostname).toBe("127.0.0.1"); + expect(Number(url.port)).toBeGreaterThan(0); + expect(inspectorUrl).toBeUndefined(); + + const response1 = await fetch(url); + await expect(response1.text()).resolves.toBe("Hello World"); + + const relativeServerResponse = await server.fetch("/url"); + await expect(relativeServerResponse.text()).resolves.toBe( + new URL("/url", url).href + ); + + const relativeWorkerResponse = await server.getWorker().fetch("/url"); + await expect(relativeWorkerResponse.text()).resolves.toBe( + new URL("/url", url).href + ); + }); + + it("support fetching different workers from the same session", async ({ + expect, + }) => { + await helper.seed({ + "wrangler.primary.jsonc": dedent` + { + "name": "primary-worker", + "main": "src/primary.ts", + "compatibility_date": "2026-05-20" + } + `, + "wrangler.auxiliary.jsonc": dedent` + { + "name": "auxiliary-worker", + "main": "src/auxiliary.ts", + "compatibility_date": "2026-05-20" + } + `, + "src/primary.ts": dedent` + export default { + fetch() { + return new Response("Hello from Primary Worker"); + } + }; + `, + "src/auxiliary.ts": dedent` + export default { + fetch() { + return new Response("Hello from Auxiliary Worker"); + } + }; + `, + }); + + const server = createServer({ + root: helper.tmpPath, + workers: [ + { configPath: "./wrangler.primary.jsonc" }, + { configPath: "./wrangler.auxiliary.jsonc" }, + ], + }); + onTestFinished(server.close); + + await server.listen(); + + const defaultServerResponse = await server.fetch("/"); + await expect(defaultServerResponse.text()).resolves.toBe( + "Hello from Primary Worker" + ); + + const defaultWorkerResponse = await server.getWorker().fetch("/"); + await expect(defaultWorkerResponse.text()).resolves.toBe( + "Hello from Primary Worker" + ); + + const primaryResponse = await server.getWorker("primary-worker").fetch("/"); + await expect(primaryResponse.text()).resolves.toBe( + "Hello from Primary Worker" + ); + + const auxiliaryResponse = await server + .getWorker("auxiliary-worker") + .fetch("/"); + await expect(auxiliaryResponse.text()).resolves.toBe( + "Hello from Auxiliary Worker" + ); + }); + + it("supports overriding fetch for outbound requests", async ({ expect }) => { + await helper.seed({ + "wrangler.jsonc": dedent` + { + "name": "hello-example", + "main": "src/index.ts", + "compatibility_date": "2026-05-20" + } + `, + "src/index.ts": dedent` + export default { + fetch() { + return fetch("http://example.com"); + } + }; + `, + }); + + const server = createServer({ + root: helper.tmpPath, + workers: [{ configPath: "./wrangler.jsonc" }], + outboundService(request) { + if (request.url === "http://example.com/") { + return new Response("Mocked response from example.com"); + } + + throw new Error(`Unexpected outbound request to ${request.url}`); + }, + }); + onTestFinished(server.close); + + await server.listen(); + + const response = await server.fetch("/"); + await expect(response.text()).resolves.toBe( + "Mocked response from example.com" + ); + }); + + it("uses the current Node process fetch for outbound requests by default", async ({ + expect, + }) => { + const mockServer = setupServer( + http.get("http://example.com/", () => { + return HttpResponse.text("Mocked by MSW"); + }) + ); + mockServer.listen({ onUnhandledRequest: "error" }); + onTestFinished(() => mockServer.close()); + + await helper.seed({ + "wrangler.jsonc": dedent` + { + "name": "default-outbound-worker", + "main": "src/index.ts", + "compatibility_date": "2026-05-20" + } + `, + "src/index.ts": dedent` + export default { + fetch() { + return fetch("http://example.com"); + } + }; + `, + }); + + const server = createServer({ + root: helper.tmpPath, + workers: [{ configPath: "./wrangler.jsonc" }], + }); + onTestFinished(server.close); + + await server.listen(); + + const response = await server.fetch("/"); + await expect(response.text()).resolves.toBe("Mocked by MSW"); + }); + + it("starts workers from inline config", async ({ expect }) => { + await helper.seed({ + "src/index.ts": dedent` + export default { + fetch() { + return new Response("Hello from inline config"); + } + }; + `, + }); + + const server = createServer({ + workers: [ + { + root: helper.tmpPath, + config: { + main: "src/index.ts", + compatibility_date: "2026-05-20", + }, + }, + ], + }); + onTestFinished(server.close); + + await server.listen(); + + const response = await server.fetch("/"); + await expect(response.text()).resolves.toBe("Hello from inline config"); + }); + + it("loads default .env files for config path workers", async ({ expect }) => { + await helper.seed({ + "wrangler.jsonc": dedent` + { + "name": "env-worker", + "main": "src/index.ts", + "compatibility_date": "2026-05-20", + "vars": { "CONFIG_VAR": "from-config" } + } + `, + ".env": dedent` + ENV_SECRET=from-env + `, + "src/index.ts": dedent` + export default { + fetch(request, env) { + return Response.json({ + CONFIG_VAR: env.CONFIG_VAR, + ENV_SECRET: env.ENV_SECRET, + }); + } + }; + `, + }); + + const server = createServer({ + root: helper.tmpPath, + workers: [{ configPath: "./wrangler.jsonc" }], + }); + onTestFinished(server.close); + + await server.listen(); + + const response = await server.fetch("/"); + await expect(response.json()).resolves.toEqual({ + CONFIG_VAR: "from-config", + ENV_SECRET: "from-env", + }); + }); + + it("loads default .dev.vars files for config path workers", async ({ + expect, + }) => { + await helper.seed({ + "wrangler.jsonc": dedent` + { + "name": "dev-vars-worker", + "main": "src/index.ts", + "compatibility_date": "2026-05-20", + "vars": { "CONFIG_VAR": "from-config" } + } + `, + ".env": dedent` + SECRET=from-env + `, + ".dev.vars": dedent` + SECRET=from-dev-vars + `, + "src/index.ts": dedent` + export default { + fetch(request, env) { + return Response.json({ + CONFIG_VAR: env.CONFIG_VAR, + SECRET: env.SECRET, + }); + } + }; + `, + }); + + const server = createServer({ + root: helper.tmpPath, + workers: [{ configPath: "./wrangler.jsonc" }], + }); + onTestFinished(server.close); + + await server.listen(); + + const response = await server.fetch("/"); + await expect(response.json()).resolves.toEqual({ + CONFIG_VAR: "from-config", + SECRET: "from-dev-vars", + }); + }); + + it("overrides vars and secrets for config path workers", async ({ + expect, + }) => { + await helper.seed({ + "wrangler.jsonc": dedent` + { + "name": "var-overrides-worker", + "main": "src/index.ts", + "compatibility_date": "2026-05-20", + "vars": { "CONFIG_VAR": "from-config" }, + "secrets": { "required": ["API_TOKEN", "SECRET_FROM_FILE"] } + } + `, + ".dev.vars": dedent` + API_TOKEN=from-dev-vars + SECRET_FROM_FILE=from-dev-vars + `, + "src/index.ts": dedent` + export default { + fetch(request, env) { + return Response.json({ + CONFIG_VAR: env.CONFIG_VAR, + API_TOKEN: env.API_TOKEN, + SECRET_FROM_FILE: env.SECRET_FROM_FILE, + ADDED_VAR: env.ADDED_VAR, + NULL_VAR: env.NULL_VAR, + }); + } + }; + `, + }); + + const server = createServer({ + root: helper.tmpPath, + workers: [ + { + configPath: "./wrangler.jsonc", + overrides: { + vars: { + CONFIG_VAR: "from-override", + ADDED_VAR: "from-override", + NULL_VAR: null, + }, + secrets: { + API_TOKEN: "from-override", + }, + }, + }, + ], + }); + onTestFinished(server.close); + + await server.listen(); + + const response = await server.fetch("/"); + await expect(response.json()).resolves.toEqual({ + CONFIG_VAR: "from-override", + API_TOKEN: "from-override", + SECRET_FROM_FILE: "from-dev-vars", + ADDED_VAR: "from-override", + NULL_VAR: null, + }); + }); + + it(`supports "nodejs_compat" flag`, async ({ expect }) => { + await helper.seed({ + "wrangler.jsonc": dedent` + { + "name": "nodejs-compat-worker", + "main": "src/index.ts", + "compatibility_date": "2026-05-20", + "compatibility_flags": ["nodejs_compat"] + } + `, + "src/index.ts": dedent` + import { Stream } from "node:stream"; + + export default { + fetch() { + return new Response(String(typeof Stream)); + } + }; + `, + }); + + const server = createServer({ + root: helper.tmpPath, + workers: [{ configPath: "./wrangler.jsonc" }], + }); + onTestFinished(server.close); + + await server.listen(); + + const response = await server.fetch("/"); + await expect(response.text()).resolves.toBe("function"); + }); + + it("supports Workers Sites", async ({ expect }) => { + await helper.seed({ + "public/hello.txt": "Hello from Workers Sites", + "src/index.ts": dedent` + import manifestJSON from "__STATIC_CONTENT_MANIFEST"; + + const manifest = JSON.parse(manifestJSON); + + export default { + async fetch(request, env) { + const key = manifest[new URL(request.url).pathname.slice(1)]; + const value = key ? await env.__STATIC_CONTENT.get(key) : null; + return new Response(value ?? "missing"); + } + }; + `, + }); + + const server = createServer({ + root: helper.tmpPath, + workers: [ + { + config: { + main: "src/index.ts", + compatibility_date: "2026-05-20", + site: { bucket: "public" }, + }, + }, + ], + }); + onTestFinished(server.close); + + await server.listen(); + + const response = await server.fetch("/hello.txt"); + await expect(response.text()).resolves.toBe("Hello from Workers Sites"); + }); + + it("uses ephemeral storage by default", async ({ expect }) => { + await helper.seed({ + "wrangler.jsonc": dedent` + { + "name": "ephemeral-storage-worker", + "main": "src/index.ts", + "compatibility_date": "2026-05-20", + "kv_namespaces": [ + { "binding": "STORE", "id": "test-store" } + ] + } + `, + "src/index.ts": dedent` + export default { + async fetch(request, env) { + const url = new URL(request.url); + if (url.pathname === "/set") { + await env.STORE.put("key", "value"); + return new Response("stored"); + } + return new Response((await env.STORE.get("key")) ?? "missing"); + } + }; + `, + }); + + const firstServer = createServer({ + root: helper.tmpPath, + workers: [{ configPath: "./wrangler.jsonc" }], + }); + onTestFinished(firstServer.close); + + await firstServer.listen(); + + const setResponse = await firstServer.fetch("/set"); + await expect(setResponse.text()).resolves.toBe("stored"); + + const storedResponse = await firstServer.fetch("/"); + await expect(storedResponse.text()).resolves.toBe("value"); + + await firstServer.close(); + + const secondServer = createServer({ + root: helper.tmpPath, + workers: [{ configPath: "./wrangler.jsonc" }], + }); + onTestFinished(secondServer.close); + + await secondServer.listen(); + + const resetResponse = await secondServer.fetch("/"); + await expect(resetResponse.text()).resolves.toBe("missing"); + }); + + it("exposes the inspector URL when enabled", async ({ expect }) => { + await helper.seed({ + "wrangler.jsonc": dedent` + { + "name": "inspector-worker", + "main": "src/index.ts", + "compatibility_date": "2026-05-20" + } + `, + "src/index.ts": dedent` + export default { + fetch() { + return new Response("Hello World"); + } + }; + `, + }); + + const server = createServer({ + root: helper.tmpPath, + workers: [{ configPath: "./wrangler.jsonc" }], + inspector: { port: 0 }, + }); + onTestFinished(server.close); + + const { inspectorUrl } = await server.listen(); + + expect(inspectorUrl).toBeDefined(); + const inspectorResponse = await fetch(`http://${inspectorUrl?.host}/json`); + expect(inspectorResponse.ok).toBe(true); + }); + + it("triggers scheduled handlers", async ({ expect }) => { + await helper.seed({ + "wrangler.jsonc": dedent` + { + "name": "scheduled-worker", + "main": "src/index.ts", + "compatibility_date": "2026-05-20" + } + `, + "src/index.ts": dedent` + let lastCron = "missing"; + + export default { + fetch() { + return new Response(lastCron); + }, + scheduled(event) { + lastCron = event.cron; + } + }; + `, + }); + + const server = createServer({ + root: helper.tmpPath, + workers: [{ configPath: "./wrangler.jsonc" }], + }); + onTestFinished(server.close); + + await server.listen(); + + const beforeScheduled = await server.fetch("/"); + await expect(beforeScheduled.text()).resolves.toBe("missing"); + + await expect( + server.getWorker().scheduled({ + cron: "* * * * *", + scheduledTime: new Date(1_700_000_100_000), + }) + ).resolves.toEqual({ outcome: "ok", noRetry: false }); + + const afterScheduled = await server.fetch("/"); + await expect(afterScheduled.text()).resolves.toBe("* * * * *"); + }); + + it("does not reload on source changes by default", async ({ expect }) => { + await helper.seed({ + "wrangler.jsonc": dedent` + { + "name": "create-server-test", + "main": "src/index.ts", + "compatibility_date": "2026-05-20" + } + `, + "src/index.ts": dedent` + export default { + fetch() { + return new Response("Hello World"); + } + }; + `, + }); + + const server = createServer({ + root: helper.tmpPath, + workers: [{ configPath: "./wrangler.jsonc" }], + }); + onTestFinished(server.close); + + await server.listen(); + + const response1 = await server.fetch("/"); + await expect(response1.text()).resolves.toBe("Hello World"); + + await helper.seed({ + "src/index.ts": dedent` + export default { + fetch() { + return new Response("Greeting"); + } + }; + `, + }); + + // Wait a moment to ensure that if the server were going to reload, it would have done so by now + await setTimeout(1000); + + const response2 = await server.fetch("/"); + await expect(response2.text()).resolves.toBe("Hello World"); + + await server.update((options) => ({ ...options, watch: true })); + + const response3 = await server.fetch("/"); + await expect(response3.text()).resolves.toBe("Greeting"); + + await helper.seed({ + "src/index.ts": dedent` + export default { + fetch() { + return new Response("Bonjour"); + } + }; + `, + }); + + await vi.waitFor(async () => { + const response4 = await server.fetch("/"); + expect(await response4.text()).toBe("Bonjour"); + }); + }); +}); diff --git a/packages/wrangler/e2e/remote-binding/start-worker-remote-bindings.test.ts b/packages/wrangler/e2e/remote-binding/start-worker-remote-bindings.test.ts index dcc554fb4b..18b2d6896c 100644 --- a/packages/wrangler/e2e/remote-binding/start-worker-remote-bindings.test.ts +++ b/packages/wrangler/e2e/remote-binding/start-worker-remote-bindings.test.ts @@ -1,130 +1,126 @@ import { readFile, writeFile } from "node:fs/promises"; import { resolve } from "node:path"; import { setTimeout } from "node:timers/promises"; -import { beforeAll, describe, it } from "vitest"; +import { beforeAll, describe, it, onTestFinished } from "vitest"; import { CLOUDFLARE_ACCOUNT_ID } from "../helpers/account-id"; import { importWrangler, WranglerE2ETestHelper, } from "../helpers/e2e-wrangler-test"; -const { unstable_startWorker: startWorker } = await importWrangler(); - -describe.skipIf(!CLOUDFLARE_ACCOUNT_ID)("startWorker - remote bindings", () => { - const remoteWorkerName = "preserve-e2e-wrangler-remote-worker"; - const helper = new WranglerE2ETestHelper(); - - beforeAll(async () => { - await helper.seed(resolve(__dirname, "./workers")); - await helper.ensureWorkerDeployed({ - entryPoint: "remote-worker.js", - workerName: remoteWorkerName, - }); - }, 60_000); - - it("allows connecting to a remote worker", async ({ expect }) => { - await helper.seed({ - "wrangler.json": JSON.stringify({ - name: "remote-bindings-test", - main: "simple-service-binding.js", - compatibility_date: "2025-05-07", - services: [ - { - binding: "REMOTE_WORKER", - service: remoteWorkerName, - remote: true, - }, - ], - }), - }); - - const worker = await startWorker({ - config: `${helper.tmpPath}/wrangler.json`, - dev: { - inspector: false, - server: { port: 0 }, - }, - }); - - await worker.ready; - - await expect( - (await worker.fetch("http://example.com")).text() - ).resolves.toContain("REMOTE: Hello from a remote worker"); - - await worker.dispose(); - }); - - it("handles code changes during development", async ({ expect }) => { - await helper.seed({ - "wrangler.json": JSON.stringify({ - name: "remote-bindings-test", - main: "simple-service-binding.js", - compatibility_date: "2025-05-07", - services: [ - { - binding: "REMOTE_WORKER", - service: remoteWorkerName, - remote: true, - }, - ], - }), +const { createServer } = await importWrangler(); + +describe.skipIf(!CLOUDFLARE_ACCOUNT_ID)( + "createServer - remote bindings", + () => { + const remoteWorkerName = "preserve-e2e-wrangler-remote-worker"; + const helper = new WranglerE2ETestHelper(); + + beforeAll(async () => { + await helper.seed(resolve(__dirname, "./workers")); + await helper.ensureWorkerDeployed({ + entryPoint: "remote-worker.js", + workerName: remoteWorkerName, + }); + }, 60_000); + + it("allows connecting to a remote worker", async ({ expect }) => { + await helper.seed({ + "wrangler.json": JSON.stringify({ + name: "remote-bindings-test", + main: "simple-service-binding.js", + compatibility_date: "2025-05-07", + services: [ + { + binding: "REMOTE_WORKER", + service: remoteWorkerName, + remote: true, + }, + ], + }), + }); + + const server = createServer({ + root: helper.tmpPath, + workers: [{ configPath: "wrangler.json" }], + allowRemoteBindings: true, + }); + onTestFinished(server.close); + + await server.listen(); + + await expect( + (await server.fetch("http://example.com")).text() + ).resolves.toContain("REMOTE: Hello from a remote worker"); }); - const worker = await startWorker({ - config: `${helper.tmpPath}/wrangler.json`, - dev: { - inspector: false, - server: { port: 0 }, - }, + it("handles code changes during development", async ({ expect }) => { + await helper.seed({ + "wrangler.json": JSON.stringify({ + name: "remote-bindings-test", + main: "simple-service-binding.js", + compatibility_date: "2025-05-07", + services: [ + { + binding: "REMOTE_WORKER", + service: remoteWorkerName, + remote: true, + }, + ], + }), + }); + + const server = createServer({ + root: helper.tmpPath, + workers: [{ configPath: "wrangler.json" }], + allowRemoteBindings: true, + watch: true, + }); + onTestFinished(server.close); + + await server.listen(); + + await expect( + (await server.fetch("http://example.com")).text() + ).resolves.toContain("REMOTE: Hello from a remote worker"); + + const indexContent = await readFile( + `${helper.tmpPath}/simple-service-binding.js`, + "utf8" + ); + await writeFile( + `${helper.tmpPath}/simple-service-binding.js`, + indexContent.replace( + "REMOTE:", + "The remote worker responded with:" + ), + "utf8" + ); + + await setTimeout(500); + + await expect( + (await server.fetch("http://example.com")).text() + ).resolves.toContain( + "The remote worker responded with: Hello from a remote worker" + ); + + await writeFile( + `${helper.tmpPath}/simple-service-binding.js`, + indexContent, + "utf8" + ); + + await setTimeout(500); + + await expect( + (await server.fetch("http://example.com")).text() + ).resolves.toContain("REMOTE: Hello from a remote worker"); }); + } +); - await worker.ready; - - await expect( - (await worker.fetch("http://example.com")).text() - ).resolves.toContain("REMOTE: Hello from a remote worker"); - - const indexContent = await readFile( - `${helper.tmpPath}/simple-service-binding.js`, - "utf8" - ); - await writeFile( - `${helper.tmpPath}/simple-service-binding.js`, - indexContent.replace( - "REMOTE:", - "The remote worker responded with:" - ), - "utf8" - ); - - await setTimeout(500); - - await expect( - (await worker.fetch("http://example.com")).text() - ).resolves.toContain( - "The remote worker responded with: Hello from a remote worker" - ); - - await writeFile( - `${helper.tmpPath}/simple-service-binding.js`, - indexContent, - "utf8" - ); - - await setTimeout(500); - - await expect( - (await worker.fetch("http://example.com")).text() - ).resolves.toContain("REMOTE: Hello from a remote worker"); - - await worker.dispose(); - }); -}); - -it("doesn't connect to remote bindings when `remote` is set to `false`", async ({ - expect, -}) => { +it("doesn't connect to remote bindings by default", async ({ expect }) => { const helper = new WranglerE2ETestHelper(); await helper.seed(resolve(__dirname, "./workers")); await helper.seed({ @@ -140,18 +136,15 @@ it("doesn't connect to remote bindings when `remote` is set to `false`", async ( }); await expect(async () => { - const worker = await startWorker({ - config: `${helper.tmpPath}/wrangler.json`, - dev: { - inspector: false, - server: { port: 0 }, - remote: false, - }, + const server = createServer({ + root: helper.tmpPath, + workers: [{ configPath: "wrangler.json" }], }); + onTestFinished(server.close); - await worker.ready; + await server.listen(); - await worker.fetch("http://example.com"); + await server.fetch("http://example.com"); }).rejects.toThrowErrorMatchingInlineSnapshot( `[Error: Binding AI needs to be run remotely]` ); diff --git a/packages/wrangler/src/__tests__/middleware.test.ts b/packages/wrangler/src/__tests__/middleware.test.ts index 1788c33653..3855d3840e 100644 --- a/packages/wrangler/src/__tests__/middleware.test.ts +++ b/packages/wrangler/src/__tests__/middleware.test.ts @@ -3,8 +3,8 @@ import { mkdir, readFile, writeFile } from "node:fs/promises"; import path from "node:path"; import { runInTempDir } from "@cloudflare/workers-utils/test-helpers"; import dedent from "ts-dedent"; -import { beforeEach, describe, it, vi } from "vitest"; -import { startWorker } from "../api/startDevWorker"; +import { beforeEach, describe, it, onTestFinished, vi } from "vitest"; +import { createServer } from "../api/server"; import { mockConsoleMethods } from "./helpers/mock-console"; import { runWrangler } from "./helpers/run-wrangler"; @@ -24,6 +24,7 @@ async function seedFs(files: Record): Promise { await writeFile(location, contents); } } + describe("middleware", () => { mockConsoleMethods(); @@ -52,21 +53,25 @@ describe("middleware", () => { `; fs.writeFileSync("index.js", scriptContent); - const worker = await startWorker({ - entrypoint: "index.js", - dev: { - server: { hostname: "127.0.0.1", port: 0 }, - inspector: false, - }, + const server = createServer({ + workers: [ + { + config: { + main: "index.js", + compatibility_date: "2026-05-20", + }, + }, + ], }); + onTestFinished(server.close); + await server.listen(); - const resp = await worker.fetch("http://dummy"); + const resp = await server.fetch("http://dummy"); let text; if (resp) { text = await resp.text(); } expect(text).toMatchInlineSnapshot(`"Hello world"`); - await worker.dispose(); }); it("should be able to access scheduled workers from middleware", async ({ @@ -88,21 +93,25 @@ describe("middleware", () => { fs.writeFileSync("index.js", scriptContent); - const worker = await startWorker({ - entrypoint: "index.js", - dev: { - server: { hostname: "127.0.0.1", port: 0 }, - inspector: false, - }, + const server = createServer({ + workers: [ + { + config: { + main: "index.js", + compatibility_date: "2026-05-20", + }, + }, + ], }); + onTestFinished(server.close); + await server.listen(); - const resp = await worker.fetch("http://dummy"); + const resp = await server.fetch("http://dummy"); let text; if (resp) { text = await resp.text(); } expect(text).toMatchInlineSnapshot(`"OK"`); - await worker.dispose(); }); it("should trigger an error in a scheduled work from middleware", async ({ @@ -127,21 +136,25 @@ describe("middleware", () => { fs.writeFileSync("index.js", scriptContent); - const worker = await startWorker({ - entrypoint: "index.js", - dev: { - server: { hostname: "127.0.0.1", port: 0 }, - inspector: false, - }, + const server = createServer({ + workers: [ + { + config: { + main: "index.js", + compatibility_date: "2026-05-20", + }, + }, + ], }); + onTestFinished(server.close); + await server.listen(); - const resp = await worker.fetch("http://dummy"); + const resp = await server.fetch("http://dummy"); let text; if (resp) { text = await resp.text(); } expect(text).toMatchInlineSnapshot(`"Error in scheduled worker"`); - await worker.dispose(); }); }); @@ -162,21 +175,25 @@ describe("middleware", () => { `; fs.writeFileSync("index.js", scriptContent); - const worker = await startWorker({ - entrypoint: "index.js", - dev: { - server: { hostname: "127.0.0.1", port: 0 }, - inspector: false, - }, + const server = createServer({ + workers: [ + { + config: { + main: "index.js", + compatibility_date: "2026-05-20", + }, + }, + ], }); + onTestFinished(server.close); + await server.listen(); - const resp = await worker.fetch("http://dummy"); + const resp = await server.fetch("http://dummy"); let text; if (resp) { text = await resp.text(); } expect(text).toMatchInlineSnapshot(`"Hello world"`); - await worker.dispose(); }); it("should register a middleware and intercept using addMiddlewareInternal", async ({ @@ -195,21 +212,25 @@ describe("middleware", () => { `; fs.writeFileSync("index.js", scriptContent); - const worker = await startWorker({ - entrypoint: "index.js", - dev: { - server: { hostname: "127.0.0.1", port: 0 }, - inspector: false, - }, + const server = createServer({ + workers: [ + { + config: { + main: "index.js", + compatibility_date: "2026-05-20", + }, + }, + ], }); + onTestFinished(server.close); + await server.listen(); - const resp = await worker.fetch("http://dummy"); + const resp = await server.fetch("http://dummy"); let text; if (resp) { text = await resp.text(); } expect(text).toMatchInlineSnapshot(`"Hello world"`); - await worker.dispose(); }); it("should be able to access scheduled workers from middleware", async ({ @@ -228,21 +249,25 @@ describe("middleware", () => { fs.writeFileSync("index.js", scriptContent); - const worker = await startWorker({ - entrypoint: "index.js", - dev: { - server: { hostname: "127.0.0.1", port: 0 }, - inspector: false, - }, + const server = createServer({ + workers: [ + { + config: { + main: "index.js", + compatibility_date: "2026-05-20", + }, + }, + ], }); + onTestFinished(server.close); + await server.listen(); - const resp = await worker.fetch("http://dummy"); + const resp = await server.fetch("http://dummy"); let text; if (resp) { text = await resp.text(); } expect(text).toMatchInlineSnapshot(`"OK"`); - await worker.dispose(); }); it("should trigger an error in a scheduled work from middleware", async ({ @@ -264,21 +289,25 @@ describe("middleware", () => { fs.writeFileSync("index.js", scriptContent); - const worker = await startWorker({ - entrypoint: "index.js", - dev: { - server: { hostname: "127.0.0.1", port: 0 }, - inspector: false, - }, + const server = createServer({ + workers: [ + { + config: { + main: "index.js", + compatibility_date: "2026-05-20", + }, + }, + ], }); + onTestFinished(server.close); + await server.listen(); - const resp = await worker.fetch("http://dummy"); + const resp = await server.fetch("http://dummy"); let text; if (resp) { text = await resp.text(); } expect(text).toMatchInlineSnapshot(`"Error in scheduled worker"`); - await worker.dispose(); }); }); }); @@ -303,20 +332,24 @@ describe("middleware", () => { `; fs.writeFileSync("index.js", scriptContent); - const worker = await startWorker({ - entrypoint: "index.js", - dev: { - server: { hostname: "127.0.0.1", port: 0 }, - inspector: false, - }, + const server = createServer({ + workers: [ + { + config: { + main: "index.js", + compatibility_date: "2026-05-20", + }, + }, + ], }); + onTestFinished(server.close); + await server.listen(); - const resp = await worker.fetch("http://dummy"); + const resp = await server.fetch("http://dummy"); if (resp) { const text = await resp.text(); expect(text).toMatchInlineSnapshot(`"Hello world"`); } - await worker.dispose(); }); it("should return hello world with empty middleware array", async ({ @@ -333,21 +366,25 @@ describe("middleware", () => { `; fs.writeFileSync("index.js", scriptContent); - const worker = await startWorker({ - entrypoint: "index.js", - dev: { - server: { hostname: "127.0.0.1", port: 0 }, - inspector: false, - }, + const server = createServer({ + workers: [ + { + config: { + main: "index.js", + compatibility_date: "2026-05-20", + }, + }, + ], }); + onTestFinished(server.close); + await server.listen(); - const resp = await worker.fetch("http://dummy"); + const resp = await server.fetch("http://dummy"); let text; if (resp) { text = await resp.text(); } expect(text).toMatchInlineSnapshot(`"Hello world"`); - await worker.dispose(); }); it("should return hello world passing through middleware", async ({ @@ -367,20 +404,24 @@ describe("middleware", () => { `; fs.writeFileSync("index.js", scriptContent); - const worker = await startWorker({ - entrypoint: "index.js", - dev: { - server: { hostname: "127.0.0.1", port: 0 }, - inspector: false, - }, + const server = createServer({ + workers: [ + { + config: { + main: "index.js", + compatibility_date: "2026-05-20", + }, + }, + ], }); + onTestFinished(server.close); + await server.listen(); - const resp = await worker.fetch("http://dummy"); + const resp = await server.fetch("http://dummy"); if (resp) { const text = await resp.text(); expect(text).toMatchInlineSnapshot(`"Hello world"`); } - await worker.dispose(); }); it("should return hello world with multiple middleware in array", async ({ @@ -403,21 +444,25 @@ describe("middleware", () => { `; fs.writeFileSync("index.js", scriptContent); - const worker = await startWorker({ - entrypoint: "index.js", - dev: { - server: { hostname: "127.0.0.1", port: 0 }, - inspector: false, - }, + const server = createServer({ + workers: [ + { + config: { + main: "index.js", + compatibility_date: "2026-05-20", + }, + }, + ], }); + onTestFinished(server.close); + await server.listen(); - const resp = await worker.fetch("http://dummy"); + const resp = await server.fetch("http://dummy"); let text; if (resp) { text = await resp.text(); } expect(text).toMatchInlineSnapshot(`"Hello world"`); - await worker.dispose(); }); it("should leave response headers unchanged with middleware", async ({ @@ -437,15 +482,20 @@ describe("middleware", () => { `; fs.writeFileSync("index.js", scriptContent); - const worker = await startWorker({ - entrypoint: "index.js", - dev: { - server: { hostname: "127.0.0.1", port: 0 }, - inspector: false, - }, + const server = createServer({ + workers: [ + { + config: { + main: "index.js", + compatibility_date: "2026-05-20", + }, + }, + ], }); + onTestFinished(server.close); + await server.listen(); - const resp = await worker.fetch("http://dummy"); + const resp = await server.fetch("http://dummy"); const status = resp?.status; let text; if (resp) { @@ -455,7 +505,6 @@ describe("middleware", () => { expect(status).toEqual(500); expect(text).toMatchInlineSnapshot(`"Hello world"`); expect(testHeader).toEqual("test"); - await worker.dispose(); }); it("waitUntil should not block responses", async ({ expect }) => { @@ -482,21 +531,25 @@ describe("middleware", () => { `; fs.writeFileSync("index.js", scriptContent); - const worker = await startWorker({ - entrypoint: "index.js", - dev: { - server: { hostname: "127.0.0.1", port: 0 }, - inspector: false, - }, + const server = createServer({ + workers: [ + { + config: { + main: "index.js", + compatibility_date: "2026-05-20", + }, + }, + ], }); + onTestFinished(server.close); + await server.listen(); - const resp = await worker.fetch("http://dummy"); + const resp = await server.fetch("http://dummy"); let text; if (resp) { text = await resp.text(); } expect(text).toMatchInlineSnapshot(`"Hello world0"`); - await worker.dispose(); }); }); @@ -511,20 +564,24 @@ describe("middleware", () => { `; fs.writeFileSync("index.js", scriptContent); - const worker = await startWorker({ - entrypoint: "index.js", - dev: { - server: { hostname: "127.0.0.1", port: 0 }, - inspector: false, - }, + const server = createServer({ + workers: [ + { + config: { + main: "index.js", + compatibility_date: "2026-05-20", + }, + }, + ], }); + onTestFinished(server.close); + await server.listen(); - const resp = await worker.fetch("http://dummy"); + const resp = await server.fetch("http://dummy"); if (resp) { const text = await resp.text(); expect(text).toMatchInlineSnapshot(`"Hello world"`); } - await worker.dispose(); }); it("should return hello world with empty middleware array", async ({ @@ -538,21 +595,25 @@ describe("middleware", () => { `; fs.writeFileSync("index.js", scriptContent); - const worker = await startWorker({ - entrypoint: "index.js", - dev: { - server: { hostname: "127.0.0.1", port: 0 }, - inspector: false, - }, + const server = createServer({ + workers: [ + { + config: { + main: "index.js", + compatibility_date: "2026-05-20", + }, + }, + ], }); + onTestFinished(server.close); + await server.listen(); - const resp = await worker.fetch("http://dummy"); + const resp = await server.fetch("http://dummy"); let text; if (resp) { text = await resp.text(); } expect(text).toMatchInlineSnapshot(`"Hello world"`); - await worker.dispose(); }); it("should return hello world passing through middleware", async ({ @@ -569,20 +630,24 @@ describe("middleware", () => { `; fs.writeFileSync("index.js", scriptContent); - const worker = await startWorker({ - entrypoint: "index.js", - dev: { - server: { hostname: "127.0.0.1", port: 0 }, - inspector: false, - }, + const server = createServer({ + workers: [ + { + config: { + main: "index.js", + compatibility_date: "2026-05-20", + }, + }, + ], }); + onTestFinished(server.close); + await server.listen(); - const resp = await worker.fetch("http://dummy"); + const resp = await server.fetch("http://dummy"); if (resp) { const text = await resp.text(); expect(text).toMatchInlineSnapshot(`"Hello world"`); } - await worker.dispose(); }); it("should return hello world with addMiddleware function called multiple times", async ({ @@ -603,21 +668,25 @@ describe("middleware", () => { `; fs.writeFileSync("index.js", scriptContent); - const worker = await startWorker({ - entrypoint: "index.js", - dev: { - server: { hostname: "127.0.0.1", port: 0 }, - inspector: false, - }, + const server = createServer({ + workers: [ + { + config: { + main: "index.js", + compatibility_date: "2026-05-20", + }, + }, + ], }); + onTestFinished(server.close); + await server.listen(); - const resp = await worker.fetch("http://dummy"); + const resp = await server.fetch("http://dummy"); let text; if (resp) { text = await resp.text(); } expect(text).toMatchInlineSnapshot(`"Hello world"`); - await worker.dispose(); }); it("should return hello world with addMiddleware function called with array of middleware", async ({ @@ -637,21 +706,25 @@ describe("middleware", () => { `; fs.writeFileSync("index.js", scriptContent); - const worker = await startWorker({ - entrypoint: "index.js", - dev: { - server: { hostname: "127.0.0.1", port: 0 }, - inspector: false, - }, + const server = createServer({ + workers: [ + { + config: { + main: "index.js", + compatibility_date: "2026-05-20", + }, + }, + ], }); + onTestFinished(server.close); + await server.listen(); - const resp = await worker.fetch("http://dummy"); + const resp = await server.fetch("http://dummy"); let text; if (resp) { text = await resp.text(); } expect(text).toMatchInlineSnapshot(`"Hello world"`); - await worker.dispose(); }); it("should return hello world with addMiddlewareInternal function called multiple times", async ({ @@ -672,21 +745,25 @@ describe("middleware", () => { `; fs.writeFileSync("index.js", scriptContent); - const worker = await startWorker({ - entrypoint: "index.js", - dev: { - server: { hostname: "127.0.0.1", port: 0 }, - inspector: false, - }, + const server = createServer({ + workers: [ + { + config: { + main: "index.js", + compatibility_date: "2026-05-20", + }, + }, + ], }); + onTestFinished(server.close); + await server.listen(); - const resp = await worker.fetch("http://dummy"); + const resp = await server.fetch("http://dummy"); let text; if (resp) { text = await resp.text(); } expect(text).toMatchInlineSnapshot(`"Hello world"`); - await worker.dispose(); }); it("should return hello world with addMiddlewareInternal function called with array of middleware", async ({ @@ -706,21 +783,25 @@ describe("middleware", () => { `; fs.writeFileSync("index.js", scriptContent); - const worker = await startWorker({ - entrypoint: "index.js", - dev: { - server: { hostname: "127.0.0.1", port: 0 }, - inspector: false, - }, + const server = createServer({ + workers: [ + { + config: { + main: "index.js", + compatibility_date: "2026-05-20", + }, + }, + ], }); + onTestFinished(server.close); + await server.listen(); - const resp = await worker.fetch("http://dummy"); + const resp = await server.fetch("http://dummy"); let text; if (resp) { text = await resp.text(); } expect(text).toMatchInlineSnapshot(`"Hello world"`); - await worker.dispose(); }); it("should return hello world with both addMiddleware and addMiddlewareInternal called", async ({ @@ -741,21 +822,25 @@ describe("middleware", () => { `; fs.writeFileSync("index.js", scriptContent); - const worker = await startWorker({ - entrypoint: "index.js", - dev: { - server: { hostname: "127.0.0.1", port: 0 }, - inspector: false, - }, + const server = createServer({ + workers: [ + { + config: { + main: "index.js", + compatibility_date: "2026-05-20", + }, + }, + ], }); + onTestFinished(server.close); + await server.listen(); - const resp = await worker.fetch("http://dummy"); + const resp = await server.fetch("http://dummy"); let text; if (resp) { text = await resp.text(); } expect(text).toMatchInlineSnapshot(`"Hello world"`); - await worker.dispose(); }); it("should leave response headers unchanged with middleware", async ({ @@ -771,15 +856,20 @@ describe("middleware", () => { `; fs.writeFileSync("index.js", scriptContent); - const worker = await startWorker({ - entrypoint: "index.js", - dev: { - server: { hostname: "127.0.0.1", port: 0 }, - inspector: false, - }, + const server = createServer({ + workers: [ + { + config: { + main: "index.js", + compatibility_date: "2026-05-20", + }, + }, + ], }); + onTestFinished(server.close); + await server.listen(); - const resp = await worker.fetch("http://dummy"); + const resp = await server.fetch("http://dummy"); const status = resp?.status; let text; if (resp) { @@ -789,7 +879,6 @@ describe("middleware", () => { expect(status).toEqual(500); expect(text).toMatchInlineSnapshot(`"Hello world"`); expect(testHeader).toEqual("test"); - await worker.dispose(); }); it("should allow multiple addEventListeners for fetch", async ({ @@ -806,21 +895,25 @@ describe("middleware", () => { `; fs.writeFileSync("index.js", scriptContent); - const worker = await startWorker({ - entrypoint: "index.js", - dev: { - server: { hostname: "127.0.0.1", port: 0 }, - inspector: false, - }, + const server = createServer({ + workers: [ + { + config: { + main: "index.js", + compatibility_date: "2026-05-20", + }, + }, + ], }); + onTestFinished(server.close); + await server.listen(); - const resp = await worker.fetch("http://dummy"); + const resp = await server.fetch("http://dummy"); let text; if (resp) { text = await resp.text(); } expect(text).toMatchInlineSnapshot(`"Hello world1"`); - await worker.dispose(); }); it("waitUntil should not block responses", async ({ expect }) => { @@ -839,21 +932,25 @@ describe("middleware", () => { `; fs.writeFileSync("index.js", scriptContent); - const worker = await startWorker({ - entrypoint: "index.js", - dev: { - server: { hostname: "127.0.0.1", port: 0 }, - inspector: false, - }, + const server = createServer({ + workers: [ + { + config: { + main: "index.js", + compatibility_date: "2026-05-20", + }, + }, + ], }); + onTestFinished(server.close); + await server.listen(); - const resp = await worker.fetch("http://dummy"); + const resp = await server.fetch("http://dummy"); let text; if (resp) { text = await resp.text(); } expect(text).toMatchInlineSnapshot(`"Hello world0"`); - await worker.dispose(); }); }); }); @@ -1102,48 +1199,42 @@ describe("middleware", () => { `; fs.writeFileSync("index.js", scriptContent); - const worker = await startWorker({ - entrypoint: "index.js", - dev: { - server: { hostname: "127.0.0.1", port: 0 }, - inspector: false, - testScheduled: true, - }, - bindings: { - DB: { - type: "d1", - database_name: "db", - database_id: "00000000-0000-0000-0000-000000000000", + const server = createServer({ + workers: [ + { + config: { + main: "index.js", + compatibility_date: "2026-05-20", + d1_databases: [ + { + binding: "DB", + database_name: "db", + database_id: "00000000-0000-0000-0000-000000000000", + }, + ], + }, }, - }, + ], }); - - try { - await worker.ready; - const url = await worker.url; - // TODO(#12596): worker.fetch() doesn't work correctly with paths when - // EXPERIMENTAL_MIDDLEWARE=true is set. The request URL pathname gets - // lost, causing the worker to not match routes like "/setup". - // We use native fetch() with the worker URL as a workaround. - let res = await fetch(new URL("/setup", url).href); - expect(res.status).toBe(204); - res = await fetch(new URL("/__scheduled", url).href); - expect(res.status).toBe(200); - expect(await res.text()).toBe("Ran scheduled event"); - res = await fetch(new URL("/query", url).href); - expect(res.status).toBe(200); - expect(await res.json()).toEqual([{ id: 1, value: "one" }]); - res = await fetch(new URL("/bad", url).href); - expect(res.status).toBe(500); - // TODO: in miniflare we don't have the `pretty-error` middleware implemented. - // instead it uses `middleware-miniflare3-json-error`, which outputs JSON rather than text. - // expect(res.headers.get("Content-Type")).toBe( - // "text/html; charset=UTF-8" - // ); - expect(await res.text()).toContain("Not found!"); - } finally { - await worker.dispose(); - } + onTestFinished(server.close); + + const { url } = await server.listen(); + let res: Response = await server.fetch("/setup"); + expect(res.status).toBe(204); + await expect( + server.getWorker().scheduled({ cron: "* * * * *" }) + ).resolves.toEqual({ outcome: "ok", noRetry: false }); + res = await server.fetch("/query"); + expect(res.status).toBe(200); + expect(await res.json()).toEqual([{ id: 1, value: "one" }]); + res = await fetch(new URL("/bad", url).href); + expect(res.status).toBe(500); + // TODO: in miniflare we don't have the `pretty-error` middleware implemented. + // instead it uses `middleware-miniflare3-json-error`, which outputs JSON rather than text. + // expect(res.headers.get("Content-Type")).toBe( + // "text/html; charset=UTF-8" + // ); + expect(await res.text()).toContain("Not found!"); }); }); }); diff --git a/packages/wrangler/src/api/index.ts b/packages/wrangler/src/api/index.ts index 20e0f1de71..8a3e5af128 100644 --- a/packages/wrangler/src/api/index.ts +++ b/packages/wrangler/src/api/index.ts @@ -58,6 +58,15 @@ export type { } from "./startDevWorker/events"; export type { DevToolsEvent } from "./startDevWorker/devtools"; +// Exports from ./server +export { createServer } from "./server"; +export type { + InspectorOptions, + ServerOptions, + WorkerInput, + WorkerServer, +} from "./server"; + // Exports from ./integrations export { unstable_getVarsForDev, diff --git a/packages/wrangler/src/api/server.ts b/packages/wrangler/src/api/server.ts new file mode 100644 index 0000000000..0fab8c99b7 --- /dev/null +++ b/packages/wrangler/src/api/server.ts @@ -0,0 +1,561 @@ +import assert from "node:assert"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { + normalizeAndValidateConfig, + UserError, +} from "@cloudflare/workers-utils"; +import { Headers, Request } from "miniflare"; +import { validateNodeCompatMode } from "../deployment-bundle/node-compat"; +import { logger } from "../logger"; +import { getSiteAssetPaths } from "../sites"; +import { requireApiToken, requireAuth } from "../user"; +import { DevEnv } from "./startDevWorker/DevEnv"; +import { MultiworkerRuntimeController } from "./startDevWorker/MultiworkerRuntimeController"; +import { NoOpProxyController } from "./startDevWorker/NoOpProxyController"; +import { convertConfigToBindings } from "./startDevWorker/utils"; +import type { + LogLevel, + ServiceFetch, + StartDevWorkerInput, + StartDevWorkerOptions, +} from "./startDevWorker/types"; +import type { + FetcherScheduledOptions, + FetcherScheduledResult, +} from "@cloudflare/workers-types/experimental"; +import type { Config, RawConfig } from "@cloudflare/workers-utils"; +import type { DispatchFetch, Json, RequestInfo } from "miniflare"; + +export type InlineConfig = Omit; + +export type ConfigOverrides = { + vars?: Record; + secrets?: Record; +}; + +export type WorkerInput = + | { + root?: string; + configPath: string | URL; + env?: string; + overrides?: ConfigOverrides; + } + | { + root?: string; + config: InlineConfig; + }; + +type DevServerOptions = Exclude< + NonNullable["server"], + undefined +>; + +export type InspectorOptions = Exclude< + NonNullable["inspector"], + undefined +>; + +export type ServerOptions = { + root?: string | undefined; + workers: WorkerInput[]; + server?: DevServerOptions | undefined; + inspector?: InspectorOptions | undefined; + persist?: boolean | string | undefined; + watch?: boolean | undefined; + logLevel?: LogLevel | undefined; + accountId?: string | undefined; + allowRemoteBindings?: boolean | undefined; + outboundService?: ServiceFetch | undefined; +}; + +export type Worker = { + fetch: DispatchFetch; + scheduled(options: FetcherScheduledOptions): Promise; +}; + +export type WorkerServer = { + listen(): Promise<{ + url: URL; + inspectorUrl: URL | undefined; + }>; + fetch: DispatchFetch; + getWorker(name?: string): Worker; + update( + options: ServerOptions | ((currentOptions: ServerOptions) => ServerOptions) + ): Promise; + close(): Promise; +}; + +type ServerSession = { + primaryDevEnv: DevEnv; + devEnvs: DevEnv[]; +}; + +type ServerAuthHook = NonNullable< + NonNullable["auth"] +>; + +function resolvePath(basePath: string, maybePath: string | URL): string { + if (maybePath instanceof URL) { + return fileURLToPath(maybePath); + } + + return path.isAbsolute(maybePath) + ? maybePath + : path.resolve(basePath, maybePath); +} + +function normalizeInlineWorkerConfig( + config: InlineConfig, + root: string +): Config { + const configPath = path.join(root, "wrangler.jsonc"); + const { config: normalizedConfig, diagnostics } = normalizeAndValidateConfig( + config, + configPath, + configPath, + {} + ); + + if (diagnostics.hasWarnings()) { + logger.warn(diagnostics.renderWarnings()); + } + + if (diagnostics.hasErrors()) { + throw new UserError(diagnostics.renderErrors(), { + telemetryMessage: "create server inline config validation failed", + }); + } + + return normalizedConfig; +} + +async function resolveFetchInput( + input: RequestInfo, + session: ServerSession +): Promise { + if (typeof input !== "string") { + return input; + } + + const { url } = await session.primaryDevEnv.proxy.ready.promise; + const baseUrl = new URL(url); + + if ( + baseUrl.hostname === "0.0.0.0" || + baseUrl.hostname === "::" || + baseUrl.hostname === "[::]" || + baseUrl.hostname === "*" + ) { + baseUrl.hostname = "localhost"; + } + + return new URL(input, baseUrl); +} + +function resolveWorkerInputs( + options: ServerOptions, + auth: ServerAuthHook +): StartDevWorkerInput[] { + if (options.workers.length === 0) { + throw new Error("Worker server requires at least one worker."); + } + + const cwd = process.cwd(); + + return options.workers.map((input, index, list) => { + const isPrimaryWorker = index === 0; + const isMultiworker = list.length > 1; + const root = input.root ?? options.root ?? cwd; + const inlineConfig = + "config" in input + ? normalizeInlineWorkerConfig(input.config, root) + : undefined; + const overrides = "configPath" in input ? input.overrides : undefined; + const bindings = convertConfigToBindings( + inlineConfig ?? { vars: overrides?.vars }, + { usePreviewIds: true } + ); + + for (const [key, value] of Object.entries(overrides?.secrets ?? {})) { + bindings[key] = { type: "secret_text", value }; + } + + return { + // Uses an empty string to avoid dev env from auto discovering a config file and merging it with the inline config + config: "configPath" in input ? resolvePath(root, input.configPath) : "", + env: "configPath" in input ? input.env : undefined, + name: inlineConfig?.name, + entrypoint: inlineConfig?.main, + compatibilityDate: inlineConfig?.compatibility_date, + compatibilityFlags: inlineConfig?.compatibility_flags, + complianceRegion: inlineConfig?.compliance_region, + pythonModules: inlineConfig?.python_modules, + bindings, + migrations: inlineConfig?.migrations, + containers: inlineConfig?.containers, + triggers: inlineConfig?.triggers?.crons?.map((cron) => ({ + type: "cron" as const, + cron, + })), + tailConsumers: inlineConfig?.tail_consumers, + streamingTailConsumers: inlineConfig?.streaming_tail_consumers, + assets: inlineConfig?.assets?.directory, + dev: { + auth, + remote: options.allowRemoteBindings ? undefined : false, + server: options.server ?? { hostname: "127.0.0.1", port: 0 }, + logLevel: options.logLevel ?? "error", + watch: options.watch ?? false, + persist: + options.persist === true ? undefined : (options.persist ?? false), + inspector: options.inspector ?? false, + outboundService: + options.outboundService ?? + ((request) => { + return globalThis.fetch(request.url, request); + }), + multiworkerPrimary: isPrimaryWorker && isMultiworker ? true : undefined, + }, + build: { + nodejsCompatMode: (config) => { + const hookConfig = inlineConfig ?? config; + return validateNodeCompatMode( + hookConfig.compatibility_date, + hookConfig.compatibility_flags ?? [], + { noBundle: hookConfig.no_bundle } + ); + }, + }, + legacy: { + site: (config) => { + const legacyAssetPaths = getSiteAssetPaths(inlineConfig ?? config); + + if (!legacyAssetPaths) { + return undefined; + } + + return { + bucket: path.join( + legacyAssetPaths.baseDirectory, + legacyAssetPaths.assetDirectory + ), + include: legacyAssetPaths.includePatterns, + exclude: legacyAssetPaths.excludePatterns, + }; + }, + }, + }; + }); +} + +async function createSession( + options: ServerOptions, + auth: ServerAuthHook +): Promise { + const inputs = resolveWorkerInputs(options, auth); + const [, ...auxiliaryWorkers] = inputs; + const isMultiworker = auxiliaryWorkers.length > 0; + const primaryDevEnv = isMultiworker + ? new DevEnv({ + runtimeFactories: [ + (devEnv) => new MultiworkerRuntimeController(devEnv, inputs.length), + ], + }) + : new DevEnv(); + const auxiliaryDevEnvs = auxiliaryWorkers.map( + () => + new DevEnv({ + runtimeFactories: [() => primaryDevEnv.runtimes[0]], + proxyFactory: (devEnv) => new NoOpProxyController(devEnv), + }) + ); + const session: ServerSession = { + primaryDevEnv, + devEnvs: [primaryDevEnv, ...auxiliaryDevEnvs], + }; + + await updateConfig(session, inputs); + + return session; +} + +async function updateConfig( + session: ServerSession, + inputs: StartDevWorkerInput[] +) { + try { + for (const [index, workerInput] of inputs.entries()) { + const devEnv = session.devEnvs[index]; + await devEnv.config.set(workerInput, true); + } + } catch (error) { + await Promise.allSettled( + session.devEnvs.map((devEnv) => devEnv.teardown()) + ); + throw error; + } +} + +// TODO: Do we want this? +function maybePrintScheduledWorkerWarning( + serverSession: ServerSession, + url: URL +): void { + const workersWithCronTriggers = serverSession.devEnvs + .map((devEnv) => devEnv.config.latestConfig) + .filter((config): config is StartDevWorkerOptions => config !== undefined) + .filter((config) => + config.triggers?.some((trigger) => trigger.type === "cron") + ); + + if (workersWithCronTriggers.length === 0) { + return; + } + + const testScheduled = workersWithCronTriggers.every( + (config) => config.dev.testScheduled + ); + if (testScheduled) { + return; + } + + const host = + url.hostname === "0.0.0.0" || url.hostname === "::" + ? "localhost" + : url.hostname.includes(":") + ? `[${url.hostname}]` + : url.hostname; + + logger.once.warn( + `Scheduled Workers are not automatically triggered during local development.\n` + + `To manually trigger a scheduled event, run:\n` + + ` curl "http://${host}:${url.port}/cdn-cgi/handler/scheduled"\n` + + `For more details, see https://developers.cloudflare.com/workers/configuration/cron-triggers/#test-cron-triggers-locally` + ); +} + +/** + * Creates a worker server with a small, migration-focused API surface. + * + * This intentionally reuses DevEnv/controller internals with minimal behavior changes. + */ +export function createServer(options: ServerOptions): WorkerServer { + let currentOptions = options; + let desiredAccountId = options.accountId; + let serverSession: ServerSession | undefined; + let startPromise: Promise | undefined; + + const resolveSession = () => { + assert( + serverSession, + "Worker server has not been started. Call server.listen()." + ); + return serverSession; + }; + + const serverAuthHook: ServerAuthHook = async (config) => { + desiredAccountId ??= await requireAuth(config); + + return { + accountId: desiredAccountId, + apiToken: requireApiToken(), + }; + }; + + const teardownSession = async (session: ServerSession) => { + await Promise.all(session.devEnvs.map((devEnv) => devEnv.teardown())); + }; + + const waitForPrimaryReady = async (session: ServerSession) => { + return new Promise< + Awaited + >((resolve, reject) => { + const onError = (error: unknown) => { + session.primaryDevEnv.off("error", onError); + reject(error); + }; + + session.primaryDevEnv.once("error", onError); + void session.primaryDevEnv.proxy.ready.promise.then( + (ready) => { + session.primaryDevEnv.off("error", onError); + resolve(ready); + }, + (error: unknown) => { + session.primaryDevEnv.off("error", onError); + reject(error); + } + ); + }); + }; + + const waitForReloadComplete = (session: ServerSession) => { + return new Promise((resolve, reject) => { + const cleanup = () => { + session.primaryDevEnv.off("error", onError); + session.primaryDevEnv.off("reloadComplete", onReloadComplete); + }; + const onError = (error: unknown) => { + cleanup(); + reject(error); + }; + const onReloadComplete = () => { + cleanup(); + resolve(); + }; + + session.primaryDevEnv.once("error", onError); + session.primaryDevEnv.once("reloadComplete", onReloadComplete); + }); + }; + + const startServerSession = async () => { + const session = await createSession(currentOptions, serverAuthHook); + + try { + const ready = await waitForPrimaryReady(session); + serverSession = session; + maybePrintScheduledWorkerWarning(session, ready.url); + } catch (error) { + await teardownSession(session); + throw error; + } + }; + + const workerServer: WorkerServer = { + async listen() { + if (!serverSession) { + if (!startPromise) { + startPromise = startServerSession().finally(() => { + startPromise = undefined; + }); + } + + await startPromise; + } + + assert(serverSession, "Worker server has no active session."); + const ready = await serverSession.primaryDevEnv.proxy.ready.promise; + + return { + url: ready.url, + inspectorUrl: ready.inspectorUrl, + }; + }, + async fetch(input, init) { + const session = resolveSession(); + const miniflare = session.primaryDevEnv.proxy.proxyWorker; + assert( + miniflare, + "The proxy worker is not available yet. Did you call server.listen()?" + ); + + return miniflare.dispatchFetch( + await resolveFetchInput(input, session), + init + ); + }, + getWorker(name?: string) { + const getRuntimeMiniflare = async (session: ServerSession) => { + await session.primaryDevEnv.proxy.runtimeMessageMutex.drained(); + const miniflare = session.primaryDevEnv.runtimes[0].mf; + assert(miniflare, "Worker runtime is not available."); + return miniflare; + }; + + return { + async fetch(input, init) { + const session = resolveSession(); + const miniflare = await getRuntimeMiniflare(session); + const request = new Request( + await resolveFetchInput(input, session), + init + ); + const headers = new Headers(request.headers); + + headers.set("MF-Original-URL", request.url); + headers.set("MF-Disable-Pretty-Error", "true"); + + if (name !== undefined) { + headers.set("MF-Route-Override", name); + } + + return miniflare.dispatchFetch(request, { + headers, + }); + }, + async scheduled(scheduledOptions) { + const session = resolveSession(); + const miniflare = await getRuntimeMiniflare(session); + const url = new URL("http://localhost/cdn-cgi/handler/scheduled"); + if (scheduledOptions?.cron !== undefined) { + url.searchParams.set("cron", scheduledOptions.cron); + } + if (scheduledOptions?.scheduledTime !== undefined) { + url.searchParams.set( + "time", + String(scheduledOptions.scheduledTime.getTime()) + ); + } + const headers = new Headers(); + headers.set("MF-Original-URL", url.toString()); + headers.set("MF-Disable-Pretty-Error", "true"); + + if (name !== undefined) { + headers.set("MF-Route-Override", name); + } + const response = await miniflare.dispatchFetch(url, { + headers, + }); + const outcomeText = await response.text(); + const outcome: FetcherScheduledResult["outcome"] = + outcomeText === "ok" || outcomeText === "exception" + ? outcomeText + : "exception"; + + return { + outcome, + // FIXME: scheduled handler should include noRetry info in the response + noRetry: false, + }; + }, + }; + }, + async update(updateInput) { + currentOptions = + typeof updateInput === "function" + ? updateInput(currentOptions) + : updateInput; + desiredAccountId = currentOptions.accountId ?? desiredAccountId; + + if (serverSession) { + const nextInputs = resolveWorkerInputs(currentOptions, serverAuthHook); + + if (nextInputs.length !== serverSession.devEnvs.length) { + throw new Error( + `Updating the number of workers running in the server is not supported.` + ); + } + + await Promise.all([ + waitForReloadComplete(serverSession), + updateConfig(serverSession, nextInputs), + ]); + } + }, + async close() { + if (startPromise) { + await startPromise.catch(() => undefined); + startPromise = undefined; + } + if (serverSession) { + await teardownSession(serverSession); + serverSession = undefined; + } + }, + }; + + return workerServer; +} diff --git a/packages/wrangler/src/api/startDevWorker/BaseController.ts b/packages/wrangler/src/api/startDevWorker/BaseController.ts index 027f5e483d..bfd3b665c1 100644 --- a/packages/wrangler/src/api/startDevWorker/BaseController.ts +++ b/packages/wrangler/src/api/startDevWorker/BaseController.ts @@ -9,6 +9,7 @@ import type { ReloadCompleteEvent, ReloadStartEvent, } from "./events"; +import type { Miniflare } from "miniflare"; export type ControllerEvent = | ErrorEvent @@ -57,6 +58,11 @@ export abstract class RuntimeController extends Controller { abstract onBundleComplete(_: BundleCompleteEvent): void; abstract onPreviewTokenExpired(_: PreviewTokenExpiredEvent): void; + // ********************* + // Runtime Accessors + // ********************* + mf: Miniflare | undefined; + // ********************* // Event Dispatchers // ********************* diff --git a/packages/wrangler/src/api/startDevWorker/BundlerController.ts b/packages/wrangler/src/api/startDevWorker/BundlerController.ts index 1968c3cae2..6fa78bebdc 100644 --- a/packages/wrangler/src/api/startDevWorker/BundlerController.ts +++ b/packages/wrangler/src/api/startDevWorker/BundlerController.ts @@ -185,6 +185,7 @@ export class BundlerController extends Controller { async #startCustomBuild(config: StartDevWorkerOptions) { await this.#customBuildWatcher?.close(); + this.#customBuildWatcher = undefined; this.#customBuildAborter?.abort(); if (!config.build?.custom?.command) { @@ -196,6 +197,11 @@ export class BundlerController extends Controller { // This is always present if a custom command is provided, defaulting to `./src` assert(pathsToWatch, "config.build.custom.watch"); + if (config.dev.watch === false) { + await this.#runCustomBuild(config, String(pathsToWatch)); + return; + } + this.#customBuildWatcher = watch(pathsToWatch, { persistent: true, // The initial custom build is always done in getEntry() @@ -278,6 +284,7 @@ export class BundlerController extends Controller { config.compatibilityDate, config.compatibilityFlags ), + watch: config.dev.watch ?? true, defineNavigatorUserAgent: isNavigatorDefined( config.compatibilityDate, config.compatibilityFlags @@ -308,6 +315,7 @@ export class BundlerController extends Controller { #assetsWatcher?: ReturnType; async #ensureWatchingAssets(config: StartDevWorkerOptions) { await this.#assetsWatcher?.close(); + this.#assetsWatcher = undefined; const debouncedRefreshBundle = debounce(() => { if (this.#currentBundle) { @@ -315,7 +323,7 @@ export class BundlerController extends Controller { } }); - if (config.assets?.directory) { + if (config.dev.watch !== false && config.assets?.directory) { this.#assetsWatcher = watch(config.assets.directory, { persistent: true, ignoreInitial: true, diff --git a/packages/wrangler/src/api/startDevWorker/ConfigController.ts b/packages/wrangler/src/api/startDevWorker/ConfigController.ts index 13b3dbc74b..98f73d3b62 100644 --- a/packages/wrangler/src/api/startDevWorker/ConfigController.ts +++ b/packages/wrangler/src/api/startDevWorker/ConfigController.ts @@ -151,8 +151,10 @@ async function resolveDevConfig( input.dev?.origin?.secure ?? config.dev.upstream_protocol === "https", hostname: host ?? getInferredHost(routes, config.configPath), }, + watch: input.dev?.watch, liveReload: input.dev?.liveReload || false, testScheduled: input.dev?.testScheduled, + outboundService: input.dev?.outboundService, // absolute resolved path persist: localPersistencePath, registry: input.dev?.registry, @@ -590,8 +592,11 @@ export class ConfigController extends Controller { { useRedirectIfAvailable: true } ); - if (!getDisableConfigWatching()) { + if (!getDisableConfigWatching() && input.dev?.watch !== false) { await this.#ensureWatchingConfig(fileConfig.configPath); + } else { + await this.#configWatcher?.close(); + this.#configWatcher = undefined; } const { config: resolvedConfig, printCurrentBindings } = diff --git a/packages/wrangler/src/api/startDevWorker/LocalRuntimeController.ts b/packages/wrangler/src/api/startDevWorker/LocalRuntimeController.ts index dab00b886a..62e3264e75 100644 --- a/packages/wrangler/src/api/startDevWorker/LocalRuntimeController.ts +++ b/packages/wrangler/src/api/startDevWorker/LocalRuntimeController.ts @@ -192,6 +192,7 @@ export async function convertToConfigBundle( liveReload: event.config.dev?.liveReload ?? false, crons, queueConsumers, + outboundService: event.config.dev.outboundService, localProtocol: event.config.dev?.server?.secure ? "https" : "http", httpsCertPath: event.config.dev?.server?.httpsCertPath, httpsKeyPath: event.config.dev?.server?.httpsKeyPath, @@ -235,7 +236,6 @@ export class LocalRuntimeController extends RuntimeController { // 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(); - #mf?: Miniflare; #remoteProxySessionData: { session: RemoteProxySession; @@ -362,13 +362,13 @@ export class LocalRuntimeController extends RuntimeController { } ); options.liveReload = false; // TODO: set in buildMiniflareOptions once old code path is removed - if (this.#mf === undefined) { + if (this.mf === undefined) { logger.log(chalk.dim("⎔ Starting local server...")); - this.#mf = new Miniflare(options); + this.mf = new Miniflare(options); } else { logger.log(chalk.dim("⎔ Reloading local server...")); - await this.#mf.setOptions(options); + await this.mf.setOptions(options); logger.log(chalk.dim("⎔ Local server updated and ready")); } @@ -376,14 +376,14 @@ export class LocalRuntimeController extends RuntimeController { // calls to complete before resolving. To ensure we get the `url` and // `inspectorUrl` for this set of `options`, we protect `#mf` with a mutex, // so only one update can happen at a time. - const userWorkerUrl = await this.#mf.ready; + const userWorkerUrl = await this.mf.ready; // TODO: Miniflare should itself return undefined on // `getInspectorURL` when no inspector is in use // (currently the function just hangs) const userWorkerInspectorUrl = options.inspectorPort === undefined ? undefined - : await this.#mf.getInspectorURL(); + : await this.mf.getInspectorURL(); // If we received a new `bundleComplete` event before we were able to // dispatch a `reloadComplete` for this bundle, ignore this bundle. if (id !== this.#currentBundleId) { @@ -482,12 +482,12 @@ export class LocalRuntimeController extends RuntimeController { process.off("exit", this.cleanupContainers); this.cleanupContainers(); - if (this.#mf) { + if (this.mf) { logger.log(chalk.dim("⎔ Shutting down local server...")); } - await this.#mf?.dispose(); - this.#mf = undefined; + await this.mf?.dispose(); + this.mf = undefined; if (this.#remoteProxySessionData) { logger.log(chalk.dim("⎔ Shutting down remote connection...")); diff --git a/packages/wrangler/src/api/startDevWorker/MultiworkerRuntimeController.ts b/packages/wrangler/src/api/startDevWorker/MultiworkerRuntimeController.ts index 9837a63d94..9f2388531e 100644 --- a/packages/wrangler/src/api/startDevWorker/MultiworkerRuntimeController.ts +++ b/packages/wrangler/src/api/startDevWorker/MultiworkerRuntimeController.ts @@ -68,7 +68,6 @@ export class MultiworkerRuntimeController extends LocalRuntimeController { // 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(); - #mf?: Miniflare; #options = new Map(); @@ -200,13 +199,13 @@ export class MultiworkerRuntimeController extends LocalRuntimeController { if (this.#canStartMiniflare()) { const mergedMfOptions = ensureMatchingSql(this.#mergedMfOptions()); - if (this.#mf === undefined) { + if (this.mf === undefined) { logger.log(chalk.dim("⎔ Starting local server...")); - this.#mf = new Miniflare(mergedMfOptions); + this.mf = new Miniflare(mergedMfOptions); } else { logger.log(chalk.dim("⎔ Reloading local server...")); - await this.#mf.setOptions(mergedMfOptions); + await this.mf.setOptions(mergedMfOptions); logger.log(chalk.dim("⎔ Local server updated and ready")); } @@ -215,8 +214,11 @@ export class MultiworkerRuntimeController extends LocalRuntimeController { // calls to complete before resolving. To ensure we get the `url` and // `inspectorUrl` for this set of `options`, we protect `#mf` with a mutex, // so only one update can happen at a time. - const userWorkerUrl = await this.#mf.ready; - const userWorkerInspectorUrl = await this.#mf.getInspectorURL(); + const userWorkerUrl = await this.mf.ready; + const userWorkerInspectorUrl = + mergedMfOptions.inspectorPort !== undefined + ? await this.mf.getInspectorURL() + : null; // If we received a new `bundleComplete` event before we were able to // dispatch a `reloadComplete` for this bundle, ignore this bundle. if (id !== this.#currentBundleId) { @@ -233,12 +235,14 @@ export class MultiworkerRuntimeController extends LocalRuntimeController { hostname: userWorkerUrl.hostname, port: userWorkerUrl.port, }, - userWorkerInspectorUrl: { - protocol: userWorkerInspectorUrl.protocol, - hostname: userWorkerInspectorUrl.hostname, - port: userWorkerInspectorUrl.port, - pathname: `/core:user:${data.config.name}`, - }, + userWorkerInspectorUrl: userWorkerInspectorUrl + ? { + protocol: userWorkerInspectorUrl.protocol, + hostname: userWorkerInspectorUrl.hostname, + port: userWorkerInspectorUrl.port, + pathname: `/core:user:${data.config.name}`, + } + : undefined, userWorkerInnerUrlOverrides: getUserWorkerInnerUrlOverrides( data.config ), @@ -301,12 +305,12 @@ export class MultiworkerRuntimeController extends LocalRuntimeController { #teardown = async (): Promise => { logger.debug("MultiworkerRuntimeController teardown beginning..."); - if (this.#mf) { + if (this.mf) { logger.log(chalk.dim("⎔ Shutting down local server...")); } - await this.#mf?.dispose(); - this.#mf = undefined; + await this.mf?.dispose(); + this.mf = undefined; if (this.#remoteProxySessionsData.size > 0) { logger.log(chalk.dim("⎔ Shutting down remote connections...")); diff --git a/packages/wrangler/src/cli.ts b/packages/wrangler/src/cli.ts index fd5d9ebf9b..a323d61fc5 100644 --- a/packages/wrangler/src/cli.ts +++ b/packages/wrangler/src/cli.ts @@ -15,6 +15,7 @@ import { startRemoteProxySession, startWorker, unstable_dev, + createServer, experimental_generateTypes, unstable_getDevCompatibilityDate, unstable_getDurableObjectClassNameToUseSQLiteMap, @@ -40,6 +41,7 @@ import type { Unstable_MiniflareWorkerOptions, Unstable_RawConfig, Unstable_RawEnvironment, + WorkerServer, } from "./api"; import type { Logger } from "./logger"; import type { Request, Response } from "miniflare"; @@ -67,6 +69,7 @@ export { unstable_pages, DevEnv as unstable_DevEnv, startWorker as unstable_startWorker, + createServer, unstable_getVarsForDev, unstable_readConfig, experimental_generateTypes, @@ -89,6 +92,7 @@ export type { Unstable_MiniflareWorkerOptions, Experimental_GenerateTypesOptions, Experimental_GenerateTypesResult, + WorkerServer, }; export { printBindings as unstable_printBindings } from "./utils/print-bindings"; diff --git a/packages/wrangler/src/dev.ts b/packages/wrangler/src/dev.ts index 537c0cb2e9..8f329cf377 100644 --- a/packages/wrangler/src/dev.ts +++ b/packages/wrangler/src/dev.ts @@ -525,13 +525,21 @@ export function getBindings( // getVarsForDev returns typed bindings: config vars are plain_text/json, // while .dev.vars/.env vars are secret_text. // When secrets is defined, only declared secret keys are loaded from files. + const secrets = configParam.secrets + ? { + ...configParam.secrets, + required: configParam.secrets?.required?.filter( + (secret) => inputBindings?.[secret]?.type !== "secret_text" + ), + } + : undefined; const vars = getVarsForDev( configParam.userConfigPath, envFiles, configParam.vars, env, false, - configParam.secrets + secrets ); for (const [name, binding] of Object.entries(vars)) { // Only override plain_text/json/secret_text vars, not other binding types like kv_namespace diff --git a/packages/wrangler/src/dev/miniflare/index.ts b/packages/wrangler/src/dev/miniflare/index.ts index 1818dd3c3d..fe224d5651 100644 --- a/packages/wrangler/src/dev/miniflare/index.ts +++ b/packages/wrangler/src/dev/miniflare/index.ts @@ -37,6 +37,7 @@ import type { CfWorkflow, Config, ContainerEngine, + ServiceFetch, } from "@cloudflare/workers-utils"; import type { DOContainerOptions, @@ -92,6 +93,7 @@ export interface ConfigBundle { localUpstream: string | undefined; upstreamProtocol: "http" | "https"; inspect: boolean; + outboundService: ServiceFetch | undefined; tails: Config["tail_consumers"] | undefined; streamingTails: Config["streaming_tail_consumers"] | undefined; testScheduled: boolean; @@ -1088,6 +1090,7 @@ export async function buildMiniflareOptions( ...bindingOptions, ...sitesOptions, ...assetOptions, + outboundService: config.outboundService, containerEngine: config.containerEngine, zone: config.zone, }, diff --git a/packages/wrangler/src/dev/use-esbuild.ts b/packages/wrangler/src/dev/use-esbuild.ts index 7df6298ed9..ca6c37a85e 100644 --- a/packages/wrangler/src/dev/use-esbuild.ts +++ b/packages/wrangler/src/dev/use-esbuild.ts @@ -1,7 +1,7 @@ import assert from "node:assert"; import { readFileSync, realpathSync } from "node:fs"; import path from "node:path"; -import { watch } from "chokidar"; +import { watch as watchPaths } from "chokidar"; import { bundleWorker } from "../deployment-bundle/bundle"; import { getBundleType } from "../deployment-bundle/bundle-type"; import { dedupeModulesByName } from "../deployment-bundle/dedupe-modules"; @@ -54,6 +54,7 @@ export function runBuild( local, targetConsumer, testScheduled, + watch, projectRoot, onStart, defineNavigatorUserAgent, @@ -82,6 +83,7 @@ export function runBuild( local: boolean; targetConsumer: "dev" | "deploy"; testScheduled: boolean; + watch: boolean; projectRoot: string | undefined; onStart: () => void; defineNavigatorUserAgent: boolean; @@ -157,7 +159,7 @@ export function runBuild( additionalModules: newAdditionalModules, jsxFactory, jsxFragment, - watch: true, + watch, tsconfig, minify, keepNames, @@ -194,9 +196,9 @@ export function runBuild( // if "noBundle" is true, then we need to manually watch all modules and // trigger "builds" when any change - if (noBundle) { + if (noBundle && watch) { const watching = [path.resolve(entry.moduleRoot)]; - const watcher = watch(watching, { + const watcher = watchPaths(watching, { persistent: true, // Ignore VCS dirs, dependencies, and the .wrangler dir (which // contains miniflare state/cache files written by workerd at diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 81e7274594..783dfc4e5d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -229,6 +229,39 @@ importers: specifier: workspace:* version: link:../../packages/wrangler + fixtures/create-server: + devDependencies: + '@cloudflare/vite-plugin': + specifier: workspace:* + version: link:../../packages/vite-plugin-cloudflare + '@cloudflare/workers-tsconfig': + specifier: workspace:* + version: link:../../packages/workers-tsconfig + '@cloudflare/workers-types': + specifier: catalog:default + version: 4.20260521.1 + '@fixture/shared': + specifier: workspace:* + version: link:../shared + '@types/node': + specifier: ^22.10.1 + version: 22.15.17 + msw: + specifier: catalog:default + version: 2.12.4(@types/node@22.15.17)(typescript@5.8.3) + typescript: + specifier: catalog:default + version: 5.8.3 + vite: + specifier: catalog:default + version: 8.0.13(@types/node@22.15.17)(esbuild@0.27.3)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.1) + vitest: + specifier: catalog:default + version: 4.1.0(@opentelemetry/api@1.9.1)(@types/node@22.15.17)(@vitest/ui@4.1.0)(msw@2.12.4(@types/node@22.15.17)(typescript@5.8.3))(vite@8.0.13(@types/node@22.15.17)(esbuild@0.27.3)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.1)) + wrangler: + specifier: workspace:* + version: link:../../packages/wrangler + fixtures/d1-read-replication-app: devDependencies: '@cloudflare/workers-tsconfig': @@ -26319,11 +26352,11 @@ snapshots: magic-string: 0.30.21 obug: 2.1.1 pathe: 2.0.3 - picomatch: 4.0.3 + picomatch: 4.0.4 std-env: 4.0.0 tinybench: 2.9.0 tinyexec: 1.0.2 - tinyglobby: 0.2.15 + tinyglobby: 0.2.16 tinyrainbow: 3.0.3 vite: 8.0.13(@types/node@22.15.17)(esbuild@0.23.1)(jiti@2.6.1)(tsx@3.12.10)(yaml@2.8.1) why-is-node-running: 2.3.0 @@ -26348,11 +26381,11 @@ snapshots: magic-string: 0.30.21 obug: 2.1.1 pathe: 2.0.3 - picomatch: 4.0.3 + picomatch: 4.0.4 std-env: 4.0.0 tinybench: 2.9.0 tinyexec: 1.0.2 - tinyglobby: 0.2.15 + tinyglobby: 0.2.16 tinyrainbow: 3.0.3 vite: 8.0.13(@types/node@22.15.17)(esbuild@0.27.3)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.1) why-is-node-running: 2.3.0 @@ -26377,11 +26410,11 @@ snapshots: magic-string: 0.30.21 obug: 2.1.1 pathe: 2.0.3 - picomatch: 4.0.3 + picomatch: 4.0.4 std-env: 4.0.0 tinybench: 2.9.0 tinyexec: 1.0.2 - tinyglobby: 0.2.15 + tinyglobby: 0.2.16 tinyrainbow: 3.0.3 vite: 8.0.13(@types/node@22.15.17)(esbuild@0.27.3)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.1) why-is-node-running: 2.3.0