diff options
14 files changed, 307 insertions, 11 deletions
diff --git a/packages/integrations/vercel/README.md b/packages/integrations/vercel/README.md index 41a5591dc..19d841a2f 100644 --- a/packages/integrations/vercel/README.md +++ b/packages/integrations/vercel/README.md @@ -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 [Vercel’s 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 [Vercel’s 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`. diff --git a/packages/integrations/vercel/package.json b/packages/integrations/vercel/package.json index 3899b9069..a039ee5a8 100644 --- a/packages/integrations/vercel/package.json +++ b/packages/integrations/vercel/package.json @@ -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" } } diff --git a/packages/integrations/vercel/src/lib/fs.ts b/packages/integrations/vercel/src/lib/fs.ts index 18fbe85d2..51b12d52f 100644 --- a/packages/integrations/vercel/src/lib/fs.ts +++ b/packages/integrations/vercel/src/lib/fs.ts @@ -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' }); +} diff --git a/packages/integrations/vercel/src/lib/nft.ts b/packages/integrations/vercel/src/lib/nft.ts index 46604db90..752f87251 100644 --- a/packages/integrations/vercel/src/lib/nft.ts +++ b/packages/integrations/vercel/src/lib/nft.ts @@ -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), }); diff --git a/packages/integrations/vercel/src/serverless/adapter.ts b/packages/integrations/vercel/src/serverless/adapter.ts index 007fb8537..9d799a7bf 100644 --- a/packages/integrations/vercel/src/serverless/adapter.ts +++ b/packages/integrations/vercel/src/serverless/adapter.ts @@ -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' }); } diff --git a/packages/integrations/vercel/src/serverless/entrypoint.ts b/packages/integrations/vercel/src/serverless/entrypoint.ts index 71ad2bfae..3c0e22a28 100644 --- a/packages/integrations/vercel/src/serverless/entrypoint.ts +++ b/packages/integrations/vercel/src/serverless/entrypoint.ts @@ -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 }; diff --git a/packages/integrations/vercel/src/serverless/middleware.ts b/packages/integrations/vercel/src/serverless/middleware.ts new file mode 100644 index 000000000..2f05756c6 --- /dev/null +++ b/packages/integrations/vercel/src/serverless/middleware.ts @@ -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); +}`; +} diff --git a/packages/integrations/vercel/test/edge-middleware.test.js b/packages/integrations/vercel/test/edge-middleware.test.js new file mode 100644 index 000000000..dd4b25b67 --- /dev/null +++ b/packages/integrations/vercel/test/edge-middleware.test.js @@ -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(); + }); +}); diff --git a/packages/integrations/vercel/test/edge-middleware.test.js.snap b/packages/integrations/vercel/test/edge-middleware.test.js.snap new file mode 100644 index 000000000..fe82ccff9 --- /dev/null +++ b/packages/integrations/vercel/test/edge-middleware.test.js.snap @@ -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 +}; +" +`; diff --git a/packages/integrations/vercel/test/fixtures/middleware/astro.config.mjs b/packages/integrations/vercel/test/fixtures/middleware/astro.config.mjs new file mode 100644 index 000000000..321a8bde3 --- /dev/null +++ b/packages/integrations/vercel/test/fixtures/middleware/astro.config.mjs @@ -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' +});
\ No newline at end of file diff --git a/packages/integrations/vercel/test/fixtures/middleware/package.json b/packages/integrations/vercel/test/fixtures/middleware/package.json new file mode 100644 index 000000000..9ba60852d --- /dev/null +++ b/packages/integrations/vercel/test/fixtures/middleware/package.json @@ -0,0 +1,9 @@ +{ + "name": "@test/vercel-edge-middleware", + "version": "0.0.0", + "private": true, + "dependencies": { + "@astrojs/vercel": "workspace:*", + "astro": "workspace:*" + } +} diff --git a/packages/integrations/vercel/test/fixtures/middleware/src/middleware.js b/packages/integrations/vercel/test/fixtures/middleware/src/middleware.js new file mode 100644 index 000000000..349a0aa79 --- /dev/null +++ b/packages/integrations/vercel/test/fixtures/middleware/src/middleware.js @@ -0,0 +1,8 @@ +/** + * @type {import("astro").MiddlewareResponseHandler} + */ +export const onRequest = async (context, next) => { + const test = 'something'; + const response = await next(); + return response; +}; diff --git a/packages/integrations/vercel/test/fixtures/middleware/src/pages/index.astro b/packages/integrations/vercel/test/fixtures/middleware/src/pages/index.astro new file mode 100644 index 000000000..e69de29bb --- /dev/null +++ b/packages/integrations/vercel/test/fixtures/middleware/src/pages/index.astro diff --git a/packages/integrations/vercel/test/fixtures/middleware/src/vercel-edge-middleware.js b/packages/integrations/vercel/test/fixtures/middleware/src/vercel-edge-middleware.js new file mode 100644 index 000000000..bf69edb3e --- /dev/null +++ b/packages/integrations/vercel/test/fixtures/middleware/src/vercel-edge-middleware.js @@ -0,0 +1,5 @@ +export default function ({ request, context }) { + return { + title: 'Hello world', + }; +} |