Skip to content
Merged
5 changes: 5 additions & 0 deletions .changeset/fix-cloudflare-miniflare-restart.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'astro': patch
---

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.
179 changes: 87 additions & 92 deletions packages/astro/src/core/dev/restart.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,35 +4,21 @@ import * as vite from 'vite';
import { globalContentLayer } from '../../content/instance.js';
import { attachContentServerListeners } from '../../content/server-listeners.js';
import { eventCliSession, telemetry } from '../../events/index.js';
import { runHookConfigDone, runHookConfigSetup } from '../../integrations/hooks.js';
import { SETTINGS_FILE } from '../../preferences/constants.js';
import { getPrerenderDefault } from '../../prerender/utils.js';
import type { AstroSettings } from '../../types/astro.js';
import type { AstroInlineConfig } from '../../types/public/config.js';
import { createSettings, resolveConfig } from '../config/index.js';
import { createNodeLogger } from '../logger/node.js';
import { createVite } from '../create-vite.js';
import { collectErrorMetadata } from '../errors/dev/utils.js';
import { isAstroConfigZodError } from '../errors/errors.js';
import { createSafeError } from '../errors/index.js';
import { createNodeLogger } from '../logger/node.js';
import { formatErrorMessage, warnIfCspWithShiki } from '../messages/runtime.js';
import { createRoutesList } from '../routing/create-manifest.js';
import type { Container } from './container.js';
import { createContainer, startContainer } from './container.js';

async function createRestartedContainer(
container: Container,
settings: AstroSettings,
): Promise<Container> {
const { logger, fs, inlineConfig } = container;
const newContainer = await createContainer({
isRestart: true,
logger: logger,
settings,
inlineConfig,
fs,
});

await startContainer(newContainer);

return newContainer;
}
import { createContainer } from './container.js';

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

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

// If the config file changed, reload the config and restart the server.
if (inlineConfig.configFile) {
shouldRestart = vite.normalizePath(inlineConfig.configFile) === normalizedChangedFile;
}
// Otherwise, watch for any astro.config.* file changes in project root
else {
} else {
shouldRestart = configRE.test(normalizedChangedFile);
const settingsPath = vite.normalizePath(
fileURLToPath(new URL(SETTINGS_FILE, settings.dotAstroDir)),
);
if (settingsPath.endsWith(normalizedChangedFile)) {
shouldRestart = settings.preferences.ignoreNextPreferenceReload ? false : true;

settings.preferences.ignoreNextPreferenceReload = false;
}
}

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

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

try {
const { astroConfig } = await resolveConfig(container.inlineConfig, 'dev', container.fs);
if (astroConfig.security.csp) {
logger.warn(
'config',
"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.",
);
}
Comment on lines -81 to -86
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

seems like we lost this, not sure if we want to keep it or not.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I didn't do this intentionally, I thought it was duplicated of the Shiki one but can see that it's not. Doesn't this just always add a warning for all CSP users in dev? @ematipico Can we remove this? Better for docs I think?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not really. First, this isn't the only logging we have. There are two instances.
Second, the configuration gets re evaluated at the restart, hence we need to show the logging again.

We already document the limitations. I don't have strong opinions about the logging.

If you want to remove it, you must remove the other logging too

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Shiki logging? I think that one makes sense to keep because using Shiki with CSP isn't supported. The user can fix this by not using Shiki.

But in this case the user can't "fix" anything, it's just a warning that they shouldn't expect CSP to work. So there's nothing they can do to get rid of the warning (other than disabling CSP!)

Make sense?

const { astroConfig } = await resolveConfig(inlineConfig, 'dev', fs);
warnIfCspWithShiki(astroConfig, logger);
const settings = await createSettings(
let settings = await createSettings(
astroConfig,
container.inlineConfig.logLevel,
inlineConfig.logLevel,
fileURLToPath(existingSettings.config.root),
);
await close();
return await createRestartedContainer(container, settings);

settings = await runHookConfigSetup({ settings, command: 'dev', logger, isRestart: true });
if (!settings.adapter?.adapterFeatures?.buildOutput) {
settings.buildOutput = getPrerenderDefault(settings.config) ? 'static' : 'server';
}
await runHookConfigDone({ settings, logger, command: 'dev' });

const mode = inlineConfig?.mode ?? 'development';
const {
server: { host, headers, allowedHosts },
} = settings.config;
const rendererClientEntries = settings.renderers
.map((r) => r.clientEntrypoint)
.filter(Boolean) as string[];
const routesList = await createRoutesList({ settings, fsMod: fs }, logger, { dev: true });
const address = container.viteServer.httpServer?.address();
const port = address !== null && typeof address === 'object' ? address.port : undefined;
const newViteConfig = await createVite(
{
server: { host, headers, allowedHosts, port },
optimizeDeps: { include: rendererClientEntries },
},
{ settings, logger, mode, command: 'dev', fs, sync: false, routesList },
);

// Resolve the new inline config into a full ResolvedConfig and assign it
// onto the existing server so Vite's restartServer() uses the new plugins.
container.viteServer.config = await vite.resolveConfig(newViteConfig, 'serve');

await container.viteServer.restart();

container.settings = settings;
return settings;
} catch (_err) {
const error = createSafeError(_err);
// Print all error messages except ZodErrors from AstroConfig as the pre-logged error is sufficient
if (!isAstroConfigZodError(_err)) {
logger.error(
'config',
formatErrorMessage(collectErrorMetadata(error), logger.level() === 'debug') + '\n',
);
}
// Inform connected clients of the config error
container.viteServer.environments.client.hot.send({
container.viteServer.environments?.client?.hot?.send({
type: 'error',
err: {
message: error.message,
stack: error.stack || '',
},
err: { message: error.message, stack: error.stack || '' },
});
container.restartInFlight = false;
logger.error(null, 'Continuing with previous valid configuration\n');
return error;
} finally {
container.restartInFlight = false;
}
}

Expand All @@ -132,12 +146,6 @@ export async function createContainerWithAutomaticRestart({
}: CreateContainerWithAutomaticRestart): Promise<Restart> {
const logger = createNodeLogger(inlineConfig ?? {});
const { userConfig, astroConfig } = await resolveConfig(inlineConfig ?? {}, 'dev', fs);
if (astroConfig.security.csp) {
logger.warn(
'config',
"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.",
);
}
warnIfCspWithShiki(astroConfig, logger);
telemetry.record(eventCliSession('dev', userConfig));

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

async function handleServerRestart(logMsg = '', server?: vite.ViteDevServer) {
logger.info(null, (logMsg + ' Restarting...').trim());
const container = restart.container;
const result = await restartContainer(container);
if (result instanceof Error) {
// Failed to restart, use existing container
resolveRestart(result);
} else {
// Restart success. Add new watches because this is a new container with a new Vite server
restart.container = result;
setupContainer();
await attachContentServerListeners(restart.container);

if (server) {
// Vite expects the resolved URLs to be available
server.resolvedUrls = result.viteServer.resolvedUrls;
}

resolveRestart(null);
}
restartComplete = new Promise<Error | null>((resolve) => {
resolveRestart = resolve;
});
}

function handleChangeRestart(logMsg: string) {
return async function (changedFile: string) {
if (shouldRestartContainer(restart.container, changedFile)) {
handleServerRestart(logMsg);
logger.info(null, (logMsg + ' Restarting...').trim());
const result = await restartContainerInPlace(restart.container);
if (result instanceof Error) {
resolveRestart(result);
} else {
setupContainer();
await attachContentServerListeners(restart.container);
resolveRestart(null);
}
restartComplete = new Promise<Error | null>((resolve) => {
resolveRestart = resolve;
});
}
};
}

// Set up watchers, vite restart API, and shortcuts
let changeHandler: (file: string) => void;
let unlinkHandler: (file: string) => void;
let addHandler: (file: string) => void;

function setupContainer() {
const watcher = restart.container.viteServer.watcher;
watcher.on('change', handleChangeRestart('Configuration file updated.'));
watcher.on('unlink', handleChangeRestart('Configuration file removed.'));
watcher.on('add', handleChangeRestart('Configuration file added.'));

// Restart the Astro dev server instead of Vite's when the API is called by plugins.
// Ignore the `forceOptimize` parameter for now.
restart.container.viteServer.restart = async () => {
if (!restart.container.restartInFlight) {
await handleServerRestart('', restart.container.viteServer);
}
};
if (changeHandler) watcher.off('change', changeHandler);
if (unlinkHandler) watcher.off('unlink', unlinkHandler);
if (addHandler) watcher.off('add', addHandler);
changeHandler = handleChangeRestart('Configuration file updated.');
unlinkHandler = handleChangeRestart('Configuration file removed.');
addHandler = handleChangeRestart('Configuration file added.');
watcher.on('change', changeHandler);
watcher.on('unlink', unlinkHandler);
watcher.on('add', addHandler);
}

setupContainer();
return restart;
}
69 changes: 67 additions & 2 deletions packages/astro/test/units/dev/restart.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -172,9 +172,10 @@ describe('dev container restarts', { timeout: 20000 }, () => {
assert.equal(isStarted(restart.container), true);

try {
let restartComplete = restart.restarted();
// viteServer.restart() is now handled natively by Vite — just verify
// it completes without error and the server is still running.
await restart.container.viteServer.restart();
await restartComplete;
assert.equal(isStarted(restart.container), true);
} finally {
await restart.container.close();
}
Expand Down Expand Up @@ -203,4 +204,68 @@ describe('dev container restarts', { timeout: 20000 }, () => {
await restart.container.close();
}
});

it('Reuses the same viteServer instance on config file change', async () => {
cleanupFile('astro.config.mjs');
fs.writeFileSync(path.join(fixtureDir, 'astro.config.mjs'), '');

const restart = await createContainerWithAutomaticRestart({
inlineConfig: { ...defaultInlineConfig, root: fixtureDir },
});
await startContainer(restart.container);

const originalViteServer = restart.container.viteServer;

try {
let restartComplete = restart.restarted();
fs.writeFileSync(path.join(fixtureDir, 'astro.config.mjs'), '');
restart.container.viteServer.watcher.emit(
'change',
path.join(fixtureDir, 'astro.config.mjs').replace(/\\/g, '/'),
);
await restartComplete;

// The viteServer object should be the same instance — in-place restart
assert.equal(restart.container.viteServer, originalViteServer);
} finally {
await restart.container.close();
cleanupFile('astro.config.mjs');
}
});

it('Does not accumulate watcher listeners on repeated restarts', async () => {
cleanupFile('astro.config.mjs');
fs.writeFileSync(path.join(fixtureDir, 'astro.config.mjs'), '');

const restart = await createContainerWithAutomaticRestart({
inlineConfig: { ...defaultInlineConfig, root: fixtureDir },
});
await startContainer(restart.container);

const watcher = restart.container.viteServer.watcher;

try {
// Do a first restart to establish the post-restart listener count
let restartComplete = restart.restarted();
fs.writeFileSync(path.join(fixtureDir, 'astro.config.mjs'), '// restart 0');
watcher.emit('change', path.join(fixtureDir, 'astro.config.mjs').replace(/\\/g, '/'));
await restartComplete;

const listenerCountAfterFirst = watcher.listenerCount('change');

// Do two more restarts and verify the count stays stable
for (let i = 1; i < 3; i++) {
restartComplete = restart.restarted();
fs.writeFileSync(path.join(fixtureDir, 'astro.config.mjs'), `// restart ${i}`);
watcher.emit('change', path.join(fixtureDir, 'astro.config.mjs').replace(/\\/g, '/'));
await restartComplete;
}

// Listener count should be stable — old listeners removed before new ones added
assert.equal(watcher.listenerCount('change'), listenerCountAfterFirst);
} finally {
await restart.container.close();
cleanupFile('astro.config.mjs');
}
});
});
Loading