Skip to content

CJS Utilities in ESM

Convert ESM module metadata (import.meta) to CommonJS equivalents (require, __filename, __dirname). The native ESM alternatives are verbose and repetitive — fileURLToPath(import.meta.url) and createRequire(import.meta.url) in every file that needs them. A single cjsify(import.meta) call returns all three.

When to use

Use this utility in any ESM module that needs require, __filename, or __dirname. CJS got these right — they are concise and obvious. ESM replaced them with verbose incantations like fileURLToPath(import.meta.url) that nobody wants to write in every file. This wrapper restores the ergonomics.

The pattern

Loading CJS-only packages

Call cjsify(import.meta) and destructure the require function to load packages that only support CommonJS:

js
import { cjsify } from "./module.js";

const { require } = cjsify(import.meta);

// tree-kill and pidtree are CJS-only
const kill = require("tree-kill");
const pidtree = require("pidtree");

Getting the current directory

Destructure __dirname from cjsify(import.meta) to resolve paths relative to the current module, just as you would in CommonJS:

js
import { cjsify } from "./module.js";

const { __dirname } = cjsify(import.meta);

const configPath = join(__dirname, "config.json");

Combining require and paths

Destructure all three values when a module needs both CJS package loading and path resolution:

js
import { cjsify } from "./module.js";

const { require, __dirname, __filename } = cjsify(import.meta);

console.log(`Running: ${__filename}`);
console.log(`From: ${__dirname}`);

const pkg = require("./package.json");

Implementing cjsify

The cjsify function takes import.meta and returns the three CommonJS globals that ESM removes:

js
import { dirname } from "node:path";
import { fileURLToPath } from "node:url";
import { createRequire } from "node:module";

export function cjsify(importMeta) {
  const require = createRequire(importMeta.url);
  const __filename = fileURLToPath(importMeta.url);
  const __dirname = dirname(__filename);
  
  return { require, __filename, __dirname };
}

The returned object provides:

  • require — a working require() function scoped to the calling module's location, created via createRequire()
  • __filename — the absolute file path of the calling module, converted from import.meta.url
  • __dirname — the directory containing the calling module

How it works

ESM uses URL-based module resolution (import.meta.url returns a file:// URL), while CJS uses file-path-based resolution. The three conversions bridge this gap:

  1. createRequire(importMeta.url) — builds a require() function that resolves paths relative to the URL
  2. fileURLToPath(importMeta.url) — converts file:///path/to/file.js to /path/to/file.js
  3. dirname(__filename) — extracts the directory from the file path

Trade-offs

Benefits:

  • One function call gives you all three CJS globals
  • require() is scoped to the caller's directory, matching CJS behavior exactly
  • No runtime overhead beyond the initial conversion
  • Works on any platform (the URL-to-path conversion handles Windows drive letters)

Costs:

  • require() is synchronous — it blocks the event loop while loading modules. Use dynamic import() when the package supports ESM.
  • Calling cjsify(import.meta) at the top of every file is verbose. Consider re-exporting from a shared module (e.g., shared/nodejs/module.js) and importing the destructured result.
  • Some bundlers may not optimize require() calls the same way they optimize import statements.