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

fix(vercel): edge middleware #9585

Merged
merged 12 commits into from
Jan 22, 2024
5 changes: 5 additions & 0 deletions .changeset/brown-parents-sniff.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@astrojs/vercel": patch
---

Fixes an issue where edge middleware did not work.
2 changes: 0 additions & 2 deletions packages/integrations/vercel/src/lib/fs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
130 changes: 75 additions & 55 deletions packages/integrations/vercel/src/serverless/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -111,8 +111,8 @@ export interface VercelServerlessConfig {
export default function vercelServerless({
webAnalytics,
speedInsights,
includeFiles,
excludeFiles = [],
includeFiles: _includeFiles = [],
excludeFiles: _excludeFiles = [],
imageService,
imagesConfig,
devImageService = 'sharp',
Expand All @@ -130,9 +130,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[] = [];

Expand Down Expand Up @@ -162,13 +163,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: {
Expand All @@ -195,10 +195,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(
Expand All @@ -208,20 +210,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
Expand All @@ -240,9 +229,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);

Expand All @@ -267,7 +261,7 @@ export default function vercelServerless({
config: _config,
logger,
NTF_CACHE,
includeFiles: filesToInclude,
includeFiles,
excludeFiles,
maxDuration,
});
Expand All @@ -278,24 +272,28 @@ export default function vercelServerless({
}
} else {
await createFunctionFolder({
functionName: 'render',
functionName: '_render',
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' : '_render';
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',
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why _middleware and not middleware?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Vercel routes the folder names to a path on the deployed website so we avoid interfering this way.

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
Expand All @@ -314,7 +312,9 @@ export default function vercelServerless({
? [
{
src: '/.*',
dest: fourOhFourRoute.prerender ? '/404.html' : 'render',
dest: fourOhFourRoute.prerender ? '/404.html'
: _middlewareEntryPoint ? '_middleware'
: 'render',
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this use dest instead? Or the render should be _render

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we should move this strings (_render, _middleware) to some constants, so we don't do any typo

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're right, it is supposed to be _render. Moved to constants.

status: 404,
},
]
Expand All @@ -337,14 +337,39 @@ export default function vercelServerless({
});

// Remove temporary folder
await removeDir(buildTempFolder);
await removeDir(_buildTempFolder);
},
},
};
}

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',
});
}

Comment on lines +364 to +388
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We create an edge function, complete with its own folder and vc-config.

interface CreateFunctionFolderArgs {
functionName: string;
runtime: Runtime;
Expand All @@ -353,7 +378,7 @@ interface CreateFunctionFolderArgs {
logger: AstroIntegrationLogger;
NTF_CACHE: any;
includeFiles: URL[];
excludeFiles: string[];
excludeFiles: URL[];
maxDuration: number | undefined;
}

Expand All @@ -379,7 +404,7 @@ async function createFunctionFolder({
entry,
outDir: functionFolder,
includeFiles,
excludeFiles: excludeFiles.map((file) => new URL(file, config.root)),
excludeFiles,
logger,
},
NTF_CACHE
Expand All @@ -393,7 +418,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("\\","/"),
ematipico marked this conversation as resolved.
Show resolved Hide resolved
launcherType: 'Nodejs',
maxDuration,
supportsResponseStreaming: true,
Expand All @@ -411,15 +436,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
);
Expand All @@ -430,14 +458,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';
}
4 changes: 4 additions & 0 deletions packages/integrations/vercel/src/serverless/entrypoint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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['x-astro-path'];
if (typeof realPath === 'string') {
req.url = realPath;
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What's this change about?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is so that the edge function can call the serverless function in next().

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I still think it's worth adding a comment because the read and write of the header happens in two different places, so it could be helpful to give some context.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

const locals =
typeof localsHeader === 'string'
? JSON.parse(localsHeader)
Expand Down
42 changes: 23 additions & 19 deletions packages/integrations/vercel/src/serverless/middleware.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
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';

/**
* It generates the Vercel Edge Middleware file.
Expand All @@ -16,16 +15,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: {
Expand All @@ -36,17 +31,26 @@ 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,
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 }));
},
}]
ematipico marked this conversation as resolved.
Show resolved Hide resolved
});
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 = '{}';
Expand All @@ -61,20 +65,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('/_render', request.url), {
headers: {
${JSON.stringify(ASTRO_LOCALS_HEADER)}: trySerializeLocals(ctx.locals)
...Object.fromEntries(request.headers.entries()),
'x-astro-path': request.url.replace(origin, ''),
'x-astro-locals': trySerializeLocals(ctx.locals)
}
});
return response;
};
})
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The edge function calls the node server at /_render, with the original path as a header.


return onRequest(ctx, next);
}`;
Expand Down
Loading
Loading