Skip to content

Instantly share code, notes, and snippets.

@unicornware
Last active December 26, 2021 08:43
Show Gist options
  • Select an option

  • Save unicornware/6d8f8f7050d5c364ea85d6ba5d43e8cd to your computer and use it in GitHub Desktop.

Select an option

Save unicornware/6d8f8f7050d5c364ea85d6ba5d43e8cd to your computer and use it in GitHub Desktop.
Custom ESM Configuration ([email protected])
# Environment Variables - NODE_OPTIONS - Use ESM syntax
if [ -f "$PWD/node_modules/ts-node/esm.mjs" ]; then
# Ignore warnings
NODE_NO_WARNINGS=1
# Import JSON modules
JSON_MODULES='--experimental-json-modules'
# Use custom ESM loader
LOADER="--loader $PWD/tools/loaders/esm.mjs"
# Don't require imported modules to include extensions
SPECIFIER_RESOLUTION='--es-module-specifier-resolution=node'
# Set options
export NODE_OPTIONS="$JSON_MODULES $LOADER $SPECIFIER_RESOLUTION"
fi
echo "NODE_NO_WARNINGS=1" >> $GITHUB_ENV
echo "NODE_OPTIONS=--experimental-modules --experimental-json-modules --loader=$GITHUB_WORKSPACE/tools/loaders/esm.mjs --es-module-specifier-resolution=node" >> $GITHUB_ENV
# ENVIRONMENT VARIABLES
# See: https://zsh.sourceforge.io/Intro/intro_3.html
# Load ESM environment variables
[[ -f "$PWD/.env.esm" ]] && . $PWD/.env.esm
declare global {
namespace EsmLoader {
type Format =
| 'builtin'
| 'commonjs'
| 'dynamic'
| 'json'
| 'module'
| 'wasm'
type Source = string | Buffer
const hooks: {
getFormat: Hooks.GetFormat
resolve: Hooks.Resolve
transformSource: Hooks.TransformSource
}
namespace Hooks {
type GetFormat = (
url: string,
context: HookContext.GetFormat,
defaultGetFormat: GetFormat
) => Promise<HookResult.GetFormat>
type Resolve = (
specifier: string,
context: HookContext.Resolve,
defaultResolve: Resolve
) => Promise<HookResult.Resolve>
type TransformSource = (
source: Source,
context: HookContext.TransformSource,
defaultTransformSource: TransformSource
) => Promise<HookResult.TransformSource>
}
namespace HookContext {
type GetFormat = Record<never, never>
type Resolve = { parentURL: string }
type TransformSource = { format: Format; url: string }
}
namespace HookResult {
type GetFormat = { format: Format }
type Resolve = { url: string }
type TransformSource = { source: Source }
}
}
}
export {}
import * as istanbul from '@istanbuljs/esm-loader-hook'
import path from 'node:path'
import pkg from '../../package.json'
import matchSpecifier from '../helpers/match-specifier.cjs'
/** @type {EsmLoader.hooks} */ const hooks = await import('ts-node/esm')
/**
* @file Helpers - Custom ESM Loader Hooks
* @module tools/loaders/esm
* @see https://github.com/TypeStrong/ts-node/issues/1007
* @see https://nodejs.org/docs/latest-v12.x/api/all.html#esm_hooks
*/
const TEST = process.env.NODE_ENV === 'test'
/**
* Determines if `url` should be interpreted as a CommonJS or ES module.
*
* @async
* @param {string} url - File URL
* @param {EsmLoader.HookContext.GetFormat} ctx - Resolver context
* @param {EsmLoader.Hooks.GetFormat} fn - Default format fn
* @return {Promise<EsmLoader.HookResult.GetFormat>} Module format
*/
export const getFormat = async (url, ctx, fn) => {
// Get file extension
const ext = path.extname(url)
// Force extensionless files in `bin` directories to load as commonjs
if (/^file:\/\/\/.*\/bin\//.test(url) && !ext) return { format: 'commonjs' }
// ! Fixes `TypeError [ERR_INVALID_MODULE_SPECIFIER]: Invalid module
// ! "file:///$HOME/node_modules/typescript-esm/dist/tsc-esm"`
if (url.includes('typescript-esm/dist/tsc-esm')) return { format: 'commonjs' }
// Load TypeScript files as ESM
// See `tsconfig.json#ts-node.moduleTypes` for file-specific overrides
if (pkg.type === 'module' && ext === '.ts') return { format: 'module' }
// Defer to ts-node for all other URLs
return await hooks.getFormat(url, ctx, fn)
}
/**
* Returns the resolved file URL for a given module `specifier` and parent URL.
*
* @see https://github.com/TypeStrong/ts-node/discussions/1450
* @see https://github.com/dividab/tsconfig-paths
*
* @async
* @param {string} specifier - Module specifier
* @param {EsmLoader.HookContext.Resolve} ctx - Function context
* @param {string} [ctx.parentURL] - URL of module that imported `specifier`
* @param {EsmLoader.Hooks.Resolve} fn - Default fn
* @return {Promise<EsmLoader.HookResult.Resolve>} Resolved file URL
*/
export const resolve = async (specifier, ctx, fn) => {
// Attempt to resolve specifier using path mappings
const match = matchSpecifier(specifier, ctx)
// Update specifier if match was found
if (match !== specifier) specifier = match
// Defer to ts-node
return await hooks.resolve(specifier, ctx, fn)
}
/**
* Applies transformations to `source`.
*
* @async
* @param {EsmLoader.Source} source - Source code to transform: ;
* @param {EsmLoader.HookContext.TransformSource} ctx - Function context
* @param {EsmLoader.Format} [ctx.format] - Module format of source code
* @param {string} [ctx.url] - Source code file URL
* @param {EsmLoader.Hooks.TransformSource} fn - Default transform fn
* @return {Promise<EsmLoader.HookResult.TransformSource>} Source code
*/
export const transformSource = async (source, ctx, fn) => {
source = (await hooks.transformSource(source, ctx, fn)).source
/** @see https://github.com/istanbuljs/esm-loader-hook */
if (TEST) source = (await istanbul.transformSource(source, ctx, fn)).source
return { source }
}
const { createMatchPath } = require('tsconfig-paths')
const {
ExplicitParams,
configLoader
} = require('tsconfig-paths/lib/config-loader')
/**
* @file Helpers - matchSpecifier
* @module tools/helpers/matchSpecifier
*/
/**
* Attempts to match `specifier` to a path alias defined in a tsconfig file.
*
* @see https://github.com/TypeStrong/ts-node/discussions/1450
* @see https://github.com/dividab/tsconfig-paths
*
* @param {string} specifier - Module specifier
* @param {EsmLoader.HookContext.Resolve} ctx - Function context
* @param {string} [ctx.parentURL] - Parent module specifier
* @param {ExplicitParams} [explicitParams] - Tsconfig path mapping options
* @return {string} Resolved file URL
* @throws {Error}
*/
const matchSpecifier = (specifier, ctx, explicitParams) => {
// Load TypeScript config to get path mappings
const result = configLoader({ cwd: process.cwd(), explicitParams })
// Handle possible error
if (result.resultType === 'failed') throw new Error(result.message)
// Get base URL and path aliases
const { absoluteBaseUrl, addMatchAll, mainFields, paths } = result
// Create path matching function
const matchPath = createMatchPath(
absoluteBaseUrl,
paths,
mainFields,
addMatchAll
)
// Attempt to match specifier using path mappings
const match = matchPath(specifier)
// Return patch match if found. Otherwise return original specifier
return match || specifier
}
module.exports = matchSpecifier
const path = require('path')
const { ExplicitParams } = require('tsconfig-paths/lib/config-loader')
const matchSpecifier = require('./match-specifier.cjs')
const { loadSync: tsconfig } = require('tsconfig/dist/tsconfig')
/**
* @file Helpers - tsconfigPaths
* @module tools/helpers/tsconfigPaths
*/
/**
* Installs a custom module load function that can adhere to paths in tsconfig.
*
* @param {ExplicitParams} [explicitParams] - Tsconfig path mapping options
* @return {() => void} Function to undo paths registration
*/
function register(explicitParams) {
// Resolve cjs directory index files
require.extensions['.cjs'] = require.extensions['.js']
// Get original node module and resolveFilename function
const Module = require('module')
const resolveFilename = Module._resolveFilename
/**
* Patches node's module loading.
*
* @param {string} specifier - Module specifier
* @param {NodeJS.Module | null} parent - Module that imported `specifier`
* @return {string} Resolved file URL
*/
Module._resolveFilename = function (specifier, parent) {
const CORE_MODULE = !!register.coreModules(Module.builtinModules)[specifier]
let args = arguments
if (!CORE_MODULE) {
try {
// Get match context
const ctx = { parentURL: parent ? parent.id : undefined }
// Attempt to resolve specifier using path mappings
const match = matchSpecifier(specifier, ctx, explicitParams)
// Update specifier if match was found
if (match !== specifier) {
args = [match, ...Array.prototype.slice.call(args, 1)]
}
} catch (error) {
console.error(`${error.message}. tsconfig-paths skipped`)
return () => void 0
}
}
return resolveFilename.apply(this, args)
}
// Restore node's module loading to original state
return () => {
Module._resolveFilename = resolveFilename
}
}
/**
* Returns a map containing defined core modules.
*
* @param {string[]} [builtins] - Names of builtin core modules
* @return {Record<string, boolean>} Core module map
*/
register.coreModules = builtins => {
builtins = builtins || [
'assert',
'buffer',
'child_process',
'cluster',
'crypto',
'dgram',
'dns',
'domain',
'events',
'fs',
'http',
'https',
'net',
'os',
'path',
'punycode',
'querystring',
'readline',
'stream',
'string_decoder',
'tls',
'tty',
'url',
'util',
'v8',
'vm',
'zlib'
]
const core = {}
for (let module of builtins) core[module] = true
return core
}
const baseUrl = path.resolve(__dirname, '..', '..')
register({
addMatchAll: true,
baseUrl,
mainFields: ['main'],
paths: tsconfig(baseUrl, 'tsconfig.json').config.compilerOptions.paths
})
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment