Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Integration defined middleware #8869

Merged
merged 16 commits into from
Nov 8, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions .changeset/khaki-glasses-raise.md
Original file line number Diff line number Diff line change
@@ -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'
});
}
}
};
}
```
7 changes: 7 additions & 0 deletions packages/astro/src/@types/astro.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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;
Expand Down
68 changes: 3 additions & 65 deletions packages/astro/src/core/build/plugins/plugin-middleware.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -75,7 +13,7 @@ export function pluginMiddleware(
hooks: {
'build:before': () => {
return {
vitePlugin: vitePluginMiddleware(opts, internals),
vitePlugin: vitePluginMiddlewareBuild(opts, internals),
};
},
},
Expand Down
1 change: 1 addition & 0 deletions packages/astro/src/core/config/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ export function createBaseSettings(config: AstroConfig): AstroSettings {
renderers: [],
scripts: [],
clientDirectives: getDefaultClientDirectives(),
middlewares: { pre: [], post: [] },
watchFiles: [],
devOverlayPlugins: [],
timer: new AstroTimer(),
Expand Down
2 changes: 2 additions & 0 deletions packages/astro/src/core/create-vite.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 }),
Expand Down
8 changes: 2 additions & 6 deletions packages/astro/src/core/middleware/loadMiddleware.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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;
Expand Down
5 changes: 3 additions & 2 deletions packages/astro/src/core/middleware/sequence.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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`.
Expand Down
124 changes: 124 additions & 0 deletions packages/astro/src/core/middleware/vite-plugin.ts
Original file line number Diff line number Diff line change
@@ -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);
}
}
},
};
}
11 changes: 11 additions & 0 deletions packages/astro/src/integrations/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};

Expand Down
3 changes: 1 addition & 2 deletions packages/astro/src/vite-plugin-astro-server/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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) {
Expand Down
Loading