Skip to content
This repository was archived by the owner on Feb 10, 2025. It is now read-only.

Commit 6ef9a6f

Browse files
authored
fix: use vercel routing utils (#525)
* fix: use vercel routing utils * changeset * log error * Format * Update changeset * Update tests * Changes from review * Format
1 parent 641d7d5 commit 6ef9a6f

9 files changed

+141
-97
lines changed

.changeset/tidy-walls-check.md

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@astrojs/vercel': patch
3+
---
4+
5+
Fixes a bug that caused redirect loops when trailingSlash was set

packages/vercel/package.json

+1
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@
4040
"@vercel/analytics": "^1.4.1",
4141
"@vercel/edge": "^1.2.1",
4242
"@vercel/nft": "^0.29.0",
43+
"@vercel/routing-utils": "^5.0.0",
4344
"esbuild": "^0.24.0",
4445
"fast-glob": "^3.3.3"
4546
},

packages/vercel/src/index.ts

+53-12
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { cpSync, existsSync, mkdirSync, readFileSync } from 'node:fs';
22
import { basename } from 'node:path';
33
import { pathToFileURL } from 'node:url';
44
import { emptyDir, removeDir, writeJson } from '@astrojs/internal-helpers/fs';
5+
import { type Route, getTransformedRoutes, normalizeRoutes } from '@vercel/routing-utils';
56
import type {
67
AstroAdapter,
78
AstroConfig,
@@ -10,6 +11,7 @@ import type {
1011
HookParameters,
1112
IntegrationResolvedRoute,
1213
} from 'astro';
14+
import { AstroError } from 'astro/errors';
1315
import glob from 'fast-glob';
1416
import {
1517
type DevImageService,
@@ -261,16 +263,23 @@ export default function vercelAdapter({
261263
);
262264
}
263265
const vercelConfigPath = new URL('vercel.json', config.root);
264-
if (existsSync(vercelConfigPath)) {
266+
if (
267+
config.trailingSlash &&
268+
config.trailingSlash !== 'ignore' &&
269+
existsSync(vercelConfigPath)
270+
) {
265271
try {
266272
const vercelConfig = JSON.parse(readFileSync(vercelConfigPath, 'utf-8'));
267-
if (vercelConfig.trailingSlash === true && config.trailingSlash === 'always') {
268-
logger.warn(
269-
'\n' +
270-
`\tYour "vercel.json" \`trailingSlash\` configuration (set to \`true\`) will conflict with your Astro \`trailinglSlash\` configuration (set to \`"always"\`).\n` +
271-
// biome-ignore lint/style/noUnusedTemplateLiteral: <explanation>
272-
`\tThis would cause infinite redirects under certain conditions and throw an \`ERR_TOO_MANY_REDIRECTS\` error.\n` +
273-
`\tTo prevent this, change your Astro configuration and update \`"trailingSlash"\` to \`"ignore"\`.\n`
273+
if (
274+
(vercelConfig.trailingSlash === true && config.trailingSlash === 'never') ||
275+
(vercelConfig.trailingSlash === false && config.trailingSlash === 'always')
276+
) {
277+
logger.error(
278+
`
279+
Your "vercel.json" \`trailingSlash\` configuration (set to \`${vercelConfig.trailingSlash}\`) will conflict with your Astro \`trailingSlash\` configuration (set to \`${JSON.stringify(config.trailingSlash)}\`).
280+
This would cause infinite redirects or duplicate content issues.
281+
Please remove the \`trailingSlash\` configuration from your \`vercel.json\` file or Astro config.
282+
`
274283
);
275284
}
276285
} catch (_err) {
@@ -435,14 +444,12 @@ export default function vercelAdapter({
435444
}
436445
const fourOhFourRoute = routes.find((route) => route.pathname === '/404');
437446
const destination = new URL('./.vercel/output/config.json', _config.root);
438-
const finalRoutes = [
439-
...getRedirects(routes, _config),
447+
const finalRoutes: Route[] = [
440448
{
441449
src: `^/${_config.build.assets}/(.*)$`,
442450
headers: { 'cache-control': 'public, max-age=31536000, immutable' },
443451
continue: true,
444452
},
445-
{ handle: 'filesystem' },
446453
];
447454
if (_buildOutput === 'server') {
448455
finalRoutes.push(...routeDefinitions);
@@ -467,6 +474,30 @@ export default function vercelAdapter({
467474
});
468475
}
469476
}
477+
// The Vercel `trailingSlash` option
478+
let trailingSlash: boolean | undefined;
479+
// Vercel's `trailingSlash` option maps to Astro's like so:
480+
// - `true` -> `"always"`
481+
// - `false` -> `"never"`
482+
// - `undefined` -> `"ignore"`
483+
// If config is set to "ignore", we leave it as undefined.
484+
if (_config.trailingSlash && _config.trailingSlash !== 'ignore') {
485+
// Otherwise, map it accordingly.
486+
trailingSlash = _config.trailingSlash === 'always';
487+
}
488+
489+
const { routes: redirects = [], error } = getTransformedRoutes({
490+
trailingSlash,
491+
rewrites: [],
492+
redirects: getRedirects(routes, _config),
493+
headers: [],
494+
});
495+
if (error) {
496+
throw new AstroError(
497+
`Error generating redirects: ${error.message}`,
498+
error.link ? `${error.action ?? 'More info'}: ${error.link}` : undefined
499+
);
500+
}
470501

471502
let images: VercelImageConfig | undefined;
472503
if (imageService || imagesConfig) {
@@ -487,11 +518,21 @@ export default function vercelAdapter({
487518
}
488519
}
489520

521+
const normalized = normalizeRoutes([...(redirects ?? []), ...finalRoutes]);
522+
if (normalized.error) {
523+
throw new AstroError(
524+
`Error generating routes: ${normalized.error.message}`,
525+
normalized.error.link
526+
? `${normalized.error.action ?? 'More info'}: ${normalized.error.link}`
527+
: undefined
528+
);
529+
}
530+
490531
// Output configuration
491532
// https://vercel.com/docs/build-output-api/v3#build-output-configuration
492533
await writeJson(destination, {
493534
version: 3,
494-
routes: finalRoutes,
535+
routes: normalized.routes,
495536
images,
496537
});
497538

packages/vercel/src/lib/redirects.ts

+38-55
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
import nodePath from 'node:path';
2-
import { appendForwardSlash, removeLeadingForwardSlash } from '@astrojs/internal-helpers/path';
2+
import { removeLeadingForwardSlash } from '@astrojs/internal-helpers/path';
33
import type { AstroConfig, IntegrationResolvedRoute, RoutePart } from 'astro';
44

5+
import type { Redirect } from '@vercel/routing-utils';
6+
57
const pathJoin = nodePath.posix.join;
68

79
// https://vercel.com/docs/project-configuration#legacy/routes
@@ -40,10 +42,32 @@ function getParts(part: string, file: string) {
4042

4143
return result;
4244
}
45+
/**
46+
* Convert Astro routes into Vercel path-to-regexp syntax, which are the input for getTransformedRoutes
47+
*/
48+
function getMatchPattern(segments: RoutePart[][]) {
49+
return segments
50+
.map((segment) => {
51+
return segment
52+
.map((part) => {
53+
if (part.spread) {
54+
// Extract parameter name from spread syntax (e.g., "...slug" -> "slug")
55+
const paramName = part.content.startsWith('...') ? part.content.slice(3) : part.content;
56+
return `:${paramName}*`;
57+
}
58+
if (part.dynamic) {
59+
return `:${part.content}`;
60+
}
61+
return part.content;
62+
})
63+
.join('');
64+
})
65+
.join('/');
66+
}
4367

4468
// Copied from /home/juanm04/dev/misc/astro/packages/astro/src/core/routing/manifest/create.ts
4569
// 2022-04-26
46-
function getMatchPattern(segments: RoutePart[][]) {
70+
function getMatchRegex(segments: RoutePart[][]) {
4771
return segments
4872
.map((segment, segmentIndex) => {
4973
return segment.length === 1 && segment[0].spread
@@ -72,37 +96,16 @@ function getMatchPattern(segments: RoutePart[][]) {
7296
.join('');
7397
}
7498

75-
function getReplacePattern(segments: RoutePart[][]) {
76-
let n = 0;
77-
let result = '';
78-
79-
for (const segment of segments) {
80-
for (const part of segment) {
81-
// biome-ignore lint/style/useTemplate: <explanation>
82-
if (part.dynamic) result += '$' + ++n;
83-
else result += part.content;
84-
}
85-
result += '/';
86-
}
87-
88-
// Remove trailing slash
89-
result = result.slice(0, -1);
90-
91-
return result;
92-
}
93-
9499
function getRedirectLocation(route: IntegrationResolvedRoute, config: AstroConfig): string {
95100
if (route.redirectRoute) {
96-
const pattern = getReplacePattern(route.redirectRoute.segments);
97-
const path = config.trailingSlash === 'always' ? appendForwardSlash(pattern) : pattern;
98-
return pathJoin(config.base, path);
99-
// biome-ignore lint/style/noUselessElse: <explanation>
100-
} else if (typeof route.redirect === 'object') {
101+
const pattern = getMatchPattern(route.redirectRoute.segments);
102+
return pathJoin(config.base, pattern);
103+
}
104+
105+
if (typeof route.redirect === 'object') {
101106
return pathJoin(config.base, route.redirect.destination);
102-
// biome-ignore lint/style/noUselessElse: <explanation>
103-
} else {
104-
return pathJoin(config.base, route.redirect || '');
105107
}
108+
return pathJoin(config.base, route.redirect || '');
106109
}
107110

108111
function getRedirectStatus(route: IntegrationResolvedRoute): number {
@@ -119,40 +122,20 @@ export function escapeRegex(content: string) {
119122
.map((s: string) => {
120123
return getParts(s, content);
121124
});
122-
return `^/${getMatchPattern(segments)}$`;
125+
return `^/${getMatchRegex(segments)}$`;
123126
}
124127

125-
export function getRedirects(
126-
routes: IntegrationResolvedRoute[],
127-
config: AstroConfig
128-
): VercelRoute[] {
129-
const redirects: VercelRoute[] = [];
128+
export function getRedirects(routes: IntegrationResolvedRoute[], config: AstroConfig): Redirect[] {
129+
const redirects: Redirect[] = [];
130130

131131
for (const route of routes) {
132132
if (route.type === 'redirect') {
133133
redirects.push({
134-
src: config.base + getMatchPattern(route.segments),
135-
headers: { Location: getRedirectLocation(route, config) },
136-
status: getRedirectStatus(route),
134+
source: config.base + getMatchPattern(route.segments),
135+
destination: getRedirectLocation(route, config),
136+
statusCode: getRedirectStatus(route),
137137
});
138-
} else if (route.type === 'page' && route.pattern !== '/') {
139-
if (config.trailingSlash === 'always') {
140-
redirects.push({
141-
src: config.base + getMatchPattern(route.segments),
142-
// biome-ignore lint/style/useTemplate: <explanation>
143-
headers: { Location: config.base + getReplacePattern(route.segments) + '/' },
144-
status: 308,
145-
});
146-
} else if (config.trailingSlash === 'never') {
147-
redirects.push({
148-
// biome-ignore lint/style/useTemplate: <explanation>
149-
src: config.base + getMatchPattern(route.segments) + '/',
150-
headers: { Location: config.base + getReplacePattern(route.segments) },
151-
status: 308,
152-
});
153-
}
154138
}
155139
}
156-
157140
return redirects;
158141
}

packages/vercel/test/isr.test.js

+7-7
Original file line numberDiff line numberDiff line change
@@ -38,31 +38,31 @@ describe('ISR', () => {
3838
dest: '_render',
3939
},
4040
{
41-
src: '^/excluded(?:\\/(.*?))?$',
41+
src: '^/excluded(?:/(.*?))?$',
4242
dest: '_render',
4343
},
4444
{
45-
src: '^\\/_server-islands\\/([^/]+?)\\/?$',
45+
src: '^/_server-islands/([^/]+?)/?$',
4646
dest: '_render',
4747
},
4848
{
49-
src: '^\\/_image\\/?$',
49+
src: '^/_image/?$',
5050
dest: '_render',
5151
},
5252
{
53-
src: '^\\/excluded\\/([^/]+?)\\/?$',
53+
src: '^/excluded/([^/]+?)/?$',
5454
dest: '/_isr?x_astro_path=$0',
5555
},
5656
{
57-
src: '^\\/excluded(?:\\/(.*?))?\\/?$',
57+
src: '^/excluded(?:/(.*?))?/?$',
5858
dest: '/_isr?x_astro_path=$0',
5959
},
6060
{
61-
src: '^\\/one\\/?$',
61+
src: '^/one/?$',
6262
dest: '/_isr?x_astro_path=$0',
6363
},
6464
{
65-
src: '^\\/two\\/?$',
65+
src: '^/two/?$',
6666
dest: '/_isr?x_astro_path=$0',
6767
},
6868
]);

packages/vercel/test/prerendered-error-pages.test.js

+1-1
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ describe('prerendered error pages routing', () => {
1818
assert.deepEqual(
1919
deploymentConfig.routes.find((r) => r.status === 404),
2020
{
21-
src: '/.*',
21+
src: '^/.*$',
2222
dest: '/404.html',
2323
status: 404,
2424
}

packages/vercel/test/redirects.test.js

+18-21
Original file line numberDiff line numberDiff line change
@@ -27,30 +27,28 @@ describe('Redirects', () => {
2727
async function getConfig() {
2828
const json = await fixture.readFile('../.vercel/output/config.json');
2929
const config = JSON.parse(json);
30-
3130
return config;
3231
}
3332

3433
it('define static routes', async () => {
3534
const config = await getConfig();
36-
37-
const oneRoute = config.routes.find((r) => r.src === '/one');
35+
const oneRoute = config.routes.find((r) => r.src === '^/one$');
3836
assert.equal(oneRoute.headers.Location, '/');
3937
assert.equal(oneRoute.status, 301);
4038

41-
const twoRoute = config.routes.find((r) => r.src === '/two');
39+
const twoRoute = config.routes.find((r) => r.src === '^/two$');
4240
assert.equal(twoRoute.headers.Location, '/');
4341
assert.equal(twoRoute.status, 301);
4442

45-
const threeRoute = config.routes.find((r) => r.src === '/three');
43+
const threeRoute = config.routes.find((r) => r.src === '^/three$');
4644
assert.equal(threeRoute.headers.Location, '/');
4745
assert.equal(threeRoute.status, 302);
4846
});
4947

5048
it('define redirects for static files', async () => {
5149
const config = await getConfig();
5250

53-
const staticRoute = config.routes.find((r) => r.src === '/Basic/http-2-0.html');
51+
const staticRoute = config.routes.find((r) => r.src === '^/Basic/http-2-0\\.html$');
5452
assert.notEqual(staticRoute, undefined);
5553
assert.equal(staticRoute.headers.Location, '/posts/http2');
5654
assert.equal(staticRoute.status, 301);
@@ -59,25 +57,24 @@ describe('Redirects', () => {
5957
it('defines dynamic routes', async () => {
6058
const config = await getConfig();
6159

62-
const blogRoute = config.routes.find((r) => r.src.startsWith('/blog'));
60+
const blogRoute = config.routes.find((r) => r.src.startsWith('^/blog'));
6361
assert.notEqual(blogRoute, undefined);
6462
assert.equal(blogRoute.headers.Location.startsWith('/team/articles'), true);
6563
assert.equal(blogRoute.status, 301);
6664
});
6765

68-
it('define trailingSlash redirect for sub pages', async () => {
69-
const config = await getConfig();
70-
71-
const subpathRoute = config.routes.find((r) => r.src === '/subpage');
72-
assert.notEqual(subpathRoute, undefined);
73-
assert.equal(subpathRoute.headers.Location, '/subpage/');
74-
});
75-
76-
it('does not define trailingSlash redirect for root page', async () => {
77-
const config = await getConfig();
78-
assert.equal(
79-
config.routes.find((r) => r.src === '/'),
80-
undefined
81-
);
66+
it('throws an error for invalid redirects', async () => {
67+
const fails = await loadFixture({
68+
root: './fixtures/redirects/',
69+
redirects: {
70+
// Invalid source syntax
71+
'/blog/(![...slug]': '/team/articles/[...slug]',
72+
},
73+
});
74+
await assert.rejects(() => fails.build(), {
75+
name: 'AstroUserError',
76+
message:
77+
'Error generating redirects: Redirect at index 0 has invalid `source` regular expression "/blog/(!:slug*".',
78+
});
8279
});
8380
});

packages/vercel/test/static.test.js

+1-1
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ describe('static routing', () => {
1717
const deploymentConfig = JSON.parse(await fixture.readFile('../.vercel/output/config.json'));
1818
// change the index if necesseary
1919
assert.deepEqual(deploymentConfig.routes[2], {
20-
src: '/.*',
20+
src: '^/.*$',
2121
dest: '/404.html',
2222
status: 404,
2323
});

0 commit comments

Comments
 (0)