diff --git a/src/node-file-trace.ts b/src/node-file-trace.ts index 7dd8627b..df6dbb24 100644 --- a/src/node-file-trace.ts +++ b/src/node-file-trace.ts @@ -2,7 +2,7 @@ import { NodeFileTraceOptions, NodeFileTraceResult, NodeFileTraceReasons, Stats, import { basename, dirname, extname, relative, resolve, sep } from 'path'; import fs from 'graceful-fs'; import analyze, { AnalyzeResult } from './analyze'; -import resolveDependency from './resolve-dependency'; +import resolveDependency, { NotFoundError } from './resolve-dependency'; import { isMatch } from 'micromatch'; import { sharedLibEmit } from './utils/sharedlib-emit'; import { join } from 'path'; @@ -214,6 +214,45 @@ export class Job { } } + private maybeEmitDep = async (dep: string, path: string, cjsResolve: boolean) => { + let resolved: string | string[] = ''; + let error: Error | undefined; + try { + resolved = await this.resolve(dep, path, this, cjsResolve); + } catch (e1: any) { + error = e1; + try { + if (this.ts && dep.endsWith('.js') && e1 instanceof NotFoundError) { + // TS with ESM relative import paths need full extensions + // (we have to write import "./foo.js" instead of import "./foo") + // See https://www.typescriptlang.org/docs/handbook/esm-node.html + const depTS = dep.slice(0, -3) + '.ts'; + resolved = await this.resolve(depTS, path, this, cjsResolve); + error = undefined; + } + } catch (e2: any) { + error = e2; + } + } + + if (error) { + this.warnings.add(new Error(`Failed to resolve dependency "${dep}":\n${error?.message}`)); + return; + } + + if (Array.isArray(resolved)) { + for (const item of resolved) { + // ignore builtins + if (item.startsWith('node:')) return; + await this.emitDependency(item, path); + } + } else { + // ignore builtins + if (resolved.startsWith('node:')) return; + await this.emitDependency(resolved, path); + } + } + async resolve (id: string, parent: string, job: Job, cjsResolve: boolean): Promise { return resolveDependency(id, parent, job, cjsResolve); } @@ -317,8 +356,11 @@ export class Job { if (path.endsWith('.json')) return; if (path.endsWith('.node')) return await sharedLibEmit(path, this); - // js files require the "type": "module" lookup, so always emit the package.json - if (path.endsWith('.js')) { + // .js and .ts files can change behavior based on { "type": "module" } + // in the nearest package.json so we must emit it too. We don't need to + // emit for .cjs/.mjs/.cts/.mts files since their behavior does not + // depend on package.json + if (path.endsWith('.js') || path.endsWith('.ts')) { const pjsonBoundary = await this.getPjsonBoundary(path); if (pjsonBoundary) await this.emitFile(pjsonBoundary + sep + 'package.json', 'resolve', path); @@ -342,8 +384,9 @@ export class Job { const { deps, imports, assets, isESM } = analyzeResult; - if (isESM) + if (isESM) { this.esmFileList.add(relative(this.base, path)); + } await Promise.all([ ...[...assets].map(async asset => { @@ -354,48 +397,8 @@ export class Job { else await this.emitFile(asset, 'asset', path); }), - ...[...deps].map(async dep => { - try { - var resolved = await this.resolve(dep, path, this, !isESM); - } - catch (e: any) { - this.warnings.add(new Error(`Failed to resolve dependency ${dep}:\n${e && e.message}`)); - return; - } - if (Array.isArray(resolved)) { - for (const item of resolved) { - // ignore builtins - if (item.startsWith('node:')) return; - await this.emitDependency(item, path); - } - } - else { - // ignore builtins - if (resolved.startsWith('node:')) return; - await this.emitDependency(resolved, path); - } - }), - ...[...imports].map(async dep => { - try { - var resolved = await this.resolve(dep, path, this, false); - } - catch (e: any) { - this.warnings.add(new Error(`Failed to resolve dependency ${dep}:\n${e && e.message}`)); - return; - } - if (Array.isArray(resolved)) { - for (const item of resolved) { - // ignore builtins - if (item.startsWith('node:')) return; - await this.emitDependency(item, path); - } - } - else { - // ignore builtins - if (resolved.startsWith('node:')) return; - await this.emitDependency(resolved, path); - } - }) + ...[...deps].map(async dep => this.maybeEmitDep(dep, path, !isESM)), + ...[...imports].map(async dep => this.maybeEmitDep(dep, path, false)), ]); } } \ No newline at end of file diff --git a/src/resolve-dependency.ts b/src/resolve-dependency.ts index 0b26ff14..b50684f4 100644 --- a/src/resolve-dependency.ts +++ b/src/resolve-dependency.ts @@ -60,7 +60,7 @@ async function resolveDir (path: string, parent: string, job: Job) { return resolveFile(resolve(path, 'index'), parent, job); } -class NotFoundError extends Error { +export class NotFoundError extends Error { public code: string; constructor(specifier: string, parent: string) { super("Cannot find module '" + specifier + "' loaded from " + parent); diff --git a/test/unit.test.js b/test/unit.test.js index 6d54e576..ce269b38 100644 --- a/test/unit.test.js +++ b/test/unit.test.js @@ -56,6 +56,9 @@ for (const { testName, isRoot } of unitTests) { if (testName === "tsx-input") { inputFileNames = ["input.tsx"]; } + if (testName === "ts-input-esm") { + inputFileNames = ["input.ts"]; + } if (testName === "processed-dependency" && cached) { inputFileNames = ["input-cached.js"] outputFileName = "output-cached.js" diff --git a/test/unit/ts-input-esm/dep1.ts b/test/unit/ts-input-esm/dep1.ts new file mode 100644 index 00000000..55bfc41f --- /dev/null +++ b/test/unit/ts-input-esm/dep1.ts @@ -0,0 +1,8 @@ +async function start() { + const { dep2 } = await import('./dep2.js'); + return dep2; +} + +start(); + +export const dep1 = 'dep1'; diff --git a/test/unit/ts-input-esm/dep2.ts b/test/unit/ts-input-esm/dep2.ts new file mode 100644 index 00000000..99b2aff5 --- /dev/null +++ b/test/unit/ts-input-esm/dep2.ts @@ -0,0 +1 @@ +export const dep2 = 'dep2'; diff --git a/test/unit/ts-input-esm/input.ts b/test/unit/ts-input-esm/input.ts new file mode 100644 index 00000000..cc562729 --- /dev/null +++ b/test/unit/ts-input-esm/input.ts @@ -0,0 +1 @@ +import { dep1 } from './dep1.js'; diff --git a/test/unit/ts-input-esm/output.js b/test/unit/ts-input-esm/output.js new file mode 100644 index 00000000..a6930b7f --- /dev/null +++ b/test/unit/ts-input-esm/output.js @@ -0,0 +1,6 @@ +[ + "test/unit/ts-input-esm/dep1.ts", + "test/unit/ts-input-esm/dep2.ts", + "test/unit/ts-input-esm/input.ts", + "test/unit/ts-input-esm/package.json" +] diff --git a/test/unit/ts-input-esm/package.json b/test/unit/ts-input-esm/package.json new file mode 100644 index 00000000..e986b24b --- /dev/null +++ b/test/unit/ts-input-esm/package.json @@ -0,0 +1,4 @@ +{ + "private": true, + "type": "module" +} diff --git a/test/unit/ts-input-esm/tsconfig.json b/test/unit/ts-input-esm/tsconfig.json new file mode 100644 index 00000000..f5e151a6 --- /dev/null +++ b/test/unit/ts-input-esm/tsconfig.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "target": "esnext", + "module": "esnext", + "lib": ["esnext"], + "moduleResolution": "node", + "esModuleInterop": true, + "strict": true + } +}