Skip to content

Commit f8b572c

Browse files
authored
Implement #1649: When entrypoint fails to resolve via ESM, fallback to CommonJS resolution (#1654)
* WIP * fix * rather than throw our own error, throw the error from node's ESM loader
1 parent 1942996 commit f8b572c

File tree

8 files changed

+448
-316
lines changed

8 files changed

+448
-316
lines changed

src/esm.ts

Lines changed: 77 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
import { extname } from 'path';
1616
import * as assert from 'assert';
1717
import { normalizeSlashes } from './util';
18+
import { createRequire } from 'module';
1819
const {
1920
createResolve,
2021
} = require('../dist-raw/node-esm-resolve-implementation');
@@ -68,7 +69,7 @@ export namespace NodeLoaderHooksAPI2 {
6869
parentURL: string;
6970
},
7071
defaultResolve: ResolveHook
71-
) => Promise<{ url: string }>;
72+
) => Promise<{ url: string; format?: NodeLoaderHooksFormat }>;
7273
export type LoadHook = (
7374
url: string,
7475
context: {
@@ -123,47 +124,93 @@ export function createEsmHooks(tsNodeService: Service) {
123124
const hooksAPI: NodeLoaderHooksAPI1 | NodeLoaderHooksAPI2 = newHooksAPI
124125
? { resolve, load, getFormat: undefined, transformSource: undefined }
125126
: { resolve, getFormat, transformSource, load: undefined };
126-
return hooksAPI;
127127

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

134+
/**
135+
* Named "probably" as a reminder that this is a guess.
136+
* node does not explicitly tell us if we're resolving the entrypoint or not.
137+
*/
138+
function isProbablyEntrypoint(specifier: string, parentURL: string) {
139+
return parentURL === undefined && specifier.startsWith('file://');
140+
}
141+
// Side-channel between `resolve()` and `load()` hooks
142+
const rememberIsProbablyEntrypoint = new Set();
143+
const rememberResolvedViaCommonjsFallback = new Set();
144+
134145
async function resolve(
135146
specifier: string,
136147
context: { parentURL: string },
137148
defaultResolve: typeof resolve
138-
): Promise<{ url: string }> {
149+
): Promise<{ url: string; format?: NodeLoaderHooksFormat }> {
139150
const defer = async () => {
140151
const r = await defaultResolve(specifier, context, defaultResolve);
141152
return r;
142153
};
154+
// See: https://github.com/nodejs/node/discussions/41711
155+
// nodejs will likely implement a similar fallback. Till then, we can do our users a favor and fallback today.
156+
async function entrypointFallback(
157+
cb: () => ReturnType<typeof resolve>
158+
): ReturnType<typeof resolve> {
159+
try {
160+
const resolution = await cb();
161+
if (
162+
resolution?.url &&
163+
isProbablyEntrypoint(specifier, context.parentURL)
164+
)
165+
rememberIsProbablyEntrypoint.add(resolution.url);
166+
return resolution;
167+
} catch (esmResolverError) {
168+
if (!isProbablyEntrypoint(specifier, context.parentURL))
169+
throw esmResolverError;
170+
try {
171+
let cjsSpecifier = specifier;
172+
// Attempt to convert from ESM file:// to CommonJS path
173+
try {
174+
if (specifier.startsWith('file://'))
175+
cjsSpecifier = fileURLToPath(specifier);
176+
} catch {}
177+
const resolution = pathToFileURL(
178+
createRequire(process.cwd()).resolve(cjsSpecifier)
179+
).toString();
180+
rememberIsProbablyEntrypoint.add(resolution);
181+
rememberResolvedViaCommonjsFallback.add(resolution);
182+
return { url: resolution, format: 'commonjs' };
183+
} catch (commonjsResolverError) {
184+
throw esmResolverError;
185+
}
186+
}
187+
}
143188

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

147192
if (!isFileUrlOrNodeStyleSpecifier(parsed)) {
148-
return defer();
193+
return entrypointFallback(defer);
149194
}
150195

151196
if (protocol !== null && protocol !== 'file:') {
152-
return defer();
197+
return entrypointFallback(defer);
153198
}
154199

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

161206
// pathname is the path to be resolved
162207

163-
return nodeResolveImplementation.defaultResolve(
164-
specifier,
165-
context,
166-
defaultResolve
208+
return entrypointFallback(() =>
209+
nodeResolveImplementation.defaultResolve(
210+
specifier,
211+
context,
212+
defaultResolve
213+
)
167214
);
168215
}
169216

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

280+
// See: https://github.com/nodejs/node/discussions/41711
281+
// nodejs will likely implement a similar fallback. Till then, we can do our users a favor and fallback today.
282+
async function entrypointFallback(
283+
cb: () => ReturnType<typeof getFormat>
284+
): ReturnType<typeof getFormat> {
285+
try {
286+
return await cb();
287+
} catch (getFormatError) {
288+
if (!rememberIsProbablyEntrypoint.has(url)) throw getFormatError;
289+
return { format: 'commonjs' };
290+
}
291+
}
292+
233293
const parsed = parseUrl(url);
234294

235295
if (!isFileUrlOrNodeStyleSpecifier(parsed)) {
236-
return defer();
296+
return entrypointFallback(defer);
237297
}
238298

239299
const { pathname } = parsed;
@@ -248,9 +308,11 @@ export function createEsmHooks(tsNodeService: Service) {
248308
const ext = extname(nativePath);
249309
let nodeSays: { format: NodeLoaderHooksFormat };
250310
if (ext !== '.js' && !tsNodeService.ignored(nativePath)) {
251-
nodeSays = await defer(formatUrl(pathToFileURL(nativePath + '.js')));
311+
nodeSays = await entrypointFallback(() =>
312+
defer(formatUrl(pathToFileURL(nativePath + '.js')))
313+
);
252314
} else {
253-
nodeSays = await defer();
315+
nodeSays = await entrypointFallback(defer);
254316
}
255317
// For files compiled by ts-node that node believes are either CJS or ESM, check if we should override that classification
256318
if (
@@ -300,4 +362,6 @@ export function createEsmHooks(tsNodeService: Service) {
300362

301363
return { source: emittedJs };
302364
}
365+
366+
return hooksAPI;
303367
}

0 commit comments

Comments
 (0)