diff --git a/.changeset/khaki-glasses-raise.md b/.changeset/khaki-glasses-raise.md new file mode 100644 index 000000000000..4a0622a42452 --- /dev/null +++ b/.changeset/khaki-glasses-raise.md @@ -0,0 +1,48 @@ +--- +'astro': minor +--- + +## Integration Hooks to add Middleware + +It's now possible in Astro for an integration to add middleware on behalf of the user. Previously when a third party wanted to provide middleware, the user would need to create a `src/middleware.ts` file themselves. Now, adding third-party middleware is as easy as adding a new integration. + +For integration authors, there is a new `addMiddleware` function in the `astro:config:setup` hook. This function allows you to specify a middleware module and the order in which it should be applied: + +```js +// my-package/middleware.js +import { defineMiddleware } from 'astro:middleware'; + +export const onRequest = defineMiddleware(async (context, next) => { + const response = await next(); + + if(response.headers.get('content-type') === 'text/html') { + let html = await response.text(); + html = minify(html); + return new Response(html, { + status: response.status, + headers: response.headers + }); + } + + return response; +}); +``` + +You can now add your integration's middleware and specify that it runs either before or after the application's own defined middleware (defined in `src/middleware.{js,ts}`) + +```js +// my-package/integration.js +export function myIntegration() { + return { + name: 'my-integration', + hooks: { + 'astro:config:setup': ({ addMiddleware }) => { + addMiddleware({ + entrypoint: 'my-package/middleware', + order: 'pre' + }); + } + } + }; +} +``` diff --git a/packages/astro/src/@types/astro.ts b/packages/astro/src/@types/astro.ts index 47ed001f0523..e1bf2cd8bd89 100644 --- a/packages/astro/src/@types/astro.ts +++ b/packages/astro/src/@types/astro.ts @@ -1701,6 +1701,7 @@ export interface AstroSettings { */ clientDirectives: Map; devOverlayPlugins: string[]; + middlewares: { pre: string[]; post: string[]; }; tsConfig: TSConfig | undefined; tsConfigPath: string | undefined; watchFiles: string[]; @@ -2279,6 +2280,7 @@ export interface AstroIntegration { injectRoute: (injectRoute: InjectedRoute) => void; addClientDirective: (directive: ClientDirectiveConfig) => void; addDevOverlayPlugin: (entrypoint: string) => void; + addMiddleware: (mid: AstroIntegrationMiddleware) => void; logger: AstroIntegrationLogger; // TODO: Add support for `injectElement()` for full HTML element injection, not just scripts. // This may require some refactoring of `scripts`, `styles`, and `links` into something @@ -2349,6 +2351,11 @@ export type AstroMiddlewareInstance = { onRequest?: MiddlewareHandler; }; +export type AstroIntegrationMiddleware = { + order: 'pre' | 'post'; + entrypoint: string; +}; + export interface AstroPluginOptions { settings: AstroSettings; logger: Logger; diff --git a/packages/astro/src/core/build/plugins/plugin-middleware.ts b/packages/astro/src/core/build/plugins/plugin-middleware.ts index 628a1cb70bf9..7f3760612d6f 100644 --- a/packages/astro/src/core/build/plugins/plugin-middleware.ts +++ b/packages/astro/src/core/build/plugins/plugin-middleware.ts @@ -1,70 +1,8 @@ -import type { Plugin as VitePlugin } from 'vite'; -import { getOutputDirectory } from '../../../prerender/utils.js'; -import { MIDDLEWARE_PATH_SEGMENT_NAME } from '../../constants.js'; -import { addRollupInput } from '../add-rollup-input.js'; import type { BuildInternals } from '../internal.js'; import type { AstroBuildPlugin } from '../plugin.js'; import type { StaticBuildOptions } from '../types.js'; - -export const MIDDLEWARE_MODULE_ID = '@astro-middleware'; - -const EMPTY_MIDDLEWARE = '\0empty-middleware'; - -export function vitePluginMiddleware( - opts: StaticBuildOptions, - internals: BuildInternals -): VitePlugin { - let resolvedMiddlewareId: string; - return { - name: '@astro/plugin-middleware', - enforce: 'post', - options(options) { - return addRollupInput(options, [MIDDLEWARE_MODULE_ID]); - }, - - async resolveId(id) { - if (id === MIDDLEWARE_MODULE_ID) { - const middlewareId = await this.resolve( - `${decodeURI(opts.settings.config.srcDir.pathname)}${MIDDLEWARE_PATH_SEGMENT_NAME}` - ); - if (middlewareId) { - resolvedMiddlewareId = middlewareId.id; - return middlewareId.id; - } else { - return EMPTY_MIDDLEWARE; - } - } - if (id === EMPTY_MIDDLEWARE) { - return EMPTY_MIDDLEWARE; - } - }, - - load(id) { - if (id === EMPTY_MIDDLEWARE) { - return 'export const onRequest = undefined'; - } else if (id === resolvedMiddlewareId) { - this.emitFile({ - type: 'chunk', - preserveSignature: 'strict', - fileName: 'middleware.mjs', - id, - }); - } - }, - - writeBundle(_, bundle) { - for (const [chunkName, chunk] of Object.entries(bundle)) { - if (chunk.type === 'asset') { - continue; - } - if (chunk.fileName === 'middleware.mjs') { - const outputDirectory = getOutputDirectory(opts.settings.config); - internals.middlewareEntryPoint = new URL(chunkName, outputDirectory); - } - } - }, - }; -} +import { vitePluginMiddlewareBuild } from '../../middleware/vite-plugin.js'; +export { MIDDLEWARE_MODULE_ID } from '../../middleware/vite-plugin.js'; export function pluginMiddleware( opts: StaticBuildOptions, @@ -75,7 +13,7 @@ export function pluginMiddleware( hooks: { 'build:before': () => { return { - vitePlugin: vitePluginMiddleware(opts, internals), + vitePlugin: vitePluginMiddlewareBuild(opts, internals), }; }, }, diff --git a/packages/astro/src/core/config/settings.ts b/packages/astro/src/core/config/settings.ts index cf4db7598e44..fca392c97672 100644 --- a/packages/astro/src/core/config/settings.ts +++ b/packages/astro/src/core/config/settings.ts @@ -97,6 +97,7 @@ export function createBaseSettings(config: AstroConfig): AstroSettings { renderers: [], scripts: [], clientDirectives: getDefaultClientDirectives(), + middlewares: { pre: [], post: [] }, watchFiles: [], devOverlayPlugins: [], timer: new AstroTimer(), diff --git a/packages/astro/src/core/create-vite.ts b/packages/astro/src/core/create-vite.ts index be62de67159b..fda837209753 100644 --- a/packages/astro/src/core/create-vite.ts +++ b/packages/astro/src/core/create-vite.ts @@ -31,6 +31,7 @@ import astroScannerPlugin from '../vite-plugin-scanner/index.js'; import astroScriptsPlugin from '../vite-plugin-scripts/index.js'; import astroScriptsPageSSRPlugin from '../vite-plugin-scripts/page-ssr.js'; import { vitePluginSSRManifest } from '../vite-plugin-ssr-manifest/index.js'; +import { vitePluginMiddleware } from './middleware/vite-plugin.js'; import { joinPaths } from './path.js'; interface CreateViteOptions { @@ -134,6 +135,7 @@ export async function createVite( astroContentVirtualModPlugin({ settings }), astroContentImportPlugin({ fs, settings }), astroContentAssetPropagationPlugin({ mode, settings }), + vitePluginMiddleware({ settings }), vitePluginSSRManifest(), astroAssetsPlugin({ settings, logger, mode }), astroPrefetch({ settings }), diff --git a/packages/astro/src/core/middleware/loadMiddleware.ts b/packages/astro/src/core/middleware/loadMiddleware.ts index b8528eb4b336..46e1e32e23af 100644 --- a/packages/astro/src/core/middleware/loadMiddleware.ts +++ b/packages/astro/src/core/middleware/loadMiddleware.ts @@ -1,6 +1,5 @@ -import type { AstroSettings } from '../../@types/astro.js'; -import { MIDDLEWARE_PATH_SEGMENT_NAME } from '../constants.js'; import type { ModuleLoader } from '../module-loader/index.js'; +import { MIDDLEWARE_MODULE_ID } from './vite-plugin.js'; /** * It accepts a module loader and the astro settings, and it attempts to load the middlewares defined in the configuration. @@ -9,12 +8,9 @@ import type { ModuleLoader } from '../module-loader/index.js'; */ export async function loadMiddleware( moduleLoader: ModuleLoader, - srcDir: AstroSettings['config']['srcDir'] ) { - // can't use node Node.js builtins - let middlewarePath = `${decodeURI(srcDir.pathname)}${MIDDLEWARE_PATH_SEGMENT_NAME}`; try { - const module = await moduleLoader.import(middlewarePath); + const module = await moduleLoader.import(MIDDLEWARE_MODULE_ID); return module; } catch { return void 0; diff --git a/packages/astro/src/core/middleware/sequence.ts b/packages/astro/src/core/middleware/sequence.ts index d8d71c66b4a4..ca3dc90a0d6a 100644 --- a/packages/astro/src/core/middleware/sequence.ts +++ b/packages/astro/src/core/middleware/sequence.ts @@ -7,7 +7,8 @@ import { defineMiddleware } from './index.js'; * It accepts one or more middleware handlers and makes sure that they are run in sequence. */ export function sequence(...handlers: MiddlewareEndpointHandler[]): MiddlewareEndpointHandler { - const length = handlers.length; + const filtered = handlers.filter(h => !!h); + const length = filtered.length; if (!length) { const handler: MiddlewareEndpointHandler = defineMiddleware((context, next) => { return next(); @@ -19,7 +20,7 @@ export function sequence(...handlers: MiddlewareEndpointHandler[]): MiddlewareEn return applyHandle(0, context); function applyHandle(i: number, handleContext: APIContext) { - const handle = handlers[i]; + const handle = filtered[i]; // @ts-expect-error // SAFETY: Usually `next` always returns something in user land, but in `sequence` we are actually // doing a loop over all the `next` functions, and eventually we call the last `next` that returns the `Response`. diff --git a/packages/astro/src/core/middleware/vite-plugin.ts b/packages/astro/src/core/middleware/vite-plugin.ts new file mode 100644 index 000000000000..d250125163b4 --- /dev/null +++ b/packages/astro/src/core/middleware/vite-plugin.ts @@ -0,0 +1,124 @@ +import type { Plugin as VitePlugin } from 'vite'; +import { normalizePath } from 'vite'; +import { getOutputDirectory } from '../../prerender/utils.js'; +import { MIDDLEWARE_PATH_SEGMENT_NAME } from '../constants.js'; +import { addRollupInput } from '../build/add-rollup-input.js'; +import type { BuildInternals } from '../build/internal.js'; +import type { StaticBuildOptions } from '../build/types.js'; +import type { AstroSettings } from '../../@types/astro.js'; + +export const MIDDLEWARE_MODULE_ID = '@astro-middleware'; +const EMPTY_MIDDLEWARE = '\0empty-middleware'; + +export function vitePluginMiddleware({ + settings +}: { + settings: AstroSettings +}): VitePlugin { + let isCommandBuild = false; + let resolvedMiddlewareId: string | undefined = undefined; + const hasIntegrationMiddleware = settings.middlewares.pre.length > 0 || settings.middlewares.post.length > 0; + + return { + name: '@astro/plugin-middleware', + + config(opts, { command }) { + isCommandBuild = command === 'build'; + return opts; + }, + + async resolveId(id) { + if (id === MIDDLEWARE_MODULE_ID) { + const middlewareId = await this.resolve( + `${decodeURI(settings.config.srcDir.pathname)}${MIDDLEWARE_PATH_SEGMENT_NAME}` + ); + if (middlewareId) { + resolvedMiddlewareId = middlewareId.id; + return MIDDLEWARE_MODULE_ID; + } else if(hasIntegrationMiddleware) { + return MIDDLEWARE_MODULE_ID; + } else { + return EMPTY_MIDDLEWARE; + } + } + if (id === EMPTY_MIDDLEWARE) { + return EMPTY_MIDDLEWARE; + } + }, + + async load(id) { + if (id === EMPTY_MIDDLEWARE) { + return 'export const onRequest = undefined'; + } else if (id === MIDDLEWARE_MODULE_ID) { + // In the build, tell Vite to emit this file + if(isCommandBuild) { + this.emitFile({ + type: 'chunk', + preserveSignature: 'strict', + fileName: 'middleware.mjs', + id, + }); + } + + const preMiddleware = createMiddlewareImports(settings.middlewares.pre, 'pre'); + const postMiddleware = createMiddlewareImports(settings.middlewares.post, 'post'); + + const source = ` +import { onRequest as userOnRequest } from '${resolvedMiddlewareId}'; +import { sequence } from 'astro:middleware'; +${preMiddleware.importsCode}${postMiddleware.importsCode} + +export const onRequest = sequence( + ${preMiddleware.sequenceCode}${preMiddleware.sequenceCode ? ',' : ''} + userOnRequest${postMiddleware.sequenceCode ? ',' : ''} + ${postMiddleware.sequenceCode} +); +`.trim(); + + return source; + } + }, + }; +} + +function createMiddlewareImports(entrypoints: string[], prefix: string): { + importsCode: string + sequenceCode: string +} { + let importsRaw = ''; + let sequenceRaw = ''; + let index = 0; + for(const entrypoint of entrypoints) { + const name = `_${prefix}_${index}`; + importsRaw += `import { onRequest as ${name} } from '${normalizePath(entrypoint)}';\n`; + sequenceRaw += `${index > 0 ? ',' : ''}${name}` + index++; + } + + return { + importsCode: importsRaw, + sequenceCode: sequenceRaw + }; +} + +export function vitePluginMiddlewareBuild( + opts: StaticBuildOptions, + internals: BuildInternals +): VitePlugin { + return { + name: '@astro/plugin-middleware-build', + + options(options) { + return addRollupInput(options, [MIDDLEWARE_MODULE_ID]); + }, + + writeBundle(_, bundle) { + for (const [chunkName, chunk] of Object.entries(bundle)) { + if (chunk.type !== 'asset' && chunk.fileName === 'middleware.mjs') { + const outputDirectory = getOutputDirectory(opts.settings.config); + internals.middlewareEntryPoint = new URL(chunkName, outputDirectory); + } + } + }, + }; +} diff --git a/packages/astro/src/integrations/index.ts b/packages/astro/src/integrations/index.ts index 4c527cea3b55..23d2160a440b 100644 --- a/packages/astro/src/integrations/index.ts +++ b/packages/astro/src/integrations/index.ts @@ -136,6 +136,17 @@ export async function runHookConfigSetup({ } addedClientDirectives.set(name, buildClientDirectiveEntrypoint(name, entrypoint)); }, + addMiddleware: ({ order, entrypoint }) => { + if(typeof updatedSettings.middlewares[order] === 'undefined') { + throw new Error( + `The "${integration.name}" integration is trying to add middleware but did not specify an order.` + ); + } + logger.debug('middleware', `The integration ${integration.name} has added middleware that runs ${ + order === 'pre' ? 'before' : 'after' + } any application middleware you define.`); + updatedSettings.middlewares[order].push(entrypoint); + }, logger: integrationLogger, }; diff --git a/packages/astro/src/vite-plugin-astro-server/route.ts b/packages/astro/src/vite-plugin-astro-server/route.ts index de1910227a2e..2b0bcbea75ae 100644 --- a/packages/astro/src/vite-plugin-astro-server/route.ts +++ b/packages/astro/src/vite-plugin-astro-server/route.ts @@ -162,7 +162,6 @@ export async function handleRoute({ manifest, }: HandleRoute): Promise { const env = pipeline.getEnvironment(); - const settings = pipeline.getSettings(); const config = pipeline.getConfig(); const moduleLoader = pipeline.getModuleLoader(); const { logger } = env; @@ -177,7 +176,7 @@ export async function handleRoute({ let mod: ComponentInstance | undefined = undefined; let options: SSROptions | undefined = undefined; let route: RouteData; - const middleware = await loadMiddleware(moduleLoader, settings.config.srcDir); + const middleware = await loadMiddleware(moduleLoader); if (!matchedRoute) { if (config.experimental.i18n) { diff --git a/packages/astro/test/fixtures/middleware space/astro.config.mjs b/packages/astro/test/fixtures/middleware space/astro.config.mjs new file mode 100644 index 000000000000..3fee066cda47 --- /dev/null +++ b/packages/astro/test/fixtures/middleware space/astro.config.mjs @@ -0,0 +1,23 @@ +import { defineConfig } from 'astro/config'; +import { fileURLToPath } from 'node:url'; + +export default defineConfig({ + integrations: [ + { + name: 'my-middleware', + hooks: { + 'astro:config:setup':({ addMiddleware }) => { + addMiddleware({ + entrypoint: fileURLToPath(new URL('./integration-middleware-pre.js', import.meta.url)), + order: 'pre' + }); + + addMiddleware({ + entrypoint: fileURLToPath(new URL('./integration-middleware-post.js', import.meta.url)), + order: 'post' + }); + } + } + } + ] +}); diff --git a/packages/astro/test/fixtures/middleware space/integration-middleware-post.js b/packages/astro/test/fixtures/middleware space/integration-middleware-post.js new file mode 100644 index 000000000000..4cc63c6b7c49 --- /dev/null +++ b/packages/astro/test/fixtures/middleware space/integration-middleware-post.js @@ -0,0 +1,13 @@ +import { sequence, defineMiddleware } from 'astro:middleware'; + +export const onRequest = defineMiddleware((context, next) => { + if(context.url.pathname === '/integration-post') { + return new Response(JSON.stringify({ post: 'works' }), { + headers: { + 'content-type': 'application/json' + } + }); + } + + return next(); +}); diff --git a/packages/astro/test/fixtures/middleware space/integration-middleware-pre.js b/packages/astro/test/fixtures/middleware space/integration-middleware-pre.js new file mode 100644 index 000000000000..3bf484b2becb --- /dev/null +++ b/packages/astro/test/fixtures/middleware space/integration-middleware-pre.js @@ -0,0 +1,13 @@ +import { sequence, defineMiddleware } from 'astro:middleware'; + +export const onRequest = defineMiddleware((context, next) => { + if(context.url.pathname === '/integration-pre') { + return new Response(JSON.stringify({ pre: 'works' }), { + headers: { + 'content-type': 'application/json' + } + }); + } + + return next(); +}); diff --git a/packages/astro/test/fixtures/middleware space/package.json b/packages/astro/test/fixtures/middleware space/package.json index bc889aa63096..91d4a344c20a 100644 --- a/packages/astro/test/fixtures/middleware space/package.json +++ b/packages/astro/test/fixtures/middleware space/package.json @@ -4,5 +4,8 @@ "private": true, "dependencies": { "astro": "workspace:*" + }, + "exports": { + "./integration-middleware.js": "./integration-middleware.js" } } diff --git a/packages/astro/test/middleware.test.js b/packages/astro/test/middleware.test.js index ac30546206fe..a9487950eb65 100644 --- a/packages/astro/test/middleware.test.js +++ b/packages/astro/test/middleware.test.js @@ -85,6 +85,20 @@ describe('Middleware in DEV mode', () => { let headers = res.headers; expect(headers.get('set-cookie')).to.not.equal(null); }); + + describe('Integration hooks', () => { + it('Integration middleware marked as "pre" runs', async () => { + let res = await fixture.fetch('/integration-pre'); + let json = await res.json(); + expect(json.pre).to.equal('works'); + }); + + it('Integration middleware marked as "post" runs', async () => { + let res = await fixture.fetch('/integration-post'); + let json = await res.json(); + expect(json.post).to.equal('works'); + }); + }); }); describe('Middleware in PROD mode, SSG', () => {