Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/entrypoint-subdomains-miniflare.md
Original file line number Diff line number Diff line change
@@ -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.
26 changes: 26 additions & 0 deletions .changeset/entrypoint-subdomains-vite.md
Original file line number Diff line number Diff line change
@@ -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/
},
},
],
});
```
35 changes: 35 additions & 0 deletions .changeset/entrypoint-subdomains-wrangler.md
Original file line number Diff line number Diff line change
@@ -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/
},
},
}
```
96 changes: 96 additions & 0 deletions packages/miniflare/src/index.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -726,6 +727,86 @@ function getWorkerRoutes(
return allRoutes;
}

async function isLocalhostSubdomainSupported(): Promise<boolean> {
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<string, Record<string, EntrypointEntry>> = {};

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<string, EntrypointEntry> = {};
const seenAliases = new Map<string, string>();

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 [
Expand Down Expand Up @@ -922,6 +1003,7 @@ export class Miniflare {
#proxyClient?: ProxyClient;

#structuredWorkerdLogs: boolean;
#localhostSubdomainChecked = false;

#cfObject?: Record<string, any> = {};

Expand Down Expand Up @@ -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
Expand Down
44 changes: 44 additions & 0 deletions packages/miniflare/src/plugins/core/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),

Expand Down Expand Up @@ -962,6 +972,18 @@ export const CORE_PLUGIN: Plugin<
export interface GlobalServicesOptions {
sharedOptions: z.infer<typeof CoreSharedOptionsSchema>;
allWorkerRoutes: Map<string, string[]>;
allEntrypointSubdomains:
| Record<
string,
Record<
string,
{
export: string;
type: "DurableObject" | "WorkerEntrypoint" | "WorkflowEntrypoint";
}
>
>
| undefined;
fallbackWorkerName: string | undefined;
loopbackPort: number;
log: Log;
Expand All @@ -973,6 +995,7 @@ export interface GlobalServicesOptions {
export function getGlobalServices({
sharedOptions,
allWorkerRoutes,
allEntrypointSubdomains,
fallbackWorkerName,
loopbackPort,
log,
Expand Down Expand Up @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions packages/miniflare/src/workers/core/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading
Loading