THRIFT-5811: Add ESM support to nodejs codegen
Client: nodejs
Patch: Cameron Martin <cameronm@graphcore.ai>

This closes #3083

This adds a flag to the JS generator to output ES modules instead of CommonJS. This is only valid when targeting node. A lot of the changes here are to test this.

The `testAll.sh` script now generates an ES module version of the services and types, and tests the client and the server with these. This has a few knock-on effects. Firstly, any module that imports a generated ES module must itself be an ES module, since CommonJS modules cannot import ES modules. ES modules also do not support `NODE_PATH`, so instead the tests directory is converted into a node package with a `file:` dependency on the root thrift package.
diff --git a/compiler/cpp/src/thrift/generate/t_js_generator.cc b/compiler/cpp/src/thrift/generate/t_js_generator.cc
index 27240e4..402ff50 100644
--- a/compiler/cpp/src/thrift/generate/t_js_generator.cc
+++ b/compiler/cpp/src/thrift/generate/t_js_generator.cc
@@ -68,6 +68,7 @@
     gen_jquery_ = false;
     gen_ts_ = false;
     gen_es6_ = false;
+    gen_esm_ = false;
     gen_episode_file_ = false;
 
     bool with_ns_ = false;
@@ -83,6 +84,8 @@
         with_ns_ = true;
       } else if( iter->first.compare("es6") == 0) {
         gen_es6_ = true;
+      } else if ( iter->first.compare("esm") == 0) {
+        gen_esm_ = true;
       } else if( iter->first.compare("imports") == 0) {
         parse_imports(program, iter->second);
       } else if (iter->first.compare("thrift_package_output_directory") == 0) {
@@ -105,6 +108,10 @@
       throw std::invalid_argument("invalid switch: [-gen js:with_ns] is only valid when using node.js");
     }
 
+    if (!gen_node_ && gen_esm_) {
+      throw std::invalid_argument("invalid switch: [-gen js:esm] is only valid when using node.js");
+    }
+
     // Depending on the processing flags, we will update these to be ES6 compatible
     js_const_type_ = "var ";
     js_let_type_ = "var ";
@@ -278,13 +285,6 @@
     return js_namespace(p);
   }
 
-  std::string js_export_namespace(t_program* p) {
-    if (gen_node_) {
-      return "exports.";
-    }
-    return js_namespace(p);
-  }
-
   bool has_js_namespace(t_program* p) {
     if (no_ns_) {
       return false;
@@ -375,6 +375,11 @@
   bool gen_es6_;
 
   /**
+   * True if we should generate ES modules, instead of CommonJS.
+   */
+  bool gen_esm_;
+
+  /**
    * True if we will generate an episode file.
    */
   bool gen_episode_file_;
@@ -452,7 +457,7 @@
     f_episode_.open(f_episode_file_path);
   }
 
-  const auto f_types_name = outdir + program_->get_name() + "_types.js";
+  const auto f_types_name = outdir + program_->get_name() + "_types" + (gen_esm_ ? ".mjs" : ".js");
   f_types_.open(f_types_name.c_str());
   if (gen_episode_file_) {
     const auto types_module = program_->get_name() + "_types";
@@ -478,7 +483,13 @@
   }
 
   if (gen_node_) {
-    f_types_ << js_const_type_ << "ttypes = module.exports = {};" << '\n';
+    if (gen_esm_) {
+      // Import the current module, so we can reference it as ttypes. This is
+      // fine in ESM, because it allows circular imports.
+      f_types_ << "import * as ttypes from './" + program_->get_name() + "_types.mjs';" << '\n';
+    } else {
+      f_types_ << js_const_type_ << "ttypes = module.exports = {};" << '\n';
+    }
   }
 
   string pns;
@@ -507,12 +518,26 @@
  */
 string t_js_generator::js_includes() {
   if (gen_node_) {
-    string result = js_const_type_ + "thrift = require('thrift');\n"
-        + js_const_type_ + "Thrift = thrift.Thrift;\n";
-    if (!gen_es6_) {
-      result += js_const_type_ + "Q = thrift.Q;\n";
+    string result;
+    
+    if (gen_esm_) {
+      result += "import { Thrift } from 'thrift';\n";
+    } else {
+      result += js_const_type_ + "thrift = require('thrift');\n"
+          + js_const_type_ + "Thrift = thrift.Thrift;\n";
     }
-    result += js_const_type_ + "Int64 = require('node-int64');\n";
+    if (!gen_es6_) {
+      if (gen_esm_) {
+        result += "import { Q } from 'thrift';\n";
+      } else {
+        result += js_const_type_ + "Q = thrift.Q;\n";
+      }
+    }
+    if (gen_esm_) {
+      result += "import Int64 from 'node-int64';";
+    } else {
+      result += js_const_type_ + "Int64 = require('node-int64');\n";
+    }
     return result;
   }
   string result = "if (typeof Int64 === 'undefined' && typeof require === 'function') {\n  " + js_const_type_ + "Int64 = require('node-int64');\n}\n";
@@ -556,7 +581,11 @@
   if (gen_node_) {
     const vector<t_program*>& includes = program_->get_includes();
     for (auto include : includes) {
-      result += js_const_type_ + make_valid_nodeJs_identifier(include->get_name()) + "_ttypes = require('" + get_import_path(include) + "');\n";
+      if (gen_esm_) {
+        result += "import * as " + make_valid_nodeJs_identifier(include->get_name()) + "_ttypes from '" + get_import_path(include) + "';\n";
+      } else {
+        result += js_const_type_ + make_valid_nodeJs_identifier(include->get_name()) + "_ttypes = require('" + get_import_path(include) + "');\n";
+      }
     }
     if (includes.size() > 0) {
       result += "\n";
@@ -590,17 +619,17 @@
 
 string t_js_generator::get_import_path(t_program* program) {
   const string import_file_name(program->get_name() + "_types");
+  const string import_file_name_with_extension = import_file_name + (gen_esm_ ? ".mjs" : ".js");
+
   if (program->get_recursive()) {
-    return "./" + import_file_name;
+    return "./" + import_file_name_with_extension;
   }
 
-  const string import_file_name_with_extension = import_file_name + ".js";
-
-    auto module_name_and_import_path_iterator = module_name_2_import_path.find(import_file_name);
-    if (module_name_and_import_path_iterator != module_name_2_import_path.end()) {
-      return module_name_and_import_path_iterator->second;
-    }
-    return "./" + import_file_name;
+  auto module_name_and_import_path_iterator = module_name_2_import_path.find(import_file_name);
+  if (module_name_and_import_path_iterator != module_name_2_import_path.end()) {
+    return module_name_and_import_path_iterator->second;
+  }
+  return "./" + import_file_name_with_extension;
 }
 
 /**
@@ -638,7 +667,11 @@
  * @param tenum The enumeration
  */
 void t_js_generator::generate_enum(t_enum* tenum) {
-  f_types_ << js_type_namespace(tenum->get_program()) << tenum->get_name() << " = {" << '\n';
+  if (gen_esm_) {
+    f_types_ << "export const " << tenum->get_name() << " = {" << '\n';
+  } else {
+    f_types_ << js_type_namespace(tenum->get_program()) << tenum->get_name() << " = {" << '\n';
+  }
 
   if (gen_ts_) {
     f_types_ts_ << ts_print_doc(tenum) << ts_indent() << ts_declare() << "enum "
@@ -680,7 +713,11 @@
   string name = tconst->get_name();
   t_const_value* value = tconst->get_value();
 
-  f_types_ << js_type_namespace(program_) << name << " = ";
+  if (gen_esm_) {
+    f_types_ << "export const " << name << " = ";
+  } else {
+    f_types_ << js_type_namespace(program_) << name << " = ";
+  }
   f_types_ << render_const_value(type, value) << ";" << '\n';
 
   if (gen_ts_) {
@@ -859,9 +896,18 @@
   vector<t_field*>::const_iterator m_iter;
 
   if (gen_node_) {
+    string commonjs_export = "";
+
+    if (is_exported) {
+      if (gen_esm_) {
+        out << "export ";
+      } else {
+        commonjs_export = " = module.exports." + tstruct->get_name();
+      }
+    }
+
     string prefix = has_js_namespace(tstruct->get_program()) ? js_namespace(tstruct->get_program()) : js_const_type_;
-    out << prefix << tstruct->get_name() <<
-      (is_exported ? " = module.exports." + tstruct->get_name() : "");
+    out << prefix << tstruct->get_name() << commonjs_export;
     if (gen_ts_) {
       f_types_ts_ << ts_print_doc(tstruct) << ts_indent() << ts_declare() << "class "
                   << tstruct->get_name() << (is_exception ? " extends Thrift.TException" : "")
@@ -1198,7 +1244,7 @@
  * @param tservice The service definition
  */
 void t_js_generator::generate_service(t_service* tservice) {
-  string f_service_name = get_out_dir() + service_name_ + ".js";
+  string f_service_name = get_out_dir() + service_name_ + (gen_esm_ ? ".mjs" : ".js");
   f_service_.open(f_service_name.c_str());
   if (gen_episode_file_) {
     f_episode_ << service_name_ << ":" << thrift_package_output_directory_ << "/" << service_name_ << '\n';
@@ -1282,7 +1328,11 @@
                     << tservice->get_extends()->get_name() << "');" << '\n';
     }
 
-    f_service_ << js_const_type_ << "ttypes = require('./" + program_->get_name() + "_types');" << '\n';
+    if (gen_esm_) {
+      f_service_ << "import * as ttypes from './" + program_->get_name() + "_types.mjs';" << '\n';
+    } else {
+      f_service_ << js_const_type_ << "ttypes = require('./" + program_->get_name() + "_types');" << '\n';
+    }
   }
 
   generate_service_helpers(tservice);
@@ -1317,27 +1367,28 @@
   vector<t_function*> functions = tservice->get_functions();
   vector<t_function*>::iterator f_iter;
 
-  if (gen_node_) {
-    string prefix = has_js_namespace(tservice->get_program()) ? js_namespace(tservice->get_program()) : js_const_type_;
-    f_service_ << prefix << service_name_ << "Processor = " << "exports.Processor";
-    if (gen_ts_) {
-      f_service_ts_ << '\n' << "declare class Processor ";
-      if (tservice->get_extends() != nullptr) {
-        f_service_ts_ << "extends " << tservice->get_extends()->get_name() << ".Processor ";
-      }
-      f_service_ts_ << "{" << '\n';
-      indent_up();
-
-      if(tservice->get_extends() == nullptr) {
-        f_service_ts_ << ts_indent() << "private _handler: object;" << '\n' << '\n';
-      }
-      f_service_ts_ << ts_indent() << "constructor(handler: object);" << '\n';
-      f_service_ts_ << ts_indent() << "process(input: thrift.TProtocol, output: thrift.TProtocol): void;" << '\n';
-      indent_down();
-    }
+  std::string service_var;
+  if (!gen_node_ || has_js_namespace(tservice->get_program())) {
+    service_var = js_namespace(tservice->get_program()) + service_name_ + "Processor";
+    f_service_ << service_var;
   } else {
-    f_service_ << js_namespace(tservice->get_program()) << service_name_ << "Processor = "
-             << "exports.Processor";
+    service_var = service_name_ + "Processor";
+    f_service_ << js_const_type_ << service_var;
+  };
+  if (gen_node_ && gen_ts_) {
+    f_service_ts_ << '\n' << "declare class Processor ";
+    if (tservice->get_extends() != nullptr) {
+      f_service_ts_ << "extends " << tservice->get_extends()->get_name() << ".Processor ";
+    }
+    f_service_ts_ << "{" << '\n';
+    indent_up();
+
+    if(tservice->get_extends() == nullptr) {
+      f_service_ts_ << ts_indent() << "private _handler: object;" << '\n' << '\n';
+    }
+    f_service_ts_ << ts_indent() << "constructor(handler: object);" << '\n';
+    f_service_ts_ << ts_indent() << "process(input: thrift.TProtocol, output: thrift.TProtocol): void;" << '\n';
+    indent_down();
   }
 
   bool is_subclass_service = tservice->get_extends() != nullptr;
@@ -1419,6 +1470,12 @@
   if (gen_node_ && gen_ts_) {
     f_service_ts_ << "}" << '\n';
   }
+
+  if(gen_esm_) {
+    f_service_ << "export { " << service_var << " as Processor };" << '\n';
+  } else {
+    f_service_ << "exports.Processor = " << service_var << ";" << '\n';
+  }
 }
 
 /**
@@ -1702,9 +1759,10 @@
 
   bool is_subclass_service = tservice->get_extends() != nullptr;
 
+  string client_var = js_namespace(tservice->get_program()) + service_name_ + "Client";
   if (gen_node_) {
-    string prefix = has_js_namespace(tservice->get_program()) ? js_namespace(tservice->get_program()) : js_const_type_;
-    f_service_ << prefix << service_name_ << "Client = " << "exports.Client";
+    string prefix = has_js_namespace(tservice->get_program()) ? "" : js_const_type_;
+    f_service_ << prefix << client_var;
     if (gen_ts_) {
       f_service_ts_ << ts_print_doc(tservice) << ts_indent() << ts_declare() << "class "
                     << "Client ";
@@ -1714,8 +1772,7 @@
       f_service_ts_ << "{" << '\n';
     }
   } else {
-    f_service_ << js_namespace(tservice->get_program()) << service_name_
-               << "Client";
+    f_service_ << client_var;
     if (gen_ts_) {
       f_service_ts_ << ts_print_doc(tservice) << ts_indent() << ts_declare() << "class "
                     << service_name_ << "Client ";
@@ -2204,6 +2261,12 @@
     indent_down();
     f_service_ << "};" << '\n';
   }
+
+  if(gen_esm_) {
+    f_service_ << "export { " << client_var << " as Client };" << '\n';
+  } else if(gen_node_) {
+    f_service_ << "exports.Client = " << client_var << ";" << '\n';
+  }
 }
 
 std::string t_js_generator::render_recv_throw(std::string var) {
diff --git a/eslint.config.mjs b/eslint.config.mjs
index a27b211..2f70a88 100644
--- a/eslint.config.mjs
+++ b/eslint.config.mjs
@@ -24,7 +24,7 @@
         ...globals.node,
       },
 
-      ecmaVersion: 2017,
+      ecmaVersion: 2022,
       sourceType: "commonjs",
     },
 
@@ -41,4 +41,10 @@
       ],
     },
   },
+  {
+    files: ["**/*.mjs"],
+    languageOptions: {
+      sourceType: "module",
+    },
+  },
 ];
diff --git a/lib/nodejs/Makefile.am b/lib/nodejs/Makefile.am
index 9503f04..5be8161 100644
--- a/lib/nodejs/Makefile.am
+++ b/lib/nodejs/Makefile.am
@@ -20,9 +20,14 @@
 stubs: $(top_srcdir)/test/v0.16/ThriftTest.thrift
 	$(THRIFT) --gen js:node -o test/ $(top_srcdir)/test/v0.16/ThriftTest.thrift
 
-deps: $(top_srcdir)/package.json
+deps-root: $(top_srcdir)/package.json
 	$(NPM) install $(top_srcdir)/ || $(NPM) install $(top_srcdir)/
 
+deps-test: test/package.json test/package-lock.json
+	cd test/ && $(NPM) install && cd ..
+
+deps: deps-root deps-test
+
 all-local: deps
 
 precross: deps stubs
diff --git a/lib/nodejs/test/binary.test.js b/lib/nodejs/test/binary.test.js
index 343d39a..1e749d8 100644
--- a/lib/nodejs/test/binary.test.js
+++ b/lib/nodejs/test/binary.test.js
@@ -18,7 +18,7 @@
  */
 
 const test = require("tape");
-const binary = require("thrift/binary");
+const binary = require("thrift/lib/nodejs/lib/thrift/binary");
 
 const cases = {
   "Should read signed byte": function (assert) {
diff --git a/lib/nodejs/test/client.js b/lib/nodejs/test/client.mjs
similarity index 90%
rename from lib/nodejs/test/client.js
rename to lib/nodejs/test/client.mjs
index 617039b..6200dc6 100644
--- a/lib/nodejs/test/client.js
+++ b/lib/nodejs/test/client.mjs
@@ -19,17 +19,19 @@
  * under the License.
  */
 
-const assert = require("assert");
-const thrift = require("thrift");
-const helpers = require("./helpers");
+import assert from "assert";
+import thrift from "thrift";
+import helpers from "./helpers.js";
 
-const ThriftTest = require(`./${helpers.genPath}/ThriftTest`);
-const ThriftTestDriver = require("./test_driver").ThriftTestDriver;
-const ThriftTestDriverPromise =
-  require("./test_driver").ThriftTestDriverPromise;
-const SecondService = require(`./${helpers.genPath}/SecondService`);
+const ThriftTest = await import(
+  `./${helpers.genPath}/ThriftTest.${helpers.moduleExt}`
+);
+import { ThriftTestDriver, ThriftTestDriverPromise } from "./test_driver.mjs";
+const SecondService = await import(
+  `./${helpers.genPath}/SecondService.${helpers.moduleExt}`
+);
 
-const { program } = require("commander");
+import { program } from "commander";
 
 program
   .option(
@@ -55,6 +57,7 @@
   )
   .option("--es6", "Use es6 code")
   .option("--es5", "Use es5 code")
+  .option("--esm", "Use es modules")
   .parse(process.argv);
 
 const opts = program.opts();
@@ -171,4 +174,4 @@
   });
 }
 
-exports.expressoTest = function () {};
+export const expressoTest = function () {};
diff --git a/lib/nodejs/test/helpers.js b/lib/nodejs/test/helpers.js
index 51a0523..1ece2f1 100644
--- a/lib/nodejs/test/helpers.js
+++ b/lib/nodejs/test/helpers.js
@@ -18,7 +18,7 @@
  */
 
 "use strict";
-const thrift = require("../lib/thrift");
+const thrift = require("thrift");
 
 module.exports.transports = {
   buffered: thrift.TBufferedTransport,
@@ -32,7 +32,36 @@
   header: thrift.THeaderProtocol,
 };
 
-module.exports.ecmaMode = process.argv.includes("--es6") ? "es6" : "es5";
-module.exports.genPath = process.argv.includes("--es6")
-  ? "gen-nodejs-es6"
-  : "gen-nodejs";
+const variant = (function () {
+  if (process.argv.includes("--es6")) {
+    return "es6";
+  } else if (process.argv.includes("--esm")) {
+    return "esm";
+  } else {
+    return "es5";
+  }
+})();
+
+module.exports.ecmaMode = ["esm", "es6"].includes(variant) ? "es6" : "es5";
+const genPath = (module.exports.genPath = (function () {
+  if (variant == "es5") {
+    return "gen-nodejs";
+  } else {
+    return `gen-nodejs-${variant}`;
+  }
+})());
+
+const moduleExt = (module.exports.moduleExt = variant === "esm" ? "mjs" : "js");
+
+/**
+ * Imports a types module, correctly handling the differences in esm and commonjs
+ */
+module.exports.importTypes = async function (filename) {
+  const typesModule = await import(`./${genPath}/${filename}.${moduleExt}`);
+
+  if (variant === "esm") {
+    return typesModule;
+  } else {
+    return typesModule.default;
+  }
+};
diff --git a/lib/nodejs/test/include.test.mjs b/lib/nodejs/test/include.test.mjs
new file mode 100644
index 0000000..70c7b24
--- /dev/null
+++ b/lib/nodejs/test/include.test.mjs
@@ -0,0 +1,18 @@
+import test from "tape";
+import { IncludeTest as IncludeTestEs5 } from "./gen-nodejs/Include_types.js";
+import { IncludeTest as IncludeTestEs6 } from "./gen-nodejs-es6/Include_types.js";
+import { IncludeTest as IncludeTestEsm } from "./gen-nodejs-esm/Include_types.mjs";
+
+function constructTest(classVariant) {
+  return function (t) {
+    const obj = new classVariant({ bools: { im_true: true, im_false: false } });
+
+    t.assert(obj.bools.im_true === true);
+    t.assert(obj.bools.im_false === false);
+    t.end();
+  };
+}
+
+test("construct es5", constructTest(IncludeTestEs5));
+test("construct es6", constructTest(IncludeTestEs6));
+test("construct esm", constructTest(IncludeTestEsm));
diff --git a/lib/nodejs/test/package-lock.json b/lib/nodejs/test/package-lock.json
new file mode 100644
index 0000000..e7f9543
--- /dev/null
+++ b/lib/nodejs/test/package-lock.json
@@ -0,0 +1,52 @@
+{
+  "name": "test",
+  "lockfileVersion": 3,
+  "requires": true,
+  "packages": {
+    "": {
+      "devDependencies": {
+        "thrift": "file:../../.."
+      }
+    },
+    "../../..": {
+      "version": "0.22.0",
+      "dev": true,
+      "license": "Apache-2.0",
+      "dependencies": {
+        "browser-or-node": "^1.2.1",
+        "isomorphic-ws": "^4.0.1",
+        "node-int64": "^0.4.0",
+        "q": "^1.5.0",
+        "ws": "^5.2.3"
+      },
+      "devDependencies": {
+        "@eslint/js": "^9.18.0",
+        "@types/node": "^22.10.5",
+        "@types/node-int64": "^0.4.29",
+        "@types/q": "^1.5.1",
+        "buffer-equals": "^1.0.4",
+        "commander": "^13.0.0",
+        "connect": "^3.6.6",
+        "eslint": "^9.18.0",
+        "eslint-config-prettier": "^10.0.1",
+        "eslint-plugin-prettier": "^5.2.1",
+        "globals": "^15.14.0",
+        "html-validator-cli": "^2.0.0",
+        "jsdoc": "^4.0.2",
+        "json-int64": "^1.0.2",
+        "nyc": "^15.0.0",
+        "prettier": "^3.4.2",
+        "tape": "^4.9.0",
+        "typescript": "^5.7.2",
+        "utf-8-validate": "^5.0.0"
+      },
+      "engines": {
+        "node": ">= 10.18.0"
+      }
+    },
+    "node_modules/thrift": {
+      "resolved": "../../..",
+      "link": true
+    }
+  }
+}
diff --git a/lib/nodejs/test/package.json b/lib/nodejs/test/package.json
new file mode 100644
index 0000000..22220ec
--- /dev/null
+++ b/lib/nodejs/test/package.json
@@ -0,0 +1,5 @@
+{
+  "devDependencies": {
+    "thrift": "file:../../.."
+  }
+}
diff --git a/lib/nodejs/test/server.js b/lib/nodejs/test/server.mjs
similarity index 84%
rename from lib/nodejs/test/server.js
rename to lib/nodejs/test/server.mjs
index b56bea7..7a3c593 100644
--- a/lib/nodejs/test/server.js
+++ b/lib/nodejs/test/server.mjs
@@ -19,11 +19,11 @@
  * under the License.
  */
 
-const fs = require("fs");
-const path = require("path");
-const thrift = require("../lib/thrift");
-const { program } = require("commander");
-const helpers = require("./helpers");
+import fs from "fs";
+import path from "path";
+import thrift from "thrift";
+import { program } from "commander";
+import helpers from "./helpers.js";
 
 program
   .option(
@@ -47,11 +47,16 @@
   .option("--callback", "test with callback style functions")
   .option("--es6", "Use es6 code")
   .option("--es5", "Use es5 code")
+  .option("--esm", "Use es modules")
   .parse(process.argv);
 
-const ThriftTest = require(`./${helpers.genPath}/ThriftTest`);
-const SecondService = require(`./${helpers.genPath}/SecondService`);
-const { ThriftTestHandler } = require("./test_handler");
+const ThriftTest = await import(
+  `./${helpers.genPath}/ThriftTest.${helpers.moduleExt}`
+);
+const SecondService = await import(
+  `./${helpers.genPath}/SecondService.${helpers.moduleExt}`
+);
+import { ThriftTestHandler } from "./test_handler.mjs";
 
 const opts = program.opts();
 const port = opts.port;
@@ -114,8 +119,8 @@
     type === "websocket"
   ) {
     options.tls = {
-      key: fs.readFileSync(path.resolve(__dirname, "server.key")),
-      cert: fs.readFileSync(path.resolve(__dirname, "server.crt")),
+      key: fs.readFileSync(path.resolve(import.meta.dirname, "server.key")),
+      cert: fs.readFileSync(path.resolve(import.meta.dirname, "server.crt")),
     };
   }
 }
diff --git a/lib/nodejs/test/test-cases.js b/lib/nodejs/test/test-cases.mjs
similarity index 85%
rename from lib/nodejs/test/test-cases.js
rename to lib/nodejs/test/test-cases.mjs
index 98077f7..543e353 100644
--- a/lib/nodejs/test/test-cases.js
+++ b/lib/nodejs/test/test-cases.mjs
@@ -19,13 +19,14 @@
 
 "use strict";
 
-const helpers = require("./helpers");
-const ttypes = require(`./${helpers.genPath}/ThriftTest_types`);
-const Int64 = require("node-int64");
+import helpers from "./helpers.js";
+import Int64 from "node-int64";
+
+const ttypes = await helpers.importTypes(`ThriftTest_types`);
 
 //all Languages in UTF-8
 /*jshint -W100 */
-const stringTest = (module.exports.stringTest =
+export const stringTest =
   "Afrikaans, Alemannisch, Aragonés, العربية, مصرى, " +
   "Asturianu, Aymar aru, Azərbaycan, Башҡорт, Boarisch, Žemaitėška, " +
   "Беларуская, Беларуская (тарашкевіца), Български, Bamanankan, " +
@@ -50,27 +51,27 @@
   "Svenska, Kiswahili, தமிழ், తెలుగు, Тоҷикӣ, ไทย, Türkmençe, Tagalog, " +
   "Türkçe, Татарча/Tatarça, Українська, اردو, Tiếng Việt, Volapük, " +
   "Walon, Winaray, 吴语, isiXhosa, ייִדיש, Yorùbá, Zeêuws, 中文, " +
-  "Bân-lâm-gú, 粵語");
+  "Bân-lâm-gú, 粵語";
 /*jshint +W100 */
 
-const specialCharacters = (module.exports.specialCharacters =
+export const specialCharacters =
   'quote: " backslash:' +
   " forwardslash-escaped: / " +
   " backspace: \b formfeed: \f newline: \n return: \r tab: " +
   ' now-all-of-them-together: "\\/\b\n\r\t' +
   " now-a-bunch-of-junk: !@#$%&()(&%$#{}{}<><><" +
-  ' char-to-test-json-parsing: ]] "]] \\" }}}{ [[[ ');
+  ' char-to-test-json-parsing: ]] "]] \\" }}}{ [[[ ';
 
-const mapTestInput = (module.exports.mapTestInput = {
+export const mapTestInput = {
   a: "123",
   "a b": "with spaces ",
   same: "same",
   0: "numeric key",
   longValue: stringTest,
   stringTest: "long key",
-});
+};
 
-const simple = [
+export const simple = [
   ["testVoid", undefined],
   ["testString", "Test"],
   ["testString", ""],
@@ -103,32 +104,32 @@
   mapout[i] = i - 10;
 }
 
-const deep = [
+export const deep = [
   [
     "testList",
     [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20],
   ],
 ];
 
-const deepUnordered = [
+export const deepUnordered = [
   ["testMap", mapout],
   ["testSet", [1, 2, 3]],
   ["testStringMap", mapTestInput],
 ];
 
-const out = new ttypes.Xtruct({
+export const out = new ttypes.Xtruct({
   string_thing: "Zero",
   byte_thing: 1,
   i32_thing: -3,
   i64_thing: 1000000,
 });
 
-const out2 = new ttypes.Xtruct2();
+export const out2 = new ttypes.Xtruct2();
 out2.byte_thing = 1;
 out2.struct_thing = out;
 out2.i32_thing = 5;
 
-const crazy = new ttypes.Insanity({
+export const crazy = new ttypes.Insanity({
   userMap: { 5: 5, 8: 8 },
   xtructs: [
     new ttypes.Xtruct({
@@ -146,7 +147,7 @@
   ],
 });
 
-const crazy2 = new ttypes.Insanity({
+export const crazy2 = new ttypes.Insanity({
   userMap: { 5: 5, 8: 8 },
   xtructs: [
     {
@@ -164,17 +165,7 @@
   ],
 });
 
-const insanity = {
+export const insanity = {
   1: { 2: crazy, 3: crazy },
   2: { 6: { userMap: {}, xtructs: [] } },
 };
-
-module.exports.simple = simple;
-module.exports.deep = deep;
-module.exports.deepUnordered = deepUnordered;
-
-module.exports.out = out;
-module.exports.out2 = out2;
-module.exports.crazy = crazy;
-module.exports.crazy2 = crazy2;
-module.exports.insanity = insanity;
diff --git a/lib/nodejs/test/testAll.sh b/lib/nodejs/test/testAll.sh
index 144832e..a3baa6e 100755
--- a/lib/nodejs/test/testAll.sh
+++ b/lib/nodejs/test/testAll.sh
@@ -35,25 +35,23 @@
 
 COUNT=0
 
-export NODE_PATH="${DIR}:${DIR}/../lib:${NODE_PATH}"
-
 testServer()
 {
-  echo "  [ECMA $1] Testing $2 Client/Server with protocol $3 and transport $4 $5";
+  echo "  [Variant: $1] Testing $2 Client/Server with protocol $3 and transport $4 $5";
   RET=0
   if [ -n "${COVER}" ]; then
-    ${ISTANBUL} cover ${DIR}/server.js --dir ${REPORT_PREFIX}${COUNT} --handle-sigint -- --type $2 -p $3 -t $4 $5 &
+    ${ISTANBUL} cover ${DIR}/server.mjs --dir ${REPORT_PREFIX}${COUNT} --handle-sigint -- --type $2 -p $3 -t $4 $5 &
     COUNT=$((COUNT+1))
   else
-    node ${DIR}/server.js --${1} --type $2 -p $3 -t $4 $5 &
+    node ${DIR}/server.mjs --${1} --type $2 -p $3 -t $4 $5 &
   fi
   SERVERPID=$!
   sleep 0.1
   if [ -n "${COVER}" ]; then
-    ${ISTANBUL} cover ${DIR}/client.js --dir ${REPORT_PREFIX}${COUNT} -- --${1} --type $2 -p $3 -t $4 $5 || RET=1
+    ${ISTANBUL} cover ${DIR}/client.mjs --dir ${REPORT_PREFIX}${COUNT} -- --${1} --type $2 -p $3 -t $4 $5 || RET=1
     COUNT=$((COUNT+1))
   else
-    node ${DIR}/client.js --${1} --type $2 -p $3 -t $4 $5 || RET=1
+    node ${DIR}/client.mjs --${1} --type $2 -p $3 -t $4 $5 || RET=1
   fi
   kill -2 $SERVERPID || RET=1
   wait $SERVERPID
@@ -90,10 +88,17 @@
 ${THRIFT_COMPILER} -o ${DIR} --gen js:node ${THRIFT_FILES_DIR}/v0.16/ThriftTest.thrift
 ${THRIFT_COMPILER} -o ${DIR} --gen js:node ${THRIFT_FILES_DIR}/JsDeepConstructorTest.thrift
 ${THRIFT_COMPILER} -o ${DIR} --gen js:node ${THRIFT_FILES_DIR}/Int64Test.thrift
+${THRIFT_COMPILER} -o ${DIR} --gen js:node ${THRIFT_FILES_DIR}/Include.thrift
 mkdir ${DIR}/gen-nodejs-es6
 ${THRIFT_COMPILER} -out ${DIR}/gen-nodejs-es6 --gen js:node,es6 ${THRIFT_FILES_DIR}/v0.16/ThriftTest.thrift
 ${THRIFT_COMPILER} -out ${DIR}/gen-nodejs-es6 --gen js:node,es6 ${THRIFT_FILES_DIR}/JsDeepConstructorTest.thrift
 ${THRIFT_COMPILER} -out ${DIR}/gen-nodejs-es6 --gen js:node,es6 ${THRIFT_FILES_DIR}/Int64Test.thrift
+${THRIFT_COMPILER} -out ${DIR}/gen-nodejs-es6 --gen js:node,es6 ${THRIFT_FILES_DIR}/Include.thrift
+mkdir ${DIR}/gen-nodejs-esm
+${THRIFT_COMPILER} -out ${DIR}/gen-nodejs-esm --gen js:node,es6,esm ${THRIFT_FILES_DIR}/v0.16/ThriftTest.thrift
+${THRIFT_COMPILER} -out ${DIR}/gen-nodejs-esm --gen js:node,es6,esm ${THRIFT_FILES_DIR}/JsDeepConstructorTest.thrift
+${THRIFT_COMPILER} -out ${DIR}/gen-nodejs-esm --gen js:node,es6,esm ${THRIFT_FILES_DIR}/Int64Test.thrift
+${THRIFT_COMPILER} -out ${DIR}/gen-nodejs-esm --gen js:node,es6,esm ${THRIFT_FILES_DIR}/Include.thrift
 
 # generate episodic compilation test code
 TYPES_PACKAGE=${EPISODIC_DIR}/node_modules/types-package
@@ -121,6 +126,7 @@
 node ${DIR}/header.test.js || TESTOK=1
 node ${DIR}/int64.test.js || TESTOK=1
 node ${DIR}/deep-constructor.test.js || TESTOK=1
+node ${DIR}/include.test.mjs || TESTOK=1
 
 # integration tests
 
@@ -130,11 +136,11 @@
   do
     for transport in buffered framed
     do
-      for ecma_version in es5 es6
+      for gen_variant in es5 es6 esm
       do
-        testServer $ecma_version $type $protocol $transport || TESTOK=1
-        testServer $ecma_version $type $protocol $transport --ssl || TESTOK=1
-        testServer $ecma_version $type $protocol $transport --callback || TESTOK=1
+        testServer $gen_variant $type $protocol $transport || TESTOK=1
+        testServer $gen_variant $type $protocol $transport --ssl || TESTOK=1
+        testServer $gen_variant $type $protocol $transport --callback || TESTOK=1
       done
     done
   done
diff --git a/lib/nodejs/test/test_driver.js b/lib/nodejs/test/test_driver.mjs
similarity index 95%
rename from lib/nodejs/test/test_driver.js
rename to lib/nodejs/test/test_driver.mjs
index 0593aea..eca56ba 100644
--- a/lib/nodejs/test/test_driver.js
+++ b/lib/nodejs/test/test_driver.mjs
@@ -26,15 +26,20 @@
 // supports an optional callback function which is called with
 // a status message when the test is complete.
 
-const test = require("tape");
+import test from "tape";
 
-const helpers = require("./helpers");
-const ttypes = require(`./${helpers.genPath}/ThriftTest_types`);
-const TException = require("thrift").Thrift.TException;
-const Int64 = require("node-int64");
-const testCases = require("./test-cases");
+import helpers from "./helpers.js";
+import thrift from "thrift";
+import Int64 from "node-int64";
+import * as testCases from "./test-cases.mjs";
 
-exports.ThriftTestDriver = function (client, callback) {
+const ttypes = await import(
+  `./${helpers.genPath}/ThriftTest_types.${helpers.moduleExt}`
+);
+
+const TException = thrift.Thrift.TException;
+
+export const ThriftTestDriver = function (client, callback) {
   test(
     "NodeJS Style Callback Client Tests",
     { skip: helpers.ecmaMode === "es6" },
@@ -162,7 +167,7 @@
   }
 };
 
-exports.ThriftTestDriverPromise = function (client, callback) {
+export const ThriftTestDriverPromise = function (client, callback) {
   test("Promise Client Tests", function (assert) {
     const checkRecursively = makeRecursiveCheck(assert);
 
diff --git a/lib/nodejs/test/test_handler.js b/lib/nodejs/test/test_handler.mjs
similarity index 95%
rename from lib/nodejs/test/test_handler.js
rename to lib/nodejs/test/test_handler.mjs
index a6a6fc2..a378fe1 100644
--- a/lib/nodejs/test/test_handler.js
+++ b/lib/nodejs/test/test_handler.mjs
@@ -19,9 +19,11 @@
 
 //This is the server side Node test handler for the standard
 //  Apache Thrift test service.
-const helpers = require("./helpers");
-const ttypes = require(`./${helpers.genPath}/ThriftTest_types`);
-const TException = require("thrift").Thrift.TException;
+import helpers from "./helpers.js";
+import thrift from "thrift";
+
+const ttypes = await helpers.importTypes(`ThriftTest_types`);
+const TException = thrift.Thrift.TException;
 
 function makeSyncHandler() {
   return function (thing) {
@@ -217,4 +219,4 @@
   asyncHandlers[label] = makeAsyncHandler(label);
 });
 
-exports.ThriftTestHandler = asyncHandlers;
+export { asyncHandlers as ThriftTestHandler };