Repository: swc-project/swc (rust)

Issue:
swcpack: Fully-Specified ESM Import Specifier Fails
### Describe the bug

Similar to [this `webpack` bug](https://github.com/webpack/webpack/issues/13252), `swcpack` aka `spack` doesn't match the behavior and semantics of TypeScript when importing using a fully-specified path with a `.js` extension.

Eg. the following fails:

```ts
// index.ts
import { two } from './two.js';
```

```ts
// two.ts
export function two() {}
```

But does not fail when building with TSC.

Full repo with failing `build` script: https://github.com/karlhorky/swcpack-esm-fully-specified-demo

### Input code

_No response_

### Config

_No response_

### Playground link

_No response_

### Expected behavior

`swcpack` should match the semantics of TSC and TypeScript, and allow for fully-specified import paths with `.js` extensions for ESM.

See @RyanCavanaugh's comment here: https://github.com/microsoft/TypeScript/issues/41887#issuecomment-741968855

> TypeScript doesn't modify JavaScript code you write, the import path you write should be the one you want to appear in the output .js file

### Version

@swc/cli@0.1.55, @swc/core@1.2.120

### Additional context

_No response_

Pull request description:
fix(swcpack): Fully-Specified ESM Import Specifier
**Related issue (if exists):**

fix #3043 


Patch:
diff --git a/crates/swc_ecma_loader/src/resolvers/node.rs b/crates/swc_ecma_loader/src/resolvers/node.rs
index 16112e2cb8..fc09b364ce 100644
--- a/crates/swc_ecma_loader/src/resolvers/node.rs
+++ b/crates/swc_ecma_loader/src/resolvers/node.rs
@@ -115,6 +115,31 @@ impl NodeModulesResolver {
                     return Ok(Some(ext_path));
                 }
             }
+
+            // TypeScript-specific behavior: if the extension is ".js" or ".jsx",
+            // try replacing it with ".ts" or ".tsx".
+            ext_path.set_file_name(name.into_owned());
+            let old_ext = path.extension().and_then(|ext| ext.to_str());
+
+            if let Some(old_ext) = old_ext {
+                let extensions = match old_ext {
+                    // Note that the official compiler code always tries ".ts" before
+                    // ".tsx" even if the original extension was ".jsx".
+                    "js" => ["ts", "tsx"].as_slice(),
+                    "jsx" => ["ts", "tsx"].as_slice(),
+                    "mjs" => ["mts"].as_slice(),
+                    "cjs" => ["cts"].as_slice(),
+                    _ => [].as_slice(),
+                };
+
+                for ext in extensions {
+                    ext_path.set_extension(ext);
+
+                    if ext_path.is_file() {
+                        return Ok(Some(ext_path));
+                    }
+                }
+            }
         }
 
         bail!("file not found: {}", path.display())


Test patch:
diff --git a/crates/swc_bundler/tests/common/mod.rs b/crates/swc_bundler/tests/common/mod.rs
index f311e5407e..614f8f4c3c 100644
--- a/crates/swc_bundler/tests/common/mod.rs
+++ b/crates/swc_bundler/tests/common/mod.rs
@@ -167,10 +167,39 @@ impl NodeResolver {
             return Ok(path.to_path_buf());
         }
 
-        for ext in EXTENSIONS {
-            let ext_path = path.with_extension(ext);
-            if ext_path.is_file() {
-                return Ok(ext_path);
+        if let Some(name) = path.file_name() {
+            let mut ext_path = path.to_path_buf();
+            let name = name.to_string_lossy();
+            for ext in EXTENSIONS {
+                ext_path.set_file_name(format!("{}.{}", name, ext));
+                if ext_path.is_file() {
+                    return Ok(ext_path);
+                }
+            }
+
+            // TypeScript-specific behavior: if the extension is ".js" or ".jsx",
+            // try replacing it with ".ts" or ".tsx".
+            ext_path.set_file_name(name.into_owned());
+            let old_ext = path.extension().and_then(|ext| ext.to_str());
+
+            if let Some(old_ext) = old_ext {
+                let extensions = match old_ext {
+                    // Note that the official compiler code always tries ".ts" before
+                    // ".tsx" even if the original extension was ".jsx".
+                    "js" => ["ts", "tsx"].as_slice(),
+                    "jsx" => ["ts", "tsx"].as_slice(),
+                    "mjs" => ["mts"].as_slice(),
+                    "cjs" => ["cts"].as_slice(),
+                    _ => [].as_slice(),
+                };
+
+                for ext in extensions {
+                    ext_path.set_extension(ext);
+
+                    if ext_path.is_file() {
+                        return Ok(ext_path);
+                    }
+                }
             }
         }
 
diff --git a/crates/swc_bundler/tests/fixture/issue-3043/case1/input/entry.ts b/crates/swc_bundler/tests/fixture/issue-3043/case1/input/entry.ts
new file mode 100644
index 0000000000..f0d49aeac4
--- /dev/null
+++ b/crates/swc_bundler/tests/fixture/issue-3043/case1/input/entry.ts
@@ -0,0 +1,2 @@
+import { two } from "./two.js";
+two();
diff --git a/crates/swc_bundler/tests/fixture/issue-3043/case1/input/two.ts b/crates/swc_bundler/tests/fixture/issue-3043/case1/input/two.ts
new file mode 100644
index 0000000000..11fc8bb7f4
--- /dev/null
+++ b/crates/swc_bundler/tests/fixture/issue-3043/case1/input/two.ts
@@ -0,0 +1,1 @@
+export function two() {}
\ No newline at end of file
diff --git a/crates/swc_bundler/tests/fixture/issue-3043/case1/output/entry.inlined.ts b/crates/swc_bundler/tests/fixture/issue-3043/case1/output/entry.inlined.ts
new file mode 100644
index 0000000000..64a5e1b51c
--- /dev/null
+++ b/crates/swc_bundler/tests/fixture/issue-3043/case1/output/entry.inlined.ts
@@ -0,0 +1,3 @@
+function two() {
+}
+two();
diff --git a/crates/swc_bundler/tests/fixture/issue-3043/case1/output/entry.ts b/crates/swc_bundler/tests/fixture/issue-3043/case1/output/entry.ts
new file mode 100644
index 0000000000..64a5e1b51c
--- /dev/null
+++ b/crates/swc_bundler/tests/fixture/issue-3043/case1/output/entry.ts
@@ -0,0 +1,3 @@
+function two() {
+}
+two();
diff --git a/crates/swc_bundler/tests/fixture/issue-3043/case2/input/entry.ts b/crates/swc_bundler/tests/fixture/issue-3043/case2/input/entry.ts
new file mode 100644
index 0000000000..50b71ca7b4
--- /dev/null
+++ b/crates/swc_bundler/tests/fixture/issue-3043/case2/input/entry.ts
@@ -0,0 +1,2 @@
+import { result } from "./foo.js";
+console.log(result);
diff --git a/crates/swc_bundler/tests/fixture/issue-3043/case2/input/foo.js.ts b/crates/swc_bundler/tests/fixture/issue-3043/case2/input/foo.js.ts
new file mode 100644
index 0000000000..1595462c9c
--- /dev/null
+++ b/crates/swc_bundler/tests/fixture/issue-3043/case2/input/foo.js.ts
@@ -0,0 +1,1 @@
+export const result = "foo.js.ts";
\ No newline at end of file
diff --git a/crates/swc_bundler/tests/fixture/issue-3043/case2/input/foo.ts b/crates/swc_bundler/tests/fixture/issue-3043/case2/input/foo.ts
new file mode 100644
index 0000000000..3d1a9c378f
--- /dev/null
+++ b/crates/swc_bundler/tests/fixture/issue-3043/case2/input/foo.ts
@@ -0,0 +1,1 @@
+export const result = "foo.ts";
\ No newline at end of file
diff --git a/crates/swc_bundler/tests/fixture/issue-3043/case2/output/entry.inlined.ts b/crates/swc_bundler/tests/fixture/issue-3043/case2/output/entry.inlined.ts
new file mode 100644
index 0000000000..408ea8c4fc
--- /dev/null
+++ b/crates/swc_bundler/tests/fixture/issue-3043/case2/output/entry.inlined.ts
@@ -0,0 +1,2 @@
+const result = "foo.js.ts";
+console.log(result);
diff --git a/crates/swc_bundler/tests/fixture/issue-3043/case2/output/entry.ts b/crates/swc_bundler/tests/fixture/issue-3043/case2/output/entry.ts
new file mode 100644
index 0000000000..408ea8c4fc
--- /dev/null
+++ b/crates/swc_bundler/tests/fixture/issue-3043/case2/output/entry.ts
@@ -0,0 +1,2 @@
+const result = "foo.js.ts";
+console.log(result);
diff --git a/crates/swc_bundler/tests/fixture/issue-3043/case3/input/entry.ts b/crates/swc_bundler/tests/fixture/issue-3043/case3/input/entry.ts
new file mode 100644
index 0000000000..ae483b290d
--- /dev/null
+++ b/crates/swc_bundler/tests/fixture/issue-3043/case3/input/entry.ts
@@ -0,0 +1,2 @@
+import { result } from "./foo.mjs";
+console.log(result);
diff --git a/crates/swc_bundler/tests/fixture/issue-3043/case3/input/foo.mts b/crates/swc_bundler/tests/fixture/issue-3043/case3/input/foo.mts
new file mode 100644
index 0000000000..20a1773a37
--- /dev/null
+++ b/crates/swc_bundler/tests/fixture/issue-3043/case3/input/foo.mts
@@ -0,0 +1,1 @@
+export const result = "foo.mts";
\ No newline at end of file
diff --git a/crates/swc_bundler/tests/fixture/issue-3043/case3/input/foo.ts b/crates/swc_bundler/tests/fixture/issue-3043/case3/input/foo.ts
new file mode 100644
index 0000000000..3d1a9c378f
--- /dev/null
+++ b/crates/swc_bundler/tests/fixture/issue-3043/case3/input/foo.ts
@@ -0,0 +1,1 @@
+export const result = "foo.ts";
\ No newline at end of file
diff --git a/crates/swc_bundler/tests/fixture/issue-3043/case3/output/entry.inlined.ts b/crates/swc_bundler/tests/fixture/issue-3043/case3/output/entry.inlined.ts
new file mode 100644
index 0000000000..1fc1fc7309
--- /dev/null
+++ b/crates/swc_bundler/tests/fixture/issue-3043/case3/output/entry.inlined.ts
@@ -0,0 +1,2 @@
+const result = "foo.mts";
+console.log(result);
diff --git a/crates/swc_bundler/tests/fixture/issue-3043/case3/output/entry.ts b/crates/swc_bundler/tests/fixture/issue-3043/case3/output/entry.ts
new file mode 100644
index 0000000000..1fc1fc7309
--- /dev/null
+++ b/crates/swc_bundler/tests/fixture/issue-3043/case3/output/entry.ts
@@ -0,0 +1,2 @@
+const result = "foo.mts";
+console.log(result);