From 2047a32cf78886b71b794a3dfac946a146ab3ffe Mon Sep 17 00:00:00 2001 From: Tahmid <60953955+tahmid-23@users.noreply.github.com> Date: Wed, 10 Jun 2026 10:27:11 -0400 Subject: [PATCH 1/4] [wrangler][miniflare] Serve local R2 bucket objects publicly via /cdn-cgi/local/r2/public (#14119) Co-authored-by: Claude Opus 4.7 --- .../r2-local-public-bucket-miniflare.md | 7 + .changeset/r2-local-public-bucket-wrangler.md | 7 + packages/miniflare/src/plugins/core/index.ts | 12 + packages/miniflare/src/plugins/r2/index.ts | 33 + .../miniflare/src/workers/core/constants.ts | 3 + .../src/workers/core/entry.worker.ts | 10 + .../miniflare/src/workers/r2/public.worker.ts | 110 ++++ .../miniflare/test/plugins/r2/public.spec.ts | 615 ++++++++++++++++++ 8 files changed, 797 insertions(+) create mode 100644 .changeset/r2-local-public-bucket-miniflare.md create mode 100644 .changeset/r2-local-public-bucket-wrangler.md create mode 100644 packages/miniflare/src/workers/r2/public.worker.ts create mode 100644 packages/miniflare/test/plugins/r2/public.spec.ts diff --git a/.changeset/r2-local-public-bucket-miniflare.md b/.changeset/r2-local-public-bucket-miniflare.md new file mode 100644 index 0000000000..4e38fe1019 --- /dev/null +++ b/.changeset/r2-local-public-bucket-miniflare.md @@ -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//` on the existing user-facing dev server. The `` 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. diff --git a/.changeset/r2-local-public-bucket-wrangler.md b/.changeset/r2-local-public-bucket-wrangler.md new file mode 100644 index 0000000000..c7f857e07c --- /dev/null +++ b/.changeset/r2-local-public-bucket-wrangler.md @@ -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//` on the existing dev server, simulating a public bucket. The `` is the bucket's `bucket_name` when set, otherwise its `binding`. Bindings configured with `remote: true` are not exposed. diff --git a/packages/miniflare/src/plugins/core/index.ts b/packages/miniflare/src/plugins/core/index.ts index 6caf4f5d0c..56ec132245 100644 --- a/packages/miniflare/src/plugins/core/index.ts +++ b/packages/miniflare/src/plugins/core/index.ts @@ -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, @@ -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( @@ -1180,6 +1188,10 @@ export function getGlobalServices({ }, ]; + if (r2PublicService !== undefined) { + services.push(r2PublicService); + } + if (sharedOptions.unsafeLocalExplorer) { const localExplorerUiPath = resolveLocalExplorerUi(tmpPath); const IDToBindingMap: BindingIdMap = constructExplorerBindingMap( diff --git a/packages/miniflare/src/plugins/r2/index.ts b/packages/miniflare/src/plugins/r2/index.ts index 8dbed85189..bb1f2d5ddb 100644 --- a/packages/miniflare/src/plugins/r2/index.ts +++ b/packages/miniflare/src/plugins/r2/index.ts @@ -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 { @@ -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 }[] +): Service | undefined { + const publicBucketIds = new Set(); + 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((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 diff --git a/packages/miniflare/src/workers/core/constants.ts b/packages/miniflare/src/workers/core/constants.ts index 47eeed82c4..de18f19e75 100644 --- a/packages/miniflare/src/workers/core/constants.ts +++ b/packages/miniflare/src/workers/core/constants.ts @@ -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 = { @@ -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 = { diff --git a/packages/miniflare/src/workers/core/entry.worker.ts b/packages/miniflare/src/workers/core/entry.worker.ts index 5d8dd0db5f..013de035eb 100644 --- a/packages/miniflare/src/workers/core/entry.worker.ts +++ b/packages/miniflare/src/workers/core/entry.worker.ts @@ -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; @@ -605,6 +606,15 @@ export default >{ 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); diff --git a/packages/miniflare/src/workers/r2/public.worker.ts b/packages/miniflare/src/workers/r2/public.worker.ts new file mode 100644 index 0000000000..39d50c6bda --- /dev/null +++ b/packages/miniflare/src/workers/r2/public.worker.ts @@ -0,0 +1,110 @@ +import { cors } from "hono/cors"; +import { Hono } from "hono/tiny"; +import { CorePaths } from "../core/constants"; + +type Env = Record; + +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; diff --git a/packages/miniflare/test/plugins/r2/public.spec.ts b/packages/miniflare/test/plugins/r2/public.spec.ts new file mode 100644 index 0000000000..9424731c30 --- /dev/null +++ b/packages/miniflare/test/plugins/r2/public.spec.ts @@ -0,0 +1,615 @@ +import { Miniflare } from "miniflare"; +import { assert, describe, test } from "vitest"; +import { miniflareTest, useDispose } from "../../test-shared"; +import type { MiniflareTestContext } from "../../test-shared"; +import type { R2Bucket } from "@cloudflare/workers-types/experimental"; +import type { MiniflareOptions, ReplaceWorkersTypes } from "miniflare"; + +interface Context extends MiniflareTestContext { + r2: ReplaceWorkersTypes; +} + +const ctx = miniflareTest<{ BUCKET: R2Bucket }, Context>( + { r2Buckets: { BUCKET: "bucket" } }, + async (global) => new global.Response(null, { status: 404 }) +); + +function bucketUrl(path: string, base: URL): URL { + return new URL(`/cdn-cgi/local/r2/public/bucket${path}`, base); +} + +test("serves object body with metadata over HTTP", async ({ expect }) => { + const r2 = await ctx.mf.getR2Bucket("BUCKET"); + await r2.put("public-key", "hello world", { + httpMetadata: { contentType: "text/plain" }, + }); + const stored = await r2.head("public-key"); + assert(stored !== null); + + const res = await fetch(bucketUrl("/public-key", ctx.url)); + + expect(res.status).toBe(200); + expect(await res.text()).toBe("hello world"); + expect(res.headers.get("Content-Type")).toBe("text/plain"); + expect(res.headers.get("ETag")).toBe(stored.httpEtag); + expect(res.headers.get("Last-Modified")).toBe(stored.uploaded.toUTCString()); +}); + +test("a plain GET returns the full object as 200, not 206", async ({ + expect, +}) => { + const r2 = await ctx.mf.getR2Bucket("BUCKET"); + await r2.put("full-key", "0123456789"); + + const res = await fetch(bucketUrl("/full-key", ctx.url)); + + expect(res.status).toBe(200); + expect(await res.text()).toBe("0123456789"); + expect(res.headers.get("Content-Length")).toBe("10"); + expect(res.headers.get("Content-Range")).toBe(null); + expect(res.headers.get("Accept-Ranges")).toBe("bytes"); +}); + +test("forwards all stored HTTP metadata as response headers", async ({ + expect, +}) => { + const r2 = await ctx.mf.getR2Bucket("BUCKET"); + await r2.put("metadata-key", "body", { + httpMetadata: { + contentType: "application/json", + contentLanguage: "en-US", + contentDisposition: 'attachment; filename="thing.json"', + contentEncoding: "identity", + cacheControl: "max-age=3600", + }, + }); + + const res = await fetch(bucketUrl("/metadata-key", ctx.url)); + + expect(res.status).toBe(200); + expect(await res.text()).toBe("body"); + expect(res.headers.get("Content-Type")).toBe("application/json"); + expect(res.headers.get("Content-Language")).toBe("en-US"); + expect(res.headers.get("Content-Disposition")).toBe( + 'attachment; filename="thing.json"' + ); + expect(res.headers.get("Content-Encoding")).toBe("identity"); + expect(res.headers.get("Cache-Control")).toBe("max-age=3600"); +}); + +test("returns 404 for a missing key", async ({ expect }) => { + const res = await fetch(bucketUrl("/does-not-exist", ctx.url)); + expect(res.status).toBe(404); +}); + +test("returns 404 for the bucket root (empty key)", async ({ expect }) => { + const res = await fetch(bucketUrl("/", ctx.url)); + expect(res.status).toBe(404); +}); + +test("returns 404 for an unknown bucket id", async ({ expect }) => { + const res = await fetch( + new URL("/cdn-cgi/local/r2/public/unknown/key", ctx.url) + ); + expect(res.status).toBe(404); +}); + +test("decodes percent-encoded keys", async ({ expect }) => { + const r2 = await ctx.mf.getR2Bucket("BUCKET"); + await r2.put("my folder/a file.txt", "nested"); + + const res = await fetch(bucketUrl("/my%20folder/a%20file.txt", ctx.url)); + + expect(res.status).toBe(200); + expect(await res.text()).toBe("nested"); +}); + +test("GET supports range requests", async ({ expect }) => { + const r2 = await ctx.mf.getR2Bucket("BUCKET"); + await r2.put("range-key", "0123456789"); + + const res = await fetch(bucketUrl("/range-key", ctx.url), { + headers: { Range: "bytes=0-3" }, + }); + + expect(res.status).toBe(206); + expect(await res.text()).toBe("0123"); + expect(res.headers.get("Content-Range")).toBe("bytes 0-3/10"); + expect(res.headers.get("Content-Length")).toBe("4"); +}); + +test("HEAD returns headers without a body", async ({ expect }) => { + const r2 = await ctx.mf.getR2Bucket("BUCKET"); + await r2.put("head-key", "abcdef", { + httpMetadata: { contentType: "text/plain" }, + }); + const stored = await r2.head("head-key"); + assert(stored !== null); + + const res = await fetch(bucketUrl("/head-key", ctx.url), { method: "HEAD" }); + + expect(res.status).toBe(200); + expect(res.headers.get("Content-Length")).toBe("6"); + expect(res.headers.get("Content-Type")).toBe("text/plain"); + expect(res.headers.get("ETag")).toBe(stored.httpEtag); + expect(res.headers.get("Accept-Ranges")).toBe("bytes"); + expect(await res.text()).toBe(""); +}); + +test("HEAD returns 404 for a missing key", async ({ expect }) => { + const res = await fetch(bucketUrl("/missing-head", ctx.url), { + method: "HEAD", + }); + expect(res.status).toBe(404); + await res.arrayBuffer(); +}); + +test("rejects write methods with 405 and an Allow header", async ({ + expect, +}) => { + const r2 = await ctx.mf.getR2Bucket("BUCKET"); + await r2.put("readonly-key", "untouched"); + + for (const method of ["PUT", "POST", "DELETE"]) { + const res = await fetch(bucketUrl("/readonly-key", ctx.url), { + method, + body: method === "DELETE" ? undefined : "tampered", + }); + expect(res.status, `${method} should be rejected`).toBe(405); + expect(res.headers.get("Allow")).toBe("GET, HEAD, OPTIONS"); + await res.arrayBuffer(); + } + + const after = await r2.get("readonly-key"); + expect(await after?.text()).toBe("untouched"); +}); + +// The entry worker rejects /cdn-cgi/* requests from non-localhost origins +// before they reach this worker, so cross-origin here means a different +// localhost port (e.g. a frontend dev server). +test("answers CORS preflight requests", async ({ expect }) => { + const res = await fetch(bucketUrl("/any-key", ctx.url), { + method: "OPTIONS", + headers: { + Origin: "http://localhost:3000", + "Access-Control-Request-Method": "GET", + "Access-Control-Request-Headers": "If-None-Match", + }, + }); + + expect(res.status).toBe(204); + expect(res.headers.get("Access-Control-Allow-Origin")).toBe("*"); + expect(res.headers.get("Access-Control-Allow-Methods")).toBe("GET,HEAD"); + expect(res.headers.get("Access-Control-Allow-Headers")).toBe("If-None-Match"); +}); + +test("sets allow-all CORS headers on cross-origin GET responses", async ({ + expect, +}) => { + const r2 = await ctx.mf.getR2Bucket("BUCKET"); + await r2.put("cors-key", "cors-body"); + + const res = await fetch(bucketUrl("/cors-key", ctx.url), { + headers: { Origin: "http://localhost:3000" }, + }); + + expect(res.status).toBe(200); + expect(res.headers.get("Access-Control-Allow-Origin")).toBe("*"); + expect(res.headers.get("Access-Control-Expose-Headers")).toBe("*"); + expect(await res.text()).toBe("cors-body"); +}); + +// Conditional requests behave identically for GET and HEAD, except that +// HEAD success responses have no body. +describe.each(["GET", "HEAD"] as const)("%s conditional requests", (method) => { + function expectedBody(body: string): string { + return method === "GET" ? body : ""; + } + + test("returns 304 when If-None-Match matches", async ({ expect }) => { + const r2 = await ctx.mf.getR2Bucket("BUCKET"); + const key = `${method}-inm-hit-key`; + await r2.put(key, "value"); + const stored = await r2.head(key); + assert(stored !== null); + + const res = await fetch(bucketUrl(`/${key}`, ctx.url), { + method, + headers: { "If-None-Match": stored.httpEtag }, + }); + + expect(res.status).toBe(304); + expect(res.headers.get("ETag")).toBe(stored.httpEtag); + expect(await res.text()).toBe(""); + }); + + test("returns 200 when If-None-Match does not match", async ({ expect }) => { + const r2 = await ctx.mf.getR2Bucket("BUCKET"); + const key = `${method}-inm-miss-key`; + await r2.put(key, "fresh"); + + const res = await fetch(bucketUrl(`/${key}`, ctx.url), { + method, + headers: { "If-None-Match": '"some-other-etag"' }, + }); + + expect(res.status).toBe(200); + expect(res.headers.get("Content-Length")).toBe("5"); + expect(await res.text()).toBe(expectedBody("fresh")); + }); + + test("returns 412 when If-Match does not match", async ({ expect }) => { + const r2 = await ctx.mf.getR2Bucket("BUCKET"); + const key = `${method}-if-match-miss-key`; + await r2.put(key, "value"); + + const res = await fetch(bucketUrl(`/${key}`, ctx.url), { + method, + headers: { "If-Match": '"definitely-not-the-etag"' }, + }); + + expect(res.status).toBe(412); + expect(await res.text()).toBe(""); + }); + + test("returns 200 when If-Match matches", async ({ expect }) => { + const r2 = await ctx.mf.getR2Bucket("BUCKET"); + const key = `${method}-if-match-hit-key`; + await r2.put(key, "value"); + const stored = await r2.head(key); + assert(stored !== null); + + const res = await fetch(bucketUrl(`/${key}`, ctx.url), { + method, + headers: { "If-Match": stored.httpEtag }, + }); + + expect(res.status).toBe(200); + expect(res.headers.get("Content-Length")).toBe("5"); + expect(await res.text()).toBe(expectedBody("value")); + }); + + test("returns 304 when If-Modified-Since is after the upload time", async ({ + expect, + }) => { + const r2 = await ctx.mf.getR2Bucket("BUCKET"); + const key = `${method}-ims-miss-key`; + await r2.put(key, "value"); + + const future = new Date(Date.now() + 60_000).toUTCString(); + const res = await fetch(bucketUrl(`/${key}`, ctx.url), { + method, + headers: { "If-Modified-Since": future }, + }); + + expect(res.status).toBe(304); + expect(await res.text()).toBe(""); + }); + + test("returns 200 when If-Modified-Since is before the upload time", async ({ + expect, + }) => { + const r2 = await ctx.mf.getR2Bucket("BUCKET"); + const key = `${method}-ims-hit-key`; + await r2.put(key, "fresh"); + + const past = new Date(Date.now() - 60_000).toUTCString(); + const res = await fetch(bucketUrl(`/${key}`, ctx.url), { + method, + headers: { "If-Modified-Since": past }, + }); + + expect(res.status).toBe(200); + expect(await res.text()).toBe(expectedBody("fresh")); + }); + + test("returns 412 when If-Unmodified-Since is before the upload time", async ({ + expect, + }) => { + const r2 = await ctx.mf.getR2Bucket("BUCKET"); + const key = `${method}-ius-miss-key`; + await r2.put(key, "value"); + + const past = new Date(Date.now() - 60_000).toUTCString(); + const res = await fetch(bucketUrl(`/${key}`, ctx.url), { + method, + headers: { "If-Unmodified-Since": past }, + }); + + expect(res.status).toBe(412); + expect(await res.text()).toBe(""); + }); + + test("reports 412 over 304 when both header families fail", async ({ + expect, + }) => { + const r2 = await ctx.mf.getR2Bucket("BUCKET"); + const key = `${method}-both-key`; + await r2.put(key, "value"); + const stored = await r2.head(key); + assert(stored !== null); + + const res = await fetch(bucketUrl(`/${key}`, ctx.url), { + method, + headers: { + "If-Match": '"definitely-not-the-etag"', + "If-None-Match": stored.httpEtag, + }, + }); + + expect(res.status).toBe(412); + expect(await res.text()).toBe(""); + }); + + test("returns 304 when If-Match passes but If-None-Match fails", async ({ + expect, + }) => { + const r2 = await ctx.mf.getR2Bucket("BUCKET"); + const key = `${method}-im-pass-inm-fail-key`; + await r2.put(key, "value"); + const stored = await r2.head(key); + assert(stored !== null); + + const res = await fetch(bucketUrl(`/${key}`, ctx.url), { + method, + headers: { + "If-Match": stored.httpEtag, + "If-None-Match": stored.httpEtag, + }, + }); + + expect(res.status).toBe(304); + expect(res.headers.get("ETag")).toBe(stored.httpEtag); + expect(await res.text()).toBe(""); + }); + + test("returns 304 when If-Unmodified-Since passes but If-None-Match fails", async ({ + expect, + }) => { + const r2 = await ctx.mf.getR2Bucket("BUCKET"); + const key = `${method}-ius-pass-inm-fail-key`; + await r2.put(key, "value"); + const stored = await r2.head(key); + assert(stored !== null); + + const future = new Date(Date.now() + 60_000).toUTCString(); + const res = await fetch(bucketUrl(`/${key}`, ctx.url), { + method, + headers: { + "If-Unmodified-Since": future, + "If-None-Match": stored.httpEtag, + }, + }); + + expect(res.status).toBe(304); + expect(await res.text()).toBe(""); + }); + + test("ignores failing If-Unmodified-Since when If-Match passes", async ({ + expect, + }) => { + const r2 = await ctx.mf.getR2Bucket("BUCKET"); + const key = `${method}-im-overrides-ius-key`; + await r2.put(key, "value"); + const stored = await r2.head(key); + assert(stored !== null); + + const past = new Date(Date.now() - 60_000).toUTCString(); + const res = await fetch(bucketUrl(`/${key}`, ctx.url), { + method, + headers: { + "If-Match": stored.httpEtag, + "If-Unmodified-Since": past, + }, + }); + + expect(res.status).toBe(200); + expect(await res.text()).toBe(expectedBody("value")); + }); + + test("returns 200 when If-Unmodified-Since is after the upload time", async ({ + expect, + }) => { + const r2 = await ctx.mf.getR2Bucket("BUCKET"); + const key = `${method}-ius-hit-key`; + await r2.put(key, "value"); + + const future = new Date(Date.now() + 60_000).toUTCString(); + const res = await fetch(bucketUrl(`/${key}`, ctx.url), { + method, + headers: { "If-Unmodified-Since": future }, + }); + + expect(res.status).toBe(200); + expect(await res.text()).toBe(expectedBody("value")); + }); + + test("ignores failing If-Modified-Since when If-None-Match passes", async ({ + expect, + }) => { + const r2 = await ctx.mf.getR2Bucket("BUCKET"); + const key = `${method}-inm-overrides-ims-key`; + await r2.put(key, "value"); + + const future = new Date(Date.now() + 60_000).toUTCString(); + const res = await fetch(bucketUrl(`/${key}`, ctx.url), { + method, + headers: { + "If-None-Match": '"some-other-etag"', + "If-Modified-Since": future, + }, + }); + + expect(res.status).toBe(200); + expect(await res.text()).toBe(expectedBody("value")); + }); + + test("returns 304 when If-Match passes but If-Modified-Since fails", async ({ + expect, + }) => { + const r2 = await ctx.mf.getR2Bucket("BUCKET"); + const key = `${method}-im-pass-ims-fail-key`; + await r2.put(key, "value"); + const stored = await r2.head(key); + assert(stored !== null); + + const future = new Date(Date.now() + 60_000).toUTCString(); + const res = await fetch(bucketUrl(`/${key}`, ctx.url), { + method, + headers: { + "If-Match": stored.httpEtag, + "If-Modified-Since": future, + }, + }); + + expect(res.status).toBe(304); + expect(await res.text()).toBe(""); + }); + + test("reports 412 over 304 when If-Unmodified-Since and If-None-Match both fail", async ({ + expect, + }) => { + const r2 = await ctx.mf.getR2Bucket("BUCKET"); + const key = `${method}-ius-inm-both-fail-key`; + await r2.put(key, "value"); + const stored = await r2.head(key); + assert(stored !== null); + + const past = new Date(Date.now() - 60_000).toUTCString(); + const res = await fetch(bucketUrl(`/${key}`, ctx.url), { + method, + headers: { + "If-Unmodified-Since": past, + "If-None-Match": stored.httpEtag, + }, + }); + + expect(res.status).toBe(412); + expect(await res.text()).toBe(""); + }); + + test("returns 404 for a missing key regardless of conditional headers", async ({ + expect, + }) => { + const res = await fetch(bucketUrl(`/${method}-missing-key`, ctx.url), { + method, + headers: { + "If-Match": '"some-etag"', + "If-None-Match": '"some-etag"', + }, + }); + + expect(res.status).toBe(404); + }); +}); + +test("a failed conditional with a Range returns 304 without a partial body", async ({ + expect, +}) => { + const r2 = await ctx.mf.getR2Bucket("BUCKET"); + await r2.put("range-conditional-key", "0123456789"); + const stored = await r2.head("range-conditional-key"); + assert(stored !== null); + + const res = await fetch(bucketUrl("/range-conditional-key", ctx.url), { + headers: { + Range: "bytes=0-3", + "If-None-Match": stored.httpEtag, + }, + }); + + expect(res.status).toBe(304); + expect(res.headers.get("Content-Range")).toBeNull(); + expect(await res.text()).toBe(""); +}); + +test("multiple buckets are each independently reachable", async ({ + expect, +}) => { + const opts: MiniflareOptions = { + modules: true, + script: + "export default { fetch() { return new Response(null, { status: 404 }) } }", + r2Buckets: { ALPHA: "alpha", BETA: "beta" }, + }; + const mf = new Miniflare(opts); + useDispose(mf); + + const url = await mf.ready; + const alpha = await mf.getR2Bucket("ALPHA"); + const beta = await mf.getR2Bucket("BETA"); + await alpha.put("k", "alpha-body"); + await beta.put("k", "beta-body"); + + const alphaRes = await fetch( + new URL("/cdn-cgi/local/r2/public/alpha/k", url) + ); + expect(alphaRes.status).toBe(200); + expect(await alphaRes.text()).toBe("alpha-body"); + + const betaRes = await fetch(new URL("/cdn-cgi/local/r2/public/beta/k", url)); + expect(betaRes.status).toBe(200); + expect(await betaRes.text()).toBe("beta-body"); +}); + +test("buckets across multiple workers are all reachable", async ({ + expect, +}) => { + const opts: MiniflareOptions = { + workers: [ + { + name: "worker-a", + modules: true, + script: + "export default { fetch() { return new Response(null, { status: 404 }) } }", + r2Buckets: { BUCKET: "alpha" }, + }, + { + name: "worker-b", + modules: true, + script: + "export default { fetch() { return new Response(null, { status: 404 }) } }", + r2Buckets: { BUCKET: "beta" }, + }, + ], + }; + const mf = new Miniflare(opts); + useDispose(mf); + + const url = await mf.ready; + const alpha = await mf.getR2Bucket("BUCKET", "worker-a"); + const beta = await mf.getR2Bucket("BUCKET", "worker-b"); + await alpha.put("k", "alpha-body"); + await beta.put("k", "beta-body"); + + const alphaRes = await fetch( + new URL("/cdn-cgi/local/r2/public/alpha/k", url) + ); + expect(alphaRes.status).toBe(200); + expect(await alphaRes.text()).toBe("alpha-body"); + + const betaRes = await fetch(new URL("/cdn-cgi/local/r2/public/beta/k", url)); + expect(betaRes.status).toBe(200); + expect(await betaRes.text()).toBe("beta-body"); +}); + +test("two bindings pointing at the same underlying bucket id share the same URL", async ({ + expect, +}) => { + const opts: MiniflareOptions = { + modules: true, + script: + "export default { fetch() { return new Response(null, { status: 404 }) } }", + r2Buckets: { ALIAS_A: "shared", ALIAS_B: "shared" }, + }; + const mf = new Miniflare(opts); + useDispose(mf); + + const url = await mf.ready; + const a = await mf.getR2Bucket("ALIAS_A"); + await a.put("k", "shared-body"); + + const res = await fetch(new URL("/cdn-cgi/local/r2/public/shared/k", url)); + expect(res.status).toBe(200); + expect(await res.text()).toBe("shared-body"); +}); From 818c105522e6d198f92cc31fd465477774c1bcf2 Mon Sep 17 00:00:00 2001 From: Dario Piotrowicz Date: Wed, 10 Jun 2026 15:30:21 +0100 Subject: [PATCH 2/4] Improve R2 Sippy error messages (#14233) --- .changeset/improve-r2-sippy-error-messages.md | 7 + .../wrangler/src/__tests__/r2/bucket.test.ts | 150 ++++++++++++++- packages/wrangler/src/r2/sippy.ts | 178 ++++++++++++------ 3 files changed, 275 insertions(+), 60 deletions(-) create mode 100644 .changeset/improve-r2-sippy-error-messages.md diff --git a/.changeset/improve-r2-sippy-error-messages.md b/.changeset/improve-r2-sippy-error-messages.md new file mode 100644 index 0000000000..e5773fdbbe --- /dev/null +++ b/.changeset/improve-r2-sippy-error-messages.md @@ -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. diff --git a/packages/wrangler/src/__tests__/r2/bucket.test.ts b/packages/wrangler/src/__tests__/r2/bucket.test.ts index 57121ed86e..5da8e87fe7 100644 --- a/packages/wrangler/src/__tests__/r2/bucket.test.ts +++ b/packages/wrangler/src/__tests__/r2/bucket.test.ts @@ -884,10 +884,154 @@ describe("r2", () => { --r2-secret-access-key The secret access key for this R2 bucket [string]" `); expect(std.err).toMatchInlineSnapshot(` - "X [ERROR] Not enough non-option arguments: got 0, need at least 1 + "X [ERROR] Not enough non-option arguments: got 0, need at least 1 - " - `); + " + `); + }); + + describe("validation errors", () => { + it("should error when --provider is missing in non-interactive mode", async () => { + setIsTTY(false); + + await expect( + runWrangler("r2 bucket sippy enable testBucket") + ).rejects.toThrowErrorMatchingInlineSnapshot( + `[Error: Missing cloud storage provider. Use --provider to specify the source provider (AWS or GCS).]` + ); + }); + + it("should error when AWS --region is missing", async () => { + setIsTTY(false); + + await expect( + runWrangler( + "r2 bucket sippy enable testBucket --provider=AWS --bucket=awsBucket --access-key-id=aws-key --secret-access-key=aws-secret --r2-access-key-id=r2-key --r2-secret-access-key=r2-secret" + ) + ).rejects.toThrowErrorMatchingInlineSnapshot( + `[Error: Missing AWS region. Use --region to specify the AWS region of your S3 bucket (e.g., us-west-2).]` + ); + }); + + it("should error when AWS --bucket is missing", async () => { + setIsTTY(false); + + await expect( + runWrangler( + "r2 bucket sippy enable testBucket --provider=AWS --region=us-west-2 --access-key-id=aws-key --secret-access-key=aws-secret --r2-access-key-id=r2-key --r2-secret-access-key=r2-secret" + ) + ).rejects.toThrowErrorMatchingInlineSnapshot( + `[Error: Missing upstream bucket name. Use --bucket to specify the name of your AWS S3 bucket.]` + ); + }); + + it("should error when AWS --access-key-id is missing", async () => { + setIsTTY(false); + + await expect( + runWrangler( + "r2 bucket sippy enable testBucket --provider=AWS --region=us-west-2 --bucket=awsBucket --secret-access-key=aws-secret --r2-access-key-id=r2-key --r2-secret-access-key=r2-secret" + ) + ).rejects.toThrowErrorMatchingInlineSnapshot( + `[Error: Missing AWS access key. Use --access-key-id to specify the AWS access key ID with read and list access to your S3 bucket.]` + ); + }); + + it("should error when AWS --secret-access-key is missing", async () => { + setIsTTY(false); + + await expect( + runWrangler( + "r2 bucket sippy enable testBucket --provider=AWS --region=us-west-2 --bucket=awsBucket --access-key-id=aws-key --r2-access-key-id=r2-key --r2-secret-access-key=r2-secret" + ) + ).rejects.toThrowErrorMatchingInlineSnapshot( + `[Error: Missing AWS secret access key. Use --secret-access-key to specify the AWS secret access key for your S3 bucket.]` + ); + }); + + it("should error when AWS --r2-access-key-id is missing", async () => { + setIsTTY(false); + + await expect( + runWrangler( + "r2 bucket sippy enable testBucket --provider=AWS --region=us-west-2 --bucket=awsBucket --access-key-id=aws-key --secret-access-key=aws-secret --r2-secret-access-key=r2-secret" + ) + ).rejects.toThrowErrorMatchingInlineSnapshot( + `[Error: Missing R2 access key. Use --r2-access-key-id to specify the R2 access key ID with read and write access to your R2 bucket. You can create API tokens at https://dash.cloudflare.com/?to=/:account/r2/api-tokens.]` + ); + }); + + it("should error when AWS --r2-secret-access-key is missing", async () => { + setIsTTY(false); + + await expect( + runWrangler( + "r2 bucket sippy enable testBucket --provider=AWS --region=us-west-2 --bucket=awsBucket --access-key-id=aws-key --secret-access-key=aws-secret --r2-access-key-id=r2-key" + ) + ).rejects.toThrowErrorMatchingInlineSnapshot( + `[Error: Missing R2 secret access key. Use --r2-secret-access-key to specify the R2 secret access key for your R2 bucket.]` + ); + }); + + it("should error when GCS --bucket is missing", async () => { + setIsTTY(false); + + await expect( + runWrangler( + "r2 bucket sippy enable testBucket --provider=GCS --client-email=gcs-email --private-key=gcs-key --r2-access-key-id=r2-key --r2-secret-access-key=r2-secret" + ) + ).rejects.toThrowErrorMatchingInlineSnapshot( + `[Error: Missing upstream bucket name. Use --bucket to specify the name of your GCS bucket.]` + ); + }); + + it("should error when GCS --client-email is missing", async () => { + setIsTTY(false); + + await expect( + runWrangler( + "r2 bucket sippy enable testBucket --provider=GCS --bucket=gcsBucket --private-key=gcs-key --r2-access-key-id=r2-key --r2-secret-access-key=r2-secret" + ) + ).rejects.toThrowErrorMatchingInlineSnapshot( + `[Error: Missing GCS client email. Use --service-account-key-file to provide your Google Cloud service account key JSON file, or specify --client-email directly.]` + ); + }); + + it("should error when GCS --private-key is missing", async () => { + setIsTTY(false); + + await expect( + runWrangler( + "r2 bucket sippy enable testBucket --provider=GCS --bucket=gcsBucket --client-email=gcs-email --r2-access-key-id=r2-key --r2-secret-access-key=r2-secret" + ) + ).rejects.toThrowErrorMatchingInlineSnapshot( + `[Error: Missing GCS private key. Use --service-account-key-file to provide your Google Cloud service account key JSON file, or specify --private-key directly.]` + ); + }); + + it("should error when GCS --r2-access-key-id is missing", async () => { + setIsTTY(false); + + await expect( + runWrangler( + "r2 bucket sippy enable testBucket --provider=GCS --bucket=gcsBucket --client-email=gcs-email --private-key=gcs-key --r2-secret-access-key=r2-secret" + ) + ).rejects.toThrowErrorMatchingInlineSnapshot( + `[Error: Missing R2 access key. Use --r2-access-key-id to specify the R2 access key ID with read and write access to your R2 bucket. You can create API tokens at https://dash.cloudflare.com/?to=/:account/r2/api-tokens.]` + ); + }); + + it("should error when GCS --r2-secret-access-key is missing", async () => { + setIsTTY(false); + + await expect( + runWrangler( + "r2 bucket sippy enable testBucket --provider=GCS --bucket=gcsBucket --client-email=gcs-email --private-key=gcs-key --r2-access-key-id=r2-key" + ) + ).rejects.toThrowErrorMatchingInlineSnapshot( + `[Error: Missing R2 secret access key. Use --r2-secret-access-key to specify the R2 secret access key for your R2 bucket.]` + ); + }); }); }); diff --git a/packages/wrangler/src/r2/sippy.ts b/packages/wrangler/src/r2/sippy.ts index cadd2f2878..54a7c054c7 100644 --- a/packages/wrangler/src/r2/sippy.ts +++ b/packages/wrangler/src/r2/sippy.ts @@ -90,22 +90,31 @@ export const r2BucketSippyEnableCommand = createCommand({ "Enter the cloud storage provider of your bucket (AWS or GCS):" ); if (!args.provider) { - throw new UserError("Must specify a cloud storage provider.", { - telemetryMessage: "r2 sippy missing cloud storage provider", - }); + throw new UserError( + "Missing cloud storage provider. Use --provider to specify the source provider (AWS or GCS).", + { + telemetryMessage: "r2 sippy missing cloud storage provider", + } + ); } if (!SIPPY_PROVIDER_CHOICES.includes(args.provider)) { - throw new UserError("Cloud storage provider must be: AWS or GCS", { - telemetryMessage: "r2 sippy invalid cloud storage provider", - }); + throw new UserError( + `Invalid cloud storage provider: "${args.provider}". The --provider flag must be one of: AWS, GCS.`, + { + telemetryMessage: "r2 sippy invalid cloud storage provider", + } + ); } args.bucket ??= await prompt( `Enter the name of your ${args.provider} bucket:` ); if (!args.bucket) { - throw new UserError(`Must specify ${args.provider} bucket name.`, { - telemetryMessage: "r2 sippy missing source bucket", - }); + throw new UserError( + `Missing upstream bucket name. Use --bucket to specify the name of your ${args.provider} bucket.`, + { + telemetryMessage: "r2 sippy missing source bucket", + } + ); } if (args.provider === "AWS") { @@ -113,25 +122,34 @@ export const r2BucketSippyEnableCommand = createCommand({ "Enter the AWS region where your S3 bucket is located (example: us-west-2):" ); if (!args.region) { - throw new UserError("Must specify an AWS Region.", { - telemetryMessage: "r2 sippy aws missing region", - }); + throw new UserError( + "Missing AWS region. Use --region to specify the AWS region of your S3 bucket (e.g., us-west-2).", + { + telemetryMessage: "r2 sippy aws missing region", + } + ); } args.accessKeyId ??= await prompt( "Enter your AWS Access Key ID (requires read and list access):" ); if (!args.accessKeyId) { - throw new UserError("Must specify an AWS Access Key ID.", { - telemetryMessage: "r2 sippy aws missing access key id", - }); + throw new UserError( + "Missing AWS access key. Use --access-key-id to specify the AWS access key ID with read and list access to your S3 bucket.", + { + telemetryMessage: "r2 sippy aws missing access key id", + } + ); } args.secretAccessKey ??= await prompt( "Enter your AWS Secret Access Key:" ); if (!args.secretAccessKey) { - throw new UserError("Must specify an AWS Secret Access Key.", { - telemetryMessage: "r2 sippy aws missing secret access key", - }); + throw new UserError( + "Missing AWS secret access key. Use --secret-access-key to specify the AWS secret access key for your S3 bucket.", + { + telemetryMessage: "r2 sippy aws missing secret access key", + } + ); } } else if (args.provider === "GCS") { if ( @@ -143,7 +161,7 @@ export const r2BucketSippyEnableCommand = createCommand({ ); if (!args.serviceAccountKeyFile) { throw new UserError( - "Must specify the path to a service account key JSON file.", + "Missing GCS credentials. Use --service-account-key-file to specify the path to your Google Cloud service account key JSON file, or provide --client-email and --private-key directly.", { telemetryMessage: "r2 sippy gcs missing service account key file", @@ -157,52 +175,85 @@ export const r2BucketSippyEnableCommand = createCommand({ "Enter your R2 Access Key ID (requires read and write access):" ); if (!args.r2AccessKeyId) { - throw new UserError("Must specify an R2 Access Key ID.", { - telemetryMessage: "r2 sippy missing r2 access key id", - }); + throw new UserError( + "Missing R2 access key. Use --r2-access-key-id to specify the R2 access key ID with read and write access to your R2 bucket. You can create API tokens at https://dash.cloudflare.com/?to=/:account/r2/api-tokens.", + { + telemetryMessage: "r2 sippy missing r2 access key id", + } + ); } args.r2SecretAccessKey ??= await prompt( "Enter your R2 Secret Access Key:" ); if (!args.r2SecretAccessKey) { - throw new UserError("Must specify an R2 Secret Access Key.", { - telemetryMessage: "r2 sippy missing r2 secret access key", - }); + throw new UserError( + "Missing R2 secret access key. Use --r2-secret-access-key to specify the R2 secret access key for your R2 bucket.", + { + telemetryMessage: "r2 sippy missing r2 secret access key", + } + ); } } let sippyConfig: SippyPutParams; + if (!args.provider) { + throw new UserError( + "Missing cloud storage provider. Use --provider to specify the source provider (AWS or GCS).", + { + telemetryMessage: "r2 sippy missing cloud storage provider", + } + ); + } + if (args.provider === "AWS") { if (!args.region) { - throw new UserError("Error: must provide --region.", { - telemetryMessage: "r2 sippy aws missing region", - }); + throw new UserError( + "Missing AWS region. Use --region to specify the AWS region of your S3 bucket (e.g., us-west-2).", + { + telemetryMessage: "r2 sippy aws missing region", + } + ); } if (!args.bucket) { - throw new UserError("Error: must provide --bucket.", { - telemetryMessage: "r2 sippy aws missing bucket", - }); + throw new UserError( + "Missing upstream bucket name. Use --bucket to specify the name of your AWS S3 bucket.", + { + telemetryMessage: "r2 sippy aws missing bucket", + } + ); } if (!args.accessKeyId) { - throw new UserError("Error: must provide --access-key-id.", { - telemetryMessage: "r2 sippy aws missing access key id", - }); + throw new UserError( + "Missing AWS access key. Use --access-key-id to specify the AWS access key ID with read and list access to your S3 bucket.", + { + telemetryMessage: "r2 sippy aws missing access key id", + } + ); } if (!args.secretAccessKey) { - throw new UserError("Error: must provide --secret-access-key.", { - telemetryMessage: "r2 sippy aws missing secret access key", - }); + throw new UserError( + "Missing AWS secret access key. Use --secret-access-key to specify the AWS secret access key for your S3 bucket.", + { + telemetryMessage: "r2 sippy aws missing secret access key", + } + ); } if (!args.r2AccessKeyId) { - throw new UserError("Error: must provide --r2-access-key-id.", { - telemetryMessage: "r2 sippy missing r2 access key id", - }); + throw new UserError( + "Missing R2 access key. Use --r2-access-key-id to specify the R2 access key ID with read and write access to your R2 bucket. You can create API tokens at https://dash.cloudflare.com/?to=/:account/r2/api-tokens.", + { + telemetryMessage: "r2 sippy missing r2 access key id", + } + ); } if (!args.r2SecretAccessKey) { - throw new UserError("Error: must provide --r2-secret-access-key.", { - telemetryMessage: "r2 sippy missing r2 secret access key", - }); + throw new UserError( + "Missing R2 secret access key. Use --r2-secret-access-key to specify the R2 secret access key for your R2 bucket.", + { + telemetryMessage: "r2 sippy missing r2 secret access key", + } + ); } sippyConfig = { @@ -234,33 +285,46 @@ export const r2BucketSippyEnableCommand = createCommand({ } if (!args.bucket) { - throw new UserError("Error: must provide --bucket.", { - telemetryMessage: "r2 sippy gcs missing bucket", - }); + throw new UserError( + "Missing upstream bucket name. Use --bucket to specify the name of your GCS bucket.", + { + telemetryMessage: "r2 sippy gcs missing bucket", + } + ); } if (!args.clientEmail) { throw new UserError( - "Error: must provide --service-account-key-file or --client-email.", - { telemetryMessage: "r2 sippy gcs missing client email" } + "Missing GCS client email. Use --service-account-key-file to provide your Google Cloud service account key JSON file, or specify --client-email directly.", + { + telemetryMessage: "r2 sippy gcs missing client email", + } ); } if (!args.privateKey) { throw new UserError( - "Error: must provide --service-account-key-file or --private-key.", - { telemetryMessage: "r2 sippy gcs missing private key" } + "Missing GCS private key. Use --service-account-key-file to provide your Google Cloud service account key JSON file, or specify --private-key directly.", + { + telemetryMessage: "r2 sippy gcs missing private key", + } ); } args.privateKey = args.privateKey.replace(/\\n/g, "\n"); if (!args.r2AccessKeyId) { - throw new UserError("Error: must provide --r2-access-key-id.", { - telemetryMessage: "r2 sippy missing r2 access key id", - }); + throw new UserError( + "Missing R2 access key. Use --r2-access-key-id to specify the R2 access key ID with read and write access to your R2 bucket. You can create API tokens at https://dash.cloudflare.com/?to=/:account/r2/api-tokens.", + { + telemetryMessage: "r2 sippy missing r2 access key id", + } + ); } if (!args.r2SecretAccessKey) { - throw new UserError("Error: must provide --r2-secret-access-key.", { - telemetryMessage: "r2 sippy missing r2 secret access key", - }); + throw new UserError( + "Missing R2 secret access key. Use --r2-secret-access-key to specify the R2 secret access key for your R2 bucket.", + { + telemetryMessage: "r2 sippy missing r2 secret access key", + } + ); } sippyConfig = { @@ -278,7 +342,7 @@ export const r2BucketSippyEnableCommand = createCommand({ }; } else { throw new UserError( - "Error: unrecognized provider. Possible options are AWS & GCS.", + `Invalid cloud storage provider: "${args.provider}". The --provider flag must be one of: AWS, GCS.`, { telemetryMessage: "r2 sippy unrecognized provider" } ); } From e30512641a194a628767ca9c44ff0499a4b326c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Somhairle=20MacLe=C3=B2id?= Date: Wed, 10 Jun 2026 15:34:32 +0100 Subject: [PATCH 3/4] [wrangler] Add cf-wrangler delegate entrypoint; remove @cloudflare/wrangler-bundler (#14184) Co-authored-by: emily-shen <69125074+emily-shen@users.noreply.github.com> --- .changeset/cf-vite-drop-config-flag.md | 7 ++ .changeset/cf-wrangler-delegate-entrypoint.md | 9 ++ packages/vite-plugin-cloudflare/AGENTS.md | 20 +-- .../vite-plugin-cloudflare/src/cf-vite.ts | 24 +--- packages/wrangler-bundler/AGENTS.md | 91 -------------- packages/wrangler-bundler/CHANGELOG.md | 33 ----- packages/wrangler-bundler/README.md | 80 ------------ packages/wrangler-bundler/bin/cf-wrangler | 31 ----- packages/wrangler-bundler/package.json | 59 --------- packages/wrangler-bundler/src/cli.ts | 113 ----------------- packages/wrangler-bundler/src/index.ts | 10 -- packages/wrangler-bundler/tsconfig.json | 14 --- packages/wrangler-bundler/tsdown.config.ts | 14 --- packages/wrangler-bundler/turbo.json | 11 -- packages/wrangler-bundler/vitest.config.ts | 11 -- packages/wrangler/AGENTS.md | 5 +- packages/wrangler/bin/cf-wrangler.js | 115 ++++++++++++++++++ packages/wrangler/package.json | 1 + .../src/__tests__/cf-wrangler}/args.test.ts | 19 +-- .../src => wrangler/src/cf-wrangler}/args.ts | 36 ++---- packages/wrangler/src/cf-wrangler/dev.ts | 42 +++++++ packages/wrangler/src/cli.ts | 8 ++ pnpm-lock.yaml | 25 ---- .../__tests__/validate-changesets.test.ts | 1 - 24 files changed, 222 insertions(+), 557 deletions(-) create mode 100644 .changeset/cf-vite-drop-config-flag.md create mode 100644 .changeset/cf-wrangler-delegate-entrypoint.md delete mode 100644 packages/wrangler-bundler/AGENTS.md delete mode 100644 packages/wrangler-bundler/CHANGELOG.md delete mode 100644 packages/wrangler-bundler/README.md delete mode 100755 packages/wrangler-bundler/bin/cf-wrangler delete mode 100644 packages/wrangler-bundler/package.json delete mode 100644 packages/wrangler-bundler/src/cli.ts delete mode 100644 packages/wrangler-bundler/src/index.ts delete mode 100644 packages/wrangler-bundler/tsconfig.json delete mode 100644 packages/wrangler-bundler/tsdown.config.ts delete mode 100644 packages/wrangler-bundler/turbo.json delete mode 100644 packages/wrangler-bundler/vitest.config.ts create mode 100755 packages/wrangler/bin/cf-wrangler.js rename packages/{wrangler-bundler/src/__tests__ => wrangler/src/__tests__/cf-wrangler}/args.test.ts (92%) rename packages/{wrangler-bundler/src => wrangler/src/cf-wrangler}/args.ts (50%) create mode 100644 packages/wrangler/src/cf-wrangler/dev.ts diff --git a/.changeset/cf-vite-drop-config-flag.md b/.changeset/cf-vite-drop-config-flag.md new file mode 100644 index 0000000000..db9b5e370e --- /dev/null +++ b/.changeset/cf-vite-drop-config-flag.md @@ -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. diff --git a/.changeset/cf-wrangler-delegate-entrypoint.md b/.changeset/cf-wrangler-delegate-entrypoint.md new file mode 100644 index 0000000000..2428516895 --- /dev/null +++ b/.changeset/cf-wrangler-delegate-entrypoint.md @@ -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. diff --git a/packages/vite-plugin-cloudflare/AGENTS.md b/packages/vite-plugin-cloudflare/AGENTS.md index dfb07d163b..835e0f24f4 100644 --- a/packages/vite-plugin-cloudflare/AGENTS.md +++ b/packages/vite-plugin-cloudflare/AGENTS.md @@ -26,24 +26,24 @@ Vite plugin for Cloudflare Workers development. Exports `cloudflare()` plugin fa `bin/cf-vite` is an experimental, internal delegate binary spawned by Cloudflare's "cf-dev" parent process — NOT part of the plugin's public API and not meant for direct end-user invocation. It is the sibling of -`@cloudflare/wrangler-bundler`'s `cf-wrangler` binary, and the two MUST -keep a shared spawn contract so the parent can drive either impl -interchangeably. +`wrangler`'s `cf-wrangler` binary, and the two MUST keep a shared spawn +contract so the parent can drive either impl interchangeably. - **Verb dispatch.** `cf-vite [flags]`. `dev` is the only verb today; future verbs (`build`, `deploy`) follow the same shape. Unknown/missing verbs exit `2` (this doubles as the parent's version-detection signal — no JSON handshake). -- **Shared flag vocabulary.** Only `--config`, `--mode`, `--port`, - `--host`, `--local` are accepted, mirroring `cf-wrangler` exactly. - Parsed with `node:util.parseArgs` strict mode → unknown flags exit - `2`. Do NOT add flags here unless `cf-wrangler` grows them too. +- **Shared flag vocabulary.** Only `--mode`, `--port`, `--host`, + `--local` are accepted, mirroring `cf-wrangler` exactly. Parsed with + `node:util.parseArgs` strict mode → unknown flags exit `2`. Do NOT add + flags here unless `cf-wrangler` grows them too. (There is no `--config` + flag: the wrangler config is discovered by `cloudflare()` itself.) - **Flag wiring.** `cf-vite` boots Vite via `createServer()` against the user's own `vite.config.ts` (which must include `cloudflare()`). Plugin-owned flags are bridged via env vars the plugin already reads - (`--config` → `CLOUDFLARE_VITE_WRANGLER_CONFIG_PATH`, `--local` → - `CLOUDFLARE_VITE_FORCE_LOCAL`); Vite-owned flags go through inline - config (`--port`/`--host` → `server.*`, `--mode` → `mode`). + (`--local` → `CLOUDFLARE_VITE_FORCE_LOCAL`); Vite-owned flags go + through inline config (`--port`/`--host` → `server.*`, `--mode` → + `mode`). - **`--local`** forces remote bindings off. There is no plugin env knob for `remoteBindings` other than `CLOUDFLARE_VITE_FORCE_LOCAL`, which `resolvePluginConfig` in `plugin-config.ts` honours by overriding the diff --git a/packages/vite-plugin-cloudflare/src/cf-vite.ts b/packages/vite-plugin-cloudflare/src/cf-vite.ts index 758917d046..068ec63c57 100644 --- a/packages/vite-plugin-cloudflare/src/cf-vite.ts +++ b/packages/vite-plugin-cloudflare/src/cf-vite.ts @@ -10,10 +10,11 @@ * * Spawn contract for `dev`: parent uses `stdio: "inherit"` and forwards * SIGINT/SIGTERM. Accepted flags mirror the sibling `cf-wrangler` - * delegate (`--config`, `--mode`, `--port`, `--host`, `--local`) so the - * parent can drive either impl interchangeably; everything else lives in - * the user's `vite.config.ts` / `wrangler.jsonc`. `cf-vite` boots Vite - * via `createServer()` against the user's own config (expected to + * delegate (`--mode`, `--port`, `--host`, `--local`) so the parent can + * drive either impl interchangeably; everything else lives in the user's + * `vite.config.ts` / `wrangler.jsonc` (including the wrangler config + * file, which is discovered by `cloudflare()` itself). `cf-vite` boots + * Vite via `createServer()` against the user's own config (expected to * include `cloudflare()`); flags are bridged to it as documented inline. * * Exit codes: 0 graceful, 2 unknown verb / parse error, 130 SIGINT, @@ -25,7 +26,6 @@ import { createServer } from "vite"; import type { InlineConfig, ServerOptions } from "vite"; interface DevArgs { - config?: string; mode?: string; port?: number; host?: string; @@ -46,7 +46,6 @@ function parseArgs(argv: string[]): DevArgs { parsed = nodeParseArgs({ args: argv, options: { - config: { type: "string" }, mode: { type: "string" }, host: { type: "string" }, // `node:util.parseArgs` has no `number` type; coerce below. @@ -61,9 +60,6 @@ function parseArgs(argv: string[]): DevArgs { } const out: DevArgs = {}; - if (parsed.values.config !== undefined) { - out.config = parsed.values.config; - } if (parsed.values.mode !== undefined) { out.mode = parsed.values.mode; } @@ -114,18 +110,10 @@ async function main(): Promise { } // Bridge plugin-owned flags via env vars the plugin reads during config - // resolution — must be set before `createServer()`. Precedence vs the - // user's `cloudflare()` options differs per flag, by design: - // - `--config`: an explicit `cloudflare({ configPath })` wins, since - // this maps to the existing `CLOUDFLARE_VITE_WRANGLER_CONFIG_PATH` - // discovery fallback (`configPath ?? env`). A pinned configPath is - // a deliberate user choice, so it stays authoritative. + // resolution — must be set before `createServer()`. // - `--local`: overrides `remoteBindings` outright, mirroring // `wrangler dev --local` (force local even if config opts into // remote). - if (args.config !== undefined) { - process.env.CLOUDFLARE_VITE_WRANGLER_CONFIG_PATH = args.config; - } if (args.local) { process.env.CLOUDFLARE_VITE_FORCE_LOCAL = "true"; } diff --git a/packages/wrangler-bundler/AGENTS.md b/packages/wrangler-bundler/AGENTS.md deleted file mode 100644 index 3305cbdfba..0000000000 --- a/packages/wrangler-bundler/AGENTS.md +++ /dev/null @@ -1,91 +0,0 @@ -# AGENTS.md — @cloudflare/wrangler-bundler - -## Overview - -esbuild-based dev server for Cloudflare Workers, extracted from -`wrangler dev` for projects that can't migrate to Vite. This is a -thin adapter on top of wrangler's `unstable_dev` API — it is NOT a -fork of wrangler internals. - -The package ships a `cf-wrangler` delegate binary that dispatches on -a leading subcommand verb. Today the only verb is `dev` -(long-running esbuild + Miniflare + workerd dev server); future verbs -(`build`, `deploy`, etc.) will follow the same shape. - -A parent process invokes `/bin/cf-wrangler dev [argv...]`, -the binary runs the dev server until Ctrl+C. - -## Structure - -- `bin/cf-wrangler` — executable shim; dispatches on the first argv - token (`dev` today) and delegates to the matching handler. -- `src/index.ts` — programmatic API (`runDev`, `DevArgs`). -- `src/cli.ts` — `runDev` main loop: build `Unstable_DevOptions`, - call `wrangler.unstable_dev`, block on `waitUntilExit`, wire signals. -- `src/args.ts` — argv parser for the `dev` verb. Built on - `node:util.parseArgs` (strict mode → unknown flags throw; no - third-party dependency). - -## Conventions - -- **Delegate to `unstable_dev`, not `unstable_DevEnv`.** `unstable_dev` - wraps `startDev`, which already wires the remote-bindings auth - hook (`requireAuth`/`requireApiToken`), `registerDevHotKeys`, and - the DevEnv lifecycle. Building on the lower-level `unstable_DevEnv` - would force us to duplicate that plumbing. The one wart: `host` - is accepted at runtime by `startDev` but not declared on the - public `Unstable_DevOptions` type, so the options object is - type-cast at the `unstable_dev` call site. Tracked as a follow-up - for wrangler to widen `Unstable_DevOptions`. -- **Remote bindings are supported.** Per-resource `remote = true` in - `wrangler.jsonc` works out of the box because `unstable_dev` - invokes the standard auth hook. Whole-worker remote dev - (`wrangler dev --remote`) is NOT supported — there's no `--remote` - flag here, and any attempt to pass it falls into the generic - "unknown flag" error from the parser. -- **Hotkeys are enabled.** `experimental.showInteractiveDevSession: -true` turns on the standard wrangler hotkey UI - (`b`/`d`/`e`/`r`/`l`/`c`/`x`/`q`). `unstable_dev` defaults this - to `false` (suits its test-harness origin); we override to match - `wrangler dev`. `startDev` only renders the UI when stdin is a - TTY (`isInteractive()` check at `start-dev.ts:108`/`118`), so - non-interactive parent processes (e.g. cf-dev with piped stdio) - see no hotkey overlay. The bundler installs its own - SIGINT/SIGTERM handlers as the teardown path for those cases. -- **Containers are enabled.** `experimental.enableContainers: true`, - again to match `wrangler dev`'s default (`unstable_dev` defaults - it to `false`). Cloudchamber-pulled `image_uri` containers work - via the same auth flow that powers remote bindings. -- **`--mode` not `--env`.** Named environments are surfaced as - `--mode ` to align with the cf-dev parent process's flag - vocabulary; internally this maps to `unstable_dev`'s `env` option. -- **Minimal accepted flags.** Only `--config`, `--mode`, `--port`, - `--host`, `--local` are recognised. Everything else belongs in - the user's `wrangler.jsonc`. Do not mirror `wrangler dev`'s full - surface — add flags here only when the cf-dev parent process - needs to pass them through. - -## Out of scope (v1) - -- Pages assets shim (`enablePagesAssetsServiceBinding`). -- Cloudflare Sites (`legacy.site`). -- Multi-worker dev sessions (`MultiworkerRuntimeController`). -- Tunnel sharing (`startTunnel`). - -## Build - -- `tsdown` to ESM (`.mjs`) in `dist/`. Same bundler as `vite-plugin-cloudflare`. -- `pnpm build` for one-shot, `pnpm dev` for watch. -- The `bin/cf-wrangler` shim imports from `../dist/index.mjs` - directly — it's NOT processed by tsdown (it's a tiny entry that - doesn't need bundling and we want it readable for debugging). -- Only `wrangler` is externalized; everything else is bundled. See - `scripts/deps.ts` for the allowlist (validated by the monorepo's - `validate-package-dependencies` script). - -## Testing - -- `vitest` (default config, no special harness yet). -- Integration testing happens out-of-package via a parent process - that spawns the `cf-wrangler` binary against a fixture project; - that coverage is tracked separately from this repo. diff --git a/packages/wrangler-bundler/CHANGELOG.md b/packages/wrangler-bundler/CHANGELOG.md deleted file mode 100644 index dd202c83c3..0000000000 --- a/packages/wrangler-bundler/CHANGELOG.md +++ /dev/null @@ -1,33 +0,0 @@ -# @cloudflare/wrangler-bundler - -## 0.1.3 - -### Patch Changes - -- Updated dependencies [[`23aecac`](https://github.com/cloudflare/workers-sdk/commit/23aecac6a2d57ee5d4888405bd12cac66ab8a725), [`b932e47`](https://github.com/cloudflare/workers-sdk/commit/b932e47d49e736cb59159341a92045dcc65df0c6), [`d076bcc`](https://github.com/cloudflare/workers-sdk/commit/d076bcc847adc0cb52c34863d3ad77f8faee5fbb), [`24497d0`](https://github.com/cloudflare/workers-sdk/commit/24497d0f5fb327d7c86f5f3022510b53cfec931d), [`4bb572f`](https://github.com/cloudflare/workers-sdk/commit/4bb572f264089b2ec1ce3a4b0d2f48c226cb4431), [`165adb2`](https://github.com/cloudflare/workers-sdk/commit/165adb2084bde4bff453b54c4a984012b6999f29), [`776098c`](https://github.com/cloudflare/workers-sdk/commit/776098c79672e4b16c53aea1c127f45fe66a14bf), [`0706fbf`](https://github.com/cloudflare/workers-sdk/commit/0706fbf950548aaa8177a062a7c5d41822dfba0d), [`7993711`](https://github.com/cloudflare/workers-sdk/commit/79937112ff580c34b182b73ef830cdb17b5b798d), [`8cf8c61`](https://github.com/cloudflare/workers-sdk/commit/8cf8c61efb9fd99892bcb250db12d7052b5fef08), [`8923f97`](https://github.com/cloudflare/workers-sdk/commit/8923f9769aaa13229d1cda535f95a9813465d765), [`b205fb7`](https://github.com/cloudflare/workers-sdk/commit/b205fb7ff0a1d897b5cbe2a9149978d9e581684c), [`a61ac29`](https://github.com/cloudflare/workers-sdk/commit/a61ac2936ae6b35146d637c18beb94567bb40bfa)]: - - wrangler@4.99.0 - -## 0.1.2 - -### Patch Changes - -- Updated dependencies [[`c6c61b5`](https://github.com/cloudflare/workers-sdk/commit/c6c61b59431443b2bcda25f3af7624dd2ce19b9b), [`c6c61b5`](https://github.com/cloudflare/workers-sdk/commit/c6c61b59431443b2bcda25f3af7624dd2ce19b9b), [`a3eea27`](https://github.com/cloudflare/workers-sdk/commit/a3eea277aae46450aec1f0c811e3fe256022c46e), [`7a6b1a4`](https://github.com/cloudflare/workers-sdk/commit/7a6b1a4f4e9d8d5bd88732c8e11368c3ad7f867b), [`7539a9b`](https://github.com/cloudflare/workers-sdk/commit/7539a9bfcf03a14b2c16f281d541b6bc45523a80), [`3b8b80a`](https://github.com/cloudflare/workers-sdk/commit/3b8b80ab32e3ac33b5df9f6944dca9cdf72c5495), [`0bb2d55`](https://github.com/cloudflare/workers-sdk/commit/0bb2d55116ce90a147582a7b4d96e3090cddf7ee), [`8400fb9`](https://github.com/cloudflare/workers-sdk/commit/8400fb945a781e7a7a78a3614a702ace2d1fbc87), [`b502d54`](https://github.com/cloudflare/workers-sdk/commit/b502d5445b9e9e030020a3d65c0334507393aa64), [`7949f81`](https://github.com/cloudflare/workers-sdk/commit/7949f81bd258292a4a0b9c5a339c6c035f27d7ca), [`d462013`](https://github.com/cloudflare/workers-sdk/commit/d46201384f656815bf9e90a595098edff43f1b32), [`c2280cd`](https://github.com/cloudflare/workers-sdk/commit/c2280cdb589c9289bb4082d0a068846f3dd22b37), [`ea12b58`](https://github.com/cloudflare/workers-sdk/commit/ea12b584ee1c3141286f0ecf6b742bd79971407e), [`acf7817`](https://github.com/cloudflare/workers-sdk/commit/acf7817266b39be9707a09b918d670a468302ebc)]: - - wrangler@4.98.0 - -## 0.1.1 - -### Patch Changes - -- Updated dependencies [[`b210c5e`](https://github.com/cloudflare/workers-sdk/commit/b210c5eefdb22d83f937728527bc0091f9308070), [`aec1bb8`](https://github.com/cloudflare/workers-sdk/commit/aec1bb826aaba963bfc1ee96ba7359e284162bfa), [`e06cbb7`](https://github.com/cloudflare/workers-sdk/commit/e06cbb722b3552b622e48c53d4f7d910162ce943), [`9a26191`](https://github.com/cloudflare/workers-sdk/commit/9a26191e1a8c4246f7999bdb3637a176b9166207), [`5565823`](https://github.com/cloudflare/workers-sdk/commit/5565823854b60937fcad7162425fcd9fad64558a), [`890fca7`](https://github.com/cloudflare/workers-sdk/commit/890fca7d63a6efab5a58e4829cf02bf731eab197), [`6fc9777`](https://github.com/cloudflare/workers-sdk/commit/6fc97775d688ab6b65c40cad1c403bb04346d77e), [`337e912`](https://github.com/cloudflare/workers-sdk/commit/337e9124cfa461a99ce7ffb800dcc341f7b2f026), [`8e7b74f`](https://github.com/cloudflare/workers-sdk/commit/8e7b74fa837dc7b67c4affab1d4b28876ce4d3f2), [`e86489a`](https://github.com/cloudflare/workers-sdk/commit/e86489a5743ff9bad7bcb5b444ad3d952d5b0164), [`42288d4`](https://github.com/cloudflare/workers-sdk/commit/42288d4886b7b7a516f5bcca6924a706201aa1e8), [`65b5f9e`](https://github.com/cloudflare/workers-sdk/commit/65b5f9e1855651c2df2c1bdfc8930141e36413d5), [`3a746ac`](https://github.com/cloudflare/workers-sdk/commit/3a746ac56a40b805e38f26ef5328e44917b543e6), [`64ef9fd`](https://github.com/cloudflare/workers-sdk/commit/64ef9fd46eeb590813bb8cbc61b58c407452362e), [`94b29f7`](https://github.com/cloudflare/workers-sdk/commit/94b29f76c6c6543c2504fb9d1967f15a3bad530d)]: - - wrangler@4.97.0 - -## 0.1.0 - -### Minor Changes - -- [#13892](https://github.com/cloudflare/workers-sdk/pull/13892) [`13cbadb`](https://github.com/cloudflare/workers-sdk/commit/13cbadbd7ecdd2b7c56b850df1209960a71f7d54) Thanks [@penalosa](https://github.com/penalosa)! - Add `@cloudflare/wrangler-bundler` — an experimental internal package. Not intended for external use. - -### Patch Changes - -- Updated dependencies [[`e3c862a`](https://github.com/cloudflare/workers-sdk/commit/e3c862a99f9b633ca288306eae8a8c3a900590ee), [`cbb39bd`](https://github.com/cloudflare/workers-sdk/commit/cbb39bdc90d4b93f9a9b4355124570d838eb1a2d), [`cbb39bd`](https://github.com/cloudflare/workers-sdk/commit/cbb39bdc90d4b93f9a9b4355124570d838eb1a2d), [`408432a`](https://github.com/cloudflare/workers-sdk/commit/408432aed493563cb13b9a9c241806112ea606bc), [`1103c07`](https://github.com/cloudflare/workers-sdk/commit/1103c07646569208c4b0a623d123395643e022d5), [`5b5cbd3`](https://github.com/cloudflare/workers-sdk/commit/5b5cbd3e98e5713ecf5ee0afa975a1f2ee38b2cc), [`e3c862a`](https://github.com/cloudflare/workers-sdk/commit/e3c862a99f9b633ca288306eae8a8c3a900590ee), [`e3c862a`](https://github.com/cloudflare/workers-sdk/commit/e3c862a99f9b633ca288306eae8a8c3a900590ee), [`97d7d81`](https://github.com/cloudflare/workers-sdk/commit/97d7d81e0a757e30e7700b183133249e2136a280), [`c647ccc`](https://github.com/cloudflare/workers-sdk/commit/c647ccc7873c2cada60ba5f4ce7c8dfeb4801acc), [`e3c862a`](https://github.com/cloudflare/workers-sdk/commit/e3c862a99f9b633ca288306eae8a8c3a900590ee), [`e3c862a`](https://github.com/cloudflare/workers-sdk/commit/e3c862a99f9b633ca288306eae8a8c3a900590ee), [`e3c862a`](https://github.com/cloudflare/workers-sdk/commit/e3c862a99f9b633ca288306eae8a8c3a900590ee), [`e3c862a`](https://github.com/cloudflare/workers-sdk/commit/e3c862a99f9b633ca288306eae8a8c3a900590ee), [`b64b7e4`](https://github.com/cloudflare/workers-sdk/commit/b64b7e4499b940efd74cdc09215620ee0b34a290), [`e3c862a`](https://github.com/cloudflare/workers-sdk/commit/e3c862a99f9b633ca288306eae8a8c3a900590ee), [`e3c862a`](https://github.com/cloudflare/workers-sdk/commit/e3c862a99f9b633ca288306eae8a8c3a900590ee), [`e4c8fd9`](https://github.com/cloudflare/workers-sdk/commit/e4c8fd97a63230fccffe3d2c62185f5350fc5351), [`2dffeeb`](https://github.com/cloudflare/workers-sdk/commit/2dffeeb92d4f0b8a4c2c91f9cca7959d1970638a), [`e3c862a`](https://github.com/cloudflare/workers-sdk/commit/e3c862a99f9b633ca288306eae8a8c3a900590ee), [`e3c862a`](https://github.com/cloudflare/workers-sdk/commit/e3c862a99f9b633ca288306eae8a8c3a900590ee), [`4c0da7b`](https://github.com/cloudflare/workers-sdk/commit/4c0da7be0d47e6127066dc6edd8a59e536e7c24c), [`13cbadb`](https://github.com/cloudflare/workers-sdk/commit/13cbadbd7ecdd2b7c56b850df1209960a71f7d54), [`59e43e4`](https://github.com/cloudflare/workers-sdk/commit/59e43e4e066f9d201fc6c1e3b31cb232853e83d7)]: - - wrangler@4.96.0 diff --git a/packages/wrangler-bundler/README.md b/packages/wrangler-bundler/README.md deleted file mode 100644 index 17de820511..0000000000 --- a/packages/wrangler-bundler/README.md +++ /dev/null @@ -1,80 +0,0 @@ -# @cloudflare/wrangler-bundler - -esbuild-based dev server for Cloudflare Workers — extracted from -`wrangler dev` for projects that can't migrate to Vite. - -> **Use [`@cloudflare/vite-plugin`](https://www.npmjs.com/package/@cloudflare/vite-plugin) -> instead** if your project uses Vite. The Vite plugin is the -> recommended JavaScript/TypeScript dev-server impl going forward. -> `@cloudflare/wrangler-bundler` is provided for legacy projects whose -> build pipeline depends on esbuild semantics. - -## What this package is - -A thin adapter on top of wrangler's `unstable_dev` API. It ships a -`cf-wrangler` delegate binary that exposes a small CLI dispatched on -a leading subcommand verb. Today it accepts only `dev` (long-running -esbuild + Miniflare); future verbs (`build`, `deploy`) will follow -the same shape. - -A parent CLI (typically a project's chosen tool) is expected to -discover this package in `devDependencies` and spawn the -`cf-wrangler dev` subcommand on its behalf. You can also invoke -`./node_modules/.bin/cf-wrangler dev` directly. - -## Installation - -```sh -npm install --save-dev @cloudflare/wrangler-bundler -``` - -## CLI - -```sh -cf-wrangler dev [flags] -``` - -The accepted flag set is deliberately minimal. Everything else -belongs in your `wrangler.jsonc` / `wrangler.toml`. - -| Flag | Description | -| ---------- | ---------------------------------------------------------------------------------------------------- | -| `--config` | Path to `wrangler.jsonc` / `wrangler.toml`. Defaults to wrangler's standard config-discovery search. | -| `--mode` | Named environment (`[env.X]` in your config). Maps to wrangler's `env` option. | -| `--port` | Listen port for the dev server. Integer 0–65535 (0 = OS-assigned). | -| `--host` | Acts-as-origin hostname override (the `Host` header your Worker sees). | -| `--local` | Force local execution even if `dev.remote` is set in config. | - -Unknown flags are rejected at parse time with a clear error. - -## What it supports - -- `wrangler.jsonc` / `wrangler.toml` config files (incl. named - environments via `--mode`) -- esbuild-based bundling (via wrangler's internals) -- Miniflare + workerd local runtime -- Dev registry registration (multi-session dev) -- **Remote bindings** — set `remote = true` on individual resources - in your wrangler config to access remote Cloudflare resources from - local code. Uses the standard wrangler auth flow - (`wrangler login` / `CLOUDFLARE_API_TOKEN`). -- **Interactive hotkeys** in a TTY session - (`b`/`d`/`e`/`r`/`l`/`c`/`x`/`q`) — same as `wrangler dev`. -- **Containers** — both `image = "..."` (local build) and - `image_uri = "..."` (Cloudchamber-pulled) work, via the same - auth flow as remote bindings. - -## What it does NOT support - -- **Whole-worker remote dev** (`wrangler dev --remote`). For remote - bindings, use the per-resource `remote = true` field instead — no - flag needed. -- `--tunnel` (use `wrangler dev --tunnel` directly). -- Cloudflare Pages and Sites. -- Multi-worker dev sessions. -- The rest of `wrangler dev`'s flag surface (`--var`, `--define`, - `--assets`, `--tsconfig`, `--persist-to`, `--live-reload`, etc.). - These all have equivalents in `wrangler.jsonc` — configure them - there. - -For any of these, run `wrangler dev` directly. diff --git a/packages/wrangler-bundler/bin/cf-wrangler b/packages/wrangler-bundler/bin/cf-wrangler deleted file mode 100755 index 78f82d147c..0000000000 --- a/packages/wrangler-bundler/bin/cf-wrangler +++ /dev/null @@ -1,31 +0,0 @@ -#!/usr/bin/env node -// `cf-wrangler` — delegate binary for `@cloudflare/wrangler-bundler`. -// -// Invoked as ` [user-argv...]`. The leading verb is the -// subcommand discriminator the parent process inserts; strip it before -// delegating to the verb's handler. -// -// We deliberately do NOT use a yargs-style "command" router here — -// this binary exposes exactly one verb today (`dev`), and the -// unknown-subcommand error doubles as the version-detection signal -// the parent uses to feature-test the impl. Future verbs (`build`, -// `deploy`, etc.) will follow the same shape. -import { runDev } from "../dist/index.mjs"; - -const argv = process.argv.slice(2); -const verb = argv[0]; -if (verb !== "dev") { - process.stderr.write( - `Error: unknown subcommand "${verb ?? ""}".\n` + - `Usage: cf-wrangler dev [args]\n` - ); - process.exit(2); -} -const rest = argv.slice(1); - -runDev(rest) - .then((code) => process.exit(code)) - .catch((err) => { - process.stderr.write(`${(err && err.stack) || err}\n`); - process.exit(1); - }); diff --git a/packages/wrangler-bundler/package.json b/packages/wrangler-bundler/package.json deleted file mode 100644 index fa38c03ad2..0000000000 --- a/packages/wrangler-bundler/package.json +++ /dev/null @@ -1,59 +0,0 @@ -{ - "name": "@cloudflare/wrangler-bundler", - "version": "0.1.3", - "description": "esbuild-based dev server for Cloudflare Workers, extracted from `wrangler dev`", - "license": "MIT OR Apache-2.0", - "repository": { - "type": "git", - "url": "https://github.com/cloudflare/workers-sdk.git", - "directory": "packages/wrangler-bundler" - }, - "bin": { - "cf-wrangler": "./bin/cf-wrangler" - }, - "files": [ - "bin", - "dist" - ], - "type": "module", - "main": "./dist/index.mjs", - "types": "./dist/index.d.mts", - "exports": { - ".": { - "types": "./dist/index.d.mts", - "import": "./dist/index.mjs" - } - }, - "scripts": { - "build": "tsdown", - "dev": "tsdown --watch", - "check:type": "tsc --build", - "test": "vitest run", - "test:ci": "vitest run", - "test:watch": "vitest" - }, - "dependencies": { - "wrangler": "workspace:*" - }, - "devDependencies": { - "@cloudflare/workers-tsconfig": "workspace:*", - "@types/node": "catalog:default", - "tsdown": "0.16.3", - "typescript": "catalog:default", - "vitest": "catalog:default" - }, - "peerDependencies": { - "@cloudflare/workers-types": "catalog:default" - }, - "peerDependenciesMeta": { - "@cloudflare/workers-types": { - "optional": true - } - }, - "engines": { - "node": ">=22.0.0" - }, - "workers-sdk": { - "prerelease": true - } -} diff --git a/packages/wrangler-bundler/src/cli.ts b/packages/wrangler-bundler/src/cli.ts deleted file mode 100644 index 7c02413797..0000000000 --- a/packages/wrangler-bundler/src/cli.ts +++ /dev/null @@ -1,113 +0,0 @@ -/** - * `dev` verb handler for the `cf-wrangler` delegate binary. - * - * Thin adapter over `wrangler.unstable_dev` (which wraps `startDev` — - * giving us DevEnv lifecycle, remote-bindings auth, and hotkeys for - * free). Accepts only the five flags cf-dev passes through; the rest - * comes from the user's wrangler config. - */ -import { unstable_dev } from "wrangler"; -import { ArgParseError, parseArgs } from "./args.js"; -import type { DevArgs } from "./args.js"; -import type { Unstable_DevOptions, Unstable_DevWorker } from "wrangler"; - -/** - * Run the bundler-based dev server until the user (or the parent - * process) terminates it. - * - * Parses argv, calls `wrangler.unstable_dev`, blocks on the dev - * session's lifetime, and translates SIGINT/SIGTERM into a clean - * teardown. Returns the desired exit code; the bin shim is - * responsible for `process.exit()`. - * - * @param argv argv slice **without** the `dev` subcommand token - * (e.g. `["--config", "wrangler.jsonc", "--port", "8788"]`). - * @returns the process exit code: `0` on clean teardown, `2` on an - * argv parse error, `130` on SIGINT, `143` on SIGTERM. - * - * @example - * ```ts - * import { runDev } from "@cloudflare/wrangler-bundler"; - * - * const code = await runDev(["--mode", "production", "--port", "8788"]); - * process.exit(code); - * ``` - */ -export async function runDev(argv: string[]): Promise { - let parsed: DevArgs; - try { - parsed = parseArgs(argv); - } catch (err) { - if (err instanceof ArgParseError) { - process.stderr.write(`Error: ${err.message}\n`); - return 2; - } - throw err; - } - - // SIGINT/SIGTERM fallback for non-TTY parents — hotkeys handle - // the TTY case. Tracks both the signal AND a reference to the - // server (which arrives asynchronously) so signals received during - // `unstable_dev`'s startup phase don't get swallowed. - let signalled: NodeJS.Signals | null = null; - let server: Unstable_DevWorker | undefined; - const onSignal = (sig: NodeJS.Signals) => { - signalled = sig; - // If startup has already produced a server, tear it down now. - // Otherwise the post-await check below handles it. - void server?.stop().catch((err) => { - process.stderr.write(`teardown error: ${err}\n`); - }); - }; - const onSigInt = () => onSignal("SIGINT"); - const onSigTerm = () => onSignal("SIGTERM"); - process.on("SIGINT", onSigInt); - process.on("SIGTERM", onSigTerm); - - try { - const options = { - config: parsed.config, - env: parsed.mode, - port: parsed.port, - local: parsed.local, - host: parsed.host, - experimental: { - disableExperimentalWarning: true, - // Both default to false in `unstable_dev` (suits its - // test-harness origin); `wrangler dev` effectively - // defaults both to true. Match `wrangler dev`. - showInteractiveDevSession: true, - enableContainers: true, - }, - } as Unstable_DevOptions; - - // Entrypoint comes from `main` in wrangler config. - server = await unstable_dev("", options); - - // Signal arrived during startup. Tear down immediately and - // skip `waitUntilExit`. - if (signalled !== null) { - await server.stop().catch(() => {}); - } else { - await server.waitUntilExit(); - } - } finally { - process.off("SIGINT", onSigInt); - process.off("SIGTERM", onSigTerm); - if (server) { - try { - await server.stop(); - } catch { - // already torn down - } - } - } - - if (signalled === "SIGINT") { - return 130; - } - if (signalled === "SIGTERM") { - return 143; - } - return 0; -} diff --git a/packages/wrangler-bundler/src/index.ts b/packages/wrangler-bundler/src/index.ts deleted file mode 100644 index 211e58842d..0000000000 --- a/packages/wrangler-bundler/src/index.ts +++ /dev/null @@ -1,10 +0,0 @@ -/** - * Public API for `@cloudflare/wrangler-bundler`. - * - * The primary entry point is the `cf-wrangler` delegate binary in - * `bin/cf-wrangler`, which dispatches on a leading subcommand verb - * (today: `dev`). Programmatic use is supported but is not the main - * use case. - */ -export { runDev } from "./cli.js"; -export type { DevArgs } from "./args.js"; diff --git a/packages/wrangler-bundler/tsconfig.json b/packages/wrangler-bundler/tsconfig.json deleted file mode 100644 index 2d140659c9..0000000000 --- a/packages/wrangler-bundler/tsconfig.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "extends": "@cloudflare/workers-tsconfig/base.json", - "include": ["src/**/*", "bin/**/*"], - "exclude": ["dist", "node_modules"], - "compilerOptions": { - "outDir": "dist", - "module": "esnext", - "moduleResolution": "bundler", - "rootDir": ".", - "noEmit": false, - "declaration": true, - "emitDeclarationOnly": true - } -} diff --git a/packages/wrangler-bundler/tsdown.config.ts b/packages/wrangler-bundler/tsdown.config.ts deleted file mode 100644 index bd4e7f5174..0000000000 --- a/packages/wrangler-bundler/tsdown.config.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { defineConfig } from "tsdown"; - -export default defineConfig({ - entry: "src/index.ts", - platform: "node", - format: "esm", - outDir: "dist", - target: "node22", - dts: true, - clean: true, - sourcemap: process.env.NODE_ENV !== "production", - minify: process.env.NODE_ENV === "production", - external: ["wrangler"], -}); diff --git a/packages/wrangler-bundler/turbo.json b/packages/wrangler-bundler/turbo.json deleted file mode 100644 index 60b2ca4c31..0000000000 --- a/packages/wrangler-bundler/turbo.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "$schema": "http://turbo.build/schema.json", - "extends": ["//"], - "tasks": { - "build": { - "inputs": ["$TURBO_DEFAULT$", "!**/__tests__/**"], - "outputs": ["dist/**"], - "env": ["NODE_ENV"] - } - } -} diff --git a/packages/wrangler-bundler/vitest.config.ts b/packages/wrangler-bundler/vitest.config.ts deleted file mode 100644 index 4d6bc60dbd..0000000000 --- a/packages/wrangler-bundler/vitest.config.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { defineConfig, mergeConfig } from "vitest/config"; -import sharedConfig from "../../vitest.shared"; - -export default mergeConfig( - sharedConfig, - defineConfig({ - test: { - include: ["src/__tests__/**/*.test.ts"], - }, - }) -); diff --git a/packages/wrangler/AGENTS.md b/packages/wrangler/AGENTS.md index 9a4a2bf44c..1b30a1d73f 100644 --- a/packages/wrangler/AGENTS.md +++ b/packages/wrangler/AGENTS.md @@ -12,13 +12,16 @@ Main CLI for Cloudflare Workers. ~2k-line yargs command tree in `src/index.ts`. - `src/__tests__/` — Unit tests, helpers in `src/__tests__/helpers/` - `e2e/` — E2E tests, requires Cloudflare credentials - `bin/wrangler.js` — Shim that spawns Node with `--experimental-vm-modules` +- `bin/cf-wrangler.js` — `cf-wrangler` delegate entrypoint. Owns verb dispatch, argv parsing (`parseCfWranglerArgs`), and the `StartDevOptions` literal; hands off to `runCfWranglerDev` from `wrangler-dist/cli.js` in-process (no re-spawn — the parent tool owns the Node runtime) +- `src/cf-wrangler/` — The `cf-wrangler` delegate entrypoint (see below) - `templates/` — Worker templates ## Entry Points -- `src/cli.ts` — Build entry AND library API surface (dual-purpose). Calls `main()` when run directly; re-exports `./api` when imported as library. +- `src/cli.ts` — Build entry AND library API surface (dual-purpose). Calls `main()` when run directly; re-exports `./api` when imported as library. Also re-exports `parseCfWranglerArgs`, `ArgParseError`, and `runCfWranglerDev` for the `cf-wrangler` bin to call in-process. - `src/index.ts` — Yargs CLI tree builder (large file). Exports `main()`. NOT the package entry point despite the name. - `src/api/index.ts` — Public programmatic API barrel. +- `src/cf-wrangler/` — The `cf-wrangler` delegate entrypoint, an experimental escape hatch for projects that can't use `@cloudflare/vite-plugin`. It sits directly on the internal `startDev` (the exact function `wrangler dev` runs) for byte-for-byte parity, exposing only `dev` + four flags (`--mode`, `--port`, `--host`, `--local`); the wrangler config file is found via wrangler's standard discovery (no `--config` flag). It is NOT a separate package and does NOT use the `unstable_dev` test harness. It shares its spawn contract (verb dispatch, flag vocabulary, exit-2 feature detection) with the sibling `cf-vite` delegate in `@cloudflare/vite-plugin`. `bin/cf-wrangler.js` owns verb dispatch, argv parsing, and the `StartDevOptions` literal; `src/cf-wrangler/dev.ts` (exported as `runCfWranglerDev`) wraps `startDev` in the experimental-flags context and waits for teardown. `src/cf-wrangler/args.ts` (exported as `parseCfWranglerArgs` / `ArgParseError`) does the strict argv parse. The "unknown subcommand" error doubles as a feature-detection signal for the parent CLI. ## Conventions (Wrangler-Specific) diff --git a/packages/wrangler/bin/cf-wrangler.js b/packages/wrangler/bin/cf-wrangler.js new file mode 100755 index 0000000000..6351dd3101 --- /dev/null +++ b/packages/wrangler/bin/cf-wrangler.js @@ -0,0 +1,115 @@ +#!/usr/bin/env node +// `cf-wrangler` delegate binary. Runs wrangler's bundled dev server +// in-process; the parent tool owns the Node runtime (version, flags). +// +// Dispatches on the leading verb. Only `dev` exists today; an unknown +// or missing verb exits 2, which the parent uses to feature-detect +// support. +const { + ArgParseError, + parseCfWranglerArgs, + runCfWranglerDev, +} = require("../wrangler-dist/cli.js"); + +const argv = process.argv.slice(2); +const verb = argv[0]; + +if (verb !== "dev") { + process.stderr.write( + `Error: unknown subcommand "${verb ?? ""}".\n` + + `Usage: cf-wrangler dev [args]\n` + ); + process.exit(2); +} + +let parsed; +try { + parsed = parseCfWranglerArgs(argv.slice(1)); +} catch (err) { + if (err instanceof ArgParseError) { + process.stderr.write(`Error: ${err.message}\n`); + process.exit(2); + } + throw err; +} + +// Build wrangler dev's full (yargs-derived) options object. Every field +// must be present; we set the four accepted flags plus `wrangler dev`'s +// defaults and leave everything else `undefined`, which makes wrangler's +// ConfigController resolve those exactly as `wrangler dev` would (config +// discovery, containers, inspector port, interactive hotkeys, ...). +// `--local` forces local execution; left unset it preserves per-resource +// `remote = true` bindings. There is no whole-worker remote dev (no +// `--remote`). +const options = { + _: [], + $0: "", + env: parsed.mode, + port: parsed.port, + host: parsed.host, + local: parsed.local, + remote: false, + latest: true, + noBundle: false, + testScheduled: false, + processEntrypoint: false, + experimentalAutoCreate: false, + types: false, + disableDevRegistry: false, + config: undefined, + script: undefined, + name: undefined, + accountId: undefined, + forceLocal: undefined, + compatibilityDate: undefined, + compatibilityFlags: undefined, + ip: undefined, + inspectorPort: undefined, + inspectorIp: undefined, + v: undefined, + cwd: undefined, + localProtocol: undefined, + httpsKeyPath: undefined, + httpsCertPath: undefined, + assets: undefined, + site: undefined, + siteInclude: undefined, + siteExclude: undefined, + persist: undefined, + persistTo: undefined, + routes: undefined, + localUpstream: undefined, + upstreamProtocol: undefined, + var: undefined, + define: undefined, + alias: undefined, + jsxFactory: undefined, + jsxFragment: undefined, + tsconfig: undefined, + minify: undefined, + legacyEnv: undefined, + logLevel: undefined, + showInteractiveDevSession: undefined, + liveReload: undefined, + bundle: undefined, + additionalModules: undefined, + enablePagesAssetsServiceBinding: undefined, + d1Databases: undefined, + experimentalProvision: undefined, + enableIpc: undefined, + nodeCompat: undefined, + enableContainers: undefined, + dockerPath: undefined, + containerEngine: undefined, + tunnel: undefined, + tunnelName: undefined, + envFile: undefined, + onReady: undefined, +}; + +runCfWranglerDev(options) + .then((code) => process.exit(code)) + .catch((err) => { + process.stderr.write(`${(err && err.stack) || err}\n`); + process.exit(1); + }); diff --git a/packages/wrangler/package.json b/packages/wrangler/package.json index 1b8631acc8..eeb4966af2 100644 --- a/packages/wrangler/package.json +++ b/packages/wrangler/package.json @@ -36,6 +36,7 @@ "directory": "packages/wrangler" }, "bin": { + "cf-wrangler": "./bin/cf-wrangler.js", "wrangler": "./bin/wrangler.js", "wrangler2": "./bin/wrangler.js" }, diff --git a/packages/wrangler-bundler/src/__tests__/args.test.ts b/packages/wrangler/src/__tests__/cf-wrangler/args.test.ts similarity index 92% rename from packages/wrangler-bundler/src/__tests__/args.test.ts rename to packages/wrangler/src/__tests__/cf-wrangler/args.test.ts index 5e9040418b..dc1f26f928 100644 --- a/packages/wrangler-bundler/src/__tests__/args.test.ts +++ b/packages/wrangler/src/__tests__/cf-wrangler/args.test.ts @@ -1,12 +1,10 @@ import { describe, it } from "vitest"; -import { ArgParseError, parseArgs } from "../args.js"; +import { ArgParseError, parseArgs } from "../../cf-wrangler/args"; -describe("parseArgs", () => { +describe("cf-wrangler parseArgs", () => { describe("happy paths", () => { it("parses all flags in --flag value form", ({ expect }) => { const out = parseArgs([ - "--config", - "wrangler.jsonc", "--mode", "production", "--port", @@ -16,7 +14,6 @@ describe("parseArgs", () => { "--local", ]); expect(out).toEqual({ - config: "wrangler.jsonc", mode: "production", port: 8788, host: "example.com", @@ -26,14 +23,12 @@ describe("parseArgs", () => { it("parses all flags in --flag=value form", ({ expect }) => { const out = parseArgs([ - "--config=wrangler.jsonc", "--mode=production", "--port=8788", "--host=example.com", "--local", ]); expect(out).toEqual({ - config: "wrangler.jsonc", mode: "production", port: 8788, host: "example.com", @@ -48,7 +43,7 @@ describe("parseArgs", () => { it("omits unset flags from the output", ({ expect }) => { const out = parseArgs(["--port", "8788"]); expect(out).toEqual({ port: 8788 }); - expect(out.config).toBeUndefined(); + expect(out.mode).toBeUndefined(); expect(out.local).toBeUndefined(); }); @@ -119,6 +114,14 @@ describe("parseArgs", () => { /Unknown option/ ); }); + + it("rejects --config (config comes from wrangler's discovery)", ({ + expect, + }) => { + expect(() => parseArgs(["--config", "wrangler.jsonc"])).toThrow( + /Unknown option/ + ); + }); }); describe("structural rejections", () => { diff --git a/packages/wrangler-bundler/src/args.ts b/packages/wrangler/src/cf-wrangler/args.ts similarity index 50% rename from packages/wrangler-bundler/src/args.ts rename to packages/wrangler/src/cf-wrangler/args.ts index 2c9babecbe..57e318f63c 100644 --- a/packages/wrangler-bundler/src/args.ts +++ b/packages/wrangler/src/cf-wrangler/args.ts @@ -1,27 +1,15 @@ /** - * Argv parser for `cf-wrangler dev [args...]`. - * - * Deliberately minimal: only the five flags the cf-dev parent - * process needs to pass through are accepted. Everything else - * belongs in the user's `wrangler.jsonc`. Built on `node:util`'s - * built-in `parseArgs` (strict mode → unknown flags throw). + * Strict argv parser for `cf-wrangler dev`. Only four flags are accepted; + * unknown flags throw. The config file comes from wrangler's standard + * discovery, not a flag. */ import { parseArgs as nodeParseArgs } from "node:util"; export interface DevArgs { - // Path to wrangler.jsonc / wrangler.toml. - config?: string; - // Named environment from wrangler.jsonc (`[env.X]`). Surfaced as - // `--mode` rather than `--env` to align with the cf-dev parent - // process's flag vocabulary; maps to wrangler's `env` option. - mode?: string; - // Listen port for the dev server. + mode?: string; // maps to wrangler's `env` (named environment) port?: number; - // Acts-as-origin hostname override. Maps to wrangler's `--host` - // (`dev.origin.hostname`). - host?: string; - // Force local execution even when `dev.remote` is set in config. - local?: boolean; + host?: string; // acts-as-origin hostname (`dev.origin.hostname`) + local?: boolean; // force local even if a resource sets `remote = true` } export class ArgParseError extends Error { @@ -37,7 +25,6 @@ export function parseArgs(argv: string[]): DevArgs { parsed = nodeParseArgs({ args: argv, options: { - config: { type: "string" }, mode: { type: "string" }, host: { type: "string" }, // `node:util.parseArgs` has no `number` type; coerce below. @@ -46,19 +33,14 @@ export function parseArgs(argv: string[]): DevArgs { }, strict: true, allowPositionals: false, - // Deliberately NOT enabling `allowNegative`: `--no-local` - // would map to `local: false`, which `unstable_dev` reads - // as whole-worker remote dev — out of scope here. Falling - // into the generic "unknown flag" error is the right UX. + // No `allowNegative`: `--no-local` should be an unknown-flag + // error, not `local: false` (which would mean remote dev). }); } catch (err) { throw new ArgParseError(err instanceof Error ? err.message : String(err)); } const out: DevArgs = {}; - if (parsed.values.config !== undefined) { - out.config = parsed.values.config; - } if (parsed.values.mode !== undefined) { out.mode = parsed.values.mode; } @@ -68,7 +50,7 @@ export function parseArgs(argv: string[]): DevArgs { if (parsed.values.port !== undefined) { const raw = parsed.values.port; const n = Number(raw); - // TCP port range. `0` means "let the OS pick" — valid. + // `0` = OS-assigned, so the valid range is 0–65535. if (!Number.isInteger(n) || n < 0 || n > 65535) { throw new ArgParseError( `--port expects an integer between 0 and 65535, got "${raw}"` diff --git a/packages/wrangler/src/cf-wrangler/dev.ts b/packages/wrangler/src/cf-wrangler/dev.ts new file mode 100644 index 0000000000..c1e5a61337 --- /dev/null +++ b/packages/wrangler/src/cf-wrangler/dev.ts @@ -0,0 +1,42 @@ +/** + * `dev` verb runtime for the `cf-wrangler` delegate entrypoint. + * + * Wraps wrangler's internal `startDev` — the same function `wrangler dev` + * runs — in the experimental-flags async-local context this entrypoint + * needs, and blocks until the dev session tears down. Argv parsing and + * the `StartDevOptions` literal live in `bin/cf-wrangler.js`. + */ +import events from "node:events"; +import { startDev } from "../dev/start-dev"; +import { run } from "../experimental-flags"; +import type { StartDevOptions } from "../dev"; + +/** + * Run the dev server until it tears down (a hotkey quit in a TTY, or a + * signal from a non-interactive parent). Mirrors `wrangler dev`'s command + * handler and installs no signal handlers of its own, so signal handling + * and exit codes match `wrangler dev` exactly. + * + * @param options Fully-built `StartDevOptions` (built in `bin/cf-wrangler.js`). + * @returns `0` on a clean teardown. + */ +export async function runCfWranglerDev( + options: StartDevOptions +): Promise { + // `startDev` reads experimental flags from async-local storage. This + // entrypoint is single-worker and never provisions resources. + const devInstance = await run( + { + MULTIWORKER: false, + RESOURCES_PROVISION: false, + AUTOCREATE_RESOURCES: false, + }, + () => startDev(options) + ); + + await events.once(devInstance.devEnv, "teardown"); + await Promise.all(devInstance.secondary.map((d) => d.teardown())); + devInstance.unregisterHotKeys?.(); + + return 0; +} diff --git a/packages/wrangler/src/cli.ts b/packages/wrangler/src/cli.ts index 0dc8de91d7..2f5f25afe2 100644 --- a/packages/wrangler/src/cli.ts +++ b/packages/wrangler/src/cli.ts @@ -102,6 +102,14 @@ export type { export { printBindings as unstable_printBindings } from "./utils/print-bindings"; export { resolveNamedTunnel as unstable_resolveNamedTunnel } from "./tunnel/client"; +// Entries for the `cf-wrangler` delegate binary (see `bin/cf-wrangler.js`), +// which calls these in-process. Not a stable public API. +export { runCfWranglerDev } from "./cf-wrangler/dev"; +export { + ArgParseError, + parseArgs as parseCfWranglerArgs, +} from "./cf-wrangler/args"; + // Export internal APIs required by the Vitest integration as `unstable_` export { splitSqlQuery as unstable_splitSqlQuery } from "./d1/splitter"; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b17c63bd82..836ce72067 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4425,31 +4425,6 @@ importers: specifier: 2.3.3 version: 2.3.3 - packages/wrangler-bundler: - dependencies: - '@cloudflare/workers-types': - specifier: catalog:default - version: 4.20260610.1 - wrangler: - specifier: workspace:* - version: link:../wrangler - devDependencies: - '@cloudflare/workers-tsconfig': - specifier: workspace:* - version: link:../workers-tsconfig - '@types/node': - specifier: 22.15.17 - version: 22.15.17 - tsdown: - specifier: 0.16.3 - version: 0.16.3(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(ms@2.1.3)(synckit@0.11.12)(typescript@5.8.3) - typescript: - specifier: catalog:default - version: 5.8.3 - 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)) - tools: devDependencies: '@actions/core': diff --git a/tools/deployments/__tests__/validate-changesets.test.ts b/tools/deployments/__tests__/validate-changesets.test.ts index 94f0029121..e5f273fbb8 100644 --- a/tools/deployments/__tests__/validate-changesets.test.ts +++ b/tools/deployments/__tests__/validate-changesets.test.ts @@ -46,7 +46,6 @@ describe("findPackageNames()", () => { "@cloudflare/workers-shared", "@cloudflare/workers-utils", "@cloudflare/workflows-shared", - "@cloudflare/wrangler-bundler", "create-cloudflare", "miniflare", "solarflare-theme", From 10b553819addbcd1224f66d5b52bb7c7f7c8e602 Mon Sep 17 00:00:00 2001 From: Dario Piotrowicz Date: Wed, 10 Jun 2026 18:44:30 +0100 Subject: [PATCH 4/4] Improve authentication error messages with specific failure reasons (#14213) --- .changeset/improve-login-error-messages.md | 10 +++ packages/workers-auth/src/flow.ts | 69 +++++++++++++++---- packages/workers-auth/src/index.ts | 7 +- packages/workers-auth/src/token-exchange.ts | 8 ++- packages/wrangler/src/__tests__/dev.test.ts | 10 ++- .../dev/remote-bindings-errors.test.ts | 10 ++- packages/wrangler/src/__tests__/user.test.ts | 12 +++- .../start-remote-proxy-session.ts | 8 +++ .../api/startDevWorker/ConfigController.ts | 63 +++++++++++++++-- packages/wrangler/src/cfetch/internal.ts | 38 +++++++++- packages/wrangler/src/user/user.ts | 25 +++++-- 11 files changed, 222 insertions(+), 38 deletions(-) create mode 100644 .changeset/improve-login-error-messages.md diff --git a/.changeset/improve-login-error-messages.md b/.changeset/improve-login-error-messages.md new file mode 100644 index 0000000000..2d24a90ac8 --- /dev/null +++ b/.changeset/improve-login-error-messages.md @@ -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. diff --git a/packages/workers-auth/src/flow.ts b/packages/workers-auth/src/flow.ts index 5dcbae2ae8..8eb094cf9b 100644 --- a/packages/workers-auth/src/flow.ts +++ b/packages/workers-auth/src/flow.ts @@ -32,6 +32,31 @@ import { exchangeRefreshTokenForAccessToken } from "./token-exchange"; import type { OAuthFlowContext } from "./context"; import type { ComplianceConfig } from "@cloudflare/workers-utils"; +/** + * Reason why {@link OAuthFlowAPI.loginOrRefreshIfRequired} could not + * authenticate the user. + */ +export type LoginOrRefreshFailureReason = + /** no stored credentials and the environment is non-interactive (CI, piped stdin, etc.) so a browser login cannot be started. */ + | "no-credentials-non-interactive" + /** stored credentials and the interactive login attempt was unsuccessful (user cancelled, etc.). */ + | "no-credentials-login-failed" + /** the stored token has expired, refresh failed, and the environment is non-interactive so a browser login cannot be started. */ + | "token-expired-non-interactive" + /** the stored token has expired, refresh failed, and the interactive login attempt was unsuccessful. */ + | "token-expired-login-failed"; + +/** + * Discriminated union returned by {@link OAuthFlowAPI.loginOrRefreshIfRequired}. + * + * When `loggedIn` is `true` the caller can proceed. When `false`, `reason` + * describes why authentication failed so the caller can surface a + * targeted error message. + */ +export type LoginOrRefreshResult = + | { loggedIn: true } + | { loggedIn: false; reason: LoginOrRefreshFailureReason }; + /** * Options for an interactive OAuth login. */ @@ -86,11 +111,12 @@ export interface OAuthFlowAPI { * Scopes are required in case an interactive login is triggered — the * consumer's scope catalog lives outside this package. * - * @returns `true` when the user is logged in (or env credentials are - * present), `false` when interactive login was needed but skipped (e.g. - * non-interactive environment). + * @returns `{ loggedIn: true }` when the user is authenticated (or env + * credentials are present). When authentication fails, returns + * `{ loggedIn: false, reason }` describing why — see + * {@link LoginOrRefreshFailureReason}. */ - loginOrRefreshIfRequired(props: LoginProps): Promise; + loginOrRefreshIfRequired(props: LoginProps): Promise; /** * Read the OAuth access token from local state, refreshing it first if @@ -240,32 +266,51 @@ export function createOAuthFlow(ctx: OAuthFlowContext): OAuthFlowAPI { } } - async function loginOrRefreshIfRequired(props: LoginProps): Promise { + async function loginOrRefreshIfRequired( + props: LoginProps + ): Promise { // If env credentials are present, the consumer's credential resolver // will use those rather than the stored OAuth token, so we don't need // to refresh or log in. if (ctx.hasEnvCredentials()) { - return true; + return { loggedIn: true }; } // TODO: ask permission before opening browser const stored = readStoredAuthState({ warningLogger: ctx.logger }); if (!stored.accessToken && !stored.deprecatedApiToken) { // Not logged in. // If we are not interactive, we cannot ask the user to login - return !ctx.isNonInteractiveOrCI() && (await login(props)); + if (ctx.isNonInteractiveOrCI()) { + return { + loggedIn: false, + reason: "no-credentials-non-interactive", + }; + } + if (await login(props)) { + return { loggedIn: true }; + } + return { loggedIn: false, reason: "no-credentials-login-failed" }; } else if (isRefreshNeeded()) { // We're logged in, but the refresh token seems to have expired, // so let's try to refresh it const didRefresh = await refreshToken(); if (didRefresh) { // The token was refreshed, so we're done here - return true; - } else { - // If the refresh token isn't valid, then we ask the user to login again - return !ctx.isNonInteractiveOrCI() && (await login(props)); + return { loggedIn: true }; } + // If the refresh token isn't valid, then we ask the user to login again + if (ctx.isNonInteractiveOrCI()) { + return { + loggedIn: false, + reason: "token-expired-non-interactive", + }; + } + if (await login(props)) { + return { loggedIn: true }; + } + return { loggedIn: false, reason: "token-expired-login-failed" }; } else { - return true; + return { loggedIn: true }; } } diff --git a/packages/workers-auth/src/index.ts b/packages/workers-auth/src/index.ts index 3481ad4ea0..6c645d6259 100644 --- a/packages/workers-auth/src/index.ts +++ b/packages/workers-auth/src/index.ts @@ -56,7 +56,12 @@ export { } from "./errors"; export { createOAuthFlow, OAUTH_CALLBACK_URL } from "./flow"; -export type { LoginProps, OAuthFlowAPI } from "./flow"; +export type { + LoginOrRefreshFailureReason, + LoginOrRefreshResult, + LoginProps, + OAuthFlowAPI, +} from "./flow"; export { generateAuthUrl } from "./generate-auth-url"; diff --git a/packages/workers-auth/src/token-exchange.ts b/packages/workers-auth/src/token-exchange.ts index ac0ca6e8d3..99cd90fbe5 100644 --- a/packages/workers-auth/src/token-exchange.ts +++ b/packages/workers-auth/src/token-exchange.ts @@ -325,7 +325,10 @@ export async function fetchAuthToken( headers, }); if (!response.ok) { - logger.error( + // Log at debug level — callers handle non-OK responses and surface + // structured errors, so an error-level log here would be redundant + // noise that confuses users with multiple error messages. + logger.debug( "Failed to fetch auth token:", response.status, response.statusText @@ -333,7 +336,8 @@ export async function fetchAuthToken( } return response; } catch (e) { - logger.error("Failed to fetch auth token:", e); + // Log at debug level — the error is re-thrown for the caller to handle. + logger.debug("Failed to fetch auth token:", e); throw e; } } diff --git a/packages/wrangler/src/__tests__/dev.test.ts b/packages/wrangler/src/__tests__/dev.test.ts index 83bdf82eca..eb120da21a 100644 --- a/packages/wrangler/src/__tests__/dev.test.ts +++ b/packages/wrangler/src/__tests__/dev.test.ts @@ -224,7 +224,15 @@ describe.sequential("wrangler dev", () => { await expect( runWrangler("dev --remote index.js") ).rejects.toThrowErrorMatchingInlineSnapshot( - `[Error: You must be logged in to use wrangler dev in remote mode. Try logging in, or run wrangler dev --local.]` + ` + [Error: Could not start remote dev session. No credentials found, and the environment is non-interactive so browser login cannot be started. + Either: + - Set a CLOUDFLARE_API_TOKEN environment variable + - Run \`wrangler login\` in an interactive terminal first + - Or use \`wrangler dev --local\` to develop locally (remote resources like KV, D1, etc. will use local simulators instead). + + You can run \`wrangler whoami\` to check your current authentication status.] + ` ); }); }); diff --git a/packages/wrangler/src/__tests__/dev/remote-bindings-errors.test.ts b/packages/wrangler/src/__tests__/dev/remote-bindings-errors.test.ts index b20cc4c15e..919391c220 100644 --- a/packages/wrangler/src/__tests__/dev/remote-bindings-errors.test.ts +++ b/packages/wrangler/src/__tests__/dev/remote-bindings-errors.test.ts @@ -43,12 +43,10 @@ describe("errors during dev with remote bindings", () => { assert(thrownError); - expect(thrownError.message).toContain( - "Failed to start the remote proxy session" - ); - - // The issue here is that with the test setup there is more than one account available (but we're - // in non-interactive mode). Here we make sure that this information is presented in the thrown error + // UserErrors (like auth/account selection failures) are re-thrown + // directly without being wrapped in a generic "Failed to start the + // remote proxy session" envelope, so the user sees a single, + // actionable error message. expect(thrownError.message).toContain( "More than one account available but unable to select one in non-interactive mode." ); diff --git a/packages/wrangler/src/__tests__/user.test.ts b/packages/wrangler/src/__tests__/user.test.ts index 4bf07424e8..5a1b332b30 100644 --- a/packages/wrangler/src/__tests__/user.test.ts +++ b/packages/wrangler/src/__tests__/user.test.ts @@ -422,7 +422,10 @@ describe("User", () => { vi.mocked(ci).isCI = true; await expect( loginOrRefreshIfRequired(COMPLIANCE_REGION_CONFIG_UNKNOWN) - ).resolves.toEqual(false); + ).resolves.toEqual({ + loggedIn: false, + reason: "no-credentials-non-interactive", + }); }); it("should revert to non-interactive mode if isTTY throws an error", async ({ @@ -436,7 +439,10 @@ describe("User", () => { }); await expect( loginOrRefreshIfRequired(COMPLIANCE_REGION_CONFIG_UNKNOWN) - ).resolves.toEqual(false); + ).resolves.toEqual({ + loggedIn: false, + reason: "no-credentials-non-interactive", + }); }); describe("CLOUDFLARE_API_TOKEN priority over stored OAuth state", () => { @@ -488,7 +494,7 @@ describe("User", () => { await expect( loginOrRefreshIfRequired(COMPLIANCE_REGION_CONFIG_UNKNOWN) - ).resolves.toEqual(true); + ).resolves.toEqual({ loggedIn: true }); expect(oauthRefreshCalled).toBe(false); }); diff --git a/packages/wrangler/src/api/remoteBindings/start-remote-proxy-session.ts b/packages/wrangler/src/api/remoteBindings/start-remote-proxy-session.ts index 8d35fb71ce..c0444802e3 100644 --- a/packages/wrangler/src/api/remoteBindings/start-remote-proxy-session.ts +++ b/packages/wrangler/src/api/remoteBindings/start-remote-proxy-session.ts @@ -1,5 +1,6 @@ import events from "node:events"; import path from "node:path"; +import { UserError } from "@cloudflare/workers-utils"; import chalk from "chalk"; import { DeferredPromise } from "miniflare"; import remoteBindingsWorkerPath from "worker:remoteBindings/ProxyServerWorker"; @@ -116,6 +117,13 @@ export async function startRemoteProxySession( }, bindings: rawBindings, }).catch((startWorkerError) => { + // If the error is already a UserError (e.g. an auth failure from + // ConfigController), re-throw it directly so the top-level error + // handler can display the original, actionable message without + // wrapping it in a generic "Failed to start" envelope. + if (startWorkerError instanceof UserError) { + throw startWorkerError; + } let errorMessage = startWorkerError; if (startWorkerError instanceof Error) { if (startWorkerError.cause instanceof Error) { diff --git a/packages/wrangler/src/api/startDevWorker/ConfigController.ts b/packages/wrangler/src/api/startDevWorker/ConfigController.ts index bae479b536..fbca20ef2b 100644 --- a/packages/wrangler/src/api/startDevWorker/ConfigController.ts +++ b/packages/wrangler/src/api/startDevWorker/ConfigController.ts @@ -49,6 +49,7 @@ import type { Trigger, WranglerStartDevWorkerInput, } from "./types"; +import type { LoginOrRefreshFailureReason } from "@cloudflare/workers-auth"; import type { CfUnsafe, Config } from "@cloudflare/workers-utils"; import type { WorkerRegistry } from "miniflare"; @@ -80,12 +81,15 @@ async function resolveDevConfig( ): Promise { const auth = async () => { if (input.dev?.remote) { - const isLoggedIn = await loginOrRefreshIfRequired(config); - if (!isLoggedIn) { - throw new UserError( - "You must be logged in to use wrangler dev in remote mode. Try logging in, or run wrangler dev --local.", - { telemetryMessage: "api dev remote login required" } + const result = await loginOrRefreshIfRequired(config); + if (!result.loggedIn) { + const errorMessage = getLoginOrRefreshFailureErrorMessage( + input.dev.remote, + result.reason ); + throw new UserError(errorMessage, { + telemetryMessage: "api dev remote login required", + }); } } @@ -182,6 +186,55 @@ async function resolveDevConfig( } satisfies StartDevWorkerOptions["dev"]; } +/** + * Maps a {@link LoginOrRefreshFailureReason} to a user-facing error message + * with actionable remediation steps (e.g. re-running `wrangler login`, + * setting `CLOUDFLARE_API_TOKEN`, or falling back to local dev). + * + * @param remoteMode - The remote dev mode that was requested. When + * `"minimal"` (remote-bindings mode), the suggestion to fall back to + * `--local` dev is omitted because local dev is not a useful alternative. + * @param failureReason - The specific {@link LoginOrRefreshFailureReason} + * that describes why login or token refresh could not succeed. + * @returns A formatted error message string prefixed with a generic failure + * summary, followed by reason-specific guidance and a `wrangler whoami` tip. + */ +function getLoginOrRefreshFailureErrorMessage( + remoteMode: boolean | "minimal", + failureReason: LoginOrRefreshFailureReason +) { + const errorMessagePrefix = "Could not start remote dev session."; + const localFallback = + remoteMode === "minimal" + ? "" // Remote bindings mode — local dev is not a useful fallback + : "\n - Or use `wrangler dev --local` to develop locally (remote resources like KV, D1, etc. will use local simulators instead)."; + const whoamiTip = + "\n\nYou can run `wrangler whoami` to check your current authentication status."; + const errorMessageBodies = { + "no-credentials-non-interactive": + " No credentials found, and the environment is non-interactive so browser login cannot be started.\n" + + "Either:\n" + + " - Set a CLOUDFLARE_API_TOKEN environment variable\n" + + ` - Run \`wrangler login\` in an interactive terminal first${localFallback}${whoamiTip}`, + "no-credentials-login-failed": + " No credentials found and the login attempt was unsuccessful.\n" + + "Either:\n" + + ` - Run \`wrangler login\` to try again${localFallback}${whoamiTip}`, + "token-expired-non-interactive": + " Your auth token has expired and could not be refreshed, and the environment is non-interactive so browser login cannot be started.\n" + + "Either:\n" + + " - Run `wrangler login` in an interactive terminal\n" + + ` - Set a CLOUDFLARE_API_TOKEN environment variable${localFallback}${whoamiTip}`, + "token-expired-login-failed": + " Your auth token has expired and could not be refreshed, and the login attempt was unsuccessful.\n" + + "Either:\n" + + ` - Run \`wrangler login\` to try again${localFallback}${whoamiTip}`, + }; + const errorMessageBody = errorMessageBodies[failureReason]; + const errorMessage = errorMessagePrefix + errorMessageBody; + return errorMessage; +} + async function resolveBindings( config: Config, input: StartDevWorkerInput diff --git a/packages/wrangler/src/cfetch/internal.ts b/packages/wrangler/src/cfetch/internal.ts index 85a9c80531..39da9be7b0 100644 --- a/packages/wrangler/src/cfetch/internal.ts +++ b/packages/wrangler/src/cfetch/internal.ts @@ -157,12 +157,44 @@ export async function resolveCredentials( return apiToken ?? requireApiToken(); } +/** + * Maps authentication failure reasons to user-facing error message bodies. + * + * Each key corresponds to a specific failure scenario returned by + * {@link loginOrRefreshIfRequired}, and the value is the descriptive message + * included in the {@link UserError} thrown by {@link requireLoggedIn}. + */ +const requireLoggedInErrorMessageBodies = { + "no-credentials-non-interactive": `Could not authenticate because no credentials were found and the environment is non-interactive. Set a CLOUDFLARE_API_TOKEN environment variable or run \`wrangler login\` in an interactive terminal first.`, + "no-credentials-login-failed": `No credentials were found and the login attempt was unsuccessful. Run \`wrangler login\` to try again.`, + "token-expired-non-interactive": `Your auth token has expired and could not be refreshed, and the environment is non-interactive. Run \`wrangler login\` in an interactive terminal or set a CLOUDFLARE_API_TOKEN.`, + "token-expired-login-failed": `Your auth token has expired and could not be refreshed, and the login attempt was unsuccessful. Run \`wrangler login\` to try again.`, +} as const; + +/** + * Tip appended to authentication error messages, prompting the user to run + * `wrangler whoami` to inspect their current login state. + * + * Used by {@link requireLoggedIn} when constructing the {@link UserError} message. + */ +const requireLoggedInErrorWhoAmITip = + "\nRun `wrangler whoami` to check your current authentication status." as const; + +/** + * Ensures the user is logged in before making an API request. + * + * @param complianceConfig - Compliance region configuration + * @throws {UserError} If the user could not be authenticated, with a message + * describing the specific reason for failure. + */ export async function requireLoggedIn( complianceConfig: ComplianceConfig ): Promise { - const loggedIn = await loginOrRefreshIfRequired(complianceConfig); - if (!loggedIn) { - throw new UserError("Not logged in.", { + const result = await loginOrRefreshIfRequired(complianceConfig); + if (!result.loggedIn) { + const errorMessageBody = requireLoggedInErrorMessageBodies[result.reason]; + const errorMessage = `Not logged in. ${errorMessageBody}${requireLoggedInErrorWhoAmITip}`; + throw new UserError(errorMessage, { telemetryMessage: "cfetch auth login required", }); } diff --git a/packages/wrangler/src/user/user.ts b/packages/wrangler/src/user/user.ts index 6f1924c173..6a5445ba64 100644 --- a/packages/wrangler/src/user/user.ts +++ b/packages/wrangler/src/user/user.ts @@ -31,7 +31,10 @@ import { fetchAllAccounts } from "./fetch-accounts"; import { generateAuthUrl } from "./generate-auth-url"; import { generateRandomState } from "./generate-random-state"; import type { Account } from "./shared"; -import type { LoginProps } from "@cloudflare/workers-auth"; +import type { + LoginOrRefreshResult, + LoginProps, +} from "@cloudflare/workers-auth"; import type { ApiCredentials, ComplianceConfig, @@ -243,10 +246,19 @@ export async function logout(): Promise { return oauthFlow.logout(); } +/** + * Attempt to ensure the user is authenticated, refreshing or prompting for + * login as needed. + * + * @param complianceConfig - Compliance region configuration + * @param props - Optional overrides for scopes, browser behaviour, etc. + * @returns A {@link LoginOrRefreshResult} indicating success or the specific + * reason authentication could not be established. + */ export async function loginOrRefreshIfRequired( complianceConfig: ComplianceConfig, props?: WranglerLoginProps -): Promise { +): Promise { return oauthFlow.loginOrRefreshIfRequired( withDefaultScopes(complianceConfig, props) ); @@ -384,9 +396,12 @@ export async function requireAuth( account_id?: string; } ): Promise { - const loggedIn = await loginOrRefreshIfRequired(config); - if (!loggedIn) { - if (isNonInteractiveOrCI()) { + const result = await loginOrRefreshIfRequired(config); + if (!result.loggedIn) { + if ( + result.reason === "no-credentials-non-interactive" || + result.reason === "token-expired-non-interactive" + ) { throw new UserError( "In a non-interactive environment, it's necessary to set a CLOUDFLARE_API_TOKEN environment variable for wrangler to work. Please go to https://developers.cloudflare.com/fundamentals/api/get-started/create-token/ for instructions on how to create an api token, and assign its value to CLOUDFLARE_API_TOKEN.", { telemetryMessage: "user auth missing api token non interactive" }