Skip to content

Commit 80e10ba

Browse files
committed
Add new Vercel middleware implementation
1 parent 32b9c43 commit 80e10ba

File tree

7 files changed

+274
-8
lines changed

7 files changed

+274
-8
lines changed
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import { fileURLToPath } from "node:url";
2+
import type { AstroConfig, AstroIntegration, IntegrationResolvedRoute } from "astro";
3+
import { getAstroMiddlewarePath } from "./utils/astro";
4+
import { buildMiddlewareMatcherRegexp, type MatcherOptions } from "./utils/matcher";
5+
import { generateEdgeMiddlewareFile } from "./utils/middleware";
6+
import {
7+
generateFunctionConfig,
8+
getFunctionDir,
9+
insertMiddlewareRoute,
10+
} from "./utils/vercelOutput";
11+
12+
const INTEGRATION_NAME = "vercel-middleware";
13+
const VERCEL_ADAPTER_LINK = "[@astrojs/vercel](https://www.npmjs.com/package/@astrojs/vercel)";
14+
const VERCEL_MIDDLEWARE_FUNCTION_NAME = "_middleware";
15+
16+
type VercelMiddlewareIntegrationOptions = MatcherOptions;
17+
18+
export default function vercelMiddlewareIntegration(options?: VercelMiddlewareIntegrationOptions) {
19+
let astroConfig: AstroConfig;
20+
let resolvedRoutes: IntegrationResolvedRoute[];
21+
22+
const integration: AstroIntegration = {
23+
name: INTEGRATION_NAME,
24+
hooks: {
25+
"astro:routes:resolved": ({ routes, logger }) => {
26+
logger.info("Resolving routes for Vercel middleware matcher…");
27+
resolvedRoutes = routes;
28+
},
29+
"astro:config:done": ({ config }) => {
30+
astroConfig = config;
31+
},
32+
"astro:build:done": async ({ logger }) => {
33+
if (
34+
!astroConfig.integrations.some((integration) => integration.name === "@astrojs/vercel")
35+
) {
36+
logger.error(`${VERCEL_ADAPTER_LINK} must be installed to use ${INTEGRATION_NAME}.`);
37+
return;
38+
}
39+
40+
if (astroConfig.adapter?.name !== "@astrojs/vercel") {
41+
logger.error(
42+
`${VERCEL_ADAPTER_LINK} must be used as adapter for proper ${INTEGRATION_NAME} work.`,
43+
);
44+
return;
45+
}
46+
47+
const rootDir = fileURLToPath(astroConfig.root);
48+
49+
logger.info("Looking for Astro middleware…");
50+
const astroMiddlewarePath = await getAstroMiddlewarePath(rootDir);
51+
52+
if (!astroMiddlewarePath) {
53+
logger.warn("Astro middleware not found. Skipping Vercel middleware build.");
54+
return;
55+
}
56+
57+
logger.info(`Found middleware file at: ${astroMiddlewarePath}`);
58+
logger.info("Building Vercel middleware…");
59+
logger.info("Compiling edge middleware file…");
60+
const functionDir = getFunctionDir(rootDir, VERCEL_MIDDLEWARE_FUNCTION_NAME);
61+
const middlewareEntrypoint = await generateEdgeMiddlewareFile(
62+
rootDir,
63+
astroMiddlewarePath,
64+
functionDir,
65+
);
66+
logger.info("Creating edge middleware Vercel config file…");
67+
await generateFunctionConfig(functionDir, middlewareEntrypoint);
68+
69+
logger.info("Collecting routes which must be handled by middleware…");
70+
const matcher = buildMiddlewareMatcherRegexp({
71+
assetsDir: astroConfig.build.assets,
72+
routes: resolvedRoutes,
73+
...options,
74+
});
75+
76+
logger.info("Inserting generated middleware into vercel output config…");
77+
await insertMiddlewareRoute(rootDir, matcher, VERCEL_MIDDLEWARE_FUNCTION_NAME);
78+
79+
logger.info("Successfully created middleware function for Vercel deployment.");
80+
},
81+
},
82+
};
83+
84+
return integration;
85+
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import path from "node:path";
2+
import fs from "fs/promises";
3+
4+
const POSSIBLE_ASTRO_MIDDLEWARE_PATHS = [
5+
"src/middleware.ts",
6+
"src/middleware/index.ts",
7+
"src/middleware.js",
8+
"src/middleware/index.js",
9+
];
10+
11+
export async function getAstroMiddlewarePath(projectRootDir: string) {
12+
for (const possiblePath of POSSIBLE_ASTRO_MIDDLEWARE_PATHS) {
13+
const fullPath = path.join(projectRootDir, possiblePath);
14+
const exists = await fs
15+
.stat(fullPath)
16+
.then(() => true)
17+
.catch(() => false);
18+
19+
if (exists) {
20+
return possiblePath;
21+
}
22+
}
23+
24+
return null;
25+
}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import type { IntegrationResolvedRoute } from "astro";
2+
3+
export interface MatcherOptions {
4+
include?: (string | RegExp)[];
5+
exclude?: (string | RegExp)[];
6+
}
7+
8+
interface BuildMiddlewareMatcherRegexpOptions extends MatcherOptions {
9+
assetsDir: string;
10+
routes: IntegrationResolvedRoute[];
11+
}
12+
13+
export function buildMiddlewareMatcherRegexp({
14+
routes,
15+
assetsDir,
16+
exclude,
17+
include,
18+
}: BuildMiddlewareMatcherRegexpOptions) {
19+
const groupedRoutes = Object.groupBy(routes, (r) => r.origin);
20+
const dontMatchPatterns = [
21+
`\\/${assetsDir}\\/(.*)`,
22+
...(groupedRoutes.internal?.map((r) => stripAstroRoutePatternRegexp(r.patternRegex)) ?? []),
23+
...(exclude ?? []).map(stripRoutePattern),
24+
];
25+
const matchPatterns = [...(include ?? []).map(stripRoutePattern)];
26+
27+
// The regex is constructed to first negate any paths that match the internal patterns
28+
// and then allow paths that match the project patterns.
29+
// For example it can output such regexp: /^(?!.*(\/_server-islands\/[^\/]+\/?|\/_image\/?)$)(?:\/(.*))$/;
30+
return `^(?!.*(${dontMatchPatterns.join("|")})$)(?:${matchPatterns.length ? matchPatterns.join("|") : "\\/.*"})$`;
31+
}
32+
33+
// Strips leading ^ and trailing $ from a RegExp pattern string
34+
const PATTERN_STRIP_LINE_START = /^\^/;
35+
const PATTERN_STRIP_LINE_END = /\$$/;
36+
function stripRoutePattern(pattern: string | RegExp) {
37+
return pattern
38+
.toString()
39+
.replace(PATTERN_STRIP_LINE_START, "")
40+
.replace(PATTERN_STRIP_LINE_END, "");
41+
}
42+
43+
function stripAstroRoutePatternRegexp(pattern: RegExp) {
44+
return stripRoutePattern(pattern.toString().slice(1, -1));
45+
}
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import { builtinModules } from "node:module";
2+
import path from "node:path";
3+
4+
// https://docs.astro.build/en/guides/middleware/
5+
const MIDDLEWARE_ENTRYPOINT = "middleware.mjs";
6+
7+
export async function generateEdgeMiddlewareFile(
8+
projectRootDir: string,
9+
middlewarePath: string,
10+
functionDir: string,
11+
) {
12+
const esbuild = await import("esbuild");
13+
const middlewareModule = getMiddlewareTemplate(middlewarePath);
14+
const outfile = path.join(functionDir, MIDDLEWARE_ENTRYPOINT);
15+
16+
await esbuild.build({
17+
stdin: {
18+
contents: middlewareModule,
19+
resolveDir: projectRootDir,
20+
},
21+
target: "esnext",
22+
platform: "browser",
23+
conditions: ["edge-light", "workerd", "worker"],
24+
outfile,
25+
allowOverwrite: true,
26+
format: "esm",
27+
bundle: true,
28+
minify: false,
29+
plugins: [
30+
{
31+
name: "esbuild-namespace-node-built-in-modules",
32+
setup(build) {
33+
const filter = new RegExp(builtinModules.map((mod) => `(^${mod}$)`).join("|"));
34+
build.onResolve({ filter }, (args) => ({
35+
path: "node:" + args.path,
36+
external: true,
37+
}));
38+
},
39+
},
40+
],
41+
});
42+
43+
return MIDDLEWARE_ENTRYPOINT;
44+
}
45+
46+
function getMiddlewareTemplate(middlewarePath: string) {
47+
return `
48+
import { createContext, trySerializeLocals } from 'astro/middleware';
49+
import { next } from "@vercel/functions";
50+
import { onRequest } from "${middlewarePath}";
51+
52+
export default async function middleware(request, context) {
53+
const url = new URL(request.url);
54+
const ctx = createContext({ request, params: {} });
55+
Object.assign(ctx.locals, { vercel: { edge: context } });
56+
57+
return onRequest(ctx, next);
58+
}`;
59+
}
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import fs from "node:fs/promises";
2+
import path from "node:path";
3+
4+
interface VercelConfig {
5+
version: 3;
6+
routes: {
7+
src: string;
8+
dest?: string;
9+
middlewarePath?: string;
10+
continue?: boolean;
11+
}[];
12+
}
13+
14+
const VERCEL_OUTPUT_DIR = ".vercel/output";
15+
16+
export function getFunctionDir(rootDir: string, functionName: string) {
17+
return path.join(rootDir, VERCEL_OUTPUT_DIR, `functions/${functionName}.func/`);
18+
}
19+
20+
export async function insertMiddlewareRoute(
21+
rootDir: string,
22+
matcher: string,
23+
middlewareName: string,
24+
) {
25+
const vercelConfigPath = path.join(rootDir, VERCEL_OUTPUT_DIR, "config.json");
26+
const vercelConfig = JSON.parse(await fs.readFile(vercelConfigPath, "utf-8")) as VercelConfig;
27+
28+
vercelConfig.routes.unshift({
29+
src: matcher,
30+
middlewarePath: middlewareName,
31+
continue: true,
32+
});
33+
34+
await fs.writeFile(vercelConfigPath, JSON.stringify(vercelConfig, null, 2));
35+
}
36+
export async function generateFunctionConfig(functionDir: string, entrypoint: string) {
37+
const config = {
38+
runtime: "edge",
39+
deploymentTarget: "v8-worker",
40+
entrypoint,
41+
};
42+
43+
await fs.writeFile(path.join(functionDir, ".vc-config.json"), JSON.stringify(config, null, 2));
44+
}

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@
5858
"@types/react-dom": "^19.1.6",
5959
"@vercel/analytics": "^1.5.0",
6060
"@vercel/edge": "^1.2.2",
61+
"@vercel/functions": "^2.2.13",
6162
"@vercel/og": "^0.6.8",
6263
"@vercel/speed-insights": "^1.2.0",
6364
"astro": "^5.13.2",
@@ -101,6 +102,7 @@
101102
"@types/node": "^22.16.5",
102103
"@vercel/node": "^5.3.6",
103104
"astro-eslint-parser": "^1.2.2",
105+
"esbuild": ">=0.25.0",
104106
"eslint": "^9.31.0",
105107
"eslint-config-flat-gitignore": "^2.1.0",
106108
"eslint-config-prettier": "^10.1.8",

pnpm-lock.yaml

Lines changed: 14 additions & 8 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)