Skip to content

Commit

Permalink
feature(astrojs/cloudflare): add support for splitted SSR bundles (#…
Browse files Browse the repository at this point in the history
…7464)

* initial commit

* try to fix windows

* output files directly into the correct folder

* allow for rest parameters

* use fixed hook

* improve tests

* apply doc's team suggestions for README

Co-authored-by: Sarah Rainsberger <[email protected]>

* try to fix prerendering

* apply doc's team suggestion for changeset

Co-authored-by: Sarah Rainsberger <[email protected]>

* bump to minor

* readme update

* resolve review comments

* optimize memory allocation

* resolve review comments

* add removed link, to make sure old docs keep same

* resolve comment

Co-authored-by: Sarah Rainsberger <[email protected]>

---------

Co-authored-by: Sarah Rainsberger <[email protected]>
Co-authored-by: Chris Swithinbank <[email protected]>
  • Loading branch information
3 people authored and matthewp committed Jul 11, 2023
1 parent 7c2f10b commit 33b2ccb
Show file tree
Hide file tree
Showing 16 changed files with 338 additions and 46 deletions.
7 changes: 7 additions & 0 deletions .changeset/healthy-books-study.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@astrojs/cloudflare': minor
---

Split Support in Cloudflare

Adds support for configuring `build.split` when using the Cloudflare adapter
6 changes: 5 additions & 1 deletion packages/integrations/cloudflare/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
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 @@ -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",
Expand Down
145 changes: 102 additions & 43 deletions packages/integrations/cloudflare/src/index.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -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 = {
Expand All @@ -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<RouteData, URL>();

return {
name: '@astrojs/cloudflare',
Expand Down Expand Up @@ -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 });

Expand Down Expand Up @@ -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);
}
},
},
};
Expand Down
2 changes: 1 addition & 1 deletion packages/integrations/cloudflare/src/server.directory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,5 +61,5 @@ export function createExports(manifest: SSRManifest) {
});
};

return { onRequest };
return { onRequest, manifest };
}
44 changes: 44 additions & 0 deletions packages/integrations/cloudflare/test/directory-split.test.js
Original file line number Diff line number Diff line change
@@ -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;
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"name": "@test/astro-cloudflare-split",
"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,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()
});
Original file line number Diff line number Diff line change
@@ -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;
---
<html>
<body>
<h1>Files / Rest Parameters / {title}</h1>
<p>DEBUG: {path} </p>
<p><a href="/">index</a></p>
</body>
<style>
h1 {
background-color: yellow;
}
</style>
</html>
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
const { person, car } = Astro.params;
---
<html>
<body>
<h1> {person} / {car}</h1>
<p><a href="/">index</a></p>
</body>
<style>
h1 {
background-color: blue;
}
</style>
</html>
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
const { post } = Astro.params;
---
<html>
<body>
<h1>Blog / {post}</h1>
<p><a href="/">index</a></p>
</body>
<style>
h1 {
background-color: pink;
}
</style>
</html>
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<html>
<body>
<h1>Blog / Cool</h1>
<p><a href="/">index</a></p>
</body>
<style>
h1 {
background-color: orange;
}
</style>
</html>
Loading

0 comments on commit 33b2ccb

Please sign in to comment.