Skip to main content

Command Palette

Search for a command to run...

Rollup Plugins: Extend Rollup Bundler

Updated
•9 min read•View as Markdown
T

Welcome to TopperBlog! šŸ‘‹

I'm a tech content creator passionate about helping developers level up their careers and master cutting-edge technologies.

šŸŽÆ What I Write About: • AI/ML Engineering & LLMs • Web3 & Blockchain Development
• System Design & Architecture • Interview Preparation (FAANG) • Freelancing & Remote Work • Modern Tech Stacks (Next.js, React, Rust, TypeScript) • Performance Optimization & Best Practices

šŸ’¼ Mission: Sharing practical, actionable insights that accelerate your tech career and maximize your earning potential.

šŸ“š 15+ In-Depth Guides covering everything from earning $10k/month as a freelancer to cracking FAANG interviews.

🌐 Let's connect and grow together in this amazing tech journey!

#TechBlogger #SoftwareEngineering #CareerGrowth #WebDevelopment #AIEngineering

Rollup Plugins: Extend Rollup Bundler

Table of Contents

  1. Introduction
  2. Understanding Rollup Hooks
  3. Plugin Structure
  4. Build Hooks
  5. Output Generation Hooks
  6. Creating Custom Plugins
  7. Real-World Examples
  8. FAQ

Introduction

Rollup plugins are the primary way to extend Rollup's functionality. They use a hook-based architecture that allows you to tap into different stages of the bundling process. Plugins can transform code, resolve imports, load files, and modify the final output.

Key Benefits:

  • Modular and composable architecture
  • Access to the entire build pipeline
  • Rich ecosystem of existing plugins
  • Type-safe with TypeScript support

Understanding Rollup Hooks

Rollup hooks are functions that execute at specific points during the build process. They fall into two main categories:

Hook Types

  1. Build Hooks: Run during the build phase (parsing, transforming)
  2. Output Generation Hooks: Run during output generation (rendering, writing)

Hook Execution Order

options → buildStart → resolveId → load → transform → 
moduleParsed → buildEnd → outputOptions → renderStart → 
banner/footer/intro/outro → renderChunk → generateBundle → 
writeBundle → closeBundle

Plugin Structure

Basic Plugin Anatomy

export default function myPlugin(options = {}) {
  return {
    name: 'my-plugin', // Required: plugin name

    // Hook functions
    buildStart(options) {
      // Initialization logic
    },

    resolveId(source, importer) {
      // Custom module resolution
    },

    load(id) {
      // Custom module loading
    },

    transform(code, id) {
      // Code transformation
    }
  };
}

Plugin Options Pattern

export default function myPlugin(userOptions = {}) {
  const defaultOptions = {
    include: '**/*.js',
    exclude: 'node_modules/**',
    verbose: false
  };

  const options = { ...defaultOptions, ...userOptions };

  return {
    name: 'my-plugin',
    // Use options in hooks
  };
}

Build Hooks

1. options Hook

Modify or read Rollup input options.

options(inputOptions) {
  return {
    ...inputOptions,
    // Modify options
    treeshake: true
  };
}

2. buildStart Hook

Called when the build starts. Good for initialization.

buildStart(options) {
  console.log('Build starting with options:', options);
  this.cache = new Map(); // Initialize plugin state
}

3. resolveId Hook

Custom module resolution logic.

resolveId(source, importer, options) {
  // Handle virtual modules
  if (source === 'virtual-module') {
    return source; // Signals this plugin will load it
  }

  // Handle custom protocols
  if (source.startsWith('custom:')) {
    return {
      id: source.slice(7),
      external: false
    };
  }

  return null; // Let other plugins/default resolver handle it
}

4. load Hook

Load module content from custom sources.

load(id) {
  // Load virtual modules
  if (id === 'virtual-module') {
    return 'export default "Virtual content"';
  }

  // Load from custom sources
  if (id.startsWith('db:')) {
    return fetchFromDatabase(id.slice(3));
  }

  return null; // Use default loader
}

5. transform Hook

Transform module code (most commonly used).

transform(code, id) {
  // Skip non-JS files
  if (!id.endsWith('.js')) return null;

  // Simple transformation
  const transformedCode = code.replace(/OLD_API/g, 'NEW_API');

  return {
    code: transformedCode,
    map: null // or generate source map
  };
}

With Source Maps:

import MagicString from 'magic-string';

transform(code, id) {
  const magicString = new MagicString(code);

  // Make changes
  magicString.replace('foo', 'bar');

  return {
    code: magicString.toString(),
    map: magicString.generateMap({ hires: true })
  };
}

6. moduleParsed Hook

Called after a module has been parsed.

moduleParsed(moduleInfo) {
  console.log(`Parsed: ${moduleInfo.id}`);
  console.log(`Imports: ${moduleInfo.importedIds.join(', ')}`);
  console.log(`Exports: ${moduleInfo.exports.join(', ')}`);
}

Output Generation Hooks

1. renderChunk Hook

Transform individual chunks before writing.

renderChunk(code, chunk, options) {
  // Add banner to each chunk
  const banner = `/* Chunk: ${chunk.fileName} */\n`;

  return {
    code: banner + code,
    map: null
  };
}

2. generateBundle Hook

Modify or add files to the bundle before writing.

generateBundle(options, bundle) {
  // Add a custom file
  this.emitFile({
    type: 'asset',
    fileName: 'manifest.json',
    source: JSON.stringify({
      files: Object.keys(bundle)
    })
  });

  // Modify existing files
  for (const fileName in bundle) {
    const chunk = bundle[fileName];
    if (chunk.type === 'chunk') {
      // Modify chunk code
      chunk.code = `/* Modified */\n${chunk.code}`;
    }
  }
}

3. writeBundle Hook

Called after files have been written to disk.

async writeBundle(options, bundle) {
  console.log('Bundle written to:', options.dir);

  // Post-processing tasks
  await compressFiles(options.dir);
  await uploadToServer(bundle);
}

Creating Custom Plugins

Example 1: Environment Variable Injection

import { createFilter } from '@rollup/pluginutils';

export default function envPlugin(options = {}) {
  const filter = createFilter(
    options.include || '**/*.js',
    options.exclude || 'node_modules/**'
  );

  const env = options.env || process.env;

  return {
    name: 'env-injection',

    transform(code, id) {
      if (!filter(id)) return null;

      let transformed = code;

      // Replace process.env.VAR with actual values
      Object.keys(env).forEach(key => {
        const regex = new RegExp(
          `process\\.env\\.${key}\\b`,
          'g'
        );
        transformed = transformed.replace(
          regex,
          JSON.stringify(env[key])
        );
      });

      if (transformed !== code) {
        return { code: transformed, map: null };
      }

      return null;
    }
  };
}

Usage:

// rollup.config.js
import envPlugin from './plugins/env-plugin.js';

export default {
  input: 'src/index.js',
  output: { file: 'dist/bundle.js', format: 'es' },
  plugins: [
    envPlugin({
      env: {
        API_URL: 'https://api.example.com',
        DEBUG: 'true'
      }
    })
  ]
};

Example 2: File Size Reporter

export default function sizeReporter(options = {}) {
  const threshold = options.threshold || 100 * 1024; // 100KB

  return {
    name: 'size-reporter',

    generateBundle(outputOptions, bundle) {
      console.log('\nšŸ“¦ Bundle Size Report:\n');

      let totalSize = 0;

      for (const [fileName, file] of Object.entries(bundle)) {
        const size = file.type === 'chunk' 
          ? Buffer.byteLength(file.code, 'utf8')
          : file.source.length;

        totalSize += size;

        const sizeKB = (size / 1024).toFixed(2);
        const warning = size > threshold ? ' āš ļø' : '';

        console.log(`  ${fileName}: ${sizeKB} KB${warning}`);
      }

      console.log(`\n  Total: ${(totalSize / 1024).toFixed(2)} KB\n`);
    }
  };
}

Example 3: Virtual Module Plugin

export default function virtualModule(modules = {}) {
  const PREFIX = '\0virtual:';

  return {
    name: 'virtual-module',

    resolveId(id) {
      if (id in modules) {
        return PREFIX + id;
      }
      return null;
    },

    load(id) {
      if (id.startsWith(PREFIX)) {
        const moduleName = id.slice(PREFIX.length);
        return modules[moduleName];
      }
      return null;
    }
  };
}

Usage:

// rollup.config.js
import virtualModule from './plugins/virtual-module.js';

export default {
  plugins: [
    virtualModule({
      'config': 'export default { version: "1.0.0" };',
      'constants': 'export const PI = 3.14159;'
    })
  ]
};

// In your code:
// import config from 'config';
// import { PI } from 'constants';

Example 4: Import Analyzer

export default function importAnalyzer() {
  const importGraph = new Map();

  return {
    name: 'import-analyzer',

    moduleParsed(moduleInfo) {
      importGraph.set(moduleInfo.id, {
        imports: moduleInfo.importedIds,
        exports: moduleInfo.exports,
        dynamicImports: moduleInfo.dynamicallyImportedIds
      });
    },

    buildEnd() {
      // Find circular dependencies
      const circular = findCircularDeps(importGraph);

      if (circular.length > 0) {
        console.warn('āš ļø  Circular dependencies detected:');
        circular.forEach(cycle => {
          console.warn(`  ${cycle.join(' → ')}`);
        });
      }

      // Find unused modules
      const entryPoints = new Set(['src/index.js']);
      const used = findReachable(importGraph, entryPoints);
      const unused = [...importGraph.keys()].filter(
        id => !used.has(id) && !id.includes('node_modules')
      );

      if (unused.length > 0) {
        console.warn('\nšŸ“¦ Potentially unused modules:');
        unused.forEach(id => console.warn(`  ${id}`));
      }
    }
  };
}

function findCircularDeps(graph) {
  // Implementation of cycle detection
  const cycles = [];
  const visited = new Set();
  const stack = new Set();

  function dfs(node, path = []) {
    if (stack.has(node)) {
      const cycleStart = path.indexOf(node);
      cycles.push(path.slice(cycleStart).concat(node));
      return;
    }

    if (visited.has(node)) return;

    visited.add(node);
    stack.add(node);
    path.push(node);

    const nodeData = graph.get(node);
    if (nodeData) {
      nodeData.imports.forEach(dep => dfs(dep, [...path]));
    }

    stack.delete(node);
  }

  graph.forEach((_, node) => dfs(node));
  return cycles;
}

function findReachable(graph, entryPoints) {
  const reachable = new Set();

  function traverse(node) {
    if (reachable.has(node)) return;
    reachable.add(node);

    const nodeData = graph.get(node);
    if (nodeData) {
      nodeData.imports.forEach(traverse);
      nodeData.dynamicImports.forEach(traverse);
    }
  }

  entryPoints.forEach(traverse);
  return reachable;
}

Real-World Examples

Complete Plugin: CSS Modules

import { createFilter } from '@rollup/pluginutils';
import postcss from 'postcss';
import postcssModules from 'postcss-modules';

export default function cssModules(options = {}) {
  const filter = createFilter(
    options.include || '**/*.module.css',
    options.exclude
  );

  const cssMap = new Map();

  return {
    name: 'css-modules',

    async transform(code, id) {
      if (!filter(id)) return null;

      let cssExports = {};

      const result = await postcss([
        postcssModules({
          getJSON(filename, json) {
            cssExports = json;
          }
        })
      ]).process(code, { from: id });

      // Store CSS for later
      cssMap.set(id, result.css);

      // Return JS module that exports class names
      return {
        code: `export default ${JSON.stringify(cssExports)};`,
        map: { mappings: '' }
      };
    },

    generateBundle() {
      // Combine all CSS
      const allCSS = Array.from(cssMap.values()).join('\n');

      // Emit as asset
      this.emitFile({
        type: 'asset',
        fileName: 'styles.css',
        source: allCSS
      });
    }
  };
}

Plugin with Caching

import { createHash } from 'crypto';

export default function cachedTransform() {
  let cache = new Map();

  return {
    name: 'cached-transform',

    buildStart() {
      // Load cache from previous build
      if (this.meta.watchMode) {
        cache = this.meta.cache || new Map();
      }
    },

    transform(code, id) {
      const hash = createHash('md5').update(code).digest('hex');

      // Check cache
      if (cache.has(id)) {
        const cached = cache.get(id);
        if (cached.hash === hash) {
          return cached.result;
        }
      }

      // Expensive transformation
      const result = expensiveTransform(code);

      // Store in cache
      cache.set(id, { hash, result });

      return result;
    },

    buildEnd() {
      // Persist cache for watch mode
      if (this.meta.watchMode) {
        this.meta.cache = cache;
      }
    }
  };
}

FAQ

Q: What's the difference between transform and renderChunk?

A: transform operates on individual modules during the build phase, while renderChunk operates on the final bundled chunks during output generation. Use transform for module-level transformations and renderChunk for chunk-level modifications.

Q: How do I handle source maps in plugins?

A: Use the magic-string library for simple transformations, or generate source maps manually. Always return both code and map properties:

import MagicString from 'magic-string';

transform(code, id) {
  const s = new MagicString(code);
  s.replace('foo', 'bar');

  return {
    code: s.toString(),
    map: s.generateMap({ hires: true })
  };
}

Q: When should I return null from a hook?

A: Return null when your plugin doesn't need to handle that particular module/situation. This allows other plugins or Rollup's default behavior to take over.

Q: How do I emit additional files?

A: Use this.emitFile() in hooks like generateBundle:

generateBundle() {
  this.emitFile({
    type: 'asset',
    fileName: 'extra-file.txt',
    source: 'content'
  });
}

Q: Can plugins have side effects?

A: Yes, but be careful. Use moduleSideEffects to control tree-shaking:

resolveId(id) {
  if (id === 'my-module') {
    return {
      id,
      moduleSideEffects: true // Prevent tree-shaking
    };
  }
}

Q: How do I debug plugins?

A: Use console logging, or leverage Rollup's --verbose flag. You can also use the buildStart hook to log configuration:

buildStart(options) {
  if (this.meta.watchMode) {
    console.log('Running in watch mode');
  }
  console.log('Input options:', options);
}

Q: What's the \0 prefix convention?

A: The \0 prefix is used for virtual module IDs to prevent conflicts with real file paths. It's a convention, not a requirement:

resolveId(id) {
  if (id === 'virtual') {
    return '\0virtual'; // Virtual module
  }
}

Q: How do I make plugins work with TypeScript?

A: Add TypeScript definitions:

import { Plugin } from 'rollup';

interface MyPluginOptions {
  include?: string;
  exclude?: string;
}

export default function myPlugin(
  options: MyPluginOptions = {}
): Plugin {
  return {
    name: 'my-plugin',
    // ... hooks
  };
}

Q: Can I use async functions in hooks?

A: Yes! Most hooks support async/await:

async load(id) {
  if (id.startsWith('http://')) {
    const response = await fetch(id);
    return await response.text();
  }
}

Q: How do I share state between hooks?

A: Use closure variables or this context:

```javascript export default function myPlugin() { const state = new Map(); // Shared across hooks

return { name: 'my-plugin',

buildStart() { state.set('startTime', Date.now()); },

buildEnd() { const duration = Date.now() - state.get('startTime'); console.log(Build took ${duration}ms); } }; }