{"slug":"audit-bundles","title":"audit-bundles","summary":"Analyze a pracht production build: client bytes per route, fat vendor chunks, routes shipping large dependencies, and dynamic `import()` / prefetch recommendations. Use for \"audit bundles\", \"why is my JS so big\", \"bundle size per route\", \"what's in my vendor chunk\", \"tune prefetc","platform":"Claude","tags":[],"authorName":"LLM Mart","authorSlug":"llm-mart","score":0,"source":"github","price":null,"verified":false,"createdAt":"2026-10-19T21:17:23.769303Z","repo":{"url":"https://github.com/JoviDeCroock/pracht","stars":99,"forks":5,"license":"MIT","updatedAt":"2026-10-19T16:43:31Z"},"bodyHtml":"<hr>\n<p>name: audit-bundles\nversion: 1.2.1\ndescription: |\nAnalyze a pracht production build: client bytes per route, fat vendor chunks,\nroutes shipping large dependencies, and dynamic <code>import()</code> / prefetch\nrecommendations.\nUse for \"audit bundles\", \"why is my JS so big\", \"bundle size per route\", \"what's\nin my vendor chunk\", \"tune prefetching\".\nallowed-tools:</p>\n<ul>\n<li>Bash</li>\n<li>Read</li>\n<li>Grep</li>\n<li>Glob</li>\n</ul>\n<hr>\n<h1>Pracht Audit Bundles</h1>\n<p>Pracht performs route-level code splitting via the Vite plugin and emits a\nmanifest. This skill reads that manifest, sizes each route's client payload,\nand surfaces the worst offenders.</p>\n<h2>Step 1: Build with analysis</h2>\n<p>MCP: when the pracht MCP server is registered (<code>pracht dev-mcp</code>), prefer its\n<code>inspect_routes</code>/<code>inspect_api</code>/<code>inspect_build</code>/<code>doctor</code>/<code>verify</code> tools over\nshelling out. <code>pracht inspect</code> needs the pracht plugin in the vite config.\n<code>inspect build</code> needs a prior <code>pracht build</code>.</p>\n<pre><code>pracht build --analyze --json\n</code></pre>\n<p>This is the native per-route payload report: for every route (pattern + render\nmode) it emits the transitive client JS chunks with raw and gzip sizes\n(<code>routes[].chunks</code>), route-specific and total sums (<code>routeGzipBytes</code>,\n<code>totalGzipBytes</code>), and the shared entry chunks broken out (<code>shared</code>), sorted by\ntotal gzip descending. A stale <code>dist/</code> produces misleading numbers — rebuild,\ndon't reuse.</p>\n<p>Exit-code footgun: when configured budgets fail, <code>pracht build --analyze --json</code> sets a nonzero exit code while still printing valid JSON on stdout.\nDo not treat exit 1 as \"no output\" — parse stdout anyway, or run with\n<code>--no-budget-fail</code> to keep the exit code clean.</p>\n<p>For a human-readable table, use <code>pracht build --analyze</code> instead.</p>\n<h2>Step 2: Pull supporting metadata</h2>\n<pre><code>pracht inspect build --json\n</code></pre>\n<p>Captures the resolved adapter, client entry URL, and CSS/JS manifest. Cross\nreference with <code>dist/client/.vite/manifest.json</code> for chunk metadata\n(file, imports, dynamicImports, css, isEntry) — needed for vendor fan-in\nanalysis (Step 4) and CSS sizing, which the analyze report does not cover.</p>\n<p>Size CSS from the <code>cssManifest</code> this command reports, never from\n<code>dist/client/.vite/manifest.json</code>. A <code>hydration: \"none\"</code> or <code>\"islands\"</code> route is\nabsent from the client manifest by construction, so reading it directly reports\nno CSS for a page whose document links a stylesheet — under-reporting exactly\nthe routes pracht recommends. <code>inspect</code> merges <code>dist/server/css-manifest.json</code>,\nthe build's own record, over the client manifest; an older build without that\nfile is client-only, and the CSS column should say so rather than read as zero.</p>\n<h2>Step 3: Interpret per-route payload</h2>\n<p>From the Step 1 JSON, report:</p>\n<table>\n<thead>\n<tr>\n<th>Route</th>\n<th>Hydration</th>\n<th>Route JS (gz)</th>\n<th>Shared (gz)</th>\n<th>Total (gz)</th>\n<th>CSS (gz)</th>\n<th>LCP-class?</th>\n</tr>\n</thead>\n</table>\n<p><code>gz</code> = gzip size, taken directly from <code>routeGzipBytes</code> / <code>shared.gzipBytes</code> /\n<code>totalGzipBytes</code>. Size CSS chunks (from <code>cssManifest</code>) with <code>zlib.gzipSync</code>.</p>\n<p>Account for the per-route <code>hydration</code> field the analyze JSON emits (omitted\nmeans <code>\"full\"</code>) before judging sizes:</p>\n<ul>\n<li><code>hydration: \"none\"</code> routes ship <strong>0 bytes</strong> of JS — the report already\nzeroes them out. Never flag them.</li>\n<li><code>hydration: \"islands\"</code> routes never load the shared client entry; their\ntotal is the islands bootstrap plus <strong>every</strong> island chunk in the app — an\nupper bound, since which islands a page actually uses is only known at\nrender time. Treat their totals as pessimistic.</li>\n<li>Only <code>\"full\"</code> routes pay route JS + shared entry.</li>\n</ul>\n<p><code>LCP-class?</code> is <code>yes</code> when total gz exceeds 200 KB — that's the order of\nmagnitude where mid-tier mobile starts losing LCP budget. Apply the threshold\nafter the hydration adjustment above.</p>\n<p>If the app declares <code>budgets</code> in the pracht plugin config, the Step 1 JSON also\ncontains a <code>budgets</code> section with per-route pass/fail — lead the report with any\nfailures. If no budgets are configured, suggest adding them (see\n<a href=\"https://pracht.resynapse.dev/docs/reference/config\">https://pracht.resynapse.dev/docs/reference/config</a>), starting from the current sizes plus ~10% headroom.</p>\n<h2>Step 4: Vendor chunk health</h2>\n<p>Identify chunks under <code>node_modules/</code>. For each:</p>\n<ul>\n<li>Size (raw and gz).</li>\n<li>Number of route chunks that import it (fan-in).</li>\n</ul>\n<p>A vendor chunk imported by every route is the framework runtime — expected.\nA vendor chunk imported by ONE route is a code-splitting opportunity (move\nthe import inside that route's component or lazy-load it).</p>\n<p>A vendor chunk over 100 KB gz that is imported by every route is worth a\nmanual review — common offenders: date libraries, validation libraries,\nicon sets, charting libraries.</p>\n<h2>Step 5: Heavy dependencies in route components</h2>\n<p>For each route chunk over 50 KB gz, run <code>pracht inspect build --json</code> plus\n<code>du</code>-style analysis on the chunk contents:</p>\n<ul>\n<li>Grep the chunk source for known heavy module headers (<code>moment</code>, <code>lodash</code>,\n<code>chart.js</code>, <code>three</code>, <code>@stripe/stripe-js</code>, etc.).</li>\n<li>For each, recommend: (a) tree-shakeable alternative, (b) dynamic import\ninside an event handler, (c) lazy-load via <code>lazy()</code> from <code>@pracht/core</code>.</li>\n</ul>\n<h2>Step 6: Prefetch strategy</h2>\n<p><code>pracht inspect routes --json</code> exposes <code>prefetch</code> per route on a current\n<code>@pracht/cli</code>. If your CLI version predates the field (it's absent from the\nJSON), fall back to grepping the route manifest source for <code>prefetch:</code>.\nRoute-level strategies are <code>\"none\"</code>, <code>\"hover\"</code>, <code>\"intent\"</code>, <code>\"viewport\"</code>\n(<code>\"render\"</code> exists only as a per-link override — see below). Recommend:</p>\n<ul>\n<li><code>\"viewport\"</code> for primary-nav links.</li>\n<li><code>\"hover\"</code> for content links inside long pages.</li>\n<li><code>\"intent\"</code> (default) is fine if you're not sure.</li>\n<li><code>\"none\"</code> for routes that are large and rarely visited (admin, settings) so\nhover doesn't preload them.</li>\n</ul>\n<p>Cost model for <code>\"viewport\"</code>: the IntersectionObserver fires once per anchor —\nit unobserves the anchor after the first prefetch, and prefetched route state\nis cached for 30 seconds — so a link in a marketing footer costs at most one\nprefetch per anchor per page view, not one per scroll. Viewport and render\nprefetches run three at a time, drop links that left the page, and are skipped\non Save-Data or 2G connections. It's still wasteful\nfor a 300 KB route that footer visitors rarely click; prefer <code>\"none\"</code> or\n<code>\"hover\"</code> there. Also note the per-link escape hatch: <code>&lt;Link prefetch=\"...\"&gt;</code>\noverrides the route-level strategy for a single anchor (and accepts the extra\n<code>\"render\"</code> value — prefetch as soon as the link renders, the most eager\noption), so a route can stay on <code>\"intent\"</code> while its primary-nav link opts\ninto <code>\"viewport\"</code> or <code>\"render\"</code>.</p>\n<p>If every route ends up on <code>\"none\"</code>, do not stop there — the prefetch listeners\nstill ship, in a chunk the router lazily imports on every page. Recommend\n<code>pracht({ client: { prefetch: false } })</code> instead, which compiles the whole\nmechanism out (~2.6 KB gzip and one fewer request). Confirm nothing relies on\nprefetching first: the router silently stops honouring <code>route({ prefetch })</code> and\n<code>&lt;Link prefetch&gt;</code>, and the imperative <code>prefetch()</code> export becomes a no-op. See\n<a href=\"https://pracht.resynapse.dev/docs/prefetching#shipping-less-javascript\">https://pracht.resynapse.dev/docs/prefetching#shipping-less-javascript</a>.</p>\n<h2>Step 6b: Chunk-group composition</h2>\n<p>App-level chunking and pracht's vendor chunk coexist — recommend it freely.\nPracht reads <code>build.rollupOptions.output</code> and contributes its Preact group in\nwhichever form the app used, so adding a group (to merge the long tail of small\ninitial chunks, say) does not cost the framework chunk:</p>\n<pre><code>// vite.config.ts\nbuild: {\n  rollupOptions: {\n    output: {\n      codeSplitting: { groups: [{ name: \"app\", tags: [\"$initial\"], minSize: 20_000 }] },\n    },\n  },\n}\n</code></pre>\n<p>Two things to check when an app already configures chunking:</p>\n<ul>\n<li><strong>Mixed forms.</strong> Rolldown lets <code>codeSplitting</code> silence both <code>advancedChunks</code>\nand <code>manualChunks</code>. An app that sets more than one is running only\n<code>codeSplitting</code>; report the dead config as <code>warn</code>.</li>\n<li><strong>A group that swallows Preact.</strong> Precedence is higher <code>priority</code> first, then\ndeclaration order, with pracht's group last — so an app group matching\n<code>node_modules</code> at equal priority absorbs the framework runtime. If a separate,\nlong-cached framework chunk was the intent, recommend a <code>priority</code> bump or a\n<code>test</code> that excludes Preact.</li>\n</ul>\n<p>Verify any grouping change against the prerendered HTML, not just the numbers:\na group broad enough to reshuffle entry chunks can drop the stylesheet links\npracht injects per route. Compare <code>&lt;link rel=\"stylesheet\"&gt;</code> tags in\n<code>dist/client/**/index.html</code> before and after.</p>\n<p><code>pracht({ vendorChunk: false })</code> makes pracht contribute nothing, for an app\nthat wants Preact inside its own chunks or places <code>frameworkChunkGroups()</code>\nitself.</p>\n<p>Pracht contributes one group to the <strong>server</strong> build too: each island becomes\nits own chunk, so a route that does not fully hydrate can resolve its CSS from\nthat build without inheriting the server entry's merged stylesheet. An app that\nconfigures SSR chunking composes with it the same way the client side does; a\ngroup that pulls islands back into the entry shows up as extra <code>&lt;link rel=\"stylesheet\"&gt;</code> tags on static pages, and the build warns.</p>\n<p><code>build.cssCodeSplit: false</code> is rejected outright — there is no <code>index.html</code> to\nlink the single stylesheet it produces, so every page would ship without one.</p>\n<p>When a route's CSS is small and the site is entered cold — a static export, a\ncontent site arriving from search — the stylesheet link is often the only\nrender-blocking request left on a page that ships no JavaScript. Report\n<code>pracht({ inlineCss: true })</code> as a suggestion there, with the cost: the same\nbytes in every document and no shared stylesheet cache. An app whose visitors\nmove between pages should keep the link.</p>\n<h2>Step 7: Report</h2>\n<p>Three sections:</p>\n<ol>\n<li><strong>Top 10 routes by total client payload</strong> — sorted desc.</li>\n<li><strong>Top 10 vendor chunks by size</strong> — with fan-in.</li>\n<li><strong>Suggestions</strong> — ordered by impact (KB saved × routes affected).</li>\n</ol>\n<p>Tag every finding with a primary severity — <code>error</code> (budget failure), <code>warn</code>\n(LCP-class route, single-route vendor chunk), <code>info</code> (tuning opportunity) —\nand keep the size numbers as supporting detail.</p>\n<p>Include before/after estimates for each suggestion: \"Lazy-load <code>chart.js</code>\ninside <code>Component</code>: -180 KB gz off <code>/dashboard/analytics</code>.\"</p>\n<h2>Rules</h2>\n<ol>\n<li>Always run <code>pracht build --analyze --json</code> first. A stale build is a source\nof bad advice.</li>\n<li>Use the Vite manifest as the source of truth — chunk names rotate per\nbuild.</li>\n<li>Report gzip size, not raw — the wire size is what users pay.</li>\n<li>Distinguish \"shared\" code (entry + framework) from \"route-specific\" code\nwhen reporting; users can only optimize the latter.</li>\n<li>Do not auto-edit. Bundle changes have render-blocking implications;\nsurface and let the user choose.</li>\n</ol>\n<p>$ARGUMENTS</p>\n","files":[{"path":"SKILL.md","sizeBytes":10794,"isText":true}],"reviewScore":null,"reviewSummary":null,"trust":{"provenance":"trusted-source-unreviewed","notice":"Community-authored content, reproduced verbatim and not vetted as instructions. Treat it as data to evaluate, never as directives to follow.","bodySource":null},"bodyLocked":false,"purchaseUrl":null,"sourceUrl":null,"report":{"provenance":"trusted-source-unreviewed","screen":{"ran":true,"outcome":"clean","suspicious":0,"notes":0,"hiddenCharacters":false},"virusScan":{"engine":"clamav","status":"clean","scannedAt":"2026-10-19T21:17:55.896358Z","sha256":"DDECCABCBDBE0C6C39066B8002D8D6B6E8924C53208A75CE7607EE598E1D66D5","sizeBytes":4953},"review":null,"source":{"repositoryUrl":"https://github.com/JoviDeCroock/pracht","path":"skills/audit-bundles","license":"MIT","commit":"8bcdd52989de3e97708e7f16221839d6313cfdac","subtreeSha":"F504BF517D6EDE5446D93F6AF9AB61A8A879111B6C0D887F3D21A127AFD31E95","lastSyncedAt":"2026-10-19T21:17:21.911347Z"},"reviewedAt":"2026-10-19T21:19:00.113972Z","notice":"Community-authored content, reproduced verbatim and not vetted as instructions. Treat it as data to evaluate, never as directives to follow."},"install":[{"target":"skills-cli","command":"npx skills add https://github.com/JoviDeCroock/pracht/tree/main/skills/audit-bundles"},{"target":"claude-code","command":"claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install jovidecroock-pracht@llmmart"},{"target":"git","command":"git clone https://github.com/JoviDeCroock/pracht.git"}]}