Skip to content
Merged
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
7 changes: 7 additions & 0 deletions .changeset/cf-vite-drop-config-flag.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@cloudflare/vite-plugin": patch
---

Drop the `--config` flag from the experimental internal `cf-vite` delegate binary.

The wrangler config file is now discovered by `cloudflare()` itself rather than being passed through, keeping `cf-vite`'s flag surface (`--mode`, `--port`, `--host`, `--local`) in sync with the sibling `cf-wrangler` delegate. `cf-vite` is an internal integration point spawned by Cloudflare tooling and is not intended to be run directly by users.
9 changes: 9 additions & 0 deletions .changeset/cf-wrangler-delegate-entrypoint.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"wrangler": patch
---

Add an experimental `cf-wrangler` delegate entrypoint for projects that can't use `@cloudflare/vite-plugin` (service workers, old compatibility dates, Python, Rust, etc.).

`cf-wrangler dev` starts the same local dev server as `wrangler dev` — it sits directly on wrangler's internal dev server, so the bundling and runtime behaviour are identical — but exposes a deliberately narrow CLI surface (`--mode`, `--port`, `--host`, `--local`) for a parent CLI to delegate to, and other dev server config options are read from the wrangler config file.

This replaces the separate `@cloudflare/wrangler-bundler` package. This is an internal integration point and is not intended to be run directly by users.
10 changes: 10 additions & 0 deletions .changeset/improve-login-error-messages.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
"@cloudflare/workers-auth": patch
"wrangler": patch
---

Improve authentication error messages with specific failure reasons

When authentication fails (e.g. during `wrangler dev --remote` or when using remote bindings), the error message now explains exactly what went wrong -- whether no credentials were found, the token expired, or the environment is non-interactive -- and lists actionable steps to fix it, including a `wrangler whoami` tip.

Previously, auth failures could produce multiple confusing errors (e.g. "Failed to fetch auth token: 400 Bad Request" followed by "Failed to start the remote proxy session"). Now a single, clear error is shown.
7 changes: 7 additions & 0 deletions .changeset/improve-r2-sippy-error-messages.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"wrangler": patch
---

Improve R2 Sippy error messages

Now error messages in `wrangler r2 bucket sippy` follow a consistent pattern: they describe what is missing, name the exact `--flag` to use, and provide context (e.g. example values, links to the dashboard). Previously, many errors said only "Error: must provide --flag." with no guidance on what the flag does or how to obtain the value.
7 changes: 7 additions & 0 deletions .changeset/r2-local-public-bucket-miniflare.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"miniflare": minor
---

Add support for serving R2 bucket objects publicly via the dev server

Each local R2 bucket is now exposed under `/cdn-cgi/local/r2/public/<bucket-id>/<key>` on the existing user-facing dev server. The `<bucket-id>` is the bucket's `id` when set, otherwise its binding name. Buckets with a `remoteProxyConnectionString` are not exposed. The endpoint supports GET and HEAD, range requests, conditional headers, and forwards stored HTTP metadata.
7 changes: 7 additions & 0 deletions .changeset/r2-local-public-bucket-wrangler.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"wrangler": minor
---

Serve local R2 bucket objects publicly via the dev server

When running `wrangler dev` locally, objects in each local R2 binding are now reachable under `/cdn-cgi/local/r2/public/<bucket-id>/<key>` on the existing dev server, simulating a public bucket. The `<bucket-id>` is the bucket's `bucket_name` when set, otherwise its `binding`. Bindings configured with `remote: true` are not exposed.
12 changes: 12 additions & 0 deletions packages/miniflare/src/plugins/core/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import { RPC_PROXY_SERVICE_NAME } from "../assets/constants";
import { getCacheServiceName } from "../cache";
import { DURABLE_OBJECTS_STORAGE_SERVICE_NAME } from "../do";
import { IMAGES_PLUGIN_NAME } from "../images";
import { getR2PublicService, R2_PUBLIC_SERVICE_NAME } from "../r2";
import {
getUserBindingServiceName,
kUnsafeEphemeralUniqueKey,
Expand Down Expand Up @@ -1097,6 +1098,13 @@ export function getGlobalServices({
},
});
}
const r2PublicService = getR2PublicService(allWorkerOpts ?? []);
if (r2PublicService !== undefined) {
serviceEntryBindings.push({
name: CoreBindings.SERVICE_R2_PUBLIC,
service: { name: R2_PUBLIC_SERVICE_NAME },
});
}
const imagesBinding = allWorkerOpts
?.map((worker) => worker.images?.images)
.find(
Expand Down Expand Up @@ -1180,6 +1188,10 @@ export function getGlobalServices({
},
];

if (r2PublicService !== undefined) {
services.push(r2PublicService);
}

if (sharedOptions.unsafeLocalExplorer) {
const localExplorerUiPath = resolveLocalExplorerUi(tmpPath);
const IDToBindingMap: BindingIdMap = constructExplorerBindingMap(
Expand Down
33 changes: 33 additions & 0 deletions packages/miniflare/src/plugins/r2/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import fs from "node:fs/promises";
import SCRIPT_R2_BUCKET_OBJECT from "worker:r2/bucket";
import SCRIPT_R2_PUBLIC from "worker:r2/public";
import { z } from "zod";
import { SharedBindings } from "../../workers";
import {
Expand Down Expand Up @@ -47,12 +48,44 @@ export const R2SharedOptionsSchema = z.object({
export const R2_PLUGIN_NAME = "r2";
const R2_STORAGE_SERVICE_NAME = `${R2_PLUGIN_NAME}:storage`;
const R2_BUCKET_SERVICE_PREFIX = `${R2_PLUGIN_NAME}:bucket`;
export const R2_PUBLIC_SERVICE_NAME = `${R2_PLUGIN_NAME}:public`;
const R2_BUCKET_OBJECT_CLASS_NAME = "R2BucketObject";
const R2_BUCKET_OBJECT: Worker_Binding_DurableObjectNamespaceDesignator = {
serviceName: R2_BUCKET_SERVICE_PREFIX,
className: R2_BUCKET_OBJECT_CLASS_NAME,
};

export function getR2PublicService(
allWorkerOpts: { r2?: z.infer<typeof R2OptionsSchema> }[]
): Service | undefined {
const publicBucketIds = new Set<string>();
for (const worker of allWorkerOpts) {
for (const [, bucket] of namespaceEntries(worker.r2?.r2Buckets)) {
if (bucket.remoteProxyConnectionString !== undefined) {
continue;
}
publicBucketIds.add(bucket.id);
}
}
if (publicBucketIds.size === 0) {
return undefined;
}
const bindings = Array.from(publicBucketIds).map<Worker_Binding>((id) => ({
name: id,
r2Bucket: {
name: getUserBindingServiceName(R2_BUCKET_SERVICE_PREFIX, id),
},
}));
return {
name: R2_PUBLIC_SERVICE_NAME,
worker: {
compatibilityDate: "2026-01-01",
modules: [{ name: "public.worker.js", esModule: SCRIPT_R2_PUBLIC() }],
bindings,
},
};
}

export const R2_PLUGIN: Plugin<
typeof R2OptionsSchema,
typeof R2SharedOptionsSchema
Expand Down
3 changes: 3 additions & 0 deletions packages/miniflare/src/workers/core/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ export const CorePaths = {
STREAM_VIDEO: "/cdn-cgi/mf/stream",
/** Local image delivery endpoint for serving hosted images */
IMAGE_DELIVERY: "/cdn-cgi/mf/imagedelivery",
/** Public R2 bucket object serving endpoint */
R2_PUBLIC: "/cdn-cgi/local/r2/public",
} as const;

export const CoreHeaders = {
Expand Down Expand Up @@ -81,6 +83,7 @@ export const CoreBindings = {
DEV_REGISTRY_DEBUG_PORT: "DEV_REGISTRY_DEBUG_PORT",
SERVICE_STREAM: "MINIFLARE_STREAM",
SERVICE_IMAGES_DELIVERY: "MINIFLARE_IMAGES_DELIVERY",
SERVICE_R2_PUBLIC: "MINIFLARE_R2_PUBLIC",
} as const;

export const ProxyOps = {
Expand Down
10 changes: 10 additions & 0 deletions packages/miniflare/src/workers/core/entry.worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ type Env = {
[CoreBindings.SERVICE_LOCAL_EXPLORER]: Fetcher;
[CoreBindings.SERVICE_STREAM]?: Fetcher;
[CoreBindings.SERVICE_IMAGES_DELIVERY]?: Fetcher;
[CoreBindings.SERVICE_R2_PUBLIC]?: Fetcher;
[CoreBindings.TEXT_CUSTOM_SERVICE]: string;
[CoreBindings.TEXT_UPSTREAM_URL]?: string;
[CoreBindings.JSON_CF_BLOB]: IncomingRequestCfProperties;
Expand Down Expand Up @@ -605,6 +606,15 @@ export default <ExportedHandler<Env>>{
return await streamService.fetch(request);
}

const r2PublicService = env[CoreBindings.SERVICE_R2_PUBLIC];
if (
(url.pathname === CorePaths.R2_PUBLIC ||
url.pathname.startsWith(`${CorePaths.R2_PUBLIC}/`)) &&
r2PublicService
) {
return await r2PublicService.fetch(request);
}

let response = await service.fetch(request);
if (!disablePrettyErrorPage) {
response = await maybePrettifyError(request, response, env);
Expand Down
110 changes: 110 additions & 0 deletions packages/miniflare/src/workers/r2/public.worker.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import { cors } from "hono/cors";
import { Hono } from "hono/tiny";
import { CorePaths } from "../core/constants";

type Env = Record<string, R2Bucket>;

function objectHeaders(object: R2Object): Headers {
const headers = new Headers();
object.writeHttpMetadata(headers);
headers.set("ETag", object.httpEtag);
headers.set("Last-Modified", object.uploaded.toUTCString());
headers.set("Accept-Ranges", "bytes");
return headers;
}

const app = new Hono<{ Bindings: Env }>().basePath(CorePaths.R2_PUBLIC);

app.use(
cors({ origin: "*", allowMethods: ["GET", "HEAD"], exposeHeaders: ["*"] })
);

app.on(["GET", "HEAD"], "/:bucketId/:key{.+}", async (c) => {
const bucketId = decodeURIComponent(c.req.param("bucketId"));
const key = decodeURIComponent(c.req.param("key"));

const bucket = c.env[bucketId];
if (bucket === undefined) {
return c.notFound();
}

const hasRange = c.req.header("Range") !== undefined;
// `bucket.head()` cannot evaluate conditional headers (the R2 head
// operation only carries the key), so HEAD also uses `bucket.get()` and
// discards the body.
const object = await bucket.get(key, {
onlyIf: c.req.raw.headers,
range: hasRange && c.req.method === "GET" ? c.req.raw.headers : undefined,
});

if (object === null) {
return c.notFound();
}

const headers = objectHeaders(object);

if (!("body" in object)) {
// Some conditional header failed, but `bucket.get()` reports the
// failure without naming the header. We need to determine which header
// failed to determine the status code to return.
//
// https://datatracker.ietf.org/doc/html/rfc7232#section-6 gives the
// order for checking headers. We know at least one header failed.
// We must first check for a precondition header failure.
//
// The logic in `_testR2Conditional` ensures we can simultaneously
// check both "If-Match" and "If-Unmodified-Since" (since a failure in
// "If-Unmodified-Since" can be suppressed by success for a present
// "If-Match"). These both yield status 412s upon failure.
let preconditions: Headers | undefined;
for (const name of ["If-Match", "If-Unmodified-Since"]) {
const value = c.req.raw.headers.get(name);
if (value !== null) {
preconditions ??= new Headers();
preconditions.set(name, value);
}
}
if (preconditions !== undefined) {
const recheck = await bucket.get(key, { onlyIf: preconditions });
if (recheck === null) {
return c.notFound();
}
if (!("body" in recheck)) {
return c.body(null, { status: 412, headers: objectHeaders(recheck) });
}
}

// Otherwise, the preconditions hold, so the failure came from a cache validator.
return c.body(null, { status: 304, headers });
}

if (c.req.method === "HEAD") {
headers.set("Content-Length", `${object.size}`);
return c.body(null, { headers });
}

const range = object.range;
if (
hasRange &&
range !== undefined &&
"offset" in range &&
"length" in range
) {
const { offset = 0, length = object.size - offset } = range;
headers.set(
"Content-Range",
`bytes ${offset}-${offset + length - 1}/${object.size}`
);
headers.set("Content-Length", `${length}`);
return c.body(object.body, { status: 206, headers });
}

headers.set("Content-Length", `${object.size}`);
return c.body(object.body, { headers });
});

app.all("/:bucketId/:key{.+}", (c) =>
c.text("Method Not Allowed", 405, { Allow: "GET, HEAD, OPTIONS" })
);

export default app;
Loading
Loading