Skip to content

Commit 6d5469e

Browse files
authored
Preserve Cloudflare miniflare instance across dev server config restarts (#16059)
* fix: preserve viteServer.restart wrapper chain for Cloudflare adapter * add changeset * fix: use Vite in-place restart for config changes to preserve Cloudflare miniflare instance * use vite.resolveConfig to get a proper ResolvedConfig instead of patching inlineConfig * fix watcher listener accumulation, null-check hot.send, move restartInFlight to finally, add tests * fix port drift on restart by passing current httpServer port to createVite * remove non-actionable CSP dev warning * merge main, fix restart tests to use static fixture dir
1 parent 604f939 commit 6d5469e

3 files changed

Lines changed: 159 additions & 94 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'astro': patch
3+
---
4+
5+
Fixes `Expected 'miniflare' to be defined` errors and 404 responses in dev mode when using the Cloudflare adapter and the config file changes. Instead of creating a brand new Vite server on config changes, Astro now performs a Vite in-place restart, allowing the Cloudflare adapter to reuse its existing miniflare instance across restarts.

packages/astro/src/core/dev/restart.ts

Lines changed: 87 additions & 92 deletions
Original file line numberDiff line numberDiff line change
@@ -4,35 +4,21 @@ import * as vite from 'vite';
44
import { globalContentLayer } from '../../content/instance.js';
55
import { attachContentServerListeners } from '../../content/server-listeners.js';
66
import { eventCliSession, telemetry } from '../../events/index.js';
7+
import { runHookConfigDone, runHookConfigSetup } from '../../integrations/hooks.js';
78
import { SETTINGS_FILE } from '../../preferences/constants.js';
9+
import { getPrerenderDefault } from '../../prerender/utils.js';
810
import type { AstroSettings } from '../../types/astro.js';
911
import type { AstroInlineConfig } from '../../types/public/config.js';
1012
import { createSettings, resolveConfig } from '../config/index.js';
11-
import { createNodeLogger } from '../logger/node.js';
13+
import { createVite } from '../create-vite.js';
1214
import { collectErrorMetadata } from '../errors/dev/utils.js';
1315
import { isAstroConfigZodError } from '../errors/errors.js';
1416
import { createSafeError } from '../errors/index.js';
17+
import { createNodeLogger } from '../logger/node.js';
1518
import { formatErrorMessage, warnIfCspWithShiki } from '../messages/runtime.js';
19+
import { createRoutesList } from '../routing/create-manifest.js';
1620
import type { Container } from './container.js';
17-
import { createContainer, startContainer } from './container.js';
18-
19-
async function createRestartedContainer(
20-
container: Container,
21-
settings: AstroSettings,
22-
): Promise<Container> {
23-
const { logger, fs, inlineConfig } = container;
24-
const newContainer = await createContainer({
25-
isRestart: true,
26-
logger: logger,
27-
settings,
28-
inlineConfig,
29-
fs,
30-
});
31-
32-
await startContainer(newContainer);
33-
34-
return newContainer;
35-
}
21+
import { createContainer } from './container.js';
3622

3723
const configRE = /.*astro.config.(?:mjs|mts|cjs|cts|js|ts)$/;
3824

@@ -45,25 +31,20 @@ function shouldRestartContainer(
4531
let shouldRestart = false;
4632
const normalizedChangedFile = vite.normalizePath(changedFile);
4733

48-
// If the config file changed, reload the config and restart the server.
4934
if (inlineConfig.configFile) {
5035
shouldRestart = vite.normalizePath(inlineConfig.configFile) === normalizedChangedFile;
51-
}
52-
// Otherwise, watch for any astro.config.* file changes in project root
53-
else {
36+
} else {
5437
shouldRestart = configRE.test(normalizedChangedFile);
5538
const settingsPath = vite.normalizePath(
5639
fileURLToPath(new URL(SETTINGS_FILE, settings.dotAstroDir)),
5740
);
5841
if (settingsPath.endsWith(normalizedChangedFile)) {
5942
shouldRestart = settings.preferences.ignoreNextPreferenceReload ? false : true;
60-
6143
settings.preferences.ignoreNextPreferenceReload = false;
6244
}
6345
}
6446

6547
if (!shouldRestart && settings.watchFiles.length > 0) {
66-
// If the config file didn't change, check if any of the watched files changed.
6748
shouldRestart = settings.watchFiles.some(
6849
(path) => vite.normalizePath(path) === vite.normalizePath(changedFile),
6950
);
@@ -72,46 +53,79 @@ function shouldRestartContainer(
7253
return shouldRestart;
7354
}
7455

75-
async function restartContainer(container: Container): Promise<Container | Error> {
76-
const { logger, close, settings: existingSettings } = container;
56+
/**
57+
* Restart the dev server in-place by reusing the existing Vite server instance.
58+
*
59+
* Instead of tearing down and recreating the entire container (which creates a
60+
* brand new Vite server), this function re-reads the Astro config, builds a new
61+
* Vite inline config with updated plugins, patches it onto the existing server,
62+
* then calls Vite's own native restart. Vite's restart does an in-place mutation
63+
* of the server object, keeping the same HTTP server / TCP socket alive and
64+
* passing `previousEnvironments` to plugins — allowing adapters like
65+
* `@cloudflare/vite-plugin` to reuse their miniflare instance rather than
66+
* disposing and recreating it.
67+
*/
68+
async function restartContainerInPlace(container: Container): Promise<AstroSettings | Error> {
69+
const { logger, settings: existingSettings, inlineConfig, fs } = container;
7770
container.restartInFlight = true;
7871

7972
try {
80-
const { astroConfig } = await resolveConfig(container.inlineConfig, 'dev', container.fs);
81-
if (astroConfig.security.csp) {
82-
logger.warn(
83-
'config',
84-
"Astro's Content Security Policy (CSP) does not work in development mode. To verify your CSP implementation, build the project and run the preview server.",
85-
);
86-
}
73+
const { astroConfig } = await resolveConfig(inlineConfig, 'dev', fs);
8774
warnIfCspWithShiki(astroConfig, logger);
88-
const settings = await createSettings(
75+
let settings = await createSettings(
8976
astroConfig,
90-
container.inlineConfig.logLevel,
77+
inlineConfig.logLevel,
9178
fileURLToPath(existingSettings.config.root),
9279
);
93-
await close();
94-
return await createRestartedContainer(container, settings);
80+
81+
settings = await runHookConfigSetup({ settings, command: 'dev', logger, isRestart: true });
82+
if (!settings.adapter?.adapterFeatures?.buildOutput) {
83+
settings.buildOutput = getPrerenderDefault(settings.config) ? 'static' : 'server';
84+
}
85+
await runHookConfigDone({ settings, logger, command: 'dev' });
86+
87+
const mode = inlineConfig?.mode ?? 'development';
88+
const {
89+
server: { host, headers, allowedHosts },
90+
} = settings.config;
91+
const rendererClientEntries = settings.renderers
92+
.map((r) => r.clientEntrypoint)
93+
.filter(Boolean) as string[];
94+
const routesList = await createRoutesList({ settings, fsMod: fs }, logger, { dev: true });
95+
const address = container.viteServer.httpServer?.address();
96+
const port = address !== null && typeof address === 'object' ? address.port : undefined;
97+
const newViteConfig = await createVite(
98+
{
99+
server: { host, headers, allowedHosts, port },
100+
optimizeDeps: { include: rendererClientEntries },
101+
},
102+
{ settings, logger, mode, command: 'dev', fs, sync: false, routesList },
103+
);
104+
105+
// Resolve the new inline config into a full ResolvedConfig and assign it
106+
// onto the existing server so Vite's restartServer() uses the new plugins.
107+
container.viteServer.config = await vite.resolveConfig(newViteConfig, 'serve');
108+
109+
await container.viteServer.restart();
110+
111+
container.settings = settings;
112+
return settings;
95113
} catch (_err) {
96114
const error = createSafeError(_err);
97-
// Print all error messages except ZodErrors from AstroConfig as the pre-logged error is sufficient
98115
if (!isAstroConfigZodError(_err)) {
99116
logger.error(
100117
'config',
101118
formatErrorMessage(collectErrorMetadata(error), logger.level() === 'debug') + '\n',
102119
);
103120
}
104-
// Inform connected clients of the config error
105-
container.viteServer.environments.client.hot.send({
121+
container.viteServer.environments?.client?.hot?.send({
106122
type: 'error',
107-
err: {
108-
message: error.message,
109-
stack: error.stack || '',
110-
},
123+
err: { message: error.message, stack: error.stack || '' },
111124
});
112-
container.restartInFlight = false;
113125
logger.error(null, 'Continuing with previous valid configuration\n');
114126
return error;
127+
} finally {
128+
container.restartInFlight = false;
115129
}
116130
}
117131

@@ -132,12 +146,6 @@ export async function createContainerWithAutomaticRestart({
132146
}: CreateContainerWithAutomaticRestart): Promise<Restart> {
133147
const logger = createNodeLogger(inlineConfig ?? {});
134148
const { userConfig, astroConfig } = await resolveConfig(inlineConfig ?? {}, 'dev', fs);
135-
if (astroConfig.security.csp) {
136-
logger.warn(
137-
'config',
138-
"Astro's Content Security Policy (CSP) does not work in development mode. To verify your CSP implementation, build the project and run the preview server.",
139-
);
140-
}
141149
warnIfCspWithShiki(astroConfig, logger);
142150
telemetry.record(eventCliSession('dev', userConfig));
143151

@@ -163,7 +171,6 @@ export async function createContainerWithAutomaticRestart({
163171
container: initialContainer,
164172
bindCLIShortcuts() {
165173
const customShortcuts: Array<vite.CLIShortcut> = [
166-
// Disable default Vite shortcuts that don't work well with Astro
167174
{ key: 'r', description: '' },
168175
{ key: 'u', description: '' },
169176
{ key: 'c', description: '' },
@@ -185,54 +192,42 @@ export async function createContainerWithAutomaticRestart({
185192
},
186193
};
187194

188-
async function handleServerRestart(logMsg = '', server?: vite.ViteDevServer) {
189-
logger.info(null, (logMsg + ' Restarting...').trim());
190-
const container = restart.container;
191-
const result = await restartContainer(container);
192-
if (result instanceof Error) {
193-
// Failed to restart, use existing container
194-
resolveRestart(result);
195-
} else {
196-
// Restart success. Add new watches because this is a new container with a new Vite server
197-
restart.container = result;
198-
setupContainer();
199-
await attachContentServerListeners(restart.container);
200-
201-
if (server) {
202-
// Vite expects the resolved URLs to be available
203-
server.resolvedUrls = result.viteServer.resolvedUrls;
204-
}
205-
206-
resolveRestart(null);
207-
}
208-
restartComplete = new Promise<Error | null>((resolve) => {
209-
resolveRestart = resolve;
210-
});
211-
}
212-
213195
function handleChangeRestart(logMsg: string) {
214196
return async function (changedFile: string) {
215197
if (shouldRestartContainer(restart.container, changedFile)) {
216-
handleServerRestart(logMsg);
198+
logger.info(null, (logMsg + ' Restarting...').trim());
199+
const result = await restartContainerInPlace(restart.container);
200+
if (result instanceof Error) {
201+
resolveRestart(result);
202+
} else {
203+
setupContainer();
204+
await attachContentServerListeners(restart.container);
205+
resolveRestart(null);
206+
}
207+
restartComplete = new Promise<Error | null>((resolve) => {
208+
resolveRestart = resolve;
209+
});
217210
}
218211
};
219212
}
220213

221-
// Set up watchers, vite restart API, and shortcuts
214+
let changeHandler: (file: string) => void;
215+
let unlinkHandler: (file: string) => void;
216+
let addHandler: (file: string) => void;
217+
222218
function setupContainer() {
223219
const watcher = restart.container.viteServer.watcher;
224-
watcher.on('change', handleChangeRestart('Configuration file updated.'));
225-
watcher.on('unlink', handleChangeRestart('Configuration file removed.'));
226-
watcher.on('add', handleChangeRestart('Configuration file added.'));
227-
228-
// Restart the Astro dev server instead of Vite's when the API is called by plugins.
229-
// Ignore the `forceOptimize` parameter for now.
230-
restart.container.viteServer.restart = async () => {
231-
if (!restart.container.restartInFlight) {
232-
await handleServerRestart('', restart.container.viteServer);
233-
}
234-
};
220+
if (changeHandler) watcher.off('change', changeHandler);
221+
if (unlinkHandler) watcher.off('unlink', unlinkHandler);
222+
if (addHandler) watcher.off('add', addHandler);
223+
changeHandler = handleChangeRestart('Configuration file updated.');
224+
unlinkHandler = handleChangeRestart('Configuration file removed.');
225+
addHandler = handleChangeRestart('Configuration file added.');
226+
watcher.on('change', changeHandler);
227+
watcher.on('unlink', unlinkHandler);
228+
watcher.on('add', addHandler);
235229
}
230+
236231
setupContainer();
237232
return restart;
238233
}

packages/astro/test/units/dev/restart.test.js

Lines changed: 67 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -172,9 +172,10 @@ describe('dev container restarts', { timeout: 20000 }, () => {
172172
assert.equal(isStarted(restart.container), true);
173173

174174
try {
175-
let restartComplete = restart.restarted();
175+
// viteServer.restart() is now handled natively by Vite — just verify
176+
// it completes without error and the server is still running.
176177
await restart.container.viteServer.restart();
177-
await restartComplete;
178+
assert.equal(isStarted(restart.container), true);
178179
} finally {
179180
await restart.container.close();
180181
}
@@ -203,4 +204,68 @@ describe('dev container restarts', { timeout: 20000 }, () => {
203204
await restart.container.close();
204205
}
205206
});
207+
208+
it('Reuses the same viteServer instance on config file change', async () => {
209+
cleanupFile('astro.config.mjs');
210+
fs.writeFileSync(path.join(fixtureDir, 'astro.config.mjs'), '');
211+
212+
const restart = await createContainerWithAutomaticRestart({
213+
inlineConfig: { ...defaultInlineConfig, root: fixtureDir },
214+
});
215+
await startContainer(restart.container);
216+
217+
const originalViteServer = restart.container.viteServer;
218+
219+
try {
220+
let restartComplete = restart.restarted();
221+
fs.writeFileSync(path.join(fixtureDir, 'astro.config.mjs'), '');
222+
restart.container.viteServer.watcher.emit(
223+
'change',
224+
path.join(fixtureDir, 'astro.config.mjs').replace(/\\/g, '/'),
225+
);
226+
await restartComplete;
227+
228+
// The viteServer object should be the same instance — in-place restart
229+
assert.equal(restart.container.viteServer, originalViteServer);
230+
} finally {
231+
await restart.container.close();
232+
cleanupFile('astro.config.mjs');
233+
}
234+
});
235+
236+
it('Does not accumulate watcher listeners on repeated restarts', async () => {
237+
cleanupFile('astro.config.mjs');
238+
fs.writeFileSync(path.join(fixtureDir, 'astro.config.mjs'), '');
239+
240+
const restart = await createContainerWithAutomaticRestart({
241+
inlineConfig: { ...defaultInlineConfig, root: fixtureDir },
242+
});
243+
await startContainer(restart.container);
244+
245+
const watcher = restart.container.viteServer.watcher;
246+
247+
try {
248+
// Do a first restart to establish the post-restart listener count
249+
let restartComplete = restart.restarted();
250+
fs.writeFileSync(path.join(fixtureDir, 'astro.config.mjs'), '// restart 0');
251+
watcher.emit('change', path.join(fixtureDir, 'astro.config.mjs').replace(/\\/g, '/'));
252+
await restartComplete;
253+
254+
const listenerCountAfterFirst = watcher.listenerCount('change');
255+
256+
// Do two more restarts and verify the count stays stable
257+
for (let i = 1; i < 3; i++) {
258+
restartComplete = restart.restarted();
259+
fs.writeFileSync(path.join(fixtureDir, 'astro.config.mjs'), `// restart ${i}`);
260+
watcher.emit('change', path.join(fixtureDir, 'astro.config.mjs').replace(/\\/g, '/'));
261+
await restartComplete;
262+
}
263+
264+
// Listener count should be stable — old listeners removed before new ones added
265+
assert.equal(watcher.listenerCount('change'), listenerCountAfterFirst);
266+
} finally {
267+
await restart.container.close();
268+
cleanupFile('astro.config.mjs');
269+
}
270+
});
206271
});

0 commit comments

Comments
 (0)