Skip to content

Commit

Permalink
more efficient _routes.json for cloudflare integration (#7846)
Browse files Browse the repository at this point in the history
* more efficient _routes.json for cloudflare integration

* added tests

* updated pnpm-lock.yaml

* added changeset

* cleaned up test

* fix: convert window path separators

* updated docs

* handle more cases

* Apply suggestions from code review

Co-authored-by: Emanuele Stoppa <my.burning@gmail.com>

* incorporate feedback from code review

* used other pnpm version

* better fallback for empty include array

* adjust test case to changed fallback for empty include array

* updated docs

---------

Co-authored-by: Emanuele Stoppa <my.burning@gmail.com>
  • Loading branch information
schummar and ematipico authored Aug 10, 2023
1 parent 9cb32e2 commit ea30a9d
Show file tree
Hide file tree
Showing 17 changed files with 242 additions and 16 deletions.
5 changes: 5 additions & 0 deletions .changeset/breezy-frogs-learn.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@astrojs/cloudflare': minor
---

More efficient \_routes.json
5 changes: 4 additions & 1 deletion packages/integrations/cloudflare/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
90 changes: 87 additions & 3 deletions packages/integrations/cloudflare/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -233,14 +238,40 @@ 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<string> = (
await glob(`${fileURLToPath(_buildConfig.client)}/**/*`, {
cwd: fileURLToPath(_config.outDir),
filesOnly: true,
})
)
.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);
Expand Down Expand Up @@ -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
Expand All @@ -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;
});
}
Original file line number Diff line number Diff line change
@@ -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
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"name": "@test/astro-cloudflare-routes-json",
"version": "0.0.0",
"private": true,
"dependencies": {
"@astrojs/cloudflare": "workspace:*",
"astro": "workspace:*"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
export const prerender=false;
---

ok
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
export const prerender=false;
---

ok
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
export const prerender=false;
---

ok
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
export const prerender=false;
---

ok
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const prerender = false;
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
ok
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
ok
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
ok
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
ok
26 changes: 14 additions & 12 deletions packages/integrations/cloudflare/test/prerender.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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: [],
});
});
});

Expand All @@ -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: [],
});
});
});
78 changes: 78 additions & 0 deletions packages/integrations/cloudflare/test/routesJson.js
Original file line number Diff line number Diff line change
@@ -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: ['/'],
});
});
});
});
9 changes: 9 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

0 comments on commit ea30a9d

Please sign in to comment.