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

feat(cloudflare): Exclude prerender and unused chunks from server bundle #222

Merged
merged 11 commits into from
Apr 18, 2024
5 changes: 5 additions & 0 deletions .changeset/pink-rings-agree.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@astrojs/cloudflare': minor
---

Exclude prerender and unused chunks from server bundle
alexanderniebuhr marked this conversation as resolved.
Show resolved Hide resolved
14 changes: 8 additions & 6 deletions packages/cloudflare/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,25 +28,27 @@
"test": "astro-scripts test \"test/**/*.test.js\""
},
"dependencies": {
"@astrojs/underscore-redirects": "^0.3.3",
"@astrojs/internal-helpers": "0.3.0",
"@astrojs/underscore-redirects": "^0.3.3",
"@cloudflare/workers-types": "^4.20240320.1",
"miniflare": "^3.20240320.0",
"esbuild": "^0.19.5",
"miniflare": "^3.20240320.0",
"tiny-glob": "^0.2.9",
"wrangler": "^3.39.0"
},
"peerDependencies": {
"astro": "^4.2.0"
},
"devDependencies": {
"@astrojs/test-utils": "workspace:*",
"astro": "^4.5.8",
"astro-scripts": "workspace:*",
"cheerio": "1.0.0-rc.12",
"execa": "^8.0.1",
"fast-glob": "^3.3.2",
"rollup": "^4.14.0",
"strip-ansi": "^7.1.0",
"astro": "^4.5.8",
"cheerio": "1.0.0-rc.12",
"@astrojs/test-utils": "workspace:*",
"astro-scripts": "workspace:*"
"vite": "^5.2.6"
},
"publishConfig": {
"provenance": true
Expand Down
12 changes: 11 additions & 1 deletion packages/cloudflare/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { AstroConfig, AstroIntegration, RouteData } from 'astro';

import { createReadStream } from 'node:fs';
import { appendFile, rename, stat } from 'node:fs/promises';
import { appendFile, rename, stat, unlink } from 'node:fs/promises';
import { createInterface } from 'node:readline/promises';
import {
appendForwardSlash,
Expand All @@ -13,6 +13,7 @@ import { AstroError } from 'astro/errors';
import { getPlatformProxy } from 'wrangler';
import { createRoutesFile, getParts } from './utils/generate-routes-json.js';
import { setImageConfig } from './utils/image-config.js';
import { UnusedChunkAnalyzer } from './utils/unused-chunk-analyzer.js';
import { wasmModuleLoader } from './utils/wasm-module-loader.js';

export type { Runtime } from './entrypoints/server.advanced.js';
Expand Down Expand Up @@ -64,6 +65,8 @@ export type Options = {
export default function createIntegration(args?: Options): AstroIntegration {
let _config: AstroConfig;

const chunkAnalyzer = new UnusedChunkAnalyzer();

return {
name: '@astrojs/cloudflare',
hooks: {
Expand All @@ -84,6 +87,7 @@ export default function createIntegration(args?: Options): AstroIntegration {
wasmModuleLoader({
disabled: !args?.wasmModuleImports,
}),
chunkAnalyzer.getPlugin(),
],
},
image: setImageConfig(args?.imageService ?? 'DEFAULT', config.image, command, logger),
Expand Down Expand Up @@ -300,6 +304,12 @@ export default function createIntegration(args?: Options): AstroIntegration {
logger.error('Failed to write _redirects file');
}
}

const chunksToDelete = chunkAnalyzer.getUnusedChunks();

for (const chunk of chunksToDelete) {
await unlink(new URL(`./_worker.js/${chunk}`, _config.outDir));
}
},
},
};
Expand Down
68 changes: 68 additions & 0 deletions packages/cloudflare/src/utils/unused-chunk-analyzer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import type { OutputBundle } from 'rollup';
import type { Plugin } from 'vite';

export class UnusedChunkAnalyzer {
private unusedChunks?: string[];

public getPlugin(): Plugin {
return {
name: 'unused-chunk-analyzer',
generateBundle: (_, bundle) => {
this.processBundle(bundle);
},
};
}

private processBundle(bundle: OutputBundle) {
const chunkNamesToFiles = new Map<string, string>();

const entryChunks: string[] = [];
const chunkToDependencies = new Map<string, string[]>();

for (const chunk of Object.values(bundle)) {
if (chunk.type !== 'chunk') continue;

chunkNamesToFiles.set(chunk.name, chunk.fileName);
chunkToDependencies.set(chunk.fileName, [...chunk.imports, ...chunk.dynamicImports]);

if (chunk.isEntry) {
// Entry chunks should always be kept around since they are to be imported by the runtime
entryChunks.push(chunk.fileName);
}
}

const chunkDecisions = new Map<string, boolean>();

for (const entry of entryChunks) {
// Keep all entry chunks
chunkDecisions.set(entry, true);
}

for (const chunk of ['prerender', 'prerender@_@astro']) {
// Exclude prerender chunks from the server bundle
const fileName = chunkNamesToFiles.get(chunk);
if (fileName) {
chunkDecisions.set(fileName, false);
}
}

const chunksToWalk = [...entryChunks];

for (let chunk = chunksToWalk.pop(); chunk; chunk = chunksToWalk.pop()) {
for (const dep of chunkToDependencies.get(chunk) ?? []) {
if (chunkDecisions.has(dep)) continue;

chunkDecisions.set(dep, true);
chunksToWalk.push(dep);
}
}

this.unusedChunks = Array.from(chunkToDependencies.keys()).filter(
(chunk) => !chunkDecisions.get(chunk)
);
}

public getUnusedChunks(): string[] {
return this.unusedChunks ?? [];
}
}
88 changes: 88 additions & 0 deletions packages/cloudflare/test/exclude-prerender-only-chunks.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import * as assert from 'node:assert/strict';
import { readFile, readdir } from 'node:fs/promises';
import { join, relative } from 'node:path';
import { before, describe, it } from 'node:test';
import { fileURLToPath } from 'node:url';
import { astroCli } from './_test-utils.js';

const root = new URL('./fixtures/prerender-optimizations/', import.meta.url);

async function lookForCodeInServerBundle(code) {
const serverBundleRoot = fileURLToPath(new URL('./dist/_worker.js/', root));

const entries = await readdir(serverBundleRoot, {
withFileTypes: true,
recursive: true,
}).catch((err) => {
console.log('Failed to read server bundle directory:', err);

throw err;
});

for (const entry of entries) {
if (!entry.isFile()) continue;

const filePath = join(entry.path, entry.name);
const fileContent = await readFile(filePath, 'utf-8').catch((err) => {
console.log(`Failed to read file ${filePath}:`, err);

throw err;
});

if (fileContent.includes(code)) {
return relative(serverBundleRoot, filePath);
}
}

return null;
}

describe('worker.js cleanup after pre-rendering', () => {
before(async () => {
const res = await astroCli(fileURLToPath(root), 'build');
process.stdout.write(res.stdout);
process.stderr.write(res.stderr);
alexanderniebuhr marked this conversation as resolved.
Show resolved Hide resolved
});

it('should not include code from pre-rendered pages in the server bundle', async () => {
assert.equal(
await lookForCodeInServerBundle('frontmatter of prerendered page'),
null,
'Code from pre-rendered pages should not be included in the server bundle.'
);

assert.equal(
await lookForCodeInServerBundle('Body of Prerendered Page'),
null,
'Code from pre-rendered pages should not be included in the server bundle.'
);
});

it('should not include markdown content used only in pre-rendered pages in the server bundle', async () => {
assert.equal(
await lookForCodeInServerBundle('Sample Post Title'),
null,
'Markdown frontmatter used only on pre-rendered pages should not be included in the server bundle.'
);

assert.equal(
await lookForCodeInServerBundle('Sample Post Content'),
null,
'Markdown content used only on pre-rendered pages should not be included in the server bundle.'
);
});

it('should include code for on-demand pages in the server bundle', async () => {
assert.notEqual(
await lookForCodeInServerBundle('frontmatter of SSR page'),
null,
'Code from pre-rendered pages should not be included in the server bundle.'
);

assert.notEqual(
await lookForCodeInServerBundle('Body of SSR Page'),
null,
'Code from pre-rendered pages should not be included in the server bundle.'
);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
.astro/
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import cloudflare from '@astrojs/cloudflare';
import { defineConfig } from 'astro/config';

export default defineConfig({
adapter: cloudflare(),
output: 'server',
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"name": "@test/astro-cloudflare-prerender-optimizations",
"version": "0.0.0",
"private": true,
"dependencies": {
"@astrojs/cloudflare": "workspace:*",
"astro": "^4.3.5"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { defineCollection } from 'astro:content';
import { z } from 'astro/zod';

export const collections = {
posts: defineCollection({
schema: z.any(),
}),
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
title: Sample Post Title
---

Sample Post Content
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
/// <reference path="../.astro/types.d.ts" />
/// <reference types="astro/client" />
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
export const prerender = false;

console.log('frontmatter of SSR page');
---

<html>
<head></head>
<body>
<h1>Body of SSR Page</h1>
</body>
</html>
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
---
import { getEntry } from 'astro:content';

export const prerender = true;

console.log('frontmatter of prerendered page');

const samplePost = await getEntry('posts', 'sample');
const { Content } = await samplePost.render();
---

<html>
<head></head>
<body>
<h1>Body of Prerendered Page</h1>
<Content />
</body>
</html>
Loading
Loading