0
Fork 0
mirror of https://github.com/withastro/astro.git synced 2024-12-16 21:46:22 -05:00

feature(astrojs/cloudflare): add support for splitted SSR bundles (#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 <sarah@rainsberger.ca>

* try to fix prerendering

* apply doc's team suggestion for changeset

Co-authored-by: Sarah Rainsberger <sarah@rainsberger.ca>

* 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 <sarah@rainsberger.ca>

---------

Co-authored-by: Sarah Rainsberger <sarah@rainsberger.ca>
Co-authored-by: Chris Swithinbank <swithinbank@gmail.com>
This commit is contained in:
Alexander Niebuhr 2023-06-30 11:09:21 +02:00 committed by GitHub
parent 7ae6e89292
commit 1a59185ddd
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
16 changed files with 337 additions and 45 deletions

View file

@ -0,0 +1,7 @@
---
'@astrojs/cloudflare': minor
---
Split Support in Cloudflare
Adds support for configuring `build.split` when using the Cloudflare adapter

View file

@ -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

View file

@ -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",

View file

@ -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<RouteData, URL>();
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'));
const functionsUrl = new URL('functions/', _config.root);
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',
},
});
if (isModeDirectory) {
await fs.promises.mkdir(functionsUrl, { recursive: true });
}
// Rename to worker.js
await fs.promises.rename(buildPath, finalBuildUrl);
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));
// throw the server folder in the bin
// 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,
});
// 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);
}
} 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);
}
},
},
};

View file

@ -61,5 +61,5 @@ export function createExports(manifest: SSRManifest) {
});
};
return { onRequest };
return { onRequest, manifest };
}

View file

@ -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;
});
});

View file

@ -0,0 +1,9 @@
{
"name": "@test/astro-cloudflare-split",
"version": "0.0.0",
"private": true,
"dependencies": {
"@astrojs/cloudflare": "workspace:*",
"astro": "workspace:*"
}
}

View file

@ -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()
});

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -0,0 +1,11 @@
<html>
<body>
<h1>Blog / Cool</h1>
<p><a href="/">index</a></p>
</body>
<style>
h1 {
background-color: orange;
}
</style>
</html>

View file

@ -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>

View file

@ -0,0 +1,22 @@
---
const data = Astro.locals;
---
<html>
<body>
<h1>Index</h1>
<p>Middleware ({data.title})</p>
<p><a href="/prerender/">prerender</a></p>
<p><a href="/blog/cool/">sub-route</a></p>
<p><a href="/blog/dynamic-post/">dynamic route in static sub-route</a></p>
<p><a href="/mustermann/bmw/">dynamic route in dynamic sub-route</a></p>
<p><a href="/files/">rest parameters root level</a></p>
<p><a href="/files/test.png/">rest parameters one level</a></p>
<p><a href="/files/assets/test.png/">rest parameters two level</a></p>
<p><a href="/files/assets/images/test.png/">rest parameters three level</a></p>
</body>
<style>
h1 {
background-color: red;
}
</style>
</html>

View file

@ -0,0 +1,14 @@
---
export const prerender = true;
---
<html>
<body>
<h1>Prerender</h1>
<p><a href="/">index</a></p>
</body>
<style>
h1 {
background-color: yellow;
}
</style>
</html>

View file

@ -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':