diff --git a/.changeset/entrypoint-subdomains-miniflare.md b/.changeset/entrypoint-subdomains-miniflare.md new file mode 100644 index 000000000000..94ce6086d7f7 --- /dev/null +++ b/.changeset/entrypoint-subdomains-miniflare.md @@ -0,0 +1,9 @@ +--- +"miniflare": minor +--- + +Add `unsafeEntrypointSubdomains` option for localhost subdomain routing + +Workers can now expose entrypoints via localhost subdomains during local development. When configured, requests to `http://{entrypoint}.{worker}.localhost:{port}` are routed to the corresponding entrypoint, and `http://{worker}.localhost:{port}` routes to the worker's default entrypoint. + +A DNS compatibility check will run on startup when `unsafeEntrypointSubdomains` is specified and warns if the system's resolver doesn't support `*.localhost` subdomains. diff --git a/.changeset/entrypoint-subdomains-vite.md b/.changeset/entrypoint-subdomains-vite.md new file mode 100644 index 000000000000..4563f7a2b1e8 --- /dev/null +++ b/.changeset/entrypoint-subdomains-vite.md @@ -0,0 +1,26 @@ +--- +"@cloudflare/vite-plugin": minor +--- + +Add `exposeEntrypoints` option for localhost subdomain routing + +You can now access worker entrypoints directly via localhost subdomains during development. This is particularly useful in multi-worker setups where you need to reach entrypoints on auxiliary workers. Set `exposeEntrypoints` in your Vite plugin config to enable this: + +```ts +cloudflare({ + configPath: "./dashboard/wrangler.json", + // Expose all entrypoints using their export names as aliases + // e.g. http://dashboard.localhost:8787/ -> default entrypoint + exposeEntrypoints: true, + auxiliaryWorkers: [ + { + configPath: "./admin/wrangler.json", + // Or use an object to pick specific entrypoints and customize aliases + exposeEntrypoints: { + default: true, // http://admin.localhost:8787/ + ApiEntrypoint: "api", // http://api.admin.localhost:8787/ + }, + }, + ], +}); +``` diff --git a/.changeset/entrypoint-subdomains-wrangler.md b/.changeset/entrypoint-subdomains-wrangler.md new file mode 100644 index 000000000000..adcc97529027 --- /dev/null +++ b/.changeset/entrypoint-subdomains-wrangler.md @@ -0,0 +1,35 @@ +--- +"wrangler": minor +--- + +Add `expose_entrypoints` config for localhost subdomain routing + +You can now access worker entrypoints directly via localhost subdomains during `wrangler dev`. This is particularly useful in multi-worker setups (e.g. `wrangler dev -c dashboard/wrangler.json -c admin/wrangler.json`) where you need to reach entrypoints on auxiliary workers. Add `dev.expose_entrypoints` to each worker config and run them together: + +Set `true` to expose all entrypoints using their export names as aliases: + +```jsonc +// dashboard/wrangler.json +{ + "name": "dashboard", + "dev": { + // e.g. http://dashboard.localhost:8787/ -> default entrypoint + "expose_entrypoints": true, + }, +} +``` + +Or use an object to pick specific entrypoints and customize their aliases: + +```jsonc +// admin/wrangler.json +{ + "name": "admin", + "dev": { + "expose_entrypoints": { + "default": true, // http://admin.localhost:8787/ + "ApiEntrypoint": "api", // http://api.admin.localhost:8787/ + }, + }, +} +``` diff --git a/packages/miniflare/src/index.ts b/packages/miniflare/src/index.ts index 1742d65f5b6f..bd34e25446df 100644 --- a/packages/miniflare/src/index.ts +++ b/packages/miniflare/src/index.ts @@ -1,5 +1,6 @@ import assert from "node:assert"; import crypto from "node:crypto"; +import dns from "node:dns/promises"; import { Abortable } from "node:events"; import fs from "node:fs"; import { mkdir, writeFile } from "node:fs/promises"; @@ -726,6 +727,86 @@ function getWorkerRoutes( return allRoutes; } +async function isLocalhostSubdomainSupported(): Promise { + try { + const result = await dns.lookup("test.domain.localhost"); + return result.address === "127.0.0.1" || result.address === "::1"; + } catch { + return false; + } +} + +type EntrypointEntry = { + export: string; + type: "DurableObject" | "WorkerEntrypoint" | "WorkflowEntrypoint"; +}; + +function getEntrypointSubdomains(allWorkerOpts: PluginWorkerOptions[]) { + const result: Record> = {}; + + for (const workerOpts of allWorkerOpts) { + const subdomains = workerOpts.core.unsafeEntrypointSubdomains; + if (!subdomains) { + continue; + } + + const workerName = workerOpts.core.name ?? ""; + + const doClassNames = new Set( + Object.values(workerOpts.do.durableObjects ?? {}).map((v) => + typeof v === "string" ? v : v.className + ) + ); + const workflowClassNames = new Set( + Object.values(workerOpts.workflows.workflows ?? {}).map( + (v) => v.className + ) + ); + + // Check for collisions and invert to alias -> { export, type } + // for the entry worker's O(1) hostname lookup. + const aliasToEntry: Record = {}; + const seenAliases = new Map(); + + for (const [exportName, rawAlias] of Object.entries(subdomains)) { + const alias = rawAlias.toLowerCase(); + + const existing = seenAliases.get(alias); + if (existing !== undefined) { + throw new MiniflareCoreError( + "ERR_VALIDATION", + `Alias collision in worker "${workerName}": ` + + `entrypoints "${existing}" and "${exportName}" both map to alias "${alias}".` + ); + } + seenAliases.set(alias, exportName); + + let type: EntrypointEntry["type"]; + if (doClassNames.has(exportName)) { + type = "DurableObject"; + } else if (workflowClassNames.has(exportName)) { + type = "WorkflowEntrypoint"; + } else { + type = "WorkerEntrypoint"; + } + + aliasToEntry[alias] = { export: exportName, type }; + } + + if (Object.keys(aliasToEntry).length === 0) { + continue; + } + + result[workerName.toLowerCase()] = aliasToEntry; + } + + if (Object.keys(result).length === 0) { + return undefined; + } + + return result; +} + // Get the name of a binding in the `ProxyServer`'s `env` function getProxyBindingName(plugin: string, worker: string, binding: string) { return [ @@ -922,6 +1003,7 @@ export class Miniflare { #proxyClient?: ProxyClient; #structuredWorkerdLogs: boolean; + #localhostSubdomainChecked = false; #cfObject?: Record = {}; @@ -1945,9 +2027,23 @@ export class Miniflare { } } + const allEntrypointSubdomains = getEntrypointSubdomains(allWorkerOpts); + + if (allEntrypointSubdomains && !this.#localhostSubdomainChecked) { + this.#localhostSubdomainChecked = true; + if (!(await isLocalhostSubdomainSupported())) { + this.#log.warn( + "Your system's DNS resolver does not support *.localhost subdomains.\n" + + "Localhost entrypoint URLs like http://{entrypoint}.{worker}.localhost will work in\n" + + "some browsers (e.g. Chrome, Edge, Firefox) but might not resolve for other tools like curl." + ); + } + } + const globalServices = getGlobalServices({ sharedOptions: sharedOpts.core, allWorkerRoutes, + allEntrypointSubdomains, /* * - if Workers + Assets project but NOT Vitest, the fallback Worker (see * `MINIFLARE_USER_FALLBACK`) should point to the (assets) RPC Proxy Worker diff --git a/packages/miniflare/src/plugins/core/index.ts b/packages/miniflare/src/plugins/core/index.ts index 6fd57e0d236b..2593f23d2223 100644 --- a/packages/miniflare/src/plugins/core/index.ts +++ b/packages/miniflare/src/plugins/core/index.ts @@ -165,6 +165,16 @@ const CoreOptionsSchemaInput = z.intersection( unsafeEphemeralDurableObjects: z.boolean().optional(), unsafeDirectSockets: UnsafeDirectSocketSchema.array().optional(), + unsafeEntrypointSubdomains: z + .record( + z + .string() + .regex( + /^[a-zA-Z0-9_-]+$/, + "Aliases must contain only alphanumeric characters, hyphens, or underscores" + ) + ) + .optional(), unsafeEvalBinding: z.string().optional(), unsafeUseModuleFallbackService: z.boolean().optional(), @@ -962,6 +972,18 @@ export const CORE_PLUGIN: Plugin< export interface GlobalServicesOptions { sharedOptions: z.infer; allWorkerRoutes: Map; + allEntrypointSubdomains: + | Record< + string, + Record< + string, + { + export: string; + type: "DurableObject" | "WorkerEntrypoint" | "WorkflowEntrypoint"; + } + > + > + | undefined; fallbackWorkerName: string | undefined; loopbackPort: number; log: Log; @@ -973,6 +995,7 @@ export interface GlobalServicesOptions { export function getGlobalServices({ sharedOptions, allWorkerRoutes, + allEntrypointSubdomains, fallbackWorkerName, loopbackPort, log, @@ -1020,6 +1043,27 @@ export function getGlobalServices({ // Add `proxyBindings` here, they'll be added to the `ProxyServer` `env` ...proxyBindings, ]; + if (allEntrypointSubdomains) { + serviceEntryBindings.push({ + name: CoreBindings.JSON_ENTRYPOINT_SUBDOMAINS, + json: JSON.stringify(allEntrypointSubdomains), + }); + for (const [workerName, entrypoints] of Object.entries( + allEntrypointSubdomains + )) { + for (const entry of Object.values(entrypoints)) { + if (entry.type === "WorkerEntrypoint") { + serviceEntryBindings.push({ + name: `${CoreBindings.SERVICE_USER_ENTRYPOINT_PREFIX}${workerName}:${entry.export}`, + service: { + name: getUserServiceName(workerName), + entrypoint: entry.export !== "default" ? entry.export : undefined, + }, + }); + } + } + } + } if (sharedOptions.unsafeLocalExplorer) { serviceEntryBindings.push({ name: CoreBindings.SERVICE_LOCAL_EXPLORER, diff --git a/packages/miniflare/src/workers/core/constants.ts b/packages/miniflare/src/workers/core/constants.ts index 4f4a8ff8180c..7be5f8c3c449 100644 --- a/packages/miniflare/src/workers/core/constants.ts +++ b/packages/miniflare/src/workers/core/constants.ts @@ -30,11 +30,13 @@ export const CoreHeaders = { export const CoreBindings = { SERVICE_LOOPBACK: "MINIFLARE_LOOPBACK", SERVICE_USER_ROUTE_PREFIX: "MINIFLARE_USER_ROUTE_", + SERVICE_USER_ENTRYPOINT_PREFIX: "MINIFLARE_USER_ENTRYPOINT_", SERVICE_USER_FALLBACK: "MINIFLARE_USER_FALLBACK", TEXT_CUSTOM_SERVICE: "MINIFLARE_CUSTOM_SERVICE", IMAGES_SERVICE: "MINIFLARE_IMAGES_SERVICE", TEXT_UPSTREAM_URL: "MINIFLARE_UPSTREAM_URL", JSON_CF_BLOB: "CF_BLOB", + JSON_ENTRYPOINT_SUBDOMAINS: "MINIFLARE_ENTRYPOINT_SUBDOMAINS", JSON_ROUTES: "MINIFLARE_ROUTES", JSON_LOG_LEVEL: "MINIFLARE_LOG_LEVEL", DATA_LIVE_RELOAD_SCRIPT: "MINIFLARE_LIVE_RELOAD_SCRIPT", diff --git a/packages/miniflare/src/workers/core/entry.worker.ts b/packages/miniflare/src/workers/core/entry.worker.ts index a726fb1c67ba..1e46a29cfb9b 100644 --- a/packages/miniflare/src/workers/core/entry.worker.ts +++ b/packages/miniflare/src/workers/core/entry.worker.ts @@ -24,6 +24,16 @@ type Env = { [CoreBindings.TEXT_CUSTOM_SERVICE]: string; [CoreBindings.TEXT_UPSTREAM_URL]?: string; [CoreBindings.JSON_CF_BLOB]: IncomingRequestCfProperties; + [CoreBindings.JSON_ENTRYPOINT_SUBDOMAINS]?: Record< + string, + Record< + string, + { + export: string; + type: "DurableObject" | "WorkerEntrypoint" | "WorkflowEntrypoint"; + } + > + >; [CoreBindings.JSON_ROUTES]: WorkerRoute[]; [CoreBindings.JSON_LOG_LEVEL]: LogLevel; [CoreBindings.DATA_LIVE_RELOAD_SCRIPT]?: ArrayBuffer; @@ -35,7 +45,11 @@ type Env = { } & { [K in `${typeof CoreBindings.SERVICE_USER_ROUTE_PREFIX}${string}`]: | Fetcher - | undefined; // Won't have a `Fetcher` for every possible `string` + | undefined; +} & { + [K in `${typeof CoreBindings.SERVICE_USER_ENTRYPOINT_PREFIX}${string}`]: + | Fetcher + | undefined; }; const encoder = new TextEncoder(); @@ -139,13 +153,115 @@ function getUserRequest( return request; } +/** + * Resolves localhost entrypoint routing. + * + * - {worker}.localhost → default entrypoint (if exposed) + * - {alias}.{worker}.localhost → WorkerEntrypoint dispatch + * - 3+ subdomain levels → 404 + * + * DurableObject and WorkflowEntrypoint exports return 400 with a helpful message. + * + * Returns the matched Fetcher, or undefined if the hostname has no subdomain + * (i.e. plain "localhost"). Throws HttpError for unmatched subdomains. + */ +function resolveHostnameRoute( + hostname: string, + config: NonNullable, + env: Env +): Fetcher | undefined { + const [tld, workerName, entrypointAlias, ...rest] = hostname + .split(".") + .reverse(); + + if (tld !== "localhost" || workerName === undefined) { + return undefined; + } + + if (rest.length > 0) { + throw new HttpError( + 404, + `Invalid subdomain: "${hostname}". ` + + `Use http://{worker}.localhost or http://{entrypoint}.{worker}.localhost if you want to route to a specific entrypoint` + ); + } + + const entrypoints = config[workerName]; + if (entrypoints === undefined) { + const workerNames = Object.keys(config); + throw new HttpError( + 404, + `Worker "${workerName}" not found. ` + + `Available workers: ${workerNames.join(", ")}` + ); + } + + if (entrypointAlias === undefined) { + const defaultEntry = Object.values(entrypoints).find( + (e) => e.export === "default" + ); + if (defaultEntry === undefined) { + throw new HttpError( + 404, + `Worker "${workerName}" does not expose its default entrypoint. ` + + `Available entrypoints: ${Object.keys(entrypoints).join(", ")}` + ); + } + + return env[ + `${CoreBindings.SERVICE_USER_ENTRYPOINT_PREFIX}${workerName}:default` + ]; + } + + const entry = entrypoints[entrypointAlias]; + if (entry === undefined) { + throw new HttpError( + 404, + `Entrypoint "${entrypointAlias}" not found on worker "${workerName}". ` + + `Available entrypoints: ${Object.keys(entrypoints).join(", ")}` + ); + } + + if (entry.type !== "WorkerEntrypoint") { + throw new HttpError( + 400, + `"${entrypointAlias}" is a ${entry.type} and cannot be accessed via subdomain routing` + ); + } + + return env[ + `${CoreBindings.SERVICE_USER_ENTRYPOINT_PREFIX}${workerName}:${entry.export}` + ]; +} + function getTargetService(request: Request, url: URL, env: Env) { let service: Fetcher | undefined = env[CoreBindings.SERVICE_USER_FALLBACK]; + // 1. Explicit override header takes priority const override = request.headers.get(CoreHeaders.ROUTE_OVERRIDE); request.headers.delete(CoreHeaders.ROUTE_OVERRIDE); - const route = override ?? matchRoutes(env[CoreBindings.JSON_ROUTES], url); + if (override !== null) { + service = env[`${CoreBindings.SERVICE_USER_ROUTE_PREFIX}${override}`]; + return service; + } + + // 2. Hostname-based routing (opt-in) + // resolveHostnameRoute throws HttpError for unmatched subdomains + const entrypointSubdomains = env[CoreBindings.JSON_ENTRYPOINT_SUBDOMAINS]; + if (entrypointSubdomains) { + const resolved = resolveHostnameRoute( + url.hostname, + entrypointSubdomains, + env + ); + if (resolved) { + return resolved; + } + } + + // 3. Standard route matching (existing behavior) + const route = matchRoutes(env[CoreBindings.JSON_ROUTES], url); if (route !== null) { service = env[`${CoreBindings.SERVICE_USER_ROUTE_PREFIX}${route}`]; } @@ -404,7 +520,16 @@ export default >{ throw e; } const url = new URL(request.url); - const service = getTargetService(request, url, env); + + let service: Fetcher | undefined; + try { + service = getTargetService(request, url, env); + } catch (e) { + if (e instanceof HttpError) { + return e.toResponse(); + } + throw e; + } if (service === undefined) { return new Response("No entrypoint worker found", { status: 404 }); } diff --git a/packages/miniflare/test/index.spec.ts b/packages/miniflare/test/index.spec.ts index b97f61fbaa34..77e3c11d8e00 100644 --- a/packages/miniflare/test/index.spec.ts +++ b/packages/miniflare/test/index.spec.ts @@ -939,6 +939,314 @@ test("Miniflare: service binding to named entrypoint that implements a method re expect(rpcTarget.id).toEqual("test-id"); }); +test("Miniflare: entrypointSubdomains ROUTE_OVERRIDE takes priority", async ({ + expect, +}) => { + const mf = new Miniflare({ + workers: [ + { + name: "main", + modules: true, + script: `export default { fetch() { return new Response("main"); } }`, + }, + { + name: "my-api", + modules: true, + unsafeEntrypointSubdomains: { default: "default" }, + script: `export default { fetch() { return new Response("my-api"); } }`, + }, + ], + }); + useDispose(mf); + + // ROUTE_OVERRIDE header should take priority over entrypoint routing. + // Without the override, default.my-api.localhost would route to my-api. + const res = await mf.dispatchFetch("http://default.my-api.localhost/", { + headers: { "MF-Route-Override": "main" }, + }); + expect(await res.text()).toBe("main"); +}); + +test("Miniflare: entrypointSubdomains uses {entrypoint}.{worker}.localhost", async ({ + expect, +}) => { + const mf = new Miniflare({ + workers: [ + { + name: "main", + modules: true, + script: `export default { fetch() { return new Response("main"); } }`, + }, + { + name: "api", + modules: true, + unsafeEntrypointSubdomains: { + default: "default", + UsersEntrypoint: "users", + }, + script: ` + import { WorkerEntrypoint } from "cloudflare:workers"; + export class UsersEntrypoint extends WorkerEntrypoint { + fetch(request) { return new Response("api:users"); } + } + export default { fetch() { return new Response("api:default"); } } + `, + }, + { + name: "admin", + modules: true, + unsafeEntrypointSubdomains: { + default: "default", + }, + script: `export default { fetch() { return new Response("admin:default"); } }`, + }, + { + // Worker that only exposes named entrypoints (no default) + name: "internal", + modules: true, + unsafeEntrypointSubdomains: { + HealthEntrypoint: "health", + }, + script: ` + import { WorkerEntrypoint } from "cloudflare:workers"; + export class HealthEntrypoint extends WorkerEntrypoint { + fetch() { return new Response("internal:health"); } + } + export default { fetch() { return new Response("internal:default"); } } + `, + }, + ], + }); + useDispose(mf); + + // {entrypoint}.{worker}.localhost routes to entrypoint + let res = await mf.dispatchFetch("http://users.api.localhost/"); + expect(await res.text()).toBe("api:users"); + + // Explicit default entrypoint mapping + res = await mf.dispatchFetch("http://default.api.localhost/"); + expect(await res.text()).toBe("api:default"); + + res = await mf.dispatchFetch("http://default.admin.localhost/"); + expect(await res.text()).toBe("admin:default"); + + // {worker}.localhost routes to default entrypoint when exposed + res = await mf.dispatchFetch("http://api.localhost/"); + expect(await res.text()).toBe("api:default"); + + res = await mf.dispatchFetch("http://admin.localhost/"); + expect(await res.text()).toBe("admin:default"); + + // {worker}.localhost returns 404 when default is not exposed + res = await mf.dispatchFetch("http://internal.localhost/"); + expect(res.status).toBe(404); + expect(await res.text()).toContain("does not expose its default entrypoint"); + + // default.{worker}.localhost returns 404 when default is not exposed + res = await mf.dispatchFetch("http://default.internal.localhost/"); + expect(res.status).toBe(404); + expect(await res.text()).toContain( + `Entrypoint "default" not found on worker "internal"` + ); + + // Named entrypoint on worker without default still works + res = await mf.dispatchFetch("http://health.internal.localhost/"); + expect(await res.text()).toBe("internal:health"); + + // Plain localhost falls through to fallback (first worker) + res = await mf.dispatchFetch("http://localhost/"); + expect(await res.text()).toBe("main"); + + // Unknown worker returns 404 + res = await mf.dispatchFetch("http://users.unknown.localhost/"); + expect(res.status).toBe(404); + expect(await res.text()).toContain(`Worker "unknown" not found`); + + // Unknown entrypoint on known worker returns 404 + res = await mf.dispatchFetch("http://nonexistent.api.localhost/"); + expect(res.status).toBe(404); + expect(await res.text()).toContain( + `Entrypoint "nonexistent" not found on worker "api"` + ); + + // Three+ subdomain levels returns 404 + res = await mf.dispatchFetch("http://a.b.c.localhost/"); + expect(res.status).toBe(404); + expect(await res.text()).toContain("Invalid subdomain"); +}); + +test("Miniflare: entrypointSubdomains routes /cdn-cgi/handler/* to correct worker", async ({ + expect, +}) => { + const mf = new Miniflare({ + unsafeTriggerHandlers: true, + workers: [ + { + name: "main", + modules: true, + script: `export default { fetch() { return new Response("main"); } }`, + }, + { + name: "worker-a", + modules: true, + unsafeEntrypointSubdomains: { + default: "default", + }, + script: ` + let scheduledRan = false; + let emailReceived = false; + export default { + fetch() { + return Response.json({ worker: "a", scheduledRan, emailReceived }); + }, + scheduled() { scheduledRan = true; }, + email() { emailReceived = true; } + } + `, + }, + { + name: "worker-b", + modules: true, + unsafeEntrypointSubdomains: { + default: "default", + }, + script: ` + let scheduledRan = false; + let emailReceived = false; + export default { + fetch() { + return Response.json({ worker: "b", scheduledRan, emailReceived }); + }, + scheduled() { scheduledRan = true; }, + email() { emailReceived = true; } + } + `, + }, + ], + }); + useDispose(mf); + + // Both workers start clean + let res = await mf.dispatchFetch("http://default.worker-a.localhost/"); + expect(await res.json()).toEqual({ + worker: "a", + scheduledRan: false, + emailReceived: false, + }); + res = await mf.dispatchFetch("http://default.worker-b.localhost/"); + expect(await res.json()).toEqual({ + worker: "b", + scheduledRan: false, + emailReceived: false, + }); + + // Trigger worker-a's scheduled handler + res = await mf.dispatchFetch( + "http://default.worker-a.localhost/cdn-cgi/handler/scheduled" + ); + expect(res.status).toBe(200); + expect(await res.text()).toBe("ok"); + + // Only worker-a's scheduled ran + res = await mf.dispatchFetch("http://default.worker-a.localhost/"); + expect(await res.json()).toEqual({ + worker: "a", + scheduledRan: true, + emailReceived: false, + }); + res = await mf.dispatchFetch("http://default.worker-b.localhost/"); + expect(await res.json()).toEqual({ + worker: "b", + scheduledRan: false, + emailReceived: false, + }); + + // Trigger worker-b's email handler + res = await mf.dispatchFetch( + "http://default.worker-b.localhost/cdn-cgi/handler/email?from=a@example.com&to=b@example.com", + { + method: "POST", + body: `From: a \r\nTo: b \r\nMessage-ID: \r\nMIME-Version: 1.0\r\nContent-Type: text/plain\r\n\r\ntest`, + } + ); + expect(await res.text()).toBe("Worker successfully processed email"); + + // Only worker-b's email handler ran + res = await mf.dispatchFetch("http://default.worker-a.localhost/"); + expect(await res.json()).toEqual({ + worker: "a", + scheduledRan: true, + emailReceived: false, + }); + res = await mf.dispatchFetch("http://default.worker-b.localhost/"); + expect(await res.json()).toEqual({ + worker: "b", + scheduledRan: false, + emailReceived: true, + }); +}); + +test("Miniflare: entrypointSubdomains returns 400 for DurableObject and WorkflowEntrypoint", async ({ + expect, +}) => { + const mf = new Miniflare({ + workers: [ + { + name: "main", + modules: true, + script: `export default { fetch() { return new Response("main"); } }`, + }, + { + name: "app", + modules: true, + durableObjects: { COUNTER: "Counter" }, + workflows: { + WORKFLOW: { + name: "my-workflow", + className: "MyWorkflow", + }, + }, + unsafeEntrypointSubdomains: { + default: "default", + Counter: "counter", + MyWorkflow: "workflow", + }, + script: ` + import { DurableObject, WorkflowEntrypoint } from "cloudflare:workers"; + export class Counter extends DurableObject { + async fetch(request) { return new Response("counter"); } + } + export class MyWorkflow extends WorkflowEntrypoint { + async run(event, step) { return "done"; } + } + export default { fetch() { return new Response("app:default"); } } + `, + }, + ], + }); + useDispose(mf); + + // DurableObject alias → 400 + let res = await mf.dispatchFetch("http://counter.app.localhost/"); + expect(res.status).toBe(400); + expect(await res.text()).toContain( + `"counter" is a DurableObject and cannot be accessed via subdomain routing` + ); + + // Workflow alias → 400 + res = await mf.dispatchFetch("http://workflow.app.localhost/"); + expect(res.status).toBe(400); + expect(await res.text()).toContain( + `"workflow" is a WorkflowEntrypoint and cannot be accessed via subdomain routing` + ); + + // WorkerEntrypoint default still works + res = await mf.dispatchFetch("http://app.localhost/"); + expect(await res.text()).toBe("app:default"); + res = await mf.dispatchFetch("http://default.app.localhost/"); + expect(await res.text()).toBe("app:default"); +}); + test("Miniflare: tail consumer called", async ({ expect }) => { const mf = new Miniflare({ handleRuntimeStdio: () => {}, diff --git a/packages/vite-plugin-cloudflare/playground/__test-utils__/responses.ts b/packages/vite-plugin-cloudflare/playground/__test-utils__/responses.ts index 9387980d9806..b9927b7053c1 100644 --- a/packages/vite-plugin-cloudflare/playground/__test-utils__/responses.ts +++ b/packages/vite-plugin-cloudflare/playground/__test-utils__/responses.ts @@ -1,14 +1,18 @@ import { page, viteTestUrl } from "./index"; -export async function getTextResponse(path = "/"): Promise { - const response = await getResponse(path); +export async function getTextResponse( + path = "/", + hostname?: string +): Promise { + const response = await getResponse(path, hostname); return response.text(); } export async function getJsonResponse( - path = "/" + path = "/", + hostname?: string ): Promise | Array> { - const response = await getResponse(path); + const response = await getResponse(path, hostname); const text = await response.text(); try { return JSON.parse(text); @@ -17,8 +21,14 @@ export async function getJsonResponse( } } -export async function getResponse(path = "/") { - const url = `${viteTestUrl}${path}`; +export async function getResponse(path = "/", hostname?: string) { + let url: string; + if (hostname) { + const base = new URL(viteTestUrl); + url = `${base.protocol}//${hostname}:${base.port}${path}`; + } else { + url = `${viteTestUrl}${path}`; + } const response = page.waitForResponse(url); await page.goto(url); return response; diff --git a/packages/vite-plugin-cloudflare/playground/multi-worker/__tests__/entrypoint-routing/entrypoint-routing.spec.ts b/packages/vite-plugin-cloudflare/playground/multi-worker/__tests__/entrypoint-routing/entrypoint-routing.spec.ts new file mode 100644 index 000000000000..891dbbc883d9 --- /dev/null +++ b/packages/vite-plugin-cloudflare/playground/multi-worker/__tests__/entrypoint-routing/entrypoint-routing.spec.ts @@ -0,0 +1,50 @@ +import { describe, test } from "vitest"; +import { getJsonResponse, getResponse, isBuild } from "../../../__test-utils__"; + +describe.skipIf(isBuild)("entrypoint routing", () => { + test("routes to worker-a entrypoint via {entrypoint}.{worker}.localhost", async ({ + expect, + }) => { + const result = await getJsonResponse("/", "greet.worker-a.localhost"); + expect(result).toEqual({ name: "Hello from Named entrypoint" }); + }); + + test("routes to worker-b entrypoint", async ({ expect }) => { + const result = await getJsonResponse( + "/", + "namedentrypoint.worker-b.localhost" + ); + expect(result).toEqual({ name: "Worker B: Named entrypoint" }); + }); + + test("{worker}.localhost routes to default entrypoint", async ({ + expect, + }) => { + const result = await getJsonResponse("/", "worker-a.localhost"); + expect(result).toEqual({ name: "Worker A" }); + }); + + test("plain localhost falls through to default", async ({ expect }) => { + const result = await getJsonResponse("/"); + expect(result).toEqual({ name: "Worker A" }); + }); + + test("returns 404 for unknown worker via single-level subdomain", async ({ + expect, + }) => { + const response = await getResponse("/", "greet.localhost"); + expect(response.status()).toBe(404); + }); + + test("returns 404 for unknown worker", async ({ expect }) => { + const response = await getResponse("/", "greet.unknown.localhost"); + expect(response.status()).toBe(404); + }); + + test("returns 404 for unknown entrypoint on known worker", async ({ + expect, + }) => { + const response = await getResponse("/", "nonexistent.worker-a.localhost"); + expect(response.status()).toBe(404); + }); +}); diff --git a/packages/vite-plugin-cloudflare/playground/multi-worker/vite.config.entrypoint-routing.ts b/packages/vite-plugin-cloudflare/playground/multi-worker/vite.config.entrypoint-routing.ts new file mode 100644 index 000000000000..7ec1a517b90a --- /dev/null +++ b/packages/vite-plugin-cloudflare/playground/multi-worker/vite.config.entrypoint-routing.ts @@ -0,0 +1,19 @@ +import { cloudflare } from "@cloudflare/vite-plugin"; +import { defineConfig } from "vite"; + +export default defineConfig({ + plugins: [ + cloudflare({ + configPath: "./worker-a/wrangler.jsonc", + exposeEntrypoints: true, + auxiliaryWorkers: [ + { + configPath: "./worker-b/wrangler.jsonc", + exposeEntrypoints: true, + }, + ], + inspectorPort: false, + persistState: false, + }), + ], +}); diff --git a/packages/vite-plugin-cloudflare/playground/multi-worker/worker-a/index.ts b/packages/vite-plugin-cloudflare/playground/multi-worker/worker-a/index.ts index 5473e137ed1b..af6be81aad62 100644 --- a/packages/vite-plugin-cloudflare/playground/multi-worker/worker-a/index.ts +++ b/packages/vite-plugin-cloudflare/playground/multi-worker/worker-a/index.ts @@ -1,3 +1,4 @@ +import { WorkerEntrypoint } from "cloudflare:workers"; import type WorkerB from "../worker-b"; import type { NamedEntrypoint } from "../worker-b"; @@ -7,6 +8,14 @@ interface Env { CONFIGURED_VAR?: string; } +export class Greet extends WorkerEntrypoint { + override fetch() { + return Response.json({ + name: "Hello from Named entrypoint", + }); + } +} + export default { async fetch(request, env) { const url = new URL(request.url); diff --git a/packages/vite-plugin-cloudflare/playground/multi-worker/worker-b/index.ts b/packages/vite-plugin-cloudflare/playground/multi-worker/worker-b/index.ts index 92dc2750a51c..526aafbde3c9 100644 --- a/packages/vite-plugin-cloudflare/playground/multi-worker/worker-b/index.ts +++ b/packages/vite-plugin-cloudflare/playground/multi-worker/worker-b/index.ts @@ -43,4 +43,10 @@ export class NamedEntrypoint extends WorkerEntrypoint { }, }; } + + override fetch() { + return Response.json({ + name: "Worker B: Named entrypoint", + }); + } } diff --git a/packages/vite-plugin-cloudflare/src/hostname-routing.ts b/packages/vite-plugin-cloudflare/src/hostname-routing.ts new file mode 100644 index 000000000000..4d291568c995 --- /dev/null +++ b/packages/vite-plugin-cloudflare/src/hostname-routing.ts @@ -0,0 +1,44 @@ +import type { ExportTypes } from "./export-types"; +import type { Worker } from "./plugin-config"; + +/** + * Resolves entrypoint routing for a single worker based on its `exposeEntrypoints` config. + * + * Returns the `entrypointSubdomains` record (export name -> alias) to pass to the + * worker's miniflare options, or `undefined` if the worker doesn't opt in. + * Miniflare handles validation, normalization, and collision detection. + * + * - `true`: all exports (including default) are exposed with export names as aliases + * - `Record`: explicit mapping of export names to aliases + * (`true` uses the export name, `false` excludes the entrypoint) + */ +export function resolveEntrypointRouting( + worker: Worker, + exportTypes: ExportTypes +): Record | undefined { + if (!worker.exposeEntrypoints) { + return undefined; + } + + const entrypoints: Record = {}; + + if (worker.exposeEntrypoints === true) { + // The default export is always present on a worker module but + // exportTypes doesn't include it, so add it explicitly. + entrypoints["default"] = "default"; + for (const [exportName] of Object.entries(exportTypes ?? {})) { + entrypoints[exportName] = exportName; + } + } else { + for (const [exportName, aliasOrTrue] of Object.entries( + worker.exposeEntrypoints + )) { + if (aliasOrTrue === false) { + continue; + } + entrypoints[exportName] = aliasOrTrue === true ? exportName : aliasOrTrue; + } + } + + return entrypoints; +} diff --git a/packages/vite-plugin-cloudflare/src/miniflare-options.ts b/packages/vite-plugin-cloudflare/src/miniflare-options.ts index 4570595062a1..397e2dc05404 100644 --- a/packages/vite-plugin-cloudflare/src/miniflare-options.ts +++ b/packages/vite-plugin-cloudflare/src/miniflare-options.ts @@ -26,6 +26,7 @@ import { } from "./constants"; import { getContainerOptions, getDockerPath } from "./containers"; import { getInputInspectorPort } from "./debug"; +import { resolveEntrypointRouting } from "./hostname-routing"; import { additionalModuleRE } from "./plugins/additional-modules"; import { ENVIRONMENT_NAME_HEADER } from "./shared"; import { withTrailingSlash } from "./utils"; @@ -327,11 +328,17 @@ export async function getDevMiniflareOptions( ); } + const entrypointSubdomains = resolveEntrypointRouting( + worker, + exportTypes + ); + return { externalWorkers, worker: { ...workerOptions, name: worker.config.name, + unsafeEntrypointSubdomains: entrypointSubdomains, modulesRoot: miniflareModulesRoot, modules: [ { diff --git a/packages/vite-plugin-cloudflare/src/plugin-config.ts b/packages/vite-plugin-cloudflare/src/plugin-config.ts index 139a0f852235..dc4e67c5e775 100644 --- a/packages/vite-plugin-cloudflare/src/plugin-config.ts +++ b/packages/vite-plugin-cloudflare/src/plugin-config.ts @@ -26,6 +26,7 @@ export type PersistState = boolean | { path: string }; interface BaseWorkerConfig { viteEnvironment?: { name?: string; childEnvironments?: string[] }; + exposeEntrypoints?: boolean | Record; } interface EntryWorkerConfig extends BaseWorkerConfig { @@ -90,6 +91,7 @@ export interface ResolvedWorkerConfig extends ResolvedAssetsOnlyConfig { export interface Worker { config: ResolvedWorkerConfig; nodeJsCompat: NodeJsCompat | undefined; + exposeEntrypoints?: boolean | Record; } interface BaseResolvedConfig { @@ -348,7 +350,10 @@ export function resolvePluginConfig( environmentNameToWorkerMap.set( prerenderWorkerEnvironmentName, - resolveWorker(workerResolvedConfig.config as ResolvedWorkerConfig) + resolveWorker( + workerResolvedConfig.config as ResolvedWorkerConfig, + prerenderWorkerConfig.exposeEntrypoints + ) ); const prerenderWorkerChildEnvironments = @@ -401,7 +406,10 @@ export function resolvePluginConfig( environmentNameToWorkerMap.set( entryWorkerEnvironmentName, - resolveWorker(entryWorkerResolvedConfig.config) + resolveWorker( + entryWorkerResolvedConfig.config, + pluginConfig.exposeEntrypoints + ) ); const entryWorkerChildEnvironments = @@ -448,7 +456,10 @@ export function resolvePluginConfig( environmentNameToWorkerMap.set( workerEnvironmentName, - resolveWorker(workerResolvedConfig.config as ResolvedWorkerConfig) + resolveWorker( + workerResolvedConfig.config as ResolvedWorkerConfig, + auxiliaryWorker.exposeEntrypoints + ) ); const auxiliaryWorkerChildEnvironments = @@ -505,11 +516,15 @@ function createEnvironmentNameValidator() { }; } -function resolveWorker(workerConfig: ResolvedWorkerConfig): Worker { +function resolveWorker( + workerConfig: ResolvedWorkerConfig, + exposeEntrypoints?: boolean | Record +): Worker { return { config: workerConfig, nodeJsCompat: hasNodeJsCompat(workerConfig) ? new NodeJsCompat(workerConfig) : undefined, + exposeEntrypoints, }; } diff --git a/packages/vite-plugin-cloudflare/src/plugins/dev.ts b/packages/vite-plugin-cloudflare/src/plugins/dev.ts index 110a4039e215..5a964cac4db1 100644 --- a/packages/vite-plugin-cloudflare/src/plugins/dev.ts +++ b/packages/vite-plugin-cloudflare/src/plugins/dev.ts @@ -279,10 +279,20 @@ export const devPlugin = createPlugin("dev", (ctx) => { redirect: "manual", }); } else { - request.headers.set( - CoreHeaders.ROUTE_OVERRIDE, - ROUTER_WORKER_NAME - ); + // Let the entry worker's hostname routing handle subdomain requests + const host = request.headers.get("Host"); + const hostname = host?.replace(/:\d+$/, ""); + const isSubdomainRequest = + hostname && + hostname.endsWith(".localhost") && + hostname !== "localhost"; + + if (!isSubdomainRequest) { + request.headers.set( + CoreHeaders.ROUTE_OVERRIDE, + ROUTER_WORKER_NAME + ); + } return ctx.miniflare.dispatchFetch(request, { redirect: "manual", diff --git a/packages/workers-utils/src/config/config.ts b/packages/workers-utils/src/config/config.ts index 5289f8715b68..145ba11b5333 100644 --- a/packages/workers-utils/src/config/config.ts +++ b/packages/workers-utils/src/config/config.ts @@ -266,6 +266,16 @@ export interface DevConfig { * @default false */ generate_types: boolean; + + /** + * Expose entrypoints via localhost subdomain URLs during local development. + * + * - `true`: expose all exports, using export names as hostname aliases + * - `Record`: selectively expose specific exports; + * `true` uses the export name as the alias, a string sets a custom alias + * - `false` or omitted: disabled (default behavior) + */ + expose_entrypoints: boolean | Record; } export type RawDevConfig = Partial; @@ -314,6 +324,7 @@ export const defaultWranglerConfig: Config = { enable_containers: true, container_engine: undefined, generate_types: false, + expose_entrypoints: false, }, /** INHERITABLE ENVIRONMENT FIELDS **/ diff --git a/packages/workers-utils/src/config/validation.ts b/packages/workers-utils/src/config/validation.ts index bb27f4370138..90dc40c7fe2e 100644 --- a/packages/workers-utils/src/config/validation.ts +++ b/packages/workers-utils/src/config/validation.ts @@ -705,6 +705,7 @@ function normalizeAndValidateDev( enable_containers = enableContainersArg ?? true, container_engine, generate_types = generateTypesArg ?? false, + expose_entrypoints, ...rest } = rawDev; validateAdditionalProperties(diagnostics, "dev", Object.keys(rest), []); @@ -766,6 +767,30 @@ function normalizeAndValidateDev( "boolean" ); + if ( + expose_entrypoints !== undefined && + expose_entrypoints !== false && + expose_entrypoints !== true + ) { + if ( + typeof expose_entrypoints !== "object" || + expose_entrypoints === null || + Array.isArray(expose_entrypoints) + ) { + diagnostics.errors.push( + `Expected "dev.expose_entrypoints" to be a boolean or an object, but got ${JSON.stringify(expose_entrypoints)}` + ); + } else { + for (const [key, val] of Object.entries(expose_entrypoints)) { + if (typeof val !== "boolean" && typeof val !== "string") { + diagnostics.errors.push( + `Expected "dev.expose_entrypoints.${key}" to be a boolean or a string alias, but got ${JSON.stringify(val)}` + ); + } + } + } + } + return { ip, port, @@ -777,6 +802,7 @@ function normalizeAndValidateDev( enable_containers, container_engine, generate_types, + expose_entrypoints: expose_entrypoints ?? false, }; } diff --git a/packages/workers-utils/tests/config/validation/normalize-and-validate-config.pages.test.ts b/packages/workers-utils/tests/config/validation/normalize-and-validate-config.pages.test.ts index 57cace8defa4..37e92883f04d 100644 --- a/packages/workers-utils/tests/config/validation/normalize-and-validate-config.pages.test.ts +++ b/packages/workers-utils/tests/config/validation/normalize-and-validate-config.pages.test.ts @@ -46,6 +46,7 @@ describe("normalizeAndValidateConfig() - Pages configuration", () => { host: "127.0.0.0", enable_containers: true, generate_types: false, + expose_entrypoints: false, }, send_metrics: true, @@ -149,6 +150,7 @@ describe("normalizeAndValidateConfig() - Pages configuration", () => { host: "127.0.0.0", enable_containers: true, generate_types: false, + expose_entrypoints: false, }, send_metrics: true, @@ -252,6 +254,7 @@ describe("normalizeAndValidateConfig() - Pages configuration", () => { host: "127.0.0.0", enable_containers: true, generate_types: false, + expose_entrypoints: false, }, send_metrics: true, @@ -372,6 +375,7 @@ describe("normalizeAndValidateConfig() - Pages configuration", () => { host: "127.0.0.0", enable_containers: true, generate_types: false, + expose_entrypoints: false, }, send_metrics: true, @@ -483,6 +487,7 @@ describe("normalizeAndValidateConfig() - Pages configuration", () => { host: "127.0.0.0", enable_containers: true, generate_types: false, + expose_entrypoints: false, }, send_metrics: true, @@ -594,6 +599,7 @@ describe("normalizeAndValidateConfig() - Pages configuration", () => { host: "127.0.0.0", enable_containers: true, generate_types: false, + expose_entrypoints: false, }, send_metrics: true, @@ -703,6 +709,7 @@ describe("normalizeAndValidateConfig() - Pages configuration", () => { host: "127.0.0.0", enable_containers: true, generate_types: false, + expose_entrypoints: false, }, send_metrics: true, @@ -852,6 +859,7 @@ function generateRawConfigForPages( upstream_protocol: "https", host: "127.0.0.0", generate_types: false, + expose_entrypoints: false, }, send_metrics: true, diff --git a/packages/workers-utils/tests/config/validation/normalize-and-validate-config.test.ts b/packages/workers-utils/tests/config/validation/normalize-and-validate-config.test.ts index 68d6b5ebbf04..3806a9b6f79b 100644 --- a/packages/workers-utils/tests/config/validation/normalize-and-validate-config.test.ts +++ b/packages/workers-utils/tests/config/validation/normalize-and-validate-config.test.ts @@ -47,6 +47,7 @@ describe("normalizeAndValidateConfig()", () => { inspector_ip: undefined, container_engine: undefined, generate_types: false, + expose_entrypoints: false, }, containers: undefined, cloudchamber: {}, @@ -145,6 +146,7 @@ describe("normalizeAndValidateConfig()", () => { upstream_protocol: "http", enable_containers: false, generate_types: false, + expose_entrypoints: false, }, }; @@ -172,6 +174,7 @@ describe("normalizeAndValidateConfig()", () => { upstream_protocol: "ws", enable_containers: true, generate_types: false, + expose_entrypoints: false, }, }; diff --git a/packages/wrangler/e2e/localhost-entrypoint-routing.test.ts b/packages/wrangler/e2e/localhost-entrypoint-routing.test.ts new file mode 100644 index 000000000000..1d4132f925dc --- /dev/null +++ b/packages/wrangler/e2e/localhost-entrypoint-routing.test.ts @@ -0,0 +1,177 @@ +import { lookup } from "node:dns/promises"; +import dedent from "ts-dedent"; +import { fetch } from "undici"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { fetchText } from "./helpers/fetch-text"; +import { seed as baseSeed, makeRoot } from "./helpers/setup"; +import { WranglerLongLivedCommand } from "./helpers/wrangler"; + +// Check if *.localhost subdomains resolve on this system. +// Some environments (Windows, older macOS, Alpine) don't support this. +let localhostSubdomainsSupported = true; +try { + const result = await lookup("test.domain.localhost"); + localhostSubdomainsSupported = + result.address === "127.0.0.1" || result.address === "::1"; +} catch { + localhostSubdomainsSupported = false; +} + +// Worker A source: Greet + Farewell entrypoints + default +const workerASrc = dedent/* javascript */ ` + import { WorkerEntrypoint } from "cloudflare:workers"; + + export class Greet extends WorkerEntrypoint { + async fetch() { + return new Response("Hello from worker-a"); + } + } + + export class Farewell extends WorkerEntrypoint { + async fetch() { + return new Response("Goodbye from worker-a"); + } + } + + export default { + fetch() { + return new Response("worker-a default"); + }, + }; +`; + +// Worker B source: Echo entrypoint + default +const workerBSrc = dedent/* javascript */ ` + import { WorkerEntrypoint } from "cloudflare:workers"; + + export class Echo extends WorkerEntrypoint { + async fetch(request) { + return new Response("echo:" + new URL(request.url).pathname); + } + } + + export default { + fetch() { + return new Response("worker-b default"); + }, + }; +`; + +describe.skipIf(!localhostSubdomainsSupported)( + "localhost entrypoint routing", + () => { + let urls: Record; + let wrangler: WranglerLongLivedCommand; + + beforeAll(async () => { + const a = makeRoot(); + await baseSeed(a, { + "wrangler.toml": dedent` + name = "worker-a" + main = "src/index.ts" + compatibility_date = "2025-01-01" + + [dev] + expose_entrypoints = true + `, + "src/index.ts": workerASrc, + "package.json": dedent` + { + "name": "worker-a", + "version": "0.0.0", + "private": true + } + `, + }); + + const b = makeRoot(); + await baseSeed(b, { + "wrangler.toml": dedent` + name = "worker-b" + main = "src/index.ts" + compatibility_date = "2025-01-01" + + [dev] + expose_entrypoints = true + `, + "src/index.ts": workerBSrc, + "package.json": dedent` + { + "name": "worker-b", + "version": "0.0.0", + "private": true + } + `, + }); + + wrangler = new WranglerLongLivedCommand( + `wrangler dev -c wrangler.toml -c ${b}/wrangler.toml`, + { cwd: a } + ); + const { url } = await wrangler.waitForReady(); + const { port } = new URL(url); + urls = { + default: url, + "greet.worker-a": `http://greet.worker-a.localhost:${port}`, + "farewell.worker-a": `http://farewell.worker-a.localhost:${port}`, + "echo.worker-b": `http://echo.worker-b.localhost:${port}`, + "worker-a": `http://worker-a.localhost:${port}`, + "worker-b": `http://worker-b.localhost:${port}`, + greet: `http://greet.localhost:${port}`, + "greet.unknown": `http://greet.unknown.localhost:${port}`, + "nonexistent.worker-a": `http://nonexistent.worker-a.localhost:${port}`, + }; + }); + + afterAll(async () => { + await wrangler?.stop(); + }); + + it("routes to worker-a entrypoint via {entrypoint}.{worker}.localhost", async () => { + await expect(fetchText(urls["greet.worker-a"])).resolves.toBe( + "Hello from worker-a" + ); + }); + + it("routes to another entrypoint on the same worker", async () => { + await expect(fetchText(urls["farewell.worker-a"])).resolves.toBe( + "Goodbye from worker-a" + ); + }); + + it("routes to worker-b entrypoint", async () => { + await expect(fetchText(urls["echo.worker-b"])).resolves.toBe("echo:/"); + }); + + it("routes to worker-a default via {worker}.localhost", async () => { + await expect(fetchText(urls["worker-a"])).resolves.toBe( + "worker-a default" + ); + }); + + it("routes to worker-b default via {worker}.localhost", async () => { + await expect(fetchText(urls["worker-b"])).resolves.toBe( + "worker-b default" + ); + }); + + it("falls through to primary worker default on plain localhost", async () => { + await expect(fetchText(urls.default)).resolves.toBe("worker-a default"); + }); + + it("returns 404 for unknown worker via single-level subdomain", async () => { + const res = await fetch(urls.greet); + expect(res.status).toBe(404); + }); + + it("returns 404 for an unknown worker name", async () => { + const res = await fetch(urls["greet.unknown"]); + expect(res.status).toBe(404); + }); + + it("returns 404 for an unknown entrypoint on a known worker", async () => { + const res = await fetch(urls["nonexistent.worker-a"]); + expect(res.status).toBe(404); + }); + } +); diff --git a/packages/wrangler/src/__tests__/config-validation-pages.test.ts b/packages/wrangler/src/__tests__/config-validation-pages.test.ts index 508352017de6..11924e62b39b 100644 --- a/packages/wrangler/src/__tests__/config-validation-pages.test.ts +++ b/packages/wrangler/src/__tests__/config-validation-pages.test.ts @@ -190,6 +190,7 @@ describe("validatePagesConfig()", () => { enable_containers: false, container_engine: undefined, generate_types: false, + expose_entrypoints: false, }, }, }; diff --git a/packages/wrangler/src/api/startDevWorker/ConfigController.ts b/packages/wrangler/src/api/startDevWorker/ConfigController.ts index 124f2c15355e..b0072d264e2d 100644 --- a/packages/wrangler/src/api/startDevWorker/ConfigController.ts +++ b/packages/wrangler/src/api/startDevWorker/ConfigController.ts @@ -165,6 +165,8 @@ async function resolveDevConfig( : undefined, containerBuildId: input.dev?.containerBuildId, generateTypes: input.dev?.generateTypes ?? config.dev.generate_types, + exposeEntrypoints: + input.dev?.exposeEntrypoints ?? config.dev.expose_entrypoints, } satisfies StartDevWorkerOptions["dev"]; } diff --git a/packages/wrangler/src/api/startDevWorker/LocalRuntimeController.ts b/packages/wrangler/src/api/startDevWorker/LocalRuntimeController.ts index 724d578c6385..0c2b766e1090 100644 --- a/packages/wrangler/src/api/startDevWorker/LocalRuntimeController.ts +++ b/packages/wrangler/src/api/startDevWorker/LocalRuntimeController.ts @@ -145,6 +145,7 @@ export async function convertToConfigBundle( enableContainers: event.config.dev.enableContainers ?? true, // Zone for CF-Worker header - extracted from routes/host configuration zone: event.config.dev?.origin?.hostname, + exposeEntrypoints: event.config.dev?.exposeEntrypoints ?? false, }; } diff --git a/packages/wrangler/src/api/startDevWorker/types.ts b/packages/wrangler/src/api/startDevWorker/types.ts index e3e698d3542a..1509ebfe0ea6 100644 --- a/packages/wrangler/src/api/startDevWorker/types.ts +++ b/packages/wrangler/src/api/startDevWorker/types.ts @@ -189,6 +189,14 @@ export interface StartDevWorkerInput { /** Re-generate your worker types when your Wrangler configuration file changes */ generateTypes?: boolean; + + /** Expose entrypoints via localhost subdomain URLs during local development. + * - true: expose all exports, using export names as hostname aliases + * - Record: selectively expose specific exports + * (true uses export name as alias, string sets a custom alias, false excludes) + * - false or omitted: disabled (default) + */ + exposeEntrypoints?: boolean | Record; }; legacy?: { site?: Hook; diff --git a/packages/wrangler/src/dev/miniflare/index.ts b/packages/wrangler/src/dev/miniflare/index.ts index baf18c8351a7..432384eb0c7d 100644 --- a/packages/wrangler/src/dev/miniflare/index.ts +++ b/packages/wrangler/src/dev/miniflare/index.ts @@ -96,6 +96,8 @@ export interface ConfigBundle { enableContainers: boolean; // Zone to use for the CF-Worker header in outbound fetches zone: string | undefined; + // Expose entrypoints via localhost subdomain routing + exposeEntrypoints: boolean | Record; } export class WranglerLog extends Log { @@ -897,6 +899,40 @@ export function buildSitesOptions({ } } +/** + * Resolves `expose_entrypoints` config into the `entrypointSubdomains` record + * (export name -> alias) that miniflare expects per worker. + * + * Unlike the Vite plugin, wrangler can't distinguish WorkerEntrypoint from + * DurableObject at build time. The `true` case exposes all exports. + */ +function resolveEntrypointRouting( + exposeEntrypoints: boolean | Record, + entrypointNames: string[] +): Record | undefined { + if (!exposeEntrypoints) { + return undefined; + } + + const entrypoints: Record = {}; + + if (exposeEntrypoints === true) { + for (const name of entrypointNames) { + entrypoints[name] = name; + } + } else { + for (const [exportName, aliasOrTrue] of Object.entries(exposeEntrypoints)) { + if (aliasOrTrue === false) { + continue; + } + entrypoints[exportName] = + aliasOrTrue === true ? exportName : String(aliasOrTrue); + } + } + + return entrypoints; +} + export type Options = Extract; export async function buildMiniflareOptions( @@ -920,6 +956,10 @@ export async function buildMiniflareOptions( const defaultPersistRoot = getDefaultPersistRoot(config.localPersistencePath); const assetOptions = buildAssetOptions(config); + const entrypointSubdomains = resolveEntrypointRouting( + config.exposeEntrypoints, + entrypointNames + ); const options: MiniflareOptions = { host: config.initialIp, port: config.initialPort, @@ -961,6 +1001,7 @@ export async function buildMiniflareOptions( entrypoint: name, proxy: true, })), + unsafeEntrypointSubdomains: entrypointSubdomains, containerEngine: config.containerEngine, zone: config.zone, },