Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/quiet-pans-batch.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@sveltejs/adapter-vercel': patch
'@sveltejs/adapter-static': patch
---

fix: combine trailing slash redirects for prerendered pages into as few routes as possible when routes use different `trailingSlash` options, to avoid exceeding Vercel's route limit
79 changes: 68 additions & 11 deletions packages/adapter-static/platforms.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,56 @@ function escape_regex(str) {
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}

// Vercel rejects routes whose `src` is longer than 4096 characters
const MAX_SRC_LENGTH = 4000;

/**
* Creates routes that redirect prerendered pages to their canonical trailing slash form.
* Since Vercel limits the number of routes in a deployment, pathnames are combined into
* as few routes as possible rather than creating one route per page.
* This function is duplicated in adapter-vercel
* @param {string[]} pathnames decoded pathnames without leading or trailing slashes
* @param {boolean} trailing_slash `true` if the redirect should add a trailing slash, `false` if it should remove it
*/
function trailing_slash_redirects(pathnames, trailing_slash) {
/** @type {Array<{ src: string, headers: { Location: string }, status: number, caseSensitive: boolean }>} */
const routes = [];

/** @type {string[]} */
let alternatives = [];
let length = 0;

const flush = () => {
if (alternatives.length === 0) return;

const group = alternatives.join('|');

routes.push({
src: trailing_slash ? `^/(${group})$` : `^/(${group})/$`,
headers: { Location: trailing_slash ? '/$1/' : '/$1' },
status: 308,
caseSensitive: true
});

alternatives = [];
length = 0;
};

for (const pathname of pathnames) {
// Vercel matches against the encoded pathname, so we encode it the same way Kit does when prerendering
const alternative = escape_regex(encodeURI(pathname));

if (length + alternative.length + 1 > MAX_SRC_LENGTH) flush();

alternatives.push(alternative);
length += alternative.length + 1;
}

flush();

return routes;
}

/**
* Returns the `trailingSlash` option if every route uses the same one, allowing
* trailing slashes to be normalized with a single route rather than one per page.
Expand Down Expand Up @@ -85,6 +135,12 @@ function static_vercel_config(builder) {
});
}

/** @type {string[]} */
const add_trailing_slash = [];

/** @type {string[]} */
const remove_trailing_slash = [];

for (const [path, page] of builder.prerendered.pages) {
if (trailing_slash === 'always' && path !== '/' && path.endsWith('/')) {
// Vercel ignores trailing slashes when looking up static files, and serves `foo/index.html`
Expand All @@ -97,25 +153,26 @@ function static_vercel_config(builder) {
let overrides_path = path.slice(1);

if (path !== '/') {
/** @type {string | undefined} */
let counterpart_route = path + '/';

if (path.endsWith('/')) {
counterpart_route = path.slice(0, -1);
overrides_path = path.slice(1, -1);
}

if (!trailing_slash) {
prerendered_redirects.push(
{ src: path, dest: counterpart_route },
{ src: counterpart_route, status: 308, headers: { Location: path } }
);
add_trailing_slash.push(overrides_path);
} else {
remove_trailing_slash.push(overrides_path);
}
}

overrides[page.file] = { path: overrides_path };
}

if (!trailing_slash) {
// Vercel ignores trailing slashes when looking up static files, so `/foo` and `/foo/`
// are both served by the `foo` file. We only need routes for the redirects
prerendered_redirects.push(
...trailing_slash_redirects(add_trailing_slash, true),
...trailing_slash_redirects(remove_trailing_slash, false)
);
}

/** @type {any[]} */
const routes = [
...prerendered_redirects,
Expand Down
32 changes: 20 additions & 12 deletions packages/adapter-vercel/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@ import {
get_uniform_trailing_slash,
parse_isr_expiration,
pattern_to_src,
resolve_runtime
resolve_runtime,
trailing_slash_redirects
} from './utils.js';

const INTERNAL = '![-]'; // this name is guaranteed not to conflict with user routes
Expand Down Expand Up @@ -454,6 +455,12 @@ function static_vercel_config(builder, config, dir) {
});
}

/** @type {string[]} */
const add_trailing_slash = [];

/** @type {string[]} */
const remove_trailing_slash = [];

for (const [path, page] of builder.prerendered.pages) {
if (trailing_slash === 'always' && path !== '/' && path.endsWith('/')) {
// Vercel ignores trailing slashes when looking up static files, and serves `foo/index.html`
Expand All @@ -466,25 +473,26 @@ function static_vercel_config(builder, config, dir) {
let overrides_path = path.slice(1);

if (path !== '/') {
/** @type {string | undefined} */
let counterpart_route = path + '/';

if (path.endsWith('/')) {
counterpart_route = path.slice(0, -1);
overrides_path = path.slice(1, -1);
}

if (!trailing_slash) {
prerendered_redirects.push(
{ src: path, dest: counterpart_route },
{ src: counterpart_route, status: 308, headers: { Location: path } }
);
add_trailing_slash.push(overrides_path);
} else {
remove_trailing_slash.push(overrides_path);
}
}

overrides[page.file] = { path: overrides_path };
}

if (!trailing_slash) {
// Vercel ignores trailing slashes when looking up static files, so `/foo` and `/foo/`
// are both served by the `foo` file. We only need routes for the redirects
prerendered_redirects.push(
...trailing_slash_redirects(add_trailing_slash, true),
...trailing_slash_redirects(remove_trailing_slash, false)
);
}

const routes = [
// Strip any user-supplied __pathname query parameter; SvelteKit reserves
// this for ISR handlers
Expand Down
18 changes: 17 additions & 1 deletion packages/adapter-vercel/test/apps/basic/build.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ const app = deploy(import.meta.dirname);
const output = path.join(app, '.vercel/output');
const functions = `${output}/functions/api/json`;

/** @type {{ routes: Array<{ src?: string, dest?: string, handle?: string }> }} */
/** @type {{ routes: Array<{ src?: string, dest?: string, handle?: string, status?: number, headers?: Record<string, string> }> }} */
let config;
beforeAll(() => (config = JSON.parse(fs.readFileSync(`${output}/config.json`, 'utf8'))));

Expand Down Expand Up @@ -66,6 +66,22 @@ test('dynamic ISR routes are matched after the filesystem', () => {
assert.ok(index > filesystem);
});

test('prerendered trailing slash redirects are combined into one route per direction', () => {
const redirects = config.routes.filter((route) => route.status === 308);
assert.equal(redirects.length, 2);

const add = redirects.find((route) => route.headers?.Location === '/$1/');
assert.equal(add?.src, '^/(prerendered-trailing-slash/a|prerendered-trailing-slash/b)$');

const remove = redirects.find((route) => route.headers?.Location === '/$1');
assert.ok(remove?.src?.includes('prerendered/caf%C3%A9'));
assert.ok(remove?.src?.includes('prerendered/v1\\.2'));
});

test('prerendered pages do not need routes to be served', () => {
assert.ok(!config.routes.some((route) => route.dest?.startsWith('/prerendered')));
});

test('process.cwd() is traced from the project directory', () => {
const function_dir = fs.realpathSync(`${output}/functions/process-cwd.func`);
assert.ok(fs.existsSync(`${function_dir}/asset.txt`));
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
export const prerender = true;
export const trailingSlash = 'always';

export function entries() {
return ['a', 'b'].map((slug) => ({ slug }));
}

export function load({ params }) {
return { slug: params.slug };
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
<script lang="ts">
let { data } = $props();
</script>

<h1>prerendered with trailing slash: {data.slug}</h1>
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
export const prerender = true;

export function entries() {
return ['a', 'b', 'café', 'v1.2'].map((slug) => ({ slug }));
}

export function load({ params }) {
return { slug: params.slug };
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
<script lang="ts">
let { data } = $props();
</script>

<h1>prerendered: {data.slug}</h1>
54 changes: 54 additions & 0 deletions packages/adapter-vercel/test/apps/basic/test/test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,60 @@ test('prerendered page trailing slash redirects', async ({ request }) => {
expect(response.headers()['location']).toBe('/prerendered');
});

test('prerendered dynamic pages are served without a trailing slash', async ({ request }) => {
for (const [pathname, slug] of [
['/prerendered/a', 'a'],
['/prerendered/b', 'b'],
['/prerendered/caf%C3%A9', 'café'],
['/prerendered/v1.2', 'v1.2']
]) {
const response = await request.get(pathname, { maxRedirects: 0 });
expect(response.status()).toBe(200);
expect(await response.text()).toContain(`prerendered: ${slug}`);
}
});

test('prerendered dynamic pages redirect to remove the trailing slash', async ({ request }) => {
for (const pathname of [
'/prerendered/a',
'/prerendered/b',
'/prerendered/caf%C3%A9',
'/prerendered/v1.2'
]) {
const response = await request.get(`${pathname}/`, { maxRedirects: 0 });
expect(response.status()).toBe(308);
expect(response.headers()['location']).toBe(pathname);
}
});

test('prerendered dynamic pages with trailingSlash always are served', async ({ request }) => {
for (const slug of ['a', 'b']) {
const response = await request.get(`/prerendered-trailing-slash/${slug}/`, {
maxRedirects: 0
});
expect(response.status()).toBe(200);
expect(await response.text()).toContain(`prerendered with trailing slash: ${slug}`);
}
});

test('prerendered dynamic pages with trailingSlash always redirect to add the trailing slash', async ({
request
}) => {
for (const slug of ['a', 'b']) {
const response = await request.get(`/prerendered-trailing-slash/${slug}`, {
maxRedirects: 0
});
expect(response.status()).toBe(308);
expect(response.headers()['location']).toBe(`/prerendered-trailing-slash/${slug}/`);
}
});

test('prerendered trailing slash redirects preserve the query string', async ({ request }) => {
const response = await request.get('/prerendered/a/?foo=bar', { maxRedirects: 0 });
expect(response.status()).toBe(308);
expect(response.headers()['location']).toBe('/prerendered/a?foo=bar');
});

test('deeply nested route works', async ({ page }) => {
await page.goto('/deep/nested/route');
await expect(page.locator('h1')).toContainText('Deep nested route');
Expand Down
50 changes: 50 additions & 0 deletions packages/adapter-vercel/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,56 @@ export function escape_regex(str) {
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}

// Vercel rejects routes whose `src` is longer than 4096 characters
const MAX_SRC_LENGTH = 4000;

/**
* Creates routes that redirect prerendered pages to their canonical trailing slash form.
* Since Vercel limits the number of routes in a deployment, pathnames are combined into
* as few routes as possible rather than creating one route per page.
* This function is duplicated in adapter-static
* @param {string[]} pathnames decoded pathnames without leading or trailing slashes
* @param {boolean} trailing_slash `true` if the redirect should add a trailing slash, `false` if it should remove it
*/
export function trailing_slash_redirects(pathnames, trailing_slash) {
/** @type {Array<{ src: string, headers: { Location: string }, status: number, caseSensitive: boolean }>} */
const routes = [];

/** @type {string[]} */
let alternatives = [];
let length = 0;

const flush = () => {
if (alternatives.length === 0) return;

const group = alternatives.join('|');

routes.push({
src: trailing_slash ? `^/(${group})$` : `^/(${group})/$`,
headers: { Location: trailing_slash ? '/$1/' : '/$1' },
status: 308,
caseSensitive: true
});

alternatives = [];
length = 0;
};

for (const pathname of pathnames) {
// Vercel matches against the encoded pathname, so we encode it the same way Kit does when prerendering
const alternative = escape_regex(encodeURI(pathname));

if (length + alternative.length + 1 > MAX_SRC_LENGTH) flush();

alternatives.push(alternative);
length += alternative.length + 1;
}

flush();

return routes;
}

/**
* Returns the `trailingSlash` option if every route uses the same one, allowing
* trailing slashes to be normalized with a single route rather than one per page.
Expand Down
Loading
Loading