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

feature(astrojs/cloudflare): add support for splitted SSR bundles #7464

Merged
merged 20 commits into from
Jun 30, 2023
Merged
Show file tree
Hide file tree
Changes from 16 commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 seperate bundle for each page which is used by cloudflare routing. Be aware that this will overwrite any files in the `functions` folder with the same name emitted by Astro. It is recommended to name your custom files, e.g. Pages Plugins & Middleware, distinctly to avoid accidental overwrite. Additionally the adapter will not empty the `functions` folder, so you might need to clean-up the folder if you remove pages. This is a difference to the `dist` folder which is always cleaned up between builds, which is necessery to not remove custom files in the `functions` folder. For now the adapter will bundle the middleware into the each page, and will not make use of [Cloudflare Pages Middleware](https://developers.cloudflare.com/pages/platform/functions/middleware/). We are working to improve the adapter, so you can set `build.excludeMiddleware: true`, to get proper supported output.
alexanderniebuhr marked this conversation as resolved.
Show resolved Hide resolved

Cloudflare documentation contains more information about [writing custom functions](https://developers.cloudflare.com/pages/platform/functions/).
alexanderniebuhr marked this conversation as resolved.
Show resolved Hide resolved

alexanderniebuhr marked this conversation as resolved.
Show resolved Hide resolved
```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()) {
alexanderniebuhr marked this conversation as resolved.
Show resolved Hide resolved
// 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 {
alexanderniebuhr marked this conversation as resolved.
Show resolved Hide resolved
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