register icon indicating copy to clipboard operation
register copied to clipboard

Should this work with `type: module` packages?

Open vjpr opened this issue 3 years ago • 3 comments

When I try to import a .ts file from an ESM entry-point I get this error:

TypeError [ERR_UNKNOWN_FILE_EXTENSION]: Unknown file extension ".ts" for /xxx/src/cli.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 async Loader.import (node:internal/modules/esm/loader:177:17)
    at async file:///xxx/index.js:5:15 {
  code: 'ERR_UNKNOWN_FILE_EXTENSION'
}

I think the underlying pirates hook only works with commonjs entry-points.

Right now ESM loader hooks are experimental and expected to change. See: https://github.com/nodejs/modules/issues/351). Problem is you have to then run the entry-point using cross-env 'NODE_OPTIONS=--experimental-loader some-swc-hook' which is annoying.

Here is an example of a transpiler hook: https://nodejs.org/api/esm.html#esm_transpiler_loader

Here is how instanbuljs is doing it: https://github.com/istanbuljs/esm-loader-hook/blob/master/index.js

vjpr avatar Aug 24 '21 12:08 vjpr

Workaround

Use a loader using the experimental ESM loader API.

cross-env NODE_OPTIONS='--experimental-loader my-loader' npm run dev

Can also use env-cmd to avoid having to specify the env for every npm run script, but be careful of https://github.com/toddbluhm/env-cmd/issues/127.

// Adapted from: https://nodejs.org/api/esm.html#esm_transpiler_loader

import {URL, pathToFileURL} from 'url'
import {cwd} from 'process'
import * as swc from '@swc/core'
import sourceMapSupport from 'source-map-support'
import path, {join} from 'path'

const baseURL = pathToFileURL(`${cwd()}/`).href

const extensionsRegex = /\.ts$/

////////////////////////////////////////////////////////////////////////////////

//
// `specifier` is like `request` using CJS terminology.
//    E.g.
//    - file:///xxx/cli-swc/bin/index.js
//    - regenerator-runtime
//    - @live/simple-cli-helper/lib/swc.js
//    - ./index
//
export function resolve(specifier, context, defaultResolve) {
  const {parentURL = baseURL} = context

  // Node.js `defaultResolve` normally errors on unknown file extensions so we resolve it ourselves.

  if (extensionsRegex.test(specifier)) {
    const newUrl = new URL(specifier, parentURL)
    return {url: newUrl.href}
  }

  if (specifier.startsWith('./') && !hasExtension(specifier)) {
    // If no extension, assume TS.
    const newUrl = new URL(specifier, parentURL)
    newUrl.pathname += '.ts'
    return {url: newUrl.href}
  }

  // Let Node.js handle all other specifiers.
  return defaultResolve(specifier, context, defaultResolve)
}

function hasExtension(specifier) {
  return path.extname(specifier) !== ''
}

export function getFormat(url, context, defaultGetFormat) {
  // Now that we patched resolve to let TS URLs through, we need to
  // tell Node.js what format such URLs should be interpreted as. For the
  // purposes of this loader, all TS URLs are ES modules.
  if (extensionsRegex.test(url)) {
    return {
      format: 'module',
    }
  }

  // Let Node.js handle all other URLs.
  return defaultGetFormat(url, context, defaultGetFormat)
}

export function transformSource(source, context, defaultTransformSource) {
  const {url, format} = context

  const opts = {}

  if (extensionsRegex.test(url)) {
    if (typeof source !== 'string') {
      source = source.toString()
    }
    const code = compile(url, source, opts)
    return {source: code}
  }

  // Let Node.js handle all other sources.
  return defaultTransformSource(source, context, defaultTransformSource)
}

////////////////////////////////////////////////////////////////////////////////

const maps: {[src: string]: string} = {}

function compile(filename, code, opts) {
  const output: swc.Output = swc.transformSync(code, {
    ...opts,
    sourceMaps: opts.sourceMaps === undefined ? 'inline' : opts.sourceMaps,
  })
  if (output.map) {
    if (Object.keys(maps).length === 0) {
      installSourceMapSupport()
    }
    maps[filename] = output.map
  }
  return output.code
}

////////////////////////////////////////////////////////////////////////////////

function installSourceMapSupport() {
  sourceMapSupport.install({
    handleUncaughtExceptions: false,
    environment: 'node',
    retrieveSourceMap(source) {
      const map = maps && maps[source]
      if (map) {
        return {
          url: null as any,
          map: map,
        }
      } else {
        return null
      }
    },
  })
}

vjpr avatar Aug 24 '21 13:08 vjpr

I think it should work, but I don't think it's okay to drop support for old js packages.

kdy1 avatar Aug 24 '21 13:08 kdy1

I think it should work, but I don't think it's okay to drop support for old js packages.

I don't think the idea is to drop support for old js packages, but add support for new ones. Something like node --loader @swc-node/loader src/main.ts

Also, loader flag is not experimental anymore. I think we should rethink about this since esm modules are getting more popular.

athenacfr avatar Dec 16 '22 14:12 athenacfr