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

Use Cloudflare Pages to serve static assets and support _headers, _redirects and _routes.json #5347

Merged
merged 10 commits into from
Nov 21, 2022
6 changes: 6 additions & 0 deletions .changeset/quick-items-jog.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@astrojs/cloudflare': minor
---

Now building for Cloudflare directory mode takes advantage of the standard asset handling from Cloudflare Pages, and therefore does not call a function script to deliver static assets anymore.
Also supports the use of `_routes.json`, `_redirects` and `_headers` files when placed into the `public` folder.
10 changes: 9 additions & 1 deletion packages/integrations/cloudflare/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ In order for preview to work you must install `wrangler`
$ pnpm install wrangler --save-dev
```

It's then possible to update the preview script in your `package.json` to `"preview": "wrangler pages dev ./dist"`.This will allow you run your entire application locally with [Wrangler](https://github.com/cloudflare/wrangler2), which supports secrets, environment variables, KV namespaces, Durable Objects and [all other supported Cloudflare bindings](https://developers.cloudflare.com/pages/platform/functions/#adding-bindings).
It's then possible to update the preview script in your `package.json` to `"preview": "wrangler pages dev ./dist"`. This will allow you run your entire application locally with [Wrangler](https://github.com/cloudflare/wrangler2), which supports secrets, environment variables, KV namespaces, Durable Objects and [all other supported Cloudflare bindings](https://developers.cloudflare.com/pages/platform/functions/#adding-bindings).

## Access to the Cloudflare runtime

Expand Down Expand Up @@ -107,6 +107,14 @@ export function get({ params }) {
}
```

## Headers, Redirects and function invocation routes

Cloudflare has support for adding custom [headers](https://developers.cloudflare.com/pages/platform/headers/), configuring static [redirects](https://developers.cloudflare.com/pages/platform/redirects/) and defining which routes should [invoke functions](https://developers.cloudflare.com/pages/platform/functions/routing/#function-invocation-routes). Cloudflare looks for `_headers`, `_redirects`, and `_routes.json` files in your build output directory to configure these features. This means they should be placed in your Astro project’s `public/` directory.

### 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.

AirBorne04 marked this conversation as resolved.
Show resolved Hide resolved
## Troubleshooting

For help, check out the `#support` channel on [Discord](https://astro.build/chat). Our friendly Support Squad members are here to help!
Expand Down
3 changes: 2 additions & 1 deletion packages/integrations/cloudflare/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,8 @@
"test": "mocha --exit --timeout 30000 test/"
},
"dependencies": {
"esbuild": "^0.14.42"
"esbuild": "^0.14.42",
"tiny-glob": "^0.2.9"
},
"peerDependencies": {
"astro": "^1.6.10"
Expand Down
112 changes: 102 additions & 10 deletions packages/integrations/cloudflare/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import type { AstroAdapter, AstroConfig, AstroIntegration } from 'astro';
import esbuild from 'esbuild';
import * as fs from 'fs';
import * as os from 'os';
import glob from 'tiny-glob';
import { fileURLToPath } from 'url';

type Options = {
Expand Down Expand Up @@ -32,6 +34,8 @@ const SHIM = `globalThis.process = {
env: {},
};`;

const SERVER_BUILD_FOLDER = '/$server_build/';

export default function createIntegration(args?: Options): AstroIntegration {
let _config: AstroConfig;
let _buildConfig: BuildConfig;
Expand All @@ -45,8 +49,8 @@ export default function createIntegration(args?: Options): AstroIntegration {
needsBuildConfig = !config.build.client;
updateConfig({
build: {
client: new URL('./static/', config.outDir),
server: new URL('./', config.outDir),
client: new URL(`.${config.base}`, config.outDir),
server: new URL(`.${SERVER_BUILD_FOLDER}`, config.outDir),
serverEntry: '_worker.js',
},
});
Expand All @@ -62,6 +66,11 @@ export default function createIntegration(args?: Options): AstroIntegration {

`);
}

if (config.base === SERVER_BUILD_FOLDER) {
throw new Error(`
[@astrojs/cloudflare] \`base: "${SERVER_BUILD_FOLDER}"\` is not allowed. Please change your \`base\` config to something else.`);
}
},
'astro:build:setup': ({ vite, target }) => {
if (target === 'server') {
Expand All @@ -84,19 +93,20 @@ export default function createIntegration(args?: Options): AstroIntegration {
'astro:build:start': ({ buildConfig }) => {
// Backwards compat
if (needsBuildConfig) {
buildConfig.client = new URL('./static/', _config.outDir);
buildConfig.server = new URL('./', _config.outDir);
buildConfig.client = new URL(`.${_config.base}`, _config.outDir);
buildConfig.server = new URL(`.${SERVER_BUILD_FOLDER}`, _config.outDir);
buildConfig.serverEntry = '_worker.js';
}
},
'astro:build:done': async () => {
const entryUrl = new URL(_buildConfig.serverEntry, _buildConfig.server);
const pkg = fileURLToPath(entryUrl);
const entryPath = fileURLToPath(new URL(_buildConfig.serverEntry, _buildConfig.server)),
entryUrl = new URL(_buildConfig.serverEntry, _config.outDir),
buildPath = fileURLToPath(entryUrl);
await esbuild.build({
target: 'es2020',
platform: 'browser',
entryPoints: [pkg],
outfile: pkg,
entryPoints: [entryPath],
outfile: buildPath,
allowOverwrite: true,
format: 'esm',
bundle: true,
Expand All @@ -107,8 +117,90 @@ export default function createIntegration(args?: Options): AstroIntegration {
});

// throw the server folder in the bin
const chunksUrl = new URL('./chunks', _buildConfig.server);
await fs.promises.rm(chunksUrl, { recursive: true, force: true });
const serverUrl = new URL(_buildConfig.server);
await fs.promises.rm(serverUrl, { recursive: true, force: true });

// move cloudflare specific files to the root
const cloudflareSpecialFiles = ['_headers', '_redirects', '_routes.json'];
if (_config.base !== '/') {
for (const file of cloudflareSpecialFiles) {
try {
await fs.promises.rename(
new URL(file, _buildConfig.client),
new URL(file, _config.outDir)
);
} catch (e) {
// ignore
}
}
}

const routesExists = await fs.promises
.stat(new URL('./_routes.json', _config.outDir))
.then((stat) => stat.isFile())
.catch(() => false);

// this creates a _routes.json, in case there is none present to enable
// cloudflare to handle static files and support _redirects configuration
// (without calling the function)
if (!routesExists) {
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}`);

const redirectsExists = await fs.promises
.stat(new URL('./_redirects', _config.outDir))
.then((stat) => stat.isFile())
.catch(() => false);

// convert all redirect source paths into a list of routes
// and add them to the static path
if (redirectsExists) {
const redirects = (
await fs.promises.readFile(new URL('./_redirects', _config.outDir), 'utf-8')
)
.split(os.EOL)
.map((line) => {
const parts = line.split(' ');
if (parts.length < 2) {
return null;
} else {
// convert /products/:id to /products/*
return (
parts[0]
.replace(/\/:.*?(?=\/|$)/g, '/*')
// remove query params as they are not supported by cloudflare
.replace(/\?.*$/, '')
);
}
})
.filter(
(line, index, arr) => line !== null && arr.indexOf(line) === index
) as string[];

if (redirects.length > 0) {
staticPathList.push(...redirects);
}
AirBorne04 marked this conversation as resolved.
Show resolved Hide resolved
}

await fs.promises.writeFile(
new URL('./_routes.json', _config.outDir),
JSON.stringify(
{
version: 1,
include: ['/*'],
exclude: staticPathList,
},
null,
2
)
);
}

if (isModeDirectory) {
const functionsUrl = new URL(`file://${process.cwd()}/functions/`);
Expand Down
7 changes: 3 additions & 4 deletions packages/integrations/cloudflare/src/server.advanced.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,11 @@ export function createExports(manifest: SSRManifest) {
const fetch = async (request: Request, env: Env, context: any) => {
process.env = env as any;

const { origin, pathname } = new URL(request.url);
const { pathname } = new URL(request.url);

// static assets
// static assets fallback, in case default _routes.json is not used
if (manifest.assets.has(pathname)) {
const assetRequest = new Request(`${origin}/static/${app.removeBase(pathname)}`, request);
return env.ASSETS.fetch(assetRequest);
return env.ASSETS.fetch(request);
}

let routeData = app.match(request, { matchNotFound: true });
Expand Down
7 changes: 3 additions & 4 deletions packages/integrations/cloudflare/src/server.directory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,10 @@ export function createExports(manifest: SSRManifest) {
} & Record<string, unknown>) => {
process.env = runtimeEnv.env as any;

const { origin, pathname } = new URL(request.url);
// static assets
const { pathname } = new URL(request.url);
// static assets fallback, in case default _routes.json is not used
if (manifest.assets.has(pathname)) {
const assetRequest = new Request(`${origin}/static/${app.removeBase(pathname)}`, request);
return next(assetRequest);
return next(request);
}

let routeData = app.match(request, { matchNotFound: true });
Expand Down
2 changes: 2 additions & 0 deletions pnpm-lock.yaml

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