From 2d0cb08c7a222220c842921e0aaf003f7f412a72 Mon Sep 17 00:00:00 2001 From: Elliott Johnson Date: Thu, 22 Oct 2026 12:15:02 -0600 Subject: [PATCH] fix: normalize trailing slashes with a single route when every route agrees --- .changeset/brave-routes-agree.md | 5 + .changeset/lean-slashes-redirect.md | 6 + .github/workflows/platform-test.yml | 2 +- .github/workflows/platform-tests-all.yml | 2 +- packages/adapter-static/platforms.js | 136 ++++++++++++++---- packages/adapter-vercel/index.js | 71 ++++++++- .../test/apps/split/build.test.js | 22 +++ .../test/apps/split/package.json | 5 +- .../routes/prerendered/[slug]/+page.server.ts | 9 ++ .../routes/prerendered/[slug]/+page.svelte | 5 + .../test/apps/split/test/test.ts | 38 +++++ .../test/apps/split/vite.config.ts | 2 +- .../test/apps/trailing-slash/build.test.js | 36 +++++ .../test/apps/trailing-slash/package.json | 22 +++ .../apps/trailing-slash/playwright.config.js | 1 + .../test/apps/trailing-slash/src/app.d.ts | 13 ++ .../test/apps/trailing-slash/src/app.html | 11 ++ .../apps/trailing-slash/src/routes/+layout.js | 1 + .../trailing-slash/src/routes/+page.svelte | 6 + .../trailing-slash/src/routes/api/+server.ts | 5 + .../routes/prerendered/[slug]/+page.server.ts | 9 ++ .../routes/prerendered/[slug]/+page.svelte | 5 + .../src/routes/server-data/+page.server.ts | 3 + .../src/routes/server-data/+page.svelte | 5 + .../apps/trailing-slash/static/no-extension | 1 + .../apps/trailing-slash/static/robots.txt | 1 + .../test/apps/trailing-slash/test/test.ts | 81 +++++++++++ .../test/apps/trailing-slash/tsconfig.json | 15 ++ .../test/apps/trailing-slash/vite.config.ts | 11 ++ packages/adapter-vercel/utils.js | 22 +++ packages/adapter-vercel/utils.spec.js | 39 ++++- packages/kit/src/core/adapt/builder.js | 3 +- packages/kit/src/core/postbuild/analyse.js | 9 +- packages/kit/src/exports/public.d.ts | 8 +- packages/kit/src/types/internal.d.ts | 1 + packages/kit/types/index.d.ts | 8 ++ pnpm-lock.yaml | 30 ++++ 37 files changed, 604 insertions(+), 45 deletions(-) create mode 100644 .changeset/brave-routes-agree.md create mode 100644 .changeset/lean-slashes-redirect.md create mode 100644 packages/adapter-vercel/test/apps/split/build.test.js create mode 100644 packages/adapter-vercel/test/apps/split/src/routes/prerendered/[slug]/+page.server.ts create mode 100644 packages/adapter-vercel/test/apps/split/src/routes/prerendered/[slug]/+page.svelte create mode 100644 packages/adapter-vercel/test/apps/trailing-slash/build.test.js create mode 100644 packages/adapter-vercel/test/apps/trailing-slash/package.json create mode 100644 packages/adapter-vercel/test/apps/trailing-slash/playwright.config.js create mode 100644 packages/adapter-vercel/test/apps/trailing-slash/src/app.d.ts create mode 100644 packages/adapter-vercel/test/apps/trailing-slash/src/app.html create mode 100644 packages/adapter-vercel/test/apps/trailing-slash/src/routes/+layout.js create mode 100644 packages/adapter-vercel/test/apps/trailing-slash/src/routes/+page.svelte create mode 100644 packages/adapter-vercel/test/apps/trailing-slash/src/routes/api/+server.ts create mode 100644 packages/adapter-vercel/test/apps/trailing-slash/src/routes/prerendered/[slug]/+page.server.ts create mode 100644 packages/adapter-vercel/test/apps/trailing-slash/src/routes/prerendered/[slug]/+page.svelte create mode 100644 packages/adapter-vercel/test/apps/trailing-slash/src/routes/server-data/+page.server.ts create mode 100644 packages/adapter-vercel/test/apps/trailing-slash/src/routes/server-data/+page.svelte create mode 100644 packages/adapter-vercel/test/apps/trailing-slash/static/no-extension create mode 100644 packages/adapter-vercel/test/apps/trailing-slash/static/robots.txt create mode 100644 packages/adapter-vercel/test/apps/trailing-slash/test/test.ts create mode 100644 packages/adapter-vercel/test/apps/trailing-slash/tsconfig.json create mode 100644 packages/adapter-vercel/test/apps/trailing-slash/vite.config.ts diff --git a/.changeset/brave-routes-agree.md b/.changeset/brave-routes-agree.md new file mode 100644 index 000000000000..d0a449a5257e --- /dev/null +++ b/.changeset/brave-routes-agree.md @@ -0,0 +1,5 @@ +--- +'@sveltejs/kit': minor +--- + +feat: expose each route's `trailingSlash` option to adapters via `builder.routes` diff --git a/.changeset/lean-slashes-redirect.md b/.changeset/lean-slashes-redirect.md new file mode 100644 index 000000000000..375f97984120 --- /dev/null +++ b/.changeset/lean-slashes-redirect.md @@ -0,0 +1,6 @@ +--- +'@sveltejs/adapter-vercel': patch +'@sveltejs/adapter-static': patch +--- + +fix: normalize trailing slashes with a single route when every route uses the same `trailingSlash` option, rather than adding routes for each prerendered page diff --git a/.github/workflows/platform-test.yml b/.github/workflows/platform-test.yml index 365330c90000..8de426563061 100644 --- a/.github/workflows/platform-test.yml +++ b/.github/workflows/platform-test.yml @@ -113,7 +113,7 @@ jobs: env: &vercel-secrets VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }} VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }} - VERCEL_PROJECT_ID: ${{ inputs.variant == 'basic' && secrets.VERCEL_PROJECT_ID_BASIC || secrets.VERCEL_PROJECT_ID_SPLIT }} + VERCEL_PROJECT_ID: ${{ inputs.variant == 'basic' && secrets.VERCEL_PROJECT_ID_BASIC || inputs.variant == 'trailing-slash' && secrets.VERCEL_PROJECT_ID_TRAILING_SLASH || secrets.VERCEL_PROJECT_ID_SPLIT }} # the CLI reads `.vercel/output` from here but checks that the project's root directory exists run: | mkdir -p "$APP" diff --git a/.github/workflows/platform-tests-all.yml b/.github/workflows/platform-tests-all.yml index 6d9d7b558f29..079138e5900d 100644 --- a/.github/workflows/platform-tests-all.yml +++ b/.github/workflows/platform-tests-all.yml @@ -46,7 +46,7 @@ jobs: strategy: fail-fast: false matrix: - variant: [basic, split] + variant: [basic, split, trailing-slash] uses: ./.github/workflows/platform-test.yml with: sha: ${{ inputs.sha || github.sha }} diff --git a/packages/adapter-static/platforms.js b/packages/adapter-static/platforms.js index 47c59903e0ab..e93dc3a0872f 100644 --- a/packages/adapter-static/platforms.js +++ b/packages/adapter-static/platforms.js @@ -10,6 +10,32 @@ import process from 'node:process'; * }} * Platform */ +// Vercel serves files named `index.*` at their directory's path, so prerendered pages that +// have a trailing slash are renamed to something else when every route has one +const INDEX_FILE = '__index.html'; + +/** @param {string} str */ +function escape_regex(str) { + return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +/** + * 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. + * Returns `undefined` if the routes disagree, if they use `'ignore'`, + * or if the version of SvelteKit doesn't expose the option to adapters. + * This function is duplicated in adapter-vercel + * @param {Array<{ trailingSlash?: 'never' | 'always' | 'ignore' }>} routes + * @returns {'never' | 'always' | undefined} + */ +function get_uniform_trailing_slash(routes) { + const values = new Set(routes.map((route) => route.trailingSlash)); + if (values.size !== 1) return; + + const [value] = values; + if (value === 'never' || value === 'always') return value; +} + // This function is duplicated in adapter-vercel /** @param {import('@sveltejs/kit').Builder} builder */ function static_vercel_config(builder) { @@ -39,7 +65,35 @@ function static_vercel_config(builder) { }); } + const base = builder.config.paths.base; + const trailing_slash = get_uniform_trailing_slash(builder.routes); + + if (base && trailing_slash) { + // the root of the app always has a trailing slash when `paths.base` is set + prerendered_redirects.push({ + src: `^${escape_regex(base)}$`, + headers: { Location: `${base}/` }, + status: 308 + }); + } + + if (trailing_slash === 'never') { + prerendered_redirects.push({ + src: `^${escape_regex(base)}/(.+)/$`, + headers: { Location: `${base}/$1` }, + status: 308 + }); + } + 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` + // at `foo`, so we rename the page to stop `/foo` from matching it. That way, `/foo` is + // redirected after the filesystem phase, while the `miss` route serves the page for `/foo/` + overrides[page.file] = { path: page.file.replace(/index\.html$/, INDEX_FILE) }; + continue; + } + let overrides_path = path.slice(1); if (path !== '/') { @@ -51,42 +105,66 @@ function static_vercel_config(builder) { overrides_path = path.slice(1, -1); } - prerendered_redirects.push( - { src: path, dest: counterpart_route }, - { src: counterpart_route, status: 308, headers: { Location: path } } - ); + if (!trailing_slash) { + prerendered_redirects.push( + { src: path, dest: counterpart_route }, + { src: counterpart_route, status: 308, headers: { Location: path } } + ); + } } overrides[page.file] = { path: overrides_path }; } + /** @type {any[]} */ + const routes = [ + ...prerendered_redirects, + { + src: `/${builder.getAppPath()}/immutable/.+`, + headers: { + 'cache-control': 'public, immutable, max-age=31536000' + } + } + ]; + + if (trailing_slash === 'always') { + // serve the renamed page for `/foo/` if it exists. Routes in the `miss` phase + // are reverted if they don't match a file, so other requests are unaffected + routes.push({ handle: 'miss' }, { src: '^(/.+)/$', dest: `$1/${INDEX_FILE}`, check: true }); + } + + routes.push( + { + handle: 'filesystem' + }, + // Prevent incorrect caching: if a request to /_app/immutable/* doesn't match + // a static file, return 404 instead of falling through to dynamic routes. + // Otherwise, we could accidentally immutably cache dynamic content served + // by the fallback function. `no-store` stops the earlier immutable header + // from sticking to this 404, so a missing asset isn't cached for a year. + { + src: `/${builder.getAppPath()}/immutable/.+`, + status: 404, + headers: { + 'cache-control': 'no-store' + }, + continue: false + } + ); + + if (trailing_slash === 'always') { + // Requests that reach this point didn't match a static file, so we can add a trailing + // slash without affecting assets. Internal requests are excluded since they never have one + routes.push({ + src: `^(?!/${escape_regex(builder.getAppPath())}/)(?!.*__(?:data\\.json|route\\.js)$)(${escape_regex(base)}/.*[^/])$`, + headers: { Location: '$1/' }, + status: 308 + }); + } + return { version: 3, - routes: [ - ...prerendered_redirects, - { - src: `/${builder.getAppPath()}/immutable/.+`, - headers: { - 'cache-control': 'public, immutable, max-age=31536000' - } - }, - { - handle: 'filesystem' - }, - // Prevent incorrect caching: if a request to /_app/immutable/* doesn't match - // a static file, return 404 instead of falling through to dynamic routes. - // Otherwise, we could accidentally immutably cache dynamic content served - // by the fallback function. `no-store` stops the earlier immutable header - // from sticking to this 404, so a missing asset isn't cached for a year. - { - src: `/${builder.getAppPath()}/immutable/.+`, - status: 404, - headers: { - 'cache-control': 'no-store' - }, - continue: false - } - ], + routes, overrides }; } diff --git a/packages/adapter-vercel/index.js b/packages/adapter-vercel/index.js index 7cec5f02109a..9da4ef8d4f1d 100644 --- a/packages/adapter-vercel/index.js +++ b/packages/adapter-vercel/index.js @@ -4,10 +4,20 @@ import process from 'node:process'; import { fileURLToPath } from 'node:url'; import { VERSION } from '@sveltejs/kit'; import { nodeFileTrace } from '@vercel/nft'; -import { parse_isr_expiration, pattern_to_src, resolve_runtime } from './utils.js'; +import { + escape_regex, + get_uniform_trailing_slash, + parse_isr_expiration, + pattern_to_src, + resolve_runtime +} from './utils.js'; const INTERNAL = '![-]'; // this name is guaranteed not to conflict with user routes +// Vercel serves files named `index.*` at their directory's path, so prerendered pages that +// have a trailing slash are renamed to something else when every route has one +const INDEX_FILE = '__index.html'; + /** @type {typeof import('./index.js').default} **/ const plugin = function (defaults = {}) { // @ts-ignore TODO remove this in a future version @@ -325,8 +335,9 @@ const plugin = function (defaults = {}) { } } - const filesystem = static_config.routes.findIndex((route) => route.handle === 'filesystem'); - static_config.routes.splice(filesystem, 0, ...static_isr_routes); + // routes before the first `handle` are matched before the filesystem + const first_handle = static_config.routes.findIndex((route) => route.handle); + static_config.routes.splice(first_handle, 0, ...static_isr_routes); if (builder.config.router.resolution === 'server') { // Create a separate serverless function just for server-side route resolution. @@ -423,7 +434,35 @@ function static_vercel_config(builder, config, dir) { }); } + const base = builder.config.paths.base; + const trailing_slash = get_uniform_trailing_slash(builder.routes); + + if (base && trailing_slash) { + // the root of the app always has a trailing slash when `paths.base` is set + prerendered_redirects.push({ + src: `^${escape_regex(base)}$`, + headers: { Location: `${base}/` }, + status: 308 + }); + } + + if (trailing_slash === 'never') { + prerendered_redirects.push({ + src: `^${escape_regex(base)}/(.+)/$`, + headers: { Location: `${base}/$1` }, + status: 308 + }); + } + 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` + // at `foo`, so we rename the page to stop `/foo` from matching it. That way, `/foo` is + // redirected after the filesystem phase, while the `miss` route serves the page for `/foo/` + overrides[page.file] = { path: page.file.replace(/index\.html$/, INDEX_FILE) }; + continue; + } + let overrides_path = path.slice(1); if (path !== '/') { @@ -435,10 +474,12 @@ function static_vercel_config(builder, config, dir) { overrides_path = path.slice(1, -1); } - prerendered_redirects.push( - { src: path, dest: counterpart_route }, - { src: counterpart_route, status: 308, headers: { Location: path } } - ); + if (!trailing_slash) { + prerendered_redirects.push( + { src: path, dest: counterpart_route }, + { src: counterpart_route, status: 308, headers: { Location: path } } + ); + } } overrides[page.file] = { path: overrides_path }; @@ -504,6 +545,12 @@ function static_vercel_config(builder, config, dir) { }); } + if (trailing_slash === 'always') { + // serve the renamed page for `/foo/` if it exists. Routes in the `miss` phase + // are reverted if they don't match a file, so other requests are unaffected + routes.push({ handle: 'miss' }, { src: '^(/.+)/$', dest: `$1/${INDEX_FILE}`, check: true }); + } + routes.push({ handle: 'filesystem' }); @@ -522,6 +569,16 @@ function static_vercel_config(builder, config, dir) { continue: false }); + if (trailing_slash === 'always') { + // Requests that reach this point didn't match a static file, so we can add a trailing + // slash without affecting assets. Internal requests are excluded since they never have one + routes.push({ + src: `^(?!/${escape_regex(builder.getAppPath())}/)(?!.*__(?:data\\.json|route\\.js)$)(${escape_regex(base)}/.*[^/])$`, + headers: { Location: '$1/' }, + status: 308 + }); + } + return { version: 3, routes, diff --git a/packages/adapter-vercel/test/apps/split/build.test.js b/packages/adapter-vercel/test/apps/split/build.test.js new file mode 100644 index 000000000000..b45c9bc1ce22 --- /dev/null +++ b/packages/adapter-vercel/test/apps/split/build.test.js @@ -0,0 +1,22 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { assert, beforeAll, test } from 'vitest'; +import { deploy } from '../../../../../test-utils/deploy.vitest.js'; + +const app = deploy(import.meta.dirname); +const output = path.join(app, '.vercel/output'); + +/** @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')))); + +test('trailing slashes are removed with a single route when every route uses trailingSlash never', () => { + const redirects = config.routes.filter((route) => route.status === 308); + assert.deepEqual(redirects, [{ src: '^/(.+)/$', headers: { Location: '/$1' }, status: 308 }]); +}); + +test('the trailing slash redirect is matched before the filesystem', () => { + const filesystem = config.routes.findIndex((route) => route.handle === 'filesystem'); + const index = config.routes.findIndex((route) => route.status === 308); + assert.ok(index < filesystem); +}); diff --git a/packages/adapter-vercel/test/apps/split/package.json b/packages/adapter-vercel/test/apps/split/package.json index 49fb9d27d13d..867fe917b10b 100644 --- a/packages/adapter-vercel/test/apps/split/package.json +++ b/packages/adapter-vercel/test/apps/split/package.json @@ -6,14 +6,17 @@ "dev": "vite dev", "preview": "vite preview", "build": "vite build", + "test:build": "vitest run build.test.js", "test:platform": "playwright test" }, "devDependencies": { + "@sveltejs/adapter-vercel": "workspace:*", "@sveltejs/kit": "workspace:*", "@sveltejs/vite-plugin-svelte": "catalog:", "svelte": "catalog:", "typescript": "catalog:", - "vite": "catalog:" + "vite": "catalog:", + "vitest": "catalog:" }, "type": "module" } diff --git a/packages/adapter-vercel/test/apps/split/src/routes/prerendered/[slug]/+page.server.ts b/packages/adapter-vercel/test/apps/split/src/routes/prerendered/[slug]/+page.server.ts new file mode 100644 index 000000000000..1cd447f4c6ab --- /dev/null +++ b/packages/adapter-vercel/test/apps/split/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/split/src/routes/prerendered/[slug]/+page.svelte b/packages/adapter-vercel/test/apps/split/src/routes/prerendered/[slug]/+page.svelte new file mode 100644 index 000000000000..e22414ef4977 --- /dev/null +++ b/packages/adapter-vercel/test/apps/split/src/routes/prerendered/[slug]/+page.svelte @@ -0,0 +1,5 @@ + + +

prerendered: {data.slug}

diff --git a/packages/adapter-vercel/test/apps/split/test/test.ts b/packages/adapter-vercel/test/apps/split/test/test.ts index 2f4b2d21d3c7..2c3b0f6f10da 100644 --- a/packages/adapter-vercel/test/apps/split/test/test.ts +++ b/packages/adapter-vercel/test/apps/split/test/test.ts @@ -17,3 +17,41 @@ test('reroute works', async ({ page }) => { // TODO: test remote function works // TODO: test preloadCode works + +test('prerendered pages are served without a trailing slash', async ({ request }) => { + for (const [pathname, slug] of [ + ['/prerendered/a', 'a'], + ['/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 pages redirect to remove the trailing slash', async ({ request }) => { + for (const pathname of ['/prerendered/a', '/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('dynamic pages redirect to remove the trailing slash', async ({ request }) => { + const response = await request.get('/a/', { maxRedirects: 0 }); + expect(response.status()).toBe(308); + expect(response.headers()['location']).toBe('/a'); +}); + +test('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('rerouted pages redirect to remove the trailing slash', async ({ request }) => { + const response = await request.get('/en/reroute/', { maxRedirects: 0 }); + expect(response.status()).toBe(308); + expect(response.headers()['location']).toBe('/en/reroute'); +}); diff --git a/packages/adapter-vercel/test/apps/split/vite.config.ts b/packages/adapter-vercel/test/apps/split/vite.config.ts index 0b40b6378475..b756797de26e 100644 --- a/packages/adapter-vercel/test/apps/split/vite.config.ts +++ b/packages/adapter-vercel/test/apps/split/vite.config.ts @@ -1,6 +1,6 @@ import { sveltekit } from '@sveltejs/kit/vite'; import { defineConfig } from 'vite'; -import adapter from '../../../index.js'; +import adapter from '@sveltejs/adapter-vercel'; export default defineConfig({ plugins: [ diff --git a/packages/adapter-vercel/test/apps/trailing-slash/build.test.js b/packages/adapter-vercel/test/apps/trailing-slash/build.test.js new file mode 100644 index 000000000000..47355d6bb905 --- /dev/null +++ b/packages/adapter-vercel/test/apps/trailing-slash/build.test.js @@ -0,0 +1,36 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { assert, beforeAll, test } from 'vitest'; +import { deploy } from '../../../../../test-utils/deploy.vitest.js'; + +const app = deploy(import.meta.dirname); +const output = path.join(app, '.vercel/output'); + +/** @type {{ routes: Array<{ src?: string, dest?: string, handle?: string, status?: number, check?: boolean, headers?: Record }>, overrides: Record }} */ +let config; +beforeAll(() => (config = JSON.parse(fs.readFileSync(`${output}/config.json`, 'utf8')))); + +test('trailing slashes are added with a single route when every route uses trailingSlash always', () => { + const redirects = config.routes.filter((route) => route.status === 308); + assert.equal(redirects.length, 1); + assert.equal(redirects[0].headers?.Location, '$1/'); +}); + +test('the trailing slash redirect is matched after the filesystem', () => { + const filesystem = config.routes.findIndex((route) => route.handle === 'filesystem'); + const index = config.routes.findIndex((route) => route.status === 308); + assert.ok(index > filesystem); +}); + +test('prerendered pages are renamed so that Vercel does not serve them without a trailing slash', () => { + assert.deepEqual(config.overrides['prerendered/a/index.html'], { + path: 'prerendered/a/__index.html' + }); + + const miss = config.routes.findIndex((route) => route.handle === 'miss'); + assert.deepEqual(config.routes[miss + 1], { + src: '^(/.+)/$', + dest: '$1/__index.html', + check: true + }); +}); diff --git a/packages/adapter-vercel/test/apps/trailing-slash/package.json b/packages/adapter-vercel/test/apps/trailing-slash/package.json new file mode 100644 index 000000000000..f62e93e5418c --- /dev/null +++ b/packages/adapter-vercel/test/apps/trailing-slash/package.json @@ -0,0 +1,22 @@ +{ + "name": "test-vercel-trailing-slash", + "version": "0.0.1", + "private": true, + "scripts": { + "dev": "vite dev", + "preview": "vite preview", + "build": "vite build", + "test:build": "vitest run build.test.js", + "test:platform": "playwright test" + }, + "devDependencies": { + "@sveltejs/adapter-vercel": "workspace:*", + "@sveltejs/kit": "workspace:*", + "@sveltejs/vite-plugin-svelte": "catalog:", + "svelte": "catalog:", + "typescript": "catalog:", + "vite": "catalog:", + "vitest": "catalog:" + }, + "type": "module" +} diff --git a/packages/adapter-vercel/test/apps/trailing-slash/playwright.config.js b/packages/adapter-vercel/test/apps/trailing-slash/playwright.config.js new file mode 100644 index 000000000000..33d36b651014 --- /dev/null +++ b/packages/adapter-vercel/test/apps/trailing-slash/playwright.config.js @@ -0,0 +1 @@ +export { config as default } from '../../utils.js'; diff --git a/packages/adapter-vercel/test/apps/trailing-slash/src/app.d.ts b/packages/adapter-vercel/test/apps/trailing-slash/src/app.d.ts new file mode 100644 index 000000000000..da08e6da592d --- /dev/null +++ b/packages/adapter-vercel/test/apps/trailing-slash/src/app.d.ts @@ -0,0 +1,13 @@ +// See https://svelte.dev/docs/kit/types#app.d.ts +// for information about these interfaces +declare global { + namespace App { + // interface Error {} + // interface Locals {} + // interface PageData {} + // interface PageState {} + // interface Platform {} + } +} + +export {}; diff --git a/packages/adapter-vercel/test/apps/trailing-slash/src/app.html b/packages/adapter-vercel/test/apps/trailing-slash/src/app.html new file mode 100644 index 000000000000..f273cc58f7eb --- /dev/null +++ b/packages/adapter-vercel/test/apps/trailing-slash/src/app.html @@ -0,0 +1,11 @@ + + + + + + %sveltekit.head% + + +
%sveltekit.body%
+ + diff --git a/packages/adapter-vercel/test/apps/trailing-slash/src/routes/+layout.js b/packages/adapter-vercel/test/apps/trailing-slash/src/routes/+layout.js new file mode 100644 index 000000000000..d3c325085ed2 --- /dev/null +++ b/packages/adapter-vercel/test/apps/trailing-slash/src/routes/+layout.js @@ -0,0 +1 @@ +export const trailingSlash = 'always'; diff --git a/packages/adapter-vercel/test/apps/trailing-slash/src/routes/+page.svelte b/packages/adapter-vercel/test/apps/trailing-slash/src/routes/+page.svelte new file mode 100644 index 000000000000..c80f9c7003f7 --- /dev/null +++ b/packages/adapter-vercel/test/apps/trailing-slash/src/routes/+page.svelte @@ -0,0 +1,6 @@ +

Hello from SvelteKit on Vercel

+ + diff --git a/packages/adapter-vercel/test/apps/trailing-slash/src/routes/api/+server.ts b/packages/adapter-vercel/test/apps/trailing-slash/src/routes/api/+server.ts new file mode 100644 index 000000000000..1588af180ee7 --- /dev/null +++ b/packages/adapter-vercel/test/apps/trailing-slash/src/routes/api/+server.ts @@ -0,0 +1,5 @@ +export const trailingSlash = 'always'; + +export function GET() { + return Response.json({ ok: true }); +} diff --git a/packages/adapter-vercel/test/apps/trailing-slash/src/routes/prerendered/[slug]/+page.server.ts b/packages/adapter-vercel/test/apps/trailing-slash/src/routes/prerendered/[slug]/+page.server.ts new file mode 100644 index 000000000000..1cd447f4c6ab --- /dev/null +++ b/packages/adapter-vercel/test/apps/trailing-slash/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/trailing-slash/src/routes/prerendered/[slug]/+page.svelte b/packages/adapter-vercel/test/apps/trailing-slash/src/routes/prerendered/[slug]/+page.svelte new file mode 100644 index 000000000000..e22414ef4977 --- /dev/null +++ b/packages/adapter-vercel/test/apps/trailing-slash/src/routes/prerendered/[slug]/+page.svelte @@ -0,0 +1,5 @@ + + +

prerendered: {data.slug}

diff --git a/packages/adapter-vercel/test/apps/trailing-slash/src/routes/server-data/+page.server.ts b/packages/adapter-vercel/test/apps/trailing-slash/src/routes/server-data/+page.server.ts new file mode 100644 index 000000000000..72fdc9353f50 --- /dev/null +++ b/packages/adapter-vercel/test/apps/trailing-slash/src/routes/server-data/+page.server.ts @@ -0,0 +1,3 @@ +export function load() { + return { message: 'loaded on server' }; +} diff --git a/packages/adapter-vercel/test/apps/trailing-slash/src/routes/server-data/+page.svelte b/packages/adapter-vercel/test/apps/trailing-slash/src/routes/server-data/+page.svelte new file mode 100644 index 000000000000..71cc0d1af565 --- /dev/null +++ b/packages/adapter-vercel/test/apps/trailing-slash/src/routes/server-data/+page.svelte @@ -0,0 +1,5 @@ + + +

{data.message}

diff --git a/packages/adapter-vercel/test/apps/trailing-slash/static/no-extension b/packages/adapter-vercel/test/apps/trailing-slash/static/no-extension new file mode 100644 index 000000000000..b3d21d888601 --- /dev/null +++ b/packages/adapter-vercel/test/apps/trailing-slash/static/no-extension @@ -0,0 +1 @@ +no extension diff --git a/packages/adapter-vercel/test/apps/trailing-slash/static/robots.txt b/packages/adapter-vercel/test/apps/trailing-slash/static/robots.txt new file mode 100644 index 000000000000..7d329b1db3f0 --- /dev/null +++ b/packages/adapter-vercel/test/apps/trailing-slash/static/robots.txt @@ -0,0 +1 @@ +User-agent: * diff --git a/packages/adapter-vercel/test/apps/trailing-slash/test/test.ts b/packages/adapter-vercel/test/apps/trailing-slash/test/test.ts new file mode 100644 index 000000000000..57d0169be332 --- /dev/null +++ b/packages/adapter-vercel/test/apps/trailing-slash/test/test.ts @@ -0,0 +1,81 @@ +import { type APIResponse, expect, test } from '@playwright/test'; + +/** + * Redirects from SvelteKit are relative, while those from Vercel's routing are not + */ +function redirect_location(response: APIResponse) { + const url = new URL(response.headers()['location'], response.url()); + return url.pathname + url.search; +} + +test('root page renders', async ({ page }) => { + await page.goto('/'); + await expect(page.locator('h1')).toContainText('Hello from SvelteKit on Vercel'); +}); + +test('prerendered pages are served with a trailing slash', async ({ request }) => { + for (const [pathname, slug] of [ + ['/prerendered/a/', 'a'], + ['/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 pages redirect to add the trailing slash', async ({ request }) => { + for (const pathname of ['/prerendered/a', '/prerendered/caf%C3%A9', '/prerendered/v1.2']) { + const response = await request.get(pathname, { maxRedirects: 0 }); + expect(response.status()).toBe(308); + expect(redirect_location(response)).toBe(`${pathname}/`); + } +}); + +test('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(redirect_location(response)).toBe('/prerendered/a/?foo=bar'); +}); + +test('dynamic pages are served with a trailing slash', async ({ page }) => { + await page.goto('/server-data/'); + expect(new URL(page.url()).pathname).toBe('/server-data/'); + await expect(page.locator('h1')).toContainText('loaded on server'); +}); + +test('dynamic pages redirect to add the trailing slash', async ({ request }) => { + const response = await request.get('/server-data', { maxRedirects: 0 }); + expect(response.status()).toBe(308); + expect(redirect_location(response)).toBe('/server-data/'); +}); + +test('endpoints are served with a trailing slash', async ({ request }) => { + const response = await request.get('/api/', { maxRedirects: 0 }); + expect(response.status()).toBe(200); + expect(await response.json()).toEqual({ ok: true }); + + const redirect = await request.get('/api', { maxRedirects: 0 }); + expect(redirect.status()).toBe(308); + expect(redirect_location(redirect)).toBe('/api/'); +}); + +test('static assets are not redirected', async ({ request }) => { + for (const pathname of ['/robots.txt', '/no-extension', '/_app/version.json']) { + const response = await request.get(pathname, { maxRedirects: 0 }); + expect(response.status()).toBe(200); + } +}); + +test('client-side navigation works (validates __data.json)', async ({ page }) => { + await page.goto('/'); + + await page.click('a[href="/server-data/"]'); + await expect(page.locator('h1')).toContainText('loaded on server'); + expect(new URL(page.url()).pathname).toBe('/server-data/'); + + await page.goBack(); + await page.click('a[href="/prerendered/a/"]'); + await expect(page.locator('h1')).toContainText('prerendered: a'); +}); diff --git a/packages/adapter-vercel/test/apps/trailing-slash/tsconfig.json b/packages/adapter-vercel/test/apps/trailing-slash/tsconfig.json new file mode 100644 index 000000000000..619fe2dd9d72 --- /dev/null +++ b/packages/adapter-vercel/test/apps/trailing-slash/tsconfig.json @@ -0,0 +1,15 @@ +{ + "extends": "$app/tsconfig", + "compilerOptions": { + "rewriteRelativeImportExtensions": true, + "allowJs": true, + "checkJs": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "sourceMap": true, + "moduleResolution": "bundler" + }, + "include": ["src", "test", "playwright.config.js", "vite.config.ts"] +} diff --git a/packages/adapter-vercel/test/apps/trailing-slash/vite.config.ts b/packages/adapter-vercel/test/apps/trailing-slash/vite.config.ts new file mode 100644 index 000000000000..a220bfd4b005 --- /dev/null +++ b/packages/adapter-vercel/test/apps/trailing-slash/vite.config.ts @@ -0,0 +1,11 @@ +import { sveltekit } from '@sveltejs/kit/vite'; +import { defineConfig } from 'vite'; +import adapter from '@sveltejs/adapter-vercel'; + +export default defineConfig({ + plugins: [ + sveltekit({ + adapter: adapter() + }) + ] +}); diff --git a/packages/adapter-vercel/utils.js b/packages/adapter-vercel/utils.js index f5ebc3b12115..c213b3a2ec08 100644 --- a/packages/adapter-vercel/utils.js +++ b/packages/adapter-vercel/utils.js @@ -19,6 +19,28 @@ export function pattern_to_src(pattern) { return src; } +/** @param {string} str */ +export function escape_regex(str) { + return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +/** + * 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. + * Returns `undefined` if the routes disagree, if they use `'ignore'`, + * or if the version of SvelteKit doesn't expose the option to adapters. + * This function is duplicated in adapter-static + * @param {Array<{ trailingSlash?: 'never' | 'always' | 'ignore' }>} routes + * @returns {'never' | 'always' | undefined} + */ +export function get_uniform_trailing_slash(routes) { + const values = new Set(routes.map((route) => route.trailingSlash)); + if (values.size !== 1) return; + + const [value] = values; + if (value === 'never' || value === 'always') return value; +} + const integer = /^\d+$/; /** diff --git a/packages/adapter-vercel/utils.spec.js b/packages/adapter-vercel/utils.spec.js index ea7bd5c02004..61da15ffd8b5 100644 --- a/packages/adapter-vercel/utils.spec.js +++ b/packages/adapter-vercel/utils.spec.js @@ -1,6 +1,11 @@ import process from 'node:process'; import { assert, test, describe } from 'vitest'; -import { parse_isr_expiration, pattern_to_src, resolve_runtime } from './utils.js'; +import { + get_uniform_trailing_slash, + parse_isr_expiration, + pattern_to_src, + resolve_runtime +} 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 const { parse_route_id } = await import( @@ -146,3 +151,35 @@ describe('resolve_runtime with bun detected', () => { }); }); }); + +describe('get_uniform_trailing_slash', () => { + test('returns the option when every route uses it', () => { + assert.equal( + get_uniform_trailing_slash([{ trailingSlash: 'never' }, { trailingSlash: 'never' }]), + 'never' + ); + assert.equal( + get_uniform_trailing_slash([{ trailingSlash: 'always' }, { trailingSlash: 'always' }]), + 'always' + ); + }); + + test('returns undefined when routes use different options', () => { + assert.equal( + get_uniform_trailing_slash([{ trailingSlash: 'never' }, { trailingSlash: 'always' }]), + undefined + ); + }); + + test('returns undefined when routes ignore trailing slashes', () => { + assert.equal(get_uniform_trailing_slash([{ trailingSlash: 'ignore' }]), undefined); + }); + + test('returns undefined when SvelteKit does not expose the option', () => { + assert.equal(get_uniform_trailing_slash([{}, {}]), undefined); + }); + + test('returns undefined when there are no routes', () => { + assert.equal(get_uniform_trailing_slash([]), undefined); + }); +}); diff --git a/packages/kit/src/core/adapt/builder.js b/packages/kit/src/core/adapt/builder.js index 37d3a422f5fe..48286e0bd7db 100644 --- a/packages/kit/src/core/adapt/builder.js +++ b/packages/kit/src/core/adapt/builder.js @@ -77,7 +77,7 @@ export function create_builder({ * we expose a stable type that adapters can use to group/filter routes */ const routes = route_data.map((route) => { - const { config, methods, page, api } = /** @type {ServerMetadataRoute} */ ( + const { config, methods, page, api, trailing_slash } = /** @type {ServerMetadataRoute} */ ( server_metadata.routes.get(route.id) ); @@ -93,6 +93,7 @@ export function create_builder({ })), pattern: route.pattern, prerender: prerender_map.get(route.id) ?? false, + trailingSlash: trailing_slash, methods, config }; diff --git a/packages/kit/src/core/postbuild/analyse.js b/packages/kit/src/core/postbuild/analyse.js index 113b035ef1a3..e92e7e62b18f 100644 --- a/packages/kit/src/core/postbuild/analyse.js +++ b/packages/kit/src/core/postbuild/analyse.js @@ -116,6 +116,8 @@ async function analyse({ const route_config = page?.config ?? endpoint?.config ?? {}; const prerender = page?.prerender ?? endpoint?.prerender; + // a page's option takes precedence over its endpoint's, as it does at runtime + const trailing_slash = page?.trailing_slash ?? endpoint?.trailing_slash ?? 'never'; if (prerender !== true) { for (const feature of list_features( @@ -142,6 +144,7 @@ async function analyse({ methods: api_methods }, prerender, + trailing_slash, entries: entries && (await entries()).map((entry_object) => resolve_route(route.id, entry_object)) }); @@ -205,7 +208,8 @@ function analyse_endpoint(route, mod) { config: mod.config, entries: mod.entries, methods, - prerender: mod.prerender ?? false + prerender: mod.prerender ?? false, + trailing_slash: mod.trailingSlash ?? 'never' }; } @@ -225,7 +229,8 @@ function analyse_page(layouts, leaf) { config: nodes.get_config(), entries: leaf.universal?.entries ?? leaf.server?.entries, methods, - prerender: nodes.prerender() + prerender: nodes.prerender(), + trailing_slash: nodes.trailing_slash() }; } diff --git a/packages/kit/src/exports/public.d.ts b/packages/kit/src/exports/public.d.ts index 33c1091e6790..cf71da58346a 100644 --- a/packages/kit/src/exports/public.d.ts +++ b/packages/kit/src/exports/public.d.ts @@ -10,7 +10,8 @@ import { Prerendered, PrerenderOption, RequestOptions, - RouteSegment + RouteSegment, + TrailingSlash } from '../types/private.js'; import { ValidatedConfig } from 'types'; import { Plugin } from 'vite'; @@ -730,6 +731,11 @@ export interface RouteDefinition { }; pattern: RegExp; prerender: PrerenderOption; + /** + * The route's [`trailingSlash`](https://svelte.dev/docs/kit/page-options#trailingSlash) option + * @since 3.2.0 + */ + trailingSlash: TrailingSlash; segments: RouteSegment[]; methods: Array; config: Config; diff --git a/packages/kit/src/types/internal.d.ts b/packages/kit/src/types/internal.d.ts index a5db4416a2dd..3cdbd9450f06 100644 --- a/packages/kit/src/types/internal.d.ts +++ b/packages/kit/src/types/internal.d.ts @@ -427,6 +427,7 @@ export interface ServerMetadataRoute { }; methods: Array; prerender: PrerenderOption | undefined; + trailing_slash: TrailingSlash; entries: string[] | undefined; } diff --git a/packages/kit/types/index.d.ts b/packages/kit/types/index.d.ts index c2b56afff601..263c58fa7485 100644 --- a/packages/kit/types/index.d.ts +++ b/packages/kit/types/index.d.ts @@ -714,6 +714,11 @@ declare module '@sveltejs/kit' { }; pattern: RegExp; prerender: PrerenderOption; + /** + * The route's [`trailingSlash`](https://svelte.dev/docs/kit/page-options#trailingSlash) option + * @since 3.2.0 + */ + trailingSlash: TrailingSlash; segments: RouteSegment[]; methods: Array; config: Config; @@ -961,6 +966,9 @@ declare module '@sveltejs/kit' { dynamic: boolean; rest: boolean; } + + /** @default 'never' */ + type TrailingSlash = 'never' | 'always' | 'ignore'; type RecursiveRequired = { // Recursive implementation of TypeScript's Required utility type. // Will recursively continue until it reaches a primitive or Function diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1c65bc761dcd..9007bdeaba5f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -679,6 +679,9 @@ importers: packages/adapter-vercel/test/apps/split: devDependencies: + '@sveltejs/adapter-vercel': + specifier: workspace:* + version: link:../../.. '@sveltejs/kit': specifier: workspace:* version: link:../../../../kit @@ -694,6 +697,33 @@ importers: vite: specifier: 'catalog:' version: 8.3.0(@types/node@22.20.4)(esbuild@0.28.1)(yaml@2.9.1) + vitest: + specifier: 'catalog:' + version: 5.0.1(@opentelemetry/api@1.9.1)(@types/node@22.20.4)(@vitest/browser-playwright@5.0.1)(@vitest/ui@5.0.1)(jsdom@30.1.0)(vite@8.3.0(@types/node@22.20.4)(esbuild@0.28.1)(yaml@2.9.1)) + + packages/adapter-vercel/test/apps/trailing-slash: + devDependencies: + '@sveltejs/adapter-vercel': + specifier: workspace:* + version: link:../../.. + '@sveltejs/kit': + specifier: workspace:* + version: link:../../../../kit + '@sveltejs/vite-plugin-svelte': + specifier: 'catalog:' + version: 7.3.1(svelte@5.57.1(@typescript-eslint/types@8.70.1))(vite@8.3.0(@types/node@22.20.4)(esbuild@0.28.1)(yaml@2.9.1)) + svelte: + specifier: 'catalog:' + version: 5.57.1(@typescript-eslint/types@8.70.1) + typescript: + specifier: 'catalog:' + version: 6.0.3 + vite: + specifier: 'catalog:' + version: 8.3.0(@types/node@22.20.4)(esbuild@0.28.1)(yaml@2.9.1) + vitest: + specifier: 'catalog:' + version: 5.0.1(@opentelemetry/api@1.9.1)(@types/node@22.20.4)(@vitest/browser-playwright@5.0.1)(@vitest/ui@5.0.1)(jsdom@30.1.0)(vite@8.3.0(@types/node@22.20.4)(esbuild@0.28.1)(yaml@2.9.1)) packages/enhanced-img: dependencies: