summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGravatar Luiz Ferraz <luiz@lferraz.com> 2024-04-18 02:38:24 -0300
committerGravatar GitHub <noreply@github.com> 2024-04-18 07:38:24 +0200
commit12e922d26c2a31d371e29b5621f4dfe3d5f03c3b (patch)
tree1b2bc8628f38a0c699157fb9437e12948c445c8f
parentc39dc343add1f1438dc40f8c71b64ff14aae2503 (diff)
downloadastro-12e922d26c2a31d371e29b5621f4dfe3d5f03c3b.tar.gz
astro-12e922d26c2a31d371e29b5621f4dfe3d5f03c3b.tar.zst
astro-12e922d26c2a31d371e29b5621f4dfe3d5f03c3b.zip
feat(cloudflare): remove unnecessary code from the server output (#222)
-rw-r--r--packages/integrations/cloudflare/package.json14
-rw-r--r--packages/integrations/cloudflare/src/index.ts75
-rw-r--r--packages/integrations/cloudflare/src/utils/non-server-chunk-detector.ts83
-rw-r--r--packages/integrations/cloudflare/test/exclude-prerender-only-chunks.test.js86
-rw-r--r--packages/integrations/cloudflare/test/fixtures/prerender-optimizations/.gitignore1
-rw-r--r--packages/integrations/cloudflare/test/fixtures/prerender-optimizations/astro.config.ts7
-rw-r--r--packages/integrations/cloudflare/test/fixtures/prerender-optimizations/package.json9
-rw-r--r--packages/integrations/cloudflare/test/fixtures/prerender-optimizations/src/content/config.ts8
-rw-r--r--packages/integrations/cloudflare/test/fixtures/prerender-optimizations/src/content/posts/sample.md5
-rw-r--r--packages/integrations/cloudflare/test/fixtures/prerender-optimizations/src/env.d.ts2
-rw-r--r--packages/integrations/cloudflare/test/fixtures/prerender-optimizations/src/pages/on-demand.astro12
-rw-r--r--packages/integrations/cloudflare/test/fixtures/prerender-optimizations/src/pages/prerender.astro18
12 files changed, 313 insertions, 7 deletions
diff --git a/packages/integrations/cloudflare/package.json b/packages/integrations/cloudflare/package.json
index 44b07ac84..433d3c4b3 100644
--- a/packages/integrations/cloudflare/package.json
+++ b/packages/integrations/cloudflare/package.json
@@ -27,11 +27,11 @@
"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"
},
@@ -39,13 +39,15 @@
"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
diff --git a/packages/integrations/cloudflare/src/index.ts b/packages/integrations/cloudflare/src/index.ts
index deed35655..d7a68c3e4 100644
--- a/packages/integrations/cloudflare/src/index.ts
+++ b/packages/integrations/cloudflare/src/index.ts
@@ -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,
@@ -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 { NonServerChunkDetector } from './utils/non-server-chunk-detector.js';
import { wasmModuleLoader } from './utils/wasm-module-loader.js';
export type { Runtime } from './entrypoints/server.js';
@@ -64,6 +65,13 @@ export type Options = {
export default function createIntegration(args?: Options): AstroIntegration {
let _config: AstroConfig;
+ // Initialize the unused chunk analyzer as a shared state between hooks.
+ // The analyzer is used on earlier hooks to collect information about used hooks on a Vite plugin
+ // and then later after the full build to clean up unused chunks, so it has to be shared between them.
+ const chunkAnalyzer = new NonServerChunkDetector();
+
+ const prerenderImports: string[][] = [];
+
return {
name: '@astrojs/cloudflare',
hooks: {
@@ -84,6 +92,63 @@ export default function createIntegration(args?: Options): AstroIntegration {
wasmModuleLoader({
disabled: !args?.wasmModuleImports,
}),
+ chunkAnalyzer.getPlugin(),
+ {
+ name: 'dynamic-imports-analyzer',
+ enforce: 'post',
+ generateBundle(_, bundle) {
+ // Find all pages (ignore the ssr entrypoint) which are prerendered based on the dynamic imports of the prerender chunk
+ for (const chunk of Object.values(bundle)) {
+ if (chunk.type !== 'chunk') continue;
+
+ const isPrerendered = chunk.dynamicImports.some(
+ (entry) =>
+ entry.includes('prerender') && chunk.name !== '_@astrojs-ssr-virtual-entry'
+ );
+ if (isPrerendered) {
+ prerenderImports.push([chunk.facadeModuleId ?? '', chunk.fileName]);
+ }
+ }
+
+ const entryChunk = bundle['index.js'];
+ if (
+ entryChunk &&
+ entryChunk.type === 'chunk' &&
+ entryChunk.name === '_@astrojs-ssr-virtual-entry'
+ ) {
+ // Update dynamicImports information, so that there are no imports listed which we remove later
+ entryChunk.dynamicImports = entryChunk.dynamicImports.filter(
+ (entry) => !prerenderImports.map((e) => e[1]).includes(entry)
+ );
+
+ // Clean the ssr entry file from prerendered imporst, since Astro adds them, which it shouldn't. But this is a current limitation in core, because the prerender meta info gets added later in the chain
+ for (const page of prerenderImports) {
+ // Find the dynamic import inside of the ssr entry file, which get generated by Astro: https://github.com/withastro/astro/blob/08cdd0919d3249a762822e4bba9e0c5d3966916c/packages/astro/src/core/build/plugins/plugin-ssr.ts#L56
+ const importRegex = new RegExp(
+ `^const (_page\\d) = \\(\\) => import\\('.\\/${page[1]}'\\);$\\n`,
+ 'gm'
+ );
+
+ let pageId: string | undefined;
+ const matches = entryChunk.code.matchAll(importRegex);
+ for (const match of matches) {
+ if (match[1]) {
+ pageId = match[1];
+ }
+ }
+ const pageSource = page[0].split(':')[1].replace('@_@', '.');
+ entryChunk.code = entryChunk.code.replace(importRegex, '');
+ if (pageId) {
+ // Find the page in the pageMap of the ssr entry file, which get generated by Astro: https://github.com/withastro/astro/blob/08cdd0919d3249a762822e4bba9e0c5d3966916c/packages/astro/src/core/build/plugins/plugin-ssr.ts#L65
+ const arrayRegex = new RegExp(`\\["${pageSource}", ?${pageId}\\],?`, 'gm');
+ entryChunk.code = entryChunk.code.replace(arrayRegex, '');
+ }
+ }
+ } else {
+ // We don't want to handle this case, since it will always occur for the client build.
+ }
+ },
+ },
],
},
image: setImageConfig(args?.imageService ?? 'DEFAULT', config.image, command, logger),
@@ -303,6 +368,14 @@ export default function createIntegration(args?: Options): AstroIntegration {
logger.error('Failed to write _redirects file');
}
}
+
+ // Get chunks from the bundle that are not needed on the server and delete them
+ // Those modules are build only for prerendering routes.
+ const chunksToDelete = chunkAnalyzer.getNonServerChunks();
+ for (const chunk of chunksToDelete) {
+ // Chunks are located on `./_worker.js` directory inside of the output directory
+ await unlink(new URL(`./_worker.js/${chunk}`, _config.outDir));
+ }
},
},
};
diff --git a/packages/integrations/cloudflare/src/utils/non-server-chunk-detector.ts b/packages/integrations/cloudflare/src/utils/non-server-chunk-detector.ts
new file mode 100644
index 000000000..c7e766802
--- /dev/null
+++ b/packages/integrations/cloudflare/src/utils/non-server-chunk-detector.ts
@@ -0,0 +1,83 @@
+import type { OutputBundle } from 'rollup';
+import type { Plugin } from 'vite';
+
+/**
+ * A Vite bundle analyzer that identifies chunks that are not used for server rendering.
+ *
+ * The chunks injected by Astro for prerendering are flagged as non-server chunks.
+ * Any chunks that is only used by a non-server chunk are also flagged as non-server chunks.
+ * This continues transitively until all non-server chunks are found.
+ */
+export class NonServerChunkDetector {
+ private nonServerChunks?: string[];
+
+ public getPlugin(): Plugin {
+ return {
+ name: 'non-server-chunk-detector',
+ 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;
+
+ // Construct a mapping from a chunk name to its file name
+ chunkNamesToFiles.set(chunk.name, chunk.fileName);
+ // Construct a mapping from a chunk file to all the modules it imports
+ 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) {
+ // Entry chunks are used on the server
+ chunkDecisions.set(entry, true);
+ }
+
+ for (const chunk of ['prerender', 'prerender@_@astro']) {
+ // Prerender chunks are not used on the server
+ const fileName = chunkNamesToFiles.get(chunk);
+ if (fileName) {
+ chunkDecisions.set(fileName, false);
+ }
+ }
+
+ // Start a stack of chunks that are used on the server
+ const chunksToWalk = [...entryChunks];
+
+ // Iterate over the chunks, traversing the transitive dependencies of the chunks used on the server
+ for (let chunk = chunksToWalk.pop(); chunk; chunk = chunksToWalk.pop()) {
+ for (const dep of chunkToDependencies.get(chunk) ?? []) {
+ // Skip dependencies already flagged, dependencies may be repeated and/or circular
+ if (chunkDecisions.has(dep)) continue;
+
+ // A dependency of a module used on the server is also used on the server
+ chunkDecisions.set(dep, true);
+ // Add the dependency to the stack so its own dependencies are also flagged
+ chunksToWalk.push(dep);
+ }
+ }
+
+ // Any chunk not flagged as used on the server is a non-server chunk
+ this.nonServerChunks = Array.from(chunkToDependencies.keys()).filter(
+ (chunk) => !chunkDecisions.get(chunk)
+ );
+ }
+
+ public getNonServerChunks(): string[] {
+ return this.nonServerChunks ?? [];
+ }
+}
diff --git a/packages/integrations/cloudflare/test/exclude-prerender-only-chunks.test.js b/packages/integrations/cloudflare/test/exclude-prerender-only-chunks.test.js
new file mode 100644
index 000000000..5a142f429
--- /dev/null
+++ b/packages/integrations/cloudflare/test/exclude-prerender-only-chunks.test.js
@@ -0,0 +1,86 @@
+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');
+ });
+
+ 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.'
+ );
+ });
+});
diff --git a/packages/integrations/cloudflare/test/fixtures/prerender-optimizations/.gitignore b/packages/integrations/cloudflare/test/fixtures/prerender-optimizations/.gitignore
new file mode 100644
index 000000000..97e663b17
--- /dev/null
+++ b/packages/integrations/cloudflare/test/fixtures/prerender-optimizations/.gitignore
@@ -0,0 +1 @@
+.astro/
diff --git a/packages/integrations/cloudflare/test/fixtures/prerender-optimizations/astro.config.ts b/packages/integrations/cloudflare/test/fixtures/prerender-optimizations/astro.config.ts
new file mode 100644
index 000000000..339f0e2a4
--- /dev/null
+++ b/packages/integrations/cloudflare/test/fixtures/prerender-optimizations/astro.config.ts
@@ -0,0 +1,7 @@
+import cloudflare from '@astrojs/cloudflare';
+import { defineConfig } from 'astro/config';
+
+export default defineConfig({
+ adapter: cloudflare(),
+ output: 'server',
+});
diff --git a/packages/integrations/cloudflare/test/fixtures/prerender-optimizations/package.json b/packages/integrations/cloudflare/test/fixtures/prerender-optimizations/package.json
new file mode 100644
index 000000000..ad583d9d5
--- /dev/null
+++ b/packages/integrations/cloudflare/test/fixtures/prerender-optimizations/package.json
@@ -0,0 +1,9 @@
+{
+ "name": "@test/astro-cloudflare-prerender-optimizations",
+ "version": "0.0.0",
+ "private": true,
+ "dependencies": {
+ "@astrojs/cloudflare": "workspace:*",
+ "astro": "^4.3.5"
+ }
+}
diff --git a/packages/integrations/cloudflare/test/fixtures/prerender-optimizations/src/content/config.ts b/packages/integrations/cloudflare/test/fixtures/prerender-optimizations/src/content/config.ts
new file mode 100644
index 000000000..83ef12586
--- /dev/null
+++ b/packages/integrations/cloudflare/test/fixtures/prerender-optimizations/src/content/config.ts
@@ -0,0 +1,8 @@
+import { defineCollection } from 'astro:content';
+import { z } from 'astro/zod';
+
+export const collections = {
+ posts: defineCollection({
+ schema: z.any(),
+ }),
+}
diff --git a/packages/integrations/cloudflare/test/fixtures/prerender-optimizations/src/content/posts/sample.md b/packages/integrations/cloudflare/test/fixtures/prerender-optimizations/src/content/posts/sample.md
new file mode 100644
index 000000000..060253707
--- /dev/null
+++ b/packages/integrations/cloudflare/test/fixtures/prerender-optimizations/src/content/posts/sample.md
@@ -0,0 +1,5 @@
+---
+title: Sample Post Title
+---
+
+Sample Post Content
diff --git a/packages/integrations/cloudflare/test/fixtures/prerender-optimizations/src/env.d.ts b/packages/integrations/cloudflare/test/fixtures/prerender-optimizations/src/env.d.ts
new file mode 100644
index 000000000..c13bd73c7
--- /dev/null
+++ b/packages/integrations/cloudflare/test/fixtures/prerender-optimizations/src/env.d.ts
@@ -0,0 +1,2 @@
+/// <reference path="../.astro/types.d.ts" />
+/// <reference types="astro/client" /> \ No newline at end of file
diff --git a/packages/integrations/cloudflare/test/fixtures/prerender-optimizations/src/pages/on-demand.astro b/packages/integrations/cloudflare/test/fixtures/prerender-optimizations/src/pages/on-demand.astro
new file mode 100644
index 000000000..490295b7b
--- /dev/null
+++ b/packages/integrations/cloudflare/test/fixtures/prerender-optimizations/src/pages/on-demand.astro
@@ -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>
diff --git a/packages/integrations/cloudflare/test/fixtures/prerender-optimizations/src/pages/prerender.astro b/packages/integrations/cloudflare/test/fixtures/prerender-optimizations/src/pages/prerender.astro
new file mode 100644
index 000000000..08d0318e6
--- /dev/null
+++ b/packages/integrations/cloudflare/test/fixtures/prerender-optimizations/src/pages/prerender.astro
@@ -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>