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:
parent
b09379428d
commit
f5bdfa272b
15 changed files with 268 additions and 75 deletions
48
.changeset/khaki-glasses-raise.md
Normal file
48
.changeset/khaki-glasses-raise.md
Normal 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'
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
```
|
|
@ -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;
|
||||
|
|
|
@ -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),
|
||||
};
|
||||
},
|
||||
},
|
||||
|
|
|
@ -97,6 +97,7 @@ export function createBaseSettings(config: AstroConfig): AstroSettings {
|
|||
renderers: [],
|
||||
scripts: [],
|
||||
clientDirectives: getDefaultClientDirectives(),
|
||||
middlewares: { pre: [], post: [] },
|
||||
watchFiles: [],
|
||||
devOverlayPlugins: [],
|
||||
timer: new AstroTimer(),
|
||||
|
|
|
@ -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 }),
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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`.
|
||||
|
|
124
packages/astro/src/core/middleware/vite-plugin.ts
Normal file
124
packages/astro/src/core/middleware/vite-plugin.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
|
@ -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,
|
||||
};
|
||||
|
||||
|
|
|
@ -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) {
|
||||
|
|
23
packages/astro/test/fixtures/middleware space/astro.config.mjs
vendored
Normal file
23
packages/astro/test/fixtures/middleware space/astro.config.mjs
vendored
Normal 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'
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
});
|
13
packages/astro/test/fixtures/middleware space/integration-middleware-post.js
vendored
Normal file
13
packages/astro/test/fixtures/middleware space/integration-middleware-post.js
vendored
Normal 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();
|
||||
});
|
13
packages/astro/test/fixtures/middleware space/integration-middleware-pre.js
vendored
Normal file
13
packages/astro/test/fixtures/middleware space/integration-middleware-pre.js
vendored
Normal 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();
|
||||
});
|
|
@ -4,5 +4,8 @@
|
|||
"private": true,
|
||||
"dependencies": {
|
||||
"astro": "workspace:*"
|
||||
},
|
||||
"exports": {
|
||||
"./integration-middleware.js": "./integration-middleware.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', () => {
|
||||
|
|
Loading…
Reference in a new issue