Skip to content

Conversation

@myovchev
Copy link
Contributor

@myovchev myovchev commented Nov 5, 2025

pnpm Dependency Resolution Plugin

This PR introduces a custom Vite plugin that solves transitive dependency resolution issues when using pnpm package manager, workspaces, or symlinked modules in ApostropheCMS projects.

Problem Statement

pnpm uses a different dependency resolution strategy than npm/yarn - it doesn't hoist all packages to the top-level node_modules. This causes Vite builds to fail when trying to resolve transitive dependencies, particularly ESM-only packages that your direct dependencies require but aren't explicitly listed in your project's package.json.

Example: Your project depends on package-a, which depends on esm-utils. With pnpm, package-a can resolve esm-utils from its own node_modules, but your project root cannot resolve it directly, causing Vite build failures.

Key Features

Fallback-First Resolution

  • Standard Vite/Node resolution always tries first
  • Custom resolution only activates when standard resolution fails
  • Zero impact on packages that already resolve correctly

Multi-Root Resolution Strategy

When fallback is needed, the plugin tries Node resolution from multiple roots in order:

  1. Workspace root (if pnpm workspace detected)
  2. Project root (fixes issues with symlinked modules in development)
  3. Each configured package's installation directory
  4. Workspace roots of those packages (if applicable)

Symlink-Aware

  • Resolves real paths through symlinks using fs.realpathSync()
  • Sets preserveSymlinks: false when active to prevent module duplication and failing esbuild optimizations
  • Critical for pnpm's symlinked structure (prevents 4x bundle size increase)

Two-Phase Operation

  • Development/HMR: Works during Vite's dependency pre-bundling (esbuild phase) disabled currently (can be enabled if needed)
  • Production Build: Works during Vite's build phase (Rollup resolveId hook)

Flexible Configuration

Three configuration methods with priority cascade:

  1. Module options (highest priority) - in app.js
  2. Vite config file - apos.vite.config.js/mjs
  3. package.json - under aposVite key

Configuration values:

  • true - Enable with defaults (['@apostrophecms/vite', 'apostrophe'])
  • ['pkg1', 'pkg2'] - Enable with custom packages (merged with defaults)
  • false or [] - Explicitly disable
  • default is disabled

How It Works

Plugin Architecture

The plugin implements two Vite hooks:

export default function VitePluginPnpmResolve({ pkgs, projectRoot }) {
  return {
    name: 'apos-pnpm-resolve',
    enforce: 'pre',
    
    config(config) {
      // Disable symlink preservation to prevent duplication
      return { resolve: { preserveSymlinks: false } };
      // Note: esbuild plugin currently disabled
    },
    
    async resolveId(source, importer, options) {
      // Custom resolution logic here
      // 1. Try standard resolution first
      // 2. If fails, try multiple roots in order
      // 3. Return resolved id or null
      // Supports pnpm workspaces and symlinked modules
    }
  };
}

Resolution Root Discovery

  1. Workspace Detection - Walks up from project root looking for pnpm-workspace.yaml or pnpm-lock.yaml
  2. Package Root Discovery - For each configured package:
    • Resolves the package from project root
    • Walks up from resolved file to find package.json
    • Validates package name matches expected name
    • Adds package root and its workspace (if any) to resolution list
  3. Deduplication - Removes duplicate roots from final list

Configuration Loading

Priority cascade in computeResolvePackages() (internals.js):

// Stops at first defined config
let activeConfig = getModuleOptionsConfig();        // 1. Module options
if (activeConfig === null) {
  activeConfig = await loadViteConfig(viteConfigFile);  // 2. Vite config
}
if (activeConfig === null) {
  activeConfig = await loadPackageJsonConfig();     // 3. package.json
}

Console Logging (Intentional)

The plugin includes console logs that are intentionally left in to provide visibility:

// before custom resolution attempt
console.log('[rollup] fallback source:', source);
// on successful custom resolution
console.log('[rollup] apos-pnpm-resolve:', id);

These help developers:

  • See which packages are falling back to custom resolution
  • Verify the plugin is working as expected
  • Debug resolution issues in their own projects
  • Understand when standard vs custom resolution is used

You'll see these logs during:

  • Build tasks (e.g. npm run build)
  • Only for packages that actually need fallback resolution

Important Considerations

Clear Cache When Switching Package Managers

IMPORTANT: If you switch between pnpm and npm, or make significant dependency changes, run:

node app @apostrophecms/asset:clear-cache

This clears Vite's cache directory to prevent stale resolution artifacts. Note that Vite typically handles dependency changes automatically, but switching package managers requires manual cache clearing.

Plugin Disabled by Default

You must explicitly enable the plugin - it won't activate automatically. This ensures no unexpected behavior changes for existing projects.

preserveSymlinks Impact

The base config sets preserveSymlinks: true by default. When the plugin is active, it overrides this to false. Setting it to true with pnpm causes significant module duplication (4x larger bundle sizes) and crashes the dev server.

Configuration Priority

Module options take precedence over all other config. If you're not seeing expected behavior, check your app.js module configuration first.

Array Configs Merge with Defaults

When you provide ['my-package'], the result is ['@apostrophecms/vite', 'apostrophe', 'my-package']. Arrays are always merged with the default packages.

esbuild Plugin Currently Disabled

The esbuild portion (lines 47-84 in vite-plugin-pnpm-resolve.mjs) is commented out. Setting preserveSymlinks: false plus standard Vite resolution handles most cases. The esbuild plugin can be uncommented if needed for edge cases during dev/HMR.

Backward Compatibility

This change is fully backward compatible. Existing projects using npm will continue to work without any modifications:

  • The plugin is disabled by default and must be explicitly enabled
  • When disabled, the base config maintains preserveSymlinks: true (existing behavior)
  • No changes required to existing npm-based workflows or configurations
  • Projects can opt-in to pnpm support when ready by adding the resolvePackageRoots configuration

Testing in the Playground

Quick Start - Vite demo

# Clone the demo project
git clone https://github.com/apostrophecms/vite-demo.git
cd vite-demo
# Funny branch name, ikr
git checkout pr-22-review

# Install dependencies
pnpm install

# Start dev server and watch console logs
pnpm run dev

# Try a production build
pnpm run build

Quick Start - Vite demo workspaces

The demo is a monorepo with workspace packages

# Clone the demo project
git clone https://github.com/apostrophecms/vite-demo.git
cd vite-demo
# Checkout the workspace branch
git checkout pr-22-review-workspace

# Install dependencies (this sets up the workspace)
pnpm install

# Start dev server and watch console logs
pnpm run dev

# Try a production build
pnpm run build

# Try a dev server (public source HMR)
pnpm run dev

After pnpm install, verify the workspace is properly linked:

# Check that the workspace package is symlinked
ls -la node_modules/@vite-demo/utils
# Should show a symlink to ../../packages/demo-utils

# Check the workspace package has its transient dependency
ls packages/demo-utils/node_modules/pretty-ms
# Should show the pretty-ms package

The demo contains "counter" widgets (client side) developed in Vue and React (Svelte plugin is disabled due to outdated setup). This is a front-end build and HMR test as well. Use node app @apostrophecms/user:add admin admin to add admin user to your project and edit home page or create Counter Apps Page, add Vue counter widget(s) and ensure:

  • it renders correctly
  • "Time elapsed" display updating every second (e.g., "5s", "1m 30s") - this uses pretty-ms
  • "Stats from workspace package" section showing:
    • Doubled: value
    • Squared: value
    • Is Even: Yes/No

Local Development Setup

To modify the plugin and see changes in real-time:

# Clone both repositories
git clone https://github.com/apostrophecms/vite.git
cd vite
git checkout pr-22-review

cd ..
git clone https://github.com/apostrophecms/vite-demo.git
cd vite-demo
git checkout pr-22-review

# Link the local plugin
pnpm link ../vite

# Verify the link (should show symlink)
ls -la node_modules/@apostrophecms/vite

# Start dev server
pnpm run dev

The demo contains "counter" widgets (client side) developed in Vue and React (Svelte plugin is disabled due to outdated setup). This is a front-end build and HMR test as well. Add plugins to the home page or a new Counter Apps Page to ensure frontent works correctly. The counters should work and be persisted after page reloads.

Uncommenting hmr: 'apos' in modules/@apostrophecms/asset/index.js will switch the dev server HMR from public source (the widgets) to the admin UI.

Hot reload workflow:

  1. Edit plugin code in ../vite/lib/vite-plugin-pnpm-resolve.mjs
  2. Type rs in the vite-demo terminal (restarts server via nodemon)
  3. See your changes immediately

Testing Different Configurations

Try different plugin configurations in vite-demo/app.js:

// Disable plugin
modules: {
  '@apostrophecms/vite': {
    options: {
      resolvePackageRoots: false
    }
  }
}

// Enable with defaults only
modules: {
  '@apostrophecms/vite': {
    options: {
      resolvePackageRoots: true
    }
  }
}

// Add custom packages
modules: {
  '@apostrophecms/vite': {
    options: {
      resolvePackageRoots: ['vue', 'react']
    }
  }
}

Also test configurations via apos.vite.config.js and package.json to see priority in action.
After each change, use rs to restart and observe the console logs.

Documentation

Full documentation added to README.md:

  • Module Options section with resolvePackageRoots configuration
  • pnpm and dependency resolution section with detailed examples
  • Configuration priority explanation
  • All three configuration methods documented with code samples

@myovchev myovchev marked this pull request as draft November 5, 2025 14:49
@myovchev myovchev changed the title Pr-22-review Expand pnpm resolution support (based on #22) Nov 6, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants