Rollup Plugins: Extend Rollup Bundler
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
- Introduction
- Understanding Rollup Hooks
- Plugin Structure
- Build Hooks
- Output Generation Hooks
- Creating Custom Plugins
- Real-World Examples
- 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
- Build Hooks: Run during the build phase (parsing, transforming)
- 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);
}
};
}