Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement #1649: When entrypoint fails to resolve via ESM, fallback to CommonJS resolution #1654

Merged
merged 4 commits into from Feb 22, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
90 changes: 77 additions & 13 deletions src/esm.ts
Expand Up @@ -15,6 +15,7 @@ import {
import { extname } from 'path';
import * as assert from 'assert';
import { normalizeSlashes } from './util';
import { createRequire } from 'module';
const {
createResolve,
} = require('../dist-raw/node-esm-resolve-implementation');
Expand Down Expand Up @@ -68,7 +69,7 @@ export namespace NodeLoaderHooksAPI2 {
parentURL: string;
},
defaultResolve: ResolveHook
) => Promise<{ url: string }>;
) => Promise<{ url: string; format?: NodeLoaderHooksFormat }>;
export type LoadHook = (
url: string,
context: {
Expand Down Expand Up @@ -123,47 +124,93 @@ export function createEsmHooks(tsNodeService: Service) {
const hooksAPI: NodeLoaderHooksAPI1 | NodeLoaderHooksAPI2 = newHooksAPI
? { resolve, load, getFormat: undefined, transformSource: undefined }
: { resolve, getFormat, transformSource, load: undefined };
return hooksAPI;

function isFileUrlOrNodeStyleSpecifier(parsed: UrlWithStringQuery) {
// We only understand file:// URLs, but in node, the specifier can be a node-style `./foo` or `foo`
const { protocol } = parsed;
return protocol === null || protocol === 'file:';
}

/**
* Named "probably" as a reminder that this is a guess.
* node does not explicitly tell us if we're resolving the entrypoint or not.
*/
function isProbablyEntrypoint(specifier: string, parentURL: string) {
return parentURL === undefined && specifier.startsWith('file://');
}
// Side-channel between `resolve()` and `load()` hooks
const rememberIsProbablyEntrypoint = new Set();
const rememberResolvedViaCommonjsFallback = new Set();

async function resolve(
specifier: string,
context: { parentURL: string },
defaultResolve: typeof resolve
): Promise<{ url: string }> {
): Promise<{ url: string; format?: NodeLoaderHooksFormat }> {
const defer = async () => {
const r = await defaultResolve(specifier, context, defaultResolve);
return r;
};
// See: https://github.com/nodejs/node/discussions/41711
// nodejs will likely implement a similar fallback. Till then, we can do our users a favor and fallback today.
async function entrypointFallback(
cb: () => ReturnType<typeof resolve>
): ReturnType<typeof resolve> {
try {
const resolution = await cb();
if (
resolution?.url &&
isProbablyEntrypoint(specifier, context.parentURL)
)
rememberIsProbablyEntrypoint.add(resolution.url);
return resolution;
} catch (esmResolverError) {
if (!isProbablyEntrypoint(specifier, context.parentURL))
throw esmResolverError;
try {
let cjsSpecifier = specifier;
// Attempt to convert from ESM file:// to CommonJS path
try {
if (specifier.startsWith('file://'))
cjsSpecifier = fileURLToPath(specifier);
} catch {}
const resolution = pathToFileURL(
createRequire(process.cwd()).resolve(cjsSpecifier)
).toString();
rememberIsProbablyEntrypoint.add(resolution);
rememberResolvedViaCommonjsFallback.add(resolution);
return { url: resolution, format: 'commonjs' };
} catch (commonjsResolverError) {
throw esmResolverError;
}
}
}

const parsed = parseUrl(specifier);
const { pathname, protocol, hostname } = parsed;

if (!isFileUrlOrNodeStyleSpecifier(parsed)) {
return defer();
return entrypointFallback(defer);
}

if (protocol !== null && protocol !== 'file:') {
return defer();
return entrypointFallback(defer);
}

// Malformed file:// URL? We should always see `null` or `''`
if (hostname) {
// TODO file://./foo sets `hostname` to `'.'`. Perhaps we should special-case this.
return defer();
return entrypointFallback(defer);
}

// pathname is the path to be resolved

return nodeResolveImplementation.defaultResolve(
specifier,
context,
defaultResolve
return entrypointFallback(() =>
nodeResolveImplementation.defaultResolve(
specifier,
context,
defaultResolve
)
);
}

Expand Down Expand Up @@ -230,10 +277,23 @@ export function createEsmHooks(tsNodeService: Service) {
const defer = (overrideUrl: string = url) =>
defaultGetFormat(overrideUrl, context, defaultGetFormat);

// See: https://github.com/nodejs/node/discussions/41711
// nodejs will likely implement a similar fallback. Till then, we can do our users a favor and fallback today.
async function entrypointFallback(
cb: () => ReturnType<typeof getFormat>
): ReturnType<typeof getFormat> {
try {
return await cb();
} catch (getFormatError) {
if (!rememberIsProbablyEntrypoint.has(url)) throw getFormatError;
return { format: 'commonjs' };
}
}

const parsed = parseUrl(url);

if (!isFileUrlOrNodeStyleSpecifier(parsed)) {
return defer();
return entrypointFallback(defer);
}

const { pathname } = parsed;
Expand All @@ -248,9 +308,11 @@ export function createEsmHooks(tsNodeService: Service) {
const ext = extname(nativePath);
let nodeSays: { format: NodeLoaderHooksFormat };
if (ext !== '.js' && !tsNodeService.ignored(nativePath)) {
nodeSays = await defer(formatUrl(pathToFileURL(nativePath + '.js')));
nodeSays = await entrypointFallback(() =>
defer(formatUrl(pathToFileURL(nativePath + '.js')))
);
} else {
nodeSays = await defer();
nodeSays = await entrypointFallback(defer);
}
// For files compiled by ts-node that node believes are either CJS or ESM, check if we should override that classification
if (
Expand Down Expand Up @@ -300,4 +362,6 @@ export function createEsmHooks(tsNodeService: Service) {

return { source: emittedJs };
}

return hooksAPI;
}