Writing a Custom Vite Plugin for Asset Transformation
Custom asset transformation in Vite targets non-JavaScript/TypeScript files (.proto, .xml, .bin, proprietary schemas) that bypass native esbuild loaders. The Vite plugin contract exposes a deterministic pipeline for intercepting, reading, and mutating these payloads before they reach the browser or bundler. Native asset handling optimizes for images, fonts, and JSON, leaving niche formats unhandled. Integrating a custom transformer into the broader Vite Configuration & Ecosystem ensures consistent resolution, caching, and HMR across development and production builds.
Core Hooks: load, transform, and resolveId
Vite executes plugin hooks in a strict sequence. resolveId intercepts import specifiers, load reads the raw file content, and transform applies syntax or structural modifications. Hook execution order matters: place enforce: 'pre' to run before Vite’s internal optimizers.
// vite.config.ts
import { defineConfig } from 'vite';
import myTransformPlugin from './plugins/my-transform';
export default defineConfig({
plugins: [myTransformPlugin({ enforce: 'pre' })],
});
A common failure point is returning raw strings from transform. Vite throws: Error: Plugin 'my-transform' hook 'transform' returned a string instead of an object. The root cause is a mismatch with the modern Rollup-compatible return signature. Always return { code: string, map?: SourceMapInput } or null. Returning null signals Vite to skip transformation for the current module.
Step-by-Step Implementation: Transforming Custom Assets
The following TypeScript scaffold demonstrates regex-based matching, buffer-safe reading, and synchronous/asynchronous transformation paths.
import type { Plugin } from 'vite';
import { readFileSync } from 'fs';
import { resolve } from 'path';
interface TransformOptions {
enforce?: 'pre' | 'post';
}
export default function customAssetPlugin(opts: TransformOptions = {}): Plugin {
const CUSTOM_REGEX = /\.custom$/;
return {
name: 'vite-plugin-custom-transform',
enforce: opts.enforce,
resolveId(id) {
if (CUSTOM_REGEX.test(id)) return id;
return null;
},
load(id) {
if (!CUSTOM_REGEX.test(id)) return null;
try {
const raw = readFileSync(id);
// Prevent: TypeError: Cannot read properties of undefined (reading 'toString')
const content = Buffer.isBuffer(raw) ? raw.toString('utf8') : String(raw);
return content;
} catch (err) {
this.error(`Failed to load ${id}: ${(err as Error).message}`);
}
},
async transform(code, id) {
if (!CUSTOM_REGEX.test(id)) return null;
// Synchronous or async transformation logic
const transformed = code.replace(/CUSTOM_TOKEN/g, 'REPLACED_VALUE');
return {
code: `export default ${JSON.stringify(transformed)};`,
map: null, // Replace with actual source map if applicable
};
},
};
}
The TypeError: Cannot read properties of undefined (reading 'toString') occurs when fs.readFileSync returns a Buffer in Node environments but the plugin attempts string methods without explicit encoding. Resolve by wrapping reads with Buffer.from() or .toString('utf8') and verifying the file exists before parsing.
Source Map Generation & HMR Invalidation
Preserving line/column accuracy requires mapping original asset positions to transformed output. Use magic-string or @jridgewell/gen-mapping to generate compliant source maps. The transform hook must attach the map to the return object:
import MagicString from 'magic-string';
// Inside transform hook:
const s = new MagicString(code);
s.replace(/CUSTOM_TOKEN/g, 'REPLACED_VALUE');
return {
code: s.toString(),
map: s.generateMap({ source: id, includeContent: true, hires: true }),
};
HMR fails for custom assets with [vite] Internal server error: Failed to resolve import when the dev server cannot track file mutations. Implement handleHotUpdate to invalidate the module graph and push updates:
handleHotUpdate(ctx) {
if (CUSTOM_REGEX.test(ctx.file)) {
ctx.server.moduleGraph.invalidateModule(ctx.modules[0]);
return ctx.modules;
}
return [];
}
For deeper module graph manipulation and cache invalidation strategies, consult Advanced Vite Plugin Configuration.
Production Build Compatibility & Rollup Handoff
During vite dev, Vite delegates to esbuild for speed. During vite build, it hands off to Rollup. Rollup enforces strict AST validation, triggering RollupError: Unexpected token when transformed output contains non-standard syntax or malformed exports.
Root cause: esbuild tolerates loose syntax, but Rollup expects standard ECMAScript modules. Fix by ensuring transform outputs strictly valid JS/JSON. Use @rollup/pluginutils for safe filtering and AST validation:
import { createFilter } from '@rollup/pluginutils';
const filter = createFilter(CUSTOM_REGEX);
// In transform:
if (!filter(id)) return null;
If legacy syntax persists, override Rollup’s parser in build.rollupOptions:
export default defineConfig({
build: {
rollupOptions: {
plugins: [/* custom rollup plugins if needed */],
treeshake: { moduleSideEffects: false },
},
},
});
Debugging Workflow & Verification Checklist
Execute a systematic diagnostic pipeline to isolate transformation failures:
- Run
vite --debugand inspect terminal output for hook execution traces. - Open browser DevTools > Network. Filter by
?transformquery params to verify payload delivery. - Validate production output:
node -e "require('./dist/asset.js')"ornode --input-type=module -e "import './dist/asset.js'".
Map exact error messages to corrective actions:
Error: ENOENT: no such file or directory→ Fix: resolve paths absolutely usingawait this.resolve(id, importer)before reading.Error: Cannot find module 'vite'→ Fix: declareviteinpeerDependenciesanddevDependenciesinpackage.json.
Maintainer Publishing Checklist:
- [ ] Plugin exports a factory function returning a
Pluginobject. - [ ]
namefield is unique and prefixed appropriately. - [ ]
transformreturns{ code, map }ornullexclusively. - [ ]
handleHotUpdatereturns an array ofModuleNodeobjects. - [ ]
package.jsonincludesviteas a peer dependency. - [ ] Test suite covers
vite dev,vite build, and HMR invalidation.