diff --git a/.changeset/healthy-books-study.md b/.changeset/healthy-books-study.md new file mode 100644 index 0000000000000..e4b51b041d92e --- /dev/null +++ b/.changeset/healthy-books-study.md @@ -0,0 +1,7 @@ +--- +'@astrojs/cloudflare': minor +--- + +Split Support in Cloudflare + +Adds support for configuring `build.split` when using the Cloudflare adapter diff --git a/packages/integrations/cloudflare/README.md b/packages/integrations/cloudflare/README.md index 6f69e3bddf46d..a6638f36dd162 100644 --- a/packages/integrations/cloudflare/README.md +++ b/packages/integrations/cloudflare/README.md @@ -48,7 +48,11 @@ Cloudflare Pages has 2 different modes for deploying functions, `advanced` mode For most projects the adapter default of `advanced` will be sufficient; the `dist` folder will contain your compiled project. Switching to directory mode allows you to use [pages plugins](https://developers.cloudflare.com/pages/platform/functions/plugins/) such as [Sentry](https://developers.cloudflare.com/pages/platform/functions/plugins/sentry/) or write custom code to enable logging. -In directory mode the adapter will compile the client side part of your app the same way, but moves the worker script into a `functions` folder in the project root. The adapter will only ever place a `[[path]].js` in that folder, allowing you to add additional plugins and pages middleware which can be checked into version control. Cloudflare documentation contains more information about [writing custom functions](https://developers.cloudflare.com/pages/platform/functions/). +In directory mode, the adapter will compile the client side part of your app the same way by default, but moves the worker script into a `functions` folder in the project root. In this case, the adapter will only ever place a `[[path]].js` in that folder, allowing you to add additional plugins and pages middleware which can be checked into version control. + +With the build configuration `split: true`, the adapter instead compiles a separate bundle for each page. This option requires some manual maintenance of the `functions` folder. Files emitted by Astro will overwrite existing `functions` files with identical names, so you must choose unique file names for each file you manually add. Additionally, the adapter will never empty the `functions` folder of outdated files, so you must clean up the folder manually when you remove pages. + +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. ```ts // directory mode diff --git a/packages/integrations/cloudflare/package.json b/packages/integrations/cloudflare/package.json index fe64ee166eb69..f4ad834a0ce4c 100644 --- a/packages/integrations/cloudflare/package.json +++ b/packages/integrations/cloudflare/package.json @@ -35,7 +35,8 @@ "build": "astro-scripts build \"src/**/*.ts\" && tsc", "build:ci": "astro-scripts build \"src/**/*.ts\"", "dev": "astro-scripts dev \"src/**/*.ts\"", - "test": "mocha --exit --timeout 30000 test/" + "test": "mocha --exit --timeout 30000 test/", + "test:match": "mocha --exit --timeout 30000 -g" }, "dependencies": { "@astrojs/underscore-redirects": "^0.1.0", diff --git a/packages/integrations/cloudflare/src/index.ts b/packages/integrations/cloudflare/src/index.ts index 252dd778a5304..f4ee26feaa4f3 100644 --- a/packages/integrations/cloudflare/src/index.ts +++ b/packages/integrations/cloudflare/src/index.ts @@ -1,8 +1,9 @@ import { createRedirectsFromAstroRoutes } from '@astrojs/underscore-redirects'; -import type { AstroAdapter, AstroConfig, AstroIntegration } from 'astro'; +import type { AstroAdapter, AstroConfig, AstroIntegration, RouteData } from 'astro'; import esbuild from 'esbuild'; import * as fs from 'fs'; import * as os from 'os'; +import { dirname } from 'path'; import glob from 'tiny-glob'; import { fileURLToPath, pathToFileURL } from 'url'; @@ -14,20 +15,21 @@ interface BuildConfig { server: URL; client: URL; serverEntry: string; + split?: boolean; } export function getAdapter(isModeDirectory: boolean): AstroAdapter { return isModeDirectory ? { - name: '@astrojs/cloudflare', - serverEntrypoint: '@astrojs/cloudflare/server.directory.js', - exports: ['onRequest'], - } + name: '@astrojs/cloudflare', + serverEntrypoint: '@astrojs/cloudflare/server.directory.js', + exports: ['onRequest', 'manifest'], + } : { - name: '@astrojs/cloudflare', - serverEntrypoint: '@astrojs/cloudflare/server.advanced.js', - exports: ['default'], - }; + name: '@astrojs/cloudflare', + serverEntrypoint: '@astrojs/cloudflare/server.advanced.js', + exports: ['default'], + }; } const SHIM = `globalThis.process = { @@ -41,6 +43,7 @@ export default function createIntegration(args?: Options): AstroIntegration { let _config: AstroConfig; let _buildConfig: BuildConfig; const isModeDirectory = args?.mode === 'directory'; + let _entryPoints = new Map(); return { name: '@astrojs/cloudflare', @@ -90,35 +93,99 @@ export default function createIntegration(args?: Options): AstroIntegration { vite.ssr.target = 'webworker'; } }, + 'astro:build:ssr': ({ manifest, entryPoints }) => { + _entryPoints = entryPoints; + }, 'astro:build:done': async ({ pages, routes, dir }) => { - const entryPath = fileURLToPath(new URL(_buildConfig.serverEntry, _buildConfig.server)); - const entryUrl = new URL(_buildConfig.serverEntry, _config.outDir); - const buildPath = fileURLToPath(entryUrl); - // A URL for the final build path after renaming - const finalBuildUrl = pathToFileURL(buildPath.replace(/\.mjs$/, '.js')); - - await esbuild.build({ - target: 'es2020', - platform: 'browser', - conditions: ['workerd', 'worker', 'browser'], - entryPoints: [entryPath], - outfile: buildPath, - allowOverwrite: true, - format: 'esm', - bundle: true, - minify: _config.vite?.build?.minify !== false, - banner: { - js: SHIM, - }, - logOverride: { - 'ignored-bare-import': 'silent', - }, - }); + const functionsUrl = new URL('functions/', _config.root); + + if (isModeDirectory) { + await fs.promises.mkdir(functionsUrl, { recursive: true }); + } + + if (isModeDirectory && _buildConfig.split) { + const entryPointsRouteData = [..._entryPoints.keys()] + const entryPointsURL = [..._entryPoints.values()] + const entryPaths = entryPointsURL.map((entry) => fileURLToPath(entry)); + const outputDir = fileURLToPath(new URL('.astro', _buildConfig.server)); + + // NOTE: AFAIK, esbuild keeps the order of the entryPoints array + const { outputFiles } = await esbuild.build({ + target: 'es2020', + platform: 'browser', + conditions: ['workerd', 'worker', 'browser'], + entryPoints: entryPaths, + outdir: outputDir, + allowOverwrite: true, + format: 'esm', + bundle: true, + minify: _config.vite?.build?.minify !== false, + banner: { + js: SHIM, + }, + logOverride: { + 'ignored-bare-import': 'silent', + }, + write: false, + }); - // Rename to worker.js - await fs.promises.rename(buildPath, finalBuildUrl); + // loop through all bundled files and write them to the functions folder + for (const [index, outputFile] of outputFiles.entries()) { + // we need to make sure the filename in the functions folder + // matches to cloudflares routing capabilities (see their docs) + // IN: src/pages/[language]/files/[...path].astro + // OUT: [language]/files/[[path]].js + const fileName = entryPointsRouteData[index].component + .replace('src/pages/', '') + .replace('.astro', '.js') + .replace(/(\[\.\.\.)(\w+)(\])/g, (_match, _p1, p2, _p3) => { + return `[[${p2}]]`; + }); + + const fileUrl = new URL(fileName, functionsUrl) + const newFileDir = dirname(fileURLToPath(fileUrl)); + if (!fs.existsSync(newFileDir)) { + fs.mkdirSync(newFileDir, { recursive: true }); + } + await fs.promises.writeFile(fileUrl, outputFile.contents); + } - // throw the server folder in the bin + } else { + const entryPath = fileURLToPath(new URL(_buildConfig.serverEntry, _buildConfig.server)); + const entryUrl = new URL(_buildConfig.serverEntry, _config.outDir); + const buildPath = fileURLToPath(entryUrl); + // A URL for the final build path after renaming + const finalBuildUrl = pathToFileURL(buildPath.replace(/\.mjs$/, '.js')); + + await esbuild.build({ + target: 'es2020', + platform: 'browser', + conditions: ['workerd', 'worker', 'browser'], + entryPoints: [entryPath], + outfile: buildPath, + allowOverwrite: true, + format: 'esm', + bundle: true, + minify: _config.vite?.build?.minify !== false, + banner: { + js: SHIM, + }, + logOverride: { + 'ignored-bare-import': 'silent', + }, + }); + + // Rename to worker.js + await fs.promises.rename(buildPath, finalBuildUrl); + + if (isModeDirectory) { + const directoryUrl = new URL('[[path]].js', functionsUrl); + await fs.promises.rename(finalBuildUrl, directoryUrl); + } + + } + + // // // throw the server folder in the bin const serverUrl = new URL(_buildConfig.server); await fs.promises.rm(serverUrl, { recursive: true, force: true }); @@ -225,14 +292,6 @@ export default function createIntegration(args?: Options): AstroIntegration { ) ); } - - if (isModeDirectory) { - const functionsUrl = new URL('functions/', _config.root); - await fs.promises.mkdir(functionsUrl, { recursive: true }); - - const directoryUrl = new URL('[[path]].js', functionsUrl); - await fs.promises.rename(finalBuildUrl, directoryUrl); - } }, }, }; diff --git a/packages/integrations/cloudflare/src/server.directory.ts b/packages/integrations/cloudflare/src/server.directory.ts index da23605573e30..f9f71a33b5b8f 100644 --- a/packages/integrations/cloudflare/src/server.directory.ts +++ b/packages/integrations/cloudflare/src/server.directory.ts @@ -61,5 +61,5 @@ export function createExports(manifest: SSRManifest) { }); }; - return { onRequest }; + return { onRequest, manifest }; } diff --git a/packages/integrations/cloudflare/test/directory-split.test.js b/packages/integrations/cloudflare/test/directory-split.test.js new file mode 100644 index 0000000000000..8bb6cd87286ec --- /dev/null +++ b/packages/integrations/cloudflare/test/directory-split.test.js @@ -0,0 +1,44 @@ +import { loadFixture } from './test-utils.js'; +import { expect } from 'chai'; +import cloudflare from '../dist/index.js'; + +/** @type {import('./test-utils').Fixture} */ +describe('Cloudflare SSR split', () => { + let fixture; + + before(async () => { + fixture = await loadFixture({ + root: './fixtures/split/', + adapter: cloudflare({ mode: 'directory' }), + output: "server", + build: { + split: true, + excludeMiddleware: false + }, + vite: { + build: { + minify: false, + }, + }, + }); + await fixture.build(); + }); + + after(() => { + fixture.clean(); + }); + + it('generates functions folders inside the project root, and checks that each page is emitted by astro', async () => { + expect(await fixture.pathExists('../functions')).to.be.true; + expect(await fixture.pathExists('../functions/index.js')).to.be.true; + expect(await fixture.pathExists('../functions/blog/cool.js')).to.be.true; + expect(await fixture.pathExists('../functions/blog/[post].js')).to.be.true; + expect(await fixture.pathExists('../functions/[person]/[car].js')).to.be.true; + expect(await fixture.pathExists('../functions/files/[[path]].js')).to.be.true; + expect(await fixture.pathExists('../functions/[language]/files/[[path]].js')).to.be.true; + }); + + it('generates pre-rendered files', async () => { + expect(await fixture.pathExists('./prerender/index.html')).to.be.true; + }); +}); diff --git a/packages/integrations/cloudflare/test/fixtures/split/package.json b/packages/integrations/cloudflare/test/fixtures/split/package.json new file mode 100644 index 0000000000000..fd7dcc2530da0 --- /dev/null +++ b/packages/integrations/cloudflare/test/fixtures/split/package.json @@ -0,0 +1,9 @@ +{ + "name": "@test/astro-cloudflare-split", + "version": "0.0.0", + "private": true, + "dependencies": { + "@astrojs/cloudflare": "workspace:*", + "astro": "workspace:*" + } +} diff --git a/packages/integrations/cloudflare/test/fixtures/split/src/middleware.ts b/packages/integrations/cloudflare/test/fixtures/split/src/middleware.ts new file mode 100644 index 0000000000000..a6ce640cb3484 --- /dev/null +++ b/packages/integrations/cloudflare/test/fixtures/split/src/middleware.ts @@ -0,0 +1,10 @@ +import { defineMiddleware } from "astro/middleware"; + +export const onRequest = defineMiddleware(({ locals, request }, next) => { + // intercept response data from a request + // optionally, transform the response by modifying `locals` + locals.title = "New title" + + // return a Response or the result of calling `next()` + return next() +}); diff --git a/packages/integrations/cloudflare/test/fixtures/split/src/pages/[language]/files/[...path].astro b/packages/integrations/cloudflare/test/fixtures/split/src/pages/[language]/files/[...path].astro new file mode 100644 index 0000000000000..84ad532286a09 --- /dev/null +++ b/packages/integrations/cloudflare/test/fixtures/split/src/pages/[language]/files/[...path].astro @@ -0,0 +1,37 @@ +--- +const files = [ + { + slug: undefined, + title: 'Root level', + }, + { + slug: 'test.png', + title: "One level" + }, + { + slug: 'assets/test.png', + title: "Two levels" + }, + { + slug: 'assets/images/test.png', + title: 'Three levels', + } +]; + +const { path } = Astro.params; +const page = files.find((page) => page.slug === path); +const { title } = page; + +--- + + +

Files / Rest Parameters / {title}

+

DEBUG: {path}

+

index

+ + + diff --git a/packages/integrations/cloudflare/test/fixtures/split/src/pages/[person]/[car].astro b/packages/integrations/cloudflare/test/fixtures/split/src/pages/[person]/[car].astro new file mode 100644 index 0000000000000..f4fda9dc53361 --- /dev/null +++ b/packages/integrations/cloudflare/test/fixtures/split/src/pages/[person]/[car].astro @@ -0,0 +1,14 @@ +--- +const { person, car } = Astro.params; +--- + + +

{person} / {car}

+

index

+ + + diff --git a/packages/integrations/cloudflare/test/fixtures/split/src/pages/blog/[post].astro b/packages/integrations/cloudflare/test/fixtures/split/src/pages/blog/[post].astro new file mode 100644 index 0000000000000..7b0e1e5b8a8df --- /dev/null +++ b/packages/integrations/cloudflare/test/fixtures/split/src/pages/blog/[post].astro @@ -0,0 +1,14 @@ +--- +const { post } = Astro.params; +--- + + +

Blog / {post}

+

index

+ + + diff --git a/packages/integrations/cloudflare/test/fixtures/split/src/pages/blog/cool.astro b/packages/integrations/cloudflare/test/fixtures/split/src/pages/blog/cool.astro new file mode 100644 index 0000000000000..7127282a45854 --- /dev/null +++ b/packages/integrations/cloudflare/test/fixtures/split/src/pages/blog/cool.astro @@ -0,0 +1,11 @@ + + +

Blog / Cool

+

index

+ + + diff --git a/packages/integrations/cloudflare/test/fixtures/split/src/pages/files/[...path].astro b/packages/integrations/cloudflare/test/fixtures/split/src/pages/files/[...path].astro new file mode 100644 index 0000000000000..84ad532286a09 --- /dev/null +++ b/packages/integrations/cloudflare/test/fixtures/split/src/pages/files/[...path].astro @@ -0,0 +1,37 @@ +--- +const files = [ + { + slug: undefined, + title: 'Root level', + }, + { + slug: 'test.png', + title: "One level" + }, + { + slug: 'assets/test.png', + title: "Two levels" + }, + { + slug: 'assets/images/test.png', + title: 'Three levels', + } +]; + +const { path } = Astro.params; +const page = files.find((page) => page.slug === path); +const { title } = page; + +--- + + +

Files / Rest Parameters / {title}

+

DEBUG: {path}

+

index

+ + + diff --git a/packages/integrations/cloudflare/test/fixtures/split/src/pages/index.astro b/packages/integrations/cloudflare/test/fixtures/split/src/pages/index.astro new file mode 100644 index 0000000000000..a7f5640469e20 --- /dev/null +++ b/packages/integrations/cloudflare/test/fixtures/split/src/pages/index.astro @@ -0,0 +1,22 @@ +--- +const data = Astro.locals; +--- + + +

Index

+

Middleware ({data.title})

+

prerender

+

sub-route

+

dynamic route in static sub-route

+

dynamic route in dynamic sub-route

+

rest parameters root level

+

rest parameters one level

+

rest parameters two level

+

rest parameters three level

+ + + diff --git a/packages/integrations/cloudflare/test/fixtures/split/src/pages/prerender.astro b/packages/integrations/cloudflare/test/fixtures/split/src/pages/prerender.astro new file mode 100644 index 0000000000000..bdda9b12ceab5 --- /dev/null +++ b/packages/integrations/cloudflare/test/fixtures/split/src/pages/prerender.astro @@ -0,0 +1,14 @@ +--- +export const prerender = true; +--- + + +

Prerender

+

index

+ + + diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 87f832f0aef1d..785a8fddf451b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3675,6 +3675,15 @@ importers: specifier: workspace:* version: link:../../../../../astro + packages/integrations/cloudflare/test/fixtures/split: + dependencies: + '@astrojs/cloudflare': + specifier: workspace:* + version: link:../../.. + astro: + specifier: workspace:* + version: link:../../../../../astro + packages/integrations/cloudflare/test/fixtures/with-solid-js: dependencies: '@astrojs/cloudflare':