From 416857cb5e7b132b995305ada0838b8aae19cc41 Mon Sep 17 00:00:00 2001
From: MatinGathani <70268627+matingathani@users.noreply.github.com>
Date: Thu, 21 May 2026 05:47:47 -0400
Subject: [PATCH 1/6] [pages-shared] fix: resolve relative link hrefs against
base href in early hint Link headers (#13779)
---
.changeset/fix-pages-link-header-base-href.md | 5 +
.../__tests__/asset-server/handler.test.ts | 165 ++++++++++++++++++
packages/pages-shared/asset-server/handler.ts | 28 ++-
3 files changed, 197 insertions(+), 1 deletion(-)
create mode 100644 .changeset/fix-pages-link-header-base-href.md
diff --git a/.changeset/fix-pages-link-header-base-href.md b/.changeset/fix-pages-link-header-base-href.md
new file mode 100644
index 0000000000..616087c8b8
--- /dev/null
+++ b/.changeset/fix-pages-link-header-base-href.md
@@ -0,0 +1,5 @@
+---
+"@cloudflare/pages-shared": patch
+---
+
+fix: resolve relative link hrefs against the document's `` when generating early hint Link headers
diff --git a/packages/pages-shared/__tests__/asset-server/handler.test.ts b/packages/pages-shared/__tests__/asset-server/handler.test.ts
index 893c3a682d..b06a71ca8a 100644
--- a/packages/pages-shared/__tests__/asset-server/handler.test.ts
+++ b/packages/pages-shared/__tests__/asset-server/handler.test.ts
@@ -632,6 +632,171 @@ describe("asset-server handler", () => {
expect(response2.headers.get("link")).toBeNull();
});
+ test("early hints should resolve relative link hrefs against base href", async ({
+ expect,
+ }) => {
+ const deploymentId = "deployment-" + Math.random();
+ const metadata = createMetadataObject({ deploymentId }) as Metadata;
+
+ const findAssetEntryForPath = async (path: string) => {
+ if (path === "/index.html") {
+ return "asset-key-index-with-base.html";
+ }
+ return null;
+ };
+ const fetchAsset = () =>
+ Promise.resolve(
+ Object.assign(
+ new Response(`
+
+
+
+
+
+
+ `),
+ { contentType: "text/html" }
+ )
+ );
+
+ const getResponse = async () =>
+ getTestResponse({
+ request: new Request("https://example.com/"),
+ metadata,
+ findAssetEntryForPath,
+ caches,
+ fetchAsset,
+ });
+
+ const { response, spies } = await getResponse();
+ expect(response.status).toBe(200);
+ await Promise.all(spies.waitUntil);
+
+ const earlyHintsCache = await caches.open(`eh:${deploymentId}`);
+ const earlyHintsRes = await earlyHintsCache.match(
+ "https://example.com/asset-key-index-with-base.html"
+ );
+ if (!earlyHintsRes) {
+ throw new Error(
+ "Did not match early hints cache on https://example.com/asset-key-index-with-base.html"
+ );
+ }
+
+ const linkHeader = earlyHintsRes.headers.get("Link");
+ // Relative href "module.js" resolved against base "/" → absolute URL
+ expect(linkHeader).toContain("");
+ expect(linkHeader).not.toContain(" {
+ const deploymentId = "deployment-" + Math.random();
+ const metadata = createMetadataObject({ deploymentId }) as Metadata;
+
+ const findAssetEntryForPath = async (path: string) => {
+ if (path === "/index.html") {
+ return "asset-key-url-semantics.html";
+ }
+ return null;
+ };
+ const fetchAsset = () =>
+ Promise.resolve(
+ Object.assign(
+ new Response(`
+
+
+
+
+
+
+
+ `),
+ { contentType: "text/html" }
+ )
+ );
+
+ const { response, spies } = await getTestResponse({
+ request: new Request("https://example.com/"),
+ metadata,
+ findAssetEntryForPath,
+ caches,
+ fetchAsset,
+ });
+ expect(response.status).toBe(200);
+ await Promise.all(spies.waitUntil);
+
+ const earlyHintsCache = await caches.open(`eh:${deploymentId}`);
+ const earlyHintsRes = await earlyHintsCache.match(
+ "https://example.com/asset-key-url-semantics.html"
+ );
+ if (!earlyHintsRes) {
+ throw new Error(
+ "Did not match early hints cache on https://example.com/asset-key-url-semantics.html"
+ );
+ }
+
+ const linkHeader = earlyHintsRes.headers.get("Link");
+ // "module.js" relative to "/subdir/" → "/subdir/module.js"
+ expect(linkHeader).toContain("");
+ // "../other.js" relative to "/subdir/" → "/other.js"
+ expect(linkHeader).toContain("");
+ });
+
+ test("early hints should only use the first element", async ({
+ expect,
+ }) => {
+ const deploymentId = "deployment-" + Math.random();
+ const metadata = createMetadataObject({ deploymentId }) as Metadata;
+
+ const findAssetEntryForPath = async (path: string) => {
+ if (path === "/index.html") {
+ return "asset-key-multi-base.html";
+ }
+ return null;
+ };
+ const fetchAsset = () =>
+ Promise.resolve(
+ Object.assign(
+ new Response(`
+
+
+
+
+
+
+
+ `),
+ { contentType: "text/html" }
+ )
+ );
+
+ const { response, spies } = await getTestResponse({
+ request: new Request("https://example.com/"),
+ metadata,
+ findAssetEntryForPath,
+ caches,
+ fetchAsset,
+ });
+ expect(response.status).toBe(200);
+ await Promise.all(spies.waitUntil);
+
+ const earlyHintsCache = await caches.open(`eh:${deploymentId}`);
+ const earlyHintsRes = await earlyHintsCache.match(
+ "https://example.com/asset-key-multi-base.html"
+ );
+ if (!earlyHintsRes) {
+ throw new Error(
+ "Did not match early hints cache on https://example.com/asset-key-multi-base.html"
+ );
+ }
+
+ const linkHeader = earlyHintsRes.headers.get("Link");
+ // Should use /first/, not /second/
+ expect(linkHeader).toContain("");
+ expect(linkHeader).not.toContain("/second/");
+ });
+
test.todo("early hints should temporarily cache failures to parse links", async () => {
// I couldn't figure out a way to make HTMLRewriter error out
});
diff --git a/packages/pages-shared/asset-server/handler.ts b/packages/pages-shared/asset-server/handler.ts
index 5d3a6d018f..05e141fa48 100644
--- a/packages/pages-shared/asset-server/handler.ts
+++ b/packages/pages-shared/asset-server/handler.ts
@@ -404,8 +404,21 @@ export async function generateHandler<
(async () => {
try {
const links: { href: string; rel: string; as?: string }[] = [];
+ let baseHref: string | undefined;
const transformedResponse = new HTMLRewriter()
+ .on("base[href]", {
+ element(element) {
+ // HTML spec: only the first defines the base URL
+ if (baseHref !== undefined) {
+ return;
+ }
+ const href = element.getAttribute("href");
+ if (href !== null) {
+ baseHref = href;
+ }
+ },
+ })
.on(
"link[rel~=preconnect],link[rel~=preload],link[rel~=modulepreload]",
{
@@ -435,7 +448,20 @@ export async function generateHandler<
await transformedResponse.text();
links.forEach(({ href, rel, as }) => {
- let link = `<${href}>; rel="${rel}"`;
+ let resolvedHref = href;
+ if (baseHref !== undefined) {
+ try {
+ // Resolve href against the base, then against the request URL,
+ // following WHATWG URL semantics for relative paths and `..` segments.
+ resolvedHref = new URL(
+ href,
+ new URL(baseHref, request.url)
+ ).href;
+ } catch {
+ // Unparseable href — leave it as-is
+ }
+ }
+ let link = `<${resolvedHref}>; rel="${rel}"`;
if (as) {
link += `; as=${as}`;
}
From 98035862e1e303ca1f380d8d2694ad3d4659e3be Mon Sep 17 00:00:00 2001
From: vaishnav <84540554+vaishnav-mk@users.noreply.github.com>
Date: Thu, 21 May 2026 15:43:41 +0530
Subject: [PATCH 2/6] [workflows] Add Workflow rollback support (#13983)
Co-authored-by: Olga Silva <78314353+pombosilva@users.noreply.github.com>
---
.changeset/saga-rollbacks.md | 11 +
packages/workflows-shared/src/context.ts | 228 +++++++++----
packages/workflows-shared/src/engine.ts | 47 ++-
packages/workflows-shared/src/instance.ts | 10 +
packages/workflows-shared/src/lib/rollback.ts | 209 ++++++++++++
.../workflows-shared/tests/engine.test.ts | 323 +++++++++++++++++-
6 files changed, 760 insertions(+), 68 deletions(-)
create mode 100644 .changeset/saga-rollbacks.md
create mode 100644 packages/workflows-shared/src/lib/rollback.ts
diff --git a/.changeset/saga-rollbacks.md b/.changeset/saga-rollbacks.md
new file mode 100644
index 0000000000..675accb05d
--- /dev/null
+++ b/.changeset/saga-rollbacks.md
@@ -0,0 +1,11 @@
+---
+"@cloudflare/workflows-shared": minor
+---
+
+Add rollback support for local Workflows development
+
+Workflow steps can now register a compensation callback with trailing rollback options: `step.do(name, fn, { rollback })` and `step.do(name, config, fn, { rollback, rollbackConfig })`. When the workflow fails, the local engine runs every registered rollback in reverse step-start order (LIFO), giving steps the opportunity to undo their side effects.
+
+Each rollback executes through an internal rollback-scoped `Context.do`, so it inherits the existing retry / timeout / attempt-tracking machinery. `rollbackConfig` lets users override the per-rollback config.
+
+Note: the public rollback option type lands with workerd's `workflows_step_rollback` compat flag. Until that ships, the trailing rollback options only flow through when called through the StepPromise wrapper from a worker that has the flag enabled.
diff --git a/packages/workflows-shared/src/context.ts b/packages/workflows-shared/src/context.ts
index d2b78cc2d9..db27a5f6b8 100644
--- a/packages/workflows-shared/src/context.ts
+++ b/packages/workflows-shared/src/context.ts
@@ -15,6 +15,11 @@ import {
WorkflowTimeoutError,
} from "./lib/errors";
import { calcRetryDuration } from "./lib/retries";
+import {
+ parseRollbackOptions,
+ registerRollbackFn,
+ ROLLBACK_CACHE_KEY_PREFIX,
+} from "./lib/rollback";
import {
cleanupPendingStreamOutput,
createReplayReadableStream,
@@ -33,6 +38,7 @@ import {
import { MODIFIER_KEYS } from "./modifier";
import type { Engine } from "./engine";
import type { InstanceMetadata } from "./instance";
+import type { RollbackFn, WorkflowStepRollbackOptions } from "./lib/rollback";
import type { StreamOutputMeta } from "./lib/streams";
import type {
WorkflowSleepDuration,
@@ -73,6 +79,7 @@ export type WorkflowStepContext = {
attempt: number;
config: ResolvedStepConfig;
};
+
const PAUSE_DATETIME = "PAUSE_DATETIME";
export class Context extends RpcTarget {
@@ -82,10 +89,17 @@ export class Context extends RpcTarget {
#counters: Map = new Map();
#lifetimeStepCounter: number = 0;
- constructor(engine: Engine, state: DurableObjectState) {
+ #rollbackStep: { cacheKey: string } | undefined;
+
+ constructor(
+ engine: Engine,
+ state: DurableObjectState,
+ rollbackStep?: { cacheKey: string }
+ ) {
super();
this.#engine = engine;
this.#state = state;
+ this.#rollbackStep = rollbackStep;
}
async #checkForPendingPause(): Promise {
@@ -123,40 +137,92 @@ export class Context extends RpcTarget {
return val;
}
+ #registerRollback(options: {
+ cacheKey: string;
+ rollbackFn: RollbackFn | undefined;
+ stepName: string;
+ output?: unknown;
+ rollbackConfig?: WorkflowStepConfig;
+ }): void {
+ const { cacheKey, rollbackFn, stepName, output, rollbackConfig } = options;
+ if (rollbackFn && this.#rollbackStep === undefined) {
+ registerRollbackFn(this.#engine.rollbackRegistry, {
+ cacheKey,
+ fn: rollbackFn,
+ stepName,
+ ...("output" in options && { output }),
+ ...(rollbackConfig !== undefined && { config: rollbackConfig }),
+ });
+ }
+ }
+
do(
name: string,
- callback: (ctx: WorkflowStepContext) => Promise
+ callback: (ctx: WorkflowStepContext) => Promise,
+ rollbackOptions?: WorkflowStepRollbackOptions
): Promise;
do(
name: string,
config: WorkflowStepConfig,
- callback: (ctx: WorkflowStepContext) => Promise
+ callback: (ctx: WorkflowStepContext) => Promise,
+ rollbackOptions?: WorkflowStepRollbackOptions
): Promise;
async do(
name: string,
- configOrCallback:
- | WorkflowStepConfig
- | ((ctx: WorkflowStepContext) => Promise),
- callback?: (ctx: WorkflowStepContext) => Promise
+ ...rest: unknown[]
): Promise {
- let closure: (ctx: WorkflowStepContext) => Promise, stepConfig;
- // If a user passes in a config, we'd like it to be the second arg so the callback is always last
- if (callback) {
- closure = callback;
- stepConfig = configOrCallback as WorkflowStepConfig;
- } else {
- closure = configOrCallback as (ctx: WorkflowStepContext) => Promise;
+ let closure: (ctx: WorkflowStepContext) => Promise;
+ let stepConfig: WorkflowStepConfig;
+ let rollbackOptions: WorkflowStepRollbackOptions | undefined;
+
+ const first = rest[0];
+ if (typeof first === "function") {
+ closure = first as (ctx: WorkflowStepContext) => Promise;
stepConfig = {};
+ rollbackOptions = parseRollbackOptions(name, rest[1]);
+ } else {
+ stepConfig = (first ?? {}) as WorkflowStepConfig;
+ closure = rest[1] as (ctx: WorkflowStepContext) => Promise;
+ if (typeof closure !== "function") {
+ const error = new WorkflowFatalError(
+ `Step "${name}" requires a callback function`
+ ) as Error & UserErrorField;
+ error.isUserError = true;
+ throw error;
+ }
+ rollbackOptions = parseRollbackOptions(name, rest[2]);
}
+ const { rollback: rollbackFn, rollbackConfig } = rollbackOptions ?? {};
+
+ const isRollback = this.#rollbackStep !== undefined;
+ const events = isRollback
+ ? {
+ start: InstanceEvent.ROLLBACK_STEP_START,
+ attemptStart: InstanceEvent.ROLLBACK_ATTEMPT_START,
+ attemptSuccess: InstanceEvent.ROLLBACK_ATTEMPT_SUCCESS,
+ attemptFailure: InstanceEvent.ROLLBACK_ATTEMPT_FAILURE,
+ success: InstanceEvent.ROLLBACK_STEP_SUCCESS,
+ failure: InstanceEvent.ROLLBACK_STEP_FAILURE,
+ }
+ : {
+ start: InstanceEvent.STEP_START,
+ attemptStart: InstanceEvent.ATTEMPT_START,
+ attemptSuccess: InstanceEvent.ATTEMPT_SUCCESS,
+ attemptFailure: InstanceEvent.ATTEMPT_FAILURE,
+ success: InstanceEvent.STEP_SUCCESS,
+ failure: InstanceEvent.STEP_FAILURE,
+ };
- this.#lifetimeStepCounter++;
+ if (!isRollback) {
+ this.#lifetimeStepCounter++;
- const stepLimit = this.#engine.stepLimit;
- if (this.#lifetimeStepCounter > stepLimit) {
- throw new WorkflowFatalError(
- `The limit of ${stepLimit} steps has been reached. This limit can be changed in your worker configuration.`
- );
+ const stepLimit = this.#engine.stepLimit;
+ if (this.#lifetimeStepCounter > stepLimit) {
+ throw new WorkflowFatalError(
+ `The limit of ${stepLimit} steps has been reached. This limit can be changed in your worker configuration.`
+ );
+ }
}
if (!isValidStepName(name)) {
@@ -188,15 +254,25 @@ export class Context extends RpcTarget {
},
};
- const hash = await computeHash(name);
- const count = this.#getCount("run-" + name);
- const cacheKey = `${hash}-${count}`;
+ let cacheKey: string;
+ let count: number;
+ let stepNameWithCounter: string;
+ const rollbackStep = this.#rollbackStep;
+ if (rollbackStep !== undefined) {
+ cacheKey = `${ROLLBACK_CACHE_KEY_PREFIX}${rollbackStep.cacheKey}`;
+ count = 1;
+ stepNameWithCounter = name;
+ } else {
+ const hash = await computeHash(name);
+ count = this.#getCount("run-" + name);
+ cacheKey = `${hash}-${count}`;
+ stepNameWithCounter = `${name}-${count}`;
+ }
const valueKey = `${cacheKey}-value`;
const streamMetaKey = getStreamOutputMetaKey(cacheKey);
const configKey = `${cacheKey}-config`;
const errorKey = `${cacheKey}-error`;
- const stepNameWithCounter = `${name}-${count}`;
const stepStateKey = `${cacheKey}-metadata`;
const retryDelayDisableKey = `${MODIFIER_KEYS.DISABLE_RETRY_DELAY}${valueKey}`;
@@ -224,11 +300,19 @@ export class Context extends RpcTarget {
);
}
- return createReplayReadableStream({
+ const result = createReplayReadableStream({
storage: this.#state.storage,
cacheKey,
meta: maybeStreamMeta,
}) as T;
+ this.#registerRollback({
+ cacheKey,
+ rollbackFn,
+ stepName: stepNameWithCounter,
+ output: result,
+ rollbackConfig,
+ });
+ return result;
} else if (maybeStreamMeta !== undefined && maybeStreamMeta !== null) {
// We're not in a complete state - means we crashed while persisting a stream on a previous invocation - need to cleanup
await cleanupPendingStreamOutput(this.#state.storage, cacheKey).catch(
@@ -239,7 +323,15 @@ export class Context extends RpcTarget {
const maybeResult = maybeMap.get(valueKey);
if (maybeResult) {
- return (maybeResult as { value: T }).value;
+ const result = (maybeResult as { value: T }).value;
+ this.#registerRollback({
+ cacheKey,
+ rollbackFn,
+ stepName: stepNameWithCounter,
+ output: result,
+ rollbackConfig,
+ });
+ return result;
}
const maybeError: (Error & UserErrorField) | undefined = maybeMap.get(
@@ -262,16 +354,16 @@ export class Context extends RpcTarget {
.readLogsFromStep(cacheKey)
.filter((val) =>
[
- InstanceEvent.ATTEMPT_SUCCESS,
- InstanceEvent.ATTEMPT_FAILURE,
- InstanceEvent.ATTEMPT_START,
+ events.attemptSuccess,
+ events.attemptFailure,
+ events.attemptStart,
].includes(val.event)
);
// this means that the the engine died while executing this step - we can mark the latest attempt as failed
if (
attemptLogs.length > 0 &&
- attemptLogs.at(-1)?.event === InstanceEvent.ATTEMPT_START
+ attemptLogs.at(-1)?.event === events.attemptStart
) {
// TODO: We should get this from SQL
const stepState = ((await this.#state.storage.get(
@@ -292,7 +384,7 @@ export class Context extends RpcTarget {
this.#engine.priorityQueue.remove(timeoutEntryPQ);
}
this.#engine.writeLog(
- InstanceEvent.ATTEMPT_FAILURE,
+ events.attemptFailure,
cacheKey,
stepNameWithCounter,
{
@@ -324,14 +416,9 @@ export class Context extends RpcTarget {
await this.#engine.timeoutHandler.acquire(this.#engine);
if (stepState.attemptedCount == 0) {
- this.#engine.writeLog(
- InstanceEvent.STEP_START,
- cacheKey,
- stepNameWithCounter,
- {
- config,
- }
- );
+ this.#engine.writeLog(events.start, cacheKey, stepNameWithCounter, {
+ config,
+ });
} else {
// in case the engine dies while retrying and wakes up before the retry period
const priorityQueueHash = `${cacheKey}-${stepState.attemptedCount}`;
@@ -408,7 +495,7 @@ export class Context extends RpcTarget {
};
this.#engine.writeLog(
- InstanceEvent.ATTEMPT_START,
+ events.attemptStart,
cacheKey,
stepNameWithCounter,
{
@@ -541,6 +628,9 @@ export class Context extends RpcTarget {
throw e;
}
+ // Fatal serialization/storage errors abort the DO immediately, so
+ // previously registered rollbacks do not run for these paths.
+ // This matches the existing terminal behavior for unrecoverable output.
// Stream-specific fatal errors
if (
e instanceof InvalidStepReadableStreamError ||
@@ -548,7 +638,7 @@ export class Context extends RpcTarget {
e instanceof UnsupportedStreamChunkError
) {
this.#engine.writeLog(
- InstanceEvent.ATTEMPT_FAILURE,
+ events.attemptFailure,
cacheKey,
stepNameWithCounter,
{
@@ -557,7 +647,7 @@ export class Context extends RpcTarget {
}
);
this.#engine.writeLog(
- InstanceEvent.STEP_FAILURE,
+ events.failure,
cacheKey,
stepNameWithCounter,
{}
@@ -580,7 +670,7 @@ export class Context extends RpcTarget {
if (e instanceof StreamOutputStorageLimitError) {
this.#engine.writeLog(
- InstanceEvent.ATTEMPT_FAILURE,
+ events.attemptFailure,
cacheKey,
stepNameWithCounter,
{
@@ -589,7 +679,7 @@ export class Context extends RpcTarget {
}
);
this.#engine.writeLog(
- InstanceEvent.STEP_FAILURE,
+ events.failure,
cacheKey,
stepNameWithCounter,
{}
@@ -613,7 +703,7 @@ export class Context extends RpcTarget {
// something that cannot be written to storage
if (e instanceof Error && e.name === "DataCloneError") {
this.#engine.writeLog(
- InstanceEvent.ATTEMPT_FAILURE,
+ events.attemptFailure,
cacheKey,
stepNameWithCounter,
{
@@ -624,7 +714,7 @@ export class Context extends RpcTarget {
}
);
this.#engine.writeLog(
- InstanceEvent.STEP_FAILURE,
+ events.failure,
cacheKey,
stepNameWithCounter,
{}
@@ -666,7 +756,7 @@ export class Context extends RpcTarget {
});
this.#engine.writeLog(
- InstanceEvent.ATTEMPT_SUCCESS,
+ events.attemptSuccess,
cacheKey,
stepNameWithCounter,
{
@@ -707,7 +797,7 @@ export class Context extends RpcTarget {
);
this.#engine.writeLog(
- InstanceEvent.ATTEMPT_FAILURE,
+ events.attemptFailure,
cacheKey,
stepNameWithCounter,
{
@@ -716,17 +806,23 @@ export class Context extends RpcTarget {
}
);
this.#engine.writeLog(
- InstanceEvent.STEP_FAILURE,
+ events.failure,
cacheKey,
stepNameWithCounter,
{}
);
+ this.#registerRollback({
+ cacheKey,
+ rollbackFn,
+ stepName: stepNameWithCounter,
+ rollbackConfig,
+ });
throw error;
}
this.#engine.writeLog(
- InstanceEvent.ATTEMPT_FAILURE,
+ events.attemptFailure,
cacheKey,
stepNameWithCounter,
{
@@ -812,29 +908,37 @@ export class Context extends RpcTarget {
// Best-effort cleanup
}
this.#engine.writeLog(
- InstanceEvent.STEP_FAILURE,
+ events.failure,
cacheKey,
stepNameWithCounter,
{}
);
+ this.#registerRollback({
+ cacheKey,
+ rollbackFn,
+ stepName: stepNameWithCounter,
+ rollbackConfig,
+ });
await this.#state.storage.put(errorKey, error);
throw error;
}
}
- this.#engine.writeLog(
- InstanceEvent.STEP_SUCCESS,
+ this.#engine.writeLog(events.success, cacheKey, stepNameWithCounter, {
+ // TODO (WOR-86): Add limits, figure out serialization
+ result: lastStreamMeta ? undefined : result,
+ ...(lastStreamMeta && {
+ streamOutput: { cacheKey, meta: lastStreamMeta },
+ }),
+ });
+ this.#registerRollback({
cacheKey,
- stepNameWithCounter,
- {
- // TODO (WOR-86): Add limits, figure out serialization
- result: lastStreamMeta ? undefined : result,
- ...(lastStreamMeta && {
- streamOutput: { cacheKey, meta: lastStreamMeta },
- }),
- }
- );
+ rollbackFn,
+ stepName: stepNameWithCounter,
+ output: result,
+ rollbackConfig,
+ });
await this.#engine.timeoutHandler.release(this.#engine);
return result;
};
diff --git a/packages/workflows-shared/src/engine.ts b/packages/workflows-shared/src/engine.ts
index afdc423b3d..67dc3b5a7e 100644
--- a/packages/workflows-shared/src/engine.ts
+++ b/packages/workflows-shared/src/engine.ts
@@ -29,6 +29,7 @@ import {
storeRestartFromStep,
wipeRestartState,
} from "./lib/restart";
+import { clearRollbackRegistry, executeRollbacks } from "./lib/rollback";
import {
createReplayReadableStream,
getInvalidStoredStreamOutputError,
@@ -40,6 +41,7 @@ import { MODIFIER_KEYS, WorkflowInstanceModifier } from "./modifier";
import type { RestartFromStep } from "./binding";
import type { Event } from "./context";
import type { InstanceMetadata, RawInstanceLog } from "./instance";
+import type { RollbackRegistryEntry } from "./lib/rollback";
import type { StreamOutputMeta } from "./lib/streams";
import type {
WorkflowEntrypoint,
@@ -106,6 +108,13 @@ export const DEFAULT_STEP_LIMIT = 10_000;
const PAUSE_DATETIME = "PAUSE_DATETIME";
+function isStepSuccessEvent(event: InstanceEvent): boolean {
+ return (
+ event === InstanceEvent.STEP_SUCCESS ||
+ event === InstanceEvent.ROLLBACK_STEP_SUCCESS
+ );
+}
+
export class Engine extends DurableObject {
logs: Array = [];
@@ -127,6 +136,9 @@ export class Engine extends DurableObject {
> = new Map();
eventMap: Map> = new Map();
+ // Not persisted: rollback fns are RPC stubs, dead across DO restarts.
+ rollbackRegistry: Map = new Map();
+
constructor(state: DurableObjectState, env: Env) {
super(state, env);
@@ -197,6 +209,21 @@ export class Engine extends DurableObject {
}
}
+ readStepStartGroupKeysDesc(): string[] {
+ const rows = [
+ ...this.ctx.storage.sql.exec<{ groupKey: string }>(
+ "SELECT groupKey FROM states WHERE event = ? AND groupKey IS NOT NULL ORDER BY id DESC",
+ InstanceEvent.STEP_START
+ ),
+ ];
+ return rows.map(({ groupKey }) => groupKey);
+ }
+
+ // Lives here for access to the protected DurableObject `ctx`.
+ createRollbackContext(rollbackStep?: { cacheKey: string }): Context {
+ return new Context(this, this.ctx, rollbackStep);
+ }
+
readLogsFromStep(_cacheKey: string): RawInstanceLog[] {
return [];
}
@@ -215,10 +242,7 @@ export class Engine extends DurableObject {
logs: logs.map((log) => {
const metadata = JSON.parse(log.metadata);
- if (
- log.event !== InstanceEvent.STEP_SUCCESS ||
- !metadata.streamOutput
- ) {
+ if (!isStepSuccessEvent(log.event) || !metadata.streamOutput) {
return { ...log, metadata, group: log.groupKey };
}
@@ -275,7 +299,7 @@ export class Engine extends DurableObject {
return rows.map((row) => {
const metadata = JSON.parse(row.metadata) as Record;
- if (row.event !== InstanceEvent.STEP_SUCCESS || !metadata.streamOutput) {
+ if (!isStepSuccessEvent(row.event) || !metadata.streamOutput) {
return {
id: row.id,
timestamp: String(row.timestamp).replace(" ", "T") + "Z",
@@ -992,6 +1016,7 @@ export class Engine extends DurableObject {
this.pauseController = new AbortController();
this.waiters = new Map();
this.eventMap = new Map();
+ clearRollbackRegistry(this.rollbackRegistry);
void this.init(accountId, workflow, version, instance, event);
}
@@ -1099,6 +1124,8 @@ export class Engine extends DurableObject {
await this.ctx.storage.transaction(async () => {
await this.setStatus(accountId, instance.id, InstanceStatus.Complete);
});
+ // Dispose dup'd stubs; otherwise they leak across DO lifetimes.
+ clearRollbackRegistry(this.rollbackRegistry);
this.isRunning = false;
} catch (err) {
if (isAbortError(err)) {
@@ -1106,6 +1133,16 @@ export class Engine extends DurableObject {
return;
}
+ // Run before the terminal status so events land before WORKFLOW_FAILURE.
+ try {
+ await executeRollbacks(
+ this,
+ err instanceof Error ? err : new Error(String(err))
+ );
+ } catch (rollbackErr) {
+ console.error("Rollback execution failed:", rollbackErr);
+ }
+
let error;
if (err instanceof Error) {
if (
diff --git a/packages/workflows-shared/src/instance.ts b/packages/workflows-shared/src/instance.ts
index a0994fc49b..d6e1739389 100644
--- a/packages/workflows-shared/src/instance.ts
+++ b/packages/workflows-shared/src/instance.ts
@@ -124,6 +124,16 @@ export const enum InstanceEvent {
WAIT_START = 14,
WAIT_COMPLETE = 15,
WAIT_TIMED_OUT = 16,
+
+ ROLLBACK_START = 17,
+ ROLLBACK_STEP_START = 18,
+ ROLLBACK_ATTEMPT_START = 19,
+ ROLLBACK_ATTEMPT_SUCCESS = 20,
+ ROLLBACK_ATTEMPT_FAILURE = 21,
+ ROLLBACK_STEP_SUCCESS = 22,
+ ROLLBACK_STEP_FAILURE = 23,
+ ROLLBACK_COMPLETE = 24,
+ ROLLBACK_FAILED = 25,
}
export const enum InstanceTrigger {
diff --git a/packages/workflows-shared/src/lib/rollback.ts b/packages/workflows-shared/src/lib/rollback.ts
new file mode 100644
index 0000000000..6aa3240c19
--- /dev/null
+++ b/packages/workflows-shared/src/lib/rollback.ts
@@ -0,0 +1,209 @@
+import { InstanceEvent } from "../instance";
+import { WorkflowFatalError } from "./errors";
+import { isValidStepConfig } from "./validators";
+import type { Engine } from "../engine";
+import type { WorkflowStepConfig } from "cloudflare:workers";
+
+type UserErrorField = {
+ isUserError?: boolean;
+};
+
+// `:` can't appear in user-step cacheKeys (sha1-hex + `-` only).
+export const ROLLBACK_CACHE_KEY_PREFIX = "rollback:";
+
+export type RollbackContext = {
+ error: Error;
+ output: unknown;
+ stepName: string;
+};
+
+// dup() to outlive the originating step.do call; Symbol.dispose locally
+// (calling `.dispose()` would RPC to a non-existent remote method).
+export type RollbackFn = ((ctx: RollbackContext) => Promise) & {
+ dup?: () => RollbackFn;
+ [Symbol.dispose]?: () => void;
+};
+
+export type WorkflowStepRollbackOptions = {
+ rollback: RollbackFn;
+ rollbackConfig?: WorkflowStepConfig;
+};
+
+export type RollbackRegistryEntry = {
+ fn: RollbackFn;
+ stepName: string;
+ output?: unknown;
+ config?: WorkflowStepConfig;
+};
+
+export type RollbackRegistration = RollbackRegistryEntry & {
+ cacheKey: string;
+};
+
+export function parseRollbackOptions(
+ stepName: string,
+ options: unknown
+): WorkflowStepRollbackOptions | undefined {
+ if (options === undefined) {
+ return undefined;
+ }
+
+ if (
+ typeof options !== "object" ||
+ options === null ||
+ Array.isArray(options)
+ ) {
+ const error = new WorkflowFatalError(
+ `Rollback options for "${stepName}" must be an object`
+ ) as Error & UserErrorField;
+ error.isUserError = true;
+ throw error;
+ }
+
+ const rollbackOptions = options as Partial;
+ if (typeof rollbackOptions.rollback !== "function") {
+ const error = new WorkflowFatalError(
+ `Rollback for "${stepName}" must be a function`
+ ) as Error & UserErrorField;
+ error.isUserError = true;
+ throw error;
+ }
+
+ if (
+ rollbackOptions.rollbackConfig !== undefined &&
+ !isValidStepConfig(rollbackOptions.rollbackConfig)
+ ) {
+ const error = new WorkflowFatalError(
+ `Rollback config for "${stepName}" is in a invalid format. See https://developers.cloudflare.com/workflows/build/sleeping-and-retrying/`
+ ) as Error & UserErrorField;
+ error.isUserError = true;
+ throw error;
+ }
+
+ return rollbackOptions as WorkflowStepRollbackOptions;
+}
+
+export function dupRollbackStub(fn: RollbackFn): RollbackFn {
+ return fn.dup ? fn.dup() : fn;
+}
+
+export function disposeRollbackStub(fn: RollbackFn): void {
+ try {
+ fn[Symbol.dispose]?.();
+ } catch (err) {
+ console.warn("Failed to dispose rollback stub", err);
+ }
+}
+
+export function registerRollbackFn(
+ registry: Map,
+ registration: RollbackRegistration
+): void {
+ const { cacheKey, fn, stepName, output, config } = registration;
+ const existing = registry.get(cacheKey);
+ if (existing) {
+ disposeRollbackStub(existing.fn);
+ }
+ registry.set(cacheKey, {
+ fn: dupRollbackStub(fn),
+ stepName,
+ ...("output" in registration && { output }),
+ ...(config !== undefined && { config }),
+ });
+}
+
+export function clearRollbackRegistry(
+ registry: Map
+): void {
+ for (const entry of registry.values()) {
+ disposeRollbackStub(entry.fn);
+ }
+ registry.clear();
+}
+
+function getRollbackRegistryEntriesInExecutionOrder(
+ engine: Engine
+): Array<[string, RollbackRegistryEntry]> {
+ const entries: Array<[string, RollbackRegistryEntry]> = [];
+ const seen = new Set();
+ const stepStartGroupKeysDesc = engine.readStepStartGroupKeysDesc();
+ const rollbackRegistryEntriesDesc = [
+ ...engine.rollbackRegistry.entries(),
+ ].reverse();
+
+ for (const cacheKey of stepStartGroupKeysDesc) {
+ const entry = engine.rollbackRegistry.get(cacheKey);
+ if (entry === undefined || seen.has(cacheKey)) {
+ continue;
+ }
+ entries.push([cacheKey, entry]);
+ seen.add(cacheKey);
+ }
+
+ for (const [cacheKey, entry] of rollbackRegistryEntriesDesc) {
+ if (!seen.has(cacheKey)) {
+ entries.push([cacheKey, entry]);
+ }
+ }
+
+ return entries;
+}
+
+// LIFO; halts on first failure. Goes through Context.do so each rollback
+// inherits retries/timeouts/attempt-logging.
+export async function executeRollbacks(
+ engine: Engine,
+ triggerError: Error
+): Promise<{ ranAny: boolean; allSucceeded: boolean }> {
+ if (engine.rollbackRegistry.size === 0) {
+ return { ranAny: false, allSucceeded: true };
+ }
+
+ const entries = getRollbackRegistryEntriesInExecutionOrder(engine);
+ engine.rollbackRegistry.clear();
+
+ engine.writeLog(InstanceEvent.ROLLBACK_START, null, null, {
+ triggerError: { name: triggerError.name, message: triggerError.message },
+ totalSteps: entries.length,
+ });
+
+ let allSucceeded = true;
+ let completed = 0;
+ let stoppedAt = entries.length;
+
+ for (const [i, [cacheKey, entry]] of entries.entries()) {
+ const ctx = engine.createRollbackContext({ cacheKey });
+ try {
+ await ctx.do(entry.stepName, entry.config ?? {}, async () => {
+ await entry.fn({
+ error: triggerError,
+ output: entry.output,
+ stepName: entry.stepName,
+ });
+ });
+ completed++;
+ } catch {
+ // Context.do already wrote ROLLBACK_STEP_FAILURE; halt the chain.
+ allSucceeded = false;
+ stoppedAt = i + 1;
+ break;
+ } finally {
+ disposeRollbackStub(entry.fn);
+ }
+ }
+
+ const remainingEntries = entries.slice(stoppedAt);
+ for (const [, entry] of remainingEntries) {
+ disposeRollbackStub(entry.fn);
+ }
+
+ engine.writeLog(
+ allSucceeded
+ ? InstanceEvent.ROLLBACK_COMPLETE
+ : InstanceEvent.ROLLBACK_FAILED,
+ null,
+ null,
+ { totalSteps: entries.length, completedSteps: completed }
+ );
+ return { ranAny: completed > 0, allSucceeded };
+}
diff --git a/packages/workflows-shared/tests/engine.test.ts b/packages/workflows-shared/tests/engine.test.ts
index 49bf627dd8..9c05e0c114 100644
--- a/packages/workflows-shared/tests/engine.test.ts
+++ b/packages/workflows-shared/tests/engine.test.ts
@@ -13,7 +13,12 @@ import type {
DatabaseWorkflow,
EngineLogs,
} from "../src/engine";
-import type { WorkflowStep } from "cloudflare:workers";
+import type {
+ RollbackContext,
+ RollbackFn,
+ WorkflowStepRollbackOptions,
+} from "../src/lib/rollback";
+import type { WorkflowStep, WorkflowStepConfig } from "cloudflare:workers";
afterEach(async () => {
await workerdUnsafe.abortAllDurableObjects();
@@ -1242,3 +1247,319 @@ describe("Engine", () => {
});
});
});
+
+// RPC-stubbed rollback fns can't reliably mutate test-scope closures —
+// assert via engine log events instead.
+describe("Rollback", () => {
+ async function readLogsAfter(
+ stub: { readLogs(): Promise | EngineLogs },
+ predicate: (logs: EngineLogs) => boolean,
+ timeout = 5000
+ ): Promise {
+ await vi.waitUntil(
+ async () => predicate((await stub.readLogs()) as EngineLogs),
+ { timeout }
+ );
+ return (await stub.readLogs()) as EngineLogs;
+ }
+
+ function targetsOf(
+ logs: EngineLogs,
+ event: InstanceEvent
+ ): (string | null)[] {
+ return logs.logs.filter((l) => l.event === event).map((l) => l.target);
+ }
+
+ function countOf(logs: EngineLogs, event: InstanceEvent): number {
+ return logs.logs.filter((l) => l.event === event).length;
+ }
+
+ function doWithRollback(
+ step: WorkflowStep,
+ name: string,
+ callback: (ctx: unknown) => Promise,
+ options: WorkflowStepRollbackOptions
+ ): Promise;
+ function doWithRollback(
+ step: WorkflowStep,
+ name: string,
+ config: WorkflowStepConfig,
+ callback: (ctx: unknown) => Promise,
+ options: WorkflowStepRollbackOptions
+ ): Promise;
+ function doWithRollback(
+ step: WorkflowStep,
+ name: string,
+ configOrCallback: WorkflowStepConfig | ((ctx: unknown) => Promise),
+ callbackOrOptions:
+ | ((ctx: unknown) => Promise)
+ | WorkflowStepRollbackOptions,
+ options?: WorkflowStepRollbackOptions
+ ): Promise {
+ if (typeof configOrCallback === "function") {
+ // @ts-expect-error -- rollback options are not in workers-types yet
+ return step.do(name, configOrCallback, callbackOrOptions);
+ }
+ // @ts-expect-error -- rollback options are not in workers-types yet
+ return step.do(name, configOrCallback, callbackOrOptions, options);
+ }
+
+ async function noopRollback(_ctx: RollbackContext): Promise {}
+
+ function rollbackOptions(
+ fn: RollbackFn = noopRollback
+ ): WorkflowStepRollbackOptions {
+ return { rollback: fn };
+ }
+
+ function rollbackOptionsWithConfig(
+ rollbackConfig: WorkflowStepConfig,
+ fn: RollbackFn = noopRollback
+ ): WorkflowStepRollbackOptions {
+ return { rollback: fn, rollbackConfig };
+ }
+
+ it("runs rollback fns in LIFO order on workflow failure", async ({
+ expect,
+ }) => {
+ const stub = await runWorkflowAndAwait("RB-LIFO", async (_e, step) => {
+ await doWithRollback(
+ step,
+ "step-1",
+ async () => "out-1",
+ rollbackOptions()
+ );
+ await doWithRollback(
+ step,
+ "step-2",
+ async () => "out-2",
+ rollbackOptions()
+ );
+ await doWithRollback(
+ step,
+ "step-3",
+ async () => "out-3",
+ rollbackOptions()
+ );
+ throw new Error("boom");
+ });
+ const logs = await readLogsAfter(stub, (l) =>
+ l.logs.some((r) => r.event === InstanceEvent.ROLLBACK_COMPLETE)
+ );
+ expect(targetsOf(logs, InstanceEvent.ROLLBACK_STEP_SUCCESS)).toEqual([
+ "step-3-1",
+ "step-2-1",
+ "step-1-1",
+ ]);
+ expect(targetsOf(logs, InstanceEvent.ROLLBACK_ATTEMPT_SUCCESS)).toEqual([
+ "step-3-1",
+ "step-2-1",
+ "step-1-1",
+ ]);
+ expect(countOf(logs, InstanceEvent.ROLLBACK_FAILED)).toBe(0);
+ });
+
+ it("runs parallel rollbacks in reverse step start order", async ({
+ expect,
+ }) => {
+ let firstStarted: () => void = () => {};
+ let firstMayFinish: () => void = () => {};
+ const firstStartedPromise = new Promise((resolve) => {
+ firstStarted = resolve;
+ });
+ const firstMayFinishPromise = new Promise((resolve) => {
+ firstMayFinish = resolve;
+ });
+
+ const stub = await runWorkflowAndAwait("RB-PARALLEL", async (_e, step) => {
+ const first = doWithRollback(
+ step,
+ "first",
+ async () => {
+ firstStarted();
+ await firstMayFinishPromise;
+ await scheduler.wait(50);
+ return "out-1";
+ },
+ rollbackOptions()
+ );
+ await firstStartedPromise;
+
+ const second = doWithRollback(
+ step,
+ "second",
+ async () => {
+ firstMayFinish();
+ return "out-2";
+ },
+ rollbackOptions()
+ );
+
+ await Promise.all([first, second]);
+ throw new Error("boom");
+ });
+ const logs = await readLogsAfter(stub, (l) =>
+ l.logs.some((r) => r.event === InstanceEvent.ROLLBACK_COMPLETE)
+ );
+ expect(targetsOf(logs, InstanceEvent.ROLLBACK_STEP_SUCCESS)).toEqual([
+ "second-1",
+ "first-1",
+ ]);
+ });
+
+ it("uses rollbackConfig when executing rollback", async ({ expect }) => {
+ const rollbackConfig = {
+ retries: { limit: 0, delay: 0, backoff: "constant" },
+ timeout: "30 seconds",
+ };
+ const stub = await runWorkflowAndAwait("RB-CONFIG", async (_e, step) => {
+ await doWithRollback(
+ step,
+ "configured-step",
+ async () => "out",
+ rollbackOptionsWithConfig(rollbackConfig)
+ );
+ throw new Error("boom");
+ });
+ const logs = await readLogsAfter(stub, (l) =>
+ l.logs.some((r) => r.event === InstanceEvent.ROLLBACK_COMPLETE)
+ );
+ expect(
+ logs.logs.find((l) => l.event === InstanceEvent.ROLLBACK_STEP_START)
+ ?.metadata
+ ).toMatchObject({ config: rollbackConfig });
+ });
+
+ it("passes rollback context to rollback fn", async ({ expect }) => {
+ const stub = await runWorkflowAndAwait("RB-CONTEXT", async (_e, step) => {
+ await doWithRollback(
+ step,
+ "ctx-step",
+ async () => "out",
+ rollbackOptions(async (ctx) => {
+ if (
+ ctx.error.name !== "Error" ||
+ ctx.error.message !== "boom" ||
+ ctx.output !== "out" ||
+ ctx.stepName !== "ctx-step-1"
+ ) {
+ throw new Error("unexpected rollback context");
+ }
+ })
+ );
+ throw new Error("boom");
+ });
+ const logs = await readLogsAfter(stub, (l) =>
+ l.logs.some((r) => r.event === InstanceEvent.ROLLBACK_COMPLETE)
+ );
+ expect(targetsOf(logs, InstanceEvent.ROLLBACK_STEP_SUCCESS)).toEqual([
+ "ctx-step-1",
+ ]);
+ });
+
+ it("runs rollback for failed step", async ({ expect }) => {
+ const stub = await runWorkflowAndAwait(
+ "RB-FAILED-STEP",
+ async (_e, step) => {
+ await doWithRollback(
+ step,
+ "failed-step",
+ { retries: { limit: 0, delay: 0, backoff: "constant" } },
+ async () => {
+ throw new Error("step-boom");
+ },
+ rollbackOptions(async (ctx) => {
+ if (
+ ctx.error.message !== "step-boom" ||
+ ctx.output !== undefined ||
+ ctx.stepName !== "failed-step-1"
+ ) {
+ throw new Error("unexpected failed-step rollback context");
+ }
+ })
+ );
+ }
+ );
+ const logs = await readLogsAfter(stub, (l) =>
+ l.logs.some((r) => r.event === InstanceEvent.ROLLBACK_COMPLETE)
+ );
+ const rollbackStepTargets = targetsOf(
+ logs,
+ InstanceEvent.ROLLBACK_STEP_SUCCESS
+ );
+ const stepFailureTargets = targetsOf(logs, InstanceEvent.STEP_FAILURE);
+
+ expect(rollbackStepTargets).toEqual(["failed-step-1"]);
+ expect(stepFailureTargets).toContain("failed-step-1");
+ });
+
+ it("only runs rollbacks for steps with a registered fn", async ({
+ expect,
+ }) => {
+ const stub = await runWorkflowAndAwait("RB-PARTIAL", async (_e, step) => {
+ await step.do("plain-step", async () => "v1");
+ await doWithRollback(
+ step,
+ "step-with-rollback",
+ async () => "v2",
+ rollbackOptions()
+ );
+ await step.do("plain-step-after", async () => "v3");
+ throw new Error("boom");
+ });
+ const logs = await readLogsAfter(stub, (l) =>
+ l.logs.some((r) => r.event === InstanceEvent.ROLLBACK_COMPLETE)
+ );
+ expect(targetsOf(logs, InstanceEvent.ROLLBACK_STEP_SUCCESS)).toEqual([
+ "step-with-rollback-1",
+ ]);
+ expect(
+ logs.logs.find((l) => l.event === InstanceEvent.ROLLBACK_START)?.metadata
+ ).toMatchObject({ totalSteps: 1 });
+ });
+
+ it("stops at the first failing rollback and logs ROLLBACK_FAILED", async ({
+ expect,
+ }) => {
+ const stub = await runWorkflowAndAwait("RB-FAILS", async (_e, step) => {
+ await doWithRollback(step, "step-1", async () => "v1", rollbackOptions());
+ await doWithRollback(
+ step,
+ "step-2",
+ async () => "v2",
+ rollbackOptionsWithConfig(
+ { retries: { limit: 0, delay: 0, backoff: "constant" } },
+ async () => {
+ throw new Error("rollback-boom");
+ }
+ )
+ );
+ await doWithRollback(step, "step-3", async () => "v3", rollbackOptions());
+ throw new Error("boom");
+ });
+ const logs = await readLogsAfter(stub, (l) =>
+ l.logs.some((r) => r.event === InstanceEvent.ROLLBACK_FAILED)
+ );
+ expect(targetsOf(logs, InstanceEvent.ROLLBACK_STEP_SUCCESS)).toEqual([
+ "step-3-1",
+ ]);
+ expect(targetsOf(logs, InstanceEvent.ROLLBACK_STEP_FAILURE)).toEqual([
+ "step-2-1",
+ ]);
+ expect(targetsOf(logs, InstanceEvent.ROLLBACK_ATTEMPT_FAILURE)).toEqual([
+ "step-2-1",
+ ]);
+ expect(countOf(logs, InstanceEvent.ROLLBACK_COMPLETE)).toBe(0);
+ });
+
+ it("does not run rollback when workflow succeeds", async ({ expect }) => {
+ const stub = await runWorkflowAndAwait("RB-NOOP", async (_e, step) => {
+ await doWithRollback(step, "a", async () => "ok", rollbackOptions());
+ return "done";
+ });
+ const logs = await readLogsAfter(stub, (l) =>
+ l.logs.some((r) => r.event === InstanceEvent.WORKFLOW_SUCCESS)
+ );
+ expect(countOf(logs, InstanceEvent.ROLLBACK_START)).toBe(0);
+ });
+});
From 1b4e8bcedfff05918a85327bb235349ad629df63 Mon Sep 17 00:00:00 2001
From: Matt Kane
Date: Thu, 21 May 2026 11:33:08 +0100
Subject: [PATCH 3/6] [ci] Remove stray debug echo from prerelease workflow
(#13994)
---
.github/workflows/prerelease.yml | 2 --
1 file changed, 2 deletions(-)
diff --git a/.github/workflows/prerelease.yml b/.github/workflows/prerelease.yml
index 0449d0cb33..ee364c9e5d 100644
--- a/.github/workflows/prerelease.yml
+++ b/.github/workflows/prerelease.yml
@@ -36,8 +36,6 @@ jobs:
with:
disable-cache: "true"
- - run: echo ${{ github.head_ref }}
-
- name: Build
run: pnpm build --filter="./packages/*"
env:
From 5ee65d572ce2718133de72c28705e2c9bda3d09b Mon Sep 17 00:00:00 2001
From: ANT Bot <116369605+workers-devprod@users.noreply.github.com>
Date: Thu, 21 May 2026 12:25:36 +0100
Subject: [PATCH 4/6] Version Packages (#13969)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
---
.changeset/bump-ws-8-20-1.md | 9 ---
.changeset/dependabot-update-13977.md | 12 ---
.changeset/dependabot-update-13984.md | 12 ---
.changeset/fair-cats-yell.md | 8 --
.changeset/fix-auth-error-hint-env-var.md | 7 --
.changeset/fix-cdn-cgi-host-validation.md | 11 ---
.changeset/fix-pages-link-header-base-href.md | 5 --
...n-unstable-get-miniflare-worker-options.md | 16 ----
.changeset/improve-asset-upload-retry-log.md | 7 --
...e-browser-run-binding-error-diagnostics.md | 9 ---
.changeset/lazy-auth-state-read.md | 13 ---
...are-recover-from-corrupted-chrome-cache.md | 11 ---
.../pipelines-r2-data-catalog-min-interval.md | 9 ---
.changeset/real-wolves-create.md | 7 --
.changeset/saga-rollbacks.md | 11 ---
.../secret-bulk-use-secrets-bulk-endpoint.md | 7 --
packages/containers-shared/CHANGELOG.md | 8 ++
packages/containers-shared/package.json | 2 +-
packages/miniflare/CHANGELOG.md | 44 ++++++++++
packages/miniflare/package.json | 2 +-
packages/pages-shared/CHANGELOG.md | 9 +++
packages/pages-shared/package.json | 2 +-
packages/vite-plugin-cloudflare/CHANGELOG.md | 29 +++++++
packages/vite-plugin-cloudflare/package.json | 2 +-
packages/vitest-pool-workers/CHANGELOG.md | 19 +++++
packages/vitest-pool-workers/package.json | 2 +-
packages/workflows-shared/CHANGELOG.md | 12 +++
packages/workflows-shared/package.json | 2 +-
packages/wrangler/CHANGELOG.md | 80 +++++++++++++++++++
packages/wrangler/package.json | 2 +-
30 files changed, 208 insertions(+), 161 deletions(-)
delete mode 100644 .changeset/bump-ws-8-20-1.md
delete mode 100644 .changeset/dependabot-update-13977.md
delete mode 100644 .changeset/dependabot-update-13984.md
delete mode 100644 .changeset/fair-cats-yell.md
delete mode 100644 .changeset/fix-auth-error-hint-env-var.md
delete mode 100644 .changeset/fix-cdn-cgi-host-validation.md
delete mode 100644 .changeset/fix-pages-link-header-base-href.md
delete mode 100644 .changeset/fix-zone-on-unstable-get-miniflare-worker-options.md
delete mode 100644 .changeset/improve-asset-upload-retry-log.md
delete mode 100644 .changeset/improve-browser-run-binding-error-diagnostics.md
delete mode 100644 .changeset/lazy-auth-state-read.md
delete mode 100644 .changeset/miniflare-recover-from-corrupted-chrome-cache.md
delete mode 100644 .changeset/pipelines-r2-data-catalog-min-interval.md
delete mode 100644 .changeset/real-wolves-create.md
delete mode 100644 .changeset/saga-rollbacks.md
delete mode 100644 .changeset/secret-bulk-use-secrets-bulk-endpoint.md
diff --git a/.changeset/bump-ws-8-20-1.md b/.changeset/bump-ws-8-20-1.md
deleted file mode 100644
index 80c3a4439d..0000000000
--- a/.changeset/bump-ws-8-20-1.md
+++ /dev/null
@@ -1,9 +0,0 @@
----
-"miniflare": patch
-"wrangler": patch
-"@cloudflare/vite-plugin": patch
----
-
-Bump `ws` from 8.18.0 to 8.20.1 to address GHSA-58qx-3vcg-4xpx
-
-[GHSA-58qx-3vcg-4xpx](https://github.com/advisories/GHSA-58qx-3vcg-4xpx) / [CVE-2026-45736](https://www.cve.org/CVERecord?id=CVE-2026-45736) reports an uninitialized-memory disclosure in `ws@<8.20.1` when a `TypedArray` is passed as the reason argument to `WebSocket.close()`. The fix shipped in [ws@8.20.1](https://github.com/websockets/ws/commit/c0327ec15a54d701eb6ccefaa8bef328cfc03086) on 2026-05-12. This change bumps the workspace catalog entry so that `miniflare`, `wrangler`, and `@cloudflare/vite-plugin` all pick up the patched release.
diff --git a/.changeset/dependabot-update-13977.md b/.changeset/dependabot-update-13977.md
deleted file mode 100644
index b1ea64607f..0000000000
--- a/.changeset/dependabot-update-13977.md
+++ /dev/null
@@ -1,12 +0,0 @@
----
-"miniflare": patch
-"wrangler": patch
----
-
-Update dependencies of "miniflare", "wrangler"
-
-The following dependency versions have been updated:
-
-| Dependency | From | To |
-| ---------- | ------------ | ------------ |
-| workerd | 1.20260518.1 | 1.20260519.1 |
diff --git a/.changeset/dependabot-update-13984.md b/.changeset/dependabot-update-13984.md
deleted file mode 100644
index 4597e7eebc..0000000000
--- a/.changeset/dependabot-update-13984.md
+++ /dev/null
@@ -1,12 +0,0 @@
----
-"miniflare": patch
-"wrangler": patch
----
-
-Update dependencies of "miniflare", "wrangler"
-
-The following dependency versions have been updated:
-
-| Dependency | From | To |
-| ---------- | ------------ | ------------ |
-| workerd | 1.20260519.1 | 1.20260520.1 |
diff --git a/.changeset/fair-cats-yell.md b/.changeset/fair-cats-yell.md
deleted file mode 100644
index d7a5bc83a2..0000000000
--- a/.changeset/fair-cats-yell.md
+++ /dev/null
@@ -1,8 +0,0 @@
----
-"@cloudflare/containers-shared": patch
-"wrangler": patch
----
-
-Preserve sibling container image tags during local dev cleanup
-
-Wrangler now keeps other `cloudflare-dev` image tags from the same dev session when multiple containers share a Dockerfile. Previously, duplicate-image cleanup could remove earlier container tags if Docker BuildKit produced the same image ID for each build.
diff --git a/.changeset/fix-auth-error-hint-env-var.md b/.changeset/fix-auth-error-hint-env-var.md
deleted file mode 100644
index 426d0b169c..0000000000
--- a/.changeset/fix-auth-error-hint-env-var.md
+++ /dev/null
@@ -1,7 +0,0 @@
----
-"wrangler": patch
----
-
-fix: show actionable hint when `/memberships` returns a bad-credentials error (code 9106)
-
-Previously, `wrangler` threw a raw Cloudflare API error ("Missing X-Auth-Key, X-Auth-Email or Authorization headers") with no guidance. Now it emits a `UserError` explaining that an environment variable such as `CLOUDFLARE_API_TOKEN`, `CLOUDFLARE_API_KEY`, or `CLOUDFLARE_EMAIL` may be set to an invalid value, and suggests running `wrangler logout` / `wrangler login` to re-authenticate.
diff --git a/.changeset/fix-cdn-cgi-host-validation.md b/.changeset/fix-cdn-cgi-host-validation.md
deleted file mode 100644
index 8dbd35029c..0000000000
--- a/.changeset/fix-cdn-cgi-host-validation.md
+++ /dev/null
@@ -1,11 +0,0 @@
----
-"miniflare": patch
-"wrangler": patch
-"@cloudflare/vite-plugin": patch
----
-
-Fix `/cdn-cgi/*` host validation incorrectly accepting subdomains of exact configured routes
-
-Miniflare's `/cdn-cgi/*` host/origin validator was treating exact configured routes the same as wildcard configured routes, so a request whose `Host` or `Origin` hostname was a subdomain of an exact route (e.g. `sub.my-custom-site.com` for a `my-custom-site.com/*` route) was incorrectly accepted. Exact configured routes and the configured `upstream` hostname are now required to match the request hostname exactly. Subdomain matching is only applied to wildcard routes such as `*.example.com/*`. Localhost hostnames continue to be allowed as before.
-
-This affects `wrangler dev` and local development through `@cloudflare/vite-plugin`, both of which use Miniflare under the hood.
diff --git a/.changeset/fix-pages-link-header-base-href.md b/.changeset/fix-pages-link-header-base-href.md
deleted file mode 100644
index 616087c8b8..0000000000
--- a/.changeset/fix-pages-link-header-base-href.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-"@cloudflare/pages-shared": patch
----
-
-fix: resolve relative link hrefs against the document's `` when generating early hint Link headers
diff --git a/.changeset/fix-zone-on-unstable-get-miniflare-worker-options.md b/.changeset/fix-zone-on-unstable-get-miniflare-worker-options.md
deleted file mode 100644
index aa7d3f25f5..0000000000
--- a/.changeset/fix-zone-on-unstable-get-miniflare-worker-options.md
+++ /dev/null
@@ -1,16 +0,0 @@
----
-"wrangler": patch
-"@cloudflare/vite-plugin": patch
-"@cloudflare/vitest-pool-workers": patch
----
-
-Fix the outbound `CF-Worker` header reflecting the route pattern hostname instead of the parent zone, and falling back to `.example.com` under `vite dev`, `vitest-pool-workers`, and `getPlatformProxy`
-
-Two related issues affected the `CF-Worker` header on outbound subrequests in local development:
-
-1. Under `@cloudflare/vite-plugin`, `@cloudflare/vitest-pool-workers`, and `getPlatformProxy`, the header fell back to `.example.com` even when `routes` were configured, because `unstable_getMiniflareWorkerOptions` and the equivalent `getPlatformProxy` worker-options path did not propagate a `zone` value to Miniflare. This broke local development against services that reject unknown `CF-Worker` hosts (for example, Apple WeatherKit returns `403 Forbidden`).
-2. Across the above paths and `wrangler dev --local`, when a route used the `zone_name` field (for example `{ pattern: "foo.example.com/*", zone_name: "example.com" }`), the header was set to the pattern's hostname (`foo.example.com`) rather than the zone name (`example.com`). Production [sets `CF-Worker` to the zone name that owns the Worker](https://developers.cloudflare.com/fundamentals/reference/http-headers/#cf-worker), so this was inconsistent with deployed behaviour.
-
-Both bugs are fixed: the new `unstable_getMiniflareWorkerOptions` / `getPlatformProxy` path now propagates a `zone` derived from the first configured route, and all four local-dev paths now prefer a route's explicit `zone_name` over the pattern hostname when computing that zone. When `zone_name` isn't set, the existing best-effort behaviour is preserved — for `wrangler dev` this means `dev.host` is still honoured as a local override and the pattern hostname is used as a final fallback. Resolving the parent zone for `zone_id`-only, `custom_domain`, or plain-string routes would require an API lookup, so locally we still approximate it with the pattern hostname.
-
-Note: `dev.host` is intentionally not consulted by the `unstable_getMiniflareWorkerOptions` / `getPlatformProxy` paths — the `dev` config block is specific to `wrangler dev`.
diff --git a/.changeset/improve-asset-upload-retry-log.md b/.changeset/improve-asset-upload-retry-log.md
deleted file mode 100644
index 3347e64632..0000000000
--- a/.changeset/improve-asset-upload-retry-log.md
+++ /dev/null
@@ -1,7 +0,0 @@
----
-"wrangler": patch
----
-
-Improve the log message shown when an asset upload attempt fails and is retried
-
-The retry message now reports which attempt is being made (e.g. `Asset upload failed. Retrying... 1 of 5 attempts.`), making it easier to gauge how close Wrangler is to exhausting its retry budget. The raw error object is no longer appended to this user-facing message; it is instead logged at debug level (visible via `WRANGLER_LOG=debug`).
diff --git a/.changeset/improve-browser-run-binding-error-diagnostics.md b/.changeset/improve-browser-run-binding-error-diagnostics.md
deleted file mode 100644
index 35a1902f6c..0000000000
--- a/.changeset/improve-browser-run-binding-error-diagnostics.md
+++ /dev/null
@@ -1,9 +0,0 @@
----
-"miniflare": patch
----
-
-Improve error diagnostics in the Browser Run binding worker
-
-When the local Browser Run binding failed to reach an upstream — for example when Chrome failed to launch and miniflare's loopback `/browser/launch` endpoint returned a 500 with a stack-trace text body — the binding worker would call `response.json()` on the non-JSON body and throw an opaque `SyntaxError: Unexpected token X, "..." is not valid JSON`. The actual upstream error message (e.g. `Chrome readiness probe at ... timed out after 5000ms`) was discarded.
-
-The binding worker now reads the response body as text first, surfaces the HTTP status and body content in the thrown error, and chains the original `SyntaxError` via `cause` when the body was a 2xx response that didn't parse as JSON. This makes both local-dev failures and CI test flakes self-diagnosing.
diff --git a/.changeset/lazy-auth-state-read.md b/.changeset/lazy-auth-state-read.md
deleted file mode 100644
index aeddc88d26..0000000000
--- a/.changeset/lazy-auth-state-read.md
+++ /dev/null
@@ -1,13 +0,0 @@
----
-"wrangler": patch
----
-
-Read the on-disk OAuth state lazily so `CLOUDFLARE_API_TOKEN` from `.env` takes priority correctly
-
-Wrangler previously read its OAuth state from the user auth config file (for example `~/.config/.wrangler/config/default.toml`) eagerly at module-import time. That happens _before_ `.env` files are loaded, so the in-memory state would always hold the OAuth tokens even when the user only wanted to authenticate via `CLOUDFLARE_API_TOKEN`. If that stored OAuth token happened to be expired, Wrangler would try to refresh it (and fail), aborting the command with `Failed to fetch auth token: 400 Bad Request` and `Not logged in.` — even though a valid API token was in scope.
-
-Wrangler now reads the auth config file on demand, after `.env` has been loaded. When `CLOUDFLARE_API_TOKEN` (or `CLOUDFLARE_API_KEY` + `CLOUDFLARE_EMAIL`) is present, the OAuth state on disk is no longer consulted, the OAuth refresh endpoint is no longer called, and the env-based token is used directly. Sibling-process refresh-token rotation is also handled naturally because every check reads the current file contents.
-
-Internally, the exported `reinitialiseAuthTokens()` function is removed — there is no module-level OAuth cache left to invalidate.
-
-Fixes [#13744](https://github.com/cloudflare/workers-sdk/issues/13744).
diff --git a/.changeset/miniflare-recover-from-corrupted-chrome-cache.md b/.changeset/miniflare-recover-from-corrupted-chrome-cache.md
deleted file mode 100644
index ce28aa874d..0000000000
--- a/.changeset/miniflare-recover-from-corrupted-chrome-cache.md
+++ /dev/null
@@ -1,11 +0,0 @@
----
-"miniflare": patch
----
-
-Recover from corrupted `@puppeteer/browsers` cache when launching a Browser Run session
-
-When Miniflare's local Browser Run binding launches Chrome, it calls `@puppeteer/browsers`' `install()` to ensure the binary is present. If a previous `install()` was interrupted mid-extraction (test timeout, process kill, antivirus quarantine), the cache directory can be left partially populated — the folder exists but the executable inside it is missing. `install()` then throws `The browser folder (...) exists but the executable (...) is missing` on every subsequent call within the same process and the entire test session, breaking every later Browser Run operation until the cache is manually cleared.
-
-`launchBrowser` now catches that specific error, removes the corrupted cache directory, and retries `install()` once. If the corruption persists after cleanup, the original error is rethrown with a clearer message.
-
-This complements [#13971](https://github.com/cloudflare/workers-sdk/pull/13971), which surfaced the original error from inside the binding worker. With that diagnostic in place and this self-healing layer, the previously-intermittent "browser folder exists but executable missing" failure mode should no longer fail an entire CI run.
diff --git a/.changeset/pipelines-r2-data-catalog-min-interval.md b/.changeset/pipelines-r2-data-catalog-min-interval.md
deleted file mode 100644
index 5ccf518c87..0000000000
--- a/.changeset/pipelines-r2-data-catalog-min-interval.md
+++ /dev/null
@@ -1,9 +0,0 @@
----
-"wrangler": patch
----
-
-Enforce minimum 60 second interval for R2 Data Catalog sinks
-
-R2 Data Catalog sinks now require a minimum `--roll-interval` of 60 seconds to prevent compaction issues in the R2 Data Catalog. This validation is applied when creating sinks via `wrangler pipelines sinks create` with type `r2-data-catalog`, and during the interactive `wrangler pipelines setup` flow.
-
-Regular R2 sinks are not affected and can still use intervals as low as 10 seconds.
diff --git a/.changeset/real-wolves-create.md b/.changeset/real-wolves-create.md
deleted file mode 100644
index c3f86fbf36..0000000000
--- a/.changeset/real-wolves-create.md
+++ /dev/null
@@ -1,7 +0,0 @@
----
-"wrangler": patch
----
-
-Recognize Artifacts repositories that are still being created
-
-Wrangler's Artifacts repo status type now accepts the `creating` lifecycle state alongside existing in-progress statuses.
diff --git a/.changeset/saga-rollbacks.md b/.changeset/saga-rollbacks.md
deleted file mode 100644
index 675accb05d..0000000000
--- a/.changeset/saga-rollbacks.md
+++ /dev/null
@@ -1,11 +0,0 @@
----
-"@cloudflare/workflows-shared": minor
----
-
-Add rollback support for local Workflows development
-
-Workflow steps can now register a compensation callback with trailing rollback options: `step.do(name, fn, { rollback })` and `step.do(name, config, fn, { rollback, rollbackConfig })`. When the workflow fails, the local engine runs every registered rollback in reverse step-start order (LIFO), giving steps the opportunity to undo their side effects.
-
-Each rollback executes through an internal rollback-scoped `Context.do`, so it inherits the existing retry / timeout / attempt-tracking machinery. `rollbackConfig` lets users override the per-rollback config.
-
-Note: the public rollback option type lands with workerd's `workflows_step_rollback` compat flag. Until that ships, the trailing rollback options only flow through when called through the StepPromise wrapper from a worker that has the flag enabled.
diff --git a/.changeset/secret-bulk-use-secrets-bulk-endpoint.md b/.changeset/secret-bulk-use-secrets-bulk-endpoint.md
deleted file mode 100644
index 5e151a1aa4..0000000000
--- a/.changeset/secret-bulk-use-secrets-bulk-endpoint.md
+++ /dev/null
@@ -1,7 +0,0 @@
----
-"wrangler": patch
----
-
-Use dedicated API endpoint for `wrangler secret bulk`
-
-`wrangler secret bulk` now uses a more efficient, dedicated API endpoint. This reduces the operation from 2 API calls to 1 and eliminates the risk of accidentally affecting non-secret bindings.
diff --git a/packages/containers-shared/CHANGELOG.md b/packages/containers-shared/CHANGELOG.md
index 1c6044551d..f86a437295 100644
--- a/packages/containers-shared/CHANGELOG.md
+++ b/packages/containers-shared/CHANGELOG.md
@@ -1,5 +1,13 @@
# @cloudflare/containers-shared
+## 0.15.1
+
+### Patch Changes
+
+- [#13963](https://github.com/cloudflare/workers-sdk/pull/13963) [`adc9221`](https://github.com/cloudflare/workers-sdk/commit/adc922174cb03133d632632d6ebcd1f05b176358) Thanks [@gabivlj](https://github.com/gabivlj)! - Preserve sibling container image tags during local dev cleanup
+
+ Wrangler now keeps other `cloudflare-dev` image tags from the same dev session when multiple containers share a Dockerfile. Previously, duplicate-image cleanup could remove earlier container tags if Docker BuildKit produced the same image ID for each build.
+
## 0.15.0
### Minor Changes
diff --git a/packages/containers-shared/package.json b/packages/containers-shared/package.json
index 5f2be7d004..1ba0c48897 100644
--- a/packages/containers-shared/package.json
+++ b/packages/containers-shared/package.json
@@ -1,6 +1,6 @@
{
"name": "@cloudflare/containers-shared",
- "version": "0.15.0",
+ "version": "0.15.1",
"private": true,
"description": "Package that contains shared container functionality for Cloudflare Workers SDK.",
"homepage": "https://github.com/cloudflare/workers-sdk/tree/main/packages/containers-shared#readme",
diff --git a/packages/miniflare/CHANGELOG.md b/packages/miniflare/CHANGELOG.md
index 8aaa17e308..5270d819e4 100644
--- a/packages/miniflare/CHANGELOG.md
+++ b/packages/miniflare/CHANGELOG.md
@@ -1,5 +1,49 @@
# miniflare
+## 4.20260520.0
+
+### Patch Changes
+
+- [#13978](https://github.com/cloudflare/workers-sdk/pull/13978) [`fa1f61f`](https://github.com/cloudflare/workers-sdk/commit/fa1f61f5c6f4b8e363eaabdc68baafa29635bacd) Thanks [@sassyconsultingllc](https://github.com/sassyconsultingllc)! - Bump `ws` from 8.18.0 to 8.20.1 to address GHSA-58qx-3vcg-4xpx
+
+ [GHSA-58qx-3vcg-4xpx](https://github.com/advisories/GHSA-58qx-3vcg-4xpx) / [CVE-2026-45736](https://www.cve.org/CVERecord?id=CVE-2026-45736) reports an uninitialized-memory disclosure in `ws@<8.20.1` when a `TypedArray` is passed as the reason argument to `WebSocket.close()`. The fix shipped in [ws@8.20.1](https://github.com/websockets/ws/commit/c0327ec15a54d701eb6ccefaa8bef328cfc03086) on 2026-05-12. This change bumps the workspace catalog entry so that `miniflare`, `wrangler`, and `@cloudflare/vite-plugin` all pick up the patched release.
+
+- [#13977](https://github.com/cloudflare/workers-sdk/pull/13977) [`2679e05`](https://github.com/cloudflare/workers-sdk/commit/2679e057d4e3bcc9b460b7fa03a900f62e43fc94) Thanks [@dependabot](https://github.com/apps/dependabot)! - Update dependencies of "miniflare", "wrangler"
+
+ The following dependency versions have been updated:
+
+ | Dependency | From | To |
+ | ---------- | ------------ | ------------ |
+ | workerd | 1.20260518.1 | 1.20260519.1 |
+
+- [#13984](https://github.com/cloudflare/workers-sdk/pull/13984) [`7e40d98`](https://github.com/cloudflare/workers-sdk/commit/7e40d98aacd79014fb88b08cc8487909a7c4d749) Thanks [@dependabot](https://github.com/apps/dependabot)! - Update dependencies of "miniflare", "wrangler"
+
+ The following dependency versions have been updated:
+
+ | Dependency | From | To |
+ | ---------- | ------------ | ------------ |
+ | workerd | 1.20260519.1 | 1.20260520.1 |
+
+- [#13912](https://github.com/cloudflare/workers-sdk/pull/13912) [`d803737`](https://github.com/cloudflare/workers-sdk/commit/d803737b74f7cb08c6a91c64a649a96307fe9dc6) Thanks [@petebacondarwin](https://github.com/petebacondarwin)! - Fix `/cdn-cgi/*` host validation incorrectly accepting subdomains of exact configured routes
+
+ Miniflare's `/cdn-cgi/*` host/origin validator was treating exact configured routes the same as wildcard configured routes, so a request whose `Host` or `Origin` hostname was a subdomain of an exact route (e.g. `sub.my-custom-site.com` for a `my-custom-site.com/*` route) was incorrectly accepted. Exact configured routes and the configured `upstream` hostname are now required to match the request hostname exactly. Subdomain matching is only applied to wildcard routes such as `*.example.com/*`. Localhost hostnames continue to be allowed as before.
+
+ This affects `wrangler dev` and local development through `@cloudflare/vite-plugin`, both of which use Miniflare under the hood.
+
+- [#13971](https://github.com/cloudflare/workers-sdk/pull/13971) [`59cd880`](https://github.com/cloudflare/workers-sdk/commit/59cd880c559023962cb2537734a7ed511b18b269) Thanks [@petebacondarwin](https://github.com/petebacondarwin)! - Improve error diagnostics in the Browser Run binding worker
+
+ When the local Browser Run binding failed to reach an upstream — for example when Chrome failed to launch and miniflare's loopback `/browser/launch` endpoint returned a 500 with a stack-trace text body — the binding worker would call `response.json()` on the non-JSON body and throw an opaque `SyntaxError: Unexpected token X, "..." is not valid JSON`. The actual upstream error message (e.g. `Chrome readiness probe at ... timed out after 5000ms`) was discarded.
+
+ The binding worker now reads the response body as text first, surfaces the HTTP status and body content in the thrown error, and chains the original `SyntaxError` via `cause` when the body was a 2xx response that didn't parse as JSON. This makes both local-dev failures and CI test flakes self-diagnosing.
+
+- [#13980](https://github.com/cloudflare/workers-sdk/pull/13980) [`e8c2031`](https://github.com/cloudflare/workers-sdk/commit/e8c2031b9ad7cec110e4310f95cf6cef72992029) Thanks [@petebacondarwin](https://github.com/petebacondarwin)! - Recover from corrupted `@puppeteer/browsers` cache when launching a Browser Run session
+
+ When Miniflare's local Browser Run binding launches Chrome, it calls `@puppeteer/browsers`' `install()` to ensure the binary is present. If a previous `install()` was interrupted mid-extraction (test timeout, process kill, antivirus quarantine), the cache directory can be left partially populated — the folder exists but the executable inside it is missing. `install()` then throws `The browser folder (...) exists but the executable (...) is missing` on every subsequent call within the same process and the entire test session, breaking every later Browser Run operation until the cache is manually cleared.
+
+ `launchBrowser` now catches that specific error, removes the corrupted cache directory, and retries `install()` once. If the corruption persists after cleanup, the original error is rethrown with a clearer message.
+
+ This complements [#13971](https://github.com/cloudflare/workers-sdk/pull/13971), which surfaced the original error from inside the binding worker. With that diagnostic in place and this self-healing layer, the previously-intermittent "browser folder exists but executable missing" failure mode should no longer fail an entire CI run.
+
## 4.20260518.0
### Minor Changes
diff --git a/packages/miniflare/package.json b/packages/miniflare/package.json
index 1304fb574c..c65bcb79b8 100644
--- a/packages/miniflare/package.json
+++ b/packages/miniflare/package.json
@@ -1,6 +1,6 @@
{
"name": "miniflare",
- "version": "4.20260518.0",
+ "version": "4.20260520.0",
"description": "Fun, full-featured, fully-local simulator for Cloudflare Workers",
"keywords": [
"cloudflare",
diff --git a/packages/pages-shared/CHANGELOG.md b/packages/pages-shared/CHANGELOG.md
index ade016a6f7..def1b8de20 100644
--- a/packages/pages-shared/CHANGELOG.md
+++ b/packages/pages-shared/CHANGELOG.md
@@ -1,5 +1,14 @@
# @cloudflare/pages-shared
+## 0.13.138
+
+### Patch Changes
+
+- [#13779](https://github.com/cloudflare/workers-sdk/pull/13779) [`416857c`](https://github.com/cloudflare/workers-sdk/commit/416857cb5e7b132b995305ada0838b8aae19cc41) Thanks [@matingathani](https://github.com/matingathani)! - fix: resolve relative link hrefs against the document's `` when generating early hint Link headers
+
+- Updated dependencies [[`fa1f61f`](https://github.com/cloudflare/workers-sdk/commit/fa1f61f5c6f4b8e363eaabdc68baafa29635bacd), [`2679e05`](https://github.com/cloudflare/workers-sdk/commit/2679e057d4e3bcc9b460b7fa03a900f62e43fc94), [`7e40d98`](https://github.com/cloudflare/workers-sdk/commit/7e40d98aacd79014fb88b08cc8487909a7c4d749), [`d803737`](https://github.com/cloudflare/workers-sdk/commit/d803737b74f7cb08c6a91c64a649a96307fe9dc6), [`59cd880`](https://github.com/cloudflare/workers-sdk/commit/59cd880c559023962cb2537734a7ed511b18b269), [`e8c2031`](https://github.com/cloudflare/workers-sdk/commit/e8c2031b9ad7cec110e4310f95cf6cef72992029)]:
+ - miniflare@4.20260520.0
+
## 0.13.137
### Patch Changes
diff --git a/packages/pages-shared/package.json b/packages/pages-shared/package.json
index 618b646046..acaa3759b9 100644
--- a/packages/pages-shared/package.json
+++ b/packages/pages-shared/package.json
@@ -1,6 +1,6 @@
{
"name": "@cloudflare/pages-shared",
- "version": "0.13.137",
+ "version": "0.13.138",
"repository": {
"type": "git",
"url": "https://github.com/cloudflare/workers-sdk.git",
diff --git a/packages/vite-plugin-cloudflare/CHANGELOG.md b/packages/vite-plugin-cloudflare/CHANGELOG.md
index c08a1ecb61..b661fe128d 100644
--- a/packages/vite-plugin-cloudflare/CHANGELOG.md
+++ b/packages/vite-plugin-cloudflare/CHANGELOG.md
@@ -1,5 +1,34 @@
# @cloudflare/vite-plugin
+## 1.37.3
+
+### Patch Changes
+
+- [#13978](https://github.com/cloudflare/workers-sdk/pull/13978) [`fa1f61f`](https://github.com/cloudflare/workers-sdk/commit/fa1f61f5c6f4b8e363eaabdc68baafa29635bacd) Thanks [@sassyconsultingllc](https://github.com/sassyconsultingllc)! - Bump `ws` from 8.18.0 to 8.20.1 to address GHSA-58qx-3vcg-4xpx
+
+ [GHSA-58qx-3vcg-4xpx](https://github.com/advisories/GHSA-58qx-3vcg-4xpx) / [CVE-2026-45736](https://www.cve.org/CVERecord?id=CVE-2026-45736) reports an uninitialized-memory disclosure in `ws@<8.20.1` when a `TypedArray` is passed as the reason argument to `WebSocket.close()`. The fix shipped in [ws@8.20.1](https://github.com/websockets/ws/commit/c0327ec15a54d701eb6ccefaa8bef328cfc03086) on 2026-05-12. This change bumps the workspace catalog entry so that `miniflare`, `wrangler`, and `@cloudflare/vite-plugin` all pick up the patched release.
+
+- [#13912](https://github.com/cloudflare/workers-sdk/pull/13912) [`d803737`](https://github.com/cloudflare/workers-sdk/commit/d803737b74f7cb08c6a91c64a649a96307fe9dc6) Thanks [@petebacondarwin](https://github.com/petebacondarwin)! - Fix `/cdn-cgi/*` host validation incorrectly accepting subdomains of exact configured routes
+
+ Miniflare's `/cdn-cgi/*` host/origin validator was treating exact configured routes the same as wildcard configured routes, so a request whose `Host` or `Origin` hostname was a subdomain of an exact route (e.g. `sub.my-custom-site.com` for a `my-custom-site.com/*` route) was incorrectly accepted. Exact configured routes and the configured `upstream` hostname are now required to match the request hostname exactly. Subdomain matching is only applied to wildcard routes such as `*.example.com/*`. Localhost hostnames continue to be allowed as before.
+
+ This affects `wrangler dev` and local development through `@cloudflare/vite-plugin`, both of which use Miniflare under the hood.
+
+- [#13919](https://github.com/cloudflare/workers-sdk/pull/13919) [`c7eab7f`](https://github.com/cloudflare/workers-sdk/commit/c7eab7f435771de716f2c59597506f6f2fcf69be) Thanks [@petebacondarwin](https://github.com/petebacondarwin)! - Fix the outbound `CF-Worker` header reflecting the route pattern hostname instead of the parent zone, and falling back to `.example.com` under `vite dev`, `vitest-pool-workers`, and `getPlatformProxy`
+
+ Two related issues affected the `CF-Worker` header on outbound subrequests in local development:
+
+ 1. Under `@cloudflare/vite-plugin`, `@cloudflare/vitest-pool-workers`, and `getPlatformProxy`, the header fell back to `.example.com` even when `routes` were configured, because `unstable_getMiniflareWorkerOptions` and the equivalent `getPlatformProxy` worker-options path did not propagate a `zone` value to Miniflare. This broke local development against services that reject unknown `CF-Worker` hosts (for example, Apple WeatherKit returns `403 Forbidden`).
+ 2. Across the above paths and `wrangler dev --local`, when a route used the `zone_name` field (for example `{ pattern: "foo.example.com/*", zone_name: "example.com" }`), the header was set to the pattern's hostname (`foo.example.com`) rather than the zone name (`example.com`). Production [sets `CF-Worker` to the zone name that owns the Worker](https://developers.cloudflare.com/fundamentals/reference/http-headers/#cf-worker), so this was inconsistent with deployed behaviour.
+
+ Both bugs are fixed: the new `unstable_getMiniflareWorkerOptions` / `getPlatformProxy` path now propagates a `zone` derived from the first configured route, and all four local-dev paths now prefer a route's explicit `zone_name` over the pattern hostname when computing that zone. When `zone_name` isn't set, the existing best-effort behaviour is preserved — for `wrangler dev` this means `dev.host` is still honoured as a local override and the pattern hostname is used as a final fallback. Resolving the parent zone for `zone_id`-only, `custom_domain`, or plain-string routes would require an API lookup, so locally we still approximate it with the pattern hostname.
+
+ Note: `dev.host` is intentionally not consulted by the `unstable_getMiniflareWorkerOptions` / `getPlatformProxy` paths — the `dev` config block is specific to `wrangler dev`.
+
+- Updated dependencies [[`fa1f61f`](https://github.com/cloudflare/workers-sdk/commit/fa1f61f5c6f4b8e363eaabdc68baafa29635bacd), [`2679e05`](https://github.com/cloudflare/workers-sdk/commit/2679e057d4e3bcc9b460b7fa03a900f62e43fc94), [`7e40d98`](https://github.com/cloudflare/workers-sdk/commit/7e40d98aacd79014fb88b08cc8487909a7c4d749), [`adc9221`](https://github.com/cloudflare/workers-sdk/commit/adc922174cb03133d632632d6ebcd1f05b176358), [`735852d`](https://github.com/cloudflare/workers-sdk/commit/735852dc7f8641a740dff01daf5943a5d477fbe1), [`d803737`](https://github.com/cloudflare/workers-sdk/commit/d803737b74f7cb08c6a91c64a649a96307fe9dc6), [`c7eab7f`](https://github.com/cloudflare/workers-sdk/commit/c7eab7f435771de716f2c59597506f6f2fcf69be), [`e04e180`](https://github.com/cloudflare/workers-sdk/commit/e04e180d4adfe7d50db835508940e7ef7e9d9706), [`59cd880`](https://github.com/cloudflare/workers-sdk/commit/59cd880c559023962cb2537734a7ed511b18b269), [`62abf97`](https://github.com/cloudflare/workers-sdk/commit/62abf970cc9da954853856156ba6fce9bef95678), [`e8c2031`](https://github.com/cloudflare/workers-sdk/commit/e8c2031b9ad7cec110e4310f95cf6cef72992029), [`e349fe0`](https://github.com/cloudflare/workers-sdk/commit/e349fe04851f421f3bd5d6cc288a12aeef0fd521), [`da0fa8c`](https://github.com/cloudflare/workers-sdk/commit/da0fa8c977727f90b6340d72cb7169f0064b7eae), [`a5c9365`](https://github.com/cloudflare/workers-sdk/commit/a5c936553d1b9d09582222ab8426febe7862b994)]:
+ - miniflare@4.20260520.0
+ - wrangler@4.93.1
+
## 1.37.2
### Patch Changes
diff --git a/packages/vite-plugin-cloudflare/package.json b/packages/vite-plugin-cloudflare/package.json
index ea823141fa..dc661c26db 100644
--- a/packages/vite-plugin-cloudflare/package.json
+++ b/packages/vite-plugin-cloudflare/package.json
@@ -1,6 +1,6 @@
{
"name": "@cloudflare/vite-plugin",
- "version": "1.37.2",
+ "version": "1.37.3",
"description": "Cloudflare plugin for Vite",
"keywords": [
"cloudflare",
diff --git a/packages/vitest-pool-workers/CHANGELOG.md b/packages/vitest-pool-workers/CHANGELOG.md
index 3cbcf7b585..71fccaa8f4 100644
--- a/packages/vitest-pool-workers/CHANGELOG.md
+++ b/packages/vitest-pool-workers/CHANGELOG.md
@@ -1,5 +1,24 @@
# @cloudflare/vitest-pool-workers
+## 0.16.8
+
+### Patch Changes
+
+- [#13919](https://github.com/cloudflare/workers-sdk/pull/13919) [`c7eab7f`](https://github.com/cloudflare/workers-sdk/commit/c7eab7f435771de716f2c59597506f6f2fcf69be) Thanks [@petebacondarwin](https://github.com/petebacondarwin)! - Fix the outbound `CF-Worker` header reflecting the route pattern hostname instead of the parent zone, and falling back to `.example.com` under `vite dev`, `vitest-pool-workers`, and `getPlatformProxy`
+
+ Two related issues affected the `CF-Worker` header on outbound subrequests in local development:
+
+ 1. Under `@cloudflare/vite-plugin`, `@cloudflare/vitest-pool-workers`, and `getPlatformProxy`, the header fell back to `.example.com` even when `routes` were configured, because `unstable_getMiniflareWorkerOptions` and the equivalent `getPlatformProxy` worker-options path did not propagate a `zone` value to Miniflare. This broke local development against services that reject unknown `CF-Worker` hosts (for example, Apple WeatherKit returns `403 Forbidden`).
+ 2. Across the above paths and `wrangler dev --local`, when a route used the `zone_name` field (for example `{ pattern: "foo.example.com/*", zone_name: "example.com" }`), the header was set to the pattern's hostname (`foo.example.com`) rather than the zone name (`example.com`). Production [sets `CF-Worker` to the zone name that owns the Worker](https://developers.cloudflare.com/fundamentals/reference/http-headers/#cf-worker), so this was inconsistent with deployed behaviour.
+
+ Both bugs are fixed: the new `unstable_getMiniflareWorkerOptions` / `getPlatformProxy` path now propagates a `zone` derived from the first configured route, and all four local-dev paths now prefer a route's explicit `zone_name` over the pattern hostname when computing that zone. When `zone_name` isn't set, the existing best-effort behaviour is preserved — for `wrangler dev` this means `dev.host` is still honoured as a local override and the pattern hostname is used as a final fallback. Resolving the parent zone for `zone_id`-only, `custom_domain`, or plain-string routes would require an API lookup, so locally we still approximate it with the pattern hostname.
+
+ Note: `dev.host` is intentionally not consulted by the `unstable_getMiniflareWorkerOptions` / `getPlatformProxy` paths — the `dev` config block is specific to `wrangler dev`.
+
+- Updated dependencies [[`fa1f61f`](https://github.com/cloudflare/workers-sdk/commit/fa1f61f5c6f4b8e363eaabdc68baafa29635bacd), [`2679e05`](https://github.com/cloudflare/workers-sdk/commit/2679e057d4e3bcc9b460b7fa03a900f62e43fc94), [`7e40d98`](https://github.com/cloudflare/workers-sdk/commit/7e40d98aacd79014fb88b08cc8487909a7c4d749), [`adc9221`](https://github.com/cloudflare/workers-sdk/commit/adc922174cb03133d632632d6ebcd1f05b176358), [`735852d`](https://github.com/cloudflare/workers-sdk/commit/735852dc7f8641a740dff01daf5943a5d477fbe1), [`d803737`](https://github.com/cloudflare/workers-sdk/commit/d803737b74f7cb08c6a91c64a649a96307fe9dc6), [`c7eab7f`](https://github.com/cloudflare/workers-sdk/commit/c7eab7f435771de716f2c59597506f6f2fcf69be), [`e04e180`](https://github.com/cloudflare/workers-sdk/commit/e04e180d4adfe7d50db835508940e7ef7e9d9706), [`59cd880`](https://github.com/cloudflare/workers-sdk/commit/59cd880c559023962cb2537734a7ed511b18b269), [`62abf97`](https://github.com/cloudflare/workers-sdk/commit/62abf970cc9da954853856156ba6fce9bef95678), [`e8c2031`](https://github.com/cloudflare/workers-sdk/commit/e8c2031b9ad7cec110e4310f95cf6cef72992029), [`e349fe0`](https://github.com/cloudflare/workers-sdk/commit/e349fe04851f421f3bd5d6cc288a12aeef0fd521), [`da0fa8c`](https://github.com/cloudflare/workers-sdk/commit/da0fa8c977727f90b6340d72cb7169f0064b7eae), [`a5c9365`](https://github.com/cloudflare/workers-sdk/commit/a5c936553d1b9d09582222ab8426febe7862b994)]:
+ - miniflare@4.20260520.0
+ - wrangler@4.93.1
+
## 0.16.7
### Patch Changes
diff --git a/packages/vitest-pool-workers/package.json b/packages/vitest-pool-workers/package.json
index 656610919a..47e24ce2d4 100644
--- a/packages/vitest-pool-workers/package.json
+++ b/packages/vitest-pool-workers/package.json
@@ -1,6 +1,6 @@
{
"name": "@cloudflare/vitest-pool-workers",
- "version": "0.16.7",
+ "version": "0.16.8",
"description": "Workers Vitest integration for writing Vitest unit and integration tests that run inside the Workers runtime",
"keywords": [
"cloudflare",
diff --git a/packages/workflows-shared/CHANGELOG.md b/packages/workflows-shared/CHANGELOG.md
index e97b533414..bd8e619961 100644
--- a/packages/workflows-shared/CHANGELOG.md
+++ b/packages/workflows-shared/CHANGELOG.md
@@ -1,5 +1,17 @@
# @cloudflare/workflows-shared
+## 0.11.0
+
+### Minor Changes
+
+- [#13983](https://github.com/cloudflare/workers-sdk/pull/13983) [`9803586`](https://github.com/cloudflare/workers-sdk/commit/98035862e1e303ca1f380d8d2694ad3d4659e3be) Thanks [@vaishnav-mk](https://github.com/vaishnav-mk)! - Add rollback support for local Workflows development
+
+ Workflow steps can now register a compensation callback with trailing rollback options: `step.do(name, fn, { rollback })` and `step.do(name, config, fn, { rollback, rollbackConfig })`. When the workflow fails, the local engine runs every registered rollback in reverse step-start order (LIFO), giving steps the opportunity to undo their side effects.
+
+ Each rollback executes through an internal rollback-scoped `Context.do`, so it inherits the existing retry / timeout / attempt-tracking machinery. `rollbackConfig` lets users override the per-rollback config.
+
+ Note: the public rollback option type lands with workerd's `workflows_step_rollback` compat flag. Until that ships, the trailing rollback options only flow through when called through the StepPromise wrapper from a worker that has the flag enabled.
+
## 0.10.0
### Minor Changes
diff --git a/packages/workflows-shared/package.json b/packages/workflows-shared/package.json
index 9261b03d94..0df7e3c87c 100644
--- a/packages/workflows-shared/package.json
+++ b/packages/workflows-shared/package.json
@@ -1,6 +1,6 @@
{
"name": "@cloudflare/workflows-shared",
- "version": "0.10.0",
+ "version": "0.11.0",
"private": true,
"description": "Package that is used at Cloudflare to power some internal features of Cloudflare Workflows.",
"keywords": [
diff --git a/packages/wrangler/CHANGELOG.md b/packages/wrangler/CHANGELOG.md
index a399d50765..76388b6ec4 100644
--- a/packages/wrangler/CHANGELOG.md
+++ b/packages/wrangler/CHANGELOG.md
@@ -1,5 +1,85 @@
# wrangler
+## 4.93.1
+
+### Patch Changes
+
+- [#13978](https://github.com/cloudflare/workers-sdk/pull/13978) [`fa1f61f`](https://github.com/cloudflare/workers-sdk/commit/fa1f61f5c6f4b8e363eaabdc68baafa29635bacd) Thanks [@sassyconsultingllc](https://github.com/sassyconsultingllc)! - Bump `ws` from 8.18.0 to 8.20.1 to address GHSA-58qx-3vcg-4xpx
+
+ [GHSA-58qx-3vcg-4xpx](https://github.com/advisories/GHSA-58qx-3vcg-4xpx) / [CVE-2026-45736](https://www.cve.org/CVERecord?id=CVE-2026-45736) reports an uninitialized-memory disclosure in `ws@<8.20.1` when a `TypedArray` is passed as the reason argument to `WebSocket.close()`. The fix shipped in [ws@8.20.1](https://github.com/websockets/ws/commit/c0327ec15a54d701eb6ccefaa8bef328cfc03086) on 2026-05-12. This change bumps the workspace catalog entry so that `miniflare`, `wrangler`, and `@cloudflare/vite-plugin` all pick up the patched release.
+
+- [#13977](https://github.com/cloudflare/workers-sdk/pull/13977) [`2679e05`](https://github.com/cloudflare/workers-sdk/commit/2679e057d4e3bcc9b460b7fa03a900f62e43fc94) Thanks [@dependabot](https://github.com/apps/dependabot)! - Update dependencies of "miniflare", "wrangler"
+
+ The following dependency versions have been updated:
+
+ | Dependency | From | To |
+ | ---------- | ------------ | ------------ |
+ | workerd | 1.20260518.1 | 1.20260519.1 |
+
+- [#13984](https://github.com/cloudflare/workers-sdk/pull/13984) [`7e40d98`](https://github.com/cloudflare/workers-sdk/commit/7e40d98aacd79014fb88b08cc8487909a7c4d749) Thanks [@dependabot](https://github.com/apps/dependabot)! - Update dependencies of "miniflare", "wrangler"
+
+ The following dependency versions have been updated:
+
+ | Dependency | From | To |
+ | ---------- | ------------ | ------------ |
+ | workerd | 1.20260519.1 | 1.20260520.1 |
+
+- [#13963](https://github.com/cloudflare/workers-sdk/pull/13963) [`adc9221`](https://github.com/cloudflare/workers-sdk/commit/adc922174cb03133d632632d6ebcd1f05b176358) Thanks [@gabivlj](https://github.com/gabivlj)! - Preserve sibling container image tags during local dev cleanup
+
+ Wrangler now keeps other `cloudflare-dev` image tags from the same dev session when multiple containers share a Dockerfile. Previously, duplicate-image cleanup could remove earlier container tags if Docker BuildKit produced the same image ID for each build.
+
+- [#13839](https://github.com/cloudflare/workers-sdk/pull/13839) [`735852d`](https://github.com/cloudflare/workers-sdk/commit/735852dc7f8641a740dff01daf5943a5d477fbe1) Thanks [@matingathani](https://github.com/matingathani)! - fix: show actionable hint when `/memberships` returns a bad-credentials error (code 9106)
+
+ Previously, `wrangler` threw a raw Cloudflare API error ("Missing X-Auth-Key, X-Auth-Email or Authorization headers") with no guidance. Now it emits a `UserError` explaining that an environment variable such as `CLOUDFLARE_API_TOKEN`, `CLOUDFLARE_API_KEY`, or `CLOUDFLARE_EMAIL` may be set to an invalid value, and suggests running `wrangler logout` / `wrangler login` to re-authenticate.
+
+- [#13912](https://github.com/cloudflare/workers-sdk/pull/13912) [`d803737`](https://github.com/cloudflare/workers-sdk/commit/d803737b74f7cb08c6a91c64a649a96307fe9dc6) Thanks [@petebacondarwin](https://github.com/petebacondarwin)! - Fix `/cdn-cgi/*` host validation incorrectly accepting subdomains of exact configured routes
+
+ Miniflare's `/cdn-cgi/*` host/origin validator was treating exact configured routes the same as wildcard configured routes, so a request whose `Host` or `Origin` hostname was a subdomain of an exact route (e.g. `sub.my-custom-site.com` for a `my-custom-site.com/*` route) was incorrectly accepted. Exact configured routes and the configured `upstream` hostname are now required to match the request hostname exactly. Subdomain matching is only applied to wildcard routes such as `*.example.com/*`. Localhost hostnames continue to be allowed as before.
+
+ This affects `wrangler dev` and local development through `@cloudflare/vite-plugin`, both of which use Miniflare under the hood.
+
+- [#13919](https://github.com/cloudflare/workers-sdk/pull/13919) [`c7eab7f`](https://github.com/cloudflare/workers-sdk/commit/c7eab7f435771de716f2c59597506f6f2fcf69be) Thanks [@petebacondarwin](https://github.com/petebacondarwin)! - Fix the outbound `CF-Worker` header reflecting the route pattern hostname instead of the parent zone, and falling back to `.example.com` under `vite dev`, `vitest-pool-workers`, and `getPlatformProxy`
+
+ Two related issues affected the `CF-Worker` header on outbound subrequests in local development:
+
+ 1. Under `@cloudflare/vite-plugin`, `@cloudflare/vitest-pool-workers`, and `getPlatformProxy`, the header fell back to `.example.com` even when `routes` were configured, because `unstable_getMiniflareWorkerOptions` and the equivalent `getPlatformProxy` worker-options path did not propagate a `zone` value to Miniflare. This broke local development against services that reject unknown `CF-Worker` hosts (for example, Apple WeatherKit returns `403 Forbidden`).
+ 2. Across the above paths and `wrangler dev --local`, when a route used the `zone_name` field (for example `{ pattern: "foo.example.com/*", zone_name: "example.com" }`), the header was set to the pattern's hostname (`foo.example.com`) rather than the zone name (`example.com`). Production [sets `CF-Worker` to the zone name that owns the Worker](https://developers.cloudflare.com/fundamentals/reference/http-headers/#cf-worker), so this was inconsistent with deployed behaviour.
+
+ Both bugs are fixed: the new `unstable_getMiniflareWorkerOptions` / `getPlatformProxy` path now propagates a `zone` derived from the first configured route, and all four local-dev paths now prefer a route's explicit `zone_name` over the pattern hostname when computing that zone. When `zone_name` isn't set, the existing best-effort behaviour is preserved — for `wrangler dev` this means `dev.host` is still honoured as a local override and the pattern hostname is used as a final fallback. Resolving the parent zone for `zone_id`-only, `custom_domain`, or plain-string routes would require an API lookup, so locally we still approximate it with the pattern hostname.
+
+ Note: `dev.host` is intentionally not consulted by the `unstable_getMiniflareWorkerOptions` / `getPlatformProxy` paths — the `dev` config block is specific to `wrangler dev`.
+
+- [#13990](https://github.com/cloudflare/workers-sdk/pull/13990) [`e04e180`](https://github.com/cloudflare/workers-sdk/commit/e04e180d4adfe7d50db835508940e7ef7e9d9706) Thanks [@petebacondarwin](https://github.com/petebacondarwin)! - Improve the log message shown when an asset upload attempt fails and is retried
+
+ The retry message now reports which attempt is being made (e.g. `Asset upload failed. Retrying... 1 of 5 attempts.`), making it easier to gauge how close Wrangler is to exhausting its retry budget. The raw error object is no longer appended to this user-facing message; it is instead logged at debug level (visible via `WRANGLER_LOG=debug`).
+
+- [#13954](https://github.com/cloudflare/workers-sdk/pull/13954) [`62abf97`](https://github.com/cloudflare/workers-sdk/commit/62abf970cc9da954853856156ba6fce9bef95678) Thanks [@petebacondarwin](https://github.com/petebacondarwin)! - Read the on-disk OAuth state lazily so `CLOUDFLARE_API_TOKEN` from `.env` takes priority correctly
+
+ Wrangler previously read its OAuth state from the user auth config file (for example `~/.config/.wrangler/config/default.toml`) eagerly at module-import time. That happens _before_ `.env` files are loaded, so the in-memory state would always hold the OAuth tokens even when the user only wanted to authenticate via `CLOUDFLARE_API_TOKEN`. If that stored OAuth token happened to be expired, Wrangler would try to refresh it (and fail), aborting the command with `Failed to fetch auth token: 400 Bad Request` and `Not logged in.` — even though a valid API token was in scope.
+
+ Wrangler now reads the auth config file on demand, after `.env` has been loaded. When `CLOUDFLARE_API_TOKEN` (or `CLOUDFLARE_API_KEY` + `CLOUDFLARE_EMAIL`) is present, the OAuth state on disk is no longer consulted, the OAuth refresh endpoint is no longer called, and the env-based token is used directly. Sibling-process refresh-token rotation is also handled naturally because every check reads the current file contents.
+
+ Internally, the exported `reinitialiseAuthTokens()` function is removed — there is no module-level OAuth cache left to invalidate.
+
+ Fixes [#13744](https://github.com/cloudflare/workers-sdk/issues/13744).
+
+- [#13951](https://github.com/cloudflare/workers-sdk/pull/13951) [`e349fe0`](https://github.com/cloudflare/workers-sdk/commit/e349fe04851f421f3bd5d6cc288a12aeef0fd521) Thanks [@sejoker](https://github.com/sejoker)! - Enforce minimum 60 second interval for R2 Data Catalog sinks
+
+ R2 Data Catalog sinks now require a minimum `--roll-interval` of 60 seconds to prevent compaction issues in the R2 Data Catalog. This validation is applied when creating sinks via `wrangler pipelines sinks create` with type `r2-data-catalog`, and during the interactive `wrangler pipelines setup` flow.
+
+ Regular R2 sinks are not affected and can still use intervals as low as 10 seconds.
+
+- [#13959](https://github.com/cloudflare/workers-sdk/pull/13959) [`da0fa8c`](https://github.com/cloudflare/workers-sdk/commit/da0fa8c977727f90b6340d72cb7169f0064b7eae) Thanks [@dmmulroy](https://github.com/dmmulroy)! - Recognize Artifacts repositories that are still being created
+
+ Wrangler's Artifacts repo status type now accepts the `creating` lifecycle state alongside existing in-progress statuses.
+
+- [#13964](https://github.com/cloudflare/workers-sdk/pull/13964) [`a5c9365`](https://github.com/cloudflare/workers-sdk/commit/a5c936553d1b9d09582222ab8426febe7862b994) Thanks [@danielrs](https://github.com/danielrs)! - Use dedicated API endpoint for `wrangler secret bulk`
+
+ `wrangler secret bulk` now uses a more efficient, dedicated API endpoint. This reduces the operation from 2 API calls to 1 and eliminates the risk of accidentally affecting non-secret bindings.
+
+- Updated dependencies [[`fa1f61f`](https://github.com/cloudflare/workers-sdk/commit/fa1f61f5c6f4b8e363eaabdc68baafa29635bacd), [`2679e05`](https://github.com/cloudflare/workers-sdk/commit/2679e057d4e3bcc9b460b7fa03a900f62e43fc94), [`7e40d98`](https://github.com/cloudflare/workers-sdk/commit/7e40d98aacd79014fb88b08cc8487909a7c4d749), [`d803737`](https://github.com/cloudflare/workers-sdk/commit/d803737b74f7cb08c6a91c64a649a96307fe9dc6), [`59cd880`](https://github.com/cloudflare/workers-sdk/commit/59cd880c559023962cb2537734a7ed511b18b269), [`e8c2031`](https://github.com/cloudflare/workers-sdk/commit/e8c2031b9ad7cec110e4310f95cf6cef72992029)]:
+ - miniflare@4.20260520.0
+
## 4.93.0
### Minor Changes
diff --git a/packages/wrangler/package.json b/packages/wrangler/package.json
index 7ef8e314fb..cd722c66e6 100644
--- a/packages/wrangler/package.json
+++ b/packages/wrangler/package.json
@@ -1,6 +1,6 @@
{
"name": "wrangler",
- "version": "4.93.0",
+ "version": "4.93.1",
"description": "Command-line interface for all things Cloudflare Workers",
"keywords": [
"assembly",
From 90092c0bca526e2e08a25fe7969534426eb6fd9f Mon Sep 17 00:00:00 2001
From: Pete Bacon Darwin
Date: Thu, 21 May 2026 14:07:20 +0100
Subject: [PATCH 5/6] [vitest-pool-workers] Stop externalizing devDependencies
from the published bundle (#13933)
---
.../fix-vitest-pool-workers-external-list.md | 11 +
.../fix-workers-utils-sideeffects-undici.md | 11 +
packages/create-cloudflare/scripts/deps.ts | 19 +
packages/vitest-pool-workers/tsdown.config.ts | 42 +-
packages/workers-utils/package.json | 13 +-
packages/workers-utils/scripts/deps.ts | 15 +
packages/workers-utils/tsup.config.ts | 2 +-
packages/wrangler/scripts/deps.ts | 25 ++
pnpm-lock.yaml | 7 +-
.../validate-package-dependencies.test.ts | 371 ++++++++++++++++++
.../validate-package-dependencies.ts | 357 ++++++++++++++++-
11 files changed, 842 insertions(+), 31 deletions(-)
create mode 100644 .changeset/fix-vitest-pool-workers-external-list.md
create mode 100644 .changeset/fix-workers-utils-sideeffects-undici.md
create mode 100644 packages/create-cloudflare/scripts/deps.ts
create mode 100644 packages/workers-utils/scripts/deps.ts
diff --git a/.changeset/fix-vitest-pool-workers-external-list.md b/.changeset/fix-vitest-pool-workers-external-list.md
new file mode 100644
index 0000000000..adf382ffb0
--- /dev/null
+++ b/.changeset/fix-vitest-pool-workers-external-list.md
@@ -0,0 +1,11 @@
+---
+"@cloudflare/vitest-pool-workers": patch
+---
+
+Derive bundler externals from `package.json` and shrink the published bundle
+
+The bundler's `external` list was previously hand-maintained and out of sync with `package.json` — `undici` and `semver` were both listed as external despite being only `devDependencies`. The published `dist/pool/index.mjs` consequently contained a top-level `import { fetch } from "undici"` that was only resolvable because pnpm happened to hoist `undici` from other packages' devDependencies during local development.
+
+The bundler now derives its `external` list from `dependencies` + `peerDependencies` in `package.json`, making it impossible for a `devDependency` to silently end up externalized.
+
+Combined with the new `"sideEffects": false` declaration in `@cloudflare/workers-utils`, the unused `cloudflared` / `tunnel` exports (and their transitive `undici` import) are now tree-shaken out of the pool entirely. `dist/pool/index.mjs` no longer references `undici` at all, and shrinks from ~489 KB to ~125 KB.
diff --git a/.changeset/fix-workers-utils-sideeffects-undici.md b/.changeset/fix-workers-utils-sideeffects-undici.md
new file mode 100644
index 0000000000..3722024446
--- /dev/null
+++ b/.changeset/fix-workers-utils-sideeffects-undici.md
@@ -0,0 +1,11 @@
+---
+"@cloudflare/workers-utils": patch
+---
+
+Mark `@cloudflare/workers-utils` as side-effect-free and properly declare `undici` as a runtime dependency
+
+The package now declares `"sideEffects": false` in its `package.json` so that downstream bundlers can tree-shake unused exports. In particular, consumers that only use a subset of the package (for example, `getTodaysCompatDate` from the main entry) will no longer carry the `cloudflared` / `tunnel` exports — or their transitive dependencies — in their final bundle.
+
+`undici` has been moved from `devDependencies` to `dependencies`. Previously it was incorrectly listed as a devDependency while the bundler config marked it as external, leaving the published `dist/index.mjs` with an unresolved `import { fetch } from "undici"` for anyone installing the package directly. `undici` is deliberately kept external (rather than bundled) so that downstream consumers don't end up with two copies of `undici` in their bundle — which would break `instanceof Request`/`Response`/`Headers` checks across the boundary and prevent `setGlobalDispatcher` / proxy configuration from applying to the bundled copy.
+
+`vitest` has been added as an optional `peerDependency` because the `./test-helpers` sub-export uses `vitest`'s `vi`, `beforeEach`, and `afterEach` APIs at runtime; consumers that import from `./test-helpers` must have `vitest` installed themselves.
diff --git a/packages/create-cloudflare/scripts/deps.ts b/packages/create-cloudflare/scripts/deps.ts
new file mode 100644
index 0000000000..18cb8bdea9
--- /dev/null
+++ b/packages/create-cloudflare/scripts/deps.ts
@@ -0,0 +1,19 @@
+/**
+ * Dependencies that _are not_ bundled along with create-cloudflare.
+ *
+ * create-cloudflare bundles all of its dependencies into a single CJS file,
+ * so this list is currently empty.
+ */
+export const EXTERNAL_DEPENDENCIES: string[] = [];
+
+/**
+ * Bare-specifier imports that legitimately appear in create-cloudflare's
+ * bundled output but should NOT be treated as missing runtime dependencies.
+ */
+export const IGNORED_DIST_IMPORTS = [
+ // `recast` (a bundled devDependency) attempts `require("babylon")` inside
+ // a try/catch as a fallback parser when `@babel/parser` is not available.
+ // We don't need to ship `babylon` because the primary `@babel/parser`
+ // path is bundled and always succeeds.
+ "babylon",
+];
diff --git a/packages/vitest-pool-workers/tsdown.config.ts b/packages/vitest-pool-workers/tsdown.config.ts
index f0b9cef7c9..f04e98c007 100644
--- a/packages/vitest-pool-workers/tsdown.config.ts
+++ b/packages/vitest-pool-workers/tsdown.config.ts
@@ -1,4 +1,4 @@
-import { readdirSync } from "node:fs";
+import { readdirSync, readFileSync } from "node:fs";
import path from "node:path";
import { defineConfig } from "tsdown";
import { getBuiltinModules } from "./scripts/rtti/query.mjs";
@@ -23,6 +23,28 @@ const libPaths = [
...walk(path.join(pkgRoot, "src/worker/node")),
];
+// Derive bundler externals from package.json so devDependencies are always
+// bundled and runtime dependencies/peer dependencies are always external.
+// This prevents drift between package.json and the bundler config — the
+// previous hand-maintained list incorrectly externalized undici and semver
+// (both devDependencies), leaving the published bundle with unresolved
+// imports for users who don't have those packages installed transitively.
+const pkg = JSON.parse(
+ readFileSync(path.join(pkgRoot, "package.json"), "utf-8")
+) as {
+ dependencies?: Record;
+ peerDependencies?: Record;
+};
+const runtimeDeps = [
+ ...Object.keys(pkg.dependencies ?? {}),
+ ...Object.keys(pkg.peerDependencies ?? {}),
+];
+// Match the bare package name and any subpath import (e.g. `vitest/node`).
+const runtimeDepPatterns = runtimeDeps.flatMap((name) => [
+ name,
+ new RegExp(`^${name.replace(/[/\\^$+?.()|[\]{}]/g, "\\$&")}/`),
+]);
+
const commonOptions: UserConfig = {
platform: "node",
target: "esnext",
@@ -36,22 +58,8 @@ const commonOptions: UserConfig = {
// Virtual/runtime modules
"__VITEST_POOL_WORKERS_DEFINES",
"__VITEST_POOL_WORKERS_USER_OBJECT",
- // All npm packages (previously handled by packages: "external")
- "cjs-module-lexer",
- "esbuild",
- "miniflare",
- "semver",
- "semver/*",
- "wrangler",
- "zod",
- "undici",
- "undici/*",
- // Peer dependencies
- "vitest",
- "vitest/*",
- "@vitest/runner",
- "@vitest/snapshot",
- "@vitest/snapshot/*",
+ // Runtime dependencies and peer dependencies (derived from package.json)
+ ...runtimeDepPatterns,
],
sourcemap: true,
outDir: path.join(pkgRoot, "dist"),
diff --git a/packages/workers-utils/package.json b/packages/workers-utils/package.json
index 8b803ed51a..58ef2ae998 100644
--- a/packages/workers-utils/package.json
+++ b/packages/workers-utils/package.json
@@ -16,6 +16,7 @@
"files": [
"dist"
],
+ "sideEffects": false,
"exports": {
".": {
"browser": "./dist/browser.mjs",
@@ -40,6 +41,9 @@
"test:ci": "vitest run",
"type:tests": "tsc -p ./tests/tsconfig.json"
},
+ "dependencies": {
+ "undici": "catalog:default"
+ },
"devDependencies": {
"@cloudflare/workers-shared": "workspace:*",
"@cloudflare/workers-tsconfig": "workspace:*",
@@ -57,10 +61,17 @@
"tsdown": "^0.15.9",
"tsup": "8.3.0",
"typescript": "catalog:default",
- "undici": "catalog:default",
"vitest": "catalog:default",
"xdg-app-paths": "^8.3.0"
},
+ "peerDependencies": {
+ "vitest": "^4.1.0"
+ },
+ "peerDependenciesMeta": {
+ "vitest": {
+ "optional": true
+ }
+ },
"volta": {
"extends": "../../package.json"
},
diff --git a/packages/workers-utils/scripts/deps.ts b/packages/workers-utils/scripts/deps.ts
new file mode 100644
index 0000000000..d3b721ce9a
--- /dev/null
+++ b/packages/workers-utils/scripts/deps.ts
@@ -0,0 +1,15 @@
+/**
+ * Dependencies that _are not_ bundled along with @cloudflare/workers-utils.
+ *
+ * These must be explicitly documented with a reason why they cannot be bundled.
+ * This list is validated by `tools/deployments/validate-package-dependencies.ts`.
+ */
+export const EXTERNAL_DEPENDENCIES = [
+ // Bundling `undici` would produce a duplicate copy in every downstream
+ // consumer that already depends on undici (e.g. wrangler), which breaks
+ // `instanceof Request`/`Response`/`Headers` checks across the boundary
+ // and prevents `setGlobalDispatcher` / proxy configuration from applying
+ // to the bundled copy. Keeping it external lets the package manager
+ // deduplicate undici to a single shared instance.
+ "undici",
+];
diff --git a/packages/workers-utils/tsup.config.ts b/packages/workers-utils/tsup.config.ts
index a308f6dc4d..1f8601e4fc 100644
--- a/packages/workers-utils/tsup.config.ts
+++ b/packages/workers-utils/tsup.config.ts
@@ -20,6 +20,6 @@ export default defineConfig(() => [
define: {
"process.env.NODE_ENV": `'${"production"}'`,
},
- external: ["@cloudflare/*", "vitest", "msw", "undici"],
+ external: ["@cloudflare/*", "vitest", "undici"],
},
]);
diff --git a/packages/wrangler/scripts/deps.ts b/packages/wrangler/scripts/deps.ts
index 1df2fc35b2..2f90199540 100644
--- a/packages/wrangler/scripts/deps.ts
+++ b/packages/wrangler/scripts/deps.ts
@@ -37,6 +37,31 @@ export const EXTERNAL_DEPENDENCIES = [
"workerd",
];
+/**
+ * Bare-specifier imports that legitimately appear in wrangler's bundled
+ * output but should NOT be treated as missing runtime dependencies.
+ *
+ * Each entry must be justified — typically the import is guarded
+ * (try/catch, optional require) inside a bundled-in third-party library that
+ * probes for the consumer's environment.
+ */
+export const IGNORED_DIST_IMPORTS = [
+ // @netlify/build-info (bundled devDependency) tries to require these
+ // framework packages to detect what the user has installed. Each call
+ // is guarded by try/catch and used only for framework detection.
+ "@angular/ssr",
+ "@cloudflare/vite-plugin",
+ "isbot",
+ "react-dom",
+ "react-router",
+ "waku",
+
+ // @aws-sdk/client-s3 (bundled devDependency) optionally uses this native
+ // crypto implementation if installed; falls back to a JS implementation
+ // when missing.
+ "@aws-sdk/signature-v4-crt",
+];
+
const pathToPackageJson = path.resolve(__dirname, "..", "package.json");
const packageJson = fs.readFileSync(pathToPackageJson, { encoding: "utf-8" });
const { dependencies, devDependencies } = JSON.parse(packageJson);
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index b4397915d7..d2742c1413 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -3826,6 +3826,10 @@ importers:
packages/workers-tsconfig: {}
packages/workers-utils:
+ dependencies:
+ undici:
+ specifier: catalog:default
+ version: 7.24.8
devDependencies:
'@cloudflare/workers-shared':
specifier: workspace:*
@@ -3875,9 +3879,6 @@ importers:
typescript:
specifier: catalog:default
version: 5.8.3
- undici:
- specifier: catalog:default
- version: 7.24.8
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))
diff --git a/tools/deployments/__tests__/validate-package-dependencies.test.ts b/tools/deployments/__tests__/validate-package-dependencies.test.ts
index 6d14c83e98..96e0537a71 100644
--- a/tools/deployments/__tests__/validate-package-dependencies.test.ts
+++ b/tools/deployments/__tests__/validate-package-dependencies.test.ts
@@ -1,8 +1,13 @@
import { describe, it } from "vitest";
import {
+ extractBareImports,
getAllDependencies,
+ getEntryPointPaths,
getNonWorkspaceDependencies,
+ getPackageNameFromSpecifier,
getPublicPackages,
+ isBareSpecifier,
+ validateDistImports,
validatePackageDependencies,
} from "../validate-package-dependencies";
@@ -310,6 +315,372 @@ describe("validatePackageDependencies()", () => {
});
});
+describe("getPackageNameFromSpecifier()", () => {
+ it("should return the package name from a bare specifier", ({ expect }) => {
+ expect(getPackageNameFromSpecifier("lodash")).toBe("lodash");
+ });
+
+ it("should strip subpath imports", ({ expect }) => {
+ expect(getPackageNameFromSpecifier("lodash/fp")).toBe("lodash");
+ expect(getPackageNameFromSpecifier("semver/functions/satisfies.js")).toBe(
+ "semver"
+ );
+ });
+
+ it("should preserve scoped package names", ({ expect }) => {
+ expect(getPackageNameFromSpecifier("@cloudflare/workers-utils")).toBe(
+ "@cloudflare/workers-utils"
+ );
+ expect(
+ getPackageNameFromSpecifier("@cloudflare/workers-utils/test-helpers")
+ ).toBe("@cloudflare/workers-utils");
+ });
+});
+
+describe("isBareSpecifier()", () => {
+ it("should accept bare package names", ({ expect }) => {
+ expect(isBareSpecifier("lodash")).toBe(true);
+ expect(isBareSpecifier("@cloudflare/workers-utils")).toBe(true);
+ expect(isBareSpecifier("vitest/runtime")).toBe(true);
+ });
+
+ it("should reject relative paths", ({ expect }) => {
+ expect(isBareSpecifier("./foo")).toBe(false);
+ expect(isBareSpecifier("../bar")).toBe(false);
+ expect(isBareSpecifier("/abs/path")).toBe(false);
+ });
+
+ it("should reject empty specifiers", ({ expect }) => {
+ expect(isBareSpecifier("")).toBe(false);
+ });
+
+ it("should reject Node.js built-ins (with and without prefix)", ({
+ expect,
+ }) => {
+ expect(isBareSpecifier("node:fs")).toBe(false);
+ expect(isBareSpecifier("fs")).toBe(false);
+ expect(isBareSpecifier("node:child_process")).toBe(false);
+ expect(isBareSpecifier("child_process")).toBe(false);
+ expect(isBareSpecifier("assert")).toBe(false);
+ });
+
+ it("should reject Cloudflare/workerd built-ins", ({ expect }) => {
+ expect(isBareSpecifier("cloudflare:workers")).toBe(false);
+ expect(isBareSpecifier("workerd:unsafe")).toBe(false);
+ });
+
+ it("should reject bundler virtual modules", ({ expect }) => {
+ expect(isBareSpecifier("virtual:react-router")).toBe(false);
+ expect(isBareSpecifier("wrangler:modules-watch")).toBe(false);
+ });
+
+ it("should reject ALL_CAPS virtual modules", ({ expect }) => {
+ expect(isBareSpecifier("__VITEST_POOL_WORKERS_DEFINES")).toBe(false);
+ expect(isBareSpecifier("__BUILD_CONFIG")).toBe(false);
+ });
+
+ it("should reject template-literal expressions in specifiers", ({
+ expect,
+ }) => {
+ expect(isBareSpecifier("${moduleName}")).toBe(false);
+ expect(isBareSpecifier("foo/${bar}")).toBe(false);
+ });
+
+ it("should reject specifiers with wildcards", ({ expect }) => {
+ expect(isBareSpecifier("*")).toBe(false);
+ expect(isBareSpecifier("foo/*")).toBe(false);
+ });
+
+ it("should reject specifiers that are not package-name shaped", ({
+ expect,
+ }) => {
+ expect(isBareSpecifier(",")).toBe(false);
+ expect(isBareSpecifier("some random text")).toBe(false);
+ expect(isBareSpecifier("ASSERT.IN.MIXED-CASE")).toBe(false);
+ });
+});
+
+describe("extractBareImports()", () => {
+ it("should extract named imports", ({ expect }) => {
+ const imports = extractBareImports(`import { x } from "lodash";`);
+ expect([...imports]).toEqual(["lodash"]);
+ });
+
+ it("should extract default imports", ({ expect }) => {
+ const imports = extractBareImports(`import x from "lodash";`);
+ expect([...imports]).toEqual(["lodash"]);
+ });
+
+ it("should extract namespace imports", ({ expect }) => {
+ const imports = extractBareImports(`import * as x from "lodash";`);
+ expect([...imports]).toEqual(["lodash"]);
+ });
+
+ it("should extract side-effect imports", ({ expect }) => {
+ const imports = extractBareImports(`import "lodash";`);
+ expect([...imports]).toEqual(["lodash"]);
+ });
+
+ it("should extract re-exports", ({ expect }) => {
+ const imports = extractBareImports(`export { x } from "lodash";`);
+ expect([...imports]).toEqual(["lodash"]);
+ });
+
+ it("should extract require() calls", ({ expect }) => {
+ const imports = extractBareImports(
+ `const x = require("lodash"); require("foo");`
+ );
+ expect([...imports].sort()).toEqual(["foo", "lodash"]);
+ });
+
+ it("should extract dynamic import() calls with string literals", ({
+ expect,
+ }) => {
+ const imports = extractBareImports(`const x = await import("lodash");`);
+ expect([...imports]).toEqual(["lodash"]);
+ });
+
+ it("should strip subpath imports down to package name", ({ expect }) => {
+ const imports = extractBareImports(
+ `import x from "semver/functions/satisfies.js";`
+ );
+ expect([...imports]).toEqual(["semver"]);
+ });
+
+ it("should skip relative imports", ({ expect }) => {
+ const imports = extractBareImports(
+ `import x from "./foo"; import y from "../bar";`
+ );
+ expect([...imports]).toEqual([]);
+ });
+
+ it("should skip Node built-in imports", ({ expect }) => {
+ const imports = extractBareImports(
+ `import fs from "node:fs"; const path = require("node:path");`
+ );
+ expect([...imports]).toEqual([]);
+ });
+
+ it("should skip Cloudflare/workerd built-in imports", ({ expect }) => {
+ const imports = extractBareImports(
+ `import { env } from "cloudflare:workers"; import x from "workerd:unsafe";`
+ );
+ expect([...imports]).toEqual([]);
+ });
+
+ it("should skip imports inside line comments", ({ expect }) => {
+ const imports = extractBareImports(
+ `// import x from "lodash";\nimport y from "react";`
+ );
+ expect([...imports]).toEqual(["react"]);
+ });
+
+ it("should skip imports inside block comments", ({ expect }) => {
+ const imports = extractBareImports(
+ `/* import x from "lodash"; */\nimport y from "react";`
+ );
+ expect([...imports]).toEqual(["react"]);
+ });
+
+ it("should not match `from` keywords that aren't part of import/export", ({
+ expect,
+ }) => {
+ const imports = extractBareImports(
+ `function foo() { return "hello"; } const z = "from";`
+ );
+ expect([...imports]).toEqual([]);
+ });
+
+ it("should deduplicate imports", ({ expect }) => {
+ const imports = extractBareImports(
+ `import { x } from "lodash";\nimport { y } from "lodash";`
+ );
+ expect([...imports]).toEqual(["lodash"]);
+ });
+
+ it("should handle multiple imports in one file", ({ expect }) => {
+ const imports = extractBareImports(`
+ import { x } from "lodash";
+ import y from "react";
+ import "polyfill";
+ const z = require("commander");
+ `);
+ expect([...imports].sort()).toEqual([
+ "commander",
+ "lodash",
+ "polyfill",
+ "react",
+ ]);
+ });
+});
+
+describe("validateDistImports()", () => {
+ it("should pass when all imports are declared dependencies", ({ expect }) => {
+ const errors = validateDistImports(
+ "test-package",
+ {
+ name: "test-package",
+ dependencies: { lodash: "^4.0.0", react: "^18.0.0" },
+ },
+ new Set(["lodash", "react"])
+ );
+ expect(errors).toEqual([]);
+ });
+
+ it("should pass when imports are peerDependencies", ({ expect }) => {
+ const errors = validateDistImports(
+ "test-package",
+ {
+ name: "test-package",
+ peerDependencies: { vitest: "^4.0.0" },
+ },
+ new Set(["vitest"])
+ );
+ expect(errors).toEqual([]);
+ });
+
+ it("should allow self-imports without error", ({ expect }) => {
+ const errors = validateDistImports(
+ "my-package",
+ { name: "my-package" },
+ new Set(["my-package"])
+ );
+ expect(errors).toEqual([]);
+ });
+
+ it("should flag devDependency-only imports with a tailored message", ({
+ expect,
+ }) => {
+ const errors = validateDistImports(
+ "test-package",
+ {
+ name: "test-package",
+ devDependencies: { undici: "^7.0.0" },
+ },
+ new Set(["undici"])
+ );
+ expect(errors).toHaveLength(1);
+ expect(errors[0]).toContain('"undici"');
+ expect(errors[0]).toContain("only a devDependency");
+ expect(errors[0]).toContain("IGNORED_DIST_IMPORTS");
+ });
+
+ it("should flag undeclared imports", ({ expect }) => {
+ const errors = validateDistImports(
+ "test-package",
+ { name: "test-package" },
+ new Set(["mystery-package"])
+ );
+ expect(errors).toHaveLength(1);
+ expect(errors[0]).toContain('"mystery-package"');
+ expect(errors[0]).toContain(
+ "not declared in dependencies or peerDependencies"
+ );
+ });
+
+ it("should skip imports in the ignored list", ({ expect }) => {
+ const errors = validateDistImports(
+ "test-package",
+ {
+ name: "test-package",
+ devDependencies: { "@netlify/build-info": "^1.0.0" },
+ },
+ new Set(["react-router", "@angular/ssr"]),
+ ["react-router", "@angular/ssr"]
+ );
+ expect(errors).toEqual([]);
+ });
+
+ it("should still flag non-ignored imports when ignored list is non-empty", ({
+ expect,
+ }) => {
+ const errors = validateDistImports(
+ "test-package",
+ {
+ name: "test-package",
+ devDependencies: { undici: "^7.0.0" },
+ },
+ new Set(["undici", "react-router"]),
+ ["react-router"]
+ );
+ expect(errors).toHaveLength(1);
+ expect(errors[0]).toContain('"undici"');
+ });
+});
+
+describe("getEntryPointPaths()", () => {
+ it("should collect main field", ({ expect }) => {
+ const paths = getEntryPointPaths({
+ name: "p",
+ main: "dist/index.js",
+ });
+ expect(paths).toEqual(["dist/index.js"]);
+ });
+
+ it("should collect module field", ({ expect }) => {
+ const paths = getEntryPointPaths({
+ name: "p",
+ module: "dist/index.mjs",
+ });
+ expect(paths).toEqual(["dist/index.mjs"]);
+ });
+
+ it("should walk nested exports object", ({ expect }) => {
+ const paths = getEntryPointPaths({
+ name: "p",
+ exports: {
+ ".": {
+ import: "./dist/index.mjs",
+ require: "./dist/index.cjs",
+ types: "./dist/index.d.ts",
+ },
+ "./test-helpers": {
+ import: "./dist/test-helpers/index.mjs",
+ },
+ },
+ });
+ expect(paths.sort()).toEqual([
+ "./dist/index.cjs",
+ "./dist/index.d.ts",
+ "./dist/index.mjs",
+ "./dist/test-helpers/index.mjs",
+ ]);
+ });
+
+ it("should collect string bin entries", ({ expect }) => {
+ const paths = getEntryPointPaths({
+ name: "p",
+ bin: "./bin/cli.js",
+ });
+ expect(paths).toEqual(["./bin/cli.js"]);
+ });
+
+ it("should collect object bin entries", ({ expect }) => {
+ const paths = getEntryPointPaths({
+ name: "p",
+ bin: { foo: "./bin/foo.js", bar: "./bin/bar.js" },
+ });
+ expect(paths.sort()).toEqual(["./bin/bar.js", "./bin/foo.js"]);
+ });
+
+ it("should deduplicate paths across fields", ({ expect }) => {
+ const paths = getEntryPointPaths({
+ name: "p",
+ main: "./dist/index.js",
+ module: "./dist/index.js",
+ exports: { ".": "./dist/index.js" },
+ });
+ expect(paths).toEqual(["./dist/index.js"]);
+ });
+
+ it("should return empty array when no entry points are declared", ({
+ expect,
+ }) => {
+ const paths = getEntryPointPaths({ name: "p" });
+ expect(paths).toEqual([]);
+ });
+});
+
describe("getPublicPackages()", () => {
it("should return only non-private packages", async ({ expect }) => {
const packages = await getPublicPackages();
diff --git a/tools/deployments/validate-package-dependencies.ts b/tools/deployments/validate-package-dependencies.ts
index 8e674c8e2f..bcd4ee318a 100644
--- a/tools/deployments/validate-package-dependencies.ts
+++ b/tools/deployments/validate-package-dependencies.ts
@@ -11,15 +11,28 @@
* 1. Listed in `dependencies` (or `peerDependencies`) in package.json
* 2. Listed in `scripts/deps.ts` with EXTERNAL_DEPENDENCIES export
* 3. Documented with a comment explaining WHY it can't be bundled
+ *
+ * In addition to validating the manifest, this script also scans the actual
+ * built/published files (from the `files` field in package.json) for bare
+ * import specifiers and checks they all resolve to a declared runtime
+ * dependency or peer dependency. This catches cases where a bundler config's
+ * `external` list drifts from `package.json` — for example, a devDependency
+ * being incorrectly externalized would leave the published bundle with an
+ * unresolved import.
*/
import { existsSync, readFileSync } from "node:fs";
+import { isBuiltin } from "node:module";
import { dirname, resolve } from "node:path";
import { glob } from "tinyglobby";
export interface PackageJSON {
name: string;
private?: boolean;
+ main?: string;
+ module?: string;
+ exports?: unknown;
+ bin?: string | Record;
dependencies?: Record;
devDependencies?: Record;
peerDependencies?: Record;
@@ -102,23 +115,47 @@ export function getNonWorkspaceDependencies(
* Attempts to load EXTERNAL_DEPENDENCIES from a package's scripts/deps.ts
*/
export function loadExternalDependencies(packageDir: string): string[] | null {
- const depsFilePath = resolve(packageDir, "scripts/deps.ts");
+ const depsModule = loadDepsModule(packageDir);
+ if (!depsModule || !Array.isArray(depsModule.EXTERNAL_DEPENDENCIES)) {
+ return null;
+ }
+ return depsModule.EXTERNAL_DEPENDENCIES;
+}
+
+/**
+ * Attempts to load IGNORED_DIST_IMPORTS from a package's scripts/deps.ts.
+ *
+ * `IGNORED_DIST_IMPORTS` is an allowlist of package names that the dist-scan
+ * validator should not flag as missing dependencies. Use this for legitimate
+ * but unfixable patterns such as:
+ * - Optional imports inside try/catch blocks in bundled libraries
+ * (e.g. `@netlify/build-info` probing for installed frameworks)
+ * - Optional native binaries (e.g. `@aws-sdk/signature-v4-crt`)
+ * - Code paths that are only reachable when consumers also install the
+ * listed package themselves
+ */
+export function loadIgnoredDistImports(packageDir: string): string[] {
+ const depsModule = loadDepsModule(packageDir);
+ if (!depsModule || !Array.isArray(depsModule.IGNORED_DIST_IMPORTS)) {
+ return [];
+ }
+ return depsModule.IGNORED_DIST_IMPORTS;
+}
+function loadDepsModule(packageDir: string): {
+ EXTERNAL_DEPENDENCIES?: string[];
+ IGNORED_DIST_IMPORTS?: string[];
+} | null {
+ const depsFilePath = resolve(packageDir, "scripts/deps.ts");
if (!existsSync(depsFilePath)) {
return null;
}
-
// Use require with esbuild-register (which is already loaded)
// eslint-disable-next-line @typescript-eslint/no-require-imports -- dynamic require needed for esbuild-register compatibility
- const depsModule = require(depsFilePath) as {
+ return require(depsFilePath) as {
EXTERNAL_DEPENDENCIES?: string[];
+ IGNORED_DIST_IMPORTS?: string[];
};
-
- if (!Array.isArray(depsModule.EXTERNAL_DEPENDENCIES)) {
- return null;
- }
-
- return depsModule.EXTERNAL_DEPENDENCIES;
}
/**
@@ -191,6 +228,284 @@ export function validatePackageDependencies(
return errors;
}
+/**
+ * Given an import specifier, returns the package name part.
+ *
+ * Examples:
+ * "lodash" -> "lodash"
+ * "lodash/fp" -> "lodash"
+ * "@cloudflare/workers-utils" -> "@cloudflare/workers-utils"
+ * "@cloudflare/workers-utils/test-helpers" -> "@cloudflare/workers-utils"
+ * "vitest/runtime" -> "vitest"
+ */
+export function getPackageNameFromSpecifier(spec: string): string {
+ if (spec.startsWith("@")) {
+ const parts = spec.split("/");
+ return parts.slice(0, 2).join("/");
+ }
+ return spec.split("/")[0];
+}
+
+/**
+ * Returns true if the specifier refers to an external bare package import
+ * (i.e. not a built-in, virtual module, or relative path).
+ */
+export function isBareSpecifier(spec: string): boolean {
+ if (!spec || spec.startsWith(".") || spec.startsWith("/")) {
+ return false;
+ }
+ // Node built-ins, both prefixed (node:fs) and unprefixed (fs).
+ if (isBuiltin(spec)) {
+ return false;
+ }
+ // Known non-package protocols used inside bundled user code / templates.
+ // These appear inside string literals in wrangler's shipped code templates,
+ // not as real imports of an npm package.
+ if (/^[a-z][a-z0-9+\-.]*:/.test(spec) && !spec.startsWith("@")) {
+ return false;
+ }
+ // Virtual / runtime-injected modules — anything in ALL_CAPS_WITH_UNDERSCORES
+ // is conventionally a build-time virtual module (e.g. __VITEST_POOL_WORKERS_DEFINES).
+ if (/^__[A-Z][A-Z0-9_]*$/.test(spec)) {
+ return false;
+ }
+ // Specifiers containing template-literal expressions are dynamic strings
+ // that appear inside source-code templates emitted by Wrangler, not real
+ // imports of the wrapping package.
+ if (spec.includes("${") || spec.includes("*")) {
+ return false;
+ }
+ // Specifier must look like a valid npm package name: lowercase letters/digits,
+ // hyphens, dots, underscores; optionally scoped (@scope/name). npm package
+ // names cannot contain uppercase letters per the npm naming rules.
+ const packageNameRe =
+ /^(?:@[a-z0-9._~-]+\/)?[a-z0-9][a-z0-9._~-]*(?:\/[a-z0-9._~-]+)*$/;
+ if (!packageNameRe.test(spec)) {
+ return false;
+ }
+ return true;
+}
+
+/**
+ * Extracts all bare-specifier imports from a JS/CJS source file.
+ *
+ * Handles:
+ * import x from "pkg"
+ * import { x } from "pkg"
+ * import * as x from "pkg"
+ * import "pkg"
+ * export ... from "pkg"
+ * require("pkg")
+ * await import("pkg") (only when specifier is a string literal)
+ *
+ * Returns top-level package names (e.g. "vitest" for "vitest/runtime").
+ *
+ * This is a regex-based scanner, not a real parser — it can produce false
+ * positives when bundled output contains string literals that look like
+ * import statements (e.g. JavaScript parser libraries shipped inside the
+ * bundle). False positives are filtered downstream by `isBareSpecifier`
+ * (rejecting non-package-shaped strings) and the self-import guard in
+ * `validateDistImports`.
+ */
+export function extractBareImports(content: string): Set {
+ const imports = new Set();
+
+ // Strip line comments and block comments to avoid matching specs inside them.
+ // This is a deliberate approximation — sufficient for built/minified output.
+ const stripped = content
+ .replace(/\/\*[\s\S]*?\*\//g, "")
+ .replace(/\/\/[^\n]*/g, "");
+
+ const patterns: RegExp[] = [
+ // import ... from "spec" / export ... from "spec"
+ // Anchored to a statement boundary (start of file, newline, `;`, or `}`)
+ // to avoid matching `from "x"` inside string literals.
+ /(?:^|[\n;}])\s*(?:import|export)\b[^"';\n]*?\bfrom\s*["']([^"'\n]+)["']/g,
+ // import "spec" (side-effect import) — also statement-anchored.
+ /(?:^|[\n;}])\s*import\s*["']([^"'\n]+)["']/g,
+ // require("spec")
+ /\brequire\s*\(\s*["']([^"'\n]+)["']\s*\)/g,
+ // import("spec") — only matches string literals
+ /\bimport\s*\(\s*["']([^"'\n]+)["']\s*\)/g,
+ ];
+
+ for (const re of patterns) {
+ let m: RegExpExecArray | null;
+ while ((m = re.exec(stripped)) !== null) {
+ const spec = m[1];
+ if (isBareSpecifier(spec)) {
+ imports.add(getPackageNameFromSpecifier(spec));
+ }
+ }
+ }
+
+ return imports;
+}
+
+/**
+ * Collects every path string referenced by the package's main/module/exports/bin
+ * fields. These are the entry points that Node.js will actually load when the
+ * package is imported or executed — and thus the surface that needs to have
+ * all of its imports resolvable from `dependencies`/`peerDependencies`.
+ *
+ * Other entries in `files` (e.g. user-facing scaffolding templates) are
+ * intentionally excluded because they aren't loaded by the package itself.
+ */
+export function getEntryPointPaths(packageJson: PackageJSON): string[] {
+ const paths = new Set();
+ if (packageJson.main) {
+ paths.add(packageJson.main);
+ }
+ if (packageJson.module) {
+ paths.add(packageJson.module);
+ }
+ walkExportValue(packageJson.exports, paths);
+ if (typeof packageJson.bin === "string") {
+ paths.add(packageJson.bin);
+ } else if (packageJson.bin && typeof packageJson.bin === "object") {
+ for (const v of Object.values(packageJson.bin)) {
+ if (typeof v === "string") {
+ paths.add(v);
+ }
+ }
+ }
+ return [...paths];
+}
+
+function walkExportValue(value: unknown, paths: Set): void {
+ if (!value) {
+ return;
+ }
+ if (typeof value === "string") {
+ paths.add(value);
+ return;
+ }
+ if (Array.isArray(value)) {
+ for (const v of value) {
+ walkExportValue(v, paths);
+ }
+ return;
+ }
+ if (typeof value === "object") {
+ for (const v of Object.values(value as Record)) {
+ walkExportValue(v, paths);
+ }
+ }
+}
+
+/**
+ * Walks the package's runtime entry points (and any sibling files in the same
+ * output directories — to cover code-split chunks) and returns the union of
+ * all bare-specifier imports found across them.
+ */
+export async function scanDistForExternalImports(
+ packageDir: string,
+ packageJson: PackageJSON
+): Promise> {
+ const imports = new Set();
+ const entryPaths = getEntryPointPaths(packageJson);
+
+ // Build the set of patterns to scan. For each entry-point path that points
+ // at a JS file, also scan its containing directory recursively to cover
+ // code-split chunks. (E.g. workers-utils' dist/index.mjs re-exports from
+ // dist/chunk-*.mjs, which we want to validate too.)
+ const patterns = new Set();
+ for (const rawPath of entryPaths) {
+ const cleaned = rawPath.replace(/^\.\//, "");
+ if (!/\.(mjs|cjs|js)$/.test(cleaned)) {
+ continue;
+ }
+ const absPath = resolve(packageDir, cleaned);
+ if (!existsSync(absPath)) {
+ continue;
+ }
+ const containingDir = dirname(cleaned);
+ if (containingDir && containingDir !== ".") {
+ patterns.add(`${containingDir}/**/*.{mjs,cjs,js}`);
+ } else {
+ patterns.add(cleaned);
+ }
+ }
+
+ if (patterns.size === 0) {
+ return imports;
+ }
+
+ const matched = await glob([...patterns], {
+ cwd: packageDir,
+ absolute: true,
+ });
+
+ for (const file of matched) {
+ if (file.endsWith(".map") || file.endsWith(".d.ts")) {
+ continue;
+ }
+ const content = readFileSync(file, "utf-8");
+ for (const imp of extractBareImports(content)) {
+ imports.add(imp);
+ }
+ }
+
+ return imports;
+}
+
+/**
+ * Validates that every bare-specifier import found in a package's published
+ * files is declared as either a `dependency` or `peerDependency`.
+ *
+ * Catches drift between bundler config `external` lists and `package.json` —
+ * for example, a devDependency that's incorrectly marked external in the
+ * bundler config would leave the published bundle importing an undeclared
+ * runtime dependency.
+ */
+export function validateDistImports(
+ packageName: string,
+ packageJson: PackageJSON,
+ importedPackages: Set,
+ ignoredImports: string[] = []
+): string[] {
+ const errors: string[] = [];
+
+ const declaredRuntimeDeps = new Set([
+ ...getAllDependencies(packageJson.dependencies),
+ ...getAllDependencies(packageJson.peerDependencies),
+ ]);
+ const devDeps = new Set(getAllDependencies(packageJson.devDependencies));
+ const ignored = new Set(ignoredImports);
+
+ for (const imp of importedPackages) {
+ // A package importing itself by name is always fine — it's a
+ // self-reference inside the bundle (e.g. wrangler's bundled cli.js
+ // contains code that references "wrangler" as a string literal).
+ if (imp === packageName) {
+ continue;
+ }
+ if (declaredRuntimeDeps.has(imp)) {
+ continue;
+ }
+ if (ignored.has(imp)) {
+ continue;
+ }
+ if (devDeps.has(imp)) {
+ errors.push(
+ `Package "${packageName}" imports "${imp}" in its published files, but ` +
+ `"${imp}" is only a devDependency. Either:\n` +
+ ` 1. Move "${imp}" to dependencies (and add to scripts/deps.ts if appropriate), or\n` +
+ ` 2. Bundle "${imp}" by removing it from the bundler config's "external" list, or\n` +
+ ` 3. Add "${imp}" to IGNORED_DIST_IMPORTS in scripts/deps.ts if it's a legitimate optional/try-catch import.`
+ );
+ } else {
+ errors.push(
+ `Package "${packageName}" imports "${imp}" in its published files, but ` +
+ `"${imp}" is not declared in dependencies or peerDependencies. ` +
+ `Add it to package.json or to IGNORED_DIST_IMPORTS in scripts/deps.ts.`
+ );
+ }
+ }
+
+ return errors;
+}
+
/**
* Validates that all packages properly declare their external dependencies
*/
@@ -211,6 +526,30 @@ export async function checkPackageDependencies(): Promise {
externalDeps
);
errors.push(...packageErrors);
+
+ // Scan the published runtime files (main/module/exports/bin) to catch
+ // drift between bundler `external` lists and `package.json`. This
+ // catches devDeps incorrectly marked as external — which would leave
+ // the published bundle with unresolvable imports for end users.
+ try {
+ const importedPackages = await scanDistForExternalImports(
+ dir,
+ packageJson
+ );
+ const ignoredImports = loadIgnoredDistImports(dir);
+ const distErrors = validateDistImports(
+ packageName,
+ packageJson,
+ importedPackages,
+ ignoredImports
+ );
+ errors.push(...distErrors);
+ } catch (e) {
+ errors.push(
+ `Package "${packageName}" dist scan failed: ${e instanceof Error ? e.message : String(e)}.\n` +
+ ` Make sure the package is built before running this check.`
+ );
+ }
}
return errors;
From 65a4b4e6ec7cdbfdc4502cccd3a4630e7c9a6bc9 Mon Sep 17 00:00:00 2001
From: emily-shen <69125074+emily-shen@users.noreply.github.com>
Date: Thu, 21 May 2026 14:49:37 +0100
Subject: [PATCH 6/6] document local explorer via agent skill (#13987)
Co-authored-by: Dario Piotrowicz
---
.opencode/skills/local-explorer/SKILL.md | 74 +++++++++++++++++++
.../assets/local-explorer-diagram.md | 57 ++++++++++++++
.../miniflare/scripts/check-generate-api.ts | 2 +-
.../src/workers/local-explorer/README.md | 11 ---
4 files changed, 132 insertions(+), 12 deletions(-)
create mode 100644 .opencode/skills/local-explorer/SKILL.md
create mode 100644 .opencode/skills/local-explorer/assets/local-explorer-diagram.md
delete mode 100644 packages/miniflare/src/workers/local-explorer/README.md
diff --git a/.opencode/skills/local-explorer/SKILL.md b/.opencode/skills/local-explorer/SKILL.md
new file mode 100644
index 0000000000..b7f6bf5111
--- /dev/null
+++ b/.opencode/skills/local-explorer/SKILL.md
@@ -0,0 +1,74 @@
+---
+name: local-explorer
+description: How to add products/resources to the local explorer or local API. Use when implementing new local APIs, or UI routes under packages/miniflare/src/workers/local-explorer or packages/local-explorer-ui.
+---
+
+# Cloudflare Local Explorer Products
+
+Use this skill when adding a new product or resource type to the local API and/or local explorer.
+
+## Start Here
+
+Read these files before editing:
+
+- `packages/miniflare/src/workers/local-explorer/explorer.worker.ts`
+- `packages/miniflare/src/plugins/core/explorer.ts`
+- `packages/miniflare/src/plugins/core/types.ts`
+- One existing resource implementation in `packages/miniflare/src/workers/local-explorer/resources/`, preferably the product most similar to the new one
+- One matching test in `packages/miniflare/test/plugins/local-explorer/`
+
+If there are UI changes, also read:
+
+- `packages/local-explorer-ui/src/components/Sidebar.tsx`
+- Existing route files under `packages/local-explorer-ui/src/routes/`
+- Existing product e2e tests under `packages/local-explorer-ui/src/__e2e__/`
+
+## Workflow
+
+1. Add the API surface to `packages/miniflare/scripts/openapi-filter-config.ts`.
+2. Generate Miniflare's filtered spec and backend types from a full Cloudflare OpenAPI spec:
+
+```bash
+OPENAPI_INPUT_PATH= pnpm --dir packages/miniflare generate:api
+```
+
+3. Inspect `packages/miniflare/src/workers/local-explorer/openapi.local.json` and generated types. If the generated schemas include fields local explorer will not support, add ignores in `openapi-filter-config.ts` and regenerate.
+4. The explorer worker should have access to all user resource bindings. Ensure `proxyBindings` include bindings to the new product and that `getExplorerServices()` exposes any extra bindings the explorer worker needs. Wire product bindings through `constructExplorerBindingMap()` and `constructExplorerWorkerOpts()` in `packages/miniflare/src/plugins/core/explorer.ts`.
+5. Add or extend resource binding metadata in `packages/miniflare/src/plugins/core/types.ts`.
+6. Implement handlers in `packages/miniflare/src/workers/local-explorer/resources/.ts`. Make sure to account for cross-instance aggregation, if applicable.
+7. Register Hono routes in `packages/miniflare/src/workers/local-explorer/explorer.worker.ts`.
+8. Validate request bodies and query params with generated Zod schemas from `generated/zod.gen.ts` using `validateRequestBody()` and `validateQuery()`.
+9. Return Cloudflare API envelope responses using `wrapResponse()` and `errorResponse()` from `common.ts` unless an existing endpoint for that product uses a different response shape.
+10. Add Miniflare tests in `packages/miniflare/test/plugins/local-explorer/.spec.ts`.
+11. Regenerate the UI API client:
+
+```bash
+pnpm --dir packages/local-explorer-ui build
+```
+
+12. Add UI routes/components. Use Kumo components for new UI. See https://github.com/cloudflare/kumo/blob/main/AGENTS.md.
+13. Add Playwright e2e tests under `packages/local-explorer-ui/src/__e2e__//` for new visible product flows.
+
+## OpenAPI Rules
+
+- Do not edit generated files like `packages/miniflare/src/workers/local-explorer/openapi.local.json` or `packages/miniflare/src/workers/local-explorer/generated/` directly.
+- Prefer upstream Cloudflare API paths when a public API exists.
+- Use `extensions.paths` in `openapi-filter-config.ts` only for local-only APIs or APIs that do not exist in the public Cloudflare API.
+- Add ignores for unsupported params, headers, request body properties, and response fields rather than pretending to support them.
+
+## Backend Patterns
+
+- Local list endpoints such as listing KV namespaces should not implement pagination as this may require cross-instance aggregation. Pagination should be supported when targeting individual resources, such as listing KV keys within a specific namespace.
+- For cross-worker aggregation, use `aggregateListResults()`, `getPeerUrlsIfAggregating()`, and `fetchFromPeer()` from `aggregation.ts`; do not hand-roll peer discovery. Add tests for both local-only behavior and aggregated behavior when the product can span multiple instances.
+- If an API needs direct filesystem access, call through the loopback service (`c.env.MINIFLARE_LOOPBACK`) to a Node.js endpoint. The local explorer API runs inside workerd, so it cannot access the host filesystem directly.
+- If an endpoint needs metadata that is not available on the runtime binding itself, put that metadata in `BindingIdMap` and pass it through `CoreBindings.JSON_LOCAL_EXPLORER_BINDING_MAP`.
+- If a product should appear in `/api/local/workers`, add it to `WorkerResourceBindings` and populate it in `constructExplorerWorkerOpts()`.
+
+## UI Patterns
+
+- The UI API client is generated from `packages/miniflare/src/workers/local-explorer/openapi.local.json` into `packages/local-explorer-ui/src/api/generated/`.
+- Sidebar resources come from `/api/local/workers`; update `LocalExplorerWorkerBindings` usage and `Sidebar.tsx` when the product should appear in navigation.
+- Add route files under `packages/local-explorer-ui/src/routes/`. TanStack Router regenerates `src/routeTree.gen.ts` during UI build/dev.
+- Preserve worker selection by carrying the `worker` search param through product links when following sidebar patterns.
+- Use Kumo for new UI components wherever possible. Do not introduce a parallel component system.
+- Do not use tailwindCSS color tokens, use Kumo color tokens instead.
diff --git a/.opencode/skills/local-explorer/assets/local-explorer-diagram.md b/.opencode/skills/local-explorer/assets/local-explorer-diagram.md
new file mode 100644
index 0000000000..d78d55028f
--- /dev/null
+++ b/.opencode/skills/local-explorer/assets/local-explorer-diagram.md
@@ -0,0 +1,57 @@
+```mermaid
+flowchart TB
+ Client["client"] --> Incoming["incoming request"]
+ Incoming --> EntryA["entry worker"]
+ EntryA -->|/cdn-cgi/explorer| ExplorerA["explorer worker (Hono)"]
+
+ subgraph Runtime["running Miniflare instances"]
+ direction LR
+
+ subgraph A["miniflare A"]
+ direction TB
+ EntryA
+ ExplorerA
+ Frontend["frontend (TanStack Router + React)"]
+ UserWorker["user worker"]
+ KV["KV, R2, D1"]
+ Wrapper["wrapped DO/Workflow class"]
+
+ EntryA --> UserWorker
+ ExplorerA -->|disk service to serve assets| Frontend
+ ExplorerA -->|binding| KV
+ ExplorerA -->|binding| Wrapper
+ UserWorker -->|binding| KV
+ UserWorker -->|binding| Wrapper
+ end
+
+ subgraph B["miniflare B"]
+ direction TB
+ EntryB["entry worker"]
+ ExplorerB["explorer worker"]
+ Etc["etc."]
+
+ EntryB --> ExplorerB
+ ExplorerB --> Etc
+ end
+ end
+
+ Frontend -->|fetch /cdn-cgi/explorer/api| Incoming
+ ExplorerA -->|fetch /cdn-cgi/explorer/api/resource with NO_AGGREGATE_HEADER| EntryB
+
+ subgraph FS["filesystem"]
+ Registry["dev registry"]
+ DOState[".wrangler/state/durable-objects (list DOs, delete workflows, etc.)"]
+ end
+
+ ExplorerA -->|node loopback binding| FS
+
+ classDef miniflareA stroke:#f08c00,fill:#fff7ed,color:#1e1e1e;
+ classDef userResource stroke:#1971c2,fill:#eff6ff,color:#1e1e1e;
+ classDef entry stroke:#e03131,fill:#fff5f5,color:#1e1e1e;
+ classDef neutral stroke:#1e1e1e,fill:#ffffff,color:#1e1e1e;
+
+ class EntryA entry;
+ class ExplorerA,Frontend,Wrapper miniflareA;
+ class UserWorker,KV userResource;
+ class Client,Incoming,Runtime,FS,Registry,DOState,EntryB,ExplorerB,Etc neutral;
+```
diff --git a/packages/miniflare/scripts/check-generate-api.ts b/packages/miniflare/scripts/check-generate-api.ts
index 124278dc40..3f75972d17 100644
--- a/packages/miniflare/scripts/check-generate-api.ts
+++ b/packages/miniflare/scripts/check-generate-api.ts
@@ -64,7 +64,7 @@ async function main(): Promise {
"\n" +
` OPENAPI_INPUT_PATH= pnpm -F miniflare generate:api\n` +
"\n" +
- "See packages/miniflare/src/workers/local-explorer/README.md for details.\n" +
+ "See .opencode/skills/local-explorer/SKILL.md for details.\n" +
`The CI check uses the spec pinned at commit ${OPENAPI_COMMIT}.\n`
);
process.exit(1);
diff --git a/packages/miniflare/src/workers/local-explorer/README.md b/packages/miniflare/src/workers/local-explorer/README.md
deleted file mode 100644
index ff498022e5..0000000000
--- a/packages/miniflare/src/workers/local-explorer/README.md
+++ /dev/null
@@ -1,11 +0,0 @@
-# Adding new APIs to explorer worker
-
-1. Download the full Cloudflare OpenAPI Spec from https://github.com/cloudflare/api-schemas.
-2. Add the new API to `miniflare/scripts/openapi-filter-config.ts`.
-3. Run `OPENAPI_INPUT_PATH= pnpm generate:api` to filter and generate types. Confirm the filtered API is as expected, and add ignores to `openapi-filter-config.ts` if necessary.
-4. The explorer should have access to all user resource bindings. This is done by adding `proxyBindings` to the explorer worker in `getGlobalServices()` in the core plugin. You may also have to add entries to `CoreBindings.JSON_LOCAL_EXPLORER_BINDING_MAP` if you need to access resource config such as IDs or database names which aren't available at runtime on the binding itself.
-5. Implement the APIs in
- `miniflare/src/workers/local-explorer/` using these bindings. You will have to register the routes in `explorer.worker.ts` and add handlers in `/local-explorer/resources/`.
-6. Add tests for your API endpoint in `miniflare/tests/plugins/local-explorer/`.
-7. Regenerate the UI's API client by running `pnpm build` in `packages/local-explorer-ui`.
-8. Make any UI changes using your new API. The built output of the UI will be bundled into Miniflare when Miniflare is built.