Created
May 22, 2026 13:43
-
-
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)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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