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 {