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

fix(vercel): edge middleware (#9585)

* create vercel edge middleware

remove getVercelOutput

* handle node built-in modules

* edge function to node fetch

* adjust tests

* add test

* add changeset

* function paths as constants

* ensure node built-in modules are namespaced with `node:`

* x-astro-path as constant

* appease linter

* add comments for ASTRO_PATH_HEADER and ASTRO_LOCALS_HEADER

---------

Co-authored-by: Nate Moore <natemoo-re@users.noreply.github.com>
This commit is contained in:
Arsh 2024-01-22 23:34:48 +00:00 committed by GitHub
parent a50926a6b6
commit 05adaaa2d2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 161 additions and 84 deletions

View file

@ -0,0 +1,5 @@
---
"@astrojs/vercel": patch
---
Fixes an issue where edge middleware did not work.

View file

@ -31,8 +31,6 @@ export async function getFilesFromFolder(dir: URL) {
return files;
}
export const getVercelOutput = (root: URL) => new URL('./.vercel/output/', root);
/**
* Copies files into a folder keeping the folder structure intact.
* The resulting file tree will start at the common ancestor.

View file

@ -8,14 +8,14 @@ import type {
import { AstroError } from 'astro/errors';
import glob from 'fast-glob';
import { basename } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { pathToFileURL } from 'node:url';
import {
getAstroImageConfig,
getDefaultImageConfig,
type DevImageService,
type VercelImageConfig,
} from '../image/shared.js';
import { getVercelOutput, removeDir, writeJson } from '../lib/fs.js';
import { removeDir, writeJson } from '../lib/fs.js';
import { copyDependenciesToFunction } from '../lib/nft.js';
import { getRedirects } from '../lib/redirects.js';
import {
@ -29,9 +29,25 @@ import {
import { generateEdgeMiddleware } from './middleware.js';
const PACKAGE_NAME = '@astrojs/vercel/serverless';
/**
* The edge function calls the node server at /_render,
* with the original path as the value of this header.
*/
export const ASTRO_PATH_HEADER = 'x-astro-path';
/**
* The edge function calls the node server at /_render,
* with the locals serialized into this header.
*/
export const ASTRO_LOCALS_HEADER = 'x-astro-locals';
export const VERCEL_EDGE_MIDDLEWARE_FILE = 'vercel-edge-middleware';
// Vercel routes the folder names to a path on the deployed website.
// We attempt to avoid interfering by prefixing with an underscore.
export const NODE_PATH = '_render';
const MIDDLEWARE_PATH = '_middleware';
// https://vercel.com/docs/concepts/functions/serverless-functions/runtimes/node-js#node.js-version
const SUPPORTED_NODE_VERSIONS: Record<
string,
@ -111,8 +127,8 @@ export interface VercelServerlessConfig {
export default function vercelServerless({
webAnalytics,
speedInsights,
includeFiles,
excludeFiles = [],
includeFiles: _includeFiles = [],
excludeFiles: _excludeFiles = [],
imageService,
imagesConfig,
devImageService = 'sharp',
@ -130,9 +146,10 @@ export default function vercelServerless({
}
let _config: AstroConfig;
let buildTempFolder: URL;
let serverEntry: string;
let _buildTempFolder: URL;
let _serverEntry: string;
let _entryPoints: Map<RouteData, URL>;
let _middlewareEntryPoint: URL | undefined;
// Extra files to be merged with `includeFiles` during build
const extraFilesToInclude: URL[] = [];
@ -162,13 +179,12 @@ export default function vercelServerless({
if (command === 'build' && speedInsights?.enabled) {
injectScript('page', 'import "@astrojs/vercel/speed-insights"');
}
const outDir = getVercelOutput(config.root);
updateConfig({
outDir,
outDir: new URL('./.vercel/output/', config.root),
build: {
serverEntry: 'entry.mjs',
client: new URL('./static/', outDir),
server: new URL('./dist/', config.root),
client: new URL('./.vercel/output/static/', config.root),
server: new URL('./.vercel/output/_functions/', config.root),
redirects: false,
},
vite: {
@ -195,10 +211,12 @@ export default function vercelServerless({
`\tYou can set functionPerRoute: false to prevent surpassing the limit.\n`
);
}
setAdapter(getAdapter({ functionPerRoute, edgeMiddleware }));
_config = config;
buildTempFolder = config.build.server;
serverEntry = config.build.serverEntry;
_buildTempFolder = config.build.server;
_serverEntry = config.build.serverEntry;
if (config.output === 'static') {
throw new AstroError(
@ -208,20 +226,7 @@ export default function vercelServerless({
},
'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
extraFilesToInclude.push(bundledMiddlewarePath);
}
_middlewareEntryPoint = middlewareEntryPoint;
},
'astro:build:done': async ({ routes, logger }) => {
// Merge any includes from `vite.assetsInclude
@ -240,9 +245,14 @@ export default function vercelServerless({
mergeGlobbedIncludes(_config.vite.assetsInclude);
}
const routeDefinitions: { src: string; dest: string }[] = [];
const filesToInclude = includeFiles?.map((file) => new URL(file, _config.root)) || [];
filesToInclude.push(...extraFilesToInclude);
const routeDefinitions: Array<{
src: string
dest: string
middlewarePath?: string
}> = [];
const includeFiles = _includeFiles.map((file) => new URL(file, _config.root)).concat(extraFilesToInclude);
const excludeFiles = _excludeFiles.map((file) => new URL(file, _config.root));
const runtime = getRuntime(process, logger);
@ -267,7 +277,7 @@ export default function vercelServerless({
config: _config,
logger,
NTF_CACHE,
includeFiles: filesToInclude,
includeFiles,
excludeFiles,
maxDuration,
});
@ -278,24 +288,28 @@ export default function vercelServerless({
}
} else {
await createFunctionFolder({
functionName: 'render',
functionName: NODE_PATH,
runtime,
entry: new URL(serverEntry, buildTempFolder),
entry: new URL(_serverEntry, _buildTempFolder),
config: _config,
logger,
NTF_CACHE,
includeFiles: filesToInclude,
includeFiles,
excludeFiles,
maxDuration,
});
const dest = _middlewareEntryPoint ? MIDDLEWARE_PATH : NODE_PATH;
for (const route of routes) {
if (route.prerender) continue;
routeDefinitions.push({
src: route.pattern.source,
dest: 'render',
});
if (!route.prerender) routeDefinitions.push({ src: route.pattern.source, dest });
}
}
if (_middlewareEntryPoint) {
await createMiddlewareFolder({
functionName: MIDDLEWARE_PATH,
entry: _middlewareEntryPoint,
config: _config,
});
}
const fourOhFourRoute = routes.find((route) => route.pathname === '/404');
// Output configuration
// https://vercel.com/docs/build-output-api/v3#build-output-configuration
@ -314,7 +328,9 @@ export default function vercelServerless({
? [
{
src: '/.*',
dest: fourOhFourRoute.prerender ? '/404.html' : 'render',
dest: fourOhFourRoute.prerender ? '/404.html'
: _middlewareEntryPoint ? MIDDLEWARE_PATH
: NODE_PATH,
status: 404,
},
]
@ -337,7 +353,7 @@ export default function vercelServerless({
});
// Remove temporary folder
await removeDir(buildTempFolder);
await removeDir(_buildTempFolder);
},
},
};
@ -345,6 +361,31 @@ export default function vercelServerless({
type Runtime = `nodejs${string}.x`;
interface CreateMiddlewareFolderArgs {
config: AstroConfig
entry: URL
functionName: string
}
async function createMiddlewareFolder({
functionName,
entry,
config,
}: CreateMiddlewareFolderArgs) {
const functionFolder = new URL(`./functions/${functionName}.func/`, config.outDir);
await generateEdgeMiddleware(
entry,
new URL(VERCEL_EDGE_MIDDLEWARE_FILE, config.srcDir),
new URL('./middleware.mjs', functionFolder),
)
await writeJson(new URL(`./.vc-config.json`, functionFolder), {
runtime: 'edge',
entrypoint: 'middleware.mjs',
});
}
interface CreateFunctionFolderArgs {
functionName: string;
runtime: Runtime;
@ -353,7 +394,7 @@ interface CreateFunctionFolderArgs {
logger: AstroIntegrationLogger;
NTF_CACHE: any;
includeFiles: URL[];
excludeFiles: string[];
excludeFiles: URL[];
maxDuration: number | undefined;
}
@ -379,7 +420,7 @@ async function createFunctionFolder({
entry,
outDir: functionFolder,
includeFiles,
excludeFiles: excludeFiles.map((file) => new URL(file, config.root)),
excludeFiles,
logger,
},
NTF_CACHE
@ -393,7 +434,7 @@ async function createFunctionFolder({
// https://vercel.com/docs/build-output-api/v3#vercel-primitives/serverless-functions/configuration
await writeJson(vcConfig, {
runtime,
handler,
handler: handler.replaceAll("\\","/"),
launcherType: 'Nodejs',
maxDuration,
supportsResponseStreaming: true,
@ -411,15 +452,18 @@ function getRuntime(process: NodeJS.Process, logger: AstroIntegrationLogger): Ru
`\tYour project will use Node.js 18 as the runtime instead.\n` +
`\tConsider switching your local version to 18.\n`
);
return 'nodejs18.x';
}
if (support.status === 'current') {
return `nodejs${major}.x`;
} else if (support?.status === 'beta') {
}
if (support.status === 'beta') {
logger.warn(
`Your project is being built for Node.js ${major} as the runtime, which is currently in beta for Vercel Serverless Functions.`
);
return `nodejs${major}.x`;
} else if (support.status === 'deprecated') {
}
if (support.status === 'deprecated') {
const removeDate = new Intl.DateTimeFormat(undefined, { dateStyle: 'long' }).format(
support.removal
);
@ -430,14 +474,6 @@ function getRuntime(process: NodeJS.Process, logger: AstroIntegrationLogger): Ru
`\tConsider upgrading your local version to 18.\n`
);
return `nodejs${major}.x`;
} else {
logger.warn(
`\n` +
`\tThe local Node.js version (${major}) is not supported by Vercel Serverless Functions.\n` +
`\tYour project will use Node.js 18 as the runtime instead.\n` +
`\tConsider switching your local version to 18.\n`
);
return 'nodejs18.x';
}
return `nodejs${major}.x`;
return 'nodejs18.x';
}

View file

@ -1,7 +1,7 @@
import type { SSRManifest } from 'astro';
import { applyPolyfills, NodeApp } from 'astro/app/node';
import type { IncomingMessage, ServerResponse } from 'node:http';
import { ASTRO_LOCALS_HEADER } from './adapter.js';
import { ASTRO_PATH_HEADER, ASTRO_LOCALS_HEADER } from './adapter.js';
applyPolyfills();
@ -10,6 +10,10 @@ export const createExports = (manifest: SSRManifest) => {
const handler = async (req: IncomingMessage, res: ServerResponse) => {
const clientAddress = req.headers['x-forwarded-for'] as string | undefined;
const localsHeader = req.headers[ASTRO_LOCALS_HEADER];
const realPath = req.headers[ASTRO_PATH_HEADER];
if (typeof realPath === 'string') {
req.url = realPath;
}
const locals =
typeof localsHeader === 'string'
? JSON.parse(localsHeader)

View file

@ -1,7 +1,7 @@
import { existsSync } from 'node:fs';
import { join } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { ASTRO_LOCALS_HEADER } from './adapter.js';
import { builtinModules } from 'node:module';
import { ASTRO_LOCALS_HEADER, ASTRO_PATH_HEADER, NODE_PATH } from './adapter.js';
/**
* It generates the Vercel Edge Middleware file.
@ -16,16 +16,12 @@ import { ASTRO_LOCALS_HEADER } from './adapter.js';
*/
export async function generateEdgeMiddleware(
astroMiddlewareEntryPointPath: URL,
outPath: string,
vercelEdgeMiddlewareHandlerPath: URL
vercelEdgeMiddlewareHandlerPath: URL,
outPath: URL,
): Promise<URL> {
const entryPointPathURLAsString = JSON.stringify(
fileURLToPath(astroMiddlewareEntryPointPath).replace(/\\/g, '/')
);
const code = edgeMiddlewareTemplate(entryPointPathURLAsString, vercelEdgeMiddlewareHandlerPath);
const code = edgeMiddlewareTemplate(astroMiddlewareEntryPointPath, vercelEdgeMiddlewareHandlerPath);
// https://vercel.com/docs/concepts/functions/edge-middleware#create-edge-middleware
const bundledFilePath = join(outPath, 'middleware.mjs');
const bundledFilePath = fileURLToPath(outPath);
const esbuild = await import('esbuild');
await esbuild.build({
stdin: {
@ -36,17 +32,27 @@ export async function generateEdgeMiddleware(
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,
// ensure node built-in modules are namespaced with `node:`
plugins: [{
name: 'esbuild-namespace-node-built-in-modules',
setup(build) {
const filter = new RegExp(builtinModules.map((mod) => `(^${mod}$)`).join('|'));
build.onResolve({ filter }, (args) => ({ path: 'node:' + args.path, external: true }));
},
}]
});
return pathToFileURL(bundledFilePath);
}
function edgeMiddlewareTemplate(middlewarePath: string, vercelEdgeMiddlewareHandlerPath: URL) {
function edgeMiddlewareTemplate(astroMiddlewareEntryPointPath: URL, vercelEdgeMiddlewareHandlerPath: URL) {
const middlewarePath = JSON.stringify(
fileURLToPath(astroMiddlewareEntryPointPath).replace(/\\/g, '/')
);
const filePathEdgeMiddleware = fileURLToPath(vercelEdgeMiddlewareHandlerPath);
let handlerTemplateImport = '';
let handlerTemplateCall = '{}';
@ -61,20 +67,20 @@ function edgeMiddlewareTemplate(middlewarePath: string, vercelEdgeMiddlewareHand
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, {
const { origin } = new URL(request.url);
const next = () =>
fetch(new URL('${NODE_PATH}', request.url), {
headers: {
${JSON.stringify(ASTRO_LOCALS_HEADER)}: trySerializeLocals(ctx.locals)
...Object.fromEntries(request.headers.entries()),
'${ASTRO_PATH_HEADER}': request.url.replace(origin, ''),
'${ASTRO_LOCALS_HEADER}': trySerializeLocals(ctx.locals)
}
});
return response;
};
})
return onRequest(ctx, next);
}`;

View file

@ -6,7 +6,7 @@ import {
type DevImageService,
type VercelImageConfig,
} from '../image/shared.js';
import { emptyDir, getVercelOutput, writeJson } from '../lib/fs.js';
import { emptyDir, writeJson } from '../lib/fs.js';
import { isServerLikeOutput } from '../lib/prerender.js';
import { getRedirects } from '../lib/redirects.js';
import {
@ -79,7 +79,7 @@ export default function vercelStatic({
if (command === 'build' && speedInsights?.enabled) {
injectScript('page', 'import "@astrojs/vercel/speed-insights"');
}
const outDir = new URL('./static/', getVercelOutput(config.root));
const outDir = new URL('./.vercel/output/static/', config.root);
updateConfig({
outDir,
build: {
@ -110,12 +110,12 @@ export default function vercelStatic({
// Ensure to have `.vercel/output` empty.
// This is because, when building to static, outDir = .vercel/output/static/,
// so .vercel/output itself won't get cleaned.
await emptyDir(getVercelOutput(_config.root));
await emptyDir(new URL('./.vercel/output/', _config.root));
},
'astro:build:done': async ({ routes }) => {
// Output configuration
// https://vercel.com/docs/build-output-api/v3#build-output-configuration
await writeJson(new URL(`./config.json`, getVercelOutput(_config.root)), {
await writeJson(new URL('./.vercel/output/config.json', _config.root), {
version: 3,
routes: [
...getRedirects(routes, _config),

View file

@ -3,6 +3,34 @@ import chaiJestSnapshot from 'chai-jest-snapshot';
import { loadFixture } from './test-utils.js';
describe('Vercel edge middleware', () => {
/** @type {import('../../../astro/test/test-utils.js').Fixture} */
let build;
before(async () => {
build = await loadFixture({
root: './fixtures/middleware-with-edge-file/',
});
await build.build();
});
it('an edge function is created', async () => {
const contents = await build.readFile(
'../.vercel/output/functions/_middleware.func/.vc-config.json'
);
expect(JSON.parse(contents)).to.deep.include({
"runtime": "edge",
"entrypoint": "middleware.mjs"
});
});
it('deployment config points to the middleware edge function', async () => {
const contents = await build.readFile(
'../.vercel/output/config.json'
);
const { routes } = JSON.parse(contents);
expect(routes.some(route => route.dest === '_middleware')).to.be.true;
});
// TODO: The path here seems to be inconsistent?
it.skip('with edge handle file, should successfully build the middleware', async () => {
const fixture = await loadFixture({

View file

@ -14,7 +14,7 @@ describe('maxDuration', () => {
it('makes it to vercel function configuration', async () => {
const vcConfig = JSON.parse(
await fixture.readFile('../.vercel/output/functions/render.func/.vc-config.json')
await fixture.readFile('../.vercel/output/functions/_render.func/.vc-config.json')
);
expect(vcConfig).to.deep.include({ maxDuration: 60 });
});

View file

@ -1,7 +1,7 @@
import { loadFixture } from './test-utils.js';
import { expect } from 'chai';
describe('maxDuration', () => {
describe('streaming', () => {
/** @type {import('./test-utils.js').Fixture} */
let fixture;
@ -14,7 +14,7 @@ describe('maxDuration', () => {
it('makes it to vercel function configuration', async () => {
const vcConfig = JSON.parse(
await fixture.readFile('../.vercel/output/functions/render.func/.vc-config.json')
await fixture.readFile('../.vercel/output/functions/_render.func/.vc-config.json')
);
expect(vcConfig).to.deep.include({ supportsResponseStreaming: true });
});