Skip to content

Instantly share code, notes, and snippets.

@jimmywarting
Last active January 27, 2024 18:02
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save jimmywarting/327a647942014d8ac4f4a333c351f94c to your computer and use it in GitHub Desktop.
Save jimmywarting/327a647942014d8ac4f4a333c351f94c to your computer and use it in GitHub Desktop.
using cjs in esm runtime (NodeJS)

This is a response to https://gist.github.com/joepie91/bca2fda868c1e8b2c2caf76af7dfcad3?permalink_comment_id=4767875#gistcomment-4767875

if you are so sure about using 'import' from common js then send some code samples - maybe it will help someone else.

Sure @cybafelo, i can give you 3 example (from easy to more adv)

i'm going to create a basic hello world example that uses express (cjs) and node-fetch (esm-only)

here is what my package.json looks like:

{
  "type": "commonjs",
  "dependencies": {
    "express": "^4.18.2",
    "node-fetch": "^3.3.2"
  }
}

To be explicit i have set the type to commonjs to make no confusion that it's definitely a cjs application

Option 1 - using dynamic import() in cjs

the fetch api is just a function that returns a promise, so it could be easy to just defer the module and only load it when it's absolutely necessary by calling a proxy function that first import the module and then passes the argument along to fetch when it has been imported.

const express = require('express')
const app = express()
const port = 3003

// wrap esm-only node-fetch with an lazy dynamic import that is imported on first use
const fetch = (...args) => import('node-fetch').then(({default: fetch}) => fetch(...args));

app.get('/*', async (req, res) => {
  const response = await fetch('https://httpbin.org/' + req.url, {
    headers: req.headers,
    method: req.method,
    body: req.body
  })
  response.body.pipe(res)
})

app.listen(port, () => {
  console.log(`Example app listening on http://0.0.0.0:${port}`)
})

Option 2 - bootstrap the application and load all modules before your own code starts to execute

This requires that you have one single bootstrap-ing .mjs file that will load first and formost, when it have loaded all modules only then will it load your own application,

So the only thing you have to change is something like

- "start": "node app.js"
+ "start": "node bootstrap.mjs" 
// bootstrap.mjs

import { readFileSync } from 'node:fs'

const pkg = JSON.parse(readFileSync('./package.json', 'utf8'))

const imports = Object.keys(pkg.dependencies).map(async dep => {
  console.log(`import ${dep} from '${dep}';`)
  const module = await import(dep)
  return [dep, module]
})

globalThis.dependencies = Object.fromEntries(await Promise.all(imports))
console.log('loaded all dependencies')
console.log('')
console.log('starting the application itself')

import('./app.js')
// app.js

const express = globalThis.dependencies.express.default
// const express = require('express') - this works also.
const app = express()
const port = 3003

// `bootstrap.mjs` will have already imported the dependencies
const {
  default: fetch,
  Response,
  Request,
  Blob,
  fileFromSync,
} = globalThis.dependencies['node-fetch']

const file = fileFromSync('./package.json')
// console.log(file.name, file.size)

app.get('/*', async (req, res) => {
  const response = await fetch('https://httpbin.org/' + req.url, {
    headers: req.headers,
    method: req.method,
    body: req.body
  })
  response.body.pipe(res)
})

app.listen(port, () => {
  console.log(`Example app listening on http://0.0.0.0:${port}`)
})

i know what you might think... pooluting global context with dependencies and using const express = globalThis.dependencies.express.default instead of require('express'). it's ugly, but that is beside the point. i could come up with a nicer way to design this, eg by manipulating require and pre populate it with things. so that require('node-fetch') have been cached with an already loaded esm-only package and did not read from any files when the app.js tries to load node-fetch

the solution i wanted to show you is that you can have a simple esm file that first imports all the esm-only modules first and formost (asynchronously) and once that's completed it would start the application as normally.

this is by no means a good solution for library developers that wants to publish something on npm. then option 1 would be better.

option 3 - trying to have both esm and cjs dual runtime (like bun.js)

This solution is a bit more trickier to get up and running... it involves

  • defaulting to esm (by setting "type": "module" in package.json)
  • register a custom import module loader
  • prepend own scoped variables like __dirname, __filename and require() on top of every file. (at runtime)
// register.js

import { register } from 'node:module'
register(import.meta.resolve('./hook.js'))
// hook.js

import { fileURLToPath } from 'node:url'
import { createRequire } from 'node:module'
import path from 'node:path'

async function load(url, context, nextLoad) {
  if (url === import.meta.url ||
    !url.startsWith('file://') ||
    url.includes('node_modules')
  ) return nextLoad(url, context);

  const { source } = await nextLoad(url, { ...context, format: 'module' });

  const header = `let { exports, require, module, __filename, __dirname } = await import('${import.meta.url}').then(_ => _._(import.meta));\n`
  const sourceCode = header + source.toString()

  return {
    format: 'module',
    shortCircuit: true,
    source: sourceCode,
  }
}

function _(meta) {
  // this will try to mimic nodejs module system somewhat
  //
  // (function(exports, require, module, __filename, __dirname) {
  //   // Module code actually lives in here
  // })

  return {
    require: createRequire(meta.url),
    __filename: path.dirname(fileURLToPath(meta.url)),
    __dirname: fileURLToPath(meta.url),
    module: {
      exports: {},
    }
  }
}

export {
  load,
  _,
}
// app.js

import fetch, { fileFromSync } from 'node-fetch'
const express = require('express')
const app = express()
const port = 3003

console.log(fileFromSync)
console.log(__dirname)
console.log(__filename)

app.get('/*', async (req, res) => {
  const response = await fetch('https://httpbin.org/' + req.url, {
    headers: req.headers,
    method: req.method,
    body: req.body
  })
  response.body.pipe(res)
})

app.listen(port, () => {
  console.log(`Example app listening on http://0.0.0.0:${port}`)
})
node --import ./register.js ./app.js 

And love and behold: now your app can use both cjs and esm in a way that you like. it's more or less like trying to add this at top of every file (but is being done automatically for you at runtime)

import {fileURLToPath} from 'node:url'
import path from 'node:path'
import { createRequire } from 'node:module'

const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const require = createRequire(import.meta.url)

One con is that anything you require() won't execute in a ESM runtime so those file that have been required can't use any ESM syntax. (or top level await) the problem i had was that the ES import hook loader would not get triggered when loading cjs files

one way to override this could be to change require into async import()

require(requirePath) {
  import(meta.resolve(requirePath))
},

this had some pro & con in itself. the good thing where that it would run in a ESM runtime, and have both support for cjs and esm. (cuz the hook.js was running when calling our own require() the con where that now require() now also became async... so returning anything became an issue. if there had been a importSync() then this would be golden.. i tried looking into atomic wait and deasync but did not found a solution that did work. anyway... i think this is a good solution to have while migrating to ESM. you kind of have to convert all the files like a file tree (starting from the root file) so that you can take your time converting things in a slow and steady pace

the goal i had was to try and have support for both esm and cjs at runtime (rather than using something like a compiler/transpiler that transform all import/export to & from cjs & esm to one format)

most ppl have tried having support for esm in a cjs runtime (i wanted to try the other way around by having cjs support in esm runtime) i had some plan of appending some kind of footer to all files that took what had been assigned to module.exports and some how do a export default module.exports or something... but did not want to spend any more time on this...

@guest271314
Copy link

I've got an edge test case I've been experimenting with. Fetch and use CommonJS modules without using npm. Can one of the approaches you describe be used for that case?

@jimmywarting
Copy link
Author

technically yes. but I'm not sure what the best approach would be.
i think it would mostly depend on if you are building a library that you then plan to host on npm as a library for other to use.
or if you a building a own application that has some package.json that tells it that it's a "type"="module".

i would expect that some form of cli flag would be required if you want to create custom hooks. to allow importing from http (cdn)

there is an example of it here: https://nodejs.org/dist/latest-v21.x/docs/api/module.html#import-from-https

i think you will have to detect what type it is when you are fetching a cjs or esm module and change the format accordingly between module and commonjs

fyi, there is also --experimental-network-imports but it will not resolve something like express to https://esm.sh/express, maybe it would be possible with a import-map or you can use a combination of both a own hook that rewrite express to a https: and then use --experimental-network-imports to do the actual fetching

@guest271314
Copy link

What I am trying to do is fetch the dependencies from NPM and use a repository hosted on GitHub that depends on CommonJS - without using npm.

This is my fork https://github.com/guest271314/telnet-client/tree/user-defined-tcpsocket-controller-web-api.

Why? A Node.js Member and maintainer of nvm made this claim

npm can’t be eliminated from the equation when you’re dealing with JavaScript ¯_(ツ)_/¯

So I don't plan on using npm for the duration.

This is what I have so far. However, I'm running into naming issues.

node webpack.wbn.js
node:internal/modules/cjs/loader:1146
  throw err;
  ^

Error: Cannot find module '@nodelib/fs.stat'

where when I fetch and extract the libraries the library folder is named "fs.stat" without the "@nodelib/" prefix.

const lockJson = require("./package-lock.json"); // edit path if needed
const fs = require("node:fs/promises");
const https = require("node:https");
const vm = require("node:vm");
const util = require('node:util');
const exec = util.promisify(require("node:child_process").exec);

const libraries = [];
// https://stackoverflow.com/a/52498771
// Loop through dependencies keys (as it is an object)
Object.keys(lockJson.dependencies).forEach((dependencyName) => {
  const dependencyData = lockJson.dependencies[dependencyName];

  libraries.push(
    dependencyData.resolved,
  );

  // Loop through requires subdependencies
  if (dependencyData.requires) {
    Object.keys(dependencyData.requires).forEach((subdependencyName) => {
      const subdependencyVersion = dependencyData.requires[subdependencyName];

      libraries.push(
        dependencyData.resolved,
        // libName: subdependencyName,
        // libVersion: subdependencyVersion,
        // parent: dependencyName,
      );
    });
  }
});

fs.writeFile("dependencies.json", JSON.stringify([...new Set(libraries)], null, 2))
  .then(console.log).catch(console.error);

(async () => {
  for (const dep of new Set(libraries)) {
    const file = dep.split("/").pop();//.replace(".tgz", "")}`;
    console.log(`Ready to fetch ${dep}`);
    await fetch(dep)
      .then((r) => r.body.pipeThrough(new DecompressionStream("gzip")))
      .then(async (r) => {
        return await fs.writeFile(file
          ,
          new Uint8Array(await new Response(r).arrayBuffer()),
        );
      }).then(() => {
          return exec(`tar xf ${file}`)
      })
      .then(async () => {
          try {
            await exec(`mv package ${file.replace(/\.tgz|-[\d-.]+$/g, "")}`);
          } catch (e) {
            console.log(e.stderr);
            await exec(`mv ${file} ${file.replace(/\.tgz|-[\d-.]+$/g, "")}`);
          } finally {
            try {
              await exec(`rm ${file}`);
            } catch {
              await exec(`rm ${file.replace(/\.tgz|-[\d-.]+$/g, "")}`);
            }
          }
           
      })
  }
})();

@guest271314
Copy link

This is what I have so far. I will try to fetch the dependencies individually, then reconstruct the folder name and file structure that would result from an npm call, and bundle all the dependencies into a single script, then test building https://github.com/guest271314/telnet-client/tree/user-defined-tcpsocket-controller-web-api which started out as a Web API only, non-extension version of https://github.com/guest271314/telnet-client/tree/user-defined-tcpsocket-controller.

let nodePackageDependencies = [
  ...new Set(
    Object.entries(
      (await (await fetch(
        "./package-lock.json",
      )).json()).packages,
    ).map((
      [key, { dependencies = {}, devDependencies = {} }],
    ) => [
      key.replace(/^node_modules\//, ""),
      ...Object.keys(dependencies),
      ...Object.keys(devDependencies),
    ]).flat().filter(Boolean),
  ),
];

console.log(nodePackageDependencies, nodePackageDependencies.length);

@guest271314
Copy link

BTW, I get 514 dependencies using the above script iterating package-lock.json. This https://npmgraph.js.org/ reports 496 dependencies when uploading package.json. I'm curious what npm and yarn report fot total dependencies.

@jimmywarting
Copy link
Author

Hmm, i think i see what you are trying to do... and it sounds very ambitious & complicated. you are kind of trying to do what deno dose without a "npm".

one huge blocker i might see is how would you resolve dependency that use different version?

- express@2.0
  - cookie-parser@4.3
- session@5.2
  - cookie-parser@3.11

the express dependency might need / depend on cookie-parser@4.x and session need cookie-parser@3.x

you need to either a) download and unpack everything the same way as npm dose into a node_modules folder.
(think it might involve changing each individual package.json. in each module to handle sub dependencies and so on.

or if you do decide to go with the "register hook" solution. then you would have to kind of generate something like a source-map where you keep track of who need to load what.

  • telnet requires express.
  • the hook kind of redirect and says: require express@2.x instead!
  • telnet require express@2.x
  • express@2.x says it needs to require cookie-parser
  • the "hook" figures out that it's express@2.x who wants to load cookie-parser so it should say: require cookie-parser@4.3
  • and so on...
// app.js
- require('express')
+ require('express@2.x')

// express@2.x
- require('cookie-parser')
+ require('cookie-parser@4.3')

The "hook" would kind of have to do this ☝️ "internal rewrite/redirect" on the fly.
when express@2.x wants to load cookie-parser then you need to figure out: Ah! it's this parent module who want / need this dependency and this exact version. so a redirect need to say "go fetch this older version of cookie-parser (and not the older v3.11)

@guest271314
Copy link

Insane!

Sure seems verbose and proprietary to me...

I think I'll try to import all of the scripts and bundle into a single script. Not sure how I'll do that, yet. The last time I tried a ESM version script from jsDelivr throew some naming error. I didn't try unpkg and other CDN's yet. Deno support npm: and node:.

I'm not really using all of the modules in telnet-client. Not using the server, express, either. I already build a .swbn that I am using as such as a Chromium/Chrome -For-Testing flag

--install-isolated-web-app-from-file="/home/user/telnet-client-web-ext/dist/telnet.swbn" \

and TCP servers that use node, deno, and txiki.js. I can communicate to and from the local (or remote) TCP server from any arbitrary Web page I open a window from using WebRTC Data Channels.

I just need to be able to update the script.

Which I might be able to do with just the webpack-webbundle imports. I don't think I really need webpack, either, but that's how they built the repository.

@guest271314
Copy link

I think I narrowed the modules needed to build a Signed Web Bundle to

  • wbn
  • wbn-sign
  • rollup-pugin-webbundle

jsDelivr and esm.sh are throwing Uncaught SyntaxError: The requested module '/npm/read@2.1.0/+esm' does not provide an export named 'default' for https://cdn.jsdelivr.net/npm/wbn-sign@0.1.2/+esm.

@guest271314
Copy link

FWIW Looks like bun install installs packages listed in package.json to ~/.bun/install/cache. Don't know if npm is built in to bun under the hood, yet. I know I didn't call npm install because npm ain't on my system.

Now I just need to figure out how to call the equivalent of npm run build for

  "scripts": {
    "start": "webpack serve --config webpack.dev.js",
    "build": "webpack --config webpack.wbn.js",
    "lint": "eslint src/*.ts"
  }

@guest271314
Copy link

Figured it out. bun run build. Didn't work when the package-lock.json file was in the repository. Thanks, again!

$ bun install
bun install v1.0.14 (d8be3e51)
error: Please upgrade package-lock.json to lockfileVersion 3

Run 'npm i --lockfile-version 3 --frozen-lockfile' to upgrade your lockfile without changing dependencies.
user@user:~/telnet-client-web-ext$ bun install
bun install v1.0.14 (d8be3e51)
 + @typescript-eslint/eslint-plugin@5.62.0 (v6.13.0 available)
 + @typescript-eslint/parser@5.62.0 (v6.13.0 available)
 + clean-webpack-plugin@4.0.0
 + copy-webpack-plugin@11.0.0
 + css-loader@6.8.1
 + dotenv@16.3.1
 + eslint@8.54.0
 + eslint-config-google@0.14.0
 + gh-pages@4.0.0 (v6.1.0 available)
 + html-loader@3.1.2 (v4.2.0 available)
 + html-webpack-plugin@5.5.3
 + mini-css-extract-plugin@2.7.6
 + style-loader@3.3.3
 + ts-loader@9.5.1
 + typescript@4.9.5 (v5.3.2 available)
 + wbn-sign@0.1.0 (v0.1.2 available)
 + webbundle-webpack-plugin@0.1.3
 + webpack@5.89.0
 + webpack-cli@4.10.0 (v5.1.4 available)
 + webpack-dev-server@4.15.1
 + webpack-merge@5.10.0
 + xterm@4.19.0 (v5.3.0 available)
 + xterm-addon-fit@0.5.0 (v0.8.0 available)

 494 packages installed [5.67s]
user@user:~/telnet-client-web-ext$ bun run build
$ webpack --config webpack.wbn.js
Setting the empty headerOverrides to IWA defaults. To bundle a non-IWA, set `integrityBlockSign { isIwa: false }` in your plugin configs. Defaults are set to:
 {"content-security-policy":"base-uri 'none'; default-src 'self'; object-src 'none'; frame-src 'self' https: blob: data:; connect-src 'self' https:; script-src 'self' 'wasm-unsafe-eval'; img-src 'self' https: blob: data:; media-src 'self' https: blob: data:; font-src 'self' blob: data:; require-trusted-types-for 'script'; frame-ancestors 'self';","cross-origin-embedder-policy":"require-corp","cross-origin-opener-policy":"same-origin","cross-origin-resource-policy":"same-origin"}
assets by path *.js 4.34 KiB
  asset main.js 3.05 KiB [emitted] [minimized] (name: main)
  asset runtime.js 910 bytes [emitted] [minimized] (name: runtime)
  asset sw.js 361 bytes [emitted] [from: assets/sw.js] [copied] [minimized]
  asset worker.js 50 bytes [emitted] [from: assets/worker.js] [copied] [minimized]
assets by path images/ 21.2 KiB
  asset images/icons-512.png 14.2 KiB [emitted] [from: assets/images/icons-512.png] [copied]
  asset images/icons-192.png 5.14 KiB [emitted] [from: assets/images/icons-192.png] [copied]
  asset images/icons-vector.svg 1.89 KiB [emitted] [from: assets/images/icons-vector.svg] [copied]
asset telnet.swbn 32.6 KiB [emitted]
asset manifest.webmanifest 721 bytes [emitted] [from: assets/manifest.webmanifest] [copied]
asset index.html 253 bytes [emitted]
Entrypoint main 3.94 KiB = runtime.js 910 bytes main.js 3.05 KiB
runtime modules 2.48 KiB 3 modules
./src/index.js 4.74 KiB [built] [code generated]

LOG from webbundle-webpack-plugin
<i> isolated-app://<ID>/

webpack 5.89.0 compiled successfully in 1500 ms
user@user:~/telnet-client-web-ext$ npm --help
Command 'npm' not found, but can be installed with:
sudo apt install npm

@guest271314
Copy link

@jimmywarting Here https://github.com/guest271314/wbn-sign-webcrypto is wbn-sign without dependence on node:crypto implementation of Ed25519 algorithm (which effectively can only be run in Node.js environment, deno and bun throw when trying to use node:crypto). I just have to update https://github.com/guest271314/webbundle a little bit to incorporate using wbn-sign-webcrypto instead of wbn-sign.

The same code can be run using

deno run --unstable-byonm --unstable-sloppy-imports -A rollup.wbn.js

where we write wbn-sign-webcrypto to the node_modules folder and symlink like deno does

bun run rollup.wbn.js

and

node --experimental-default-type=module rollup.wbn.js

Install the .swbn in chrome://web-app-internals.

Next step is substituting WICG File System Access API for node:fs so we can generate .swbn and IWA in the browser - and thus use Direct Sockets at will.

@guest271314
Copy link

@jimmywarting I have began work on creating an Isolated Web App in the browser. I've got all the code imported into the browser version. Producing a Signed Web Bundle, though the IWA can't find the files. Could use some help getting it working if you are interested. Thanks.

@guest271314
Copy link

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment