rechoir icon indicating copy to clipboard operation
rechoir copied to clipboard

Support for esm.ts using `node --loader ts-node/esm`

Open monyarm opened this issue 3 years ago • 17 comments

I use ts for my gulpfile, but some of the plugins i use (gulp-imagemin specifically), are esm. So having a way to use both would be useful, as otherwise when one tries to run gulp, they get something like this:

[18:15:57] Requiring external module ts-node/register
TypeError: Unknown file extension ".ts" for /home/monyarm/Documents/gulp-gameoptimizer/gulpfile.ts
    at new NodeError (node:internal/errors:371:5)
    at Loader.defaultGetFormat [as _getFormat] (node:internal/modules/esm/get_format:71:15)
    at Loader.getFormat (node:internal/modules/esm/loader:105:42)
    at Loader.getModuleJob (node:internal/modules/esm/loader:243:31)
    at processTicksAndRejections (node:internal/process/task_queues:96:5)
    at Loader.import (node:internal/modules/esm/loader:177:17)
    at importModuleDynamicallyWrapper (node:internal/vm/module:437:15) {
  code: 'ERR_UNKNOWN_FILE_EXTENSION'
}

monyarm avatar Aug 19 '21 15:08 monyarm

That sucks.

This is a complex feature to add and is on our roadmap for rechoir, but we probably won't have time to work on it for awhile.

I'll move this over to the rechoir library, as it needs to be implemented in the loader.

phated avatar Aug 19 '21 21:08 phated

I just spent hours digging into this, and my general sense is that esm loaders in nodejs are an absolute shitshow right now. They've recently changed their loader API, so a lot of stuff has to shim their internal loader logic, but then I noticed that ts-node had to wholesale copy-paste node internals into their codebase to support their module loader. Check this out: https://github.com/TypeStrong/ts-node/blob/main/dist-raw/README.md

Anyway, I dove down that rabbit hole because the most efficient way for rechoir to support esm loaders would be to bootstrap our own loader on startup that proxies through to everyone else's loaders. However, once I discovered that we'd have to copy all the internals from node to do this "right", I decided to hack on a much worse performing solution and came up with something that works:


const { spawn } = require('child_process');

const argv = process.argv

async function main() {
  try {
    require('ts-node/register')
    return require('./bar.ts');
  } catch (err) {
    if (err.code === 'ERR_REQUIRE_ESM') {
      try {
        return await import('./bar.ts');
        console.log(mod);
      } catch (err) {
        if (err.code === 'ERR_UNKNOWN_FILE_EXTENSION') {
          var child = spawn(argv[0], [
            '--loader', 'ts-node/esm',
            ...argv.slice(1)
          ], { stdio: 'inherit' });
          child.on('exit', function (code, signal) {
            process.on('exit', function () {
              /* istanbul ignore if */
              if (signal) {
                process.kill(process.pid, signal);
              } else {
                process.exit(code);
              }
            });
          });
        } else {
          throw err
        }
      }
    } else {
      throw err
    }
  }
}

main()
  .then(mod => console.log(mod))
  .catch(err => {
    process.nextTick(() => { throw err })
  })

Essentially what is happening here is that we are assuming require.extensions is the default loading situation (since it is the most battle tested, and you can't do this in reverse from ESM) and load the ts-node/register module for commonjs modules. Then we try to load the file, but if it errors with the ERR_REQUIRE_ESM code, we know that it tried to resolve as an ESM module. In that scenario, we try to load the file with a dynamic import() and if that fails with the code ERR_UNKNOWN_FILE_EXTENSION it means that there was no loader registered for it and we need to reboot the entire node instance with the loader and start the whole process over.

The reason this is pretty insane is that it'll be double registering loaders (for commonjs and esm) for every esm loader you try to use. Additionally, multiple ESM loaders will cause multiple child processes to be spawned (which then reloads all modules from the start).

phated avatar Oct 19 '21 03:10 phated

I'm not a huge fan of this, but it solves the problem and makes the actual loaders other people's problem. I'll think on it a bit more.

phated avatar Oct 19 '21 03:10 phated

For reference, I used concepts from gulp-cli, liftoff and https://github.com/lukeed/loadr for the above solution.

phated avatar Oct 20 '21 21:10 phated

I have the same problem, did you solve it?

JusticHentai avatar Feb 10 '22 12:02 JusticHentai

@JusticHentai No, and this will be punted until post-v5 of gulp because nodejs support of loaders is so shitty.

phated avatar Feb 10 '22 19:02 phated

Just commenting to say that I'm following along with issues like this across the ecosystem, and am always open to discussing ways that tooling and loader authors can shim our way around node's shortcomings.

I have this idea that we might devise a pass-through loader which does nothing on its own, but which allows tools such as yours to install hooks at runtime as necessary.

Roughly, it would look like this:

Node is invoked like this:

node --loader @cspotcode/multiloader ./whatever-tool-or-entrypoint.js

If a tool realizes at runtime that it wants to install ts-node's hooks:

await process[Symbol.for('multiloader-api')].add('ts-node/esm');
await import('./some-ts-file.ts')

Theoretically someone could add NODE_OPTIONS='--loader /abs/path/to/@cspotcode/multiloader' to their shell profile, and then node would have this runtime loader-installation API. I say theoretically, because I would not recommend that users do this, but it's a nice thought experiment, and I think it makes the case that node should support this natively.

cspotcode avatar Apr 18 '22 19:04 cspotcode

@cspotcode I believe something like a "multiloader" would make the most sense sense, but your current example only works for dynamic imports. We'd want to be able to prepare the multiloader at launch with flags. Maybe that would be something like:

node --loader multiloader --require ts-node/esm` 

phated avatar Apr 18 '22 20:04 phated

That is actually possible today, with, for example: node --loader @cspotcode/multiloader/compose?yaml-loader,ts-node/esm

The runtime API I described in my previous comment is not yet implemented, but composing multiple loaders at startup is implemented today.

https://github.com/cspotcode/multiloader

The end-goal is a future where tools such as yours do not need to attempt to spawn a child process at all. When they decide that a loader is necessary, they can install it at runtime, the same way we do for CJS loaders.

EDIT: fixed a typo in the invocation syntax above

cspotcode avatar Apr 18 '22 20:04 cspotcode

For gulp, I believe we'll always need to respawn because node doesn't have good APIs for allowing us to pass other nodejs flags through, but I understand that having the programmatic API would be useful for other applications.

phated avatar Apr 18 '22 20:04 phated

Hmm, I'm looking at your code above (https://github.com/gulpjs/rechoir/issues/43#issuecomment-946334346) and assuming that you'd replace the child_process.spawn() with await process.loaderApi.add(); is that not the case?

As far as I know, when you do require('ts-node/register'), you expect things to "just work" after that point, which is how it was with CJS. With the runtime loader API, they would still "just work." Registering ts-node's ESM loader also installs the CJS hooks, so you'd have one-stop shopping for injecting TS support into the node runtime.

Does gulp do things differently?

cspotcode avatar Apr 18 '22 20:04 cspotcode

@cspotcode sorry if I was unclear, I'm wasn't talking about the loaders for respawn. We still need to respawn for the usage of gulp --some-v8-flag

phated avatar Apr 18 '22 21:04 phated

Ah I see. I think it still might have benefits for this situation:

Additionally, multiple ESM loaders will cause multiple child processes to be spawned (which then reloads all modules from the start).

I think a multiloader solution can limit the number of spawns to, at most, 1.

The first time you realize that you need to spawn, you can eagerly register multiloader into the child process. From then on, you know you can install as many CJS and ESM loaders into the process as necessary, without any further respawning. Since the multiloader API is exposed on the process object, you can feature-detect for its presence, avoiding unnecessary respawns when it's available.

When it's available, you can even skip require('ts-node/register') and go straight for adding the ts-node/esm loader, since that will also install the CJS hooks.

cspotcode avatar Apr 18 '22 21:04 cspotcode

@cspotcode I've been thinking about this a bunch and I think your original description is ideal. I'd want to specify --loader multiloader when we boot up the CLI tool (this will cause a respawn due to the new node flag) and then we want a programatic API to register loaders, like you showed:

// Would that have to be async?
process[Symbol.for('multiloader-api')].register('ts-node/esm');

phated avatar Jun 29 '22 23:06 phated

Nice, yeah if we want to collaborate on such a runtime API, I think that'd be great. I was toying with adding this to my multiloader thing but I don't remember where I got to. I'll see if I can dig up the code -- maybe it's my work laptop? -- and push it tomorrow.

https://github.com/cspotcode/multiloader/

cspotcode avatar Jun 30 '22 01:06 cspotcode

Hey @monyarm I've been racking my brain for the past 5 hours trying to understand why my code was breaking randomly when I added certain dependencies. The error message is very misleading.

Anyway, if you're interested in a workaround until Node sorts out their loader situation, this is what I did:

First, I set "type":"module" in package.json. Next I created a tsconfig.json file with the following content:

{
  "compilerOptions": {
    "module": "ES2022",
    "target": "ES6",
    "moduleResolution": "node",
    "noImplicitAny": false
  }
}

Lastly, to get Gulp to run, I call the gulp.js script directly using either node command line options:

  "type": "module",
  "scripts": {
    "gulp": "npm run ts-gulp",
    "build": "npm run ts-gulp build",
    "ts-gulp": "node --experimental-loader ts-node/esm --no-warnings node_modules/gulp/bin/gulp.js"
  },

You can also change the ts-gulp script to ts-node --esm node_modules/gulp/bin/gulp.js .

Here is a link to a write-up that gives some more details https://gitlab.com/-/snippets/2581927

kshep92 avatar Aug 11 '23 07:08 kshep92

Any update?

@cspotcode can tsx dependency be used as alternative ts-node? Maybe adding both tsx and ts-node as optional peer dependencies.

rtritto avatar Sep 20 '24 12:09 rtritto