DEV Community

Cover image for My OSS Projects: Codegen
Florian Rappl
Florian Rappl

Posted on AI-assisted

My OSS Projects: Codegen

This is part of my series "My Open-Source Projects", where I walk through the projects I started or helped to grow – with the back story, what they do well, where they struggle, and what's next. Previous parts: AngleSharp, MAGES, Piral, Electron.NET, Netpack, Picard.js and dets.

And here we are: the last article of the series. Fittingly, it is about a project that is – in the best sense – small, boring and everywhere in my own toolchains: Codegen.

If you read the Piral article, you have already met it in passing. Today it gets the stage for itself.

Drumroll

What is Codegen?

A .codegen file is a Node.js module that writes another module.

That's the whole idea. You export a function. The function returns a string. The string is the module that your bundler continues to work with. In other words: you can program how a file looks to your application, using the full power of Node.js at build time.

// info.codegen
const { name, version } = require('../package.json');

module.exports = function () {
  return `
    export const name = ${JSON.stringify(name)};
    export const version = ${JSON.stringify(version)};
  `;
};
Enter fullscreen mode Exit fullscreen mode
// app.ts
import { name, version } from './info.codegen';

console.log(`${name}@${version}`);
Enter fullscreen mode Exit fullscreen mode

Your application never sees the require, the package.json, or the function. It sees two consts – the rest happened while bundling. If you like fancy words: this is metaprogramming. The program in the .codegen file describes what the module looks like from the outside.

A .codegen file: a program that writes a module

It's like magic

How it came to be

The idea dates back to the very beginning of Piral. Back then, Piral used Parcel as its default bundler (as opposed to Webpack v5 today). A micro frontend framework needs to bend the bundler a bit: aliases, shared dependencies, externals, and a bunch of other things that you do not want to explain to every user in a bundler-specific way.

We looked at a couple of options and found that the best one is a custom format that controls all of this: the .codegen file. By referencing Piral you would indirectly reference a .codegen file, which is then picked up by the bundler. The user never has to know. They just npm install and go.

From there it grew a life of its own. I wrote about it in 2022 – Getting Bundling Superpowers Using Codegen – and since then, the family of plugins has grown to cover pretty much every bundler I come across.

How does it work?

The mechanism is the same everywhere:

  1. Your code imports a .codegen file.
  2. The bundler plugin recognizes the extension and, instead of loading the file as-is, evaluates it in Node.js.
  3. The exported function is called. It can be synchronous or async – so reading files and calling APIs is fair game.
  4. The returned string becomes the source of the module. From here on, the bundler treats it like any other module: it can contain imports, be TypeScript-ish or JSX-ish depending on your pipeline, be tree-shaken, and so on.

The function is called with a little context as this:

Member What it is
this.name The file that is being evaluated.
this.options Options of the build, e.g. outDir and rootDir.
this.addDependency(file) Tell the bundler: if this file changes, re-run the generator. (Crucial for watch mode.)

(That's why you should use function () {} and not an arrow function if you need the context.)

Under the hood, the plugins share one small library, codegen-lib, which loads the module (CommonJS by default; files named *.mjs.codegen, *.esm.codegen, *.module.codegen or *.m.codegen are loaded as ES modules), calls the function, and normalizes the result. Each plugin is only a thin layer that connects the library to a bundler's hooks: onLoad in esbuild, resolveId plus load in Vite, a loader in Webpack, a transformer in Parcel v2.

What happens inside codegen-lib

The real superpower: staying bundler-agnostic

When I explain .codegen to people, they often ask: "Why not just use a plugin for X / a config option for Y?"

The answer is: because you want to do something with the bundler without writing it for a bundler.

Say you build an app for the web and for mobile. The web build should use localStorage and fetch; the mobile build should use native storage and native HTTP. In bundler land you'd reach for aliases, define plugins, conditional exports, or some bundler-specific magic – and rewrite it all if you ever switch bundlers.

With codegen you just write what you mean:

// platform.codegen
module.exports = function () {
  const target = process.env.TARGET || 'web';
  return `export * from './platform.${target}';`;
};
Enter fullscreen mode Exit fullscreen mode
// anywhere in the app
import { storage, http } from './platform.codegen';
Enter fullscreen mode Exit fullscreen mode

One import, different modules

The decision can be driven by an environment variable (as above), a special file, a call to a license server, the phase of the moon – whatever you want. It's just Node.js. And because the contract is "return a string", the same file works in Webpack, Parcel, Vite, esbuild, Rollup – and Netpack.

One ring to rule them all

More examples

The nice thing about "it's just code" is that the possibilities are endless. Here are some that I find useful.

1. Build information

Stamp your app with the version, the commit and the build time – and do the computations while bundling instead of in the browser:

// build-info.codegen
const { execSync } = require('child_process');
const { name, version } = require('../package.json');

module.exports = function () {
  const commit = execSync('git rev-parse --short HEAD').toString().trim();
  const [major, minor, patch] = version.split('-')[0].split('.').map(Number);

  return `
    export const name = ${JSON.stringify(name)};
    export const version = ${JSON.stringify(version)};
    export const packageId = ${JSON.stringify(`${name}@${version}`)};
    export const major = ${major};
    export const minor = ${minor};
    export const patch = ${patch};
    export const isPrerelease = ${version.includes('-')};
    export const commit = ${JSON.stringify(commit)};
    export const builtAt = ${JSON.stringify(new Date().toISOString())};
  `;
};
Enter fullscreen mode Exit fullscreen mode
import { packageId, major, isPrerelease } from './build-info.codegen';
Enter fullscreen mode Exit fullscreen mode

A fair question: "Can't I just import { version } from '../package.json'?" You can – and that is a good option for the simple case. Many bundlers (Webpack, Rollup, Vite, esbuild, …) tree-shake JSON files, so if you use the fields as named exports, you don't get the full package.json in your bundle either. (Importing the file as a default object is a different story: you usually get all of it.)

So where is the difference? Codegen works on a finer granularity and allows a lot more:

  • Compile-time computation instead of runtime computation. Splitting the version into major, minor and patch, building the packageId, or checking for a pre-release is work you'd otherwise do in every user's browser, on every start. Here, the result is baked into the bundle as literals.
  • Literals unlock optimizations. The values are plain consts with literal values, so the bundler and the minifier see them as constants: they can be inlined at the use site, and if (isPrerelease) { … } can be removed entirely when the flag is false. With a runtime computation, that is impossible – the bundler can't know the result.
  • Unused exports still disappear. Just like with JSON, named exports that nobody imports are tree-shaken away. You can generate a rich module with ten fields and pay only for the ones you use.
  • Not limited to files. The commit hash comes from git, the build time from the clock. Neither lives in any file a bundler could import.

Tree shaking, in a nutshell: the bundler looks at which exports are actually used and drops the rest. It works best when a module is "static" – plain export const declarations with no side effects. And that's exactly what a good codegen file generates: static code, with all the dynamic parts resolved before the bundler even starts looking.

2. Feature flags that really disappear

// flags.codegen
module.exports = function () {
  return `
    export const beta = ${process.env.BETA === '1'};
    export const telemetry = ${process.env.TELEMETRY !== '0'};
  `;
};
Enter fullscreen mode Exit fullscreen mode
import { beta } from './flags.codegen';

if (beta) {
  // this block can be removed by the minifier when beta is false
}
Enter fullscreen mode Exit fullscreen mode

Since the flags are plain consts with literal values, dead code elimination does its job. The flags don't exist at runtime, they were decided at build time.

3. Languages from a folder – with lazy loading

Add a de.json to your locales folder, and it shows up in the app. No registry to maintain:

// languages.codegen
const { readdirSync } = require('fs');
const { join, basename } = require('path');

module.exports = function () {
  const dir = join(__dirname, 'locales');
  const languages = readdirSync(dir)
    .filter((file) => file.endsWith('.json'))
    .map((file) => basename(file, '.json'));

  languages.forEach((lang) => this.addDependency(join(dir, `${lang}.json`)));

  const loaders = languages
    .map((lang) => `  ${JSON.stringify(lang)}: () => import(${JSON.stringify(`./locales/${lang}.json`)})`)
    .join(',\n');

  return `
    export const languages = ${JSON.stringify(languages)};
    export const load = {
${loaders}
    };
  `;
};
Enter fullscreen mode Exit fullscreen mode

Each language becomes its own chunk, loaded only when somebody asks for it: const messages = await load['de'](). Note the addDependency: in watch mode, editing a locale file re-runs the generator.

4. File-system routing

Everybody loves the "every file in pages/ is a route" convention. With codegen you don't need a meta framework for it:

// routes.codegen
const { readdirSync } = require('fs');
const { join, relative, parse } = require('path');

function walk(dir) {
  return readdirSync(dir, { withFileTypes: true }).flatMap((entry) =>
    entry.isDirectory() ? walk(join(dir, entry.name)) : [join(dir, entry.name)],
  );
}

module.exports = function () {
  const root = join(__dirname, 'pages');
  const entries = walk(root)
    .filter((file) => file.endsWith('.tsx'))
    .map((file) => {
      const rel = relative(root, file).split('\\').join('/');
      const { dir, name } = parse(rel);
      const path = '/' + (name === 'index' ? dir : [dir, name].filter(Boolean).join('/'));
      return `  { path: ${JSON.stringify(path)}, component: lazy(() => import(${JSON.stringify(`./pages/${rel}`)})) }`;
    });

  return `
    import { lazy } from 'react';
    export const routes = [
${entries.join(',\n')}
    ];
  `;
};
Enter fullscreen mode Exit fullscreen mode

pages/index.tsx becomes /, pages/about/index.tsx becomes /about, pages/blog.tsx becomes /blog – each lazily loaded. (This is a simplified version of what I showed in the old article; dynamic segments like [id] are left as an exercise for the reader.)

5. Shared dependencies, à la Piral

This is the kind of thing that started it all. Say you want a module that lists the dependencies an app shell shares with its plugins:

// shared.codegen
const { dependencies } = require('../package.json');

const shared = ['react', 'react-dom', 'react-router-dom'].filter((name) => name in dependencies);

module.exports = function () {
  const entries = shared.map((name) => `  ${JSON.stringify(name)}: () => import(${JSON.stringify(name)})`);

  return `export const shared = {\n${entries.join(',\n')}\n};`;
};
Enter fullscreen mode Exit fullscreen mode

(This is an illustration of the principle, not what Piral ships.) The list is derived from the package.json, so nobody has to maintain it by hand, and the bundler sees real import() calls, so everything gets split and resolved properly.

6. Generating a client from a schema – at build time

Because the function can be async, you can fetch things while bundling:

// api.codegen
module.exports = async function () {
  const response = await fetch('https://api.example.com/openapi.json');
  const spec = await response.json();

  const operations = Object.entries(spec.paths).flatMap(([path, methods]) =>
    Object.entries(methods).map(([method, op]) => ({ id: op.operationId, method: method.toUpperCase(), path })),
  );

  const functions = operations.map(
    ({ id, method, path }) =>
      `export const ${id} = (init) => fetch(${JSON.stringify(path)}, { method: ${JSON.stringify(method)}, ...init });`,
  );

  return functions.join('\n');
};
Enter fullscreen mode Exit fullscreen mode

The result is a module with one function per operation, always in sync with the API as of the build. (Is that a good idea for your CI? Depends on your network. Which brings us to the caveats…)

7. A media library

One of the classics from the old article: take a folder of images and make them importable by name:

// media.codegen
const { readdirSync } = require('fs');
const { join, parse } = require('path');

module.exports = function () {
  const files = readdirSync(join(__dirname, 'avatars'));
  const imports = files.map((file, i) => `import _${i} from ${JSON.stringify(`./avatars/${file}`)};`);
  const entries = files.map((file, i) => `  ${JSON.stringify(parse(file).name)}: _${i}`);

  return `${imports.join('\n')}\nexport const images = {\n${entries.join(',\n')}\n};`;
};
Enter fullscreen mode Exit fullscreen mode

So many possibilities

Codegen in the wild

Piral

Piral is where it all started, and it is still a main consumer: referencing Piral means (indirectly) referencing .codegen files that the bundler plugin picks up. They handle aliases, shared dependencies and the other things a micro frontend needs from its bundler – on Parcel back then, on Webpack v5 today.

Piral.Blazor

For Piral.Blazor, codegen files are used to take a Blazor-powered pilet from the .NET world and bring it into the same format we already know and appreciate in the JS world. From the outside, it looks just like any other pilet.

Netpack

In Netpack, .codegen support lives behind the opt-in Node bridge. Netpack itself is a native .NET application – so, to evaluate a .codegen module, it makes an IPC call to Node.js, which evaluates the module and hands the resulting string back. The string then joins the bundling process like in any other bundler. (If you don't use .codegen, you don't need Node.js for the build. If you do: yes, a .NET bundler calls Node. I know.)

The plugin family

All plugins live in one repository: FlorianRappl/codegen-js. Here is the family:

One file format, many bundlers

Bundler Package
Webpack 4 / 5 parcel-codegen-loader
Parcel v1 (the original) parcel-plugin-codegen
Parcel v2 parcel-transformer-codegen
esbuild esbuild-codegen-plugin
Vite vite-plugin-codegen
Rollup rollup-plugin-codegen
Bun bun-codegen-loader
Netpack built in (via the Node bridge)

For Rspack you can use the Webpack loader – the setup is the same.

(Yes, the Webpack loader is called parcel-codegen-loader. History. The first plugin was for Parcel, and renaming things on npm is a special kind of pain.)

Setting it up

Pick your bundler; the setup is a one-time thing.

Webpack (rule with the loader):

module.exports = {
  module: {
    rules: [
      {
        test: /\.codegen$/i,
        use: [{ loader: 'parcel-codegen-loader' }],
      },
    ],
  },
};
Enter fullscreen mode Exit fullscreen mode

Parcel v2 (.parcelrc):

{
  "extends": "@parcel/config-default",
  "transformers": {
    "*.codegen": ["parcel-transformer-codegen"]
  }
}
Enter fullscreen mode Exit fullscreen mode

Vite and Rollup (plugin):

import codegen from 'vite-plugin-codegen'; // or 'rollup-plugin-codegen'

export default {
  plugins: [codegen()],
};
Enter fullscreen mode Exit fullscreen mode

esbuild:

import { build } from 'esbuild';
import { codegenPlugin } from 'esbuild-codegen-plugin';

await build({
  // ...
  plugins: [codegenPlugin()],
});
Enter fullscreen mode Exit fullscreen mode

From there, import x from './thing.codegen' just works.

The numbers

Not huge, not tiny: in the week from 25 June to 1 July 2026, the Webpack loader saw about 8,800 downloads, the Vite plugin about 3,800, the esbuild plugin ~200, Parcel v1 ~330, Rollup ~70 and Parcel v2 ~30. Each bundler has its own crowd, and Webpack's is the biggest.

A bit of history

The repository was consolidated at the end of 2024. Version 2.0.0 (26 December 2024) brought all packages together in one monorepo, split the core logic into codegen-lib, and added ESM output to the packages. 2.1.0 (same day) fixed a problem in the Webpack loader, introduced the Bun loader and limited the Parcel v1 plugin to CommonJS. There is also an official VS Code extension (florianrappl.vscode-codegen) that treats *.codegen as JavaScript and ships snippets for new files.

The practical side

TypeScript

TypeScript has no idea what a .codegen import contains. Put a declaration file next to it:

// info.codegen.d.ts
export const name: string;
export const version: string;
Enter fullscreen mode Exit fullscreen mode

Testing

Your unit-test runner isn't a bundler, so it doesn't know .codegen either – unless it is built on one. There are three approaches: mock the import (jest.mock / vi.mock, or a small .js stand-in), add a transformer that evaluates the codegen files, or – if your runner is based on a bundler – use the plugin for it.

Jest has no bundler underneath, so you add a transform entry:

transform: {
  '^.+\\.codegen$': resolve(__dirname, 'codegen.js'),
},
Enter fullscreen mode Exit fullscreen mode

(See the old article for a transformer implementation; Jest needs a synchronous one.)

Vitest is built on Vite and reuses its configuration and plugins. So there is nothing to write: if you already use vite-plugin-codegen for your app, the same plugin works in your tests. If you don't have a Vite config yet, add one for Vitest:

// vitest.config.ts
import { defineConfig } from 'vitest/config';
import codegen from 'vite-plugin-codegen';

export default defineConfig({
  plugins: [codegen()],
  test: {
    // your usual Vitest options
  },
});
Enter fullscreen mode Exit fullscreen mode

Now import { version } from './build-info.codegen' in a test runs the real generator, just like in the build. And if you'd rather have a fixed result in a test, vi.mock('./build-info.codegen', () => ({ version: '1.0.0' })) does the job.

Debugging

Remember: the generated module is what gets bundled. If something looks wrong, console.log the string your function returns. It's the single best debugging technique for codegen.

Where Codegen shines

  • Bundler-agnostic. One mechanism, many bundlers. Switch bundlers, keep your codegen files.
  • No new language. It's Node.js. Everyone already knows how to read files, call fetch or look at process.env.
  • Maximum control. Build-time decisions become plain constants; generated imports become normal chunks.
  • Small. The plugins are tiny wrappers around one shared library.
  • Great for conventions. "Every file in this folder is X" becomes a ten-line generator instead of a manual registry.

Where it struggles

Let's be honest:

  • It runs arbitrary code at build time. A .codegen file has full Node.js access: it can read secrets, call the network, and run commands. Treat these files like build scripts, not like app code – because that's what they are.
  • No parameters. A .codegen file can't take arguments from the importer. The workarounds are generating several modules, or exporting many named values from one and letting tree shaking remove the unused ones. Dynamic import() can't take parameters either, so lazy-loading recipes generate loadX functions per file.
  • It's not cached. Every load re-runs the generator. Expensive generators (network!) make for slow builds, so keep them lean or do the caching yourself.
  • CommonJS first. CJS is the default; ES module generators are possible via the .esm.codegen-style names, but the ESM loader only rewrites simple import x from './y' statements. If you stay on the common path, it's fine; if you get fancy, you'll find the edge.
  • Debugging is indirect. The code you ran is not the code you ship – by design, and sometimes annoying.
  • Editor support is minimal. The VS Code extension treats the files as JavaScript and ships snippets; that's about it.
  • Tests. The repository doesn't contain a dedicated test suite; the plugins are small, but be aware of it.

With great power...

What's next

Honestly: not much has to happen. It's one of the "finished" kind of projects – a tiny mechanism that does its job. The most visible movement lately has been breadth: new bundlers keep appearing (the Bun loader was the newest addition in 2.1.0). (Rspack doesn't even need its own plugin – the Webpack loader works there.) When a new bundler gets traction, adding another thin wrapper around codegen-lib is an afternoon.

If you have an idea for a codegen recipe that deserves to be in the docs – or a bundler that deserves a plugin – open an issue or a pull request against the develop branch.

Links

That's a wrap

This was the last article of the series. Thanks for reading all the way through! Here are all the parts, in case you missed one:

  1. AngleSharp – the .NET HTML parser that started it all.
  2. MAGES – a parser and interpreter for mathematical expressions.
  3. Piral – the micro frontend framework.
  4. Electron.NET – .NET meets Electron.
  5. Netpack – a native bundler for the .NET world.
  6. Picard.js – a lightweight micro frontend orchestrator.
  7. dets – a TypeScript declaration file bundler.
  8. Codegen – metaprogramming for your bundler (you are here).

If something in the series resonated with you: try the projects, open an issue, send a pull request, or just say hi. Open source lives from the people who use it.

Top comments (3)

Collapse
 
alexshev profile image
Alex Shev •

Build-time generation makes the inputs part of the build contract. A useful check is to regenerate twice from the same inputs and compare outputs, then change a declared input and confirm the bundler invalidates its cache.

Collapse
 
florianrappl profile image
Florian Rappl •

That only works if you know that the module is side-effect free, which is, unfortunately, not a property that codegen enforces / requires.

Collapse
 
robertpaschal profile image
Odinaka Robert Nnamani •

a codegen project living in public is its own kind of maintenance, every old output still out there.