From 2a9ea22490ce80771ed370b7a3afc1f81be85a4e Mon Sep 17 00:00:00 2001
From: Sam Gammon <sam@elide.dev>
Date: Mon, 24 Mar 2025 00:10:40 -0700
Subject: [PATCH] Add support for `package.json` `exports` field

If a module `package.json` specifies `exports`, GraalJs will now read
them and prefer exports over standard resolution; export types can be
registered by the developer as preferred. In lieu of these types (and
as a default), the following export types are preferred, in order:

- `graaljs`
- `import` (in ESM)
- `require`
- `default`

Fixes and closes oracle/graaljs#903

Relates-to: oracle/graaljs#903
Signed-off-by: Sam Gammon <sam@elide.dev>
---
 .../commonjs/NpmCompatibleESModuleLoader.java | 104 ++++++++++++++++++
 1 file changed, 104 insertions(+)

diff --git a/graal-js/src/com.oracle.truffle.js/src/com/oracle/truffle/js/builtins/commonjs/NpmCompatibleESModuleLoader.java b/graal-js/src/com.oracle.truffle.js/src/com/oracle/truffle/js/builtins/commonjs/NpmCompatibleESModuleLoader.java
index 70145e9a7cf..3f9973a38eb 100644
--- a/graal-js/src/com.oracle.truffle.js/src/com/oracle/truffle/js/builtins/commonjs/NpmCompatibleESModuleLoader.java
+++ b/graal-js/src/com.oracle.truffle.js/src/com/oracle/truffle/js/builtins/commonjs/NpmCompatibleESModuleLoader.java
@@ -61,7 +61,10 @@
 import java.io.IOException;
 import java.net.URI;
 import java.net.URISyntaxException;
+import java.util.HashMap;
+import java.util.LinkedList;
 import java.util.List;
+import java.util.Map;
 
 import com.oracle.js.parser.ir.Module.ModuleRequest;
 import com.oracle.truffle.api.CompilerDirectives.TruffleBoundary;
@@ -98,9 +101,31 @@ public final class NpmCompatibleESModuleLoader extends DefaultESModuleLoader {
     private static final String INVALID_MODULE_SPECIFIER = "Invalid module specifier: '";
     private static final String UNSUPPORTED_FILE_EXTENSION = "Unsupported file extension: '";
     private static final String UNSUPPORTED_PACKAGE_EXPORTS = "Unsupported package exports: '";
+    private static final String INVALID_PACKAGE_EXPORT = "Invalid package export: ";
     private static final String UNSUPPORTED_PACKAGE_IMPORTS = "Unsupported package imports: '";
     private static final String UNSUPPORTED_DIRECTORY_IMPORT = "Unsupported directory import: '";
     private static final String INVALID_PACKAGE_CONFIGURATION = "Invalid package configuration: '";
+    private static final String EXPORT_TYPE_GRAALJS = "graaljs";
+    private static final String EXPORT_TYPE_IMPORT = "import";
+    private static final String EXPORT_TYPE_REQUIRE = "require";
+    private static final String EXPORT_TYPE_DEFAULT = "default";
+    private static final LinkedList<String> EXPORT_TYPES;
+
+    static {
+        EXPORT_TYPES = new LinkedList<>(
+                List.of(EXPORT_TYPE_GRAALJS, EXPORT_TYPE_IMPORT, EXPORT_TYPE_REQUIRE, EXPORT_TYPE_DEFAULT)
+        );
+    }
+
+    public static void registerPreferredExportType(String exportType) {
+        if (!EXPORT_TYPES.contains(exportType)) {
+            EXPORT_TYPES.addFirst(exportType);
+        }
+    }
+
+    public static List<String> getRegisteredExportTypes() {
+        return List.copyOf(EXPORT_TYPES);
+    }
 
     public static NpmCompatibleESModuleLoader create(JSRealm realm) {
         return new NpmCompatibleESModuleLoader(realm);
@@ -340,6 +365,10 @@ private Format esmFileFormat(URI url, TruffleLanguage.Env env) {
                 if (url.getPath().endsWith(JS_EXT)) {
                     return Format.ESM;
                 }
+            } else if (url.getPath().endsWith(JS_EXT)) {
+                // Np Fallback to CJS as below (in the case that there is a package.json without a "type" field, or
+                // the "type" field is not "module").
+                return Format.CommonJS;
             }
         } else if (url.getPath().endsWith(JS_EXT)) {
             // Np Package.json with .js extension: try loading as CJS like Node.js does.
@@ -349,6 +378,27 @@ private Format esmFileFormat(URI url, TruffleLanguage.Env env) {
         throw fail(UNSUPPORTED_FILE_EXTENSION, url.toString());
     }
 
+    private URI exportForImport(URI packageUrl, Map<String, String> exports, TruffleLanguage.Env env) {
+        // in order of preference, find the best import to use for this circumstance; this will be `graaljs` if
+        // specified (as top preference), then `import`, then `require`, then `default`. if the developer has registered
+        // their own preferred export types, these will be preferred first.
+        //
+        // this branch only activates if package exports are present and need to be used to resolve an import. thus,
+        // there is no fallback behavior waiting for us, and so an exception is thrown if no export can be matched.
+
+        // 1. for preferred export types...
+        for (String preferred : getRegisteredExportTypes()) {
+            // 1.1: is it specified within the exports?
+            if (exports.containsKey(preferred)) {
+                // 1.2: if so, resolve the import from the package root. make sure to slice off the `./` prefix.
+                return packageUrl.resolve(exports.get(preferred).substring(2));
+            }
+        }
+
+        // 2. if no preferred export types are specified, or none of them are found, throw an exception.
+        throw failMessage(UNSUPPORTED_PACKAGE_EXPORTS);
+    }
+
     /**
      * PACKAGE_RESOLVE(packageSpecifier, parentURL).
      */
@@ -424,6 +474,12 @@ private URI packageResolve(String packageSpecifier, URI parentURL, TruffleLangua
             PackageJson pjson = readPackageJson(packageUrl, env);
             // 11.5 If pjson is not null and pjson.exports is not null or undefined, then
             if (pjson != null && pjson.hasExportsProperty()) {
+                var exp = pjson.getExport(packageSubpath);
+                if (exp != null) {
+                    // we should receive a map of the form `type => path` for the requested export. determine the best
+                    // import type to use and resolve from there.
+                    return exportForImport(packageUrl, exp, env);
+                }
                 throw fail(UNSUPPORTED_PACKAGE_EXPORTS, packageSpecifier);
             } else if (packageSubpath.equals(DOT)) {
                 // 11.6 Otherwise, if packageSubpath is equal to ".", then
@@ -544,6 +600,54 @@ public boolean hasExportsProperty() {
             return hasNonNullProperty(jsonObj, EXPORTS_PROPERTY_NAME);
         }
 
+        public Map<String, String> getExport(String specifier) {
+            assert hasNonNullProperty(jsonObj, EXPORTS_PROPERTY_NAME);
+            var data = JSObject.get(jsonObj, EXPORTS_PROPERTY_NAME);
+            if (data instanceof JSDynamicObject exportsObj) {
+                for (TruffleString key : JSObject.enumerableOwnNames(exportsObj)) {
+                    // find a match for the requested export...
+                    if (key.toString().equals(specifier)) {
+                        // if we found it, it should be a nested object with export mappings. at this point, we've
+                        // already matched the path, so these are mappings of (type => path). `path` must be relative to
+                        // the package root, must start with `.`, must not contain relative backwards references, and
+                        // must be an extant regular file.
+                        Object value = JSObject.get(exportsObj, key);
+                        if (value instanceof JSDynamicObject valueObj) {
+                            var exportKeys = valueObj.ownPropertyKeys();
+                            var exportMap = new HashMap<String, String>();
+                            for (Object exportKey : exportKeys) {
+                                if (exportKey instanceof TruffleString exportKeyStr) {
+                                    Object exportValue = JSObject.get(valueObj, exportKeyStr);
+                                    if (Strings.isTString(exportValue)) {
+                                        var exportStr = exportKeyStr.toString();
+                                        var exportVal = exportValue.toString();
+                                        if (!exportVal.startsWith(".") || exportVal.contains("..")) {
+                                            // must start with `.`, must not contain `..`
+                                            throw failMessage(INVALID_PACKAGE_EXPORT + exportStr);
+                                        }
+                                        exportMap.put(exportKeyStr.toString(), exportValue.toString());
+                                    }
+                                } else {
+                                    throw failMessage(UNSUPPORTED_PACKAGE_EXPORTS + exportKey.toString());
+                                }
+                            }
+                            return exportMap;
+                        } else if (value instanceof TruffleString exportStr) {
+                            // if the export is a string, it should be a path to the file to import.
+                            if (!exportStr.toString().startsWith(".") || exportStr.toString().contains("..")) {
+                                // must start with `.`, must not contain `..`
+                                throw failMessage(INVALID_PACKAGE_EXPORT + exportStr);
+                            }
+                            return Map.of(EXPORT_TYPE_DEFAULT, exportStr.toString());
+                        } else {
+                            throw failMessage(INVALID_PACKAGE_EXPORT + value);
+                        }
+                    }
+                }
+            }
+            return null;
+        }
+
         public boolean hasMainProperty() {
             if (JSObject.hasProperty(jsonObj, PACKAGE_JSON_MAIN_PROPERTY_NAME)) {
                 Object value = JSObject.get(jsonObj, PACKAGE_JSON_MAIN_PROPERTY_NAME);