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
5 changes: 5 additions & 0 deletions .changeset/brave-routes-agree.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@sveltejs/kit': minor
---

feat: expose each route's `trailingSlash` option to adapters via `builder.routes`
6 changes: 6 additions & 0 deletions .changeset/lean-slashes-redirect.md
Original file line number Diff line number Diff line change
@@ -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
2 changes: 1 addition & 1 deletion .github/workflows/platform-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/platform-tests-all.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand Down
136 changes: 107 additions & 29 deletions packages/adapter-static/platforms.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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 !== '/') {
Expand All @@ -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
};
}
Expand Down
71 changes: 64 additions & 7 deletions packages/adapter-vercel/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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 !== '/') {
Expand All @@ -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 };
Expand Down Expand Up @@ -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'
});
Expand All @@ -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,
Expand Down
22 changes: 22 additions & 0 deletions packages/adapter-vercel/test/apps/split/build.test.js
Original file line number Diff line number Diff line change
@@ -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<string, string> }> }} */
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);
});
5 changes: 4 additions & 1 deletion packages/adapter-vercel/test/apps/split/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
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>
Loading
Loading