diff --git a/.changeset/moody-turkeys-agree.md b/.changeset/moody-turkeys-agree.md
new file mode 100644
index 000000000000..7be1ee8a96ed
--- /dev/null
+++ b/.changeset/moody-turkeys-agree.md
@@ -0,0 +1,5 @@
+---
+'astro': patch
+---
+
+Fixes an issue with the development server, where changes to the middleware weren't picked, and it required a full restart of the server.
diff --git a/packages/astro/e2e/fixtures/hmr/src/middleware.ts b/packages/astro/e2e/fixtures/hmr/src/middleware.ts
new file mode 100644
index 000000000000..f71e27917b98
--- /dev/null
+++ b/packages/astro/e2e/fixtures/hmr/src/middleware.ts
@@ -0,0 +1,9 @@
+import { defineMiddleware } from 'astro:middleware';
+
+export const onRequest = defineMiddleware((_context, next) => {
+ const response = next();
+ response.then((res) => {
+ res.headers.set('x-test-middleware', 'before');
+ });
+ return response;
+});
diff --git a/packages/astro/e2e/fixtures/hmr/src/pages/middleware-test.astro b/packages/astro/e2e/fixtures/hmr/src/pages/middleware-test.astro
new file mode 100644
index 000000000000..b38b3d737e7c
--- /dev/null
+++ b/packages/astro/e2e/fixtures/hmr/src/pages/middleware-test.astro
@@ -0,0 +1,8 @@
+
+
+ Middleware HMR Test
+
+
+ Middleware HMR
+
+
diff --git a/packages/astro/e2e/hmr.test.js b/packages/astro/e2e/hmr.test.js
index 3529f1214403..55ce0908941a 100644
--- a/packages/astro/e2e/hmr.test.js
+++ b/packages/astro/e2e/hmr.test.js
@@ -123,3 +123,23 @@ test.describe('Styles', () => {
await expect(h).toHaveCSS('color', 'rgb(0, 0, 0)');
});
});
+
+test.describe('Middleware', () => {
+ test('middleware changes are picked up without restart', async ({ astro }) => {
+ // Verify original middleware header
+ const res1 = await astro.fetch('/middleware-test');
+ expect(res1.headers.get('x-test-middleware')).toBe('before');
+
+ // Edit middleware to change the header value
+ await astro.editFile('./src/middleware.ts', (original) =>
+ original.replace("'before'", "'after'"),
+ );
+
+ // Wait briefly for HMR to propagate
+ await new Promise((resolve) => setTimeout(resolve, 1000));
+
+ // Verify the new header value is returned without server restart
+ const res2 = await astro.fetch('/middleware-test');
+ expect(res2.headers.get('x-test-middleware')).toBe('after');
+ });
+});
diff --git a/packages/astro/src/core/app/dev/app.ts b/packages/astro/src/core/app/dev/app.ts
index fa4d4118d55d..9c5b5734c083 100644
--- a/packages/astro/src/core/app/dev/app.ts
+++ b/packages/astro/src/core/app/dev/app.ts
@@ -36,6 +36,14 @@ export class DevApp extends BaseApp {
return true;
}
+ /**
+ * Clears the cached middleware so it is re-resolved on the next request.
+ * Called via HMR when middleware files change.
+ */
+ clearMiddleware(): void {
+ this.pipeline.clearMiddleware();
+ }
+
/**
* Updates the routes list when files change during development.
* Called via HMR when new pages are added/removed.
diff --git a/packages/astro/src/core/app/entrypoints/virtual/dev.ts b/packages/astro/src/core/app/entrypoints/virtual/dev.ts
index 01fdf63f8010..2ad43f34d71d 100644
--- a/packages/astro/src/core/app/entrypoints/virtual/dev.ts
+++ b/packages/astro/src/core/app/entrypoints/virtual/dev.ts
@@ -33,6 +33,13 @@ export const createApp: CreateApp = ({ streaming } = {}) => {
if (!currentDevApp) return;
currentDevApp.pipeline.routeCache.clearAll();
});
+
+ // Listen for middleware file changes via HMR.
+ // Clear the cached middleware so it is re-resolved on the next request.
+ import.meta.hot.on('astro:middleware-updated', () => {
+ if (!currentDevApp) return;
+ currentDevApp.clearMiddleware();
+ });
}
return currentDevApp;
diff --git a/packages/astro/src/core/base-pipeline.ts b/packages/astro/src/core/base-pipeline.ts
index b95ab98f1fcf..be941c2568ce 100644
--- a/packages/astro/src/core/base-pipeline.ts
+++ b/packages/astro/src/core/base-pipeline.ts
@@ -154,6 +154,14 @@ export abstract class Pipeline {
}
}
+ /**
+ * Clears the cached middleware so it is re-resolved on the next request.
+ * Called via HMR when middleware files change during development.
+ */
+ clearMiddleware() {
+ this.resolvedMiddleware = undefined;
+ }
+
async getActions(): Promise {
if (this.resolvedActions) {
return this.resolvedActions;
diff --git a/packages/astro/src/core/middleware/vite-plugin.ts b/packages/astro/src/core/middleware/vite-plugin.ts
index c19ef310cd8c..2de16d29232b 100644
--- a/packages/astro/src/core/middleware/vite-plugin.ts
+++ b/packages/astro/src/core/middleware/vite-plugin.ts
@@ -1,4 +1,9 @@
-import type { Plugin as VitePlugin } from 'vite';
+import { fileURLToPath } from 'node:url';
+import {
+ normalizePath as viteNormalizePath,
+ type ViteDevServer,
+ type Plugin as VitePlugin,
+} from 'vite';
import { getServerOutputDirectory } from '../../prerender/utils.js';
import type { AstroSettings } from '../../types/astro.js';
import { addRollupInput } from '../build/add-rollup-input.js';
@@ -21,6 +26,8 @@ export function vitePluginMiddleware({ settings }: { settings: AstroSettings }):
settings.middlewares.pre.length > 0 || settings.middlewares.post.length > 0;
let userMiddlewareIsPresent = false;
+ const normalizedSrcDir = viteNormalizePath(fileURLToPath(settings.config.srcDir));
+
return {
name: '@astro/plugin-middleware',
applyToEnvironment(environment) {
@@ -30,6 +37,31 @@ export function vitePluginMiddleware({ settings }: { settings: AstroSettings }):
environment.name === ASTRO_VITE_ENVIRONMENT_NAMES.prerender
);
},
+ configureServer(server: ViteDevServer) {
+ server.watcher.on('change', (path) => {
+ const normalizedPath = viteNormalizePath(path);
+ // Check if the changed file is a middleware file under srcDir
+ if (!normalizedPath.startsWith(normalizedSrcDir)) return;
+ const relativePath = normalizedPath.slice(normalizedSrcDir.length);
+ // Dot ensures we match "middleware.ts" but not e.g. "middleware-utils.ts"
+ if (!relativePath.startsWith(`${MIDDLEWARE_PATH_SEGMENT_NAME}.`)) return;
+
+ for (const name of [
+ ASTRO_VITE_ENVIRONMENT_NAMES.ssr,
+ ASTRO_VITE_ENVIRONMENT_NAMES.astro,
+ ] as const) {
+ const environment = server.environments[name];
+ if (!environment) continue;
+
+ const virtualMod = environment.moduleGraph.getModuleById(MIDDLEWARE_RESOLVED_MODULE_ID);
+ if (virtualMod) {
+ environment.moduleGraph.invalidateModule(virtualMod);
+ }
+
+ environment.hot.send('astro:middleware-updated', {});
+ }
+ });
+ },
resolveId: {
filter: {
id: new RegExp(`^${MIDDLEWARE_MODULE_ID}$`),
diff --git a/packages/astro/src/vite-plugin-app/app.ts b/packages/astro/src/vite-plugin-app/app.ts
index cbc7f82d9669..69c59646ed7b 100644
--- a/packages/astro/src/vite-plugin-app/app.ts
+++ b/packages/astro/src/vite-plugin-app/app.ts
@@ -70,6 +70,14 @@ export class AstroServerApp extends BaseApp {
this.pipeline.clearRouteCache();
}
+ /**
+ * Clears the cached middleware so it is re-resolved on the next request.
+ * Called via HMR when middleware files change.
+ */
+ clearMiddleware(): void {
+ this.pipeline.clearMiddleware();
+ }
+
async devMatch(pathname: string): Promise {
const matchedRoute = await matchRoute(
pathname,
diff --git a/packages/astro/src/vite-plugin-app/createAstroServerApp.ts b/packages/astro/src/vite-plugin-app/createAstroServerApp.ts
index 03127f2625a4..a471389d27fc 100644
--- a/packages/astro/src/vite-plugin-app/createAstroServerApp.ts
+++ b/packages/astro/src/vite-plugin-app/createAstroServerApp.ts
@@ -78,6 +78,13 @@ export default async function createAstroServerApp(
app.clearRouteCache();
actualLogger.debug('router', 'Route cache cleared due to content change');
});
+
+ // Listen for middleware file changes via HMR.
+ // Clear the cached middleware so it is re-resolved on the next request.
+ import.meta.hot.on('astro:middleware-updated', () => {
+ app.clearMiddleware();
+ actualLogger.debug('router', 'Middleware cache cleared due to file change');
+ });
}
return {