Skip to content
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

fix(cloudflare): added config for _routes.json generation #8459

Merged
Merged
5 changes: 5 additions & 0 deletions .changeset/famous-seas-obey.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@astrojs/cloudflare': minor
---

added config for `_routes.json` generation
schummar marked this conversation as resolved.
Show resolved Hide resolved
30 changes: 30 additions & 0 deletions packages/integrations/cloudflare/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,36 @@ export default defineConfig({

Note that this adapter does not support using [Cloudflare Pages Middleware](https://developers.cloudflare.com/pages/platform/functions/middleware/). Astro will bundle the [Astro middleware](https://docs.astro.build/en/guides/middleware/) into each page.

### routes.strategy

`routes.strategy: "auto" | "include" | "exclude"`

default `"auto"`

If no [custom `_routes.json`](#custom-_routesjson) is provided, `@astrojs/cloudflare` will generate one for you. There are two ways to generate the `_routes.json`:

1. For each page or endpoint in your application that is not prerendered, an entry in the `include` array will be generated. For each page that is prerendered and whoose path is matched by an `include` entry, an entry in the `exclude` array will be generated.

2. One `"/*"` entry in the `include` array will be generated. For each page that is prerendered, an entry in the `exclude` array will be generated.

Setting `routes.strategy` to `"include"` will generate a `_routes.json` with the first strategy. Setting it to `"exclude"` will use the second strategy. Setting it to `"auto"` will use the strategy that generates the least amount of entries.
alexanderniebuhr marked this conversation as resolved.
Show resolved Hide resolved

### routes.include

`routes.include: string[]`

default `[]`

If you want to use the automatic `_routes.json` generation, but want to include additional routes (e.g. when having custom functions in the `functions` folder), you can use the `routes.include` option to add additional routes to the `include` array.

### routes.exclude

`routes.exclude: string[]`

default `[]`

If you want to use the automatic `_routes.json` generation, but want to exclude additional routes, you can use the `routes.exclude` option to add additional routes to the `exclude` array.

alexanderniebuhr marked this conversation as resolved.
Show resolved Hide resolved
## Enabling Preview

In order for preview to work you must install `wrangler`
Expand Down
77 changes: 56 additions & 21 deletions packages/integrations/cloudflare/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,19 @@ export type { DirectoryRuntime } from './server.directory';
type Options = {
mode: 'directory' | 'advanced';
functionPerRoute?: boolean;
/** Configure automatic `routes.json` generation */
routes?: {
/** Strategy for generating `include` and `exclude` patterns
* - `auto`: Will use the strategy that generates the least amount of entries.
* - `include`: For each page or endpoint in your application that is not prerendered, an entry in the `include` array will be generated. For each page that is prerendered and whoose path is matched by an `include` entry, an entry in the `exclude` array will be generated.
* - `exclude`: One `"/*"` entry in the `include` array will be generated. For each page that is prerendered, an entry in the `exclude` array will be generated.
* */
strategy?: 'auto' | 'include' | 'exclude';
/** Additional `include` patterns */
include?: string[];
/** Additional `exclude` patterns */
exclude?: string[];
};
};

interface BuildConfig {
Expand Down Expand Up @@ -373,39 +386,61 @@ 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))
)
);
const strategy = args?.routes?.strategy ?? 'auto';

// Strategy `include`: include all function endpoints, and then exclude static paths that would be matched by an include pattern
const includeStrategy =
strategy === 'exclude'
? undefined
: {
include: deduplicatePatterns(
functionEndpoints
.map((endpoint) => endpoint.includePattern)
.concat(args?.routes?.include ?? [])
),
exclude: deduplicatePatterns(
staticPathList
.filter((file: string) =>
functionEndpoints.some((endpoint) => endpoint.regexp.test(file))
)
.concat(args?.routes?.exclude ?? [])
),
};

// 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 (includeStrategy?.include.length === 0) {
includeStrategy.include = ['/'];
includeStrategy.exclude = ['/'];
ematipico marked this conversation as resolved.
Show resolved Hide resolved
}

// 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);
}
// Strategy `exclude`: include everything, and then exclude all static paths
const excludeStrategy =
strategy === 'include'
? undefined
: {
include: ['/*'],
exclude: deduplicatePatterns(staticPathList.concat(args?.routes?.exclude ?? [])),
};

const includeStrategyLength = includeStrategy
? includeStrategy.include.length + includeStrategy.exclude.length
ematipico marked this conversation as resolved.
Show resolved Hide resolved
: Infinity;

const excludeStrategyLength = excludeStrategy
ematipico marked this conversation as resolved.
Show resolved Hide resolved
? excludeStrategy.include.length + excludeStrategy.exclude.length
: Infinity;

const winningStrategy =
includeStrategyLength <= excludeStrategyLength ? includeStrategy : excludeStrategy;

await fs.promises.writeFile(
new URL('./_routes.json', _config.outDir),
JSON.stringify(
{
version: 1,
include,
exclude,
...winningStrategy,
},
null,
2
Expand Down
193 changes: 193 additions & 0 deletions packages/integrations/cloudflare/test/routes-json.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
import { expect } from 'chai';
import { loadFixture } from './test-utils.js';
import cloudflare from '../dist/index.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/*', '/_image'],
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: ['/_image'],
exclude: [],
});
});
});

describe('with strategy `"include"`', () => {
let fixture;

before(async () => {
process.env.SRC = './src/dynamicOnly';
fixture = await loadFixture({
root: './fixtures/routesJson/',
adapter: cloudflare({
mode: 'directory',
routes: { strategy: 'include' },
}),
});
await fixture.build();
});

it('creates `include` entries even though the `"exclude"` strategy would have produced less entries.', async () => {
const _routesJson = await fixture.readFile('/_routes.json');
const routes = JSON.parse(_routesJson);

expect(routes).to.deep.equal({
version: 1,
include: ['/', '/_image', '/another'],
exclude: [],
});
});
});

describe('with strategy `"exclude"`', () => {
let fixture;

before(async () => {
process.env.SRC = './src/staticOnly';
fixture = await loadFixture({
root: './fixtures/routesJson/',
adapter: cloudflare({
mode: 'directory',
routes: { strategy: 'exclude' },
}),
});
await fixture.build();
});

it('creates `exclude` entries even though the `"include"` strategy would have produced less entries.', async () => {
const _routesJson = await fixture.readFile('/_routes.json');
const routes = JSON.parse(_routesJson);

expect(routes).to.deep.equal({
version: 1,
include: ['/*'],
exclude: ['/', '/index.html', '/a/redirect'],
});
});
});

describe('with additional `include` entries', () => {
let fixture;

before(async () => {
process.env.SRC = './src/mixed';
fixture = await loadFixture({
root: './fixtures/routesJson/',
adapter: cloudflare({
mode: 'directory',
routes: {
strategy: 'include',
include: ['/another', '/a/redundant'],
},
}),
});
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/*', '/_image', '/another'],
exclude: ['/a/', '/a/redirect', '/a/index.html'],
});
});
});

describe('with additional `exclude` entries', () => {
let fixture;

before(async () => {
process.env.SRC = './src/mixed';
fixture = await loadFixture({
root: './fixtures/routesJson/',
adapter: cloudflare({
mode: 'directory',
routes: {
strategy: 'include',
exclude: ['/another', '/a/*', '/a/index.html'],
},
}),
});
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/*', '/_image'],
exclude: ['/a/', '/a/*', '/another'],
});
});
});
});
Loading