Skip to content

Commit bfdace0

Browse files
61465teemingc
andauthored
fix: restart the dev server when the wrangler config file changes (#17432)
## Summary Closes #17429. The Cloudflare adapter's Vite plugin currently reads the wrangler config once at `configureServer` time and never re-reads it. Edit `wrangler.toml`/`wrangler.jsonc` and the platform proxy keeps serving the stale config until the user manually restarts the dev server. This PR watches the resolved wrangler config path on the Vite dev server's watcher. When the file is edited, we call `server.restart()`. The existing teardown (`closeServer` → `dispose`) tears the shared platform proxy down, and `configureServer` → `setup` creates a fresh one — `getPlatformProxy(options)` re-reads the config file on each call, so the restart alone is enough to pick up the new config. ## What changed - **`packages/adapter-cloudflare/index.js`** — `setup` now accepts the Vite dev/preview server and, when it's a dev server with a `watcher`, resolves the wrangler config path via `unstable_readConfig({ config: options.configPath })` and installs a `change` listener that calls `server.restart()` when the resolved config file is touched. Preview servers have no `watcher`, so they skip the hookup. Resolution via `unstable_readConfig` means we honour the same resolution rules wrangler itself uses (`wrangler.toml`, `wrangler.jsonc`, `--config`, etc.). - **`packages/adapter-cloudflare/index.spec.js`** — two new tests: - `restarts the dev server when the wrangler config file changes` — mocks `unstable_readConfig` to return a resolved path, asserts `server.restart()` is called on the matching change and not on any other file. - `does not install a config watcher when wrangler cannot resolve a config path` — proves we're defensive about the `{ configPath: undefined }` case. ## Local verification ``` $ ./node_modules/.bin/vitest run index.spec.js Test Files 1 passed (1) Tests 8 passed (8) ``` 6 existing tests + 2 new, all green. Install was `pnpm install --filter @sveltejs/adapter-cloudflare` on Node 24 / pnpm 12. ## Notes - Builds directly on top of #17368 (@glw907, merged 2026-10-14) — the shared-proxy teardown that this restart relies on landed one minute before the author filed this issue. The two together give `wrangler config change → one proxy restart across both dev servers`. - The listener is attached per-server. If the SSR dev server triggers the restart, Vite tears both down (SvelteKit SSR + client share the proxy via `__sveltekit_cloudflare_platform_setup`), and the next `setup` runs with the fresh config. - The restart does not debounce — if an editor saves twice in a row, Vite's own `server.restart()` already queues them. ## Changeset Added patch changeset for `@sveltejs/adapter-cloudflare`. cc @teemingc — thanks for the sharp report with the Wrangler file:line references. If you'd rather drive this yourself I'm happy to close and move my test coverage into your PR. 🤖 Used Claude Code for the research pass and to cross-check the mock shapes against existing adapter-cloudflare tests; every assertion signed off by me. --------- Co-authored-by: Tee Ming <chewteeming01@gmail.com>
1 parent 242149e commit bfdace0

3 files changed

Lines changed: 142 additions & 5 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@sveltejs/adapter-cloudflare': patch
3+
---
4+
5+
fix: restart the platform proxy when the Wrangler config file changes

‎packages/adapter-cloudflare/index.js‎

Lines changed: 55 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
/** @import { Plugin } from 'vite' */
1+
/** @import { Plugin, PreviewServer, ViteDevServer } from 'vite' */
22
/** @import { GetPlatformProxyOptions } from 'wrangler' */
33

44
import fs from 'node:fs';
@@ -216,14 +216,37 @@ export default function (options = {}) {
216216
};
217217
}
218218

219+
/**
220+
* Sets a new platform proxy and disposes the old one if it exists
221+
* @param {GetPlatformProxyOptions} options
222+
*/
223+
async function reload_cloudflare_platform_proxy(options) {
224+
const previous = globalThis.__sveltekit_cloudflare_platform;
225+
const next = await getPlatformProxy(options);
226+
globalThis.__sveltekit_cloudflare_platform = next;
227+
if (globalThis.__sveltekit_cloudflare_platform_setup) {
228+
globalThis.__sveltekit_cloudflare_platform_setup.proxy = Promise.resolve(next);
229+
}
230+
Object.defineProperty(globalThis, 'caches', {
231+
value: next.caches,
232+
writable: true,
233+
enumerable: true,
234+
configurable: true
235+
});
236+
if (previous) {
237+
await previous.dispose();
238+
}
239+
}
240+
219241
/**
220242
* @param {GetPlatformProxyOptions} options
221243
* @param {string} stub_import
222244
* @returns {Plugin}
223245
*/
224246
function virtual_workers_module(options, stub_import) {
225-
// shared so that concurrent servers start one proxy and only the last close disposes it
226-
const setup = async () => {
247+
/** @param {ViteDevServer | PreviewServer} server */
248+
const setup = async (server) => {
249+
// shared so that concurrent servers start one proxy and only the last close disposes it
227250
const shared = (globalThis.__sveltekit_cloudflare_platform_setup ??= {
228251
servers: 0,
229252
proxy: getPlatformProxy(options)
@@ -241,6 +264,35 @@ function virtual_workers_module(options, stub_import) {
241264
enumerable: true,
242265
configurable: true
243266
});
267+
268+
// reinitialise the platform proxy when the wrangler config changes
269+
if ('watcher' in server) {
270+
const resolved = unstable_readConfig({ config: options.configPath });
271+
if (resolved.configPath) {
272+
const config_path = path.resolve(resolved.configPath);
273+
let reloading = false;
274+
275+
/** @param {string} file */
276+
const on_change = (file) => {
277+
if (reloading || path.resolve(file) !== config_path) return;
278+
reloading = true;
279+
reload_cloudflare_platform_proxy(options)
280+
.catch((/** @type {unknown} */ error) => {
281+
// A rejection must not become unhandled — it would crash the dev
282+
// server. Log the message and leave the previous proxy in place
283+
// (it is only disposed on the success path, so bindings keep working).
284+
server.config.logger.warn(
285+
`[adapter-cloudflare] failed to reload the Cloudflare platform proxy after a wrangler config change: ${error instanceof Error ? error.message : String(error)}`
286+
);
287+
})
288+
.finally(() => {
289+
reloading = false;
290+
});
291+
};
292+
server.watcher.add(config_path);
293+
server.watcher.on('change', on_change);
294+
}
295+
}
244296
} catch (error) {
245297
// let the next server retry
246298
if (globalThis.__sveltekit_cloudflare_platform_setup === shared) {

‎packages/adapter-cloudflare/index.spec.js‎

Lines changed: 82 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
1+
import path from 'node:path';
12
import { beforeEach, expect, test, vi } from 'vitest';
23
import { createServer } from 'vite';
3-
import { getPlatformProxy } from 'wrangler';
4+
import { getPlatformProxy, unstable_readConfig } from 'wrangler';
45
import adapter from './index.js';
56

67
const dispose = vi.fn(() => Promise.resolve());
78

89
vi.mock('wrangler', () => ({
910
getPlatformProxy: vi.fn(() => Promise.resolve({ caches: {}, dispose })),
10-
unstable_readConfig: vi.fn()
11+
unstable_readConfig: vi.fn(() => ({ configPath: undefined }))
1112
}));
1213

1314
beforeEach(() => {
@@ -109,6 +110,85 @@ test('retries the platform proxy after it fails to start', async () => {
109110
expect(globalThis.__sveltekit_cloudflare_platform_setup).toBeUndefined();
110111
});
111112

113+
test('swaps the shared platform proxy in place when wrangler config changes', async () => {
114+
const config_path = path.resolve('wrangler.toml');
115+
vi.mocked(unstable_readConfig).mockReturnValueOnce(
116+
/** @type {any} */ ({ configPath: config_path })
117+
);
118+
119+
const server = await create_server();
120+
expect(server.watcher.listeners('change').length).toBeGreaterThan(0);
121+
122+
const previous = globalThis.__sveltekit_cloudflare_platform;
123+
const previous_dispose = /** @type {any} */ (previous).dispose;
124+
const previous_proxy = globalThis.__sveltekit_cloudflare_platform_setup?.proxy;
125+
expect(previous).toBeDefined();
126+
127+
// Prime the mock so the SECOND getPlatformProxy call (the config-change reload)
128+
// returns a fresh proxy, distinguishable from the first.
129+
const next_dispose = vi.fn(() => Promise.resolve());
130+
const next_caches = /** @type {any} */ ({});
131+
vi.mocked(getPlatformProxy).mockResolvedValueOnce(
132+
/** @type {any} */ ({ caches: next_caches, dispose: next_dispose })
133+
);
134+
135+
server.watcher.emit('change', config_path);
136+
await vi.waitFor(() => {
137+
expect(globalThis.__sveltekit_cloudflare_platform).not.toBe(previous);
138+
});
139+
expect(previous_dispose).toHaveBeenCalledOnce();
140+
expect(globalThis.__sveltekit_cloudflare_platform_setup?.proxy).not.toBe(previous_proxy);
141+
142+
// Unrelated file changes do not swap or dispose again.
143+
previous_dispose.mockClear();
144+
next_dispose.mockClear();
145+
server.watcher.emit('change', path.resolve('src/routes/+page.svelte'));
146+
expect(previous_dispose).not.toHaveBeenCalled();
147+
expect(next_dispose).not.toHaveBeenCalled();
148+
149+
await server.close();
150+
expect(next_dispose).toHaveBeenCalledOnce();
151+
});
152+
153+
test('does not install a config watcher when wrangler cannot resolve a config path', async () => {
154+
// `unstable_readConfig` returns `{ configPath: undefined }` from the default mock
155+
const server = await create_server();
156+
157+
const starting_proxy = globalThis.__sveltekit_cloudflare_platform;
158+
server.watcher.emit('change', path.resolve('wrangler.toml'));
159+
// No swap even if a file-change event sneaks in — the handler was never wired.
160+
expect(globalThis.__sveltekit_cloudflare_platform).toBe(starting_proxy);
161+
162+
await server.close();
163+
});
164+
165+
test('swallows a rejection from the config-reload path and warns, keeping the previous proxy', async () => {
166+
const config_path = path.resolve('wrangler.toml');
167+
vi.mocked(unstable_readConfig).mockReturnValueOnce(
168+
/** @type {any} */ ({ configPath: config_path })
169+
);
170+
const server = await create_server();
171+
const previous = globalThis.__sveltekit_cloudflare_platform;
172+
const warn = vi.spyOn(server.config.logger, 'warn').mockImplementation(() => {});
173+
174+
// The SECOND getPlatformProxy call — the reload — rejects.
175+
vi.mocked(getPlatformProxy).mockRejectedValueOnce(new Error('nope'));
176+
177+
server.watcher.emit('change', config_path);
178+
// Wait for the microtask queue to drain so the catch handler runs.
179+
await vi.waitFor(() => {
180+
expect(warn).toHaveBeenCalledOnce();
181+
});
182+
const first_call = /** @type {any[]} */ (warn.mock.calls[0]);
183+
expect(first_call[0]).toMatch(/adapter-cloudflare.*wrangler config change.*nope/);
184+
// Previous proxy is still in place since the swap aborted before dispose.
185+
expect(globalThis.__sveltekit_cloudflare_platform).toBe(previous);
186+
expect(dispose).not.toHaveBeenCalled();
187+
188+
await server.close();
189+
expect(dispose).toHaveBeenCalledOnce();
190+
});
191+
112192
test('rejects concurrent servers sharing a failed platform proxy and allows a retry', async () => {
113193
const { promise, reject } = Promise.withResolvers();
114194
vi.mocked(getPlatformProxy).mockReturnValueOnce(promise);

0 commit comments

Comments
 (0)