diff --git a/.changeset/breezy-frogs-learn.md b/.changeset/breezy-frogs-learn.md new file mode 100644 index 000000000..b3f2f86b9 --- /dev/null +++ b/.changeset/breezy-frogs-learn.md @@ -0,0 +1,5 @@ +--- +'@astrojs/cloudflare': minor +--- + +More efficient \_routes.json diff --git a/packages/integrations/cloudflare/README.md b/packages/integrations/cloudflare/README.md index 7f4292d97..45f8e01ba 100644 --- a/packages/integrations/cloudflare/README.md +++ b/packages/integrations/cloudflare/README.md @@ -106,7 +106,10 @@ Cloudflare has support for adding custom [headers](https://developers.cloudflare ### Custom `_routes.json` -By default, `@astrojs/cloudflare` will generate a `_routes.json` file that lists all files from your `dist/` folder and redirects from the `_redirects` file in the `exclude` array. This will enable Cloudflare to serve files and process static redirects without a function invocation. Creating a custom `_routes.json` will override this automatic optimization and, if not configured manually, cause function invocations that will count against the request limits of your Cloudflare plan. +By default, `@astrojs/cloudflare` will generate a `_routes.json` file with `include` and `exclude` rules based on your applications's dynamic and static routes. +This will enable Cloudflare to serve files and process static redirects without a function invocation. Creating a custom `_routes.json` will override this automatic optimization and, if not configured manually, cause function invocations that will count against the request limits of your Cloudflare plan. + +See [Cloudflare's documentation](https://developers.cloudflare.com/pages/platform/functions/routing/#create-a-_routesjson-file) for more details. ## Troubleshooting diff --git a/packages/integrations/cloudflare/src/index.ts b/packages/integrations/cloudflare/src/index.ts index ef452aa95..336f747cc 100644 --- a/packages/integrations/cloudflare/src/index.ts +++ b/packages/integrations/cloudflare/src/index.ts @@ -39,6 +39,11 @@ const SHIM = `globalThis.process = { const SERVER_BUILD_FOLDER = '/$server_build/'; +/** + * These route types are candiates for being part of the `_routes.json` `include` array. + */ +const potentialFunctionRouteTypes = ['endpoint', 'page']; + export default function createIntegration(args?: Options): AstroIntegration { let _config: AstroConfig; let _buildConfig: BuildConfig; @@ -233,6 +238,32 @@ export default function createIntegration(args?: Options): AstroIntegration { // cloudflare to handle static files and support _redirects configuration // (without calling the function) if (!routesExists) { + const functionEndpoints = routes + // Certain route types, when their prerender option is set to false, a run on the server as function invocations + .filter((route) => potentialFunctionRouteTypes.includes(route.type) && !route.prerender) + .map((route) => { + const includePattern = + '/' + + route.segments + .flat() + .map((segment) => (segment.dynamic ? '*' : segment.content)) + .join('/'); + + const regexp = new RegExp( + '^\\/' + + route.segments + .flat() + .map((segment) => (segment.dynamic ? '(.*)' : segment.content)) + .join('\\/') + + '$' + ); + + return { + includePattern, + regexp, + }; + }); + const staticPathList: Array = ( await glob(`${fileURLToPath(_buildConfig.client)}/**/*`, { cwd: fileURLToPath(_config.outDir), @@ -240,7 +271,7 @@ export default function createIntegration(args?: Options): AstroIntegration { }) ) .filter((file: string) => cloudflareSpecialFiles.indexOf(file) < 0) - .map((file: string) => `/${file}`); + .map((file: string) => `/${file.replace(/\\/g, '/')}`); for (let page of pages) { let pagePath = prependForwardSlash(page.pathname); @@ -303,13 +334,41 @@ export default function createIntegration(args?: Options): AstroIntegration { ); } + staticPathList.push(...routes.filter((r) => r.type === 'redirect').map((r) => r.route)); + + // In order to product the shortest list of patterns, we first try to + // include all function endpoints, and then exclude all static paths + let include = deduplicatePatterns( + functionEndpoints.map((endpoint) => endpoint.includePattern) + ); + let exclude = deduplicatePatterns( + staticPathList.filter((file: string) => + functionEndpoints.some((endpoint) => endpoint.regexp.test(file)) + ) + ); + + // Cloudflare requires at least one include pattern: + // https://developers.cloudflare.com/pages/platform/functions/routing/#limits + // So we add a pattern that we immediately exclude again + if (include.length === 0) { + include = ['/']; + exclude = ['/']; + } + + // If using only an exclude list would produce a shorter list of patterns, + // we use that instead + if (include.length + exclude.length > staticPathList.length) { + include = ['/*']; + exclude = deduplicatePatterns(staticPathList); + } + await fs.promises.writeFile( new URL('./_routes.json', _config.outDir), JSON.stringify( { version: 1, - include: ['/*'], - exclude: staticPathList, + include, + exclude, }, null, 2 @@ -324,3 +383,28 @@ export default function createIntegration(args?: Options): AstroIntegration { function prependForwardSlash(path: string) { return path[0] === '/' ? path : '/' + path; } + +/** + * Remove duplicates and redundant patterns from an `include` or `exclude` list. + * Otherwise Cloudflare will throw an error on deployment. Plus, it saves more entries. + * E.g. `['/foo/*', '/foo/*', '/foo/bar'] => ['/foo/*']` + * @param patterns a list of `include` or `exclude` patterns + * @returns a deduplicated list of patterns + */ +function deduplicatePatterns(patterns: string[]) { + const openPatterns: RegExp[] = []; + + return [...new Set(patterns)] + .sort((a, b) => a.length - b.length) + .filter((pattern) => { + if (openPatterns.some((p) => p.test(pattern))) { + return false; + } + + if (pattern.endsWith('*')) { + openPatterns.push(new RegExp(`^${pattern.replace(/(\*\/)*\*$/g, '.*')}`)); + } + + return true; + }); +} diff --git a/packages/integrations/cloudflare/test/fixtures/routesJson/astro.config.mjs b/packages/integrations/cloudflare/test/fixtures/routesJson/astro.config.mjs new file mode 100644 index 000000000..66b50c098 --- /dev/null +++ b/packages/integrations/cloudflare/test/fixtures/routesJson/astro.config.mjs @@ -0,0 +1,11 @@ +import { defineConfig } from 'astro/config'; +import cloudflare from '@astrojs/cloudflare'; + +export default defineConfig({ + adapter: cloudflare({ mode: 'directory' }), + output: 'hybrid', + redirects: { + '/a/redirect': '/', + }, + srcDir: process.env.SRC +}); diff --git a/packages/integrations/cloudflare/test/fixtures/routesJson/package.json b/packages/integrations/cloudflare/test/fixtures/routesJson/package.json new file mode 100644 index 000000000..4ff746f02 --- /dev/null +++ b/packages/integrations/cloudflare/test/fixtures/routesJson/package.json @@ -0,0 +1,9 @@ +{ + "name": "@test/astro-cloudflare-routes-json", + "version": "0.0.0", + "private": true, + "dependencies": { + "@astrojs/cloudflare": "workspace:*", + "astro": "workspace:*" + } +} diff --git a/packages/integrations/cloudflare/test/fixtures/routesJson/src/dynamicOnly/pages/another.astro b/packages/integrations/cloudflare/test/fixtures/routesJson/src/dynamicOnly/pages/another.astro new file mode 100644 index 000000000..9a2306b86 --- /dev/null +++ b/packages/integrations/cloudflare/test/fixtures/routesJson/src/dynamicOnly/pages/another.astro @@ -0,0 +1,5 @@ +--- +export const prerender=false; +--- + +ok diff --git a/packages/integrations/cloudflare/test/fixtures/routesJson/src/dynamicOnly/pages/index.astro b/packages/integrations/cloudflare/test/fixtures/routesJson/src/dynamicOnly/pages/index.astro new file mode 100644 index 000000000..9a2306b86 --- /dev/null +++ b/packages/integrations/cloudflare/test/fixtures/routesJson/src/dynamicOnly/pages/index.astro @@ -0,0 +1,5 @@ +--- +export const prerender=false; +--- + +ok diff --git a/packages/integrations/cloudflare/test/fixtures/routesJson/src/mixed/pages/a/[...rest].astro b/packages/integrations/cloudflare/test/fixtures/routesJson/src/mixed/pages/a/[...rest].astro new file mode 100644 index 000000000..9a2306b86 --- /dev/null +++ b/packages/integrations/cloudflare/test/fixtures/routesJson/src/mixed/pages/a/[...rest].astro @@ -0,0 +1,5 @@ +--- +export const prerender=false; +--- + +ok diff --git a/packages/integrations/cloudflare/test/fixtures/routesJson/src/mixed/pages/a/[id].astro b/packages/integrations/cloudflare/test/fixtures/routesJson/src/mixed/pages/a/[id].astro new file mode 100644 index 000000000..9a2306b86 --- /dev/null +++ b/packages/integrations/cloudflare/test/fixtures/routesJson/src/mixed/pages/a/[id].astro @@ -0,0 +1,5 @@ +--- +export const prerender=false; +--- + +ok diff --git a/packages/integrations/cloudflare/test/fixtures/routesJson/src/mixed/pages/a/endpoint.ts b/packages/integrations/cloudflare/test/fixtures/routesJson/src/mixed/pages/a/endpoint.ts new file mode 100644 index 000000000..d43d0cd2a --- /dev/null +++ b/packages/integrations/cloudflare/test/fixtures/routesJson/src/mixed/pages/a/endpoint.ts @@ -0,0 +1 @@ +export const prerender = false; diff --git a/packages/integrations/cloudflare/test/fixtures/routesJson/src/mixed/pages/a/index.astro b/packages/integrations/cloudflare/test/fixtures/routesJson/src/mixed/pages/a/index.astro new file mode 100644 index 000000000..9766475a4 --- /dev/null +++ b/packages/integrations/cloudflare/test/fixtures/routesJson/src/mixed/pages/a/index.astro @@ -0,0 +1 @@ +ok diff --git a/packages/integrations/cloudflare/test/fixtures/routesJson/src/mixed/pages/b/index.html b/packages/integrations/cloudflare/test/fixtures/routesJson/src/mixed/pages/b/index.html new file mode 100644 index 000000000..9766475a4 --- /dev/null +++ b/packages/integrations/cloudflare/test/fixtures/routesJson/src/mixed/pages/b/index.html @@ -0,0 +1 @@ +ok diff --git a/packages/integrations/cloudflare/test/fixtures/routesJson/src/mixed/public/public.txt b/packages/integrations/cloudflare/test/fixtures/routesJson/src/mixed/public/public.txt new file mode 100644 index 000000000..9766475a4 --- /dev/null +++ b/packages/integrations/cloudflare/test/fixtures/routesJson/src/mixed/public/public.txt @@ -0,0 +1 @@ +ok diff --git a/packages/integrations/cloudflare/test/fixtures/routesJson/src/staticOnly/pages/index.astro b/packages/integrations/cloudflare/test/fixtures/routesJson/src/staticOnly/pages/index.astro new file mode 100644 index 000000000..9766475a4 --- /dev/null +++ b/packages/integrations/cloudflare/test/fixtures/routesJson/src/staticOnly/pages/index.astro @@ -0,0 +1 @@ +ok diff --git a/packages/integrations/cloudflare/test/prerender.test.js b/packages/integrations/cloudflare/test/prerender.test.js index 847bd950a..fe0721f27 100644 --- a/packages/integrations/cloudflare/test/prerender.test.js +++ b/packages/integrations/cloudflare/test/prerender.test.js @@ -18,13 +18,14 @@ describe('Prerendering', () => { fixture.clean(); }); - it('includes prerendered routes in the routes.json config', async () => { - const foundRoutes = JSON.parse(await fixture.readFile('/_routes.json')).exclude.map((r) => - r.replace(/\\/g, '/') - ); - const expectedExcludedRoutes = ['/_worker.js', '/one/index.html', '/one/']; + it('includes non prerendered routes in the routes.json config', async () => { + const foundRoutes = JSON.parse(await fixture.readFile('/_routes.json')); - expect(foundRoutes.every((element) => expectedExcludedRoutes.includes(element))).to.be.true; + expect(foundRoutes).to.deep.equal({ + version: 1, + include: ['/'], + exclude: [], + }); }); }); @@ -45,12 +46,13 @@ describe('Hybrid rendering', () => { delete process.env.PRERENDER; }); - it('includes prerendered routes in the routes.json config', async () => { - const foundRoutes = JSON.parse(await fixture.readFile('/_routes.json')).exclude.map((r) => - r.replace(/\\/g, '/') - ); - const expectedExcludedRoutes = ['/_worker.js', '/index.html', '/']; + it('includes non prerendered routes in the routes.json config', async () => { + const foundRoutes = JSON.parse(await fixture.readFile('/_routes.json')); - expect(foundRoutes.every((element) => expectedExcludedRoutes.includes(element))).to.be.true; + expect(foundRoutes).to.deep.equal({ + version: 1, + include: ['/one'], + exclude: [], + }); }); }); diff --git a/packages/integrations/cloudflare/test/routesJson.js b/packages/integrations/cloudflare/test/routesJson.js new file mode 100644 index 000000000..927e4c38e --- /dev/null +++ b/packages/integrations/cloudflare/test/routesJson.js @@ -0,0 +1,78 @@ +import { expect } from 'chai'; +import { loadFixture } from './test-utils.js'; + +/** @type {import('./test-utils.js').Fixture} */ +describe('_routes.json generation', () => { + after(() => { + delete process.env.SRC; + }); + + describe('of both functions and static files', () => { + let fixture; + + before(async () => { + process.env.SRC = './src/mixed'; + fixture = await loadFixture({ + root: './fixtures/routesJson/', + }); + await fixture.build(); + }); + + it('creates `include` for functions and `exclude` for static files where needed', async () => { + const _routesJson = await fixture.readFile('/_routes.json'); + const routes = JSON.parse(_routesJson); + + expect(routes).to.deep.equal({ + version: 1, + include: ['/a/*'], + exclude: ['/a/', '/a/redirect', '/a/index.html'], + }); + }); + }); + + describe('of only functions', () => { + let fixture; + + before(async () => { + process.env.SRC = './src/dynamicOnly'; + fixture = await loadFixture({ + root: './fixtures/routesJson/', + }); + await fixture.build(); + }); + + it('creates a wildcard `include` and `exclude` only for the redirect', async () => { + const _routesJson = await fixture.readFile('/_routes.json'); + const routes = JSON.parse(_routesJson); + + expect(routes).to.deep.equal({ + version: 1, + include: ['/*'], + exclude: ['/a/redirect'], + }); + }); + }); + + describe('of only static files', () => { + let fixture; + + before(async () => { + process.env.SRC = './src/staticOnly'; + fixture = await loadFixture({ + root: './fixtures/routesJson/', + }); + await fixture.build(); + }); + + it('create only one `include` and `exclude` that are supposed to match nothing', async () => { + const _routesJson = await fixture.readFile('/_routes.json'); + const routes = JSON.parse(_routesJson); + + expect(routes).to.deep.equal({ + version: 1, + include: ['/'], + exclude: ['/'], + }); + }); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b0da65551..3e47be814 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3706,6 +3706,15 @@ importers: specifier: workspace:* version: link:../../../../../astro + packages/integrations/cloudflare/test/fixtures/routesJson: + dependencies: + '@astrojs/cloudflare': + specifier: workspace:* + version: link:../../.. + astro: + specifier: workspace:* + version: link:../../../../../astro + packages/integrations/cloudflare/test/fixtures/split: dependencies: '@astrojs/cloudflare':