From 37b4461b891a537ada1ae01d427c179cd72cabb9 Mon Sep 17 00:00:00 2001 From: Samuel Macleod Date: Mon, 23 Mar 2026 14:34:51 +0000 Subject: [PATCH 01/17] [wrangler] fix: prevent remote binding sessions expiring during long dev sessions --- .changeset/fix-remote-bindings-timeout.md | 12 ++ .../shared/remote-proxy-client.worker.ts | 17 +++ .../RemoteRuntimeController.test.ts | 103 +++++++++++++++--- .../wrangler/src/api/remoteBindings/index.ts | 8 +- .../startDevWorker/LocalRuntimeController.ts | 13 +-- .../startDevWorker/RemoteRuntimeController.ts | 100 ++++++++++++----- .../wrangler/src/api/startDevWorker/utils.ts | 7 ++ .../templates/startDevWorker/ProxyWorker.ts | 14 ++- 8 files changed, 219 insertions(+), 55 deletions(-) create mode 100644 .changeset/fix-remote-bindings-timeout.md diff --git a/.changeset/fix-remote-bindings-timeout.md b/.changeset/fix-remote-bindings-timeout.md new file mode 100644 index 0000000000..ae83a4a1ac --- /dev/null +++ b/.changeset/fix-remote-bindings-timeout.md @@ -0,0 +1,12 @@ +--- +"wrangler": patch +"miniflare": patch +--- + +fix: prevent remote binding sessions from expiring during long-running dev sessions + +Preview tokens for remote bindings expire after one hour. Previously, the first request after expiry would fail before a refresh was triggered. This change proactively refreshes the token at 50 minutes so no request ever sees an expired session. + +The reactive recovery path is also improved: `error code: 1031` responses (returned by bindings such as Workers AI when their session times out) now correctly trigger a refresh, where previously only `Invalid Workers Preview configuration` HTML responses did. + +Auth credentials are now resolved lazily when a remote proxy session starts rather than at bundle-complete time. This means that if your OAuth access token has been refreshed since `wrangler dev` started, the new token is used rather than the one captured at startup. diff --git a/packages/miniflare/src/workers/shared/remote-proxy-client.worker.ts b/packages/miniflare/src/workers/shared/remote-proxy-client.worker.ts index 18af7ce586..d7a7de4d66 100644 --- a/packages/miniflare/src/workers/shared/remote-proxy-client.worker.ts +++ b/packages/miniflare/src/workers/shared/remote-proxy-client.worker.ts @@ -6,6 +6,20 @@ import { throwRemoteRequired, } from "./remote-bindings-utils"; +const HANDLER_RESERVED_KEYS = new Set([ + "alarm", + "connect", + "scheduled", + "self", + "tail", + "tailStream", + "test", + "trace", + "webSocketClose", + "webSocketError", + "webSocketMessage", +]); + /** Generic remote proxy client for bindings. */ export default class Client extends WorkerEntrypoint { fetch(request: Request): Promise { @@ -27,6 +41,9 @@ export default class Client extends WorkerEntrypoint { if (Reflect.has(target, prop)) { return Reflect.get(target, prop); } + if (typeof prop === "string" && HANDLER_RESERVED_KEYS.has(prop)) { + return; + } if (!stub) { throwRemoteRequired(env.binding); } diff --git a/packages/wrangler/src/__tests__/api/startDevWorker/RemoteRuntimeController.test.ts b/packages/wrangler/src/__tests__/api/startDevWorker/RemoteRuntimeController.test.ts index 9a240cdc08..d22f90701b 100644 --- a/packages/wrangler/src/__tests__/api/startDevWorker/RemoteRuntimeController.test.ts +++ b/packages/wrangler/src/__tests__/api/startDevWorker/RemoteRuntimeController.test.ts @@ -1,7 +1,6 @@ // eslint-disable-next-line no-restricted-imports -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { RemoteRuntimeController } from "../../../api/startDevWorker/RemoteRuntimeController"; -import { unwrapHook } from "../../../api/startDevWorker/utils"; // Import the mocked functions so we can set their behavior import { createPreviewSession, @@ -39,10 +38,6 @@ vi.mock("../../../user/access", () => ({ domainUsesAccess: vi.fn(), })); -vi.mock("../../../api/startDevWorker/utils", () => ({ - unwrapHook: vi.fn(), -})); - function makeConfig( overrides: Partial = {} ): StartDevWorkerOptions { @@ -104,12 +99,6 @@ describe("RemoteRuntimeController", () => { } beforeEach(() => { - // Setup mock implementations - vi.mocked(unwrapHook).mockResolvedValue({ - accountId: "test-account-id", - apiToken: { apiToken: "test-token" }, - }); - vi.mocked(getWorkerAccountAndContext).mockResolvedValue({ workerAccount: { accountId: "test-account-id", @@ -160,12 +149,100 @@ describe("RemoteRuntimeController", () => { vi.mocked(createWorkerPreview).mockResolvedValue({ value: "test-preview-token", host: "test.workers.dev", - tailUrl: "wss://test.workers.dev/tail", + // No tailUrl — avoids real WebSocket connections in unit tests }); vi.mocked(getAccessToken).mockResolvedValue(undefined); }); + describe("proactive token refresh", () => { + afterEach(() => vi.useRealTimers()); + + it("should proactively refresh the token before expiry", async () => { + vi.useFakeTimers(); + + const { controller, bus } = setup(); + const config = makeConfig(); + const bundle = makeBundle(); + + controller.onBundleStart({ type: "bundleStart", config }); + controller.onBundleComplete({ type: "bundleComplete", config, bundle }); + await bus.waitFor("reloadComplete"); + + vi.mocked(createWorkerPreview).mockClear(); + vi.mocked(createRemoteWorkerInit).mockClear(); + vi.mocked(createWorkerPreview).mockResolvedValue({ + value: "proactively-refreshed-token", + host: "test.workers.dev", + }); + + // Register the waiter before advancing so it's in place when the + // event fires. Use a timeout larger than the advance window so the + // waiter's own faked setTimeout doesn't race the refresh timer. + const reloadPromise = bus.waitFor( + "reloadComplete", + undefined, + 60 * 60 * 1000 + ); + await vi.advanceTimersByTimeAsync(50 * 60 * 1000 + 1); + const reloadEvent = await reloadPromise; + + expect(createWorkerPreview).toHaveBeenCalledTimes(1); + expect(reloadEvent).toMatchObject({ + type: "reloadComplete", + proxyData: { + headers: { + "cf-workers-preview-token": "proactively-refreshed-token", + }, + }, + }); + }); + + it("should cancel the proactive refresh timer on bundle start", async () => { + vi.useFakeTimers(); + + const { controller, bus } = setup(); + const config = makeConfig(); + const bundle = makeBundle(); + + controller.onBundleStart({ type: "bundleStart", config }); + controller.onBundleComplete({ type: "bundleComplete", config, bundle }); + await bus.waitFor("reloadComplete"); + + vi.mocked(createWorkerPreview).mockClear(); + + // A new bundleStart cancels the old timer before it fires + controller.onBundleStart({ type: "bundleStart", config }); + controller.onBundleComplete({ type: "bundleComplete", config, bundle }); + await bus.waitFor("reloadComplete"); + + vi.mocked(createWorkerPreview).mockClear(); + + // Advance to just before T2 would fire — no proactive refresh should occur + await vi.advanceTimersByTimeAsync(50 * 60 * 1000 - 1); + expect(createWorkerPreview).not.toHaveBeenCalled(); + }); + + it("should cancel the proactive refresh timer on teardown", async () => { + vi.useFakeTimers(); + + const { controller, bus } = setup(); + const config = makeConfig(); + const bundle = makeBundle(); + + controller.onBundleStart({ type: "bundleStart", config }); + controller.onBundleComplete({ type: "bundleComplete", config, bundle }); + await bus.waitFor("reloadComplete"); + + vi.mocked(createWorkerPreview).mockClear(); + await controller.teardown(); + + // Advance past where the timer would have fired + await vi.advanceTimersByTimeAsync(50 * 60 * 1000 + 1); + expect(createWorkerPreview).not.toHaveBeenCalled(); + }); + }); + describe("preview token refresh", () => { it("should handle missing state gracefully", async () => { const { controller } = setup(); diff --git a/packages/wrangler/src/api/remoteBindings/index.ts b/packages/wrangler/src/api/remoteBindings/index.ts index a7df42d1a6..5c95abeb91 100644 --- a/packages/wrangler/src/api/remoteBindings/index.ts +++ b/packages/wrangler/src/api/remoteBindings/index.ts @@ -69,9 +69,9 @@ export async function maybeStartOrUpdateRemoteProxySession( preExistingRemoteProxySessionData?: { session: RemoteProxySession; remoteBindings: Record; - auth?: CfAccount | undefined; + auth?: AsyncHook | undefined; } | null, - auth?: CfAccount | undefined + auth?: AsyncHook | undefined ): Promise<{ session: RemoteProxySession; remoteBindings: Record; @@ -175,9 +175,9 @@ export async function maybeStartOrUpdateRemoteProxySession( * @returns the auth hook to pass to the startRemoteProxy session function if any */ function getAuthHook( - auth: CfAccount | undefined, + auth: AsyncHook | undefined, config: Pick | undefined -): AsyncHook]> | undefined { +): AsyncHook | undefined { if (auth) { return auth; } diff --git a/packages/wrangler/src/api/startDevWorker/LocalRuntimeController.ts b/packages/wrangler/src/api/startDevWorker/LocalRuntimeController.ts index 33d60ca8f5..39c95483a3 100644 --- a/packages/wrangler/src/api/startDevWorker/LocalRuntimeController.ts +++ b/packages/wrangler/src/api/startDevWorker/LocalRuntimeController.ts @@ -14,7 +14,7 @@ import * as MF from "../../dev/miniflare"; import { logger } from "../../logger"; import { RuntimeController } from "./BaseController"; import { castErrorCause } from "./events"; -import { getBinaryFileContents, unwrapHook } from "./utils"; +import { getBinaryFileContents } from "./utils"; import type { RemoteProxySession } from "../remoteBindings"; import type { BundleCompleteEvent, @@ -207,12 +207,6 @@ export class LocalRuntimeController extends RuntimeController { const remoteBindings = pickRemoteBindings(configBundle.bindings ?? {}); - const auth = - Object.keys(remoteBindings).length === 0 - ? // If there are no remote bindings (this is a local only session) there's no need to get auth data - undefined - : await unwrapHook(data.config.dev.auth); - this.#remoteProxySessionData = await maybeStartOrUpdateRemoteProxySession( { @@ -221,7 +215,10 @@ export class LocalRuntimeController extends RuntimeController { bindings: remoteBindings, }, this.#remoteProxySessionData ?? null, - auth + Object.keys(remoteBindings).length === 0 + ? // If there are no remote bindings (this is a local only session) there's no need to get auth data + undefined + : data.config.dev.auth ); } diff --git a/packages/wrangler/src/api/startDevWorker/RemoteRuntimeController.ts b/packages/wrangler/src/api/startDevWorker/RemoteRuntimeController.ts index 585106aa60..880ae5b39c 100644 --- a/packages/wrangler/src/api/startDevWorker/RemoteRuntimeController.ts +++ b/packages/wrangler/src/api/startDevWorker/RemoteRuntimeController.ts @@ -20,7 +20,7 @@ import { getAccessToken } from "../../user/access"; import { retryOnAPIFailure } from "../../utils/retry"; import { RuntimeController } from "./BaseController"; import { castErrorCause } from "./events"; -import { unwrapHook } from "./utils"; +import { getPreviewTokenRefreshInterval, unwrapHook } from "./utils"; import type { CfAccount, CfPreviewSession, @@ -30,6 +30,7 @@ import type { BundleCompleteEvent, BundleStartEvent, PreviewTokenExpiredEvent, + ProxyData, ReloadCompleteEvent, ReloadStartEvent, } from "./events"; @@ -38,6 +39,16 @@ import type { Route } from "@cloudflare/workers-utils"; type CreateRemoteWorkerInitProps = Parameters[0]; +/** + * A narrowed variant of StartDevWorkerOptions where dev.auth is required. + * RemoteRuntimeController only stores config after verifying auth is present. + */ +type RemoteStartDevWorkerOptions = StartDevWorkerOptions & { + dev: StartDevWorkerOptions["dev"] & { + auth: NonNullable; + }; +}; + export class RemoteRuntimeController extends RuntimeController { #abortController = new AbortController(); @@ -46,12 +57,19 @@ export class RemoteRuntimeController extends RuntimeController { #session?: CfPreviewSession; + // Saved so we can re-emit reloadComplete if a token refresh fails, + // ensuring the ProxyWorker is never left permanently paused. + #latestProxyData?: ProxyData; + #activeTail?: WebSocket; - #latestConfig?: StartDevWorkerOptions; + #latestConfig?: RemoteStartDevWorkerOptions; #latestBundle?: Bundle; #latestRoutes?: Route[]; + // Timer for proactive token refresh before the 1-hour expiry + #refreshTimer?: ReturnType; + async #previewSession( props: Parameters[0] & { name: string; @@ -312,25 +330,38 @@ export class RemoteRuntimeController extends RuntimeController { const accessToken = await getAccessToken(token.host); + const proxyData: ProxyData = { + userWorkerUrl: { + protocol: "https:", + hostname: token.host, + port: "443", + }, + headers: { + "cf-workers-preview-token": token.value, + ...(accessToken ? { Cookie: `CF_Authorization=${accessToken}` } : {}), + "cf-connecting-ip": "", + }, + liveReload: config.dev.liveReload, + proxyLogsToController: true, + }; + + this.#latestProxyData = proxyData; + this.emitReloadCompleteEvent({ type: "reloadComplete", bundle, config, - proxyData: { - userWorkerUrl: { - protocol: "https:", - hostname: token.host, - port: "443", - }, - headers: { - "cf-workers-preview-token": token.value, - ...(accessToken ? { Cookie: `CF_Authorization=${accessToken}` } : {}), - "cf-connecting-ip": "", - }, - liveReload: config.dev.liveReload, - proxyLogsToController: true, - }, + proxyData, }); + + this.#scheduleProactiveRefresh(); + } + + #scheduleProactiveRefresh() { + clearTimeout(this.#refreshTimer); + this.#refreshTimer = setTimeout(() => { + void this.#mutex.runWith(() => this.#refreshPreviewToken()); + }, getPreviewTokenRefreshInterval()); } async #onBundleComplete({ config, bundle }: BundleCompleteEvent, id: number) { @@ -345,8 +376,7 @@ export class RemoteRuntimeController extends RuntimeController { const auth = await unwrapHook(config.dev.auth); - // Store for token refresh - this.#latestConfig = config; + this.#latestConfig = config as RemoteStartDevWorkerOptions; this.#latestBundle = bundle; this.#latestRoutes = routes; @@ -391,14 +421,9 @@ export class RemoteRuntimeController extends RuntimeController { bundle: this.#latestBundle, }); - if (!this.#latestConfig.dev?.auth) { - // This shouldn't happen as it's checked earlier, but we guard against it anyway - throw new MissingConfigError("config.dev.auth"); - } - - const auth = await unwrapHook(this.#latestConfig.dev.auth); - try { + const auth = await unwrapHook(this.#latestConfig.dev.auth); + this.#session = await this.#getPreviewSession( this.#latestConfig, auth, @@ -425,6 +450,20 @@ export class RemoteRuntimeController extends RuntimeController { source: "RemoteRuntimeController", data: undefined, }); + + // Re-emit the last known reloadComplete so the ProxyWorker is unpaused. + // Without this, a failed refresh leaves the ProxyWorker permanently paused + // and all subsequent requests hang indefinitely. + if (this.#latestProxyData && this.#latestConfig && this.#latestBundle) { + this.emitReloadCompleteEvent({ + type: "reloadComplete", + config: this.#latestConfig, + bundle: this.#latestBundle, + proxyData: this.#latestProxyData, + }); + } + + this.#scheduleProactiveRefresh(); } } @@ -436,6 +475,8 @@ export class RemoteRuntimeController extends RuntimeController { // Abort any previous operations when a new bundle is started this.#abortController.abort(); this.#abortController = new AbortController(); + // Cancel any pending proactive refresh — a new token will be issued as part of the bundle reload + clearTimeout(this.#refreshTimer); } onBundleComplete(ev: BundleCompleteEvent) { const id = ++this.#currentBundleId; @@ -453,8 +494,12 @@ export class RemoteRuntimeController extends RuntimeController { void this.#mutex.runWith(() => this.#onBundleComplete(ev, id)); } - onPreviewTokenExpired(_: PreviewTokenExpiredEvent): void { - logger.log(chalk.dim("⎔ Preview token expired, refreshing...")); + onPreviewTokenExpired(ev: PreviewTokenExpiredEvent): void { + logger.log( + chalk.dim( + `⎔ Preview token expired, refreshing... (host=${ev.proxyData.userWorkerUrl.hostname})` + ) + ); void this.#mutex.runWith(() => this.#refreshPreviewToken()); } @@ -465,6 +510,7 @@ export class RemoteRuntimeController extends RuntimeController { } logger.debug("RemoteRuntimeController teardown beginning..."); this.#session = undefined; + clearTimeout(this.#refreshTimer); this.#abortController.abort(); // Suppress errors from terminating a WebSocket that hasn't connected yet this.#activeTail?.removeAllListeners("error"); diff --git a/packages/wrangler/src/api/startDevWorker/utils.ts b/packages/wrangler/src/api/startDevWorker/utils.ts index 89b5281254..5b5dbe19b2 100644 --- a/packages/wrangler/src/api/startDevWorker/utils.ts +++ b/packages/wrangler/src/api/startDevWorker/utils.ts @@ -16,6 +16,13 @@ import type { export function assertNever(_value: never) {} +/** + * Preview tokens expire after 1 hour (hardcoded in the Workers control plane). + */ +export function getPreviewTokenRefreshInterval() { + return 50 * 60 * 1000; +} + export type MaybePromise = T | Promise; export type DeferredPromise = { promise: Promise; diff --git a/packages/wrangler/templates/startDevWorker/ProxyWorker.ts b/packages/wrangler/templates/startDevWorker/ProxyWorker.ts index bb7a94ef5d..312fae8b48 100644 --- a/packages/wrangler/templates/startDevWorker/ProxyWorker.ts +++ b/packages/wrangler/templates/startDevWorker/ProxyWorker.ts @@ -155,8 +155,9 @@ export class ProxyWorker implements DurableObject { res = new Response(res.body, res); rewriteUrlRelatedHeaders(res.headers, innerUrl, outerUrl); + await checkForPreviewTokenError(res, this.env, proxyData); + if (isHtmlResponse(res)) { - await checkForPreviewTokenError(res, this.env, proxyData); res = insertLiveReloadScript(request, res, this.env, proxyData); } @@ -256,8 +257,15 @@ async function checkForPreviewTokenError( // so we clone and read the text instead. const clone = response.clone(); const text = await clone.text(); - // Naive string match should be good enough when combined with status code check - if (text.includes("Invalid Workers Preview configuration")) { + // Naive string match should be good enough when combined with status code check. + // "Invalid Workers Preview configuration" is the HTML error returned when the + // preview token has expired. "error code: 1031" is a text/plain error returned + // by remote bindings (e.g. Workers AI) when their underlying session has timed out. + // Both indicate the preview session needs to be refreshed. + if ( + text.includes("Invalid Workers Preview configuration") || + text.includes("error code: 1031") + ) { void sendMessageToProxyController(env, { type: "previewTokenExpired", proxyData, From cb76ef9a65d38f68d6e6fc7e2169b14cdff34c43 Mon Sep 17 00:00:00 2001 From: Samuel Macleod Date: Mon, 23 Mar 2026 17:07:42 +0000 Subject: [PATCH 02/17] fix: update remote-bindings tests for lazy auth hook resolution --- .../src/__tests__/dev/remote-bindings.test.ts | 38 ++++++++++++------- 1 file changed, 24 insertions(+), 14 deletions(-) diff --git a/packages/wrangler/src/__tests__/dev/remote-bindings.test.ts b/packages/wrangler/src/__tests__/dev/remote-bindings.test.ts index d324ba600d..6babc72dcb 100644 --- a/packages/wrangler/src/__tests__/dev/remote-bindings.test.ts +++ b/packages/wrangler/src/__tests__/dev/remote-bindings.test.ts @@ -714,16 +714,21 @@ describe("dev with remote bindings", { sequential: true, retry: 2 }, () => { await vi.waitFor(() => expect(std.out).toMatch(/Ready/), { timeout: 5_000, }); - expect(sessionOptions).toEqual({ - auth: { - accountId: "some-account-id", - apiToken: { - apiToken: "some-api-token", - }, - }, + expect(sessionOptions).toBeDefined(); + const { auth: authHook1, ...rest1 } = sessionOptions ?? {}; + expect(rest1).toEqual({ complianceRegion: undefined, workerName: "worker", }); + // auth is now an AsyncHook — resolve it before comparing + const resolvedAuth1 = + typeof authHook1 === "function" + ? await (authHook1 as unknown as () => Promise)() + : authHook1; + expect(resolvedAuth1).toEqual({ + accountId: "some-account-id", + apiToken: { apiToken: "some-api-token" }, + }); await stopWrangler(); await wranglerStopped; }); @@ -756,16 +761,21 @@ describe("dev with remote bindings", { sequential: true, retry: 2 }, () => { timeout: 5_000, }); - expect(sessionOptions).toEqual({ - auth: { - accountId: "mock-account-id", - apiToken: { - apiToken: "some-api-token", - }, - }, + expect(sessionOptions).toBeDefined(); + const { auth: authHook2, ...rest2 } = sessionOptions ?? {}; + expect(rest2).toEqual({ complianceRegion: undefined, workerName: "worker", }); + // auth is now an AsyncHook — resolve it before comparing + const resolvedAuth2 = + typeof authHook2 === "function" + ? await (authHook2 as unknown as () => Promise)() + : authHook2; + expect(resolvedAuth2).toEqual({ + accountId: "mock-account-id", + apiToken: { apiToken: "some-api-token" }, + }); await stopWrangler(); From 86a90f15b538e3b8afb9a5ce5f5cd4476e761d21 Mon Sep 17 00:00:00 2001 From: Samuel Macleod Date: Mon, 23 Mar 2026 17:57:13 +0000 Subject: [PATCH 03/17] fix: re-throw session creation errors so catch block handles ProxyWorker recovery --- .../wrangler/src/api/startDevWorker/RemoteRuntimeController.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/wrangler/src/api/startDevWorker/RemoteRuntimeController.ts b/packages/wrangler/src/api/startDevWorker/RemoteRuntimeController.ts index 880ae5b39c..b85ec32783 100644 --- a/packages/wrangler/src/api/startDevWorker/RemoteRuntimeController.ts +++ b/packages/wrangler/src/api/startDevWorker/RemoteRuntimeController.ts @@ -106,6 +106,8 @@ export class RemoteRuntimeController extends RuntimeController { source: "RemoteRuntimeController", data: undefined, }); + + throw err; } } From 27687a7732b1ae7dfa8ceeacef1027c1443f1809 Mon Sep 17 00:00:00 2001 From: Samuel Macleod Date: Fri, 27 Mar 2026 14:59:47 +0000 Subject: [PATCH 04/17] address review: unify refresh through onPreviewTokenExpired, use assert, unwrapHook in tests --- .../src/__tests__/dev/remote-bindings.test.ts | 24 ++- .../startDevWorker/RemoteRuntimeController.ts | 153 ++++++++---------- .../wrangler/src/api/startDevWorker/utils.ts | 13 +- 3 files changed, 87 insertions(+), 103 deletions(-) diff --git a/packages/wrangler/src/__tests__/dev/remote-bindings.test.ts b/packages/wrangler/src/__tests__/dev/remote-bindings.test.ts index 6babc72dcb..77e9e7b4cb 100644 --- a/packages/wrangler/src/__tests__/dev/remote-bindings.test.ts +++ b/packages/wrangler/src/__tests__/dev/remote-bindings.test.ts @@ -1,4 +1,5 @@ /* eslint-disable @typescript-eslint/consistent-type-imports */ +import assert from "node:assert"; import { seed } from "@cloudflare/workers-utils/test-helpers"; import { fetch } from "undici"; /* eslint-disable no-restricted-imports */ @@ -13,6 +14,7 @@ import { } from "vitest"; /* eslint-enable no-restricted-imports */ import { Binding, StartRemoteProxySessionOptions } from "../../api"; +import { unwrapHook } from "../../api/startDevWorker/utils"; import { mockAccountId, mockApiToken } from "../helpers/mock-account-id"; import { mockConsoleMethods } from "../helpers/mock-console"; import { @@ -715,17 +717,14 @@ describe("dev with remote bindings", { sequential: true, retry: 2 }, () => { timeout: 5_000, }); expect(sessionOptions).toBeDefined(); - const { auth: authHook1, ...rest1 } = sessionOptions ?? {}; + assert(sessionOptions); + const { auth, ...rest1 } = sessionOptions; expect(rest1).toEqual({ complianceRegion: undefined, workerName: "worker", }); - // auth is now an AsyncHook — resolve it before comparing - const resolvedAuth1 = - typeof authHook1 === "function" - ? await (authHook1 as unknown as () => Promise)() - : authHook1; - expect(resolvedAuth1).toEqual({ + assert(auth); + expect(await unwrapHook(auth, { account_id: undefined })).toEqual({ accountId: "some-account-id", apiToken: { apiToken: "some-api-token" }, }); @@ -762,17 +761,14 @@ describe("dev with remote bindings", { sequential: true, retry: 2 }, () => { }); expect(sessionOptions).toBeDefined(); - const { auth: authHook2, ...rest2 } = sessionOptions ?? {}; + assert(sessionOptions); + const { auth: auth2, ...rest2 } = sessionOptions; expect(rest2).toEqual({ complianceRegion: undefined, workerName: "worker", }); - // auth is now an AsyncHook — resolve it before comparing - const resolvedAuth2 = - typeof authHook2 === "function" - ? await (authHook2 as unknown as () => Promise)() - : authHook2; - expect(resolvedAuth2).toEqual({ + assert(auth2); + expect(await unwrapHook(auth2, { account_id: undefined })).toEqual({ accountId: "mock-account-id", apiToken: { apiToken: "some-api-token" }, }); diff --git a/packages/wrangler/src/api/startDevWorker/RemoteRuntimeController.ts b/packages/wrangler/src/api/startDevWorker/RemoteRuntimeController.ts index b85ec32783..ab1ee65d46 100644 --- a/packages/wrangler/src/api/startDevWorker/RemoteRuntimeController.ts +++ b/packages/wrangler/src/api/startDevWorker/RemoteRuntimeController.ts @@ -1,3 +1,4 @@ +import assert from "node:assert"; import { MissingConfigError } from "@cloudflare/workers-utils"; import chalk from "chalk"; import { Mutex } from "miniflare"; @@ -20,7 +21,11 @@ import { getAccessToken } from "../../user/access"; import { retryOnAPIFailure } from "../../utils/retry"; import { RuntimeController } from "./BaseController"; import { castErrorCause } from "./events"; -import { getPreviewTokenRefreshInterval, unwrapHook } from "./utils"; +import { + getFailedRefreshRetryInterval, + getPreviewTokenRefreshInterval, + unwrapHook, +} from "./utils"; import type { CfAccount, CfPreviewSession, @@ -39,16 +44,6 @@ import type { Route } from "@cloudflare/workers-utils"; type CreateRemoteWorkerInitProps = Parameters[0]; -/** - * A narrowed variant of StartDevWorkerOptions where dev.auth is required. - * RemoteRuntimeController only stores config after verifying auth is present. - */ -type RemoteStartDevWorkerOptions = StartDevWorkerOptions & { - dev: StartDevWorkerOptions["dev"] & { - auth: NonNullable; - }; -}; - export class RemoteRuntimeController extends RuntimeController { #abortController = new AbortController(); @@ -57,15 +52,12 @@ export class RemoteRuntimeController extends RuntimeController { #session?: CfPreviewSession; - // Saved so we can re-emit reloadComplete if a token refresh fails, - // ensuring the ProxyWorker is never left permanently paused. - #latestProxyData?: ProxyData; - #activeTail?: WebSocket; - #latestConfig?: RemoteStartDevWorkerOptions; + #latestConfig?: StartDevWorkerOptions; #latestBundle?: Bundle; #latestRoutes?: Route[]; + #latestProxyData?: ProxyData; // Timer for proactive token refresh before the 1-hour expiry #refreshTimer?: ReturnType; @@ -356,14 +348,19 @@ export class RemoteRuntimeController extends RuntimeController { proxyData, }); - this.#scheduleProactiveRefresh(); + this.#scheduleRefresh(getPreviewTokenRefreshInterval()); } - #scheduleProactiveRefresh() { + #scheduleRefresh(interval: number) { clearTimeout(this.#refreshTimer); this.#refreshTimer = setTimeout(() => { - void this.#mutex.runWith(() => this.#refreshPreviewToken()); - }, getPreviewTokenRefreshInterval()); + if (this.#latestProxyData) { + this.onPreviewTokenExpired({ + type: "previewTokenExpired", + proxyData: this.#latestProxyData, + }); + } + }, interval); } async #onBundleComplete({ config, bundle }: BundleCompleteEvent, id: number) { @@ -376,9 +373,10 @@ export class RemoteRuntimeController extends RuntimeController { throw new MissingConfigError("config.dev.auth"); } + assert(config.dev.auth); const auth = await unwrapHook(config.dev.auth); - this.#latestConfig = config as RemoteStartDevWorkerOptions; + this.#latestConfig = config; this.#latestBundle = bundle; this.#latestRoutes = routes; @@ -409,66 +407,6 @@ export class RemoteRuntimeController extends RuntimeController { } } - async #refreshPreviewToken() { - if (!this.#latestConfig || !this.#latestBundle) { - logger.warn( - "Cannot refresh preview token: missing config or bundle data" - ); - return; - } - - this.emitReloadStartEvent({ - type: "reloadStart", - config: this.#latestConfig, - bundle: this.#latestBundle, - }); - - try { - const auth = await unwrapHook(this.#latestConfig.dev.auth); - - this.#session = await this.#getPreviewSession( - this.#latestConfig, - auth, - this.#latestRoutes - ); - - await this.#updatePreviewToken( - this.#latestConfig, - this.#latestBundle, - auth, - this.#latestRoutes, - this.#currentBundleId - ); - logger.log(chalk.green("✔ Preview token refreshed successfully")); - } catch (error) { - if (error instanceof Error && error.name == "AbortError") { - return; - } - - this.emitErrorEvent({ - type: "error", - reason: "Error refreshing preview token", - cause: castErrorCause(error), - source: "RemoteRuntimeController", - data: undefined, - }); - - // Re-emit the last known reloadComplete so the ProxyWorker is unpaused. - // Without this, a failed refresh leaves the ProxyWorker permanently paused - // and all subsequent requests hang indefinitely. - if (this.#latestProxyData && this.#latestConfig && this.#latestBundle) { - this.emitReloadCompleteEvent({ - type: "reloadComplete", - config: this.#latestConfig, - bundle: this.#latestBundle, - proxyData: this.#latestProxyData, - }); - } - - this.#scheduleProactiveRefresh(); - } - } - // ****************** // Event Handlers // ****************** @@ -496,13 +434,52 @@ export class RemoteRuntimeController extends RuntimeController { void this.#mutex.runWith(() => this.#onBundleComplete(ev, id)); } - onPreviewTokenExpired(ev: PreviewTokenExpiredEvent): void { - logger.log( - chalk.dim( - `⎔ Preview token expired, refreshing... (host=${ev.proxyData.userWorkerUrl.hostname})` - ) - ); - void this.#mutex.runWith(() => this.#refreshPreviewToken()); + onPreviewTokenExpired(_ev: PreviewTokenExpiredEvent): void { + logger.log(chalk.dim("⎔ Refreshing preview token...")); + void this.#mutex.runWith(async () => { + if (!this.#latestConfig || !this.#latestBundle) { + logger.warn( + "Cannot refresh preview token: missing config or bundle data" + ); + return; + } + + try { + assert(this.#latestConfig.dev.auth); + const auth = await unwrapHook(this.#latestConfig.dev.auth); + + this.#session = await this.#getPreviewSession( + this.#latestConfig, + auth, + this.#latestRoutes + ); + + await this.#updatePreviewToken( + this.#latestConfig, + this.#latestBundle, + auth, + this.#latestRoutes, + this.#currentBundleId + ); + logger.log(chalk.green("✔ Preview token refreshed successfully")); + } catch (error) { + if (error instanceof Error && error.name == "AbortError") { + return; + } + + this.emitErrorEvent({ + type: "error", + reason: "Error refreshing preview token", + cause: castErrorCause(error), + source: "RemoteRuntimeController", + data: undefined, + }); + + // Retry sooner than the normal 50-minute interval so the user + // isn't stuck for long if the refresh fails transiently. + this.#scheduleRefresh(getFailedRefreshRetryInterval()); + } + }); } override async teardown() { diff --git a/packages/wrangler/src/api/startDevWorker/utils.ts b/packages/wrangler/src/api/startDevWorker/utils.ts index 5b5dbe19b2..61dd699add 100644 --- a/packages/wrangler/src/api/startDevWorker/utils.ts +++ b/packages/wrangler/src/api/startDevWorker/utils.ts @@ -17,12 +17,23 @@ import type { export function assertNever(_value: never) {} /** - * Preview tokens expire after 1 hour (hardcoded in the Workers control plane). + * When to proactively refresh the preview token. + * + * Preview tokens expire after 1 hour (hardcoded in the Workers control plane), so we retry after 50 mins. */ export function getPreviewTokenRefreshInterval() { return 50 * 60 * 1000; } +/** + * When to retry a failed token refresh. + * + * Short interval so the user isn't stuck for long if the first attempt fails. + */ +export function getFailedRefreshRetryInterval() { + return 30 * 1000; +} + export type MaybePromise = T | Promise; export type DeferredPromise = { promise: Promise; From 6804e5eaa769ca243f7a454c3afb7d81e1e51d46 Mon Sep 17 00:00:00 2001 From: Samuel Macleod Date: Mon, 30 Mar 2026 14:18:58 +0100 Subject: [PATCH 05/17] fix: hard error on failed token refresh instead of silent retry --- .../startDevWorker/RemoteRuntimeController.ts | 24 +++++++++---------- .../wrangler/src/api/startDevWorker/utils.ts | 9 ------- 2 files changed, 11 insertions(+), 22 deletions(-) diff --git a/packages/wrangler/src/api/startDevWorker/RemoteRuntimeController.ts b/packages/wrangler/src/api/startDevWorker/RemoteRuntimeController.ts index ab1ee65d46..c925db33e2 100644 --- a/packages/wrangler/src/api/startDevWorker/RemoteRuntimeController.ts +++ b/packages/wrangler/src/api/startDevWorker/RemoteRuntimeController.ts @@ -21,11 +21,7 @@ import { getAccessToken } from "../../user/access"; import { retryOnAPIFailure } from "../../utils/retry"; import { RuntimeController } from "./BaseController"; import { castErrorCause } from "./events"; -import { - getFailedRefreshRetryInterval, - getPreviewTokenRefreshInterval, - unwrapHook, -} from "./utils"; +import { getPreviewTokenRefreshInterval, unwrapHook } from "./utils"; import type { CfAccount, CfPreviewSession, @@ -279,11 +275,11 @@ export class RemoteRuntimeController extends RuntimeController { auth: CfAccount, routes: Route[] | undefined, bundleId: number - ) { + ): Promise { // If we received a new `bundleComplete` event before we were able to // dispatch a `reloadComplete` for this bundle, ignore this bundle. if (bundleId !== this.#currentBundleId) { - return; + return false; } const token = await this.#previewToken({ @@ -319,7 +315,7 @@ export class RemoteRuntimeController extends RuntimeController { // dispatch a `reloadComplete` for this bundle, ignore this bundle. // If `token` is undefined, we've surfaced a relevant error to the user above, so ignore this bundle if (bundleId !== this.#currentBundleId || !token) { - return; + return false; } const accessToken = await getAccessToken(token.host); @@ -349,6 +345,7 @@ export class RemoteRuntimeController extends RuntimeController { }); this.#scheduleRefresh(getPreviewTokenRefreshInterval()); + return true; } #scheduleRefresh(interval: number) { @@ -454,13 +451,18 @@ export class RemoteRuntimeController extends RuntimeController { this.#latestRoutes ); - await this.#updatePreviewToken( + const refreshed = await this.#updatePreviewToken( this.#latestConfig, this.#latestBundle, auth, this.#latestRoutes, this.#currentBundleId ); + + if (!refreshed) { + throw new Error("Failed to refresh preview token"); + } + logger.log(chalk.green("✔ Preview token refreshed successfully")); } catch (error) { if (error instanceof Error && error.name == "AbortError") { @@ -474,10 +476,6 @@ export class RemoteRuntimeController extends RuntimeController { source: "RemoteRuntimeController", data: undefined, }); - - // Retry sooner than the normal 50-minute interval so the user - // isn't stuck for long if the refresh fails transiently. - this.#scheduleRefresh(getFailedRefreshRetryInterval()); } }); } diff --git a/packages/wrangler/src/api/startDevWorker/utils.ts b/packages/wrangler/src/api/startDevWorker/utils.ts index 61dd699add..85137b5c07 100644 --- a/packages/wrangler/src/api/startDevWorker/utils.ts +++ b/packages/wrangler/src/api/startDevWorker/utils.ts @@ -25,15 +25,6 @@ export function getPreviewTokenRefreshInterval() { return 50 * 60 * 1000; } -/** - * When to retry a failed token refresh. - * - * Short interval so the user isn't stuck for long if the first attempt fails. - */ -export function getFailedRefreshRetryInterval() { - return 30 * 1000; -} - export type MaybePromise = T | Promise; export type DeferredPromise = { promise: Promise; From 3afd157892d9b2e4ee1aa7839a83e64149da362d Mon Sep 17 00:00:00 2001 From: Samuel Macleod Date: Mon, 30 Mar 2026 17:55:19 +0100 Subject: [PATCH 06/17] back to base --- fixtures/worker-ts/src/index.ts | 8 +++----- fixtures/worker-ts/wrangler.jsonc | 3 +-- 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/fixtures/worker-ts/src/index.ts b/fixtures/worker-ts/src/index.ts index 401ff87722..6a010a461e 100644 --- a/fixtures/worker-ts/src/index.ts +++ b/fixtures/worker-ts/src/index.ts @@ -27,10 +27,8 @@ export default { env: Env, ctx: ExecutionContext ): Promise { - const response = await env.AI.run("@cf/meta/llama-3.1-8b-instruct", { - prompt: "What is the origin of the phrase Hello, World", - }); - - return new Response(JSON.stringify(response)); + const url = new URL(request.url); + if (url.pathname === "/error") throw new Error("Hello Error"); + return new Response("Hello World!"); }, }; diff --git a/fixtures/worker-ts/wrangler.jsonc b/fixtures/worker-ts/wrangler.jsonc index 3f388c1dee..25cff9cbce 100644 --- a/fixtures/worker-ts/wrangler.jsonc +++ b/fixtures/worker-ts/wrangler.jsonc @@ -2,6 +2,5 @@ "$schema": "node_modules/wrangler/config-schema.json", "name": "worker-ts", "main": "src/index.ts", - "compatibility_date": "2026-01-01", - "ai": { "binding": "AI" } + "compatibility_date": "2023-05-04", } From 517834205923d642731ac9de56e89e6c9cb7653a Mon Sep 17 00:00:00 2001 From: Samuel Macleod Date: Mon, 30 Mar 2026 17:59:54 +0100 Subject: [PATCH 07/17] restore #refreshPreviewToken as separate method, revert HANDLER_RESERVED_KEYS --- .../shared/remote-proxy-client.worker.ts | 17 ---- .../startDevWorker/RemoteRuntimeController.ts | 92 ++++++++++--------- 2 files changed, 47 insertions(+), 62 deletions(-) diff --git a/packages/miniflare/src/workers/shared/remote-proxy-client.worker.ts b/packages/miniflare/src/workers/shared/remote-proxy-client.worker.ts index d7a7de4d66..18af7ce586 100644 --- a/packages/miniflare/src/workers/shared/remote-proxy-client.worker.ts +++ b/packages/miniflare/src/workers/shared/remote-proxy-client.worker.ts @@ -6,20 +6,6 @@ import { throwRemoteRequired, } from "./remote-bindings-utils"; -const HANDLER_RESERVED_KEYS = new Set([ - "alarm", - "connect", - "scheduled", - "self", - "tail", - "tailStream", - "test", - "trace", - "webSocketClose", - "webSocketError", - "webSocketMessage", -]); - /** Generic remote proxy client for bindings. */ export default class Client extends WorkerEntrypoint { fetch(request: Request): Promise { @@ -41,9 +27,6 @@ export default class Client extends WorkerEntrypoint { if (Reflect.has(target, prop)) { return Reflect.get(target, prop); } - if (typeof prop === "string" && HANDLER_RESERVED_KEYS.has(prop)) { - return; - } if (!stub) { throwRemoteRequired(env.binding); } diff --git a/packages/wrangler/src/api/startDevWorker/RemoteRuntimeController.ts b/packages/wrangler/src/api/startDevWorker/RemoteRuntimeController.ts index 8a49e49119..8923c21c68 100644 --- a/packages/wrangler/src/api/startDevWorker/RemoteRuntimeController.ts +++ b/packages/wrangler/src/api/startDevWorker/RemoteRuntimeController.ts @@ -360,6 +360,52 @@ export class RemoteRuntimeController extends RuntimeController { }, interval); } + async #refreshPreviewToken() { + if (!this.#latestConfig || !this.#latestBundle) { + logger.warn( + "Cannot refresh preview token: missing config or bundle data" + ); + return; + } + + try { + assert(this.#latestConfig.dev.auth); + const auth = await unwrapHook(this.#latestConfig.dev.auth); + + this.#session = await this.#getPreviewSession( + this.#latestConfig, + auth, + this.#latestRoutes + ); + + const refreshed = await this.#updatePreviewToken( + this.#latestConfig, + this.#latestBundle, + auth, + this.#latestRoutes, + this.#currentBundleId + ); + + if (!refreshed) { + throw new Error("Failed to refresh preview token"); + } + + logger.log(chalk.green("✔ Preview token refreshed successfully")); + } catch (error) { + if (error instanceof Error && error.name == "AbortError") { + return; + } + + this.emitErrorEvent({ + type: "error", + reason: "Error refreshing preview token", + cause: castErrorCause(error), + source: "RemoteRuntimeController", + data: undefined, + }); + } + } + async #onBundleComplete({ config, bundle }: BundleCompleteEvent, id: number) { logger.log(chalk.dim("⎔ Starting remote preview...")); @@ -433,51 +479,7 @@ export class RemoteRuntimeController extends RuntimeController { } onPreviewTokenExpired(_ev: PreviewTokenExpiredEvent): void { logger.log(chalk.dim("⎔ Refreshing preview token...")); - void this.#mutex.runWith(async () => { - if (!this.#latestConfig || !this.#latestBundle) { - logger.warn( - "Cannot refresh preview token: missing config or bundle data" - ); - return; - } - - try { - assert(this.#latestConfig.dev.auth); - const auth = await unwrapHook(this.#latestConfig.dev.auth); - - this.#session = await this.#getPreviewSession( - this.#latestConfig, - auth, - this.#latestRoutes - ); - - const refreshed = await this.#updatePreviewToken( - this.#latestConfig, - this.#latestBundle, - auth, - this.#latestRoutes, - this.#currentBundleId - ); - - if (!refreshed) { - throw new Error("Failed to refresh preview token"); - } - - logger.log(chalk.green("✔ Preview token refreshed successfully")); - } catch (error) { - if (error instanceof Error && error.name == "AbortError") { - return; - } - - this.emitErrorEvent({ - type: "error", - reason: "Error refreshing preview token", - cause: castErrorCause(error), - source: "RemoteRuntimeController", - data: undefined, - }); - } - }); + void this.#mutex.runWith(() => this.#refreshPreviewToken()); } override async teardown() { From 3c190c1df88a4126fa4d1ea24f4180a2a14ae0dd Mon Sep 17 00:00:00 2001 From: Samuel Macleod Date: Mon, 30 Mar 2026 18:02:58 +0100 Subject: [PATCH 08/17] move #refreshPreviewToken after onPreviewTokenExpired --- .../startDevWorker/RemoteRuntimeController.ts | 92 +++++++++---------- 1 file changed, 46 insertions(+), 46 deletions(-) diff --git a/packages/wrangler/src/api/startDevWorker/RemoteRuntimeController.ts b/packages/wrangler/src/api/startDevWorker/RemoteRuntimeController.ts index 8923c21c68..a7c65a6e42 100644 --- a/packages/wrangler/src/api/startDevWorker/RemoteRuntimeController.ts +++ b/packages/wrangler/src/api/startDevWorker/RemoteRuntimeController.ts @@ -360,52 +360,6 @@ export class RemoteRuntimeController extends RuntimeController { }, interval); } - async #refreshPreviewToken() { - if (!this.#latestConfig || !this.#latestBundle) { - logger.warn( - "Cannot refresh preview token: missing config or bundle data" - ); - return; - } - - try { - assert(this.#latestConfig.dev.auth); - const auth = await unwrapHook(this.#latestConfig.dev.auth); - - this.#session = await this.#getPreviewSession( - this.#latestConfig, - auth, - this.#latestRoutes - ); - - const refreshed = await this.#updatePreviewToken( - this.#latestConfig, - this.#latestBundle, - auth, - this.#latestRoutes, - this.#currentBundleId - ); - - if (!refreshed) { - throw new Error("Failed to refresh preview token"); - } - - logger.log(chalk.green("✔ Preview token refreshed successfully")); - } catch (error) { - if (error instanceof Error && error.name == "AbortError") { - return; - } - - this.emitErrorEvent({ - type: "error", - reason: "Error refreshing preview token", - cause: castErrorCause(error), - source: "RemoteRuntimeController", - data: undefined, - }); - } - } - async #onBundleComplete({ config, bundle }: BundleCompleteEvent, id: number) { logger.log(chalk.dim("⎔ Starting remote preview...")); @@ -482,6 +436,52 @@ export class RemoteRuntimeController extends RuntimeController { void this.#mutex.runWith(() => this.#refreshPreviewToken()); } + async #refreshPreviewToken() { + if (!this.#latestConfig || !this.#latestBundle) { + logger.warn( + "Cannot refresh preview token: missing config or bundle data" + ); + return; + } + + try { + assert(this.#latestConfig.dev.auth); + const auth = await unwrapHook(this.#latestConfig.dev.auth); + + this.#session = await this.#getPreviewSession( + this.#latestConfig, + auth, + this.#latestRoutes + ); + + const refreshed = await this.#updatePreviewToken( + this.#latestConfig, + this.#latestBundle, + auth, + this.#latestRoutes, + this.#currentBundleId + ); + + if (!refreshed) { + throw new Error("Failed to refresh preview token"); + } + + logger.log(chalk.green("✔ Preview token refreshed successfully")); + } catch (error) { + if (error instanceof Error && error.name == "AbortError") { + return; + } + + this.emitErrorEvent({ + type: "error", + reason: "Error refreshing preview token", + cause: castErrorCause(error), + source: "RemoteRuntimeController", + data: undefined, + }); + } + } + override async teardown() { await super.teardown(); if (this.#session) { From 61b5a9e8fc3a369da1d205f0c178000b832fb02a Mon Sep 17 00:00:00 2001 From: Samuel Macleod Date: Mon, 30 Mar 2026 18:05:52 +0100 Subject: [PATCH 09/17] move #refreshPreviewToken above event handlers --- .../startDevWorker/RemoteRuntimeController.ts | 92 +++++++++---------- 1 file changed, 46 insertions(+), 46 deletions(-) diff --git a/packages/wrangler/src/api/startDevWorker/RemoteRuntimeController.ts b/packages/wrangler/src/api/startDevWorker/RemoteRuntimeController.ts index a7c65a6e42..8923c21c68 100644 --- a/packages/wrangler/src/api/startDevWorker/RemoteRuntimeController.ts +++ b/packages/wrangler/src/api/startDevWorker/RemoteRuntimeController.ts @@ -360,6 +360,52 @@ export class RemoteRuntimeController extends RuntimeController { }, interval); } + async #refreshPreviewToken() { + if (!this.#latestConfig || !this.#latestBundle) { + logger.warn( + "Cannot refresh preview token: missing config or bundle data" + ); + return; + } + + try { + assert(this.#latestConfig.dev.auth); + const auth = await unwrapHook(this.#latestConfig.dev.auth); + + this.#session = await this.#getPreviewSession( + this.#latestConfig, + auth, + this.#latestRoutes + ); + + const refreshed = await this.#updatePreviewToken( + this.#latestConfig, + this.#latestBundle, + auth, + this.#latestRoutes, + this.#currentBundleId + ); + + if (!refreshed) { + throw new Error("Failed to refresh preview token"); + } + + logger.log(chalk.green("✔ Preview token refreshed successfully")); + } catch (error) { + if (error instanceof Error && error.name == "AbortError") { + return; + } + + this.emitErrorEvent({ + type: "error", + reason: "Error refreshing preview token", + cause: castErrorCause(error), + source: "RemoteRuntimeController", + data: undefined, + }); + } + } + async #onBundleComplete({ config, bundle }: BundleCompleteEvent, id: number) { logger.log(chalk.dim("⎔ Starting remote preview...")); @@ -436,52 +482,6 @@ export class RemoteRuntimeController extends RuntimeController { void this.#mutex.runWith(() => this.#refreshPreviewToken()); } - async #refreshPreviewToken() { - if (!this.#latestConfig || !this.#latestBundle) { - logger.warn( - "Cannot refresh preview token: missing config or bundle data" - ); - return; - } - - try { - assert(this.#latestConfig.dev.auth); - const auth = await unwrapHook(this.#latestConfig.dev.auth); - - this.#session = await this.#getPreviewSession( - this.#latestConfig, - auth, - this.#latestRoutes - ); - - const refreshed = await this.#updatePreviewToken( - this.#latestConfig, - this.#latestBundle, - auth, - this.#latestRoutes, - this.#currentBundleId - ); - - if (!refreshed) { - throw new Error("Failed to refresh preview token"); - } - - logger.log(chalk.green("✔ Preview token refreshed successfully")); - } catch (error) { - if (error instanceof Error && error.name == "AbortError") { - return; - } - - this.emitErrorEvent({ - type: "error", - reason: "Error refreshing preview token", - cause: castErrorCause(error), - source: "RemoteRuntimeController", - data: undefined, - }); - } - } - override async teardown() { await super.teardown(); if (this.#session) { From e99385f3f8215fa16c8c77fad9fa9e1a52dfd8a4 Mon Sep 17 00:00:00 2001 From: Samuel Macleod Date: Mon, 30 Mar 2026 18:07:21 +0100 Subject: [PATCH 10/17] move --- .../startDevWorker/RemoteRuntimeController.ts | 95 ++++++++++--------- 1 file changed, 48 insertions(+), 47 deletions(-) diff --git a/packages/wrangler/src/api/startDevWorker/RemoteRuntimeController.ts b/packages/wrangler/src/api/startDevWorker/RemoteRuntimeController.ts index 8923c21c68..ef380bd71a 100644 --- a/packages/wrangler/src/api/startDevWorker/RemoteRuntimeController.ts +++ b/packages/wrangler/src/api/startDevWorker/RemoteRuntimeController.ts @@ -64,8 +64,9 @@ export class RemoteRuntimeController extends RuntimeController { } ): Promise { try { - const { workerAccount, workerContext } = - await getWorkerAccountAndContext(props); + const { workerAccount, workerContext } = await getWorkerAccountAndContext( + props + ); return await retryOnAPIFailure( () => @@ -298,7 +299,7 @@ export class RemoteRuntimeController extends RuntimeController { assetDirectory: "", excludePatterns: config.legacy?.site?.exclude ?? [], includePatterns: config.legacy?.site?.include ?? [], - } + } : undefined, format: bundle.entry.format, bindings: config.bindings, @@ -360,6 +361,50 @@ export class RemoteRuntimeController extends RuntimeController { }, interval); } + async #onBundleComplete({ config, bundle }: BundleCompleteEvent, id: number) { + logger.log(chalk.dim("⎔ Starting remote preview...")); + + try { + const routes = this.#extractRoutes(config); + + if (!config.dev?.auth) { + throw new MissingConfigError("config.dev.auth"); + } + + assert(config.dev.auth); + const auth = await unwrapHook(config.dev.auth); + + this.#latestConfig = config; + this.#latestBundle = bundle; + this.#latestRoutes = routes; + + if (this.#session) { + logger.log(chalk.dim("⎔ Detected changes, restarted server.")); + } + + // Recreate session if the worker name changed, since the session + // host bakes in the name from creation time. + if (this.#session && config.name !== this.#session.name) { + this.#session = undefined; + } + + this.#session ??= await this.#getPreviewSession(config, auth, routes); + await this.#updatePreviewToken(config, bundle, auth, routes, id); + } catch (error) { + if (error instanceof Error && error.name == "AbortError") { + return; + } + + this.emitErrorEvent({ + type: "error", + reason: "Error reloading remote server", + cause: castErrorCause(error), + source: "RemoteRuntimeController", + data: undefined, + }); + } + } + async #refreshPreviewToken() { if (!this.#latestConfig || !this.#latestBundle) { logger.warn( @@ -406,50 +451,6 @@ export class RemoteRuntimeController extends RuntimeController { } } - async #onBundleComplete({ config, bundle }: BundleCompleteEvent, id: number) { - logger.log(chalk.dim("⎔ Starting remote preview...")); - - try { - const routes = this.#extractRoutes(config); - - if (!config.dev?.auth) { - throw new MissingConfigError("config.dev.auth"); - } - - assert(config.dev.auth); - const auth = await unwrapHook(config.dev.auth); - - this.#latestConfig = config; - this.#latestBundle = bundle; - this.#latestRoutes = routes; - - if (this.#session) { - logger.log(chalk.dim("⎔ Detected changes, restarted server.")); - } - - // Recreate session if the worker name changed, since the session - // host bakes in the name from creation time. - if (this.#session && config.name !== this.#session.name) { - this.#session = undefined; - } - - this.#session ??= await this.#getPreviewSession(config, auth, routes); - await this.#updatePreviewToken(config, bundle, auth, routes, id); - } catch (error) { - if (error instanceof Error && error.name == "AbortError") { - return; - } - - this.emitErrorEvent({ - type: "error", - reason: "Error reloading remote server", - cause: castErrorCause(error), - source: "RemoteRuntimeController", - data: undefined, - }); - } - } - // ****************** // Event Handlers // ****************** From 442c620517b5a000712c794fe0594a59715485b4 Mon Sep 17 00:00:00 2001 From: Samuel Macleod Date: Wed, 1 Apr 2026 14:21:27 +0100 Subject: [PATCH 11/17] use UserError, restore reloadStart in #refreshPreviewToken --- .../src/api/startDevWorker/RemoteRuntimeController.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/wrangler/src/api/startDevWorker/RemoteRuntimeController.ts b/packages/wrangler/src/api/startDevWorker/RemoteRuntimeController.ts index ef380bd71a..c3875396f8 100644 --- a/packages/wrangler/src/api/startDevWorker/RemoteRuntimeController.ts +++ b/packages/wrangler/src/api/startDevWorker/RemoteRuntimeController.ts @@ -1,5 +1,5 @@ import assert from "node:assert"; -import { MissingConfigError } from "@cloudflare/workers-utils"; +import { MissingConfigError, UserError } from "@cloudflare/workers-utils"; import chalk from "chalk"; import { Mutex } from "miniflare"; import { WebSocket } from "ws"; @@ -413,6 +413,12 @@ export class RemoteRuntimeController extends RuntimeController { return; } + this.emitReloadStartEvent({ + type: "reloadStart", + config: this.#latestConfig, + bundle: this.#latestBundle, + }); + try { assert(this.#latestConfig.dev.auth); const auth = await unwrapHook(this.#latestConfig.dev.auth); @@ -432,7 +438,7 @@ export class RemoteRuntimeController extends RuntimeController { ); if (!refreshed) { - throw new Error("Failed to refresh preview token"); + throw new UserError("Failed to refresh preview token"); } logger.log(chalk.green("✔ Preview token refreshed successfully")); From 993dc158d506ae860ad5d8e11edf9946140df6df Mon Sep 17 00:00:00 2001 From: Samuel Macleod Date: Wed, 1 Apr 2026 14:34:18 +0100 Subject: [PATCH 12/17] prettify --- .changeset/fix-sourcemap-comment-syntax-error.md | 6 +----- .../src/api/startDevWorker/RemoteRuntimeController.ts | 7 +++---- 2 files changed, 4 insertions(+), 9 deletions(-) diff --git a/.changeset/fix-sourcemap-comment-syntax-error.md b/.changeset/fix-sourcemap-comment-syntax-error.md index aa432ac3ed..69c7a2c6ff 100644 --- a/.changeset/fix-sourcemap-comment-syntax-error.md +++ b/.changeset/fix-sourcemap-comment-syntax-error.md @@ -4,8 +4,4 @@ Fix SyntaxError when SSR-transformed module ends with a single-line comment -When module code ends with a `//` comment (e.g. `//# sourceMappingURL=...` -preserved by vite-plus), the closing `}` of the async wrapper in -`runInlinedModule` was absorbed into the comment, causing -`SyntaxError: Unexpected end of input`. Adding a newline before the -closing brace prevents this. +When module code ends with a `//` comment (e.g. `//# sourceMappingURL=...` preserved by vite-plus), the closing `}` of the async wrapper in `runInlinedModule` was absorbed into the comment, causing `SyntaxError: Unexpected end of input`. Adding a newline before the closing brace prevents this. diff --git a/packages/wrangler/src/api/startDevWorker/RemoteRuntimeController.ts b/packages/wrangler/src/api/startDevWorker/RemoteRuntimeController.ts index c3875396f8..c19aa825f3 100644 --- a/packages/wrangler/src/api/startDevWorker/RemoteRuntimeController.ts +++ b/packages/wrangler/src/api/startDevWorker/RemoteRuntimeController.ts @@ -64,9 +64,8 @@ export class RemoteRuntimeController extends RuntimeController { } ): Promise { try { - const { workerAccount, workerContext } = await getWorkerAccountAndContext( - props - ); + const { workerAccount, workerContext } = + await getWorkerAccountAndContext(props); return await retryOnAPIFailure( () => @@ -299,7 +298,7 @@ export class RemoteRuntimeController extends RuntimeController { assetDirectory: "", excludePatterns: config.legacy?.site?.exclude ?? [], includePatterns: config.legacy?.site?.include ?? [], - } + } : undefined, format: bundle.entry.format, bindings: config.bindings, From 6b6996cc6adc40f32ecac1b00e3d0d61b3bf0550 Mon Sep 17 00:00:00 2001 From: Samuel Macleod Date: Thu, 2 Apr 2026 11:46:51 +0100 Subject: [PATCH 13/17] address review: remove reloadStart from proactive refresh, fix expect imports --- .../RemoteRuntimeController.test.ts | 7 ++++--- .../startDevWorker/RemoteRuntimeController.ts | 16 +++++----------- .../wrangler/src/api/startDevWorker/utils.ts | 1 + 3 files changed, 10 insertions(+), 14 deletions(-) diff --git a/packages/wrangler/src/__tests__/api/startDevWorker/RemoteRuntimeController.test.ts b/packages/wrangler/src/__tests__/api/startDevWorker/RemoteRuntimeController.test.ts index 493bbeb94c..1c2ccfa80d 100644 --- a/packages/wrangler/src/__tests__/api/startDevWorker/RemoteRuntimeController.test.ts +++ b/packages/wrangler/src/__tests__/api/startDevWorker/RemoteRuntimeController.test.ts @@ -157,7 +157,7 @@ describe("RemoteRuntimeController", () => { describe("proactive token refresh", () => { afterEach(() => vi.useRealTimers()); - it("should proactively refresh the token before expiry", async () => { + it("should proactively refresh the token before expiry", async ({ expect }) => { vi.useFakeTimers(); const { controller, bus } = setup(); @@ -197,7 +197,7 @@ describe("RemoteRuntimeController", () => { }); }); - it("should cancel the proactive refresh timer on bundle start", async () => { + it("should cancel the proactive refresh timer on bundle start", async ({ expect }) => { vi.useFakeTimers(); const { controller, bus } = setup(); @@ -222,7 +222,7 @@ describe("RemoteRuntimeController", () => { expect(createWorkerPreview).not.toHaveBeenCalled(); }); - it("should cancel the proactive refresh timer on teardown", async () => { + it("should cancel the proactive refresh timer on teardown", async ({ expect }) => { vi.useFakeTimers(); const { controller, bus } = setup(); @@ -240,6 +240,7 @@ describe("RemoteRuntimeController", () => { await vi.advanceTimersByTimeAsync(50 * 60 * 1000 + 1); expect(createWorkerPreview).not.toHaveBeenCalled(); }); + }); describe("preview token refresh", () => { diff --git a/packages/wrangler/src/api/startDevWorker/RemoteRuntimeController.ts b/packages/wrangler/src/api/startDevWorker/RemoteRuntimeController.ts index c19aa825f3..458cf27a37 100644 --- a/packages/wrangler/src/api/startDevWorker/RemoteRuntimeController.ts +++ b/packages/wrangler/src/api/startDevWorker/RemoteRuntimeController.ts @@ -64,8 +64,9 @@ export class RemoteRuntimeController extends RuntimeController { } ): Promise { try { - const { workerAccount, workerContext } = - await getWorkerAccountAndContext(props); + const { workerAccount, workerContext } = await getWorkerAccountAndContext( + props + ); return await retryOnAPIFailure( () => @@ -298,7 +299,7 @@ export class RemoteRuntimeController extends RuntimeController { assetDirectory: "", excludePatterns: config.legacy?.site?.exclude ?? [], includePatterns: config.legacy?.site?.include ?? [], - } + } : undefined, format: bundle.entry.format, bindings: config.bindings, @@ -412,12 +413,6 @@ export class RemoteRuntimeController extends RuntimeController { return; } - this.emitReloadStartEvent({ - type: "reloadStart", - config: this.#latestConfig, - bundle: this.#latestBundle, - }); - try { assert(this.#latestConfig.dev.auth); const auth = await unwrapHook(this.#latestConfig.dev.auth); @@ -464,7 +459,6 @@ export class RemoteRuntimeController extends RuntimeController { // Abort any previous operations when a new bundle is started this.#abortController.abort(); this.#abortController = new AbortController(); - // Cancel any pending proactive refresh — a new token will be issued as part of the bundle reload clearTimeout(this.#refreshTimer); } onBundleComplete(ev: BundleCompleteEvent) { @@ -483,7 +477,7 @@ export class RemoteRuntimeController extends RuntimeController { void this.#mutex.runWith(() => this.#onBundleComplete(ev, id)); } - onPreviewTokenExpired(_ev: PreviewTokenExpiredEvent): void { + onPreviewTokenExpired(_: PreviewTokenExpiredEvent): void { logger.log(chalk.dim("⎔ Refreshing preview token...")); void this.#mutex.runWith(() => this.#refreshPreviewToken()); } diff --git a/packages/wrangler/src/api/startDevWorker/utils.ts b/packages/wrangler/src/api/startDevWorker/utils.ts index 3238746dce..0218d28b34 100644 --- a/packages/wrangler/src/api/startDevWorker/utils.ts +++ b/packages/wrangler/src/api/startDevWorker/utils.ts @@ -25,6 +25,7 @@ export function getPreviewTokenRefreshInterval() { return 50 * 60 * 1000; } + export type MaybePromise = T | Promise; export type DeferredPromise = { promise: Promise; From fe9753f9881010b8bdb5b253c92601858c8c8a2e Mon Sep 17 00:00:00 2001 From: Samuel Macleod Date: Thu, 2 Apr 2026 11:54:04 +0100 Subject: [PATCH 14/17] prettify --- .../startDevWorker/RemoteRuntimeController.test.ts | 13 +++++++++---- .../api/startDevWorker/RemoteRuntimeController.ts | 7 +++---- packages/wrangler/src/api/startDevWorker/utils.ts | 1 - 3 files changed, 12 insertions(+), 9 deletions(-) diff --git a/packages/wrangler/src/__tests__/api/startDevWorker/RemoteRuntimeController.test.ts b/packages/wrangler/src/__tests__/api/startDevWorker/RemoteRuntimeController.test.ts index 1c2ccfa80d..3167ee2dec 100644 --- a/packages/wrangler/src/__tests__/api/startDevWorker/RemoteRuntimeController.test.ts +++ b/packages/wrangler/src/__tests__/api/startDevWorker/RemoteRuntimeController.test.ts @@ -157,7 +157,9 @@ describe("RemoteRuntimeController", () => { describe("proactive token refresh", () => { afterEach(() => vi.useRealTimers()); - it("should proactively refresh the token before expiry", async ({ expect }) => { + it("should proactively refresh the token before expiry", async ({ + expect, + }) => { vi.useFakeTimers(); const { controller, bus } = setup(); @@ -197,7 +199,9 @@ describe("RemoteRuntimeController", () => { }); }); - it("should cancel the proactive refresh timer on bundle start", async ({ expect }) => { + it("should cancel the proactive refresh timer on bundle start", async ({ + expect, + }) => { vi.useFakeTimers(); const { controller, bus } = setup(); @@ -222,7 +226,9 @@ describe("RemoteRuntimeController", () => { expect(createWorkerPreview).not.toHaveBeenCalled(); }); - it("should cancel the proactive refresh timer on teardown", async ({ expect }) => { + it("should cancel the proactive refresh timer on teardown", async ({ + expect, + }) => { vi.useFakeTimers(); const { controller, bus } = setup(); @@ -240,7 +246,6 @@ describe("RemoteRuntimeController", () => { await vi.advanceTimersByTimeAsync(50 * 60 * 1000 + 1); expect(createWorkerPreview).not.toHaveBeenCalled(); }); - }); describe("preview token refresh", () => { diff --git a/packages/wrangler/src/api/startDevWorker/RemoteRuntimeController.ts b/packages/wrangler/src/api/startDevWorker/RemoteRuntimeController.ts index 458cf27a37..77d9000e3a 100644 --- a/packages/wrangler/src/api/startDevWorker/RemoteRuntimeController.ts +++ b/packages/wrangler/src/api/startDevWorker/RemoteRuntimeController.ts @@ -64,9 +64,8 @@ export class RemoteRuntimeController extends RuntimeController { } ): Promise { try { - const { workerAccount, workerContext } = await getWorkerAccountAndContext( - props - ); + const { workerAccount, workerContext } = + await getWorkerAccountAndContext(props); return await retryOnAPIFailure( () => @@ -299,7 +298,7 @@ export class RemoteRuntimeController extends RuntimeController { assetDirectory: "", excludePatterns: config.legacy?.site?.exclude ?? [], includePatterns: config.legacy?.site?.include ?? [], - } + } : undefined, format: bundle.entry.format, bindings: config.bindings, diff --git a/packages/wrangler/src/api/startDevWorker/utils.ts b/packages/wrangler/src/api/startDevWorker/utils.ts index 0218d28b34..3238746dce 100644 --- a/packages/wrangler/src/api/startDevWorker/utils.ts +++ b/packages/wrangler/src/api/startDevWorker/utils.ts @@ -25,7 +25,6 @@ export function getPreviewTokenRefreshInterval() { return 50 * 60 * 1000; } - export type MaybePromise = T | Promise; export type DeferredPromise = { promise: Promise; From 1ba32549fc64b4d63ad20fd7eb321f76d43bbeb9 Mon Sep 17 00:00:00 2001 From: Samuel Macleod Date: Thu, 2 Apr 2026 13:56:23 +0100 Subject: [PATCH 15/17] remove duplicate error emission from #previewSession, don't throw on preempted refresh --- .../api/startDevWorker/RemoteRuntimeController.ts | 14 ++------------ 1 file changed, 2 insertions(+), 12 deletions(-) diff --git a/packages/wrangler/src/api/startDevWorker/RemoteRuntimeController.ts b/packages/wrangler/src/api/startDevWorker/RemoteRuntimeController.ts index 77d9000e3a..1fdc35f26d 100644 --- a/packages/wrangler/src/api/startDevWorker/RemoteRuntimeController.ts +++ b/packages/wrangler/src/api/startDevWorker/RemoteRuntimeController.ts @@ -87,14 +87,6 @@ export class RemoteRuntimeController extends RuntimeController { handlePreviewSessionCreationError(err, props.accountId); - this.emitErrorEvent({ - type: "error", - reason: "Failed to create a preview token", - cause: castErrorCause(err), - source: "RemoteRuntimeController", - data: undefined, - }); - throw err; } } @@ -430,11 +422,9 @@ export class RemoteRuntimeController extends RuntimeController { this.#currentBundleId ); - if (!refreshed) { - throw new UserError("Failed to refresh preview token"); + if (refreshed) { + logger.log(chalk.green("✔ Preview token refreshed successfully")); } - - logger.log(chalk.green("✔ Preview token refreshed successfully")); } catch (error) { if (error instanceof Error && error.name == "AbortError") { return; From b117f6c4030c29bfccda99e3079d5d5a043d00a8 Mon Sep 17 00:00:00 2001 From: "ask-bonk[bot]" Date: Tue, 7 Apr 2026 09:51:10 +0000 Subject: [PATCH 16/17] fix: remove unused UserError import --- .../wrangler/src/api/startDevWorker/RemoteRuntimeController.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/wrangler/src/api/startDevWorker/RemoteRuntimeController.ts b/packages/wrangler/src/api/startDevWorker/RemoteRuntimeController.ts index 1fdc35f26d..345097d69f 100644 --- a/packages/wrangler/src/api/startDevWorker/RemoteRuntimeController.ts +++ b/packages/wrangler/src/api/startDevWorker/RemoteRuntimeController.ts @@ -1,5 +1,5 @@ import assert from "node:assert"; -import { MissingConfigError, UserError } from "@cloudflare/workers-utils"; +import { MissingConfigError } from "@cloudflare/workers-utils"; import chalk from "chalk"; import { Mutex } from "miniflare"; import { WebSocket } from "ws"; From 4852073e9bccc35f037015e67504bfcb58e7af2e Mon Sep 17 00:00:00 2001 From: Samuel Macleod Date: Tue, 7 Apr 2026 16:32:18 +0100 Subject: [PATCH 17/17] convert getPreviewTokenRefreshInterval to PREVIEW_TOKEN_REFRESH_INTERVAL constant --- .../src/api/startDevWorker/RemoteRuntimeController.ts | 4 ++-- packages/wrangler/src/api/startDevWorker/utils.ts | 4 +--- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/packages/wrangler/src/api/startDevWorker/RemoteRuntimeController.ts b/packages/wrangler/src/api/startDevWorker/RemoteRuntimeController.ts index 345097d69f..32afcf750d 100644 --- a/packages/wrangler/src/api/startDevWorker/RemoteRuntimeController.ts +++ b/packages/wrangler/src/api/startDevWorker/RemoteRuntimeController.ts @@ -21,7 +21,7 @@ import { getAccessHeaders } from "../../user/access"; import { retryOnAPIFailure } from "../../utils/retry"; import { RuntimeController } from "./BaseController"; import { castErrorCause } from "./events"; -import { getPreviewTokenRefreshInterval, unwrapHook } from "./utils"; +import { PREVIEW_TOKEN_REFRESH_INTERVAL, unwrapHook } from "./utils"; import type { CfAccount, CfPreviewSession, @@ -336,7 +336,7 @@ export class RemoteRuntimeController extends RuntimeController { proxyData, }); - this.#scheduleRefresh(getPreviewTokenRefreshInterval()); + this.#scheduleRefresh(PREVIEW_TOKEN_REFRESH_INTERVAL); return true; } diff --git a/packages/wrangler/src/api/startDevWorker/utils.ts b/packages/wrangler/src/api/startDevWorker/utils.ts index eabf67770a..46f133e2e7 100644 --- a/packages/wrangler/src/api/startDevWorker/utils.ts +++ b/packages/wrangler/src/api/startDevWorker/utils.ts @@ -21,9 +21,7 @@ export function assertNever(_value: never) {} * * Preview tokens expire after 1 hour (hardcoded in the Workers control plane), so we retry after 50 mins. */ -export function getPreviewTokenRefreshInterval() { - return 50 * 60 * 1000; -} +export const PREVIEW_TOKEN_REFRESH_INTERVAL = 50 * 60 * 1000; export type MaybePromise = T | Promise; export type DeferredPromise = {