diff --git a/.changeset/hip-apes-marry.md b/.changeset/hip-apes-marry.md new file mode 100644 index 000000000000..f41133600485 --- /dev/null +++ b/.changeset/hip-apes-marry.md @@ -0,0 +1,5 @@ +--- +'@sveltejs/adapter-cloudflare': patch +--- + +fix: avoid overriding user's existing `_headers` rules diff --git a/packages/adapter-cloudflare/index.js b/packages/adapter-cloudflare/index.js index cdbe96df353e..c39f57e61521 100644 --- a/packages/adapter-cloudflare/index.js +++ b/packages/adapter-cloudflare/index.js @@ -7,7 +7,8 @@ import { is_building_for_cloudflare_pages, validate_worker_settings, get_routes_json, - parse_redirects + parse_redirects, + append_headers } from './utils.js'; const name = '@sveltejs/adapter-cloudflare'; @@ -133,10 +134,12 @@ export default function (options = {}) { // _headers const headers_src = '_headers'; const headers_dest = `${dest}/_headers`; + /** @type {string | undefined} */ + let headers; if (existsSync(headers_src)) { - copyFileSync(headers_src, headers_dest); + headers = readFileSync(headers_src, 'utf-8'); } - writeFileSync(headers_dest, generate_headers(builder.getAppPath()), { flag: 'a' }); + writeFileSync(headers_dest, generate_headers(builder.getAppPath(), headers)); // _redirects const redirects_src = '_redirects'; @@ -218,19 +221,23 @@ export default function (options = {}) { /** * @param {string} app_dir + * @param {string | undefined} content existing `_headers` file content * @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(); +function generate_headers(app_dir, content = '') { + content = append_headers( + `/${app_dir}/*`, + ['X-Robots-Tag: noindex', 'Cache-Control: no-cache'], + content + ); + + content = append_headers( + `/${app_dir}/immutable/*`, + ['! Cache-Control', 'Cache-Control: public, immutable, max-age=31536000'], + content + ); + + return content; } /** diff --git a/packages/adapter-cloudflare/utils.js b/packages/adapter-cloudflare/utils.js index 2b1c7c74b567..90e45a27a3b9 100644 --- a/packages/adapter-cloudflare/utils.js +++ b/packages/adapter-cloudflare/utils.js @@ -163,3 +163,29 @@ export function get_routes_json(builder, client_assets, redirects, routes) { exclude }; } + +/** + * Adds header rules to the contents of a `_headers` file + * @param {string} url + * @param {string[]} rules + * @param {string} content + * @returns {string} + */ +export function append_headers(url, rules, content) { + const regex = new RegExp(`^(${url.replaceAll('*', '\\*')})$`, 'm'); + const formatted_headers = rules.map((rule) => ` ${rule}`).join('\n'); + + // if the URL already exists, just add header rules to it + if (regex.test(content)) { + return content.replace(regex, `$1\n${formatted_headers}`); + } + + // otherwise, we add the url and header rules + return ` +${content} +# === START AUTOGENERATED SVELTE IMMUTABLE HEADERS === +${url} +${formatted_headers} +# === END AUTOGENERATED SVELTE IMMUTABLE HEADERS === +`.trim(); +} diff --git a/packages/adapter-cloudflare/utils.spec.js b/packages/adapter-cloudflare/utils.spec.js index b595423cb094..d9f2ed97dc5a 100644 --- a/packages/adapter-cloudflare/utils.spec.js +++ b/packages/adapter-cloudflare/utils.spec.js @@ -3,7 +3,8 @@ import { is_building_for_cloudflare_pages, validate_worker_settings, get_routes_json, - parse_redirects + parse_redirects, + append_headers } from './utils.js'; describe('detects Cloudflare Pages project', () => { @@ -157,178 +158,219 @@ describe('validates Wrangler config', () => { }); }); -test('ignores comments in _redirects file', () => { - const redirects = parse_redirects( - ` -# This is a comment -/home301 / 301 - # Indented comment -/blog/* https://blog.my.domain/:splat +describe('_headers', () => { + test('adds URL and its headers', () => { + const content = append_headers( + '/_app/*', + ['X-Robots-Tag: noindex', 'Cache-Control: no-cache'], + '/custom\n X-Custom-Rule: value' + ); + expect(content).toEqual( + ` +/custom + X-Custom-Rule: value +# === START AUTOGENERATED SVELTE IMMUTABLE HEADERS === +/_app/* + X-Robots-Tag: noindex + Cache-Control: no-cache +# === END AUTOGENERATED SVELTE IMMUTABLE HEADERS === `.trim() - ); + ); + }); - expect(redirects).toEqual(['/home301', '/blog/*']); + test('adds headers to existing URLs', () => { + const content = append_headers( + '/_app/*', + ['X-Robots-Tag: noindex', 'Cache-Control: no-cache'], + '/_app/*\n X-Custom-Rule: value' + ); + expect(content).toEqual( + ` +/_app/* + X-Robots-Tag: noindex + Cache-Control: no-cache + X-Custom-Rule: value +`.trim() + ); + }); }); -test('parses _redirects file', () => { - const redirects = parse_redirects( - ` +describe('_redirects', () => { + test('parses sources', () => { + const redirects = parse_redirects( + ` /home301 / 301 /notrailing/ /nottrailing 301 /blog/* https://blog.my.domain/:splat `.trim() - ); + ); + + expect(redirects).toEqual(['/home301', '/notrailing/', '/blog/*']); + }); + + test('ignores comments while parsing', () => { + const redirects = parse_redirects( + ` +# This is a comment +/home301 / 301 + # Indented comment +/blog/* https://blog.my.domain/:splat +`.trim() + ); - expect(redirects).toEqual(['/home301', '/notrailing/', '/blog/*']); + expect(redirects).toEqual(['/home301', '/blog/*']); + }); }); -test('generates a _routes.json file', () => { - const routes = get_routes_json( - { - getAppPath: () => 'base-path/_app', - config: { - kit: { - appDir: '_app', - paths: { - base: '/base-path', - assets: '', - relative: true - }, - alias: {}, - csrf: { - checkOrigin: true, - trustedOrigins: [] - }, - embedded: false, - files: { - src: 'src', - assets: 'static', - hooks: { - client: 'src/hooks.client.js', - server: 'src/hooks.server.js', - universal: 'src/hooks.js' +describe('_routes.json', () => { + test('generates file contents', () => { + const routes = get_routes_json( + { + getAppPath: () => 'base-path/_app', + config: { + kit: { + appDir: '_app', + paths: { + base: '/base-path', + assets: '', + relative: true + }, + alias: {}, + csrf: { + checkOrigin: true, + trustedOrigins: [] }, - lib: 'src/lib', - params: 'src/params', - routes: 'src/routes', - serviceWorker: 'src/service-worker.js', - appTemplate: 'src/app.html', - errorTemplate: 'src/error.html' - }, - inlineStyleThreshold: 0, - moduleExtensions: ['.js', '.ts'], - csp: { - mode: 'auto', - // @ts-ignore - directives: {}, - // @ts-ignore - reportOnly: {} - }, - env: { - dir: '.' - }, - outDir: '.svelte-kit' + embedded: false, + files: { + src: 'src', + assets: 'static', + hooks: { + client: 'src/hooks.client.js', + server: 'src/hooks.server.js', + universal: 'src/hooks.js' + }, + lib: 'src/lib', + params: 'src/params', + routes: 'src/routes', + serviceWorker: 'src/service-worker.js', + appTemplate: 'src/app.html', + errorTemplate: 'src/error.html' + }, + inlineStyleThreshold: 0, + moduleExtensions: ['.js', '.ts'], + csp: { + mode: 'auto', + // @ts-ignore + directives: {}, + // @ts-ignore + reportOnly: {} + }, + env: { + dir: '.' + }, + outDir: '.svelte-kit' + } + }, + prerendered: { + paths: ['/base-path/prerendered'], + pages: new Map(), + assets: new Map(), + redirects: new Map() } }, - prerendered: { - paths: ['/base-path/prerendered'], - pages: new Map(), - assets: new Map(), - redirects: new Map() - } - }, - ['_app/immutable/this-should-not-be-excluded.js', 'robots.txt'], - ['/base-path/redirect'], - undefined - ); + ['_app/immutable/this-should-not-be-excluded.js', 'robots.txt'], + ['/base-path/redirect'], + undefined + ); - expect(routes).toEqual({ - version: 1, - description: 'Generated by @sveltejs/adapter-cloudflare', - include: ['/*'], - exclude: [ - '/base-path/_app/version.json', - '/base-path/_app/immutable/*', - '/base-path/robots.txt', - '/base-path/prerendered', - '/base-path/redirect' - ] + expect(routes).toEqual({ + version: 1, + description: 'Generated by @sveltejs/adapter-cloudflare', + include: ['/*'], + exclude: [ + '/base-path/_app/version.json', + '/base-path/_app/immutable/*', + '/base-path/robots.txt', + '/base-path/prerendered', + '/base-path/redirect' + ] + }); }); -}); -test('truncates excess _routes.json exclude rules', () => { - const routes = get_routes_json( - { - // @ts-ignore - log: { - warn: console.warn - }, - getAppPath: () => 'base-path/_app', - config: { - kit: { - appDir: '_app', - paths: { - base: '/base-path', - assets: '', - relative: true - }, - alias: {}, - csrf: { - checkOrigin: true, - trustedOrigins: [] - }, - embedded: false, - files: { - src: 'src', - assets: 'static', - hooks: { - client: 'src/hooks.client.js', - server: 'src/hooks.server.js', - universal: 'src/hooks.js' + test('truncates excess exclude rules', () => { + const routes = get_routes_json( + { + // @ts-ignore + log: { + warn: console.warn + }, + getAppPath: () => 'base-path/_app', + config: { + kit: { + appDir: '_app', + paths: { + base: '/base-path', + assets: '', + relative: true + }, + alias: {}, + csrf: { + checkOrigin: true, + trustedOrigins: [] + }, + embedded: false, + files: { + src: 'src', + assets: 'static', + hooks: { + client: 'src/hooks.client.js', + server: 'src/hooks.server.js', + universal: 'src/hooks.js' + }, + lib: 'src/lib', + params: 'src/params', + routes: 'src/routes', + serviceWorker: 'src/service-worker.js', + appTemplate: 'src/app.html', + errorTemplate: 'src/error.html' }, - lib: 'src/lib', - params: 'src/params', - routes: 'src/routes', - serviceWorker: 'src/service-worker.js', - appTemplate: 'src/app.html', - errorTemplate: 'src/error.html' - }, - inlineStyleThreshold: 0, - moduleExtensions: ['.js', '.ts'], - csp: { - mode: 'auto', - // @ts-ignore - directives: {}, - // @ts-ignore - reportOnly: {} - }, - env: { - dir: '.' - }, - outDir: '.svelte-kit' + inlineStyleThreshold: 0, + moduleExtensions: ['.js', '.ts'], + csp: { + mode: 'auto', + // @ts-ignore + directives: {}, + // @ts-ignore + reportOnly: {} + }, + env: { + dir: '.' + }, + outDir: '.svelte-kit' + } + }, + prerendered: { + paths: Array.from({ length: 100 }, (_, i) => `/base-path/blog/post/${i + 1}`), + pages: new Map(), + assets: new Map(), + redirects: new Map() } }, - prerendered: { - paths: Array.from({ length: 100 }, (_, i) => `/base-path/blog/post/${i + 1}`), - pages: new Map(), - assets: new Map(), - redirects: new Map() - } - }, - ['_app/immutable/this-should-not-be-excluded.js', 'robots.txt'], - [], - undefined - ); + ['_app/immutable/this-should-not-be-excluded.js', 'robots.txt'], + [], + undefined + ); - expect(routes).toEqual({ - version: 1, - description: 'Generated by @sveltejs/adapter-cloudflare', - include: ['/*'], - exclude: [ - '/base-path/_app/version.json', - '/base-path/_app/immutable/*', - '/base-path/robots.txt' - ].concat(Array.from({ length: 96 }, (_, i) => `/base-path/blog/post/${i + 1}`)) + expect(routes).toEqual({ + version: 1, + description: 'Generated by @sveltejs/adapter-cloudflare', + include: ['/*'], + exclude: [ + '/base-path/_app/version.json', + '/base-path/_app/immutable/*', + '/base-path/robots.txt' + ].concat(Array.from({ length: 96 }, (_, i) => `/base-path/blog/post/${i + 1}`)) + }); }); });