Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
f3d9971
[miniflare] Match r2.dev's write and precondition errors on the local…
tahmid-23 Jun 12, 2026
48afb90
[miniflare] Reject malformed, multiple, and inverted ranges with 400 …
tahmid-23 Jun 12, 2026
3ba32ac
[miniflare] Honor Range on HEAD requests to the local R2 public endpoint
tahmid-23 Jun 12, 2026
39e4169
[miniflare] Fix Content-Range for suffix ranges on the local R2 publi…
tahmid-23 Jun 12, 2026
380e85f
[miniflare] Reject unsatisfiable ranges with 416 on the local R2 publ…
tahmid-23 Jun 12, 2026
789177a
[miniflare] Decode object keys exactly once on the local R2 public en…
tahmid-23 Jun 12, 2026
2d672fa
[miniflare] Cancel unread object bodies when serving R2 objects
tahmid-23 Jun 12, 2026
72537be
Add a changeset for the local R2 public bucket fixes and body cancell…
tahmid-23 Jun 12, 2026
99b651c
[wrangler] Add experimental_local_s3_credentials to r2_buckets config
tahmid-23 Jun 12, 2026
76eba04
[miniflare] Generalize namespaceEntries over the entry type
tahmid-23 Jun 12, 2026
3780e1c
[miniflare] Scaffold the local S3-compatible endpoint for R2 buckets
tahmid-23 Jun 12, 2026
612dc65
[miniflare] Add the shared AWS SigV4 verification core to the local S…
tahmid-23 Jun 12, 2026
5996412
[miniflare] Verify Authorization-header SigV4 signatures on the local…
tahmid-23 Jun 12, 2026
965d4a8
[miniflare] Verify presigned-URL SigV4 signatures on the local S3 end…
tahmid-23 Jun 12, 2026
139f4f1
[miniflare] Route S3 requests through detected operations, serving R2…
tahmid-23 Jun 12, 2026
8760db4
[miniflare] Extract shared R2 object serving into serve.worker.ts
tahmid-23 Jun 12, 2026
fe98e9d
[miniflare] Implement object reads on the local S3 endpoint
tahmid-23 Jun 12, 2026
7827e21
[miniflare] Implement object writes on the local S3 endpoint
tahmid-23 Jun 12, 2026
cf7dd97
[miniflare] Implement bucket reads on the local S3 endpoint
tahmid-23 Jun 12, 2026
8cda1ae
[miniflare] Implement object listing on the local S3 endpoint
tahmid-23 Jun 12, 2026
8471e81
[miniflare] Implement DeleteObjects on the local S3 endpoint
tahmid-23 Jun 12, 2026
47ba6d2
[miniflare] Implement CopyObject on the local S3 endpoint
tahmid-23 Jun 12, 2026
a5fa6db
[miniflare] Implement multipart operations on the local S3 endpoint
tahmid-23 Jun 12, 2026
bc39556
[miniflare] Implement ListBuckets on the local S3 endpoint
tahmid-23 Jun 12, 2026
abb1dc3
[wrangler] Serve buckets with experimental_local_s3_credentials over …
tahmid-23 Jun 12, 2026
dee1979
Add changesets for the local R2 S3 endpoint
tahmid-23 Jun 12, 2026
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
5 changes: 5 additions & 0 deletions .changeset/r2-local-public-fixes.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"miniflare": patch
---

Fix edge cases on the local R2 public bucket endpoint (`/cdn-cgi/local/r2/public`) to match r2.dev: write methods are rejected with 401, malformed/multiple/inverted ranges with 400 and unsatisfiable ranges (including `bytes=-0`) with 416, `Range` is honored on HEAD requests with a bodyless 206, `Content-Range` is correct for suffix ranges, and object keys are percent-decoded exactly once (keys containing a literal `%` no longer fail). Unread object bodies are also cancelled (on HEAD and unsatisfiable-range responses) instead of leaking a read stream until garbage collection.
7 changes: 7 additions & 0 deletions .changeset/r2-local-s3-endpoint.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"miniflare": minor
---

Add a local S3-compatible API for R2 buckets at `/cdn-cgi/local/r2/s3/<bucket-id>`

Buckets configured with `s3Credentials: { accessKeyId, secretAccessKey }` in `r2Buckets` are served over an S3-compatible HTTP API, authenticated with AWS Signature Version 4 (both `Authorization` header and presigned URL query authentication). Supported operations: GetObject, HeadObject, PutObject, CopyObject, DeleteObject, DeleteObjects, ListObjects, ListObjectsV2, HeadBucket, ListBuckets, CreateMultipartUpload, UploadPart, UploadPartCopy, CompleteMultipartUpload, and AbortMultipartUpload. Status codes, error responses, and unsupported-header screening mirror R2's S3 endpoint, including its static responses for bucket-configuration reads and its named errors for unimplemented operations.
22 changes: 22 additions & 0 deletions .changeset/r2-local-s3-wrangler.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
---
"wrangler": minor
---

Add experimental `experimental_local_s3_credentials` to `r2_buckets` config

When set, the R2 bucket is served over a local S3-compatible API at `/cdn-cgi/local/r2/s3/<bucket-name>` during local development, authenticated with the configured AWS SigV4 credentials:

```jsonc
{
"r2_buckets": [
{
"binding": "BUCKET",
"bucket_name": "my-bucket",
"experimental_local_s3_credentials": {
"accessKeyId": "local-access-key-id",
"secretAccessKey": "local-secret-access-key",
},
},
],
}
```
5 changes: 5 additions & 0 deletions packages/miniflare/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,9 @@
"youch": "4.1.0-beta.10"
},
"devDependencies": {
"@aws-crypto/sha256-js": "^5.2.0",
"@aws-sdk/client-s3": "^3.721.0",
"@aws-sdk/s3-request-presigner": "3.721.0",
"@cloudflare/cli-shared-helpers": "workspace:*",
"@cloudflare/containers-shared": "workspace:*",
"@cloudflare/kv-asset-handler": "workspace:*",
Expand All @@ -68,6 +71,7 @@
"@hey-api/openapi-ts": "catalog:default",
"@microsoft/api-extractor": "^7.52.8",
"@puppeteer/browsers": "^2.10.6",
"@smithy/signature-v4": "^4.2.4",
"@types/debug": "^4.1.7",
"@types/estree": "^1.0.0",
"@types/glob-to-regexp": "^0.4.1",
Expand All @@ -89,6 +93,7 @@
"devtools-protocol": "^0.0.1182435",
"esbuild": "catalog:default",
"expect-type": "^0.15.0",
"fast-xml-parser": "^4.4.1",
"get-port": "^7.1.0",
"glob-to-regexp": "0.4.1",
"hono": "^4.12.5",
Expand Down
17 changes: 16 additions & 1 deletion packages/miniflare/src/plugins/core/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,12 @@ 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 {
getR2PublicService,
getR2S3Service,
R2_PUBLIC_SERVICE_NAME,
R2_S3_SERVICE_NAME,
} from "../r2";
import {
getUserBindingServiceName,
kUnsafeEphemeralUniqueKey,
Expand Down Expand Up @@ -1105,6 +1110,13 @@ export function getGlobalServices({
service: { name: R2_PUBLIC_SERVICE_NAME },
});
}
const r2S3Service = getR2S3Service(allWorkerOpts ?? []);
if (r2S3Service !== undefined) {
serviceEntryBindings.push({
name: CoreBindings.SERVICE_R2_S3,
service: { name: R2_S3_SERVICE_NAME },
});
}
const imagesBinding = allWorkerOpts
?.map((worker) => worker.images?.images)
.find(
Expand Down Expand Up @@ -1191,6 +1203,9 @@ export function getGlobalServices({
if (r2PublicService !== undefined) {
services.push(r2PublicService);
}
if (r2S3Service !== undefined) {
services.push(r2S3Service);
}

if (sharedOptions.unsafeLocalExplorer) {
const localExplorerUiPath = resolveLocalExplorerUi(tmpPath);
Expand Down
88 changes: 85 additions & 3 deletions packages/miniflare/src/plugins/r2/index.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import fs from "node:fs/promises";
import SCRIPT_R2_BUCKET_OBJECT from "worker:r2/bucket";
import SCRIPT_R2_PUBLIC from "worker:r2/public";
import SCRIPT_R2_S3 from "worker:r2/s3/index";
import { z } from "zod";
import { MiniflareCoreError } from "../../shared";
import { SharedBindings } from "../../workers";
import { R2S3Bindings } from "../../workers/r2/constants";
import {
getMiniflareObjectBindings,
getPersistPath,
Expand All @@ -21,8 +24,16 @@ import type {
Worker_Binding,
Worker_Binding_DurableObjectNamespaceDesignator,
} from "../../runtime";
import type { S3Credentials } from "../../workers/r2/constants";
import type { Plugin, RemoteProxyConnectionString } from "../shared";

export const R2S3CredentialsSchema = z.object({
accessKeyId: z.string(),
secretAccessKey: z.string(),
}) satisfies z.ZodType<S3Credentials>;

export type R2S3Credentials = z.infer<typeof R2S3CredentialsSchema>;

export const R2OptionsSchema = z.object({
r2Buckets: z
.union([
Expand All @@ -34,6 +45,7 @@ export const R2OptionsSchema = z.object({
remoteProxyConnectionString: z
.custom<RemoteProxyConnectionString>()
.optional(),
s3Credentials: R2S3CredentialsSchema.optional(),
}),
])
),
Expand All @@ -49,18 +61,27 @@ 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`;
export const R2_S3_SERVICE_NAME = `${R2_PLUGIN_NAME}:s3`;
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,
};

interface R2BucketEntry {
id: string;
remoteProxyConnectionString?: RemoteProxyConnectionString;
s3Credentials?: R2S3Credentials;
}

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)) {
for (const [, bucket] of namespaceEntries<R2BucketEntry>(
worker.r2?.r2Buckets
)) {
if (bucket.remoteProxyConnectionString !== undefined) {
continue;
}
Expand All @@ -86,14 +107,75 @@ export function getR2PublicService(
};
}

export function getR2S3Service(
allWorkerOpts: { r2?: z.infer<typeof R2OptionsSchema> }[]
): Service | undefined {
const credentialsById: Record<
string,
z.infer<typeof R2S3CredentialsSchema>
> = {};
for (const worker of allWorkerOpts) {
for (const [, bucket] of namespaceEntries<R2BucketEntry>(
worker.r2?.r2Buckets
)) {
if (
bucket.remoteProxyConnectionString !== undefined ||
bucket.s3Credentials === undefined
) {
continue;
}

const existing = credentialsById[bucket.id];
if (
existing !== undefined &&
(existing.accessKeyId !== bucket.s3Credentials.accessKeyId ||
existing.secretAccessKey !== bucket.s3Credentials.secretAccessKey)
) {
throw new MiniflareCoreError(
"ERR_DIFFERENT_S3_CREDENTIALS",
`Bucket "${bucket.id}" is bound by multiple Workers with different S3 credentials`
);
}

credentialsById[bucket.id] = bucket.s3Credentials;
}
}

const bucketIds = Object.keys(credentialsById);
if (bucketIds.length === 0) {
return undefined;
}

const bindings = bucketIds.map<Worker_Binding>((id) => ({
name: `${R2S3Bindings.BUCKET_PREFIX}${id}`,
r2Bucket: {
name: getUserBindingServiceName(R2_BUCKET_SERVICE_PREFIX, id),
},
}));
bindings.push({
name: R2S3Bindings.JSON_CREDENTIALS,
json: JSON.stringify(credentialsById),
});

return {
name: R2_S3_SERVICE_NAME,
worker: {
compatibilityDate: "2026-01-01",
compatibilityFlags: ["nodejs_compat"],
modules: [{ name: "s3.worker.js", esModule: SCRIPT_R2_S3() }],
bindings,
},
};
}

export const R2_PLUGIN: Plugin<
typeof R2OptionsSchema,
typeof R2SharedOptionsSchema
> = {
options: R2OptionsSchema,
sharedOptions: R2SharedOptionsSchema,
getBindings(options) {
const buckets = namespaceEntries(options.r2Buckets);
const buckets = namespaceEntries<R2BucketEntry>(options.r2Buckets);
return buckets.map<Worker_Binding>(([name, bucket]) => ({
name,
r2Bucket: {
Expand All @@ -120,7 +202,7 @@ export const R2_PLUGIN: Plugin<
unsafeStickyBlobs,
}) {
const persist = sharedOptions.r2Persist;
const buckets = namespaceEntries(options.r2Buckets);
const buckets = namespaceEntries<R2BucketEntry>(options.r2Buckets);
const services = buckets.map<Service>(
([name, { id, remoteProxyConnectionString }]) => ({
name: getUserBindingServiceName(
Expand Down
38 changes: 14 additions & 24 deletions packages/miniflare/src/plugins/shared/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -181,35 +181,25 @@ export type RemoteProxyConnectionString = URL & {
__brand: "RemoteProxyConnectionString";
};

export function namespaceEntries(
namespaces?:
| Record<
string,
| string
| {
id: string;
remoteProxyConnectionString?: RemoteProxyConnectionString;
}
>
| string[]
): [
bindingName: string,
{ id: string; remoteProxyConnectionString?: RemoteProxyConnectionString },
][] {
export function namespaceEntries<
Entry extends {
id: string;
remoteProxyConnectionString?: RemoteProxyConnectionString;
} = { id: string; remoteProxyConnectionString?: RemoteProxyConnectionString },
>(
namespaces?: Record<string, string | Entry> | string[]
): [bindingName: string, entry: Entry][] {
if (Array.isArray(namespaces)) {
return namespaces.map((bindingName) => [bindingName, { id: bindingName }]);
return namespaces.map((bindingName) => [
bindingName,
{ id: bindingName } as Entry,
]);
} else if (namespaces !== undefined) {
return Object.entries(namespaces).map(([key, value]) => {
if (typeof value === "string") {
return [key, { id: value }];
return [key, { id: value } as Entry];
}
return [
key,
{
id: value.id,
remoteProxyConnectionString: value.remoteProxyConnectionString,
},
];
return [key, value];
});
} else {
return [];
Expand Down
1 change: 1 addition & 0 deletions packages/miniflare/src/shared/error.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export const USER_ERROR_CODES = new Set([
"ERR_DIFFERENT_STORAGE_BACKEND", // Multiple Durable Object bindings declared for same class with different storage backends
"ERR_DIFFERENT_UNIQUE_KEYS", // Multiple Durable Object bindings declared for same class with different unsafe unique keys
"ERR_DIFFERENT_PREVENT_EVICTION", // Multiple Durable Object bindings declared for same class with different unsafe prevent eviction values
"ERR_DIFFERENT_S3_CREDENTIALS", // Multiple R2 bucket bindings declared for same bucket with different S3 credentials
"ERR_MULTIPLE_OUTBOUNDS", // Both `outboundService` and `fetchMock` specified
"ERR_INVALID_WRAPPED", // Worker not allowed to be used as wrapped binding
"ERR_MISSING_INSPECTOR_PROXY_PORT", // An inspector proxy has been requested but no inspector port to use has been specified
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 @@ -23,6 +23,8 @@ export const CorePaths = {
IMAGE_DELIVERY: "/cdn-cgi/mf/imagedelivery",
/** Public R2 bucket object serving endpoint */
R2_PUBLIC: "/cdn-cgi/local/r2/public",
/** S3-compatible API endpoint for local R2 buckets */
R2_S3: "/cdn-cgi/local/r2/s3",
} as const;

export const CoreHeaders = {
Expand Down Expand Up @@ -84,6 +86,7 @@ export const CoreBindings = {
SERVICE_STREAM: "MINIFLARE_STREAM",
SERVICE_IMAGES_DELIVERY: "MINIFLARE_IMAGES_DELIVERY",
SERVICE_R2_PUBLIC: "MINIFLARE_R2_PUBLIC",
SERVICE_R2_S3: "MINIFLARE_R2_S3",
} as const;

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

const r2S3Service = env[CoreBindings.SERVICE_R2_S3];
if (
(url.pathname === CorePaths.R2_S3 ||
url.pathname.startsWith(`${CorePaths.R2_S3}/`)) &&
r2S3Service
) {
// SigV4 verification compares against the host the client
// signed, so undo the `upstream` URL/Host rewrite from
// `getUserRequest()`
let s3Request = request;
const originalHostname = request.headers.get(
CoreHeaders.ORIGINAL_HOSTNAME
);
if (originalHostname !== null) {
const s3Url = new URL(url);
s3Url.host = originalHostname;
s3Request = new Request(s3Url, request);
s3Request.headers.set("Host", originalHostname);
}
return await r2S3Service.fetch(s3Request);
}

let response = await service.fetch(request);
if (!disablePrettyErrorPage) {
response = await maybePrettifyError(request, response, env);
Expand Down
13 changes: 13 additions & 0 deletions packages/miniflare/src/workers/r2/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,19 @@ export const R2Limits = {
MIN_MULTIPART_PART_SIZE_TEST: 50,
} as const;

/** AWS SigV4 credentials guarding a bucket on the local S3 endpoint */
export interface S3Credentials {
accessKeyId: string;
secretAccessKey: string;
}

export const R2S3Bindings = {
/** JSON map of bucket id to `{ accessKeyId, secretAccessKey }` */
JSON_CREDENTIALS: "MINIFLARE_R2_S3_CREDENTIALS",
/** Prefix for per-bucket `R2Bucket` bindings (followed by the bucket id) */
BUCKET_PREFIX: "MINIFLARE_R2_S3_BUCKET_",
} as const;

export const R2Headers = {
ERROR: "cf-r2-error",
REQUEST: "cf-r2-request",
Expand Down
Loading
Loading