explanation

Using typescript hybrid compiler to compile for ESM and CommonJS is a experimental for NodeJS.
Some function/magic function/constructor is not supported by CommonJS. So, you must define them manually, such as: __dirname, __filename

for an example below error outputs:

(node:11396) ExperimentalWarning: Custom ESM Loaders is an experimental feature and might change at any time
(Use `node --trace-warnings ...` to show where the warning was created)
(node:11396) ExperimentalWarning: The Node.js specifier resolution flag is experimental. It could change or be 
removed at any time.
ReferenceError: __dirname is not defined in ES module scope
    at file:///d:/Workspaces/Javascript/whatsapp-autoreply/test/xlsxParse.ts:9:40
    at ModuleJob.run (node:internal/modules/esm/module_job:194:25)

__dirname and __filename resolver for ESM and CommonJS

export const getDirname = () => {
  // get the stack
  const { stack } = new Error();
  // get the third line (the original invoker)
  const invokeFileLine = (stack || '').split(`\n`)[2];
  // match the file url from file://(.+.(ts|js)) and get the first capturing group
  const __filename = (invokeFileLine.match(/file:\/\/(.+.(ts|js))/) || [])[1].slice(1);
  // match the file URL from file://(.+)/ and get the first capturing group
  //     the (.+) is a greedy quantifier and will make the RegExp expand to the largest match
  const __dirname = (invokeFileLine.match(/file:\/\/(.+)\//) || [])[1].slice(1);
  return { __dirname, __filename };
};
export default getDirname;

Usage

import * as path from 'path';
import { getDirname } from './dirname.ts'; // to prevent called by ESM, we using static import
const __resolve = getDirname();
const __filename = __resolve.__filename;
const __dirname = path.dirname(__filename);
console.log(__dirname, __filename); // /media/dimaslanjaka/DATA/Repositories/traffic-generator/express/src/public/routes /media/dimaslanjaka/DATA/Repositories/traffic-generator/express/src/public/routes/index.ts

Usage Force Unix Style

Install upath

npm i upath

Call them

import path from 'upath';
import { getDirname } from 'config';
const { dirname } = path;
const __resolve = getDirname();
const __filename = __resolve.__filename;
const __dirname = dirname(__filename);

This is trick for resolving __dirname and __filename on typescript ESM hybrid compiler

Hybrid ESM/CJS modules in TypeScript

Generating and using hybrid node modules that target both CommonJS/CJS and ECMAScript modules/ESM is difficult to modify to allow these modules to be used within node as well.

This repo provides a working example of how to achieve this in TypeScript (based on a previous example by @demurgos).

Why?

Support for 'old' CommonJS modules is always needed, but to get the most out of modern tools like 'Rollup', release ES modules as well so these tools work their magic and bundle size should be able to shrink.

Gotchas

First of all, in order to publicly export a CJS and ESM entrypoint, you need to supply main (CJS or hybrid entrypoint), module (ESM entry point), browser (CJS entry point), and types for correct TypeScript type export in your package.json.

However, node does not accept standard .js files as ESM modules, but you need to either supply a loader script that handles module type detection (slow, potentially error prone) or change the filenames of all ES modules to .mjs, which node natively recognises as ESM modules.

TypeScript unfortunately does not support renaming output files, so this needs to be done manually.

Finally, importing the modules correctly requires different compiler arguments (see below).

Structure

The hybrid directory contains a hybrid CJS/ESM module (package.json) that just exports a single function. The function prints a string and returns a boolean indicating whether this function was executed in CJS or ESM.

It gets imported by our main repo (package.json), and the exported function gets used. This app just prints a few strings and finally whether the imported module was an ES or CJS module.

Detecting CJS vs ESM

In ESM, the exports variable does not exist, since exports are handled via the export keyword. Hence, whether or not this variable is undefined tells us if the executing context is ESM or CJS.

Install

Install the dependencies of the hybrid module, build it, then install the dependencies in the main repo.

cd hybrid && npm install && npm run build && cd .. && npm install

Build config

Any ES modules need to be renamed to .mjs or node.js will fail to recognise them as such and throw errors.

Building hybrid apps

From the main project package.json:

{
  "scripts": {
    "build:mjs": "tsc --rootDir src --outDir build/mjs --moduleResolution node --allowSyntheticDefaultImports --module esnext src/*.ts && mv build/mjs/index.js build/mjs/index.mjs",
    "build:cjs": "tsc --rootDir src --outDir build/cjs --moduleResolution node --esModuleInterop src/*.ts",
  }
}

For regular applications, it's a good idea to split CJS and ESM output into different directories, because they will be used separately anyway. In both cases, you want moduleResolution set to node.

To build an ES module app (that can also work with CJS modules), you need to enable the option allowSyntheticDefaultImports and set the module compiler option to esnext.

To build a CommonJS module app (that can also work with ES modules), you need to enable the option esModuleInterop.

Building hybrid packages

From the hybrid package package.json:

{
  "main": "./dist/hybrid",
  "types": "./dist/hybrid.d.ts",
  "browser": "./dist/hybrid.js",
  "module": "./dist/hybrid.mjs",
  "scripts": {
    "build:mjs": "tsc --allowSyntheticDefaultImports --module esnext && npm run move-esm-output && npm run process-source-maps",
    "move-esm-output": "mv dist/hybrid.js dist/hybrid.mjs && mv dist/say-hello.js dist/say-hello.mjs",
    "process-source-maps": "sed -e \"s/hybrid.js/hybrid.mjs/g\" ./dist/hybrid.js.map > ./dist/hybrid.mjs.map && sed -e \"s/say-hello.js/say-hello.mjs/g\" ./dist/say-hello.js.map > ./dist/say-hello.mjs.map",
    "build:cjs": "tsc --esModuleInterop",
    "build": "npm run build:mjs && npm run build:cjs"
  }

First of all, if no file extension is specified in the main field, the module loader can choose the desired from the available options at the path. If the module loader is configured to preferably load ES modules, then the .mjs file will be loaded if it exists, otherwise the .js at the spot will be loaded as a CJS module.
A hybrid module will have a .js and a .mjs version of each file in the module.
The build process for the ES module files is as follows:

  • Build the ES module with typescript: This generates .js code files and .js.map source map files. Note that you need to specify --allowSyntheticDefaultImports and --module esnext as compiler options.
  • Rename all generated .js files to .mjs (see the move-esm-output task above)
  • Parse all source map files and rename the target filename inside from the .js version to .mjs. Then output the replaced code at the same path ending in .mjs.map (see process-source-maps task above).
    The build process for CJS module files is simpler and it should be run after the ES module code has been generated and post-processed (i.e. not in parallel). You only need to run tsc with the additional compiler option --esModuleInterop.
    The final result will be, the following set of files for each individual source TypeScript source file / module (using the example file <some_path>/filename.ts):
  • <some_path>/filename.d.ts: The TypeScript definition file.
  • <some_path>/filename.js: The CommonJS version of the module
  • <some_path>/filename.js.map: the source map of the CommonJS module.
  • <some_path>/filename.mjs: The ESM version of the module
  • <some_path>/filename.mjs.map: the source map of the ES module.

Minor gotcha

Technically, in ES modules, you need to specify the full file path of an import (according to the spec). Within node, this is not necessary though, if an .mjs file exists at that location. On the browser side, this will not work, so you should post-process your output further to rewrite the paths to fully qualified paths including extension. Alternatively, you could reconfigure the module loader to search at the appropriate locations.
The easiest option though is probably to just bundle up all the output using an ESM-aware bundler like rollup.

Running hybrid apps

From the main project package.json:

{
  "scripts": {
    "start:mjs": "npm run build:mjs && node --experimental-modules build/mjs/index.mjs",
    "start:cjs": "npm run build:cjs && node build/cjs/index.js"
  }
}

To start node using CommonJS, you don't need to do anything and can just run node <PATH_TO_TARGET>.js.
To start node using/supporting ES modules, you need to (currently) supply the --experimental-modules option and then a path to the entrypoint .mjs file (node --experimental-modules <PATH_TO_TARGET>.mjs).

License

MIT License