diff --git a/.changeset/quiet-pans-batch.md b/.changeset/quiet-pans-batch.md new file mode 100644 index 000000000000..0f7e5febab9e --- /dev/null +++ b/.changeset/quiet-pans-batch.md @@ -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 diff --git a/packages/adapter-static/platforms.js b/packages/adapter-static/platforms.js index e93dc3a0872f..4ec2305c4910 100644 --- a/packages/adapter-static/platforms.js +++ b/packages/adapter-static/platforms.js @@ -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. @@ -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` @@ -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, diff --git a/packages/adapter-vercel/index.js b/packages/adapter-vercel/index.js index 9da4ef8d4f1d..5fe5939d0cc1 100644 --- a/packages/adapter-vercel/index.js +++ b/packages/adapter-vercel/index.js @@ -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 @@ -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` @@ -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 diff --git a/packages/adapter-vercel/test/apps/basic/build.test.js b/packages/adapter-vercel/test/apps/basic/build.test.js index 440adc4425d3..fa70a96b8c7f 100644 --- a/packages/adapter-vercel/test/apps/basic/build.test.js +++ b/packages/adapter-vercel/test/apps/basic/build.test.js @@ -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 }> }} */ let config; beforeAll(() => (config = JSON.parse(fs.readFileSync(`${output}/config.json`, 'utf8')))); @@ -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`)); diff --git a/packages/adapter-vercel/test/apps/basic/src/routes/prerendered-trailing-slash/[slug]/+page.server.ts b/packages/adapter-vercel/test/apps/basic/src/routes/prerendered-trailing-slash/[slug]/+page.server.ts new file mode 100644 index 000000000000..8c6814ef3d4a --- /dev/null +++ b/packages/adapter-vercel/test/apps/basic/src/routes/prerendered-trailing-slash/[slug]/+page.server.ts @@ -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 }; +} diff --git a/packages/adapter-vercel/test/apps/basic/src/routes/prerendered-trailing-slash/[slug]/+page.svelte b/packages/adapter-vercel/test/apps/basic/src/routes/prerendered-trailing-slash/[slug]/+page.svelte new file mode 100644 index 000000000000..d6c32bf1157a --- /dev/null +++ b/packages/adapter-vercel/test/apps/basic/src/routes/prerendered-trailing-slash/[slug]/+page.svelte @@ -0,0 +1,5 @@ + + +

prerendered with trailing slash: {data.slug}

diff --git a/packages/adapter-vercel/test/apps/basic/src/routes/prerendered/[slug]/+page.server.ts b/packages/adapter-vercel/test/apps/basic/src/routes/prerendered/[slug]/+page.server.ts new file mode 100644 index 000000000000..1cd447f4c6ab --- /dev/null +++ b/packages/adapter-vercel/test/apps/basic/src/routes/prerendered/[slug]/+page.server.ts @@ -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 }; +} diff --git a/packages/adapter-vercel/test/apps/basic/src/routes/prerendered/[slug]/+page.svelte b/packages/adapter-vercel/test/apps/basic/src/routes/prerendered/[slug]/+page.svelte new file mode 100644 index 000000000000..e22414ef4977 --- /dev/null +++ b/packages/adapter-vercel/test/apps/basic/src/routes/prerendered/[slug]/+page.svelte @@ -0,0 +1,5 @@ + + +

prerendered: {data.slug}

diff --git a/packages/adapter-vercel/test/apps/basic/test/test.ts b/packages/adapter-vercel/test/apps/basic/test/test.ts index a631c4a26cea..f128daa9dd4b 100644 --- a/packages/adapter-vercel/test/apps/basic/test/test.ts +++ b/packages/adapter-vercel/test/apps/basic/test/test.ts @@ -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'); diff --git a/packages/adapter-vercel/utils.js b/packages/adapter-vercel/utils.js index c213b3a2ec08..c434801e24d1 100644 --- a/packages/adapter-vercel/utils.js +++ b/packages/adapter-vercel/utils.js @@ -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. diff --git a/packages/adapter-vercel/utils.spec.js b/packages/adapter-vercel/utils.spec.js index 61da15ffd8b5..97822e0be889 100644 --- a/packages/adapter-vercel/utils.spec.js +++ b/packages/adapter-vercel/utils.spec.js @@ -4,7 +4,8 @@ import { get_uniform_trailing_slash, parse_isr_expiration, pattern_to_src, - resolve_runtime + resolve_runtime, + trailing_slash_redirects } from './utils.js'; // workaround so that TypeScript doesn't follow that import which makes it pick up that file and then error on missing import aliases @@ -183,3 +184,57 @@ describe('get_uniform_trailing_slash', () => { assert.equal(get_uniform_trailing_slash([]), undefined); }); }); + +describe('trailing_slash_redirects', () => { + test('returns no routes for no pathnames', () => { + assert.deepEqual(trailing_slash_redirects([], false), []); + }); + + test('combines pathnames into a single route that removes the trailing slash', () => { + assert.deepEqual(trailing_slash_redirects(['foo', 'blog/bar'], false), [ + { + src: '^/(foo|blog/bar)/$', + headers: { Location: '/$1' }, + status: 308, + caseSensitive: true + } + ]); + }); + + test('combines pathnames into a single route that adds the trailing slash', () => { + assert.deepEqual(trailing_slash_redirects(['foo', 'blog/bar'], true), [ + { + src: '^/(foo|blog/bar)$', + headers: { Location: '/$1/' }, + status: 308, + caseSensitive: true + } + ]); + }); + + test('encodes pathnames and escapes regex characters', () => { + const [route] = trailing_slash_redirects(['café', 'v1.2', 'a (b)', 'c+d'], false); + assert.equal(route.src, '^/(caf%C3%A9|v1\\.2|a%20\\(b\\)|c\\+d)/$'); + + const regex = new RegExp(route.src); + assert.ok(regex.test('/v1.2/')); + assert.ok(!regex.test('/v1x2/')); + assert.ok(regex.test('/a%20(b)/')); + }); + + test('splits pathnames across routes when they exceed the maximum length', () => { + const pathnames = Array.from({ length: 1000 }, (_, i) => `blog/post-${i}`); + const routes = trailing_slash_redirects(pathnames, false); + + assert.ok(routes.length > 1); + + for (const route of routes) { + assert.ok(route.src.length <= 4096); + } + + const matched = pathnames.filter((pathname) => + routes.some((route) => new RegExp(route.src).test(`/${pathname}/`)) + ); + assert.equal(matched.length, pathnames.length); + }); +});