diff --git a/.changeset/quick-items-jog.md b/.changeset/quick-items-jog.md new file mode 100644 index 000000000000..fe35edd9bbc8 --- /dev/null +++ b/.changeset/quick-items-jog.md @@ -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. \ No newline at end of file diff --git a/packages/integrations/cloudflare/README.md b/packages/integrations/cloudflare/README.md index 0a8012b1cfbb..3acec5512ada 100644 --- a/packages/integrations/cloudflare/README.md +++ b/packages/integrations/cloudflare/README.md @@ -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 @@ -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. + ## Troubleshooting For help, check out the `#support` channel on [Discord](https://astro.build/chat). Our friendly Support Squad members are here to help! diff --git a/packages/integrations/cloudflare/package.json b/packages/integrations/cloudflare/package.json index 82110c7ef5c0..81db5b1ce08d 100644 --- a/packages/integrations/cloudflare/package.json +++ b/packages/integrations/cloudflare/package.json @@ -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" diff --git a/packages/integrations/cloudflare/src/index.ts b/packages/integrations/cloudflare/src/index.ts index 44112e8be15c..805eccb9c69e 100644 --- a/packages/integrations/cloudflare/src/index.ts +++ b/packages/integrations/cloudflare/src/index.ts @@ -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 = { @@ -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; @@ -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', }, }); @@ -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') { @@ -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, @@ -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 = ( + 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); + } + } + + 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/`); diff --git a/packages/integrations/cloudflare/src/server.advanced.ts b/packages/integrations/cloudflare/src/server.advanced.ts index cb83dd9942bb..0d765d0bba18 100644 --- a/packages/integrations/cloudflare/src/server.advanced.ts +++ b/packages/integrations/cloudflare/src/server.advanced.ts @@ -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 }); diff --git a/packages/integrations/cloudflare/src/server.directory.ts b/packages/integrations/cloudflare/src/server.directory.ts index 321f37e18303..69d008b0faa0 100644 --- a/packages/integrations/cloudflare/src/server.directory.ts +++ b/packages/integrations/cloudflare/src/server.directory.ts @@ -17,11 +17,10 @@ export function createExports(manifest: SSRManifest) { } & Record) => { 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 }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7bf60c302f9f..c6fcfa56c5cd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2554,9 +2554,11 @@ importers: cheerio: ^1.0.0-rc.11 esbuild: ^0.14.42 mocha: ^9.2.2 + tiny-glob: ^0.2.9 wrangler: ^2.0.23 dependencies: esbuild: 0.14.54 + tiny-glob: 0.2.9 devDependencies: astro: link:../../astro astro-scripts: link:../../../scripts