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.