Skip to content

Instantly share code, notes, and snippets.

@hyfdev
Created May 22, 2026 13:43
Show Gist options
  • Select an option

  • Save hyfdev/4e4f1047d8380a820d7cddb945cd29ab to your computer and use it in GitHub Desktop.

Select an option

Save hyfdev/4e4f1047d8380a820d7cddb945cd29ab to your computer and use it in GitHub Desktop.
ClickUp Vite-direct build executor — rolldown chunking config (see https://gist.github.com/hyf0/93d53718780efc57019cfe7d5e7fc1c4)
import { ExecutorContext } from '@nx/devkit';
import browserslist from 'browserslist';
import browserslistToEsbuild from 'browserslist-to-esbuild';
import assert from 'node:assert';
import path from 'node:path';
import { build } from 'vite';
import { getBuildTargetConfig } from '@cu/nx-plugin-bundler/common/nx/target-config/get-target-config';
import { getPlugins } from '../../common/get-plugins';
import { BuildExecutorSchema } from './schema';
// =============================================================================
// CHUNKING SIZE BUDGETS (source bytes)
// =============================================================================
// Rolldown's minSize/maxSize operate on *source* bytes — not transferred
// (brotli'd) bytes. That gap is unavoidable: the bundler decides chunk
// assignment *before* tree-shake / minify / brotli, so the only signal it has
// is pre-resolution source size. Every bundler (webpack, vite, rollup,
// rolldown) inherits the same constraint.
//
// To map our brotli'd target back to a source-byte threshold, we apply two
// factors: a typical compression ratio (median JS source → brotli ≈ 5×, per
// the DoHost benchmark of 500 KB raw → 112 KB brotli q11) and a tree-shake
// safety factor (heavy-DCE packages can reach 20-80×; e.g. @floating-ui/react
// at 187 KB source → 2.3 KB brotli = 78×).
//
// Without the safety factor, packages whose tree-shaken output is tiny still
// pass the threshold and emit as wasteful 2-5 KB brotli'd chunks. With the
// factor, we bias the heuristic toward "skip this package" when in doubt —
// the fall-through (scope-merge or catch-all) is a cheaper failure than
// emitting a chunk that costs more in HTTP overhead than it carries.
//
// Calibrated against research:
// • Calibre / Alex Russell : ≤ 50 KB compressed per chunk for HTTP/2
// parallel download to remain useful; total compressed page budget
// ~300-365 KB.
// • web.dev (Google + Next.js) : 25 parallel initial requests had no
// measurable cost; >160 KB source warrants own chunk; performance
// degradation begins "only at hundreds of requests".
// =============================================================================
/** Target post-brotli chunk size — Calibre's "worth a parallel request" floor. */
const BROTLI_TARGET_PER_CHUNK = 20 * 1024;
/**
* Typical source-to-brotli compression ratio for JS. From DoHost's real-world
* benchmark of JS bundles: 500 KB raw → 112 KB brotli (q11) = 4.46×. Rounded
* up to 5× for headroom.
*/
const COMPRESSION_RATIO_TYPICAL = 5;
/**
* Safety factor for heavy tree-shaking. Some packages ship large source but
* only export a small useful API; when only a couple of exports are used,
* DCE strips most of the source. Observed in this codebase:
* - @floating-ui/react : 187 KB source → 2.3 KB brotli = 78× ratio
* - @auth0/angular-jwt : 10 KB source → 1.8 KB brotli = 5.8× ratio
*
* A 3× safety pad on top of the typical ratio gives roughly 90% confidence
* that a chunk passing the threshold will land ≥ 20 KB brotli'd. Higher
* values bias toward fewer-but-larger chunks; lower values toward more
* per-package granularity (and a longer tail of tiny chunks).
*/
const TREE_SHAKE_SAFETY_FACTOR = 3;
/**
* Minimum source size for a package to deserve its own per-package chunk
* at tier 1.
*
* = 20 KB × 5 × 3 = 300 KB source. Only genuinely large packages clear this.
* @angular/core (~500 KB source) keeps its chunk; @floating-ui/react
* (187 KB) falls to tier 2 scope-merge.
*/
const PACKAGE_OWN_CHUNK_MIN_SOURCE =
BROTLI_TARGET_PER_CHUNK * COMPRESSION_RATIO_TYPICAL * TREE_SHAKE_SAFETY_FACTOR;
/**
* Minimum source size for a scope-merged chunk (tier 2) to emit.
*
* = 20 KB × 5 = 100 KB source. No safety factor here — by the time we reach
* tier 2, modules from multiple scoped packages aggregate into one chunk,
* which smooths over per-package DCE variance. The typical compression
* ratio alone is enough.
*
* Below this, a scope with too few leftovers falls through to tier 3
* catch-all rather than emit a tiny named chunk.
*/
const SCOPE_MERGE_MIN_SOURCE =
BROTLI_TARGET_PER_CHUNK * COMPRESSION_RATIO_TYPICAL;
/**
* Per-chunk ceiling: above this, rolldown should split the chunk into pieces
* for parallel download.
*
* 1 MB source ≈ 200 KB brotli'd. Below Calibre's "starts to hurt parallelism"
* zone (>500 KB brotli'd). Caps things like our 750 KB brotli'd univerjs
* chunks, splitting them into 3-4 pieces each loadable in parallel.
*/
const CHUNK_MAX_SOURCE = 1024 * 1024;
// =============================================================================
// HELPERS — extracting package and scope names from a resolved module id
// =============================================================================
/**
* Extracts the npm package name from a node_modules path, handling pnpm's
* virtual store layout. Returns null if the id is not under node_modules.
*
* We take the LAST match because pnpm's virtual store nests the real package
* under a second node_modules/. The first match would yield '.pnpm'.
*/
function packageName(id: string): string | null {
const m = [...id.matchAll(/node_modules\/(@[^/]+\/[^/]+|[^/]+)/g)];
if (m.length === 0) return null;
return m[m.length - 1][1].replace(/\//g, '-');
}
/**
* Extracts the npm scope (the '@scope' prefix) from a node_modules path.
* Returns null for unscoped packages, which causes those modules to skip the
* scope-merge tier and fall to the catch-all.
*
* Why only @-scoped packages: we have no reliable signal that 'lodash' and
* 'lodash.isequal' belong together (they're separate npm packages, even
* though same author). Only the official @-scope is unambiguous.
*/
function scopeName(id: string): string | null {
const m = [...id.matchAll(/node_modules\/(@[^/]+\/[^/]+|[^/]+)/g)];
if (m.length === 0) return null;
const last = m[m.length - 1][1];
if (!last.startsWith('@')) return null;
return last.split('/')[0].slice(1); // '@angular' → 'angular'
}
export default async function runExecutor(
options: BuildExecutorSchema,
context: ExecutorContext,
): Promise<{ success: boolean }> {
const projectName =
context.projectName ?? context.nxJsonConfiguration?.defaultProject;
assert(projectName, 'Could not find project name');
const projectConfig = context.projectsConfigurations?.projects[projectName];
assert(
projectConfig,
`Could not find project configuration for ${projectName}`,
);
const buildTargetConfig = {
...getBuildTargetConfig(
options.buildTarget,
options.configurationName,
context,
),
// Apply any options passed in via cli args
...options,
};
const outputPath =
typeof buildTargetConfig.outputPath === 'object'
? buildTargetConfig.outputPath.browser
: buildTargetConfig.outputPath;
const minifyScripts =
(typeof buildTargetConfig.optimization === 'boolean' &&
buildTargetConfig.optimization) ||
(typeof buildTargetConfig.optimization === 'object' &&
buildTargetConfig.optimization.scripts === true);
const minifyStyles =
(typeof buildTargetConfig.optimization === 'boolean' &&
buildTargetConfig.optimization) ||
(typeof buildTargetConfig.optimization === 'object' &&
(buildTargetConfig.optimization.styles === true ||
(typeof buildTargetConfig.optimization.styles === 'object' &&
buildTargetConfig.optimization.styles.minify === true)));
const sourcemap =
buildTargetConfig.sourceMap &&
typeof buildTargetConfig.sourceMap === 'object'
? buildTargetConfig.sourceMap.hidden &&
buildTargetConfig.sourceMap.scripts !== false
? 'hidden'
: (buildTargetConfig.sourceMap.scripts ?? true)
: !!buildTargetConfig.sourceMap;
const result = await build({
configFile: false,
root: projectConfig.sourceRoot,
plugins: [
...getPlugins(
'build',
buildTargetConfig,
context.root,
{ name: projectName, sourceRoot: projectConfig.sourceRoot },
{
rtl: process.env.RTL === '1',
useAngularTypescriptCompiler: false,
disableTypeChecking: process.env.NG_BUILD_TYPE_CHECK === '0',
},
),
{
name: 'set-base-tag',
transformIndexHtml: {
order: 'post',
handler(html) {
return html.replace(
'<base href="/">',
`<base href="${buildTargetConfig.baseHref ?? '/'}">`,
);
},
},
},
],
base: '',
build: {
target: browserslistToEsbuild(
browserslist.loadConfig({
path: path.join(context.root, projectConfig?.root),
}),
),
modulePreload: false,
outDir: path.join(context.root, outputPath ?? '', 'browser'),
emptyOutDir: buildTargetConfig.deleteOutputPath ?? true,
assetsDir: 'statics',
cssMinify: minifyStyles,
sourcemap,
// Compressing large output files can be slow, so disabling to improve build performance
reportCompressedSize: false,
rolldownOptions: {
tsconfig: path.join(context.root, buildTargetConfig.tsConfig),
// experimental.chunkOptimization is true by default — rolldown's
// post-emission `mergeCommonChunks` / `avoidRedundantChunkLoads`
// passes are enabled. Previously set to false during the alias-leak
// diagnostic; re-enabled in V4.1.
output: {
minify: minifyScripts
? process.env.NG_BUILD_MANGLE !== 'false'
? true
: {
compress: true,
// Disable mangling of variable and class names for better debugging
mangle: false,
codegen: true,
}
: 'dce-only',
// Needed for codeSplitting to work, otherwise we get crashes at runtime
strictExecutionOrder: true,
// =================================================================
// codeSplitting — three vendor tiers × two reachability tracks
// =================================================================
// Each vendor group has two variants:
// - "-initial" variant: tags=['$initial']. Captures only modules
// statically reachable from a user-defined entry. Keeps the
// initial-page bundle stable across deploys and away from lazy
// code.
// - "-async" variant: no tags filter. Captures any vendor module
// (including $initial ones if they outlast the initial track).
//
// **Critical priority invariant**: ALL initial-track groups must
// outrank ALL async-track groups, not just at the same tier level.
//
// A module that is reached both statically AND dynamically is
// $initial-tagged. It matches BOTH initial-tier groups (because of
// the tags filter) AND async-tier groups (because they don't filter
// by tag). If an async tier outranks an initial tier and that async
// tier emits (e.g., its per-package chunk clears minSize), it yanks
// the $initial-tagged modules out of the initial chunks they should
// have anchored. Concretely: with tier-1-async (22) > tier-3-initial
// (16) in an earlier revision, a $initial @angular subpackage that
// failed tier 1's 300 KB minSize but is reachable both ways would
// get pulled into a vendor-{scope}-async chunk instead of staying
// in vendor-common-initial.
//
// So the priority chain is fully initial-first:
// 30 tier 1 initial · per-package
// 26 tier 2 initial · scope-merge
// 22 tier 3 initial · catch-all ← MUST outrank all async
// 18 tier 1 async · per-package
// 14 tier 2 async · scope-merge
// 10 tier 3 async · catch-all
// 6 initial-deps · app initial-graph split
//
// Within each track, order is: per-package → scope-merge → catch-
// all. Across tracks, every initial tier beats every async tier.
// =================================================================
codeSplitting: {
// Required to keep tsconfig-aliased app code out of vendor
// chunks. See vite-migration-study.md for the alias-leak repro.
includeDependenciesRecursively: false,
// Groups are listed in DESCENDING priority order to match the
// order rolldown actually pops them off the priority queue. This
// makes it easy to read the cascade top-to-bottom and see what
// each module falls through to next.
groups: [
// -----------------------------------------------------------
// priority 30 — Tier 1 initial · per-package
// -----------------------------------------------------------
// Big libraries (source ≥ PACKAGE_OWN_CHUNK_MIN_SOURCE) that
// are statically reachable from an entry get their own chunk.
// Most cache-friendly tier: a patch release of one package
// invalidates only its own chunk. minSize floor keeps the
// brotli'd output reasonable (recall e.g. 0.4 KB
// vendor-@angular-localize observed under the old 30 KB
// threshold).
{
name: (id: string) => {
const p = packageName(id);
return p ? `vendor-${p}` : null;
},
tags: ['$initial'],
test: /\/node_modules\//,
minSize: PACKAGE_OWN_CHUNK_MIN_SOURCE,
priority: 30,
},
// -----------------------------------------------------------
// priority 26 — Tier 2 initial · @scope-merge
// -----------------------------------------------------------
// For @-scoped packages whose individual size didn't clear
// tier 1's threshold, merge into a single chunk per scope.
// E.g. all @angular/* leftovers land in `vendor-angular`.
// maxSize forces splitting if a scope-merged chunk would
// exceed it (important for big scopes like @univerjs/*).
// Unscoped packages return null from scopeName() and skip
// this tier, falling to tier 3.
{
name: (id: string) => {
const s = scopeName(id);
return s ? `vendor-${s}` : null;
},
tags: ['$initial'],
test: /\/node_modules\//,
minSize: SCOPE_MERGE_MIN_SOURCE,
maxSize: CHUNK_MAX_SOURCE,
priority: 26,
},
// -----------------------------------------------------------
// priority 22 — Tier 3 initial · catch-all
// -----------------------------------------------------------
// Final stop for $initial vendor modules: unscoped packages,
// and scoped packages that didn't form a large-enough scope
// group. No minSize — this is the floor; whatever made it
// here ships.
//
// Critical: this priority MUST outrank all async-track
// priorities below. A module reached both statically and
// dynamically is $initial-tagged AND matches the async groups
// (which have no tags filter). Without this ordering, async
// groups would yank the module into a vendor-{pkg}-async or
// vendor-{scope}-async chunk, even though the module has to
// load on the initial page anyway.
{
name: 'vendor-common-initial',
tags: ['$initial'],
test: /\/node_modules\//,
priority: 22,
},
// -----------------------------------------------------------
// priority 18 — Tier 1 async · per-package
// -----------------------------------------------------------
// Same shape as initial tier 1, no $initial filter. Captures
// any lazily-imported package whose source ≥ minSize. This is
// where the big editor libs live: @univerjs/*, mermaid, gojs,
// tldraw, etc.
{
name: (id: string) => {
const p = packageName(id);
return p ? `vendor-${p}-async` : null;
},
test: /\/node_modules\//,
minSize: PACKAGE_OWN_CHUNK_MIN_SOURCE,
priority: 18,
},
// -----------------------------------------------------------
// priority 14 — Tier 2 async · @scope-merge
// -----------------------------------------------------------
// Mirror of initial tier 2 for async-only @-scoped leftovers.
{
name: (id: string) => {
const s = scopeName(id);
return s ? `vendor-${s}-async` : null;
},
test: /\/node_modules\//,
minSize: SCOPE_MERGE_MIN_SOURCE,
maxSize: CHUNK_MAX_SOURCE,
priority: 14,
},
// -----------------------------------------------------------
// priority 10 — Tier 3 async · catch-all
// -----------------------------------------------------------
// Final stop for async vendors. Plain for now (no maxSize, no
// entriesAware) — those knobs are deferred until we see the
// output shape warrant them.
{
name: 'vendor-common-async',
test: /\/node_modules\//,
priority: 10,
},
// -----------------------------------------------------------
// priority 6 — initial-deps · app-side initial-graph split
// -----------------------------------------------------------
// All modules statically reachable from an entry point that
// DIDN'T match any vendor group above (i.e. app source).
//
// minSize: rolldown auto-splits this group by reachability
// bitset, so unique-bitset code paths can emit as tiny chunks
// (observed 1.9 KB brotli'd). Below this floor, rolldown
// merges the crumb into a sibling — request overhead beats
// any per-entry over-fetch on a ~30 KB source crumb.
//
// maxSize: looser than the vendor cap. Once minSize starts
// folding crumbs together, we want headroom for the merged
// chunk to grow before a second split kicks in.
{
name: 'initial-deps',
tags: ['$initial'],
minSize: 30 * 1024,
maxSize: 3 * 1024 * 1024,
priority: 6,
},
// Layer 3 (app-code) is NOT manually grouped. The V4.x
// investigation explored several approaches (entriesAware,
// shared, B-style common, maxModuleSize filters) and
// documented each in vite-migration-study.md → "Layer 3
// investigation".
//
// The shippable conclusion is: no Layer 3 manual group is
// safe to add. Rolldown's `experimental.chunkOptimization`
// (true by default) is the right consolidation mechanism —
// it operates post-emission on the actual chunk graph,
// which avoids the over-fetch / duplication problems every
// manual approach we tried hit.
],
},
},
},
},
});
return {
success: true,
};
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment