0
Fork 0
mirror of https://github.com/withastro/astro.git synced 2025-01-06 22:10:10 -05:00

Integration defined middleware (#8869)

* Rebase

* Use an empty module if there is no real middleware

* Add debug logging

* Use normalizePath

* Add a better example in the changesetp

* Update .changeset/khaki-glasses-raise.md

Co-authored-by: Bjorn Lu <bjornlu.dev@gmail.com>

* Update .changeset/khaki-glasses-raise.md

Co-authored-by: Bjorn Lu <bjornlu.dev@gmail.com>

* Update .changeset/khaki-glasses-raise.md

Co-authored-by: Sarah Rainsberger <sarah@rainsberger.ca>

* Update .changeset/khaki-glasses-raise.md

Co-authored-by: Sarah Rainsberger <sarah@rainsberger.ca>

* Update .changeset/khaki-glasses-raise.md

Co-authored-by: Sarah Rainsberger <sarah@rainsberger.ca>

* Update .changeset/khaki-glasses-raise.md

Co-authored-by: Sarah Rainsberger <sarah@rainsberger.ca>

* Update packages/astro/src/core/middleware/vite-plugin.ts

Co-authored-by: Emanuele Stoppa <my.burning@gmail.com>

* Review comments

* oops

* Update .changeset/khaki-glasses-raise.md

Co-authored-by: Bjorn Lu <bjornlu.dev@gmail.com>

---------

Co-authored-by: Bjorn Lu <bjornlu.dev@gmail.com>
Co-authored-by: Sarah Rainsberger <sarah@rainsberger.ca>
Co-authored-by: Emanuele Stoppa <my.burning@gmail.com>
This commit is contained in:
Matthew Phillips 2023-11-08 13:36:50 -05:00 committed by GitHub
parent b09379428d
commit f5bdfa272b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
15 changed files with 268 additions and 75 deletions

View file

@ -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'
});
}
}
};
}
```

View file

@ -1701,6 +1701,7 @@ export interface AstroSettings {
*/
clientDirectives: Map<string, string>;
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<R> = {
onRequest?: MiddlewareHandler<R>;
};
export type AstroIntegrationMiddleware = {
order: 'pre' | 'post';
entrypoint: string;
};
export interface AstroPluginOptions {
settings: AstroSettings;
logger: Logger;

View file

@ -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),
};
},
},

View file

@ -97,6 +97,7 @@ export function createBaseSettings(config: AstroConfig): AstroSettings {
renderers: [],
scripts: [],
clientDirectives: getDefaultClientDirectives(),
middlewares: { pre: [], post: [] },
watchFiles: [],
devOverlayPlugins: [],
timer: new AstroTimer(),

View file

@ -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 }),

View file

@ -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;

View file

@ -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`.

View file

@ -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);
}
}
},
};
}

View file

@ -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,
};

View file

@ -162,7 +162,6 @@ export async function handleRoute({
manifest,
}: HandleRoute): Promise<void> {
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) {

View file

@ -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'
});
}
}
}
]
});

View file

@ -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();
});

View file

@ -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();
});

View file

@ -4,5 +4,8 @@
"private": true,
"dependencies": {
"astro": "workspace:*"
},
"exports": {
"./integration-middleware.js": "./integration-middleware.js"
}
}

View file

@ -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', () => {