diff --git a/dist-raw/node-primordials.js b/dist-raw/node-primordials.js index ec8083460..ae3b8b911 100644 --- a/dist-raw/node-primordials.js +++ b/dist-raw/node-primordials.js @@ -16,6 +16,7 @@ module.exports = { ObjectGetOwnPropertyNames: Object.getOwnPropertyNames, ObjectDefineProperty: Object.defineProperty, ObjectPrototypeHasOwnProperty: (obj, prop) => Object.prototype.hasOwnProperty.call(obj, prop), + RegExpPrototypeExec: (obj, string) => RegExp.prototype.exec.call(obj, string), RegExpPrototypeTest: (obj, string) => RegExp.prototype.test.call(obj, string), RegExpPrototypeSymbolReplace: (obj, ...rest) => RegExp.prototype[Symbol.replace].apply(obj, rest), SafeMap: Map, diff --git a/src/esm.ts b/src/esm.ts index 35bca748a..c83fd22c4 100644 --- a/src/esm.ts +++ b/src/esm.ts @@ -36,6 +36,53 @@ const { defaultGetFormat } = require('../dist-raw/node-esm-default-get-format'); // from node, build our implementation of the *new* API on top of it, and implement the *old* // hooks API as a shim to the *new* API. +export interface NodeLoaderHooksAPI1 { + resolve: NodeLoaderHooksAPI1.ResolveHook; + getFormat: NodeLoaderHooksAPI1.GetFormatHook; + transformSource: NodeLoaderHooksAPI1.TransformSourceHook; +} +export namespace NodeLoaderHooksAPI1 { + export type ResolveHook = NodeLoaderHooksAPI2.ResolveHook; + export type GetFormatHook = ( + url: string, + context: {}, + defaultGetFormat: GetFormatHook + ) => Promise<{ format: NodeLoaderHooksFormat }>; + export type TransformSourceHook = ( + source: string | Buffer, + context: { url: string; format: NodeLoaderHooksFormat }, + defaultTransformSource: NodeLoaderHooksAPI1.TransformSourceHook + ) => Promise<{ source: string | Buffer }>; +} + +export interface NodeLoaderHooksAPI2 { + resolve: NodeLoaderHooksAPI2.ResolveHook; + load: NodeLoaderHooksAPI2.LoadHook; +} +export namespace NodeLoaderHooksAPI2 { + export type ResolveHook = ( + specifier: string, + context: { parentURL: string }, + defaultResolve: ResolveHook + ) => Promise<{ url: string }>; + export type LoadHook = ( + url: string, + context: { format: NodeLoaderHooksFormat | null | undefined }, + defaultLoad: NodeLoaderHooksAPI2['load'] + ) => Promise<{ + format: NodeLoaderHooksFormat; + source: string | Buffer | undefined; + }>; +} + +export type NodeLoaderHooksFormat = + | 'builtin' + | 'commonjs' + | 'dynamic' + | 'json' + | 'module' + | 'wasm'; + /** @internal */ export function registerAndCreateEsmHooks(opts?: RegisterOptions) { // Automatically performs registration just like `-r ts-node/register` @@ -62,12 +109,7 @@ export function createEsmHooks(tsNodeService: Service) { versionGteLt(process.versions.node, '12.999.999', '13.0.0'); // Explicit return type to avoid TS's non-ideal inferred type - const hooksAPI: { - resolve: typeof resolve; - getFormat: typeof getFormat | undefined; - transformSource: typeof transformSource | undefined; - load: typeof load | undefined; - } = newHooksAPI + const hooksAPI: NodeLoaderHooksAPI1 | NodeLoaderHooksAPI2 = newHooksAPI ? { resolve, load, getFormat: undefined, transformSource: undefined } : { resolve, getFormat, transformSource, load: undefined }; return hooksAPI; @@ -117,9 +159,12 @@ export function createEsmHooks(tsNodeService: Service) { // `load` from new loader hook API (See description at the top of this file) async function load( url: string, - context: { format: Format | null | undefined }, + context: { format: NodeLoaderHooksFormat | null | undefined }, defaultLoad: typeof load - ): Promise<{ format: Format; source: string | Buffer | undefined }> { + ): Promise<{ + format: NodeLoaderHooksFormat; + source: string | Buffer | undefined; + }> { // If we get a format hint from resolve() on the context then use it // otherwise call the old getFormat() hook using node's old built-in defaultGetFormat() that ships with ts-node const format = @@ -160,12 +205,11 @@ export function createEsmHooks(tsNodeService: Service) { return { format, source }; } - type Format = 'builtin' | 'commonjs' | 'dynamic' | 'json' | 'module' | 'wasm'; async function getFormat( url: string, context: {}, defaultGetFormat: typeof getFormat - ): Promise<{ format: Format }> { + ): Promise<{ format: NodeLoaderHooksFormat }> { const defer = (overrideUrl: string = url) => defaultGetFormat(overrideUrl, context, defaultGetFormat); @@ -185,7 +229,7 @@ export function createEsmHooks(tsNodeService: Service) { // If file has .ts, .tsx, or .jsx extension, then ask node how it would treat this file if it were .js const ext = extname(nativePath); - let nodeSays: { format: Format }; + let nodeSays: { format: NodeLoaderHooksFormat }; if (ext !== '.js' && !tsNodeService.ignored(nativePath)) { nodeSays = await defer(formatUrl(pathToFileURL(nativePath + '.js'))); } else { @@ -210,7 +254,7 @@ export function createEsmHooks(tsNodeService: Service) { async function transformSource( source: string | Buffer, - context: { url: string; format: Format }, + context: { url: string; format: NodeLoaderHooksFormat }, defaultTransformSource: typeof transformSource ): Promise<{ source: string | Buffer }> { if (source === null || source === undefined) { diff --git a/src/index.ts b/src/index.ts index 15fbb0ad0..977922ea5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -23,6 +23,7 @@ import { ModuleTypeClassifier, } from './module-type-classifier'; import { createResolverFunctions } from './resolver-functions'; +import type { createEsmHooks as createEsmHooksFn } from './esm'; export { TSCommon }; export { @@ -39,6 +40,11 @@ export type { TranspileOptions, Transpiler, } from './transpilers/types'; +export type { + NodeLoaderHooksAPI1, + NodeLoaderHooksAPI2, + NodeLoaderHooksFormat, +} from './esm'; /** * Does this version of node obey the package.json "type" field @@ -1486,7 +1492,16 @@ function getTokenAtPosition( } } -import type { createEsmHooks as createEsmHooksFn } from './esm'; +/** + * Create an implementation of node's ESM loader hooks. + * + * This may be useful if you + * want to wrap or compose the loader hooks to add additional functionality or + * combine with another loader. + * + * Node changed the hooks API, so there are two possible APIs. This function + * detects your node version and returns the appropriate API. + */ export const createEsmHooks: typeof createEsmHooksFn = ( tsNodeService: Service -) => require('./esm').createEsmHooks(tsNodeService); +) => (require('./esm') as typeof import('./esm')).createEsmHooks(tsNodeService); diff --git a/src/test/esm-loader.spec.ts b/src/test/esm-loader.spec.ts index e117e1ef8..6c3c1c51a 100644 --- a/src/test/esm-loader.spec.ts +++ b/src/test/esm-loader.spec.ts @@ -7,11 +7,15 @@ import semver = require('semver'); import { contextTsNodeUnderTest, EXPERIMENTAL_MODULES_FLAG, + resetNodeEnvironment, TEST_DIR, } from './helpers'; import { createExec } from './exec-helpers'; import { join } from 'path'; import * as expect from 'expect'; +import type { NodeLoaderHooksAPI2 } from '../'; + +const nodeUsesNewHooksApi = semver.gte(process.version, '16.12.0'); const test = context(contextTsNodeUnderTest); @@ -37,3 +41,33 @@ test.suite('createEsmHooks', (test) => { }); } }); + +test.suite('hooks', (_test) => { + const test = _test.context(async (t) => { + const service = t.context.tsNodeUnderTest.create({ + cwd: TEST_DIR, + }); + t.teardown(() => { + resetNodeEnvironment(); + }); + return { + service, + hooks: t.context.tsNodeUnderTest.createEsmHooks(service), + }; + }); + + if (nodeUsesNewHooksApi) { + test('Correctly determines format of data URIs', async (t) => { + const { hooks } = t.context; + const url = 'data:text/javascript,console.log("hello world");'; + const result = await (hooks as NodeLoaderHooksAPI2).load( + url, + { format: undefined }, + async (url, context, _ignored) => { + return { format: context.format!, source: '' }; + } + ); + expect(result.format).toBe('module'); + }); + } +});