Skip to content

Commit 2a9ea22

Browse files
committed
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#903 Relates-to: oracle#903 Signed-off-by: Sam Gammon <[email protected]>
1 parent 8a412bb commit 2a9ea22

File tree

1 file changed

+104
-0
lines changed

1 file changed

+104
-0
lines changed

graal-js/src/com.oracle.truffle.js/src/com/oracle/truffle/js/builtins/commonjs/NpmCompatibleESModuleLoader.java

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,10 @@
6161
import java.io.IOException;
6262
import java.net.URI;
6363
import java.net.URISyntaxException;
64+
import java.util.HashMap;
65+
import java.util.LinkedList;
6466
import java.util.List;
67+
import java.util.Map;
6568

6669
import com.oracle.js.parser.ir.Module.ModuleRequest;
6770
import com.oracle.truffle.api.CompilerDirectives.TruffleBoundary;
@@ -98,9 +101,31 @@ public final class NpmCompatibleESModuleLoader extends DefaultESModuleLoader {
98101
private static final String INVALID_MODULE_SPECIFIER = "Invalid module specifier: '";
99102
private static final String UNSUPPORTED_FILE_EXTENSION = "Unsupported file extension: '";
100103
private static final String UNSUPPORTED_PACKAGE_EXPORTS = "Unsupported package exports: '";
104+
private static final String INVALID_PACKAGE_EXPORT = "Invalid package export: ";
101105
private static final String UNSUPPORTED_PACKAGE_IMPORTS = "Unsupported package imports: '";
102106
private static final String UNSUPPORTED_DIRECTORY_IMPORT = "Unsupported directory import: '";
103107
private static final String INVALID_PACKAGE_CONFIGURATION = "Invalid package configuration: '";
108+
private static final String EXPORT_TYPE_GRAALJS = "graaljs";
109+
private static final String EXPORT_TYPE_IMPORT = "import";
110+
private static final String EXPORT_TYPE_REQUIRE = "require";
111+
private static final String EXPORT_TYPE_DEFAULT = "default";
112+
private static final LinkedList<String> EXPORT_TYPES;
113+
114+
static {
115+
EXPORT_TYPES = new LinkedList<>(
116+
List.of(EXPORT_TYPE_GRAALJS, EXPORT_TYPE_IMPORT, EXPORT_TYPE_REQUIRE, EXPORT_TYPE_DEFAULT)
117+
);
118+
}
119+
120+
public static void registerPreferredExportType(String exportType) {
121+
if (!EXPORT_TYPES.contains(exportType)) {
122+
EXPORT_TYPES.addFirst(exportType);
123+
}
124+
}
125+
126+
public static List<String> getRegisteredExportTypes() {
127+
return List.copyOf(EXPORT_TYPES);
128+
}
104129

105130
public static NpmCompatibleESModuleLoader create(JSRealm realm) {
106131
return new NpmCompatibleESModuleLoader(realm);
@@ -340,6 +365,10 @@ private Format esmFileFormat(URI url, TruffleLanguage.Env env) {
340365
if (url.getPath().endsWith(JS_EXT)) {
341366
return Format.ESM;
342367
}
368+
} else if (url.getPath().endsWith(JS_EXT)) {
369+
// Np Fallback to CJS as below (in the case that there is a package.json without a "type" field, or
370+
// the "type" field is not "module").
371+
return Format.CommonJS;
343372
}
344373
} else if (url.getPath().endsWith(JS_EXT)) {
345374
// 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) {
349378
throw fail(UNSUPPORTED_FILE_EXTENSION, url.toString());
350379
}
351380

381+
private URI exportForImport(URI packageUrl, Map<String, String> exports, TruffleLanguage.Env env) {
382+
// in order of preference, find the best import to use for this circumstance; this will be `graaljs` if
383+
// specified (as top preference), then `import`, then `require`, then `default`. if the developer has registered
384+
// their own preferred export types, these will be preferred first.
385+
//
386+
// this branch only activates if package exports are present and need to be used to resolve an import. thus,
387+
// there is no fallback behavior waiting for us, and so an exception is thrown if no export can be matched.
388+
389+
// 1. for preferred export types...
390+
for (String preferred : getRegisteredExportTypes()) {
391+
// 1.1: is it specified within the exports?
392+
if (exports.containsKey(preferred)) {
393+
// 1.2: if so, resolve the import from the package root. make sure to slice off the `./` prefix.
394+
return packageUrl.resolve(exports.get(preferred).substring(2));
395+
}
396+
}
397+
398+
// 2. if no preferred export types are specified, or none of them are found, throw an exception.
399+
throw failMessage(UNSUPPORTED_PACKAGE_EXPORTS);
400+
}
401+
352402
/**
353403
* PACKAGE_RESOLVE(packageSpecifier, parentURL).
354404
*/
@@ -424,6 +474,12 @@ private URI packageResolve(String packageSpecifier, URI parentURL, TruffleLangua
424474
PackageJson pjson = readPackageJson(packageUrl, env);
425475
// 11.5 If pjson is not null and pjson.exports is not null or undefined, then
426476
if (pjson != null && pjson.hasExportsProperty()) {
477+
var exp = pjson.getExport(packageSubpath);
478+
if (exp != null) {
479+
// we should receive a map of the form `type => path` for the requested export. determine the best
480+
// import type to use and resolve from there.
481+
return exportForImport(packageUrl, exp, env);
482+
}
427483
throw fail(UNSUPPORTED_PACKAGE_EXPORTS, packageSpecifier);
428484
} else if (packageSubpath.equals(DOT)) {
429485
// 11.6 Otherwise, if packageSubpath is equal to ".", then
@@ -544,6 +600,54 @@ public boolean hasExportsProperty() {
544600
return hasNonNullProperty(jsonObj, EXPORTS_PROPERTY_NAME);
545601
}
546602

603+
public Map<String, String> getExport(String specifier) {
604+
assert hasNonNullProperty(jsonObj, EXPORTS_PROPERTY_NAME);
605+
var data = JSObject.get(jsonObj, EXPORTS_PROPERTY_NAME);
606+
if (data instanceof JSDynamicObject exportsObj) {
607+
for (TruffleString key : JSObject.enumerableOwnNames(exportsObj)) {
608+
// find a match for the requested export...
609+
if (key.toString().equals(specifier)) {
610+
// if we found it, it should be a nested object with export mappings. at this point, we've
611+
// already matched the path, so these are mappings of (type => path). `path` must be relative to
612+
// the package root, must start with `.`, must not contain relative backwards references, and
613+
// must be an extant regular file.
614+
Object value = JSObject.get(exportsObj, key);
615+
if (value instanceof JSDynamicObject valueObj) {
616+
var exportKeys = valueObj.ownPropertyKeys();
617+
var exportMap = new HashMap<String, String>();
618+
for (Object exportKey : exportKeys) {
619+
if (exportKey instanceof TruffleString exportKeyStr) {
620+
Object exportValue = JSObject.get(valueObj, exportKeyStr);
621+
if (Strings.isTString(exportValue)) {
622+
var exportStr = exportKeyStr.toString();
623+
var exportVal = exportValue.toString();
624+
if (!exportVal.startsWith(".") || exportVal.contains("..")) {
625+
// must start with `.`, must not contain `..`
626+
throw failMessage(INVALID_PACKAGE_EXPORT + exportStr);
627+
}
628+
exportMap.put(exportKeyStr.toString(), exportValue.toString());
629+
}
630+
} else {
631+
throw failMessage(UNSUPPORTED_PACKAGE_EXPORTS + exportKey.toString());
632+
}
633+
}
634+
return exportMap;
635+
} else if (value instanceof TruffleString exportStr) {
636+
// if the export is a string, it should be a path to the file to import.
637+
if (!exportStr.toString().startsWith(".") || exportStr.toString().contains("..")) {
638+
// must start with `.`, must not contain `..`
639+
throw failMessage(INVALID_PACKAGE_EXPORT + exportStr);
640+
}
641+
return Map.of(EXPORT_TYPE_DEFAULT, exportStr.toString());
642+
} else {
643+
throw failMessage(INVALID_PACKAGE_EXPORT + value);
644+
}
645+
}
646+
}
647+
}
648+
return null;
649+
}
650+
547651
public boolean hasMainProperty() {
548652
if (JSObject.hasProperty(jsonObj, PACKAGE_JSON_MAIN_PROPERTY_NAME)) {
549653
Object value = JSObject.get(jsonObj, PACKAGE_JSON_MAIN_PROPERTY_NAME);

0 commit comments

Comments
 (0)