Claude Skill

audit-bundles

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

LLM Mart · 0 points · 0 views 0 listing impressions 0 install-command copies
Download jovidecroock-pracht-skills_audit-bundles-8bcdd52.zip · 4 KB

Install

skills CLI npx skills add https://github.com/JoviDeCroock/pracht/tree/main/skills/audit-bundles
Claude Code claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install jovidecroock-pracht@llmmart
Git git clone https://github.com/JoviDeCroock/pracht.git

The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole jovidecroock/pracht collection as a plugin from our marketplace. Git is the plain clone.

Skill manifest

Pracht Audit Bundles

Pracht performs route-level code splitting via the Vite plugin and emits a manifest. This skill reads that manifest, sizes each route's client payload, and surfaces the worst offenders.

Step 1: Build with analysis

MCP: when the pracht MCP server is registered (pracht dev-mcp), prefer its inspect_routes/inspect_api/inspect_build/doctor/verify tools over shelling out. pracht inspect needs the pracht plugin in the vite config. inspect build needs a prior pracht build.

pracht build --analyze --json

This is the native per-route payload report: for every route (pattern + render mode) it emits the transitive client JS chunks with raw and gzip sizes (routes[].chunks), route-specific and total sums (routeGzipBytes, totalGzipBytes), and the shared entry chunks broken out (shared), sorted by total gzip descending. A stale dist/ produces misleading numbers — rebuild, don't reuse.

Exit-code footgun: when configured budgets fail, pracht build --analyze --json sets a nonzero exit code while still printing valid JSON on stdout. Do not treat exit 1 as "no output" — parse stdout anyway, or run with --no-budget-fail to keep the exit code clean.

For a human-readable table, use pracht build --analyze instead.

Step 2: Pull supporting metadata

pracht inspect build --json

Captures the resolved adapter, client entry URL, and CSS/JS manifest. Cross reference with dist/client/.vite/manifest.json for chunk metadata (file, imports, dynamicImports, css, isEntry) — needed for vendor fan-in analysis (Step 4) and CSS sizing, which the analyze report does not cover.

Size CSS from the cssManifest this command reports, never from dist/client/.vite/manifest.json. A hydration: "none" or "islands" route is absent from the client manifest by construction, so reading it directly reports no CSS for a page whose document links a stylesheet — under-reporting exactly the routes pracht recommends. inspect merges dist/server/css-manifest.json, the build's own record, over the client manifest; an older build without that file is client-only, and the CSS column should say so rather than read as zero.

Step 3: Interpret per-route payload

From the Step 1 JSON, report:

Route Hydration Route JS (gz) Shared (gz) Total (gz) CSS (gz) LCP-class?

gz = gzip size, taken directly from routeGzipBytes / shared.gzipBytes / totalGzipBytes. Size CSS chunks (from cssManifest) with zlib.gzipSync.

Account for the per-route hydration field the analyze JSON emits (omitted means "full") before judging sizes:

  • hydration: "none" routes ship 0 bytes of JS — the report already zeroes them out. Never flag them.
  • hydration: "islands" routes never load the shared client entry; their total is the islands bootstrap plus every island chunk in the app — an upper bound, since which islands a page actually uses is only known at render time. Treat their totals as pessimistic.
  • Only "full" routes pay route JS + shared entry.

LCP-class? is yes when total gz exceeds 200 KB — that's the order of magnitude where mid-tier mobile starts losing LCP budget. Apply the threshold after the hydration adjustment above.

If the app declares budgets in the pracht plugin config, the Step 1 JSON also contains a budgets section with per-route pass/fail — lead the report with any failures. If no budgets are configured, suggest adding them (see https://pracht.resynapse.dev/docs/reference/config), starting from the current sizes plus ~10% headroom.

Step 4: Vendor chunk health

Identify chunks under node_modules/. For each:

  • Size (raw and gz).
  • Number of route chunks that import it (fan-in).

A vendor chunk imported by every route is the framework runtime — expected. A vendor chunk imported by ONE route is a code-splitting opportunity (move the import inside that route's component or lazy-load it).

A vendor chunk over 100 KB gz that is imported by every route is worth a manual review — common offenders: date libraries, validation libraries, icon sets, charting libraries.

Step 5: Heavy dependencies in route components

For each route chunk over 50 KB gz, run pracht inspect build --json plus du-style analysis on the chunk contents:

  • Grep the chunk source for known heavy module headers (moment, lodash, chart.js, three, @stripe/stripe-js, etc.).
  • For each, recommend: (a) tree-shakeable alternative, (b) dynamic import inside an event handler, (c) lazy-load via lazy() from @pracht/core.

Step 6: Prefetch strategy

pracht inspect routes --json exposes prefetch per route on a current @pracht/cli. If your CLI version predates the field (it's absent from the JSON), fall back to grepping the route manifest source for prefetch:. Route-level strategies are "none", "hover", "intent", "viewport" ("render" exists only as a per-link override — see below). Recommend:

  • "viewport" for primary-nav links.
  • "hover" for content links inside long pages.
  • "intent" (default) is fine if you're not sure.
  • "none" for routes that are large and rarely visited (admin, settings) so hover doesn't preload them.

Cost model for "viewport": the IntersectionObserver fires once per anchor — it unobserves the anchor after the first prefetch, and prefetched route state is cached for 30 seconds — so a link in a marketing footer costs at most one prefetch per anchor per page view, not one per scroll. Viewport and render prefetches run three at a time, drop links that left the page, and are skipped on Save-Data or 2G connections. It's still wasteful for a 300 KB route that footer visitors rarely click; prefer "none" or "hover" there. Also note the per-link escape hatch: <Link prefetch="..."> overrides the route-level strategy for a single anchor (and accepts the extra "render" value — prefetch as soon as the link renders, the most eager option), so a route can stay on "intent" while its primary-nav link opts into "viewport" or "render".

If every route ends up on "none", do not stop there — the prefetch listeners still ship, in a chunk the router lazily imports on every page. Recommend pracht({ client: { prefetch: false } }) instead, which compiles the whole mechanism out (~2.6 KB gzip and one fewer request). Confirm nothing relies on prefetching first: the router silently stops honouring route({ prefetch }) and <Link prefetch>, and the imperative prefetch() export becomes a no-op. See https://pracht.resynapse.dev/docs/prefetching#shipping-less-javascript.

Step 6b: Chunk-group composition

App-level chunking and pracht's vendor chunk coexist — recommend it freely. Pracht reads build.rollupOptions.output and contributes its Preact group in whichever form the app used, so adding a group (to merge the long tail of small initial chunks, say) does not cost the framework chunk:

// vite.config.ts
build: {
  rollupOptions: {
    output: {
      codeSplitting: { groups: [{ name: "app", tags: ["$initial"], minSize: 20_000 }] },
    },
  },
}

Two things to check when an app already configures chunking:

  • Mixed forms. Rolldown lets codeSplitting silence both advancedChunks and manualChunks. An app that sets more than one is running only codeSplitting; report the dead config as warn.
  • A group that swallows Preact. Precedence is higher priority first, then declaration order, with pracht's group last — so an app group matching node_modules at equal priority absorbs the framework runtime. If a separate, long-cached framework chunk was the intent, recommend a priority bump or a test that excludes Preact.

Verify any grouping change against the prerendered HTML, not just the numbers: a group broad enough to reshuffle entry chunks can drop the stylesheet links pracht injects per route. Compare <link rel="stylesheet"> tags in dist/client/**/index.html before and after.

pracht({ vendorChunk: false }) makes pracht contribute nothing, for an app that wants Preact inside its own chunks or places frameworkChunkGroups() itself.

Pracht contributes one group to the server build too: each island becomes its own chunk, so a route that does not fully hydrate can resolve its CSS from that build without inheriting the server entry's merged stylesheet. An app that configures SSR chunking composes with it the same way the client side does; a group that pulls islands back into the entry shows up as extra <link rel="stylesheet"> tags on static pages, and the build warns.

build.cssCodeSplit: false is rejected outright — there is no index.html to link the single stylesheet it produces, so every page would ship without one.

When a route's CSS is small and the site is entered cold — a static export, a content site arriving from search — the stylesheet link is often the only render-blocking request left on a page that ships no JavaScript. Report pracht({ inlineCss: true }) as a suggestion there, with the cost: the same bytes in every document and no shared stylesheet cache. An app whose visitors move between pages should keep the link.

Step 7: Report

Three sections:

  1. Top 10 routes by total client payload — sorted desc.
  2. Top 10 vendor chunks by size — with fan-in.
  3. Suggestions — ordered by impact (KB saved × routes affected).

Tag every finding with a primary severity — error (budget failure), warn (LCP-class route, single-route vendor chunk), info (tuning opportunity) — and keep the size numbers as supporting detail.

Include before/after estimates for each suggestion: "Lazy-load chart.js inside Component: -180 KB gz off /dashboard/analytics."

Rules

  1. Always run pracht build --analyze --json first. A stale build is a source of bad advice.
  2. Use the Vite manifest as the source of truth — chunk names rotate per build.
  3. Report gzip size, not raw — the wire size is what users pay.
  4. Distinguish "shared" code (entry + framework) from "route-specific" code when reporting; users can only optimize the latter.
  5. Do not auto-edit. Bundle changes have render-blocking implications; surface and let the user choose.

$ARGUMENTS

Files (pracht)
  • SKILL.md 10.5 KB
    ---
    name: audit-bundles
    version: 1.2.1
    description: |
      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 prefetching".
    allowed-tools:
      - Bash
      - Read
      - Grep
      - Glob
    ---
    
    # Pracht Audit Bundles
    
    Pracht performs route-level code splitting via the Vite plugin and emits a
    manifest. This skill reads that manifest, sizes each route's client payload,
    and surfaces the worst offenders.
    
    ## Step 1: Build with analysis
    
    MCP: when the pracht MCP server is registered (`pracht dev-mcp`), prefer its
    `inspect_routes`/`inspect_api`/`inspect_build`/`doctor`/`verify` tools over
    shelling out. `pracht inspect` needs the pracht plugin in the vite config.
    `inspect build` needs a prior `pracht build`.
    
    ```bash
    pracht build --analyze --json
    ```
    
    This is the native per-route payload report: for every route (pattern + render
    mode) it emits the transitive client JS chunks with raw and gzip sizes
    (`routes[].chunks`), route-specific and total sums (`routeGzipBytes`,
    `totalGzipBytes`), and the shared entry chunks broken out (`shared`), sorted by
    total gzip descending. A stale `dist/` produces misleading numbers — rebuild,
    don't reuse.
    
    Exit-code footgun: when configured budgets fail, `pracht build --analyze
    --json` sets a nonzero exit code while still printing valid JSON on stdout.
    Do not treat exit 1 as "no output" — parse stdout anyway, or run with
    `--no-budget-fail` to keep the exit code clean.
    
    For a human-readable table, use `pracht build --analyze` instead.
    
    ## Step 2: Pull supporting metadata
    
    ```bash
    pracht inspect build --json
    ```
    
    Captures the resolved adapter, client entry URL, and CSS/JS manifest. Cross
    reference with `dist/client/.vite/manifest.json` for chunk metadata
    (file, imports, dynamicImports, css, isEntry) — needed for vendor fan-in
    analysis (Step 4) and CSS sizing, which the analyze report does not cover.
    
    Size CSS from the `cssManifest` this command reports, never from
    `dist/client/.vite/manifest.json`. A `hydration: "none"` or `"islands"` route is
    absent from the client manifest by construction, so reading it directly reports
    no CSS for a page whose document links a stylesheet — under-reporting exactly
    the routes pracht recommends. `inspect` merges `dist/server/css-manifest.json`,
    the build's own record, over the client manifest; an older build without that
    file is client-only, and the CSS column should say so rather than read as zero.
    
    ## Step 3: Interpret per-route payload
    
    From the Step 1 JSON, report:
    
    | Route | Hydration | Route JS (gz) | Shared (gz) | Total (gz) | CSS (gz) | LCP-class? |
    | ----- | --------- | ------------- | ----------- | ---------- | -------- | ---------- |
    
    `gz` = gzip size, taken directly from `routeGzipBytes` / `shared.gzipBytes` /
    `totalGzipBytes`. Size CSS chunks (from `cssManifest`) with `zlib.gzipSync`.
    
    Account for the per-route `hydration` field the analyze JSON emits (omitted
    means `"full"`) before judging sizes:
    
    - `hydration: "none"` routes ship **0 bytes** of JS — the report already
      zeroes them out. Never flag them.
    - `hydration: "islands"` routes never load the shared client entry; their
      total is the islands bootstrap plus **every** island chunk in the app — an
      upper bound, since which islands a page actually uses is only known at
      render time. Treat their totals as pessimistic.
    - Only `"full"` routes pay route JS + shared entry.
    
    `LCP-class?` is `yes` when total gz exceeds 200 KB — that's the order of
    magnitude where mid-tier mobile starts losing LCP budget. Apply the threshold
    after the hydration adjustment above.
    
    If the app declares `budgets` in the pracht plugin config, the Step 1 JSON also
    contains a `budgets` section with per-route pass/fail — lead the report with any
    failures. If no budgets are configured, suggest adding them (see
    <https://pracht.resynapse.dev/docs/reference/config>), starting from the current sizes plus ~10% headroom.
    
    ## Step 4: Vendor chunk health
    
    Identify chunks under `node_modules/`. For each:
    - Size (raw and gz).
    - Number of route chunks that import it (fan-in).
    
    A vendor chunk imported by every route is the framework runtime — expected.
    A vendor chunk imported by ONE route is a code-splitting opportunity (move
    the import inside that route's component or lazy-load it).
    
    A vendor chunk over 100 KB gz that is imported by every route is worth a
    manual review — common offenders: date libraries, validation libraries,
    icon sets, charting libraries.
    
    ## Step 5: Heavy dependencies in route components
    
    For each route chunk over 50 KB gz, run `pracht inspect build --json` plus
    `du`-style analysis on the chunk contents:
    
    - Grep the chunk source for known heavy module headers (`moment`, `lodash`,
      `chart.js`, `three`, `@stripe/stripe-js`, etc.).
    - For each, recommend: (a) tree-shakeable alternative, (b) dynamic import
      inside an event handler, (c) lazy-load via `lazy()` from `@pracht/core`.
    
    ## Step 6: Prefetch strategy
    
    `pracht inspect routes --json` exposes `prefetch` per route on a current
    `@pracht/cli`. If your CLI version predates the field (it's absent from the
    JSON), fall back to grepping the route manifest source for `prefetch:`.
    Route-level strategies are `"none"`, `"hover"`, `"intent"`, `"viewport"`
    (`"render"` exists only as a per-link override — see below). Recommend:
    
    - `"viewport"` for primary-nav links.
    - `"hover"` for content links inside long pages.
    - `"intent"` (default) is fine if you're not sure.
    - `"none"` for routes that are large and rarely visited (admin, settings) so
      hover doesn't preload them.
    
    Cost model for `"viewport"`: the IntersectionObserver fires once per anchor —
    it unobserves the anchor after the first prefetch, and prefetched route state
    is cached for 30 seconds — so a link in a marketing footer costs at most one
    prefetch per anchor per page view, not one per scroll. Viewport and render
    prefetches run three at a time, drop links that left the page, and are skipped
    on Save-Data or 2G connections. It's still wasteful
    for a 300 KB route that footer visitors rarely click; prefer `"none"` or
    `"hover"` there. Also note the per-link escape hatch: `<Link prefetch="...">`
    overrides the route-level strategy for a single anchor (and accepts the extra
    `"render"` value — prefetch as soon as the link renders, the most eager
    option), so a route can stay on `"intent"` while its primary-nav link opts
    into `"viewport"` or `"render"`.
    
    If every route ends up on `"none"`, do not stop there — the prefetch listeners
    still ship, in a chunk the router lazily imports on every page. Recommend
    `pracht({ client: { prefetch: false } })` instead, which compiles the whole
    mechanism out (~2.6 KB gzip and one fewer request). Confirm nothing relies on
    prefetching first: the router silently stops honouring `route({ prefetch })` and
    `<Link prefetch>`, and the imperative `prefetch()` export becomes a no-op. See
    <https://pracht.resynapse.dev/docs/prefetching#shipping-less-javascript>.
    
    ## Step 6b: Chunk-group composition
    
    App-level chunking and pracht's vendor chunk coexist — recommend it freely.
    Pracht reads `build.rollupOptions.output` and contributes its Preact group in
    whichever form the app used, so adding a group (to merge the long tail of small
    initial chunks, say) does not cost the framework chunk:
    
    ```ts
    // vite.config.ts
    build: {
      rollupOptions: {
        output: {
          codeSplitting: { groups: [{ name: "app", tags: ["$initial"], minSize: 20_000 }] },
        },
      },
    }
    ```
    
    Two things to check when an app already configures chunking:
    
    - **Mixed forms.** Rolldown lets `codeSplitting` silence both `advancedChunks`
      and `manualChunks`. An app that sets more than one is running only
      `codeSplitting`; report the dead config as `warn`.
    - **A group that swallows Preact.** Precedence is higher `priority` first, then
      declaration order, with pracht's group last — so an app group matching
      `node_modules` at equal priority absorbs the framework runtime. If a separate,
      long-cached framework chunk was the intent, recommend a `priority` bump or a
      `test` that excludes Preact.
    
    Verify any grouping change against the prerendered HTML, not just the numbers:
    a group broad enough to reshuffle entry chunks can drop the stylesheet links
    pracht injects per route. Compare `<link rel="stylesheet">` tags in
    `dist/client/**/index.html` before and after.
    
    `pracht({ vendorChunk: false })` makes pracht contribute nothing, for an app
    that wants Preact inside its own chunks or places `frameworkChunkGroups()`
    itself.
    
    Pracht contributes one group to the **server** build too: each island becomes
    its own chunk, so a route that does not fully hydrate can resolve its CSS from
    that build without inheriting the server entry's merged stylesheet. An app that
    configures SSR chunking composes with it the same way the client side does; a
    group that pulls islands back into the entry shows up as extra `<link
    rel="stylesheet">` tags on static pages, and the build warns.
    
    `build.cssCodeSplit: false` is rejected outright — there is no `index.html` to
    link the single stylesheet it produces, so every page would ship without one.
    
    When a route's CSS is small and the site is entered cold — a static export, a
    content site arriving from search — the stylesheet link is often the only
    render-blocking request left on a page that ships no JavaScript. Report
    `pracht({ inlineCss: true })` as a suggestion there, with the cost: the same
    bytes in every document and no shared stylesheet cache. An app whose visitors
    move between pages should keep the link.
    
    ## Step 7: Report
    
    Three sections:
    
    1. **Top 10 routes by total client payload** — sorted desc.
    2. **Top 10 vendor chunks by size** — with fan-in.
    3. **Suggestions** — ordered by impact (KB saved × routes affected).
    
    Tag every finding with a primary severity — `error` (budget failure), `warn`
    (LCP-class route, single-route vendor chunk), `info` (tuning opportunity) —
    and keep the size numbers as supporting detail.
    
    Include before/after estimates for each suggestion: "Lazy-load `chart.js`
    inside `Component`: -180 KB gz off `/dashboard/analytics`."
    
    ## Rules
    
    1. Always run `pracht build --analyze --json` first. A stale build is a source
       of bad advice.
    2. Use the Vite manifest as the source of truth — chunk names rotate per
       build.
    3. Report gzip size, not raw — the wire size is what users pay.
    4. Distinguish "shared" code (entry + framework) from "route-specific" code
       when reporting; users can only optimize the latter.
    5. Do not auto-edit. Bundle changes have render-blocking implications;
       surface and let the user choose.
    
    $ARGUMENTS
    

Comments (0)

Sign in to join the conversation.

No comments yet.

Reviews (0)

No reviews yet.