From f2a3445c9d556ba9465aa4a90393d5caaca1477c Mon Sep 17 00:00:00 2001 From: Edmund Hung Date: Wed, 20 May 2026 15:03:07 +0100 Subject: [PATCH 01/20] feat(wrangler): initial implementation --- packages/wrangler/src/api/index.ts | 9 + packages/wrangler/src/api/server.ts | 442 ++++++++++++++++++ .../src/api/startDevWorker/BaseController.ts | 6 + .../api/startDevWorker/ConfigController.ts | 1 + .../startDevWorker/LocalRuntimeController.ts | 18 +- .../MultiworkerRuntimeController.ts | 34 +- packages/wrangler/src/cli.ts | 2 + packages/wrangler/src/dev/miniflare/index.ts | 3 + 8 files changed, 491 insertions(+), 24 deletions(-) create mode 100644 packages/wrangler/src/api/server.ts 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..6a81bcab9c --- /dev/null +++ b/packages/wrangler/src/api/server.ts @@ -0,0 +1,442 @@ +import assert from "node:assert"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { Headers, Request } from "miniflare"; +import { logger } from "../logger"; +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 } from "@cloudflare/workers-utils"; +import type { DispatchFetch } from "miniflare"; + +export type WorkerInput = + | { + root?: string; + configPath: string | URL; + env?: string; + } + | { + root?: string; + config: Config; + }; + +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; + 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 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 dev: StartDevWorkerInput["dev"] = { + auth, + 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, + }; + const root = input.root ?? options.root ?? cwd; + + if ("config" in input) { + const config = input.config; + + return { + // FIXME: to avoid dev env from auto discovering a config file and merging it with the inline config + config: "", + name: config.name, + entrypoint: config.main ? resolvePath(root, config.main) : undefined, + compatibilityDate: config.compatibility_date, + compatibilityFlags: config.compatibility_flags, + complianceRegion: config.compliance_region, + bindings: convertConfigToBindings(config, { usePreviewIds: true }), + migrations: config.migrations, + containers: config.containers, + triggers: config.triggers.crons?.map((cron) => ({ + type: "cron", + cron, + })), + tailConsumers: config.tail_consumers, + streamingTailConsumers: config.streaming_tail_consumers, + sendMetrics: config.send_metrics, + assets: config.assets?.directory, + dev, + }; + } + + if ("configPath" in input) { + return { + config: resolvePath(root, input.configPath), + env: input.env, + dev, + }; + } + + throw new Error( + `Invalid worker input at index ${index}. Expected an object with either a "config" property or a "configPath" property.` + ); + }); +} + +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 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(info, 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(info, init); + }, + getWorker(name?: string) { + const getRuntimeMiniflare = async () => { + const session = resolveSession(); + 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(requestInput, requestInit) { + const miniflare = await getRuntimeMiniflare(); + const request = new Request(requestInput, requestInit); + 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 miniflare = await getRuntimeMiniflare(); + 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 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/ConfigController.ts b/packages/wrangler/src/api/startDevWorker/ConfigController.ts index 13b3dbc74b..60c4003e93 100644 --- a/packages/wrangler/src/api/startDevWorker/ConfigController.ts +++ b/packages/wrangler/src/api/startDevWorker/ConfigController.ts @@ -153,6 +153,7 @@ async function resolveDevConfig( }, liveReload: input.dev?.liveReload || false, testScheduled: input.dev?.testScheduled, + outboundService: input.dev?.outboundService, // absolute resolved path persist: localPersistencePath, registry: input.dev?.registry, 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 d7e98f1722..1ab53a4128 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, @@ -67,6 +68,7 @@ export { unstable_pages, DevEnv as unstable_DevEnv, startWorker as unstable_startWorker, + createServer, unstable_getVarsForDev, unstable_readConfig, experimental_generateTypes, diff --git a/packages/wrangler/src/dev/miniflare/index.ts b/packages/wrangler/src/dev/miniflare/index.ts index 32e0da7cf2..6ff7aa93f9 100644 --- a/packages/wrangler/src/dev/miniflare/index.ts +++ b/packages/wrangler/src/dev/miniflare/index.ts @@ -37,6 +37,7 @@ import type { Config, ContainerEngine, LegacyAssetPaths, + 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; @@ -1123,6 +1125,7 @@ export async function buildMiniflareOptions( ...bindingOptions, ...sitesOptions, ...assetOptions, + outboundService: config.outboundService, containerEngine: config.containerEngine, zone: config.zone, }, From 44077eafcda774124cc91ce723f4773fc781e82c Mon Sep 17 00:00:00 2001 From: Edmund Hung Date: Wed, 20 May 2026 15:03:24 +0100 Subject: [PATCH 02/20] chore: add create-server fixture --- fixtures/create-server/package.json | 27 +++++++ fixtures/create-server/src/auxiliary.ts | 18 +++++ fixtures/create-server/src/primary.ts | 18 +++++ fixtures/create-server/tests/tsconfig.json | 9 +++ .../create-server/tests/vite-project.test.ts | 70 +++++++++++++++++++ .../tests/wrangler-project.test.ts | 70 +++++++++++++++++++ fixtures/create-server/tsconfig.json | 17 +++++ fixtures/create-server/turbo.json | 9 +++ fixtures/create-server/vite.config.ts | 13 ++++ fixtures/create-server/vitest.config.ts | 9 +++ .../create-server/wrangler.auxiliary.jsonc | 5 ++ fixtures/create-server/wrangler.primary.jsonc | 5 ++ pnpm-lock.yaml | 33 +++++++++ 13 files changed, 303 insertions(+) create mode 100644 fixtures/create-server/package.json create mode 100644 fixtures/create-server/src/auxiliary.ts create mode 100644 fixtures/create-server/src/primary.ts create mode 100644 fixtures/create-server/tests/tsconfig.json create mode 100644 fixtures/create-server/tests/vite-project.test.ts create mode 100644 fixtures/create-server/tests/wrangler-project.test.ts create mode 100644 fixtures/create-server/tsconfig.json create mode 100644 fixtures/create-server/turbo.json create mode 100644 fixtures/create-server/vite.config.ts create mode 100644 fixtures/create-server/vitest.config.ts create mode 100644 fixtures/create-server/wrangler.auxiliary.jsonc create mode 100644 fixtures/create-server/wrangler.primary.jsonc 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/pnpm-lock.yaml b/pnpm-lock.yaml index 2ce01f65f0..8a86966949 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -232,6 +232,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.20260526.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': From 9f623c7573bbf2af8542101e5aca30aaa4663797 Mon Sep 17 00:00:00 2001 From: Edmund Hung Date: Wed, 20 May 2026 18:12:30 +0100 Subject: [PATCH 03/20] fix: disable watch support --- packages/wrangler/e2e/create-server.test.ts | 113 ++++++++++++++++++ .../api/startDevWorker/BundlerController.ts | 10 +- .../api/startDevWorker/ConfigController.ts | 6 +- packages/wrangler/src/dev/use-esbuild.ts | 10 +- 4 files changed, 133 insertions(+), 6 deletions(-) create mode 100644 packages/wrangler/e2e/create-server.test.ts diff --git a/packages/wrangler/e2e/create-server.test.ts b/packages/wrangler/e2e/create-server.test.ts new file mode 100644 index 0000000000..5d2f2b772f --- /dev/null +++ b/packages/wrangler/e2e/create-server.test.ts @@ -0,0 +1,113 @@ +import { setTimeout } from "node:timers/promises"; +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(); + }); + + describe("watch", () => { + it("does not reload on source changes by default", async ({ expect }) => { + await helper.seed({ + "wrangler.jsonc": dedent` + { + "name": "create-server-watch-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("http://dummy"); + 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("http://dummy"); + await expect(response2.text()).resolves.toBe("Hello World"); + }); + + it(`reloads on source changes when "watch" is set to true`, async ({ + expect, + }) => { + await helper.seed({ + "wrangler.jsonc": dedent` + { + "name": "create-server-watch-test", + "main": "src/index.ts", + "compatibility_date": "2024-09-23" + } + `, + "src/index.ts": dedent` + export default { + fetch() { + return new Response("Hello World"); + } + }; + `, + }); + + const server = createServer({ + root: helper.tmpPath, + workers: [{ configPath: "./wrangler.jsonc" }], + watch: true, + }); + onTestFinished(server.close); + + await server.listen(); + + let response = await server.fetch("http://dummy"); + await expect(response.text()).resolves.toBe("Hello World"); + + await helper.seed({ + "src/index.ts": dedent` + export default { + fetch() { + return new Response("Greeting"); + } + }; + `, + }); + + await vi.waitFor(async () => { + response = await server.fetch("http://dummy"); + expect(await response.text()).toBe("Greeting"); + }); + }); + }); +}); diff --git a/packages/wrangler/src/api/startDevWorker/BundlerController.ts b/packages/wrangler/src/api/startDevWorker/BundlerController.ts index e124e57c1e..890cecb7a4 100644 --- a/packages/wrangler/src/api/startDevWorker/BundlerController.ts +++ b/packages/wrangler/src/api/startDevWorker/BundlerController.ts @@ -184,6 +184,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) { @@ -195,6 +196,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() @@ -277,6 +283,7 @@ export class BundlerController extends Controller { config.compatibilityDate, config.compatibilityFlags ), + watch: config.dev.watch ?? true, defineNavigatorUserAgent: isNavigatorDefined( config.compatibilityDate, config.compatibilityFlags @@ -307,6 +314,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) { @@ -314,7 +322,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 60c4003e93..98f73d3b62 100644 --- a/packages/wrangler/src/api/startDevWorker/ConfigController.ts +++ b/packages/wrangler/src/api/startDevWorker/ConfigController.ts @@ -151,6 +151,7 @@ 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, @@ -591,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/dev/use-esbuild.ts b/packages/wrangler/src/dev/use-esbuild.ts index 5b40945ef3..cf78fcd177 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"; @@ -58,6 +58,7 @@ export function runBuild( local, targetConsumer, testScheduled, + watch, projectRoot, onStart, defineNavigatorUserAgent, @@ -86,6 +87,7 @@ export function runBuild( local: boolean; targetConsumer: "dev" | "deploy"; testScheduled: boolean; + watch: boolean; projectRoot: string | undefined; onStart: () => void; defineNavigatorUserAgent: boolean; @@ -161,7 +163,7 @@ export function runBuild( additionalModules: newAdditionalModules, jsxFactory, jsxFragment, - watch: true, + watch, tsconfig, minify, keepNames, @@ -198,9 +200,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 From 3fb8fdd1b49644a2e77a5cfce24e5fda6acb2301 Mon Sep 17 00:00:00 2001 From: Edmund Hung Date: Wed, 20 May 2026 20:39:32 +0100 Subject: [PATCH 04/20] fix: allow fetch with path only --- packages/wrangler/e2e/create-server.test.ts | 253 +++++++++++++++----- packages/wrangler/src/api/server.ts | 73 +++++- 2 files changed, 253 insertions(+), 73 deletions(-) diff --git a/packages/wrangler/e2e/create-server.test.ts b/packages/wrangler/e2e/create-server.test.ts index 5d2f2b772f..9b5bf6f791 100644 --- a/packages/wrangler/e2e/create-server.test.ts +++ b/packages/wrangler/e2e/create-server.test.ts @@ -1,3 +1,4 @@ +import path from "node:path"; import { setTimeout } from "node:timers/promises"; import dedent from "ts-dedent"; import { beforeEach, describe, it, onTestFinished, vi } from "vitest"; @@ -15,99 +16,225 @@ describe("createServer", { sequential: true }, () => { helper = new WranglerE2ETestHelper(); }); - describe("watch", () => { - it("does not reload on source changes by default", async ({ expect }) => { - await helper.seed({ - "wrangler.jsonc": dedent` + it("starts with default server options", async ({ expect }) => { + await helper.seed({ + "wrangler.jsonc": dedent` { - "name": "create-server-watch-test", + "name": "hello-worker", "main": "src/index.ts", "compatibility_date": "2026-05-20" } `, - "src/index.ts": dedent` + "src/index.ts": dedent` export default { - fetch() { + fetch(request) { + if (new URL(request.url).pathname === "/url") { + return new Response(request.url); + } return new Response("Hello World"); } }; `, - }); + }); - const server = createServer({ - root: helper.tmpPath, - workers: [{ configPath: "./wrangler.jsonc" }], - }); - onTestFinished(server.close); + const server = createServer({ + workers: [ + { configPath: path.resolve(helper.tmpPath, "./wrangler.jsonc") }, + ], + }); + onTestFinished(server.close); - await server.listen(); + const { url, inspectorUrl } = await server.listen(); - const response1 = await server.fetch("http://dummy"); - await expect(response1.text()).resolves.toBe("Hello World"); + expect(url.protocol).toBe("http:"); + expect(url.hostname).toBe("127.0.0.1"); + expect(Number(url.port)).toBeGreaterThan(0); + expect(inspectorUrl).toBeUndefined(); - await helper.seed({ - "src/index.ts": dedent` - export default { - fetch() { - return new Response("Greeting"); - } - }; - `, - }); + const response1 = await fetch(url); + await expect(response1.text()).resolves.toBe("Hello World"); - // Wait a moment to ensure that if the server were going to reload, it would have done so by now - await setTimeout(1000); + 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 response2 = await server.fetch("http://dummy"); - await expect(response2.text()).resolves.toBe("Hello World"); + 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(`reloads on source changes when "watch" is set to true`, async ({ - expect, - }) => { - await helper.seed({ - "wrangler.jsonc": dedent` - { - "name": "create-server-watch-test", - "main": "src/index.ts", - "compatibility_date": "2024-09-23" + 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"); } - `, - "src/index.ts": dedent` - export default { - fetch() { - return new Response("Hello World"); - } - }; - `, - }); + }; + `, + }); + + 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("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" }], - watch: true, - }); - onTestFinished(server.close); + const server = createServer({ + root: helper.tmpPath, + workers: [{ configPath: "./wrangler.jsonc" }], + }); + onTestFinished(server.close); - await server.listen(); + await server.listen(); - let response = await server.fetch("http://dummy"); - await expect(response.text()).resolves.toBe("Hello World"); + const response1 = await server.fetch("/"); + await expect(response1.text()).resolves.toBe("Hello World"); - await helper.seed({ - "src/index.ts": dedent` + 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 () => { - response = await server.fetch("http://dummy"); - expect(await response.text()).toBe("Greeting"); - }); + await vi.waitFor(async () => { + const response4 = await server.fetch("/"); + expect(await response4.text()).toBe("Bonjour"); }); }); }); diff --git a/packages/wrangler/src/api/server.ts b/packages/wrangler/src/api/server.ts index 6a81bcab9c..3c4516cdd5 100644 --- a/packages/wrangler/src/api/server.ts +++ b/packages/wrangler/src/api/server.ts @@ -19,7 +19,7 @@ import type { FetcherScheduledResult, } from "@cloudflare/workers-types/experimental"; import type { Config } from "@cloudflare/workers-utils"; -import type { DispatchFetch } from "miniflare"; +import type { DispatchFetch, RequestInfo } from "miniflare"; export type WorkerInput = | { @@ -91,6 +91,29 @@ function resolvePath(basePath: string, maybePath: string | URL): string { : path.resolve(basePath, maybePath); } +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 @@ -302,6 +325,26 @@ export function createServer(options: ServerOptions): WorkerServer { }); }; + 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); @@ -335,7 +378,7 @@ export function createServer(options: ServerOptions): WorkerServer { inspectorUrl: ready.inspectorUrl, }; }, - async fetch(info, init) { + async fetch(input, init) { const session = resolveSession(); const miniflare = session.primaryDevEnv.proxy.proxyWorker; assert( @@ -343,11 +386,13 @@ export function createServer(options: ServerOptions): WorkerServer { "The proxy worker is not available yet. Did you call server.listen()?" ); - return miniflare.dispatchFetch(info, init); + return miniflare.dispatchFetch( + await resolveFetchInput(input, session), + init + ); }, getWorker(name?: string) { - const getRuntimeMiniflare = async () => { - const session = resolveSession(); + 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."); @@ -355,9 +400,13 @@ export function createServer(options: ServerOptions): WorkerServer { }; return { - async fetch(requestInput, requestInit) { - const miniflare = await getRuntimeMiniflare(); - const request = new Request(requestInput, requestInit); + 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); @@ -372,7 +421,8 @@ export function createServer(options: ServerOptions): WorkerServer { }); }, async scheduled(scheduledOptions) { - const miniflare = await getRuntimeMiniflare(); + 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); @@ -423,7 +473,10 @@ export function createServer(options: ServerOptions): WorkerServer { ); } - await updateConfig(serverSession, nextInputs); + await Promise.all([ + waitForReloadComplete(serverSession), + updateConfig(serverSession, nextInputs), + ]); } }, async close() { From db2581c901005810b4b53fe5887649262e7231f4 Mon Sep 17 00:00:00 2001 From: Edmund Hung Date: Wed, 20 May 2026 21:39:45 +0100 Subject: [PATCH 05/20] fix: refine config type --- packages/wrangler/e2e/create-server.test.ts | 203 ++++++++++++++++++++ packages/wrangler/src/api/server.ts | 7 +- 2 files changed, 206 insertions(+), 4 deletions(-) diff --git a/packages/wrangler/e2e/create-server.test.ts b/packages/wrangler/e2e/create-server.test.ts index 9b5bf6f791..729cddd53f 100644 --- a/packages/wrangler/e2e/create-server.test.ts +++ b/packages/wrangler/e2e/create-server.test.ts @@ -1,5 +1,7 @@ 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 { @@ -172,6 +174,207 @@ describe("createServer", { sequential: true }, () => { ); }); + 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("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` diff --git a/packages/wrangler/src/api/server.ts b/packages/wrangler/src/api/server.ts index 3c4516cdd5..c95b917b0a 100644 --- a/packages/wrangler/src/api/server.ts +++ b/packages/wrangler/src/api/server.ts @@ -18,7 +18,7 @@ import type { FetcherScheduledOptions, FetcherScheduledResult, } from "@cloudflare/workers-types/experimental"; -import type { Config } from "@cloudflare/workers-utils"; +import type { RawEnvironment } from "@cloudflare/workers-utils"; import type { DispatchFetch, RequestInfo } from "miniflare"; export type WorkerInput = @@ -29,7 +29,7 @@ export type WorkerInput = } | { root?: string; - config: Config; + config: RawEnvironment; }; type DevServerOptions = Exclude< @@ -158,13 +158,12 @@ function resolveWorkerInputs( bindings: convertConfigToBindings(config, { usePreviewIds: true }), migrations: config.migrations, containers: config.containers, - triggers: config.triggers.crons?.map((cron) => ({ + triggers: config.triggers?.crons?.map((cron) => ({ type: "cron", cron, })), tailConsumers: config.tail_consumers, streamingTailConsumers: config.streaming_tail_consumers, - sendMetrics: config.send_metrics, assets: config.assets?.directory, dev, }; From ff77395475bacd1a3eab0dba195af1a6de24799d Mon Sep 17 00:00:00 2001 From: Edmund Hung Date: Wed, 20 May 2026 21:58:25 +0100 Subject: [PATCH 06/20] chore: migrate fixtures to createServer --- .../additional-modules/test/index.test.ts | 60 +++++++++---------- fixtures/no-bundle-import/src/index.test.ts | 40 ++++++------- fixtures/ratelimit-app/tests/index.test.ts | 27 ++++----- .../start-worker-node-test/src/index.test.js | 20 +++---- .../tests/index.test.ts | 17 +++--- packages/wrangler/src/cli.ts | 2 + pnpm-lock.yaml | 2 +- 7 files changed, 79 insertions(+), 89 deletions(-) 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/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/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/packages/wrangler/src/cli.ts b/packages/wrangler/src/cli.ts index 1ab53a4128..7e99cb33a1 100644 --- a/packages/wrangler/src/cli.ts +++ b/packages/wrangler/src/cli.ts @@ -41,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"; @@ -91,6 +92,7 @@ export type { Unstable_MiniflareWorkerOptions, Experimental_GenerateTypesOptions, Experimental_GenerateTypesResult, + WorkerServer, }; export { printBindings as unstable_printBindings } from "./utils/print-bindings"; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8a86966949..0f0ae04f42 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -242,7 +242,7 @@ importers: version: link:../../packages/workers-tsconfig '@cloudflare/workers-types': specifier: catalog:default - version: 4.20260526.1 + version: 4.20260528.1 '@fixture/shared': specifier: workspace:* version: link:../shared From 424f44d724ccfbf8cd3df0a88c2e2e7d0554b3fa Mon Sep 17 00:00:00 2001 From: Edmund Hung Date: Wed, 20 May 2026 22:47:04 +0100 Subject: [PATCH 07/20] chore: migrate startWorker remote bindings test --- packages/wrangler/e2e/create-server.test.ts | 66 +++++ .../start-worker-remote-bindings.test.ts | 239 +++++++++--------- packages/wrangler/src/api/server.ts | 2 + 3 files changed, 184 insertions(+), 123 deletions(-) diff --git a/packages/wrangler/e2e/create-server.test.ts b/packages/wrangler/e2e/create-server.test.ts index 729cddd53f..953f614eb9 100644 --- a/packages/wrangler/e2e/create-server.test.ts +++ b/packages/wrangler/e2e/create-server.test.ts @@ -298,6 +298,72 @@ describe("createServer", { sequential: true }, () => { await expect(resetResponse.text()).resolves.toBe("missing"); }); + it("uses local bindings when remote bindings are not allowed", async ({ + expect, + }) => { + await helper.seed({ + "wrangler.jsonc": dedent` + { + "name": "config-local-bindings-worker", + "main": "src/config.ts", + "compatibility_date": "2026-05-20", + "kv_namespaces": [ + { "binding": "STORE", "id": "config-test-store", "remote": true } + ] + } + `, + "src/config.ts": dedent` + export default { + async fetch(request, env) { + await env.STORE.put("key", "config-value"); + return new Response((await env.STORE.get("key")) ?? "missing"); + } + }; + `, + "src/inline.ts": dedent` + export default { + async fetch(request, env) { + await env.STORE.put("key", "inline-value"); + return new Response((await env.STORE.get("key")) ?? "missing"); + } + }; + `, + }); + + const server = createServer({ + root: helper.tmpPath, + workers: [ + { configPath: "./wrangler.jsonc" }, + { + config: { + name: "inline-local-bindings-worker", + main: "src/inline.ts", + compatibility_date: "2026-05-20", + kv_namespaces: [ + { + binding: "STORE", + id: "inline-test-store", + remote: true, + }, + ], + }, + }, + ], + allowRemoteBindings: false, + }); + onTestFinished(server.close); + + await server.listen(); + + const configResponse = await server.fetch("/"); + await expect(configResponse.text()).resolves.toBe("config-value"); + + const inlineResponse = await server + .getWorker("inline-local-bindings-worker") + .fetch("/"); + await expect(inlineResponse.text()).resolves.toBe("inline-value"); + }); + it("exposes the inspector URL when enabled", async ({ expect }) => { await helper.seed({ "wrangler.jsonc": dedent` 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/api/server.ts b/packages/wrangler/src/api/server.ts index c95b917b0a..6ceb018f9a 100644 --- a/packages/wrangler/src/api/server.ts +++ b/packages/wrangler/src/api/server.ts @@ -51,6 +51,7 @@ export type ServerOptions = { watch?: boolean | undefined; logLevel?: LogLevel | undefined; accountId?: string | undefined; + allowRemoteBindings?: boolean | undefined; outboundService?: ServiceFetch | undefined; }; @@ -129,6 +130,7 @@ function resolveWorkerInputs( const isMultiworker = list.length > 1; const dev: StartDevWorkerInput["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, From e856997612e8017731cee498f210c37d58119e60 Mon Sep 17 00:00:00 2001 From: Edmund Hung Date: Wed, 20 May 2026 23:29:21 +0100 Subject: [PATCH 08/20] chore: migrate middleware test --- .../wrangler/src/__tests__/middleware.test.ts | 559 ++++++++++-------- 1 file changed, 325 insertions(+), 234 deletions(-) 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!"); }); }); }); From 13409998e3489b55f973df29263877d223ffbf0c Mon Sep 17 00:00:00 2001 From: Edmund Hung Date: Thu, 21 May 2026 11:06:37 +0100 Subject: [PATCH 09/20] chore: migrate more fixtures --- .../tests/index.test.ts | 28 +++++------ .../tests/index.test.ts | 21 ++++---- .../import-example/tests/index.test.ts | 19 ++++---- .../packages/import-example/tsconfig.json | 2 +- fixtures/import-npm/turbo.json | 3 +- .../import-wasm-example/tests/index.test.ts | 19 ++++---- fixtures/import-wasm-example/tsconfig.json | 2 +- fixtures/node-env/tests/node-env.test.ts | 28 ++++++----- fixtures/wildcard-modules/test/index.test.ts | 48 +++++++++++-------- .../worker-app/tests/undrained-body.test.ts | 19 ++++---- fixtures/worker-app/tsconfig.json | 2 +- 11 files changed, 98 insertions(+), 93 deletions(-) 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/packages/import-example/tsconfig.json b/fixtures/import-npm/packages/import-example/tsconfig.json index 7e797b59a8..65a5ca7394 100644 --- a/fixtures/import-npm/packages/import-example/tsconfig.json +++ b/fixtures/import-npm/packages/import-example/tsconfig.json @@ -5,7 +5,7 @@ "module": "preserve", "lib": ["ES2020"], "types": ["node"], - "moduleResolution": "node", + "moduleResolution": "bundler", "noEmit": true, "skipLibCheck": true }, diff --git a/fixtures/import-npm/turbo.json b/fixtures/import-npm/turbo.json index 8b7b7af1c1..ea3755df62 100644 --- a/fixtures/import-npm/turbo.json +++ b/fixtures/import-npm/turbo.json @@ -2,6 +2,7 @@ "extends": ["//"], "tasks": { "_clean_install": { + "dependsOn": ["wrangler#build"], "outputs": ["node_modules"] }, "check:type": { @@ -14,7 +15,7 @@ "dependsOn": ["_clean_install"] }, "test:ci": { - "dependsOn": ["_clean_install", "wrangler#build"] + "dependsOn": ["_clean_install"] } } } 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/import-wasm-example/tsconfig.json b/fixtures/import-wasm-example/tsconfig.json index 7e797b59a8..65a5ca7394 100644 --- a/fixtures/import-wasm-example/tsconfig.json +++ b/fixtures/import-wasm-example/tsconfig.json @@ -5,7 +5,7 @@ "module": "preserve", "lib": ["ES2020"], "types": ["node"], - "moduleResolution": "node", + "moduleResolution": "bundler", "noEmit": true, "skipLibCheck": true }, 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/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/fixtures/worker-app/tsconfig.json b/fixtures/worker-app/tsconfig.json index 7480e11dee..2b744fa45e 100644 --- a/fixtures/worker-app/tsconfig.json +++ b/fixtures/worker-app/tsconfig.json @@ -6,7 +6,7 @@ "lib": ["ES2020"], "types": ["node"], "skipLibCheck": true, - "moduleResolution": "node", + "moduleResolution": "bundler", "noEmit": true }, "include": ["tests"] From 3e785af77005575de112e3a431e74050c25dd88a Mon Sep 17 00:00:00 2001 From: Edmund Hung Date: Thu, 21 May 2026 14:37:43 +0100 Subject: [PATCH 10/20] fix: normalize inline config --- packages/wrangler/e2e/create-server.test.ts | 123 +++++++++++++++ packages/wrangler/src/api/server.ts | 156 +++++++++++++------- 2 files changed, 227 insertions(+), 52 deletions(-) diff --git a/packages/wrangler/e2e/create-server.test.ts b/packages/wrangler/e2e/create-server.test.ts index 953f614eb9..4eb01f33b0 100644 --- a/packages/wrangler/e2e/create-server.test.ts +++ b/packages/wrangler/e2e/create-server.test.ts @@ -244,6 +244,129 @@ describe("createServer", { sequential: true }, () => { 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("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` diff --git a/packages/wrangler/src/api/server.ts b/packages/wrangler/src/api/server.ts index 6ceb018f9a..05acdbc3e1 100644 --- a/packages/wrangler/src/api/server.ts +++ b/packages/wrangler/src/api/server.ts @@ -1,8 +1,14 @@ 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"; @@ -18,9 +24,11 @@ import type { FetcherScheduledOptions, FetcherScheduledResult, } from "@cloudflare/workers-types/experimental"; -import type { RawEnvironment } from "@cloudflare/workers-utils"; +import type { Config, RawConfig } from "@cloudflare/workers-utils"; import type { DispatchFetch, RequestInfo } from "miniflare"; +export type InlineConfig = Omit; + export type WorkerInput = | { root?: string; @@ -29,7 +37,7 @@ export type WorkerInput = } | { root?: string; - config: RawEnvironment; + config: InlineConfig; }; type DevServerOptions = Exclude< @@ -92,6 +100,31 @@ function resolvePath(basePath: string, maybePath: string | URL): string { : 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 @@ -128,60 +161,79 @@ function resolveWorkerInputs( return options.workers.map((input, index, list) => { const isPrimaryWorker = index === 0; const isMultiworker = list.length > 1; - const dev: StartDevWorkerInput["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, - }; const root = input.root ?? options.root ?? cwd; + const inlineConfig = + "config" in input + ? normalizeInlineWorkerConfig(input.config, root) + : undefined; - if ("config" in input) { - const config = input.config; - - return { - // FIXME: to avoid dev env from auto discovering a config file and merging it with the inline config - config: "", - name: config.name, - entrypoint: config.main ? resolvePath(root, config.main) : undefined, - compatibilityDate: config.compatibility_date, - compatibilityFlags: config.compatibility_flags, - complianceRegion: config.compliance_region, - bindings: convertConfigToBindings(config, { usePreviewIds: true }), - migrations: config.migrations, - containers: config.containers, - triggers: config.triggers?.crons?.map((cron) => ({ - type: "cron", - cron, - })), - tailConsumers: config.tail_consumers, - streamingTailConsumers: config.streaming_tail_consumers, - assets: config.assets?.directory, - dev, - }; - } + 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: inlineConfig + ? convertConfigToBindings(inlineConfig, { usePreviewIds: true }) + : undefined, + 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 ("configPath" in input) { - return { - config: resolvePath(root, input.configPath), - env: input.env, - dev, - }; - } + if (!legacyAssetPaths) { + return undefined; + } - throw new Error( - `Invalid worker input at index ${index}. Expected an object with either a "config" property or a "configPath" property.` - ); + return { + bucket: path.join( + legacyAssetPaths.baseDirectory, + legacyAssetPaths.assetDirectory + ), + include: legacyAssetPaths.includePatterns, + exclude: legacyAssetPaths.excludePatterns, + }; + }, + }, + }; }); } From 8825d6ba316eb5819d24acc380dfd6a64e30ffde Mon Sep 17 00:00:00 2001 From: Edmund Hung Date: Thu, 21 May 2026 18:17:23 +0100 Subject: [PATCH 11/20] feat: add override support --- packages/wrangler/e2e/create-server.test.ts | 97 +++++++++++++++++++++ packages/wrangler/src/api/server.ts | 21 ++++- packages/wrangler/src/dev.ts | 10 ++- 3 files changed, 123 insertions(+), 5 deletions(-) diff --git a/packages/wrangler/e2e/create-server.test.ts b/packages/wrangler/e2e/create-server.test.ts index 4eb01f33b0..157608c78c 100644 --- a/packages/wrangler/e2e/create-server.test.ts +++ b/packages/wrangler/e2e/create-server.test.ts @@ -329,6 +329,103 @@ describe("createServer", { sequential: true }, () => { }); }); + 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", diff --git a/packages/wrangler/src/api/server.ts b/packages/wrangler/src/api/server.ts index 05acdbc3e1..0fab8c99b7 100644 --- a/packages/wrangler/src/api/server.ts +++ b/packages/wrangler/src/api/server.ts @@ -25,15 +25,21 @@ import type { FetcherScheduledResult, } from "@cloudflare/workers-types/experimental"; import type { Config, RawConfig } from "@cloudflare/workers-utils"; -import type { DispatchFetch, RequestInfo } from "miniflare"; +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; @@ -166,6 +172,15 @@ function resolveWorkerInputs( "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 @@ -177,9 +192,7 @@ function resolveWorkerInputs( compatibilityFlags: inlineConfig?.compatibility_flags, complianceRegion: inlineConfig?.compliance_region, pythonModules: inlineConfig?.python_modules, - bindings: inlineConfig - ? convertConfigToBindings(inlineConfig, { usePreviewIds: true }) - : undefined, + bindings, migrations: inlineConfig?.migrations, containers: inlineConfig?.containers, triggers: inlineConfig?.triggers?.crons?.map((cron) => ({ diff --git a/packages/wrangler/src/dev.ts b/packages/wrangler/src/dev.ts index 3efdf3b242..c188c10b09 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 From b80f33cc2dd5604b058b9c295fd4eb90c10e6420 Mon Sep 17 00:00:00 2001 From: Edmund Hung Date: Tue, 26 May 2026 18:37:23 +0100 Subject: [PATCH 12/20] fix: routes fetches based on worker routes --- packages/wrangler/e2e/create-server.test.ts | 61 +++++++++++++++ packages/wrangler/src/api/server.ts | 74 +++++++------------ .../api/startDevWorker/ConfigController.ts | 7 +- .../startDevWorker/LocalRuntimeController.ts | 4 + .../wrangler/src/api/startDevWorker/types.ts | 2 + packages/wrangler/src/dev/miniflare/index.ts | 2 + packages/wrangler/src/dev/start-dev.ts | 1 + 7 files changed, 103 insertions(+), 48 deletions(-) diff --git a/packages/wrangler/e2e/create-server.test.ts b/packages/wrangler/e2e/create-server.test.ts index 157608c78c..2d9be2e6c4 100644 --- a/packages/wrangler/e2e/create-server.test.ts +++ b/packages/wrangler/e2e/create-server.test.ts @@ -135,6 +135,67 @@ describe("createServer", { sequential: true }, () => { ); }); + it("routes fetches based on worker routes", async ({ expect }) => { + await helper.seed({ + "wrangler.primary.jsonc": dedent` + { + "name": "primary-worker", + "main": "src/primary.ts", + "compatibility_date": "2026-05-20", + "routes": ["primary.example.com/*"] + } + `, + "src/primary.ts": dedent` + export default { + fetch(request) { + return Response.json({ name: "primary", url: request.url }); + } + }; + `, + "src/auxiliary.ts": dedent` + export default { + fetch(request) { + return Response.json({ name: "auxiliary", url: request.url }); + } + }; + `, + }); + + const server = createServer({ + root: helper.tmpPath, + workers: [ + { configPath: "./wrangler.primary.jsonc" }, + { + config: { + name: "auxiliary-worker", + main: "src/auxiliary.ts", + compatibility_date: "2026-05-20", + routes: ["auxiliary.example.com/*"], + }, + }, + ], + }); + onTestFinished(server.close); + + await server.listen(); + + const primaryResponse = await server.fetch( + "http://primary.example.com/path?value=1" + ); + await expect(primaryResponse.json()).resolves.toEqual({ + name: "primary", + url: "http://primary.example.com/path?value=1", + }); + + const auxiliaryResponse = await server.fetch( + "http://auxiliary.example.com/path?value=2" + ); + await expect(auxiliaryResponse.json()).resolves.toEqual({ + name: "auxiliary", + url: "http://auxiliary.example.com/path?value=2", + }); + }); + it("supports overriding fetch for outbound requests", async ({ expect }) => { await helper.seed({ "wrangler.jsonc": dedent` diff --git a/packages/wrangler/src/api/server.ts b/packages/wrangler/src/api/server.ts index 0fab8c99b7..35e1f23b4c 100644 --- a/packages/wrangler/src/api/server.ts +++ b/packages/wrangler/src/api/server.ts @@ -18,13 +18,12 @@ 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 { Config, RawConfig, Trigger } from "@cloudflare/workers-utils"; import type { DispatchFetch, Json, RequestInfo } from "miniflare"; export type InlineConfig = Omit; @@ -131,6 +130,28 @@ function normalizeInlineWorkerConfig( return normalizedConfig; } +type ConfigRoute = NonNullable | NonNullable[number]; + +function routeToTrigger(route: ConfigRoute): Extract { + return typeof route === "string" + ? { type: "route", pattern: route } + : { type: "route", ...route }; +} + +function getInlineConfigTriggers(config: Config): Trigger[] { + const routes = [ + ...(config.route ? [config.route] : []), + ...(config.routes ?? []), + ].map(routeToTrigger); + const crons = + config.triggers.crons?.map>((cron) => ({ + type: "cron", + cron, + })) ?? []; + + return [...routes, ...crons]; +} + async function resolveFetchInput( input: RequestInfo, session: ServerSession @@ -195,10 +216,7 @@ function resolveWorkerInputs( bindings, migrations: inlineConfig?.migrations, containers: inlineConfig?.containers, - triggers: inlineConfig?.triggers?.crons?.map((cron) => ({ - type: "cron" as const, - cron, - })), + triggers: inlineConfig ? getInlineConfigTriggers(inlineConfig) : undefined, tailConsumers: inlineConfig?.tail_consumers, streamingTailConsumers: inlineConfig?.streaming_tail_consumers, assets: inlineConfig?.assets?.directory, @@ -216,7 +234,8 @@ function resolveWorkerInputs( ((request) => { return globalThis.fetch(request.url, request); }), - multiworkerPrimary: isPrimaryWorker && isMultiworker ? true : undefined, + multiworkerPrimary: isMultiworker ? isPrimaryWorker : undefined, + inferOriginFromRoutes: false, }, build: { nodejsCompatMode: (config) => { @@ -298,44 +317,6 @@ async function updateConfig( } } -// 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. * @@ -415,9 +396,8 @@ export function createServer(options: ServerOptions): WorkerServer { const session = await createSession(currentOptions, serverAuthHook); try { - const ready = await waitForPrimaryReady(session); + await waitForPrimaryReady(session); serverSession = session; - maybePrintScheduledWorkerWarning(session, ready.url); } catch (error) { await teardownSession(session); throw error; diff --git a/packages/wrangler/src/api/startDevWorker/ConfigController.ts b/packages/wrangler/src/api/startDevWorker/ConfigController.ts index 98f73d3b62..8febddb6fd 100644 --- a/packages/wrangler/src/api/startDevWorker/ConfigController.ts +++ b/packages/wrangler/src/api/startDevWorker/ConfigController.ts @@ -149,7 +149,11 @@ async function resolveDevConfig( origin: { secure: input.dev?.origin?.secure ?? config.dev.upstream_protocol === "https", - hostname: host ?? getInferredHost(routes, config.configPath), + hostname: + host ?? + (input.dev?.inferOriginFromRoutes + ? getInferredHost(routes, config.configPath) + : undefined), }, watch: input.dev?.watch, liveReload: input.dev?.liveReload || false, @@ -159,6 +163,7 @@ async function resolveDevConfig( persist: localPersistencePath, registry: input.dev?.registry, multiworkerPrimary: input.dev?.multiworkerPrimary, + inferOriginFromRoutes: input.dev?.inferOriginFromRoutes, enableContainers: input.dev?.enableContainers ?? config.dev.enable_containers, dockerPath: input.dev?.dockerPath ?? getDockerPath(), diff --git a/packages/wrangler/src/api/startDevWorker/LocalRuntimeController.ts b/packages/wrangler/src/api/startDevWorker/LocalRuntimeController.ts index 62e3264e75..fd8d263d43 100644 --- a/packages/wrangler/src/api/startDevWorker/LocalRuntimeController.ts +++ b/packages/wrangler/src/api/startDevWorker/LocalRuntimeController.ts @@ -112,10 +112,13 @@ export async function convertToConfigBundle( const bindings: Record = { ...event.config.bindings }; const crons = []; + const routes = []; const queueConsumers = []; for (const trigger of event.config.triggers ?? []) { if (trigger.type === "cron") { crons.push(trigger.cron); + } else if (trigger.type === "route") { + routes.push(trigger.pattern); } else if (trigger.type === "queue-consumer") { const { type: _, ...consumer } = trigger; queueConsumers.push(consumer); @@ -191,6 +194,7 @@ export async function convertToConfigBundle( localPersistencePath: event.config.dev.persist, liveReload: event.config.dev?.liveReload ?? false, crons, + routes, queueConsumers, outboundService: event.config.dev.outboundService, localProtocol: event.config.dev?.server?.secure ? "https" : "http", diff --git a/packages/wrangler/src/api/startDevWorker/types.ts b/packages/wrangler/src/api/startDevWorker/types.ts index ae17bcdf03..e9f6302b9c 100644 --- a/packages/wrangler/src/api/startDevWorker/types.ts +++ b/packages/wrangler/src/api/startDevWorker/types.ts @@ -173,6 +173,8 @@ export interface StartDevWorkerInput { /** Treat this as the primary worker in a multiworker setup (i.e. the first Worker in Miniflare's options) */ multiworkerPrimary?: boolean; + /** Whether to infer the local request origin from configured routes. */ + inferOriginFromRoutes?: boolean; containerBuildId?: string; /** Whether to build and connect to containers during local dev. Requires Docker daemon to be running. Defaults to true. */ diff --git a/packages/wrangler/src/dev/miniflare/index.ts b/packages/wrangler/src/dev/miniflare/index.ts index 6ff7aa93f9..b01162fac5 100644 --- a/packages/wrangler/src/dev/miniflare/index.ts +++ b/packages/wrangler/src/dev/miniflare/index.ts @@ -86,6 +86,7 @@ export interface ConfigBundle { localPersistencePath: string | false; liveReload: boolean; crons: Config["triggers"]["crons"]; + routes: string[] | undefined; queueConsumers: Config["queues"]["consumers"]; localProtocol: "http" | "https"; httpsKeyPath: string | undefined; @@ -1125,6 +1126,7 @@ export async function buildMiniflareOptions( ...bindingOptions, ...sitesOptions, ...assetOptions, + routes: config.routes, outboundService: config.outboundService, containerEngine: config.containerEngine, zone: config.zone, diff --git a/packages/wrangler/src/dev/start-dev.ts b/packages/wrangler/src/dev/start-dev.ts index 8067fb18e9..0e352aa4f5 100644 --- a/packages/wrangler/src/dev/start-dev.ts +++ b/packages/wrangler/src/dev/start-dev.ts @@ -265,6 +265,7 @@ async function setupDevEnv( logLevel: args.logLevel, registry: args.disableDevRegistry ? undefined : getRegistryPath(), multiworkerPrimary: args.multiworkerPrimary, + inferOriginFromRoutes: true, enableContainers: args.enableContainers, dockerPath: args.dockerPath, // initialise with a random id From c60fddf414acd92f6d36735274e77075714339f4 Mon Sep 17 00:00:00 2001 From: Edmund Hung Date: Tue, 26 May 2026 21:44:51 +0100 Subject: [PATCH 13/20] chore: more tests --- packages/wrangler/e2e/create-server.test.ts | 135 ++++++++++++++++++++ 1 file changed, 135 insertions(+) diff --git a/packages/wrangler/e2e/create-server.test.ts b/packages/wrangler/e2e/create-server.test.ts index 2d9be2e6c4..f9670d0860 100644 --- a/packages/wrangler/e2e/create-server.test.ts +++ b/packages/wrangler/e2e/create-server.test.ts @@ -194,6 +194,113 @@ describe("createServer", { sequential: true }, () => { name: "auxiliary", url: "http://auxiliary.example.com/path?value=2", }); + + await server.update({ + root: helper.tmpPath, + workers: [ + { configPath: "./wrangler.primary.jsonc" }, + { + config: { + name: "auxiliary-worker", + main: "src/auxiliary.ts", + compatibility_date: "2026-05-20", + routes: ["updated-auxiliary.example.com/*"], + }, + }, + ], + }); + + const updatedAuxiliaryResponse = await server.fetch( + "http://updated-auxiliary.example.com/path?value=3" + ); + await expect(updatedAuxiliaryResponse.json()).resolves.toEqual({ + name: "auxiliary", + url: "http://updated-auxiliary.example.com/path?value=3", + }); + }); + + it("rejects updates that change the number of workers", async ({ expect }) => { + await helper.seed({ + "src/primary.ts": dedent` + export default { + fetch() { + return new Response("primary"); + } + }; + `, + "src/auxiliary.ts": dedent` + export default { + fetch() { + return new Response("auxiliary"); + } + }; + `, + }); + + const server = createServer({ + root: helper.tmpPath, + workers: [ + { + config: { + name: "primary-worker", + main: "src/primary.ts", + compatibility_date: "2026-05-20", + }, + }, + ], + }); + onTestFinished(server.close); + + await server.listen(); + + await expect( + server.update((options) => ({ + ...options, + workers: [ + ...options.workers, + { + config: { + name: "auxiliary-worker", + main: "src/auxiliary.ts", + compatibility_date: "2026-05-20", + }, + }, + ], + })) + ).rejects.toThrowErrorMatchingInlineSnapshot( + `[Error: Updating the number of workers running in the server is not supported.]` + ); + }); + + it("returns a 404 response for missing workers", async ({ expect }) => { + await helper.seed({ + "wrangler.jsonc": dedent` + { + "name": "primary-worker", + "main": "src/index.ts", + "compatibility_date": "2026-05-20" + } + `, + "src/index.ts": dedent` + export default { + fetch() { + return new Response("primary"); + } + }; + `, + }); + + const server = createServer({ + root: helper.tmpPath, + workers: [{ configPath: "./wrangler.jsonc" }], + }); + onTestFinished(server.close); + + await server.listen(); + + const response = await server.getWorker("missing-worker").fetch("/"); + expect(response.status).toBe(404); + await expect(response.text()).resolves.toBe("No entrypoint worker found"); }); it("supports overriding fetch for outbound requests", async ({ expect }) => { @@ -452,6 +559,34 @@ describe("createServer", { sequential: true }, () => { ADDED_VAR: "from-override", NULL_VAR: null, }); + + await server.update({ + root: helper.tmpPath, + workers: [ + { + configPath: "./wrangler.jsonc", + overrides: { + vars: { + CONFIG_VAR: "from-updated-override", + ADDED_VAR: "from-updated-override", + NULL_VAR: null, + }, + secrets: { + API_TOKEN: "from-updated-override", + }, + }, + }, + ], + }); + + const updatedResponse = await server.fetch("/"); + await expect(updatedResponse.json()).resolves.toEqual({ + CONFIG_VAR: "from-updated-override", + API_TOKEN: "from-updated-override", + SECRET_FROM_FILE: "from-dev-vars", + ADDED_VAR: "from-updated-override", + NULL_VAR: null, + }); }); it(`supports "nodejs_compat" flag`, async ({ expect }) => { From 916abbd2a478e551a213df9fae30fbce98fa384e Mon Sep 17 00:00:00 2001 From: Edmund Hung Date: Tue, 26 May 2026 22:45:24 +0100 Subject: [PATCH 14/20] feat: streamline inline config setup --- packages/wrangler/src/api/server.ts | 55 ++++--------------- .../api/startDevWorker/ConfigController.ts | 51 +++++++++-------- .../wrangler/src/api/startDevWorker/types.ts | 8 ++- 3 files changed, 43 insertions(+), 71 deletions(-) diff --git a/packages/wrangler/src/api/server.ts b/packages/wrangler/src/api/server.ts index 35e1f23b4c..57d33b9d75 100644 --- a/packages/wrangler/src/api/server.ts +++ b/packages/wrangler/src/api/server.ts @@ -23,7 +23,7 @@ import type { FetcherScheduledOptions, FetcherScheduledResult, } from "@cloudflare/workers-types/experimental"; -import type { Config, RawConfig, Trigger } from "@cloudflare/workers-utils"; +import type { Config, RawConfig } from "@cloudflare/workers-utils"; import type { DispatchFetch, Json, RequestInfo } from "miniflare"; export type InlineConfig = Omit; @@ -130,28 +130,6 @@ function normalizeInlineWorkerConfig( return normalizedConfig; } -type ConfigRoute = NonNullable | NonNullable[number]; - -function routeToTrigger(route: ConfigRoute): Extract { - return typeof route === "string" - ? { type: "route", pattern: route } - : { type: "route", ...route }; -} - -function getInlineConfigTriggers(config: Config): Trigger[] { - const routes = [ - ...(config.route ? [config.route] : []), - ...(config.routes ?? []), - ].map(routeToTrigger); - const crons = - config.triggers.crons?.map>((cron) => ({ - type: "cron", - cron, - })) ?? []; - - return [...routes, ...crons]; -} - async function resolveFetchInput( input: RequestInfo, session: ServerSession @@ -195,31 +173,20 @@ function resolveWorkerInputs( : undefined; const overrides = "configPath" in input ? input.overrides : undefined; const bindings = convertConfigToBindings( - inlineConfig ?? { vars: overrides?.vars }, + { 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) : "", + config: + "configPath" in input + ? resolvePath(root, input.configPath) + : inlineConfig, 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 ? getInlineConfigTriggers(inlineConfig) : undefined, - tailConsumers: inlineConfig?.tail_consumers, - streamingTailConsumers: inlineConfig?.streaming_tail_consumers, - assets: inlineConfig?.assets?.directory, dev: { auth, remote: options.allowRemoteBindings ? undefined : false, @@ -229,6 +196,7 @@ function resolveWorkerInputs( persist: options.persist === true ? undefined : (options.persist ?? false), inspector: options.inspector ?? false, + registry: undefined, outboundService: options.outboundService ?? ((request) => { @@ -239,17 +207,16 @@ function resolveWorkerInputs( }, build: { nodejsCompatMode: (config) => { - const hookConfig = inlineConfig ?? config; return validateNodeCompatMode( - hookConfig.compatibility_date, - hookConfig.compatibility_flags ?? [], - { noBundle: hookConfig.no_bundle } + config.compatibility_date, + config.compatibility_flags ?? [], + { noBundle: config.no_bundle } ); }, }, legacy: { site: (config) => { - const legacyAssetPaths = getSiteAssetPaths(inlineConfig ?? config); + const legacyAssetPaths = getSiteAssetPaths(config); if (!legacyAssetPaths) { return undefined; diff --git a/packages/wrangler/src/api/startDevWorker/ConfigController.ts b/packages/wrangler/src/api/startDevWorker/ConfigController.ts index 8febddb6fd..8f1ab7d8b7 100644 --- a/packages/wrangler/src/api/startDevWorker/ConfigController.ts +++ b/packages/wrangler/src/api/startDevWorker/ConfigController.ts @@ -572,30 +572,33 @@ export class ConfigController extends Controller { const signal = this.#abortController.signal; this.latestInput = input; try { - const fileConfig = readConfig( - { - script: input.entrypoint, - config: input.config, - env: input.env, - "dispatch-namespace": undefined, - "legacy-env": !input.legacy?.useServiceEnvironments, - remote: !!input.dev?.remote, - upstreamProtocol: - input.dev?.origin?.secure === undefined - ? undefined - : input.dev?.origin?.secure - ? "https" - : "http", - localProtocol: - input.dev?.server?.secure === undefined - ? undefined - : input.dev?.server?.secure - ? "https" - : "http", - generateTypes: input.dev?.generateTypes, - }, - { useRedirectIfAvailable: true } - ); + const fileConfig = + typeof input.config === "object" + ? input.config + : readConfig( + { + script: input.entrypoint, + config: input.config, + env: input.env, + "dispatch-namespace": undefined, + "legacy-env": !input.legacy?.useServiceEnvironments, + remote: !!input.dev?.remote, + upstreamProtocol: + input.dev?.origin?.secure === undefined + ? undefined + : input.dev?.origin?.secure + ? "https" + : "http", + localProtocol: + input.dev?.server?.secure === undefined + ? undefined + : input.dev?.server?.secure + ? "https" + : "http", + generateTypes: input.dev?.generateTypes, + }, + { useRedirectIfAvailable: true } + ); if (!getDisableConfigWatching() && input.dev?.watch !== false) { await this.#ensureWatchingConfig(fileConfig.configPath); diff --git a/packages/wrangler/src/api/startDevWorker/types.ts b/packages/wrangler/src/api/startDevWorker/types.ts index e9f6302b9c..d3a4725b28 100644 --- a/packages/wrangler/src/api/startDevWorker/types.ts +++ b/packages/wrangler/src/api/startDevWorker/types.ts @@ -46,8 +46,8 @@ export interface StartDevWorkerInput { * This is the `main` property of a Wrangler configuration file. */ entrypoint?: string; - /** The configuration path of the worker. */ - config?: string; + /** The configuration path of the worker, or a normalized configuration object. */ + config?: string | Config; /** The compatibility date for the workerd runtime. */ compatibilityDate?: string; @@ -210,8 +210,10 @@ export interface StartDevWorkerInput { export type StartDevWorkerOptions = Omit< StartDevWorkerInput, - "assets" | "containers" | "dev" + "assets" | "config" | "containers" | "dev" > & { + /** The configuration path of the worker */ + config?: string; /** A worker's directory. Usually where the Wrangler configuration file is located */ projectRoot: string; build: StartDevWorkerInput["build"] & { From 3f4310c8c13be748eb36c4faf91cc3c778742d92 Mon Sep 17 00:00:00 2001 From: Edmund Hung Date: Wed, 27 May 2026 00:08:36 +0100 Subject: [PATCH 15/20] feat: clearStorage() api --- packages/wrangler/e2e/create-server.test.ts | 90 ++++++++++++++++++++- packages/wrangler/src/api/server.ts | 56 +++++++++++-- 2 files changed, 140 insertions(+), 6 deletions(-) diff --git a/packages/wrangler/e2e/create-server.test.ts b/packages/wrangler/e2e/create-server.test.ts index f9670d0860..d37d37b25e 100644 --- a/packages/wrangler/e2e/create-server.test.ts +++ b/packages/wrangler/e2e/create-server.test.ts @@ -219,7 +219,9 @@ describe("createServer", { sequential: true }, () => { }); }); - it("rejects updates that change the number of workers", async ({ expect }) => { + it("rejects updates that change the number of workers", async ({ + expect, + }) => { await helper.seed({ "src/primary.ts": dedent` export default { @@ -714,6 +716,92 @@ describe("createServer", { sequential: true }, () => { await expect(resetResponse.text()).resolves.toBe("missing"); }); + it("clears local storage", async ({ expect }) => { + await helper.seed({ + "wrangler.jsonc": dedent` + { + "name": "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 server = createServer({ + root: helper.tmpPath, + workers: [{ configPath: "./wrangler.jsonc" }], + }); + onTestFinished(server.close); + + await expect(server.clearStorage()).rejects.toThrow( + "Worker server has not been started. Call server.listen()." + ); + + await server.listen(); + + const setResponse = await server.fetch("/set"); + await expect(setResponse.text()).resolves.toBe("stored"); + const storedResponse = await server.fetch("/"); + await expect(storedResponse.text()).resolves.toBe("value"); + await server.close(); + + const persistentServer = createServer({ + root: helper.tmpPath, + persist: "state", + workers: [{ configPath: "./wrangler.jsonc" }], + }); + onTestFinished(persistentServer.close); + + await persistentServer.listen(); + const persistentSetResponse = await persistentServer.fetch("/set"); + await expect(persistentSetResponse.text()).resolves.toBe("stored"); + await persistentServer.close(); + + const nextPersistentServer = createServer({ + root: helper.tmpPath, + persist: "state", + workers: [{ configPath: "./wrangler.jsonc" }], + }); + onTestFinished(nextPersistentServer.close); + + await nextPersistentServer.listen(); + const persistentStoredResponse = await nextPersistentServer.fetch("/"); + await expect(persistentStoredResponse.text()).resolves.toBe("value"); + + await nextPersistentServer.clearStorage(); + + const persistentClearedResponse = await nextPersistentServer.fetch("/"); + await expect(persistentClearedResponse.text()).resolves.toBe("missing"); + await nextPersistentServer.close(); + + const defaultPersistentServer = createServer({ + root: helper.tmpPath, + persist: true, + workers: [{ configPath: "./wrangler.jsonc" }], + }); + onTestFinished(defaultPersistentServer.close); + await defaultPersistentServer.listen(); + + await expect(defaultPersistentServer.clearStorage()).rejects.toThrow( + "clearStorage() cannot clear storage when persist is true." + ); + }); + it("uses local bindings when remote bindings are not allowed", async ({ expect, }) => { diff --git a/packages/wrangler/src/api/server.ts b/packages/wrangler/src/api/server.ts index 57d33b9d75..245e45b49e 100644 --- a/packages/wrangler/src/api/server.ts +++ b/packages/wrangler/src/api/server.ts @@ -3,6 +3,7 @@ import path from "node:path"; import { fileURLToPath } from "node:url"; import { normalizeAndValidateConfig, + removeDir, UserError, } from "@cloudflare/workers-utils"; import { Headers, Request } from "miniflare"; @@ -83,6 +84,7 @@ export type WorkerServer = { update( options: ServerOptions | ((currentOptions: ServerOptions) => ServerOptions) ): Promise; + clearStorage(): Promise; close(): Promise; }; @@ -130,6 +132,19 @@ function normalizeInlineWorkerConfig( return normalizedConfig; } +function resolvePersistOption( + root: string, + persist: ServerOptions["persist"] +): string | false | undefined { + if (persist === true) { + return undefined; + } + if (typeof persist === "string") { + return resolvePath(root, persist); + } + return persist ?? false; +} + async function resolveFetchInput( input: RequestInfo, session: ServerSession @@ -162,6 +177,7 @@ function resolveWorkerInputs( } const cwd = process.cwd(); + const serverRoot = options.root ?? cwd; return options.workers.map((input, index, list) => { const isPrimaryWorker = index === 0; @@ -193,8 +209,7 @@ function resolveWorkerInputs( 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), + persist: resolvePersistOption(serverRoot, options.persist), inspector: options.inspector ?? false, registry: undefined, outboundService: @@ -316,6 +331,13 @@ export function createServer(options: ServerOptions): WorkerServer { await Promise.all(session.devEnvs.map((devEnv) => devEnv.teardown())); }; + const restartServerSession = async () => { + startPromise = startServerSession().finally(() => { + startPromise = undefined; + }); + await startPromise; + }; + const waitForPrimaryReady = async (session: ServerSession) => { return new Promise< Awaited @@ -375,9 +397,7 @@ export function createServer(options: ServerOptions): WorkerServer { async listen() { if (!serverSession) { if (!startPromise) { - startPromise = startServerSession().finally(() => { - startPromise = undefined; - }); + void restartServerSession(); } await startPromise; @@ -492,6 +512,32 @@ export function createServer(options: ServerOptions): WorkerServer { ]); } }, + async clearStorage() { + if (startPromise) { + await startPromise; + } + const session = resolveSession(); + + if (currentOptions.persist === true) { + throw new Error( + "clearStorage() cannot clear storage when persist is true. Omit persist or set it to false for ephemeral storage, or set persist to a path to clear that directory." + ); + } + + await teardownSession(session); + serverSession = undefined; + + if (typeof currentOptions.persist === "string") { + const persistPath = resolvePath( + currentOptions.root ?? process.cwd(), + currentOptions.persist + ); + + await removeDir(persistPath); + } + + await restartServerSession(); + }, async close() { if (startPromise) { await startPromise.catch(() => undefined); From 2f87ba9eb7915fab7056a226312638efafb85e21 Mon Sep 17 00:00:00 2001 From: Edmund Hung Date: Wed, 27 May 2026 14:06:25 +0100 Subject: [PATCH 16/20] chore: add jsdocs --- packages/wrangler/src/api/index.ts | 1 + packages/wrangler/src/api/server.ts | 147 +++++++++++++++++++++++++++- 2 files changed, 144 insertions(+), 4 deletions(-) diff --git a/packages/wrangler/src/api/index.ts b/packages/wrangler/src/api/index.ts index 8a3e5af128..31a392a7b2 100644 --- a/packages/wrangler/src/api/index.ts +++ b/packages/wrangler/src/api/index.ts @@ -63,6 +63,7 @@ export { createServer } from "./server"; export type { InspectorOptions, ServerOptions, + WorkerHandle, WorkerInput, WorkerServer, } from "./server"; diff --git a/packages/wrangler/src/api/server.ts b/packages/wrangler/src/api/server.ts index 245e45b49e..f5dd4b81c3 100644 --- a/packages/wrangler/src/api/server.ts +++ b/packages/wrangler/src/api/server.ts @@ -57,34 +57,162 @@ export type InspectorOptions = Exclude< >; export type ServerOptions = { + /** + * Base directory used to resolve relative worker config paths and persist paths. + * Defaults to `process.cwd()`. + */ root?: string | undefined; + /** + * Workers to run in this server. The first worker is the primary worker. + */ workers: WorkerInput[]; + /** + * Host, port, and protocol options for the public server. + * Defaults to `{ hostname: "127.0.0.1", port: 0 }`. + */ server?: DevServerOptions | undefined; + /** + * Inspector options for debugging Workers. Set to `false` to disable. + * Defaults to `false`. + */ inspector?: InspectorOptions | undefined; + /** + * Controls local storage persistence. + * Defaults to `false` for ephemeral storage. Set to a path to persist storage there. + */ persist?: boolean | string | undefined; + /** + * Whether to watch worker source/config files and reload on changes. + * Defaults to `false`. + */ watch?: boolean | undefined; + /** + * Minimum Wrangler log level emitted while running the server. + * Defaults to `"error"`. + */ logLevel?: LogLevel | undefined; + /** + * Cloudflare account ID used when an operation requires account context. + * Defaults to the account selected by Wrangler auth when needed. + */ accountId?: string | undefined; + /** + * Whether bindings configured with `remote: true` may connect to remote resources. + * Defaults to `false`. + */ allowRemoteBindings?: boolean | undefined; + /** + * Handles outbound `fetch()` calls from Workers. + * Defaults to the current process `fetch`. + */ outboundService?: ServiceFetch | undefined; }; -export type Worker = { +export type WorkerHandle = { + /** + * Dispatches a fetch event directly to this worker. + * Relative URL inputs are resolved against the current server URL. + * + * @example + * ```ts + * const response = await worker.fetch("/", { + * method: "POST", + * body: "Hello, world!" + * }); + * ``` + */ fetch: DispatchFetch; + /** + * Dispatches a scheduled event directly to this Worker. + * + * @example + * ```ts + * const result = await worker.scheduled({ + * cron: "0 * * * *", + * scheduledTime: new Date(), + * }); + * ``` + */ scheduled(options: FetcherScheduledOptions): Promise; }; export type WorkerServer = { + /** + * Starts the server and returns its current URL. + * Calling this more than once returns the same running server session until + * the server is closed or reset by an operation such as `clearStorage()`. + */ listen(): Promise<{ url: URL; inspectorUrl: URL | undefined; }>; + /** + * Dispatches a fetch request through the server. + * + * - Relative URLs are resolved against the current server URL. Absolute URLs + * are also accepted, and can be used to control the hostname seen by the Worker. + * - Requests are matched against each Worker's configured routes and dispatched to + * the first matching Worker, or to the primary Worker if no routes match. + * - To dispatch directly to a specific Worker, use `server.getWorker(name).fetch()`. + * + * @example + * ```ts + * const server = createServer({ + * workers: [ + * { configPath: "./wrangler.dashboard.jsonc" }, // No route pattern + * { configPath: "./wrangler.api.jsonc" }, // Route pattern: "example.com/api/*" + * { configPath: "./wrangler.admin.jsonc" }, // Route pattern: "admin.example.com/*" + * ] + * }); + * + * await server.fetch("/users"); + * // Dispatches a request to the dashboard Worker (the first Worker) with URL "http://localhost:{port}/users" + * + * await server.fetch("http://admin.example.com/accounts"); + * // Dispatches a request to the admin Worker with URL "http://admin.example.com/accounts" + * + * await server.fetch("http://example.com/api/data"); + * // Dispatches a request to the API Worker with URL "http://example.com/api/data" + * ``` + */ fetch: DispatchFetch; - getWorker(name?: string): Worker; + /** + * Returns a handle for dispatching events directly to a Worker. + * When no name is provided, this returns the primary Worker, which is the first + * Worker in the server's `workers` options. + */ + getWorker(name?: string): WorkerHandle; + /** + * Updates the server configuration and reloads the running Workers. + * + * @example + * ```ts + * await server.update((options) => ({ + * ...options, + * outboundService(request) { + * if (request.url === "http://example.com/api/data") { + * return Response.json([ + * { id: 1, name: "Alice" }, + * { id: 2, name: "Bob" } + * ]); + * } + * + * throw new Error(`Unexpected request to ${request.url}`); + * }, + * })); + * ``` + */ update( options: ServerOptions | ((currentOptions: ServerOptions) => ServerOptions) ): Promise; + /** + * Clears local storage and restarts the server session. + * The server URL may change after storage is cleared. + */ clearStorage(): Promise; + /** + * Stops the server and releases all runtime resources. + */ close(): Promise; }; @@ -300,9 +428,20 @@ async function updateConfig( } /** - * Creates a worker server with a small, migration-focused API surface. + * Creates a server that runs Workers locally. * - * This intentionally reuses DevEnv/controller internals with minimal behavior changes. + * The server can run one or more Workers from Wrangler config files, including + * generated configs from vite, or from inline configuration objects. + * + * @example + * ```ts + * const server = createServer({ + * workers: [{ configPath: "./wrangler.jsonc" }], + * }); + * await server.listen(); + * const response = await server.fetch("/api/users"); + * await server.close(); + * ``` */ export function createServer(options: ServerOptions): WorkerServer { let currentOptions = options; From 66cce9b4c3dd47987fecf65cd5a44a4cf28ef1e2 Mon Sep 17 00:00:00 2001 From: Edmund Hung Date: Wed, 27 May 2026 15:37:45 +0100 Subject: [PATCH 17/20] refactor: createServer implementation --- packages/wrangler/src/api/index.ts | 8 +- packages/wrangler/src/api/server.ts | 564 +++++++++++++--------------- 2 files changed, 270 insertions(+), 302 deletions(-) diff --git a/packages/wrangler/src/api/index.ts b/packages/wrangler/src/api/index.ts index 31a392a7b2..c43c8f2743 100644 --- a/packages/wrangler/src/api/index.ts +++ b/packages/wrangler/src/api/index.ts @@ -60,13 +60,7 @@ export type { DevToolsEvent } from "./startDevWorker/devtools"; // Exports from ./server export { createServer } from "./server"; -export type { - InspectorOptions, - ServerOptions, - WorkerHandle, - WorkerInput, - WorkerServer, -} from "./server"; +export type { ServerOptions, WorkerHandle, WorkerServer } from "./server"; // Exports from ./integrations export { diff --git a/packages/wrangler/src/api/server.ts b/packages/wrangler/src/api/server.ts index f5dd4b81c3..42266753f9 100644 --- a/packages/wrangler/src/api/server.ts +++ b/packages/wrangler/src/api/server.ts @@ -15,6 +15,7 @@ import { DevEnv } from "./startDevWorker/DevEnv"; import { MultiworkerRuntimeController } from "./startDevWorker/MultiworkerRuntimeController"; import { NoOpProxyController } from "./startDevWorker/NoOpProxyController"; import { convertConfigToBindings } from "./startDevWorker/utils"; +import type { CfAccount } from "../dev/create-worker-preview"; import type { LogLevel, ServiceFetch, @@ -25,36 +26,7 @@ import type { 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 ->; +import type { DispatchFetch, Json, Miniflare, RequestInfo } from "miniflare"; export type ServerOptions = { /** @@ -216,222 +188,45 @@ export type WorkerServer = { 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; -} - -function resolvePersistOption( - root: string, - persist: ServerOptions["persist"] -): string | false | undefined { - if (persist === true) { - return undefined; - } - if (typeof persist === "string") { - return resolvePath(root, persist); - } - return persist ?? false; -} +type InlineConfig = Omit; -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(); - const serverRoot = options.root ?? 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( - { vars: overrides?.vars }, - { usePreviewIds: true } - ); - for (const [key, value] of Object.entries(overrides?.secrets ?? {})) { - bindings[key] = { type: "secret_text", value }; - } - - return { - config: - "configPath" in input - ? resolvePath(root, input.configPath) - : inlineConfig, - env: "configPath" in input ? input.env : undefined, - bindings, - 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: resolvePersistOption(serverRoot, options.persist), - inspector: options.inspector ?? false, - registry: undefined, - outboundService: - options.outboundService ?? - ((request) => { - return globalThis.fetch(request.url, request); - }), - multiworkerPrimary: isMultiworker ? isPrimaryWorker : undefined, - inferOriginFromRoutes: false, - }, - build: { - nodejsCompatMode: (config) => { - return validateNodeCompatMode( - config.compatibility_date, - config.compatibility_flags ?? [], - { noBundle: config.no_bundle } - ); - }, - }, - legacy: { - site: (config) => { - const legacyAssetPaths = getSiteAssetPaths(config); - - if (!legacyAssetPaths) { - return undefined; - } - - return { - bucket: path.join( - legacyAssetPaths.baseDirectory, - legacyAssetPaths.assetDirectory - ), - include: legacyAssetPaths.includePatterns, - exclude: legacyAssetPaths.excludePatterns, - }; - }, - }, - }; - }); -} +type ConfigOverrides = { + vars?: Record; + secrets?: Record; +}; -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], - }; +type WorkerInput = + | { + root?: string; + configPath: string | URL; + env?: string; + overrides?: ConfigOverrides; + } + | { + root?: string; + config: InlineConfig; + }; - await updateConfig(session, inputs); +type DevServerOptions = Exclude< + NonNullable["server"], + undefined +>; - return session; -} +type InspectorOptions = Exclude< + NonNullable["inspector"], + undefined +>; -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; - } -} +type ServerSession = { + primaryDevEnv: DevEnv; + devEnvs: DevEnv[]; +}; /** * Creates a server that runs Workers locally. * * The server can run one or more Workers from Wrangler config files, including - * generated configs from vite, or from inline configuration objects. + * generated configs from Vite, or from inline configuration objects. * * @example * ```ts @@ -449,35 +244,210 @@ export function createServer(options: ServerOptions): WorkerServer { let serverSession: ServerSession | undefined; let startPromise: Promise | undefined; - const resolveSession = () => { + 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; + } + + function resolvePersistOption( + root: string, + persist: ServerOptions["persist"] + ): string | false | undefined { + if (persist === true) { + return undefined; + } + if (typeof persist === "string") { + return resolvePath(root, persist); + } + return persist ?? false; + } + + function resolveWorkerInputs( + serverOptions: ServerOptions + ): StartDevWorkerInput[] { + if (serverOptions.workers.length === 0) { + throw new Error("Worker server requires at least one worker."); + } + + const cwd = process.cwd(); + const serverRoot = serverOptions.root ?? cwd; + + return serverOptions.workers.map((input, index, list) => { + const isPrimaryWorker = index === 0; + const isMultiworker = list.length > 1; + const root = input.root ?? serverOptions.root ?? cwd; + const inlineConfig = + "config" in input + ? normalizeInlineWorkerConfig(input.config, root) + : undefined; + const overrides = "configPath" in input ? input.overrides : undefined; + const bindings = convertConfigToBindings( + { vars: overrides?.vars }, + { usePreviewIds: true } + ); + for (const [key, value] of Object.entries(overrides?.secrets ?? {})) { + bindings[key] = { type: "secret_text", value }; + } + + return { + config: + "configPath" in input + ? resolvePath(root, input.configPath) + : inlineConfig, + env: "configPath" in input ? input.env : undefined, + bindings, + dev: { + auth: serverAuthHook, + remote: serverOptions.allowRemoteBindings ? undefined : false, + server: serverOptions.server ?? { hostname: "127.0.0.1", port: 0 }, + logLevel: serverOptions.logLevel ?? "error", + watch: serverOptions.watch ?? false, + persist: resolvePersistOption(serverRoot, serverOptions.persist), + inspector: serverOptions.inspector ?? false, + registry: undefined, + outboundService: + serverOptions.outboundService ?? + ((request) => { + return globalThis.fetch(request.url, request); + }), + multiworkerPrimary: isMultiworker ? isPrimaryWorker : undefined, + inferOriginFromRoutes: false, + }, + build: { + nodejsCompatMode: (config) => { + return validateNodeCompatMode( + config.compatibility_date, + config.compatibility_flags ?? [], + { noBundle: config.no_bundle } + ); + }, + }, + legacy: { + site: (config) => { + const legacyAssetPaths = getSiteAssetPaths(config); + + if (!legacyAssetPaths) { + return undefined; + } + + return { + bucket: path.join( + legacyAssetPaths.baseDirectory, + legacyAssetPaths.assetDirectory + ), + include: legacyAssetPaths.includePatterns, + exclude: legacyAssetPaths.excludePatterns, + }; + }, + }, + }; + }); + } + + async function createSession( + serverOptions: ServerOptions + ): Promise { + const inputs = resolveWorkerInputs(serverOptions); + 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; + } + } + + function resolveSession() { assert( serverSession, "Worker server has not been started. Call server.listen()." ); return serverSession; - }; + } - const serverAuthHook: ServerAuthHook = async (config) => { + async function serverAuthHook( + config: Pick + ): Promise { desiredAccountId ??= await requireAuth(config); return { accountId: desiredAccountId, apiToken: requireApiToken(), }; - }; + } - const teardownSession = async (session: ServerSession) => { + async function teardownSession(session: ServerSession) { await Promise.all(session.devEnvs.map((devEnv) => devEnv.teardown())); - }; + } - const restartServerSession = async () => { + async function restartServerSession() { startPromise = startServerSession().finally(() => { startPromise = undefined; }); await startPromise; - }; + } - const waitForPrimaryReady = async (session: ServerSession) => { + async function waitForPrimaryReady(session: ServerSession) { return new Promise< Awaited >((resolve, reject) => { @@ -498,9 +468,9 @@ export function createServer(options: ServerOptions): WorkerServer { } ); }); - }; + } - const waitForReloadComplete = (session: ServerSession) => { + async function waitForReloadComplete(session: ServerSession) { return new Promise((resolve, reject) => { const cleanup = () => { session.primaryDevEnv.off("error", onError); @@ -518,10 +488,10 @@ export function createServer(options: ServerOptions): WorkerServer { session.primaryDevEnv.once("error", onError); session.primaryDevEnv.once("reloadComplete", onReloadComplete); }); - }; + } - const startServerSession = async () => { - const session = await createSession(currentOptions, serverAuthHook); + async function startServerSession() { + const session = await createSession(currentOptions); try { await waitForPrimaryReady(session); @@ -530,9 +500,51 @@ export function createServer(options: ServerOptions): WorkerServer { await teardownSession(session); throw error; } - }; + } - const workerServer: WorkerServer = { + async function getRuntimeMiniflare(session: ServerSession) { + await session.primaryDevEnv.proxy.runtimeMessageMutex.drained(); + const miniflare = session.primaryDevEnv.runtimes[0].mf; + assert(miniflare, "Worker runtime is not available."); + return miniflare; + } + + async function dispatchFetch( + miniflare: Miniflare, + input: RequestInfo, + init?: RequestInit, + worker?: string + ) { + if (typeof input === "string") { + const session = resolveSession(); + 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"; + } + + input = new URL(input, baseUrl); + } + + if (worker === undefined) { + return miniflare.dispatchFetch(input, init); + } + + const request = new Request(input, init); + const headers = new Headers(request.headers); + + headers.set("MF-Route-Override", worker); + + return miniflare.dispatchFetch(request, { headers }); + } + + return { async listen() { if (!serverSession) { if (!startPromise) { @@ -558,39 +570,15 @@ export function createServer(options: ServerOptions): WorkerServer { "The proxy worker is not available yet. Did you call server.listen()?" ); - return miniflare.dispatchFetch( - await resolveFetchInput(input, session), - init - ); + return dispatchFetch(miniflare, input, 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, - }); + return dispatchFetch(miniflare, input, init, name); }, async scheduled(scheduledOptions) { const session = resolveSession(); @@ -605,21 +593,9 @@ export function createServer(options: ServerOptions): WorkerServer { 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"; + const response = await dispatchFetch(miniflare, url, undefined, name); + const outcome = await response.text(); return { outcome, @@ -637,7 +613,7 @@ export function createServer(options: ServerOptions): WorkerServer { desiredAccountId = currentOptions.accountId ?? desiredAccountId; if (serverSession) { - const nextInputs = resolveWorkerInputs(currentOptions, serverAuthHook); + const nextInputs = resolveWorkerInputs(currentOptions); if (nextInputs.length !== serverSession.devEnvs.length) { throw new Error( @@ -688,6 +664,4 @@ export function createServer(options: ServerOptions): WorkerServer { } }, }; - - return workerServer; } From e3ac9d36f1b17ca6aef9cc8d444d07b02b456e6a Mon Sep 17 00:00:00 2001 From: Edmund Hung Date: Wed, 27 May 2026 18:33:17 +0100 Subject: [PATCH 18/20] feat: binding mode support --- packages/wrangler/e2e/create-server.test.ts | 39 +++++++-------- .../start-worker-remote-bindings.test.ts | 11 ++--- .../startDevWorker/ConfigController.test.ts | 47 +++++++++++++++++++ packages/wrangler/src/api/index.ts | 1 + packages/wrangler/src/api/server.ts | 25 +++++++--- .../api/startDevWorker/ConfigController.ts | 3 +- .../wrangler/src/api/startDevWorker/types.ts | 5 ++ packages/wrangler/src/dev.ts | 42 +++++++++++++++-- 8 files changed, 137 insertions(+), 36 deletions(-) diff --git a/packages/wrangler/e2e/create-server.test.ts b/packages/wrangler/e2e/create-server.test.ts index d37d37b25e..2ed3e33a13 100644 --- a/packages/wrangler/e2e/create-server.test.ts +++ b/packages/wrangler/e2e/create-server.test.ts @@ -802,32 +802,31 @@ describe("createServer", { sequential: true }, () => { ); }); - it("uses local bindings when remote bindings are not allowed", async ({ + it("uses local bindings by default when local support is available", async ({ expect, }) => { await helper.seed({ "wrangler.jsonc": dedent` { - "name": "config-local-bindings-worker", - "main": "src/config.ts", + "name": "write-worker", + "main": "src/writer.ts", "compatibility_date": "2026-05-20", "kv_namespaces": [ - { "binding": "STORE", "id": "config-test-store", "remote": true } + { "binding": "STORE", "id": "store-id", "remote": true } ] } `, - "src/config.ts": dedent` + "src/writer.ts": dedent` export default { async fetch(request, env) { - await env.STORE.put("key", "config-value"); - return new Response((await env.STORE.get("key")) ?? "missing"); + await env.STORE.put("key", "value"); + return new Response("Ok"); } }; `, - "src/inline.ts": dedent` + "src/reader.ts": dedent` export default { async fetch(request, env) { - await env.STORE.put("key", "inline-value"); return new Response((await env.STORE.get("key")) ?? "missing"); } }; @@ -840,32 +839,34 @@ describe("createServer", { sequential: true }, () => { { configPath: "./wrangler.jsonc" }, { config: { - name: "inline-local-bindings-worker", - main: "src/inline.ts", + name: "read-worker", + main: "src/reader.ts", compatibility_date: "2026-05-20", kv_namespaces: [ { binding: "STORE", - id: "inline-test-store", + id: "store-id", remote: true, }, ], }, }, ], - allowRemoteBindings: false, }); onTestFinished(server.close); await server.listen(); - const configResponse = await server.fetch("/"); - await expect(configResponse.text()).resolves.toBe("config-value"); + const writeResponse = await server.getWorker("write-worker").fetch("/"); + await expect(writeResponse.text()).resolves.toBe("Ok"); - const inlineResponse = await server - .getWorker("inline-local-bindings-worker") - .fetch("/"); - await expect(inlineResponse.text()).resolves.toBe("inline-value"); + const readResponse1 = await server.getWorker("read-worker").fetch("/"); + await expect(readResponse1.text()).resolves.toBe("value"); + + await server.clearStorage(); + + const readResponse2 = await server.getWorker("read-worker").fetch("/"); + await expect(readResponse2.text()).resolves.toBe("missing"); }); it("exposes the inspector URL when enabled", async ({ expect }) => { 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 18b2d6896c..c2a5ebff5b 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 @@ -43,7 +43,7 @@ describe.skipIf(!CLOUDFLARE_ACCOUNT_ID)( const server = createServer({ root: helper.tmpPath, workers: [{ configPath: "wrangler.json" }], - allowRemoteBindings: true, + bindingMode: "configured", }); onTestFinished(server.close); @@ -73,7 +73,7 @@ describe.skipIf(!CLOUDFLARE_ACCOUNT_ID)( const server = createServer({ root: helper.tmpPath, workers: [{ configPath: "wrangler.json" }], - allowRemoteBindings: true, + bindingMode: "configured", watch: true, }); onTestFinished(server.close); @@ -120,7 +120,7 @@ describe.skipIf(!CLOUDFLARE_ACCOUNT_ID)( } ); -it("doesn't connect to remote bindings by default", async ({ expect }) => { +it("rejects remote-only bindings in local mode", async ({ expect }) => { const helper = new WranglerE2ETestHelper(); await helper.seed(resolve(__dirname, "./workers")); await helper.seed({ @@ -139,13 +139,12 @@ it("doesn't connect to remote bindings by default", async ({ expect }) => { const server = createServer({ root: helper.tmpPath, workers: [{ configPath: "wrangler.json" }], + bindingMode: "local", }); onTestFinished(server.close); await server.listen(); - - await server.fetch("http://example.com"); }).rejects.toThrowErrorMatchingInlineSnapshot( - `[Error: Binding AI needs to be run remotely]` + `[Error: Binding AI (ai) does not support local development. Set bindingMode to "local-first" or "configured" to allow remote access.]` ); }); diff --git a/packages/wrangler/src/__tests__/api/startDevWorker/ConfigController.test.ts b/packages/wrangler/src/__tests__/api/startDevWorker/ConfigController.test.ts index 640d7931ff..5baff78479 100644 --- a/packages/wrangler/src/__tests__/api/startDevWorker/ConfigController.test.ts +++ b/packages/wrangler/src/__tests__/api/startDevWorker/ConfigController.test.ts @@ -141,6 +141,53 @@ describe("ConfigController", () => { }); }); + it("should set configured remote bindings with local support to local in local-first mode", async ({ + expect, + }) => { + const event = bus.waitFor("configUpdate"); + await seed({ + "src/index.ts": dedent /* javascript */ ` + export default {} + `, + "wrangler.jsonc": dedent` + { + "name": "my-worker", + "main": "src/index.ts", + "compatibility_date": "2024-06-01", + "kv_namespaces": [ + { "binding": "STORE", "id": "namespace-id", "remote": true } + ], + "vectorize": [ + { "binding": "VECTORIZE", "index_name": "index", "remote": true } + ] + } + `, + }); + + await controller.set({ + config: "./wrangler.jsonc", + dev: { bindingMode: "local-first" }, + }); + + const configUpdate = await event; + + expect(configUpdate).toMatchObject({ + type: "configUpdate", + config: { + bindings: { + STORE: { + type: "kv_namespace", + remote: false, + }, + VECTORIZE: { + type: "vector_index", + remote: true, + }, + }, + }, + }); + }); + it("should apply module root to parent if main is nested from base_dir", async ({ expect, }) => { diff --git a/packages/wrangler/src/api/index.ts b/packages/wrangler/src/api/index.ts index c43c8f2743..62a2d0c3c4 100644 --- a/packages/wrangler/src/api/index.ts +++ b/packages/wrangler/src/api/index.ts @@ -29,6 +29,7 @@ export type { AsyncHook, Bundle, LogLevel, + BindingMode, File, BinaryFile, Trigger, diff --git a/packages/wrangler/src/api/server.ts b/packages/wrangler/src/api/server.ts index 42266753f9..850a3420d8 100644 --- a/packages/wrangler/src/api/server.ts +++ b/packages/wrangler/src/api/server.ts @@ -18,6 +18,7 @@ import { convertConfigToBindings } from "./startDevWorker/utils"; import type { CfAccount } from "../dev/create-worker-preview"; import type { LogLevel, + BindingMode, ServiceFetch, StartDevWorkerInput, } from "./startDevWorker/types"; @@ -69,10 +70,15 @@ export type ServerOptions = { */ accountId?: string | undefined; /** - * Whether bindings configured with `remote: true` may connect to remote resources. - * Defaults to `false`. + * Overrides how bindings are resolved for this server. + * + * - `"local"`: use local bindings and reject bindings without local support + * - `"local-first"`: use local bindings when available, remote otherwise + * - `"configured"`: use the binding mode configured in each Worker config + * + * Defaults to `"local-first"`. */ - allowRemoteBindings?: boolean | undefined; + bindingMode?: BindingMode | undefined; /** * Handles outbound `fetch()` calls from Workers. * Defaults to the current process `fetch`. @@ -288,6 +294,10 @@ export function createServer(options: ServerOptions): WorkerServer { return persist ?? false; } + function bindingMode(serverOptions: ServerOptions): BindingMode { + return serverOptions.bindingMode ?? "local-first"; + } + function resolveWorkerInputs( serverOptions: ServerOptions ): StartDevWorkerInput[] { @@ -324,7 +334,8 @@ export function createServer(options: ServerOptions): WorkerServer { bindings, dev: { auth: serverAuthHook, - remote: serverOptions.allowRemoteBindings ? undefined : false, + remote: bindingMode(serverOptions) === "local" ? false : undefined, + bindingMode: bindingMode(serverOptions), server: serverOptions.server ?? { hostname: "127.0.0.1", port: 0 }, logLevel: serverOptions.logLevel ?? "error", watch: serverOptions.watch ?? false, @@ -548,10 +559,10 @@ export function createServer(options: ServerOptions): WorkerServer { async listen() { if (!serverSession) { if (!startPromise) { - void restartServerSession(); + await restartServerSession(); + } else { + await startPromise; } - - await startPromise; } assert(serverSession, "Worker server has no active session."); diff --git a/packages/wrangler/src/api/startDevWorker/ConfigController.ts b/packages/wrangler/src/api/startDevWorker/ConfigController.ts index 8f1ab7d8b7..67c753ce7b 100644 --- a/packages/wrangler/src/api/startDevWorker/ConfigController.ts +++ b/packages/wrangler/src/api/startDevWorker/ConfigController.ts @@ -192,7 +192,8 @@ async function resolveBindings( input.envFiles, !input.dev?.remote, input.bindings, - input.defaultBindings + input.defaultBindings, + input.dev?.bindingMode ); // Create a print function that captures the current bindings context diff --git a/packages/wrangler/src/api/startDevWorker/types.ts b/packages/wrangler/src/api/startDevWorker/types.ts index d3a4725b28..c7a9801328 100644 --- a/packages/wrangler/src/api/startDevWorker/types.ts +++ b/packages/wrangler/src/api/startDevWorker/types.ts @@ -143,6 +143,8 @@ export interface StartDevWorkerInput { * - undefined (default): Run your Worker's code locally, and any configured remote bindings remotely */ remote?: boolean | "minimal"; + /** Overrides how bindings are resolved in local mode. */ + bindingMode?: BindingMode; /** Cloudflare Account credentials. Can be provided upfront or as a function which will be called only when required. */ auth?: AsyncHook]>; // provide config.account_id as a hook param /** Whether local storage (KV, Durable Objects, R2, D1, etc) is persisted. You can also specify the directory to persist data to. Set to `false` to disable persistence. */ @@ -208,6 +210,9 @@ export interface StartDevWorkerInput { experimental?: Record; } +/** Controls how local dev resolves bindings that may use local or remote resources. */ +export type BindingMode = "local" | "local-first" | "configured"; + export type StartDevWorkerOptions = Omit< StartDevWorkerInput, "assets" | "config" | "containers" | "dev" diff --git a/packages/wrangler/src/dev.ts b/packages/wrangler/src/dev.ts index c188c10b09..8375df293c 100644 --- a/packages/wrangler/src/dev.ts +++ b/packages/wrangler/src/dev.ts @@ -3,6 +3,7 @@ import events from "node:events"; import { configFileName, formatConfigSnippet, + getBindingLocalSupport, UserError, } from "@cloudflare/workers-utils"; import { isWebContainer } from "@webcontainer/env"; @@ -14,7 +15,7 @@ import { getVarsForDev } from "./dev/dev-vars"; import { startDev } from "./dev/start-dev"; import { logger } from "./logger"; import { getHostFromRoute } from "./zones"; -import type { StartDevWorkerInput, Trigger } from "./api"; +import type { BindingMode, StartDevWorkerInput, Trigger } from "./api"; import type { EnablePagesAssetsServiceBindingOptions } from "./miniflare-cli/types"; import type { Binding, @@ -505,6 +506,7 @@ function applyHyperdriveEnvVars(config: Config, local: boolean): void { * If `undefined` it defaults to the standard .env files from `getDefaultEnvFiles()`. * @param local Whether the dev server should run locally. * @param inputBindings Additional bindings to merge on top of config bindings + * @param bindingMode How local dev should resolve bindings with local/remote support. * @returns The bindings for the Cloudflare Worker. */ export function getBindings( @@ -513,7 +515,8 @@ export function getBindings( envFiles: string[] | undefined, local: boolean, inputBindings: StartDevWorkerInput["bindings"], - defaultBindings: StartDevWorkerInput["bindings"] + defaultBindings: StartDevWorkerInput["bindings"], + bindingMode?: BindingMode ): StartDevWorkerInput["bindings"] { applyHyperdriveEnvVars(configParam, local); @@ -554,7 +557,40 @@ export function getBindings( } } - return { ...defaultBindings, ...bindings, ...inputBindings }; + let result = { ...defaultBindings, ...bindings, ...inputBindings }; + + if (bindingMode === "local" || bindingMode === "local-first") { + result = Object.fromEntries( + Object.entries(result).map(([name, binding]) => { + if (!("remote" in binding) || binding.remote !== true) { + return [name, binding]; + } + + const support = getBindingLocalSupport(binding.type); + const cannotRunLocally = + support === "remote" || + support === + "DO-NOT-USE-this-resource-will-never-have-a-local-simulator"; + + if (bindingMode === "local" && cannotRunLocally) { + throw new UserError( + `Binding ${name} (${binding.type}) does not support local development. Set bindingMode to "local-first" or "configured" to allow remote access.`, + { + telemetryMessage: "server remote binding disabled but required", + } + ); + } + + if (support === "local-only" || support === "local-and-remote") { + return [name, { ...binding, remote: false }]; + } + + return [name, binding]; + }) + ); + } + + return result; } export function getAssetChangeMessage( From ccbc1f9800d46bb6f54be5e8d6f8b18231560505 Mon Sep 17 00:00:00 2001 From: Edmund Hung Date: Thu, 28 May 2026 11:41:12 +0100 Subject: [PATCH 19/20] refactor: createServer --- packages/wrangler/e2e/create-server.test.ts | 2 +- packages/wrangler/src/api/server.ts | 124 ++++++++++---------- 2 files changed, 63 insertions(+), 63 deletions(-) diff --git a/packages/wrangler/e2e/create-server.test.ts b/packages/wrangler/e2e/create-server.test.ts index 2ed3e33a13..ebe8d715af 100644 --- a/packages/wrangler/e2e/create-server.test.ts +++ b/packages/wrangler/e2e/create-server.test.ts @@ -749,7 +749,7 @@ describe("createServer", { sequential: true }, () => { onTestFinished(server.close); await expect(server.clearStorage()).rejects.toThrow( - "Worker server has not been started. Call server.listen()." + "Worker server has not been started. Start it with server.listen() before calling this method." ); await server.listen(); diff --git a/packages/wrangler/src/api/server.ts b/packages/wrangler/src/api/server.ts index 850a3420d8..f7e61fd4e0 100644 --- a/packages/wrangler/src/api/server.ts +++ b/packages/wrangler/src/api/server.ts @@ -248,7 +248,7 @@ export function createServer(options: ServerOptions): WorkerServer { let currentOptions = options; let desiredAccountId = options.accountId; let serverSession: ServerSession | undefined; - let startPromise: Promise | undefined; + let startPromise: Promise | undefined; function resolvePath(basePath: string, maybePath: string | URL): string { if (maybePath instanceof URL) { @@ -406,33 +406,36 @@ export function createServer(options: ServerOptions): WorkerServer { devEnvs: [primaryDevEnv, ...auxiliaryDevEnvs], }; - await updateConfig(session, inputs); - - return session; + try { + await updateConfig(session, inputs); + await waitForProxyReady(session); + return session; + } catch (error) { + await teardownSession(session); + throw error; + } } 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; + for (const [index, workerInput] of inputs.entries()) { + const devEnv = session.devEnvs[index]; + await devEnv.config.set(workerInput, true); } } - function resolveSession() { + async function resolveSession() { + if (startPromise) { + return await startPromise; + } + assert( serverSession, - "Worker server has not been started. Call server.listen()." + "Worker server has not been started. Start it with server.listen() before calling this method." ); + return serverSession; } @@ -448,17 +451,31 @@ export function createServer(options: ServerOptions): WorkerServer { } async function teardownSession(session: ServerSession) { - await Promise.all(session.devEnvs.map((devEnv) => devEnv.teardown())); + try { + await Promise.all(session.devEnvs.map((devEnv) => devEnv.teardown())); + } finally { + if (session === serverSession) { + serverSession = undefined; + } + } } - async function restartServerSession() { - startPromise = startServerSession().finally(() => { - startPromise = undefined; - }); - await startPromise; + async function startServerSession() { + if (!startPromise) { + startPromise = createSession(currentOptions) + .then((session) => { + serverSession = session; + return session; + }) + .finally(() => { + startPromise = undefined; + }); + } + + return await startPromise; } - async function waitForPrimaryReady(session: ServerSession) { + async function waitForProxyReady(session: ServerSession) { return new Promise< Awaited >((resolve, reject) => { @@ -501,18 +518,6 @@ export function createServer(options: ServerOptions): WorkerServer { }); } - async function startServerSession() { - const session = await createSession(currentOptions); - - try { - await waitForPrimaryReady(session); - serverSession = session; - } catch (error) { - await teardownSession(session); - throw error; - } - } - async function getRuntimeMiniflare(session: ServerSession) { await session.primaryDevEnv.proxy.runtimeMessageMutex.drained(); const miniflare = session.primaryDevEnv.runtimes[0].mf; @@ -527,8 +532,8 @@ export function createServer(options: ServerOptions): WorkerServer { worker?: string ) { if (typeof input === "string") { - const session = resolveSession(); - const { url } = await session.primaryDevEnv.proxy.ready.promise; + const session = await resolveSession(); + const { url } = await waitForProxyReady(session); const baseUrl = new URL(url); if ( @@ -557,16 +562,8 @@ export function createServer(options: ServerOptions): WorkerServer { return { async listen() { - if (!serverSession) { - if (!startPromise) { - await restartServerSession(); - } else { - await startPromise; - } - } - - assert(serverSession, "Worker server has no active session."); - const ready = await serverSession.primaryDevEnv.proxy.ready.promise; + const session = serverSession ?? (await startServerSession()); + const ready = await waitForProxyReady(session); return { url: ready.url, @@ -574,7 +571,7 @@ export function createServer(options: ServerOptions): WorkerServer { }; }, async fetch(input, init) { - const session = resolveSession(); + const session = await resolveSession(); const miniflare = session.primaryDevEnv.proxy.proxyWorker; assert( miniflare, @@ -586,18 +583,20 @@ export function createServer(options: ServerOptions): WorkerServer { getWorker(name?: string) { return { async fetch(input, init) { - const session = resolveSession(); + const session = await resolveSession(); const miniflare = await getRuntimeMiniflare(session); return dispatchFetch(miniflare, input, init, name); }, async scheduled(scheduledOptions) { - const session = resolveSession(); + const session = await 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", @@ -632,17 +631,19 @@ export function createServer(options: ServerOptions): WorkerServer { ); } - await Promise.all([ - waitForReloadComplete(serverSession), - updateConfig(serverSession, nextInputs), - ]); + try { + await Promise.all([ + waitForReloadComplete(serverSession), + updateConfig(serverSession, nextInputs), + ]); + } catch (error) { + await teardownSession(serverSession); + throw error; + } } }, async clearStorage() { - if (startPromise) { - await startPromise; - } - const session = resolveSession(); + const session = await resolveSession(); if (currentOptions.persist === true) { throw new Error( @@ -651,7 +652,6 @@ export function createServer(options: ServerOptions): WorkerServer { } await teardownSession(session); - serverSession = undefined; if (typeof currentOptions.persist === "string") { const persistPath = resolvePath( @@ -662,16 +662,16 @@ export function createServer(options: ServerOptions): WorkerServer { await removeDir(persistPath); } - await restartServerSession(); + await startServerSession(); }, async close() { if (startPromise) { + // Wait for it to start before tearing down + // ignoring any errors since we're closing the server anyway await startPromise.catch(() => undefined); - startPromise = undefined; } if (serverSession) { await teardownSession(serverSession); - serverSession = undefined; } }, }; From 571faa475512ad90e4bffa5863a782bf347fc0ea Mon Sep 17 00:00:00 2001 From: Edmund Hung Date: Thu, 28 May 2026 14:29:41 +0100 Subject: [PATCH 20/20] fix: scheudled handler support --- packages/wrangler/src/api/server.ts | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/packages/wrangler/src/api/server.ts b/packages/wrangler/src/api/server.ts index f7e61fd4e0..8d126d0144 100644 --- a/packages/wrangler/src/api/server.ts +++ b/packages/wrangler/src/api/server.ts @@ -591,7 +591,9 @@ export function createServer(options: ServerOptions): WorkerServer { async scheduled(scheduledOptions) { const session = await resolveSession(); const miniflare = await getRuntimeMiniflare(session); - const url = new URL("http://localhost/cdn-cgi/handler/scheduled"); + const url = new URL( + "http://localhost/cdn-cgi/handler/scheduled?format=json" + ); if (scheduledOptions?.cron !== undefined) { url.searchParams.set("cron", scheduledOptions.cron); @@ -605,13 +607,9 @@ export function createServer(options: ServerOptions): WorkerServer { } const response = await dispatchFetch(miniflare, url, undefined, name); - const outcome = await response.text(); + const result = await response.json(); - return { - outcome, - // FIXME: scheduled handler should include noRetry info in the response - noRetry: false, - }; + return result as FetcherScheduledResult; }, }; },