feat: vercel edge middleware support (#7532)

Co-authored-by: Bjorn Lu <bjornlu.dev@gmail.com>
Co-authored-by: Sarah Rainsberger <sarah@rainsberger.ca>
This commit is contained in:
Emanuele Stoppa 2023-07-05 16:45:58 +01:00 committed by GitHub
parent cfd5b2b785
commit 9e5fafa2b2
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
36 changed files with 758 additions and 50 deletions

View file

@ -0,0 +1,11 @@
---
'astro': minor
---
The `astro/middleware` module exports a new utility called `trySerializeLocals`.
This utility can be used by adapters to validate their `locals` before sending it
to the Astro middleware.
This function will throw a runtime error if the value passed is not serializable, so
consumers will need to handle that error.

View file

@ -0,0 +1,24 @@
---
'astro': minor
---
Astro exposes the middleware file path to the integrations in the hook `astro:build:ssr`
```ts
// myIntegration.js
import type { AstroIntegration } from 'astro';
function integration(): AstroIntegration {
return {
name: "fancy-astro-integration",
hooks: {
'astro:build:ssr': ({ middlewareEntryPoint }) => {
if (middlewareEntryPoint) {
// do some operations
}
}
}
}
}
```
The `middlewareEntryPoint` is only defined if the user has created an Astro middleware.

View file

@ -0,0 +1,5 @@
---
'astro': patch
---
Correctly track the middleware during the SSR build.

View file

@ -0,0 +1,11 @@
---
'@astrojs/vercel': minor
---
Support for Vercel Edge Middleware via Astro middleware.
When a project uses the new option Astro `build.excludeMiddleware`, the
`@astrojs/vercel/serverless` adapter will automatically create a Vercel Edge Middleware
that will automatically communicate with the Astro Middleware.
Check the [documentation](https://github.com/withastro/astro/blob/main/packages/integrations/vercel/README.md##vercel-edge-middleware-with-astro-middleware) for more details.

View file

@ -0,0 +1,7 @@
---
'astro': minor
---
The `astro/middleware` module exports a new API called `createContext`.
This a low-level API that adapters can use to create a context that can be consumed by middleware functions.

View file

@ -0,0 +1,20 @@
---
'astro': minor
---
Introduced a new build option for SSR, called `build.excludeMiddleware`.
```js
// astro.config.mjs
import {defineConfig} from "astro/config";
export default defineConfig({
build: {
excludeMiddleware: true
}
})
```
When enabled, the code that belongs to be middleware **won't** be imported
by the final pages/entry points. The user is responsible for importing it and
calling it manually.

View file

@ -849,6 +849,27 @@ export interface AstroUserConfig {
* ```
*/
split?: boolean;
/**
* @docs
* @name build.excludeMiddleware
* @type {boolean}
* @default {false}
* @version 2.8.0
* @description
* Defines whether or not any SSR middleware code will be bundled when built.
*
* When enabled, middleware code is not bundled and imported by all pages during the build. To instead execute and import middleware code manually, set `build.excludeMiddleware: true`:
*
* ```js
* {
* build: {
* excludeMiddleware: true
* }
* }
* ```
*/
excludeMiddleware?: boolean;
};
/**
@ -1842,6 +1863,10 @@ export interface AstroIntegration {
* the physical file you should import.
*/
entryPoints: Map<RouteData, URL>;
/**
* File path of the emitted middleware
*/
middlewareEntryPoint: URL | undefined;
}) => void | Promise<void>;
'astro:build:start'?: () => void | Promise<void>;
'astro:build:setup'?: (options: {

View file

@ -1,5 +1,4 @@
import type { AstroConfig, AstroSettings, ManifestData, RuntimeMode } from '../../@types/astro';
import fs from 'fs';
import * as colors from 'kleur/colors';
import { performance } from 'perf_hooks';
@ -12,7 +11,7 @@ import {
runHookConfigSetup,
} from '../../integrations/index.js';
import { createVite } from '../create-vite.js';
import { debug, info, levels, timerMessage, type LogOptions } from '../logger/core.js';
import { debug, info, warn, levels, timerMessage, type LogOptions } from '../logger/core.js';
import { printHelp } from '../messages.js';
import { apply as applyPolyfill } from '../polyfill.js';
import { RouteCache } from '../render/route-cache.js';
@ -211,6 +210,25 @@ class AstroBuilder {
`the outDir cannot be the root folder. Please build to a folder such as dist.`
);
}
if (config.build.split === true) {
if (config.output === 'static') {
warn(
this.logging,
'configuration',
'The option `build.split` won\'t take effect, because `output` is not `"server"` or `"hybrid"`.'
);
}
}
if (config.build.excludeMiddleware === true) {
if (config.output === 'static') {
warn(
this.logging,
'configuration',
'The option `build.excludeMiddleware` won\'t take effect, because `output` is not `"server"` or `"hybrid"`.'
);
}
}
}
/** Stats */

View file

@ -88,6 +88,7 @@ export interface BuildInternals {
entryPoints: Map<RouteData, URL>;
ssrSplitEntryChunks: Map<string, Rollup.OutputChunk>;
componentMetadata: SSRResult['componentMetadata'];
middlewareEntryPoint?: URL;
}
/**

View file

@ -19,7 +19,7 @@ export function registerAllPlugins({ internals, options, register }: AstroBuildP
register(pluginAnalyzer(internals));
register(pluginInternals(internals));
register(pluginRenderers(options));
register(pluginMiddleware(options));
register(pluginMiddleware(options, internals));
register(pluginPages(options, internals));
register(pluginCSS(options, internals));
register(astroHeadBuildPlugin(internals));

View file

@ -3,12 +3,17 @@ import { MIDDLEWARE_PATH_SEGMENT_NAME } from '../../constants.js';
import { addRollupInput } from '../add-rollup-input.js';
import type { AstroBuildPlugin } from '../plugin';
import type { StaticBuildOptions } from '../types';
import type { BuildInternals } from '../internal';
export const MIDDLEWARE_MODULE_ID = '@astro-middleware';
const EMPTY_MIDDLEWARE = '\0empty-middleware';
export function vitePluginMiddleware(opts: StaticBuildOptions): VitePlugin {
export function vitePluginMiddleware(
opts: StaticBuildOptions,
internals: BuildInternals
): VitePlugin {
let resolvedMiddlewareId: string;
return {
name: '@astro/plugin-middleware',
@ -22,6 +27,7 @@ export function vitePluginMiddleware(opts: StaticBuildOptions): VitePlugin {
`${opts.settings.config.srcDir.pathname}/${MIDDLEWARE_PATH_SEGMENT_NAME}`
);
if (middlewareId) {
resolvedMiddlewareId = middlewareId.id;
return middlewareId.id;
} else {
return EMPTY_MIDDLEWARE;
@ -35,18 +41,39 @@ export function vitePluginMiddleware(opts: StaticBuildOptions): VitePlugin {
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') {
internals.middlewareEntryPoint = new URL(chunkName, opts.settings.config.build.server);
}
}
},
};
}
export function pluginMiddleware(opts: StaticBuildOptions): AstroBuildPlugin {
export function pluginMiddleware(
opts: StaticBuildOptions,
internals: BuildInternals
): AstroBuildPlugin {
return {
build: 'ssr',
hooks: {
'build:before': () => {
return {
vitePlugin: vitePluginMiddleware(opts),
vitePlugin: vitePluginMiddleware(opts, internals),
};
},
},

View file

@ -73,10 +73,13 @@ function vitePluginPages(opts: StaticBuildOptions, internals: BuildInternals): V
imports.push(`import { renderers } from "${RENDERERS_MODULE_ID}";`);
exports.push(`export { renderers };`);
const middlewareModule = await this.resolve(MIDDLEWARE_MODULE_ID);
if (middlewareModule) {
imports.push(`import { onRequest } from "${middlewareModule.id}";`);
exports.push(`export { onRequest };`);
// The middleware should not be imported by the pages
if (!opts.settings.config.build.excludeMiddleware) {
const middlewareModule = await this.resolve(MIDDLEWARE_MODULE_ID);
if (middlewareModule) {
imports.push(`import { onRequest } from "${middlewareModule.id}";`);
exports.push(`export { onRequest };`);
}
}
return `${imports.join('\n')}${exports.join('\n')}`;

View file

@ -138,6 +138,7 @@ export function pluginSSR(
manifest,
logging: options.logging,
entryPoints: internals.entryPoints,
middlewareEntryPoint: internals.middlewareEntryPoint,
});
const code = injectManifest(manifest, internals.ssrEntryChunk);
mutate(internals.ssrEntryChunk, 'server', code);
@ -260,6 +261,7 @@ export function pluginSSRSplit(
manifest,
logging: options.logging,
entryPoints: internals.entryPoints,
middlewareEntryPoint: internals.middlewareEntryPoint,
});
for (const [, chunk] of internals.ssrSplitEntryChunks) {
const code = injectManifest(manifest, chunk);

View file

@ -26,7 +26,6 @@ import { generatePages } from './generate.js';
import { trackPageData } from './internal.js';
import { createPluginContainer, type AstroBuildPluginContainer } from './plugin.js';
import { registerAllPlugins } from './plugins/index.js';
import { MIDDLEWARE_MODULE_ID } from './plugins/plugin-middleware.js';
import { ASTRO_PAGE_RESOLVED_MODULE_ID } from './plugins/plugin-pages.js';
import { RESOLVED_RENDERERS_MODULE_ID } from './plugins/plugin-renderers.js';
import { RESOLVED_SPLIT_MODULE_ID, SSR_VIRTUAL_MODULE_ID } from './plugins/plugin-ssr.js';
@ -183,8 +182,6 @@ async function ssrBuild(
);
} else if (chunkInfo.facadeModuleId?.startsWith(RESOLVED_SPLIT_MODULE_ID)) {
return makeSplitEntryPointFileName(chunkInfo.facadeModuleId, routes);
} else if (chunkInfo.facadeModuleId === MIDDLEWARE_MODULE_ID) {
return 'middleware.mjs';
} else if (chunkInfo.facadeModuleId === SSR_VIRTUAL_MODULE_ID) {
return opts.settings.config.build.serverEntry;
} else if (chunkInfo.facadeModuleId === RESOLVED_RENDERERS_MODULE_ID) {

View file

@ -25,6 +25,7 @@ const ASTRO_CONFIG_DEFAULTS = {
redirects: true,
inlineStylesheets: 'never',
split: false,
excludeMiddleware: false,
},
compressHTML: false,
server: {
@ -122,6 +123,10 @@ export const AstroConfigSchema = z.object({
.default(ASTRO_CONFIG_DEFAULTS.build.inlineStylesheets),
split: z.boolean().optional().default(ASTRO_CONFIG_DEFAULTS.build.split),
excludeMiddleware: z
.boolean()
.optional()
.default(ASTRO_CONFIG_DEFAULTS.build.excludeMiddleware),
})
.optional()
.default({}),
@ -283,6 +288,10 @@ export function createRelativeSchema(cmd: string, fileProtocolRoot: URL) {
.default(ASTRO_CONFIG_DEFAULTS.build.inlineStylesheets),
split: z.boolean().optional().default(ASTRO_CONFIG_DEFAULTS.build.split),
excludeMiddleware: z
.boolean()
.optional()
.default(ASTRO_CONFIG_DEFAULTS.build.excludeMiddleware),
})
.optional()
.default({}),

View file

@ -31,19 +31,26 @@ type EndpointCallResult =
response: Response;
};
type CreateAPIContext = {
request: Request;
params: Params;
site?: string;
props: Record<string, any>;
adapterName?: string;
};
/**
* Creates a context that holds all the information needed to handle an Astro endpoint.
*
* @param {CreateAPIContext} payload
*/
export function createAPIContext({
request,
params,
site,
props,
adapterName,
}: {
request: Request;
params: Params;
site?: string;
props: Record<string, any>;
adapterName?: string;
}): APIContext {
}: CreateAPIContext): APIContext {
const context = {
cookies: new AstroCookies(request),
request,

View file

@ -1,9 +1,107 @@
import type { MiddlewareResponseHandler } from '../../@types/astro';
import type { MiddlewareResponseHandler, Params } from '../../@types/astro';
import { sequence } from './sequence.js';
import { createAPIContext } from '../endpoint/index.js';
function defineMiddleware(fn: MiddlewareResponseHandler) {
return fn;
}
/**
* Payload for creating a context to be passed to Astro middleware
*/
export type CreateContext = {
/**
* The incoming request
*/
request: Request;
/**
* Optional parameters
*/
params?: Params;
};
/**
* Creates a context to be passed to Astro middleware `onRequest` function.
*/
function createContext({ request, params }: CreateContext) {
return createAPIContext({
request,
params: params ?? {},
props: {},
site: undefined,
});
}
/**
* Checks whether the passed `value` is serializable.
*
* A serializable value contains plain values. For example, `Proxy`, `Set`, `Map`, functions, etc.
* are not accepted because they can't be serialized.
*/
function isLocalsSerializable(value: unknown): boolean {
let type = typeof value;
let plainObject = true;
if (type === 'object' && isPlainObject(value)) {
for (const [, nestedValue] of Object.entries(value)) {
if (!isLocalsSerializable(nestedValue)) {
plainObject = false;
break;
}
}
} else {
plainObject = false;
}
let result =
value === null ||
type === 'string' ||
type === 'number' ||
type === 'boolean' ||
Array.isArray(value) ||
plainObject;
return result;
}
/**
*
* From [redux-toolkit](https://github.com/reduxjs/redux-toolkit/blob/master/packages/toolkit/src/isPlainObject.ts)
*
* Returns true if the passed value is "plain" object, i.e. an object whose
* prototype is the root `Object.prototype`. This includes objects created
* using object literals, but not for instance for class instances.
*/
function isPlainObject(value: unknown): value is object {
if (typeof value !== 'object' || value === null) return false;
let proto = Object.getPrototypeOf(value);
if (proto === null) return true;
let baseProto = proto;
while (Object.getPrototypeOf(baseProto) !== null) {
baseProto = Object.getPrototypeOf(baseProto);
}
return proto === baseProto;
}
/**
* It attempts to serialize `value` and return it as a string.
*
* ## Errors
* If the `value` is not serializable if the function will throw a runtime error.
*
* Something is **not serializable** when it contains properties/values like functions, `Map`, `Set`, `Date`,
* and other types that can't be made a string.
*
* @param value
*/
function trySerializeLocals(value: unknown) {
if (isLocalsSerializable(value)) {
return JSON.stringify(value);
} else {
throw new Error("The passed value can't be serialized.");
}
}
// NOTE: this export must export only the functions that will be exposed to user-land as officials APIs
export { sequence, defineMiddleware };
export { sequence, defineMiddleware, createContext, trySerializeLocals };

View file

@ -298,22 +298,30 @@ export async function runHookBuildSetup({
return updatedConfig;
}
type RunHookBuildSsr = {
config: AstroConfig;
manifest: SerializedSSRManifest;
logging: LogOptions;
entryPoints: Map<RouteData, URL>;
middlewareEntryPoint: URL | undefined;
};
export async function runHookBuildSsr({
config,
manifest,
logging,
entryPoints,
}: {
config: AstroConfig;
manifest: SerializedSSRManifest;
logging: LogOptions;
entryPoints: Map<RouteData, URL>;
}) {
middlewareEntryPoint,
}: RunHookBuildSsr) {
for (const integration of config.integrations) {
if (integration?.hooks?.['astro:build:ssr']) {
await withTakingALongTimeMsg({
name: integration.name,
hookResult: integration.hooks['astro:build:ssr']({ manifest, entryPoints }),
hookResult: integration.hooks['astro:build:ssr']({
manifest,
entryPoints,
middlewareEntryPoint,
}),
logging,
});
}
@ -340,17 +348,14 @@ export async function runHookBuildGenerated({
}
}
export async function runHookBuildDone({
config,
pages,
routes,
logging,
}: {
type RunHookBuildDone = {
config: AstroConfig;
pages: string[];
routes: RouteData[];
logging: LogOptions;
}) {
};
export async function runHookBuildDone({ config, pages, routes, logging }: RunHookBuildDone) {
const dir = isServerLikeOutput(config) ? config.build.client : config.outDir;
await fs.promises.mkdir(dir, { recursive: true });

View file

@ -2,6 +2,8 @@ import { loadFixture } from './test-utils.js';
import { expect } from 'chai';
import * as cheerio from 'cheerio';
import testAdapter from './test-adapter.js';
import { fileURLToPath } from 'node:url';
import { readFileSync, existsSync } from 'node:fs';
describe('Middleware in DEV mode', () => {
/** @type {import('./test-utils').Fixture} */
@ -104,12 +106,19 @@ describe('Middleware in PROD mode, SSG', () => {
describe('Middleware API in PROD mode, SSR', () => {
/** @type {import('./test-utils').Fixture} */
let fixture;
let middlewarePath;
before(async () => {
fixture = await loadFixture({
root: './fixtures/middleware-dev/',
output: 'server',
adapter: testAdapter({}),
adapter: testAdapter({
setEntryPoints(entryPointsOrMiddleware) {
if (entryPointsOrMiddleware instanceof URL) {
middlewarePath = entryPointsOrMiddleware;
}
},
}),
});
await fixture.build();
});
@ -201,6 +210,18 @@ describe('Middleware API in PROD mode, SSR', () => {
const text = await response.text();
expect(text.includes('REDACTED')).to.be.true;
});
it('the integration should receive the path to the middleware', async () => {
expect(middlewarePath).to.not.be.undefined;
try {
const path = fileURLToPath(middlewarePath);
expect(existsSync(path)).to.be.true;
const content = readFileSync(fileURLToPath(middlewarePath), 'utf-8');
expect(content.length).to.be.greaterThan(0);
} catch (e) {
throw e;
}
});
});
describe('Middleware with tailwind', () => {
@ -224,3 +245,29 @@ describe('Middleware with tailwind', () => {
expect(bundledCSS.includes('--tw-content')).to.be.true;
});
});
describe('Middleware, split middleware option', () => {
/** @type {import('./test-utils').Fixture} */
let fixture;
before(async () => {
fixture = await loadFixture({
root: './fixtures/middleware-dev/',
output: 'server',
build: {
excludeMiddleware: true,
},
adapter: testAdapter({}),
});
await fixture.build();
});
it('should not render locals data because the page does not export it', async () => {
const app = await fixture.loadTestAdapterApp();
const request = new Request('http://example.com/');
const response = await app.render(request);
const html = await response.text();
const $ = cheerio.load(html);
expect($('p').html()).to.not.equal('bar');
});
});

View file

@ -18,7 +18,9 @@ describe('astro:ssr-manifest, split', () => {
output: 'server',
adapter: testAdapter({
setEntryPoints(entries) {
entryPoints = entries;
if (entries) {
entryPoints = entries;
}
},
setRoutes(routes) {
currentRoutes = routes;

View file

@ -74,9 +74,10 @@ export default function (
...extendAdapter,
});
},
'astro:build:ssr': ({ entryPoints }) => {
'astro:build:ssr': ({ entryPoints, middlewareEntryPoint }) => {
if (setEntryPoints) {
setEntryPoints(entryPoints);
setEntryPoints(middlewareEntryPoint);
}
},
'astro:build:done': ({ routes }) => {

View file

@ -233,9 +233,9 @@ export default defineConfig({
});
```
### Vercel Middleware
### Vercel Edge Middleware
You can use Vercel middleware to intercept a request and redirect before sending a response. Vercel middleware can run for Edge, SSR, and Static deployments. You don't need to install `@vercel/edge` to write middleware, but you do need to install it to use features such as geolocation. For more information see [Vercels middleware documentation](https://vercel.com/docs/concepts/functions/edge-middleware).
You can use Vercel Edge middleware to intercept a request and redirect before sending a response. Vercel middleware can run for Edge, SSR, and Static deployments. You may not need to install this package for your middleware. `@vercel/edge` is only required to use some middleware features such as geolocation. For more information see [Vercels middleware documentation](https://vercel.com/docs/concepts/functions/edge-middleware).
1. Add a `middleware.js` file to the root of your project:
@ -262,6 +262,76 @@ You can use Vercel middleware to intercept a request and redirect before sending
> **Warning**
> **Trying to rewrite?** Currently rewriting a request with middleware only works for static files.
### Vercel Edge Middleware with Astro middleware
The `@astrojs/vercel/serverless` adapter can automatically create the Vercel Edge middleware from an Astro middleware in your code base.
This is an opt-in feature, and the `build.excludeMiddleware` option needs to be set to `true`:
```js
// astro.config.mjs
import {defineConfig} from "astro/config";
import vercel from "@astrojs/vercel";
export default defineConfig({
output: "server",
adapter: vercel(),
build: {
excludeMiddleware: true
}
})
```
Optionally, you can create a file recognized by the adapter named `vercel-edge-middleware.(js|ts)` in the [`srcDir`](https://docs.astro.build/en/reference/configuration-reference/#srcdir) folder to create [`Astro.locals`](https://docs.astro.build/en/reference/api-reference/#astrolocals).
Typings requires the [`@vercel/edge`](https://www.npmjs.com/package/@vercel/edge) package.
```js
// src/vercel-edge-middleware.js
/**
*
* @param options.request {Request}
* @param options.context {import("@vercel/edge").RequestContext}
* @returns {object}
*/
export default function({ request, context }) {
// do something with request and context
return {
title: "Spider-man's blog"
}
}
```
If you use TypeScript, you can type the function as follows:
```ts
// src/vercel-edge-middleware.ts
import type {RequestContext} from "@vercel/edge";
export default function ({request, context}: { request: Request, context: RequestContext }) {
// do something with request and context
return {
title: "Spider-man's blog"
}
}
```
The data returned by this function will be passed to Astro middleware.
The function:
- must export a **default** function;
- must **return** an `object`;
- accepts an object with a `request` and `context` as properties;
- `request` is typed as [`Request`](https://developer.mozilla.org/en-US/docs/Web/API/Request);
- `context` is typed as [`RequestContext`](https://vercel.com/docs/concepts/functions/edge-functions/vercel-edge-package#requestcontext);
#### Limitations and constraints
When you opt in to this feature, there are few constraints to note:
- The Vercel Edge middleware will always be the **first** function to receive the `Request` and the last function to receive `Response`. This an architectural constraint that follows the [boundaries set by Vercel](https://vercel.com/docs/concepts/functions/edge-middleware).
- Only `request` and `context` may be used to produce an `Astro.locals` object. Operations like redirects, etc. should be delegated to Astro middleware.
- `Astro.locals` **must be serializable**. Failing to do so will result in a **runtime error**. This means that you **cannot** store complex types like `Map`, `function`, `Set`, etc.
## Troubleshooting
**A few known complex packages (example: [puppeteer](https://github.com/puppeteer/puppeteer)) do not support bundling and therefore will not work properly with this adapter.** By default, Vercel doesn't include npm installed files & packages from your project's `./node_modules` folder. To address this, the `@astrojs/vercel` adapter automatically bundles your final build output using `esbuild`.

View file

@ -64,10 +64,13 @@
},
"devDependencies": {
"@types/set-cookie-parser": "^2.4.2",
"@vercel/edge": "^0.3.4",
"astro": "workspace:*",
"astro-scripts": "workspace:*",
"chai": "^4.3.7",
"chai-jest-snapshot": "^2.0.0",
"cheerio": "1.0.0-rc.12",
"mocha": "^9.2.2"
"mocha": "^9.2.2",
"rollup": "^3.20.1"
}
}

View file

@ -86,3 +86,7 @@ export async function copyFilesToFunction(
return commonAncestor;
}
export async function writeFile(path: PathLike, content: string) {
await fs.writeFile(path, content, { encoding: 'utf-8' });
}

View file

@ -1,7 +1,5 @@
import { nodeFileTrace } from '@vercel/nft';
import { relative as relativePath } from 'node:path';
import { fileURLToPath } from 'node:url';
import { copyFilesToFunction } from './fs.js';
export async function copyDependenciesToFunction({
@ -23,6 +21,11 @@ export async function copyDependenciesToFunction({
base = new URL('../', base);
}
// The Vite bundle includes an import to `@vercel/nft` for some reason,
// and that trips up `@vercel/nft` itself during the adapter build. Using a
// dynamic import helps prevent the issue.
// TODO: investigate why
const { nodeFileTrace } = await import('@vercel/nft');
const result = await nodeFileTrace([entryPath], {
base: fileURLToPath(base),
});

View file

@ -13,8 +13,12 @@ import { exposeEnv } from '../lib/env.js';
import { getVercelOutput, removeDir, writeJson } from '../lib/fs.js';
import { copyDependenciesToFunction } from '../lib/nft.js';
import { getRedirects } from '../lib/redirects.js';
import { generateEdgeMiddleware } from './middleware.js';
import { fileURLToPath } from 'node:url';
const PACKAGE_NAME = '@astrojs/vercel/serverless';
export const ASTRO_LOCALS_HEADER = 'x-astro-locals';
export const VERCEL_EDGE_MIDDLEWARE_FILE = 'vercel-edge-middleware';
function getAdapter(): AstroAdapter {
return {
@ -70,6 +74,8 @@ export default function vercelServerless({
});
}
const filesToInclude = includeFiles?.map((file) => new URL(file, _config.root)) || [];
return {
name: PACKAGE_NAME,
hooks: {
@ -106,17 +112,32 @@ export default function vercelServerless({
`);
}
},
'astro:build:ssr': async ({ entryPoints }) => {
'astro:build:ssr': async ({ entryPoints, middlewareEntryPoint }) => {
_entryPoints = entryPoints;
if (middlewareEntryPoint) {
const outPath = fileURLToPath(buildTempFolder);
const vercelEdgeMiddlewareHandlerPath = new URL(
VERCEL_EDGE_MIDDLEWARE_FILE,
_config.srcDir
);
const bundledMiddlewarePath = await generateEdgeMiddleware(
middlewareEntryPoint,
outPath,
vercelEdgeMiddlewareHandlerPath
);
// let's tell the adapter that we need to save this file
filesToInclude.push(bundledMiddlewarePath);
}
},
'astro:build:done': async ({ routes }) => {
// Merge any includes from `vite.assetsInclude
const inc = includeFiles?.map((file) => new URL(file, _config.root)) || [];
if (_config.vite.assetsInclude) {
const mergeGlobbedIncludes = (globPattern: unknown) => {
if (typeof globPattern === 'string') {
const entries = glob.sync(globPattern).map((p) => pathToFileURL(p));
inc.push(...entries);
filesToInclude.push(...entries);
} else if (Array.isArray(globPattern)) {
for (const pattern of globPattern) {
mergeGlobbedIncludes(pattern);
@ -133,14 +154,18 @@ export default function vercelServerless({
if (_entryPoints.size) {
for (const [route, entryFile] of _entryPoints) {
const func = basename(entryFile.toString()).replace(/\.mjs$/, '');
await createFunctionFolder(func, entryFile, inc);
await createFunctionFolder(func, entryFile, filesToInclude);
routeDefinitions.push({
src: route.pattern.source,
dest: func,
});
}
} else {
await createFunctionFolder('render', new URL(serverEntry, buildTempFolder), inc);
await createFunctionFolder(
'render',
new URL(serverEntry, buildTempFolder),
filesToInclude
);
routeDefinitions.push({ src: '/.*', dest: 'render' });
}

View file

@ -4,6 +4,7 @@ import { App } from 'astro/app';
import type { IncomingMessage, ServerResponse } from 'node:http';
import { getRequest, setResponse } from './request-transform';
import { ASTRO_LOCALS_HEADER } from './adapter';
polyfill(globalThis, {
exclude: 'window document',
@ -28,7 +29,14 @@ export const createExports = (manifest: SSRManifest) => {
return res.end('Not found');
}
await setResponse(app, res, await app.render(request, routeData));
let locals = {};
if (request.headers.has(ASTRO_LOCALS_HEADER)) {
let localsAsString = request.headers.get(ASTRO_LOCALS_HEADER);
if (localsAsString) {
locals = JSON.parse(localsAsString);
}
}
await setResponse(app, res, await app.render(request, routeData, locals));
};
return { default: handler };

View file

@ -0,0 +1,81 @@
import { fileURLToPath, pathToFileURL } from 'node:url';
import { join } from 'node:path';
import { ASTRO_LOCALS_HEADER } from './adapter.js';
import { existsSync } from 'fs';
/**
* It generates the Vercel Edge Middleware file.
*
* It creates a temporary file, the edge middleware, with some dynamic info.
*
* Then this file gets bundled with esbuild. The bundle phase will inline the Astro middleware code.
*
* @param astroMiddlewareEntryPoint
* @param outPath
* @returns {Promise<URL>} The path to the bundled file
*/
export async function generateEdgeMiddleware(
astroMiddlewareEntryPointPath: URL,
outPath: string,
vercelEdgeMiddlewareHandlerPath: URL
): Promise<URL> {
const entryPointPathURLAsString = JSON.stringify(
fileURLToPath(astroMiddlewareEntryPointPath).replace(/\\/g, '/')
);
const code = edgeMiddlewareTemplate(entryPointPathURLAsString, vercelEdgeMiddlewareHandlerPath);
// https://vercel.com/docs/concepts/functions/edge-middleware#create-edge-middleware
const bundledFilePath = join(outPath, 'middleware.mjs');
const esbuild = await import('esbuild');
await esbuild.build({
stdin: {
contents: code,
resolveDir: process.cwd(),
},
target: 'es2020',
platform: 'browser',
// https://runtime-keys.proposal.wintercg.org/#edge-light
conditions: ['edge-light', 'worker', 'browser'],
external: ['astro/middleware'],
outfile: bundledFilePath,
allowOverwrite: true,
format: 'esm',
bundle: true,
minify: false,
});
return pathToFileURL(bundledFilePath);
}
function edgeMiddlewareTemplate(middlewarePath: string, vercelEdgeMiddlewareHandlerPath: URL) {
const filePathEdgeMiddleware = fileURLToPath(vercelEdgeMiddlewareHandlerPath);
let handlerTemplateImport = '';
let handlerTemplateCall = '{}';
if (existsSync(filePathEdgeMiddleware) + '.js' || existsSync(filePathEdgeMiddleware) + '.ts') {
const stringified = JSON.stringify(filePathEdgeMiddleware.replace(/\\/g, '/'));
handlerTemplateImport = `import handler from ${stringified}`;
handlerTemplateCall = `handler({ request, context })`;
} else {
}
return `
${handlerTemplateImport}
import { onRequest } from ${middlewarePath};
import { createContext, trySerializeLocals } from 'astro/middleware';
export default async function middleware(request, context) {
const url = new URL(request.url);
const ctx = createContext({
request,
params: {}
});
ctx.locals = ${handlerTemplateCall};
const next = async () => {
const response = await fetch(url, {
headers: {
${JSON.stringify(ASTRO_LOCALS_HEADER)}: trySerializeLocals(ctx.locals)
}
});
return response;
};
return onRequest(ctx, next);
}`;
}

View file

@ -0,0 +1,30 @@
import { loadFixture } from './test-utils.js';
import { expect, use } from 'chai';
import chaiJestSnapshot from 'chai-jest-snapshot';
use(chaiJestSnapshot);
describe('Serverless prerender', () => {
/** @type {import('./test-utils').Fixture} */
let fixture;
beforeEach(function () {
chaiJestSnapshot.configureUsingMochaContext(this);
});
before(async () => {
chaiJestSnapshot.resetSnapshotRegistry();
fixture = await loadFixture({
root: './fixtures/middleware/',
});
});
it('build successfully the middleware edge file', async () => {
await fixture.build();
const contents = await fixture.readFile(
// this is abysmal...
'../.vercel/output/functions/render.func/packages/integrations/vercel/test/fixtures/middleware/dist/middleware.mjs'
);
expect(contents).to.matchSnapshot();
});
});

View file

@ -0,0 +1,40 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Serverless prerender build successfully the middleware edge file 1`] = `
"// test/fixtures/middleware/src/vercel-edge-middleware.js
function vercel_edge_middleware_default({ request, context }) {
return {
title: \\"Hello world\\"
};
}
// test/fixtures/middleware/dist/middleware2.mjs
var onRequest = async (context, next) => {
const response = await next();
return response;
};
// <stdin>
import { createContext, trySerializeLocals } from \\"astro/middleware\\";
async function middleware(request, context) {
const url = new URL(request.url);
const ctx = createContext({
request,
params: {}
});
ctx.locals = vercel_edge_middleware_default({ request, context });
const next = async () => {
const response = await fetch(url, {
headers: {
\\"x-astro-locals\\": trySerializeLocals(ctx.locals)
}
});
return response;
};
return onRequest(ctx, next);
}
export {
middleware as default
};
"
`;

View file

@ -0,0 +1,10 @@
import {defineConfig} from "astro/config";
import vercel from "@astrojs/vercel/serverless";
export default defineConfig({
adapter: vercel(),
build: {
excludeMiddleware: true
},
output: 'server'
});

View file

@ -0,0 +1,9 @@
{
"name": "@test/vercel-edge-middleware",
"version": "0.0.0",
"private": true,
"dependencies": {
"@astrojs/vercel": "workspace:*",
"astro": "workspace:*"
}
}

View file

@ -0,0 +1,8 @@
/**
* @type {import("astro").MiddlewareResponseHandler}
*/
export const onRequest = async (context, next) => {
const test = 'something';
const response = await next();
return response;
};

View file

@ -0,0 +1,5 @@
export default function ({ request, context }) {
return {
title: 'Hello world',
};
}

View file

@ -4898,6 +4898,9 @@ importers:
'@types/set-cookie-parser':
specifier: ^2.4.2
version: 2.4.2
'@vercel/edge':
specifier: ^0.3.4
version: 0.3.4
astro:
specifier: workspace:*
version: link:../../astro
@ -4907,12 +4910,18 @@ importers:
chai:
specifier: ^4.3.7
version: 4.3.7
chai-jest-snapshot:
specifier: ^2.0.0
version: 2.0.0(chai@4.3.7)
cheerio:
specifier: 1.0.0-rc.12
version: 1.0.0-rc.12
mocha:
specifier: ^9.2.2
version: 9.2.2
rollup:
specifier: ^3.20.1
version: 3.25.1
packages/integrations/vercel/test/fixtures/basic:
dependencies:
@ -4932,6 +4941,15 @@ importers:
specifier: workspace:*
version: link:../../../../../astro
packages/integrations/vercel/test/fixtures/middleware:
dependencies:
'@astrojs/vercel':
specifier: workspace:*
version: link:../../..
astro:
specifier: workspace:*
version: link:../../../../../astro
packages/integrations/vercel/test/fixtures/no-output:
dependencies:
'@astrojs/vercel':
@ -9015,6 +9033,10 @@ packages:
optional: true
dev: false
/@vercel/edge@0.3.4:
resolution: {integrity: sha512-dFU+yAUDQRwpuRGxRDlEO1LMq0y1LGsBgkyryQWe4w15/Fy2/lCnpvdIoAhHl3QvIGAxCLHzwRHsqfLRdpxgJQ==}
dev: true
/@vercel/nft@0.22.6:
resolution: {integrity: sha512-gTsFnnT4mGxodr4AUlW3/urY+8JKKB452LwF3m477RFUJTAaDmcz2JqFuInzvdybYIeyIv1sSONEJxsxnbQ5JQ==}
engines: {node: '>=14'}
@ -9355,6 +9377,11 @@ packages:
type-fest: 1.4.0
dev: false
/ansi-regex@3.0.1:
resolution: {integrity: sha512-+O9Jct8wf++lXxxFc4hc8LsjaSq0HFzzL7cVsw8pRDIPdjKD2mT4ytDZlLuSBZ4cLKZFXIrMGO7DbQCtMJJMKw==}
engines: {node: '>=4'}
dev: true
/ansi-regex@5.0.1:
resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==}
engines: {node: '>=8'}
@ -9885,6 +9912,16 @@ packages:
check-error: 1.0.2
dev: true
/chai-jest-snapshot@2.0.0(chai@4.3.7):
resolution: {integrity: sha512-u8jZZjw/0G1t5A8wDfH6K7DAVfMg3g0dsw9wKQURNUyrZX96VojHNrFMmLirq1m0kOvC5icgL/Qh/fu1MZyvUw==}
peerDependencies:
chai: '>=1.9.0'
dependencies:
chai: 4.3.7
jest-snapshot: 21.2.1
lodash.values: 4.3.0
dev: true
/chai-xml@0.4.1(chai@4.3.7):
resolution: {integrity: sha512-VUf5Ol4ifOAsgz+lN4tfWENgQtrKxHPWsmpL5wdbqQdkpblZkcDlaT2aFvsPQH219Yvl8vc4064yFErgBIn9bw==}
engines: {node: '>= 0.8.0'}
@ -10594,6 +10631,11 @@ packages:
/didyoumean@1.2.2:
resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==}
/diff@3.5.0:
resolution: {integrity: sha512-A46qtFgd+g7pDZinpnwiRJtxbC1hpgf0uzP3iG89scHk0AUC7A1TGxf5OiiOUv/JMZR8GOt8hL900hV0bOy5xA==}
engines: {node: '>=0.3.1'}
dev: true
/diff@5.0.0:
resolution: {integrity: sha512-/VTCrvm5Z0JGty/BWHljh+BAiw3IK+2j87NGMu8Nwc/f48WoDAC395uomO9ZD117ZOBaHmkX1oyLvkVM/aIT3w==}
engines: {node: '>=0.3.1'}
@ -12837,6 +12879,38 @@ packages:
minimatch: 3.1.2
dev: false
/jest-diff@21.2.1:
resolution: {integrity: sha512-E5fu6r7PvvPr5qAWE1RaUwIh/k6Zx/3OOkZ4rk5dBJkEWRrUuSgbMt2EO8IUTPTd6DOqU3LW6uTIwX5FRvXoFA==}
dependencies:
chalk: 2.4.2
diff: 3.5.0
jest-get-type: 21.2.0
pretty-format: 21.2.1
dev: true
/jest-get-type@21.2.0:
resolution: {integrity: sha512-y2fFw3C+D0yjNSDp7ab1kcd6NUYfy3waPTlD8yWkAtiocJdBRQqNoRqVfMNxgj+IjT0V5cBIHJO0z9vuSSZ43Q==}
dev: true
/jest-matcher-utils@21.2.1:
resolution: {integrity: sha512-kn56My+sekD43dwQPrXBl9Zn9tAqwoy25xxe7/iY4u+mG8P3ALj5IK7MLHZ4Mi3xW7uWVCjGY8cm4PqgbsqMCg==}
dependencies:
chalk: 2.4.2
jest-get-type: 21.2.0
pretty-format: 21.2.1
dev: true
/jest-snapshot@21.2.1:
resolution: {integrity: sha512-bpaeBnDpdqaRTzN8tWg0DqOTo2DvD3StOemxn67CUd1p1Po+BUpvePAp44jdJ7Pxcjfg+42o4NHw1SxdCA2rvg==}
dependencies:
chalk: 2.4.2
jest-diff: 21.2.1
jest-matcher-utils: 21.2.1
mkdirp: 0.5.6
natural-compare: 1.4.0
pretty-format: 21.2.1
dev: true
/jest-worker@26.6.2:
resolution: {integrity: sha512-KWYVV1c4i+jbMpaBC+U++4Va0cp8OisU185o73T1vo99hqi7w8tSJfUXYswwqqrjzwxa6KpRK54WhPvwf5w6PQ==}
engines: {node: '>= 10.13.0'}
@ -13134,6 +13208,10 @@ packages:
resolution: {integrity: sha512-+WKqsK294HMSc2jEbNgpHpd0JfIBhp7rEV4aqXWqFr6AlXov+SlcgB1Fv01y2kGe3Gc8nMW7VA0SrGuSkRfIEg==}
dev: true
/lodash.values@4.3.0:
resolution: {integrity: sha512-r0RwvdCv8id9TUblb/O7rYPwVy6lerCbcawrfdo9iC/1t1wsNMJknO79WNBgwkH0hIeJ08jmvvESbFpNb4jH0Q==}
dev: true
/lodash@4.17.21:
resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==}
dev: false
@ -14018,6 +14096,13 @@ packages:
/mkdirp-classic@0.5.3:
resolution: {integrity: sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==}
/mkdirp@0.5.6:
resolution: {integrity: sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==}
hasBin: true
dependencies:
minimist: 1.2.8
dev: true
/mkdirp@1.0.4:
resolution: {integrity: sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==}
engines: {node: '>=10'}
@ -15221,6 +15306,13 @@ packages:
engines: {node: ^14.13.1 || >=16.0.0}
dev: false
/pretty-format@21.2.1:
resolution: {integrity: sha512-ZdWPGYAnYfcVP8yKA3zFjCn8s4/17TeYH28MXuC8vTp0o21eXjbFGcOAXZEaDaOFJjc3h2qa7HQNHNshhvoh2A==}
dependencies:
ansi-regex: 3.0.1
ansi-styles: 3.2.1
dev: true
/pretty-format@27.5.1:
resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==}
engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0}