Skip to content

Commit bd58920

Browse files
committed
chore: halve build time
1 parent 842a335 commit bd58920

File tree

3 files changed

+129
-101
lines changed

3 files changed

+129
-101
lines changed

src/generators/web/index.mjs

Lines changed: 22 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { createRequire } from 'node:module';
33
import { join } from 'node:path';
44

55
import createASTBuilder from './utils/generate.mjs';
6-
import { processJSXEntry } from './utils/processing.mjs';
6+
import { processJSXEntries } from './utils/processing.mjs';
77
import { safeWrite } from '../../utils/safeWrite.mjs';
88

99
/**
@@ -37,48 +37,33 @@ export default {
3737

3838
const requireFn = createRequire(import.meta.url);
3939

40-
const results = [];
41-
42-
let mainCss = '';
43-
44-
const writtenChunks = new Set();
45-
46-
for (const entry of entries) {
47-
const { html, css, jsChunks } = await processJSXEntry(
48-
entry,
49-
template,
50-
astBuilders,
51-
requireFn,
52-
{ version }
53-
);
54-
55-
results.push({ html, css });
40+
// Process all entries at once
41+
const { results, css, jsChunks } = await processJSXEntries(
42+
entries,
43+
template,
44+
astBuilders,
45+
requireFn,
46+
{ version }
47+
);
5648

57-
// Capture the main CSS bundle from the first processed entry
58-
if (!mainCss && css) {
59-
mainCss = css;
49+
// Write all files if output directory is specified
50+
if (output) {
51+
// Write all HTML files
52+
for (const { html, api } of results) {
53+
safeWrite(join(output, `${api}.html`), html, 'utf-8');
6054
}
6155

62-
// Write HTML file if output directory is specified
63-
if (output) {
64-
safeWrite(join(output, `${entry.data.api}.html`), html, 'utf-8');
65-
66-
// Write JS chunks (only once per unique chunk)
67-
for (const chunk of jsChunks) {
68-
if (!writtenChunks.has(chunk.fileName)) {
69-
safeWrite(join(output, chunk.fileName), chunk.code, 'utf-8');
70-
71-
writtenChunks.add(chunk.fileName);
72-
}
73-
}
56+
// Write all JS chunks
57+
for (const chunk of jsChunks) {
58+
safeWrite(join(output, chunk.fileName), chunk.code, 'utf-8');
7459
}
75-
}
7660

77-
// Write the main CSS file once after processing all entries
78-
if (output && mainCss) {
79-
safeWrite(join(output, 'styles.css'), mainCss, 'utf-8');
61+
// Write the CSS file
62+
if (css) {
63+
safeWrite(join(output, 'styles.css'), css, 'utf-8');
64+
}
8065
}
8166

82-
return results;
67+
return results.map(({ html }) => ({ html, css }));
8368
},
8469
};

src/generators/web/utils/bundle.mjs

Lines changed: 41 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -8,21 +8,24 @@ import staticData from './data.mjs';
88
* Asynchronously bundles JavaScript source code (and its CSS imports),
99
* targeting either browser (client) or server (Node.js) environments.
1010
*
11-
* @param {string} code - JavaScript/JSX source code to bundle.
11+
* @param {Map<string, string> | string} codeOrMap - Map of {fileName: code} or single code string for server builds.
1212
* @param {{ server: boolean }} options - Build configuration object.
1313
*/
14-
export default async function bundleCode(code, { server = false } = {}) {
14+
export default async function bundleCode(codeOrMap, { server = false } = {}) {
1515
// Store the import map HTML for later extraction
1616
let importMapHtml = '';
1717

18+
// Convert input to Map format
19+
const codeMap =
20+
codeOrMap instanceof Map
21+
? codeOrMap
22+
: new Map([['entrypoint.jsx', codeOrMap]]);
23+
1824
/** @type {import('rolldown').OutputOptions} */
1925
const serverOutputConfig = {
2026
inlineDynamicImports: true,
2127
};
2228

23-
/** @type {import('rolldown').OutputOptions} */
24-
const clientOutputConfig = {};
25-
2629
/** @type {import('rolldown').InputOptions['experimental']} */
2730
const clientExperimentalConfig = {
2831
chunkImportMap: !server && {
@@ -32,15 +35,15 @@ export default async function bundleCode(code, { server = false } = {}) {
3235
};
3336

3437
const result = await build({
35-
// Define the entry point module namethis is virtual (not a real file).
36-
// The virtual plugin will provide the actual code string under this name.
37-
input: 'entrypoint.jsx',
38+
// Define the entry point module namesthese are virtual (not real files).
39+
// The virtual plugin will provide the actual code string under these names.
40+
input: Array.from(codeMap.keys()),
3841

3942
// Enable experimental chunk import map for cache-busted module resolution
4043
// https://rolldown.rs/options/experimental#chunkimportmap
4144
// Also enable incremental builds for faster rebuilds (similar to Rollup's cache)
4245
// https://rolldown.rs/options/experimental#incrementalbuild
43-
experimental: !server ? clientExperimentalConfig : {},
46+
experimental: server ? {} : clientExperimentalConfig,
4447

4548
// Configuration for the output bundle
4649
output: {
@@ -55,7 +58,7 @@ export default async function bundleCode(code, { server = false } = {}) {
5558

5659
// Enable code splitting for client builds to allow dynamic imports
5760
// For server builds, inline everything into a single bundle
58-
...(server ? serverOutputConfig : clientOutputConfig),
61+
...(server ? serverOutputConfig : {}),
5962
},
6063

6164
// Platform informs Rolldown of the environment-specific code behavior:
@@ -105,10 +108,10 @@ export default async function bundleCode(code, { server = false } = {}) {
105108

106109
// Array of plugins to apply during the build.
107110
plugins: [
108-
// The virtual plugin lets us define a virtual file called 'entrypoint.jsx'
109-
// with the contents provided by the `code` argument.
110-
// This becomes the root module for the bundler.
111-
virtual({ 'entrypoint.jsx': code }),
111+
// The virtual plugin lets us define virtual files
112+
// with the contents provided by the `codeMap` argument.
113+
// These become the root modules for the bundler.
114+
virtual(Object.fromEntries(codeMap)),
112115

113116
// Load CSS imports via the custom plugin.
114117
// This plugin will collect imported CSS files and return them as `source` chunks.
@@ -160,20 +163,37 @@ export default async function bundleCode(code, { server = false } = {}) {
160163
});
161164

162165
// Destructure the result to get the output chunks.
163-
// The first output is always the JavaScript entrypoint.
164-
// Any additional chunks are styles (CSS) or code-split JS chunks.
165-
const [mainJs, ...otherChunks] = result.output;
166+
// Separate entry chunks from other chunks (CSS and code-split JS)
167+
const entryChunks = [];
168+
const otherChunks = [];
169+
170+
// Separate the entrypoints from remaining JavaScript files
171+
for (const chunk of result.output) {
172+
const chunkTarget =
173+
chunk.type === 'chunk' && chunk.isEntry ? entryChunks : otherChunks;
174+
175+
chunkTarget['push'](chunk);
176+
}
166177

167178
// Separate CSS files from JS chunks
168179
const cssFiles = otherChunks.filter(chunk => chunk.type === 'asset');
169180
const jsChunks = otherChunks.filter(chunk => chunk.type === 'chunk');
170181

171-
const bundleResult = {
172-
js: mainJs.code,
182+
// For client builds, create a map of entry code by original fileName
183+
const jsMap = {};
184+
185+
for (const chunk of entryChunks) {
186+
// Map back to original fileName from facadeModuleId
187+
const originalFileName =
188+
chunk.facadeModuleId?.split('/').pop() || chunk.fileName;
189+
190+
jsMap[originalFileName.replace('\x00virtual:', '')] = chunk.code;
191+
}
192+
193+
return {
194+
jsMap,
173195
jsChunks: jsChunks.map(({ fileName, code }) => ({ fileName, code })),
174196
css: cssFiles.map(f => f.source).join(''),
175197
importMapHtml,
176198
};
177-
178-
return bundleResult;
179199
}

src/generators/web/utils/processing.mjs

Lines changed: 66 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -5,65 +5,79 @@ import bundleCode from './bundle.mjs';
55

66
/**
77
* Executes server-side JavaScript code in a safe, isolated context.
8-
* This function takes a string of JavaScript code, bundles it, and then runs it
8+
* This function takes a Map of JavaScript code strings, bundles them together, and then runs each
99
* within a new Function constructor to prevent scope pollution and allow for
1010
* dynamic module loading via a provided `require` function.
1111
* The result of the server-side execution is expected to be assigned to a
1212
* dynamically generated variable name, which is then returned.
1313
*
14-
* @param {string} serverCode - The server-side JavaScript code to execute as a string.
14+
* @param {Map<string, string>} serverCodeMap - Map of fileName to server-side JavaScript code.
1515
* @param {ReturnType<import('node:module').createRequire>} requireFn - A Node.js `require` function
16+
* @returns {Promise<Map<string, string>>} Map of fileName to dehydrated HTML content
1617
*/
17-
export async function executeServerCode(serverCode, requireFn) {
18-
// Bundle the server-side code. This step resolves imports and prepares the code
19-
// for execution, ensuring all necessary dependencies are self-contained.
20-
const { js: bundledServer } = await bundleCode(serverCode, { server: true });
21-
22-
// Create a new Function from the bundled server code.
23-
// The `require` argument is passed into the function's scope, allowing the
24-
// `bundledServer` code to use it for dynamic imports.
25-
const executedFunction = new Function('require', bundledServer);
26-
27-
// Execute the dynamically created function with the provided `requireFn`.
28-
// The result of this execution is the dehydrated content from the server-side rendering.
29-
return executedFunction(requireFn);
18+
export async function executeServerCode(serverCodeMap, requireFn) {
19+
// Execute each bundled server code and collect results
20+
const dehydratedMap = new Map();
21+
22+
for (const [fileName, serverCode] of serverCodeMap.entries()) {
23+
// Bundle all server-side code together. This step resolves imports and prepares the code
24+
// for execution, ensuring all necessary dependencies are self-contained.
25+
const { jsMap } = await bundleCode(serverCode, { server: true });
26+
27+
// Create a new Function from the bundled server code.
28+
// The `require` argument is passed into the function's scope, allowing the
29+
// `bundledServer` code to use it for dynamic imports.
30+
const executedFunction = new Function('require', jsMap['entrypoint.jsx']);
31+
32+
// Execute the dynamically created function with the provided `requireFn`.
33+
// The result of this execution is the dehydrated content from the server-side rendering.
34+
dehydratedMap.set(fileName, executedFunction(requireFn));
35+
}
36+
37+
return dehydratedMap;
3038
}
3139

3240
/**
33-
* Processes a single JSX AST (Abstract Syntax Tree) entry to generate a complete
34-
* HTML page, including server-side rendered content, client-side JavaScript, and CSS.
41+
* Processes multiple JSX AST (Abstract Syntax Tree) entries to generate complete
42+
* HTML pages, including server-side rendered content, client-side JavaScript, and CSS.
3543
*
36-
* @param {import('../jsx-ast/utils/buildContent.mjs').JSXContent} entry - The JSX AST entry to process.
44+
* @param {import('../jsx-ast/utils/buildContent.mjs').JSXContent[]} entries - The JSX AST entries to process.
3745
* @param {string} template - The HTML template string that serves as the base for the output page.
3846
* @param {ReturnType<import('./generate.mjs')>} astBuilders - The AST generators
3947
* @param {Object} options - Processing options
4048
* @param {string} options.version - The version to generate the documentation for
4149
* @param {ReturnType<import('node:module').createRequire>} requireFn - A Node.js `require` function.
4250
*/
43-
export async function processJSXEntry(
44-
entry,
51+
export async function processJSXEntries(
52+
entries,
4553
template,
4654
{ buildServerProgram, buildClientProgram },
4755
requireFn,
4856
{ version }
4957
) {
50-
// `estree-util-to-js` with the `jsx` handler converts the AST nodes into a string
51-
// that represents the equivalent JavaScript code, including JSX syntax.
52-
const { value: code } = toJs(entry, { handlers: jsx });
58+
// Convert all entries to JavaScript code
59+
const serverCodeMap = new Map();
60+
const clientCodeMap = new Map();
5361

54-
// `buildServerProgram` takes the JSX-derived code and prepares it for server execution.
55-
// `executeServerCode` then runs this code in a Node.js environment to produce
56-
// the initial HTML content (dehydrated state) that will be sent to the client.
57-
const serverCode = buildServerProgram(code);
62+
for (const entry of entries) {
63+
const fileName = `${entry.data.api}.jsx`;
5864

59-
const dehydrated = await executeServerCode(serverCode, requireFn);
65+
// `estree-util-to-js` with the `jsx` handler converts the AST nodes into a string
66+
// that represents the equivalent JavaScript code, including JSX syntax.
67+
const { value: code } = toJs(entry, { handlers: jsx });
6068

61-
// `buildClientProgram` prepares the JSX-derived code for client-side execution.
62-
// `bundleCode` then bundles this client-side code, resolving imports and
63-
// potentially generating associated CSS. This bundle will hydrate the SSR content.
64-
const clientCode = buildClientProgram(code);
69+
// `buildServerProgram` takes the JSX-derived code and prepares it for server execution.
70+
serverCodeMap.set(fileName, buildServerProgram(code));
6571

66-
const clientBundle = await bundleCode(clientCode);
72+
// `buildClientProgram` prepares the JSX-derived code for client-side execution.
73+
clientCodeMap.set(fileName, buildClientProgram(code));
74+
}
75+
76+
// Execute all server code at once
77+
const dehydratedMap = await executeServerCode(serverCodeMap, requireFn);
78+
79+
// Bundle all client code at once
80+
const clientBundle = await bundleCode(clientCodeMap);
6781

6882
// Rolldown's experimental.chunkImportMap generates the import map automatically
6983
// The import map is extracted by our plugin and returned as HTML
@@ -77,22 +91,31 @@ export async function processJSXEntry(
7791
hash: '', // No need for manual hashing, Rolldown handles cache busting via importMap
7892
}));
7993

80-
const title = `${entry.data.heading.data.name} | Node.js v${version} Documentation`;
94+
// Process each entry to create HTML
95+
const results = entries.map(entry => {
96+
const fileName = `${entry.data.api}.jsx`;
97+
const dehydrated = dehydratedMap.get(fileName);
98+
const mainJsCode = clientBundle.jsMap[fileName];
99+
100+
const title = `${entry.data.heading.data.name} | Node.js v${version} Documentation`;
101+
102+
// Replace template placeholders with actual content
103+
const renderedHtml = template
104+
.replace('{{title}}', title)
105+
.replace('{{dehydrated}}', dehydrated ?? '')
106+
.replace('{{importMap}}', importMapScript)
107+
.replace('{{mainJsCode}}', () => mainJsCode);
81108

82-
// Replace template placeholders with actual content
83-
const renderedHtml = template
84-
.replace('{{title}}', title)
85-
.replace('{{dehydrated}}', dehydrated ?? '')
86-
.replace('{{importMap}}', importMapScript)
87-
.replace('{{mainJsCode}}', () => clientBundle.js);
109+
// The input to `minify` must be a Buffer.
110+
const finalHTMLBuffer = HTMLMinifier.minify(Buffer.from(renderedHtml), {});
88111

89-
// The input to `minify` must be a Buffer.
90-
const finalHTMLBuffer = HTMLMinifier.minify(Buffer.from(renderedHtml), {});
112+
return { html: finalHTMLBuffer, api: entry.data.api };
113+
});
91114

92115
// Return the generated HTML, CSS, and any JS chunks from code splitting
93116
// Note: main JS is inlined in HTML, so we don't return it separately
94117
return {
95-
html: finalHTMLBuffer,
118+
results,
96119
css: clientBundle.css,
97120
jsChunks: chunksWithHashes,
98121
};

0 commit comments

Comments
 (0)