Skip to content

fix: correctly handle pathnames found in the _redirects file #12821

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 24 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
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/fuzzy-maps-sort.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@sveltejs/adapter-cloudflare': patch
---

fix: error if `_routes.json` is in the `/static` public directory
5 changes: 5 additions & 0 deletions .changeset/quiet-onions-accept.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@sveltejs/adapter-cloudflare': patch
---

fix: correctly handle pathnames found in the `_redirects` file
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ Only for Cloudflare Pages. Allows you to customise the [`_routes.json`](https://
- `exclude` defines routes that will _not_ invoke a function — this is a faster and cheaper way to serve your app's static assets. This array can include the following special values:
- `<build>` contains your app's build artifacts (the files generated by Vite)
- `<files>` contains the contents of your `static` directory
- `<redirects>` contains a list of pathnames from your [`_redirects` file](https://developers.cloudflare.com/pages/configuration/redirects/) at the root
- `<prerendered>` contains a list of prerendered pages
- `<all>` (the default) contains all of the above

Expand Down
4 changes: 4 additions & 0 deletions packages/adapter-cloudflare/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,10 @@ export interface AdapterOptions {
platformProxy?: GetPlatformProxyOptions;
}

/**
* The JSON format of the {@link https://developers.cloudflare.com/pages/functions/routing/#create-a-_routesjson-file | `_routes.json`}
* file that controls when the Cloudflare Pages Function is invoked.
*/
export interface RoutesJSONSpec {
version: 1;
description: string;
Expand Down
118 changes: 43 additions & 75 deletions packages/adapter-cloudflare/index.js
Original file line number Diff line number Diff line change
@@ -1,15 +1,19 @@
import { copyFileSync, existsSync, writeFileSync } from 'node:fs';
import path from 'node:path';
import process from 'node:process';
import { copyFileSync, existsSync, readFileSync, writeFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { get_routes_json, parse_redirects } from './utils.js';
import { getPlatformProxy, unstable_readConfig } from 'wrangler';

/** @type {import('./index.js').default} */
export default function (options = {}) {
return {
name: '@sveltejs/adapter-cloudflare',
async adapt(builder) {
if (existsSync('_routes.json')) {
if (
existsSync('_routes.json') ||
existsSync(`${builder.config.kit.files.assets}/_routes.json`)
) {
throw new Error(
"Cloudflare Pages' _routes.json should be configured in svelte.config.js. See https://svelte.dev/docs/kit/adapter-cloudflare#Options-routes"
);
Expand Down Expand Up @@ -67,7 +71,9 @@ export default function (options = {}) {
building_for_cloudflare_pages ||
wrangler_config.assets?.not_found_handling === '404-page'
) {
// generate plaintext 404.html first which can then be overridden by prerendering, if the user defined such a page
// generate plaintext 404.html first which can then be overridden by prerendering, if the user defined such a page.
// This file is served when a request fails to match an asset.
// If we're building for Cloudflare Pages, it's only served when a request matches an entry in `routes.exclude`
const fallback = path.join(assets_dest, '404.html');
if (options.fallback === 'spa') {
await builder.generateFallback(fallback);
Expand Down Expand Up @@ -104,28 +110,43 @@ export default function (options = {}) {
});

// _headers
if (existsSync('_headers')) {
copyFileSync('_headers', `${dest}/_headers`);
const headers_src = '_headers';
const headers_dest = `${dest}/_headers`;
if (existsSync(headers_src)) {
copyFileSync(headers_src, headers_dest);
}
writeFileSync(`${dest}/_headers`, generate_headers(builder.getAppPath()), { flag: 'a' });
writeFileSync(headers_dest, generate_headers(builder.getAppPath()), { flag: 'a' });

// _redirects
if (existsSync('_redirects')) {
copyFileSync('_redirects', `${dest}/_redirects`);
const redirects_src = '_redirects';
const redirects_dest = `${dest}/_redirects`;
if (existsSync(redirects_src)) {
copyFileSync(redirects_src, redirects_dest);
}
if (builder.prerendered.redirects.size > 0) {
writeFileSync(`${dest}/_redirects`, generate_redirects(builder.prerendered.redirects), {
writeFileSync(redirects_dest, generate_redirects(builder.prerendered.redirects), {
flag: 'a'
});
}

writeFileSync(`${dest}/.assetsignore`, generate_assetsignore(), { flag: 'a' });

if (building_for_cloudflare_pages) {
// _routes.json
/** @type {string[]} */
let redirects = [];
if (existsSync(redirects_dest)) {
const redirect_rules = readFileSync(redirects_dest, 'utf8');
redirects = parse_redirects(redirect_rules);
}
writeFileSync(
`${dest}/_routes.json`,
JSON.stringify(get_routes_json(builder, client_assets, options.routes ?? {}), null, '\t')
JSON.stringify(
get_routes_json(builder, client_assets, redirects, options.routes ?? {}),
null,
'\t'
)
);
} else {
writeFileSync(`${dest}/.assetsignore`, generate_assetsignore(), { flag: 'a' });
}
},
emulate() {
Expand Down Expand Up @@ -167,68 +188,9 @@ export default function (options = {}) {
}

/**
* @param {import('@sveltejs/kit').Builder} builder
* @param {string[]} assets
* @param {import('./index.js').AdapterOptions['routes']} routes
* @returns {import('./index.js').RoutesJSONSpec}
* @param {string} app_dir
* @returns {string}
*/
function get_routes_json(builder, assets, { include = ['/*'], exclude = ['<all>'] }) {
if (!Array.isArray(include) || !Array.isArray(exclude)) {
throw new Error('routes.include and routes.exclude must be arrays');
}

if (include.length === 0) {
throw new Error('routes.include must contain at least one route');
}

if (include.length > 100) {
throw new Error('routes.include must contain 100 or fewer routes');
}

exclude = exclude
.flatMap((rule) => (rule === '<all>' ? ['<build>', '<files>', '<prerendered>'] : rule))
.flatMap((rule) => {
if (rule === '<build>') {
return [`/${builder.getAppPath()}/immutable/*`, `/${builder.getAppPath()}/version.json`];
}

if (rule === '<files>') {
return assets
.filter(
(file) =>
!(
file.startsWith(`${builder.config.kit.appDir}/`) ||
file === '_headers' ||
file === '_redirects'
)
)
.map((file) => `${builder.config.kit.paths.base}/${file}`);
}

if (rule === '<prerendered>') {
return builder.prerendered.paths;
}

return rule;
});

const excess = include.length + exclude.length - 100;
if (excess > 0) {
const message = `Cloudflare Pages Functions' includes/excludes exceeds _routes.json limits (see https://developers.cloudflare.com/pages/platform/functions/routing/#limits). Dropping ${excess} exclude rules — this will cause unnecessary function invocations.`;
builder.log.warn(message);

exclude.length -= excess;
}

return {
version: 1,
description: 'Generated by @sveltejs/adapter-cloudflare',
include,
exclude
};
}

/** @param {string} app_dir */
function generate_headers(app_dir) {
return `
# === START AUTOGENERATED SVELTE IMMUTABLE HEADERS ===
Expand All @@ -242,7 +204,10 @@ function generate_headers(app_dir) {
`.trimEnd();
}

/** @param {Map<string, { status: number; location: string }>} redirects */
/**
* @param {Map<string, { status: number; location: string }>} redirects
* @returns {string}
*/
function generate_redirects(redirects) {
const rules = Array.from(
redirects.entries(),
Expand All @@ -256,14 +221,17 @@ ${rules}
`.trimEnd();
}

/**
* @returns {string}
*/
function generate_assetsignore() {
// this comes from https://github.com/cloudflare/workers-sdk/blob/main/packages/create-cloudflare/templates-experimental/svelte/templates/static/.assetsignore
return `
_worker.js
_routes.json
_headers
_redirects
`;
`.trimEnd();
}

/**
Expand Down
7 changes: 5 additions & 2 deletions packages/adapter-cloudflare/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,9 @@
"format": "pnpm lint --write",
"check": "tsc --skipLibCheck",
"prepublishOnly": "pnpm build",
"test": "pnpm build && pnpm -r --workspace-concurrency 1 --filter=\"./test/**\" test"
"test:unit": "vitest run",
"test:e2e": "pnpm build && pnpm -r --workspace-concurrency 1 --filter=\"./test/**\" test",
"test": "pnpm test:unit && pnpm test:e2e"
},
"dependencies": {
"@cloudflare/workers-types": "^4.20250507.0",
Expand All @@ -49,7 +51,8 @@
"@sveltejs/kit": "workspace:^",
"@types/node": "^18.19.48",
"esbuild": "^0.25.4",
"typescript": "^5.3.3"
"typescript": "^5.3.3",
"vitest": "^3.1.1"
},
"peerDependencies": {
"@sveltejs/kit": "^2.0.0",
Expand Down
9 changes: 8 additions & 1 deletion packages/adapter-cloudflare/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,5 +15,12 @@
"lib": ["es2021"],
"types": ["@cloudflare/workers-types"]
},
"include": ["index.js", "utils.js", "test/utils.js", "internal.d.ts", "src/worker.js"]
"include": [
"index.js",
"utils.js",
"utils.spec.js",
"test/utils.js",
"internal.d.ts",
"src/worker.js"
]
}
111 changes: 111 additions & 0 deletions packages/adapter-cloudflare/utils.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
/**
* Extracts the redirect source from each line of a [_redirects](https://developers.cloudflare.com/pages/configuration/redirects/)
* file so we can exclude them in [_routes.json](https://developers.cloudflare.com/pages/functions/routing/#create-a-_routesjson-file)
* to ensure the redirect is invoked instead of the Cloudflare Worker.
* @param {string} file_contents
* @returns {string[]}
*/
export function parse_redirects(file_contents) {
/** @type {string[]} */
const redirects = [];

for (const line of file_contents.split('\n')) {
const content = line.trim();
if (!content) continue;

const [pathname] = line.split(' ');
// pathnames with placeholders are not supported
if (!pathname || pathname.includes('/:')) {
throw new Error(`The following _redirects rule cannot be excluded by _routes.json: ${line}`);
}
redirects.push(pathname);
}

return redirects;
}

/**
* Generates the [_routes.json](https://developers.cloudflare.com/pages/functions/routing/#create-a-_routesjson-file)
* file that dictates which routes invoke the Cloudflare Worker.
* @param {import('@sveltejs/kit').Builder} builder
* @param {string[]} client_assets
* @param {string[]} redirects
* @param {import('./index.js').AdapterOptions['routes']} routes
* @returns {import('./index.js').RoutesJSONSpec}
*/
export function get_routes_json(builder, client_assets, redirects, routes) {
const include = routes?.include ?? ['/*'];
let exclude = routes?.exclude ?? ['<all>'];

if (!Array.isArray(include) || !Array.isArray(exclude)) {
throw new Error('routes.include and routes.exclude must be arrays');
}

if (include?.length === 0) {
throw new Error('routes.include must contain at least one route');
}

if (include?.length > 100) {
throw new Error('routes.include must contain 100 or fewer routes');
}

/** @type {Set<string>} */
const transformed_rules = new Set();
for (const rule of exclude) {
if (rule === '<all>') {
transformed_rules.add('<build>');
transformed_rules.add('<files>');
transformed_rules.add('<prerendered>');
transformed_rules.add('<redirects>');
} else {
transformed_rules.add(rule);
}
}

/** @type {Set<string>} */
const excluded_routes = new Set();
for (const rule of transformed_rules) {
if (rule === '<build>') {
const app_path = builder.getAppPath();
excluded_routes.add(`/${app_path}/version.json`);
excluded_routes.add(`/${app_path}/immutable/*`);
continue;
}

if (rule === '<files>') {
for (const file of client_assets) {
if (file.startsWith(`${builder.config.kit.appDir}/`)) continue;
excluded_routes.add(`${builder.config.kit.paths.base}/${file}`);
}
continue;
}

if (rule === '<prerendered>') {
builder.prerendered.paths.forEach((path) => excluded_routes.add(path));
continue;
}

if (rule === '<redirects>') {
redirects.forEach((path) => excluded_routes.add(path));
continue;
}

excluded_routes.add(rule);
}
exclude = Array.from(excluded_routes);

const excess = include.length + exclude.length - 100;
if (excess > 0) {
builder.log.warn(
`Cloudflare Pages Functions' includes/excludes exceeds _routes.json limits (see https://developers.cloudflare.com/pages/platform/functions/routing/#limits). Dropping ${excess} exclude rules — this will cause unnecessary function invocations.`
);
exclude.length -= excess;
}

return {
version: 1,
description: 'Generated by @sveltejs/adapter-cloudflare',
include,
exclude
};
}
Loading
Loading