Skip to content
Closed
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/cloudflare-headers-collision.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@sveltejs/adapter-cloudflare': patch
---

fix: preserve user-defined `_headers` rules that collide with autogenerated immutable headers instead of discarding them
24 changes: 3 additions & 21 deletions packages/adapter-cloudflare/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
is_building_for_cloudflare_pages,
validate_worker_settings,
get_routes_json,
generate_headers,
parse_redirects
} from './utils.js';

Expand Down Expand Up @@ -136,10 +137,8 @@ export default function (options = {}) {
// _headers
const headers_src = '_headers';
const headers_dest = `${dest}/_headers`;
if (existsSync(headers_src)) {
copyFileSync(headers_src, headers_dest);
}
writeFileSync(headers_dest, generate_headers(builder.getAppPath()), { flag: 'a' });
const user_headers = existsSync(headers_src) ? readFileSync(headers_src, 'utf8') : '';
writeFileSync(headers_dest, generate_headers(builder.getAppPath(), user_headers));

// _redirects
const redirects_src = '_redirects';
Expand Down Expand Up @@ -228,23 +227,6 @@ export default function (options = {}) {
};
}

/**
* @param {string} app_dir
* @returns {string}
*/
function generate_headers(app_dir) {
return `
# === START AUTOGENERATED SVELTE IMMUTABLE HEADERS ===
/${app_dir}/*
X-Robots-Tag: noindex
Cache-Control: no-cache
/${app_dir}/immutable/*
! Cache-Control
Cache-Control: public, immutable, max-age=31536000
# === END AUTOGENERATED SVELTE IMMUTABLE HEADERS ===
`.trimEnd();
}

/**
* @param {Map<string, { status: number; location: string }>} redirects
* @returns {string}
Expand Down
121 changes: 121 additions & 0 deletions packages/adapter-cloudflare/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,127 @@ export function parse_redirects(file_contents) {
return redirects;
}

/**
* Parses a [_headers](https://developers.cloudflare.com/pages/configuration/headers/)
* file into a map of path pattern to its (trimmed) header lines, so we can merge
* the autogenerated immutable headers into any colliding rules the user defined.
* Cloudflare only applies the last rule for a given path, so a naive append would
* silently drop the user's headers.
* @param {string} file_contents
* @returns {Map<string, string[]>}
*/
export function parse_headers(file_contents) {
/** @type {Map<string, string[]>} */
const rules = new Map();

/** @type {string[] | undefined} */
let current;

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

if (/^\s/.test(line)) {
// indented line — a header belonging to the current path pattern.
// a header that appears before any path pattern is malformed and ignored
current?.push(content);
} else {
// a path pattern; reuse the existing entry if the pattern is repeated
current = rules.get(content);
if (!current) {
current = [];
rules.set(content, current);
}
}
}

return rules;
}

/**
* Removes the given path-pattern blocks (a pattern line plus its indented header
* lines) from a `_headers` file's contents, leaving all other rules and comments
* untouched. We use this to drop the user's rules that collide with the
* autogenerated immutable headers before re-emitting a single merged rule for
* each, so the output never contains two blocks for the same path — which
* Cloudflare would otherwise combine, duplicating the user's header values.
* @param {string} file_contents
* @param {Set<string>} patterns
* @returns {string}
*/
export function strip_headers_rules(file_contents, patterns) {
/** @type {string[]} */
const lines = [];
let skip = false;

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

if (content && /^\s/.test(line)) {
// indented header line — belongs to the current block
if (!skip) lines.push(line);
} else if (content && !content.startsWith('#')) {
// a path pattern — start of a new block
skip = patterns.has(content);
if (!skip) lines.push(line);
} else {
// blank line or comment — `parse_headers` ignores these without ending the
// current block (indented header lines after them still belong to the
// preceding pattern), so we must not reset `skip` here either. Otherwise a
// blank line inside a stripped block would let subsequent header lines
// survive as orphaned, malformed lines — and be duplicated in the merged
// autogenerated section. The line itself is harmless to keep.
lines.push(line);
}
}

return lines.join('\n');
}

/**
* Generates the contents of the `_headers` file: the user's own rules followed by
* the autogenerated immutable headers section. Any headers the user defined for
* the autogenerated paths are merged into that section (and their original blocks
* removed) so they aren't lost, while the adapter's own headers are negated first
* (`! <name>`) so they reliably override colliding user values instead of being
* comma-joined with them. See https://github.com/sveltejs/kit/issues/15862.
* @param {string} app_dir
* @param {string} user_headers contents of the user's `_headers` file (or `''`)
* @returns {string}
*/
export function generate_headers(app_dir, user_headers = '') {
const rules = parse_headers(user_headers);
const app = rules.get(`/${app_dir}/*`) ?? [];
const immutable = rules.get(`/${app_dir}/immutable/*`) ?? [];

const remaining = strip_headers_rules(
user_headers,
new Set([`/${app_dir}/*`, `/${app_dir}/immutable/*`])
).trimEnd();

const app_headers = [
...app,
'! X-Robots-Tag',
'X-Robots-Tag: noindex',
'! Cache-Control',
'Cache-Control: no-cache'
];
const immutable_headers = [
...immutable,
'! Cache-Control',
'Cache-Control: public, immutable, max-age=31536000'
];

const autogenerated = `# === START AUTOGENERATED SVELTE IMMUTABLE HEADERS ===
/${app_dir}/*
${app_headers.map((line) => `\t${line}`).join('\n')}
/${app_dir}/immutable/*
${immutable_headers.map((line) => `\t${line}`).join('\n')}
# === END AUTOGENERATED SVELTE IMMUTABLE HEADERS ===`;

return remaining ? `${remaining}\n${autogenerated}` : autogenerated;
}

/**
* Generates the [_routes.json](https://developers.cloudflare.com/pages/functions/routing/#create-a-_routesjson-file)
* file that dictates which routes invoke the Cloudflare Worker.
Expand Down
128 changes: 127 additions & 1 deletion packages/adapter-cloudflare/utils.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,10 @@ import {
is_building_for_cloudflare_pages,
validate_worker_settings,
get_routes_json,
parse_redirects
generate_headers,
parse_headers,
parse_redirects,
strip_headers_rules
} from './utils.js';

describe('detects Cloudflare Pages project', () => {
Expand Down Expand Up @@ -174,6 +177,129 @@ test('parses _redirects file', () => {
expect(redirects).toEqual(['/home301', '/notrailing/', '/blog/*']);
});

test('parses a _headers file and ignores comments', () => {
const headers = parse_headers(
`
# This is a comment
/_app/immutable/*
Cross-Origin-Embedder-Policy: require-corp
# Indented comment
X-Custom: value
/about
X-Robots-Tag: noindex
`.trim()
);

expect(headers).toEqual(
new Map([
['/_app/immutable/*', ['Cross-Origin-Embedder-Policy: require-corp', 'X-Custom: value']],
['/about', ['X-Robots-Tag: noindex']]
])
);
});

test('merges repeated path patterns in a _headers file', () => {
const headers = parse_headers(
`
/_app/immutable/*
Cross-Origin-Embedder-Policy: require-corp
/_app/immutable/*
X-Custom: value
`.trim()
);

expect(headers).toEqual(
new Map([
['/_app/immutable/*', ['Cross-Origin-Embedder-Policy: require-corp', 'X-Custom: value']]
])
);
});

test('strips the given rules while leaving other rules and comments intact', () => {
const stripped = strip_headers_rules(
`# top comment
/_app/*
X-Custom: app
/_app/immutable/*
Cross-Origin-Embedder-Policy: require-corp

/about
X-Robots-Tag: noindex
`,
new Set(['/_app/*', '/_app/immutable/*'])
);

expect(stripped.trim()).toBe(
`# top comment

/about
X-Robots-Tag: noindex`
);
});

test('strips an entire block even when it contains blank lines', () => {
const stripped = strip_headers_rules(
`/_app/*
X-Custom: app

X-Other: value
/about
X-Robots-Tag: noindex
`,
new Set(['/_app/*'])
);

// the header following the blank line must not survive as an orphaned line
expect(stripped.trim()).toBe(
`/about
X-Robots-Tag: noindex`
);
});

test('generates immutable headers without a user _headers file', () => {
expect(generate_headers('_app')).toBe(
`# === START AUTOGENERATED SVELTE IMMUTABLE HEADERS ===
/_app/*
\t! X-Robots-Tag
\tX-Robots-Tag: noindex
\t! Cache-Control
\tCache-Control: no-cache
/_app/immutable/*
\t! Cache-Control
\tCache-Control: public, immutable, max-age=31536000
# === END AUTOGENERATED SVELTE IMMUTABLE HEADERS ===`
);
});

test('merges colliding user headers into the autogenerated section and removes duplicate blocks', () => {
const generated = generate_headers(
'_app',
`/about
X-Robots-Tag: noindex
/_app/immutable/*
Cross-Origin-Embedder-Policy: require-corp
Cache-Control: max-age=1
`
);

expect(generated).toBe(
`/about
X-Robots-Tag: noindex
# === START AUTOGENERATED SVELTE IMMUTABLE HEADERS ===
/_app/*
\t! X-Robots-Tag
\tX-Robots-Tag: noindex
\t! Cache-Control
\tCache-Control: no-cache
/_app/immutable/*
\tCross-Origin-Embedder-Policy: require-corp
\tCache-Control: max-age=1
\t! Cache-Control
\tCache-Control: public, immutable, max-age=31536000
# === END AUTOGENERATED SVELTE IMMUTABLE HEADERS ===`
);
});

test('generates a _routes.json file', () => {
const routes = get_routes_json(
{
Expand Down
Loading