Skip to content

Commit

Permalink
Fix data URL format determination in new loader hooks API (#1529)
Browse files Browse the repository at this point in the history
* Add regression test

* add missing primordial RegExpPrototypeExec

* fixg

* Improve createEsmHooks docs

* Fix docs for esm hooks

* lint-fix
  • Loading branch information
cspotcode committed Oct 22, 2021
1 parent 56b70da commit 889c21d
Show file tree
Hide file tree
Showing 4 changed files with 108 additions and 14 deletions.
1 change: 1 addition & 0 deletions dist-raw/node-primordials.js
Expand Up @@ -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,
Expand Down
68 changes: 56 additions & 12 deletions src/esm.ts
Expand Up @@ -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`
Expand All @@ -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;
Expand Down Expand Up @@ -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 =
Expand Down Expand Up @@ -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);

Expand All @@ -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 {
Expand All @@ -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) {
Expand Down
19 changes: 17 additions & 2 deletions src/index.ts
Expand Up @@ -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 {
Expand All @@ -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
Expand Down Expand Up @@ -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);
34 changes: 34 additions & 0 deletions src/test/esm-loader.spec.ts
Expand Up @@ -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);

Expand All @@ -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');
});
}
});

0 comments on commit 889c21d

Please sign in to comment.