diff --git a/packages/graalvm-js/src/main/kotlin/elide/runtime/lang/javascript/ElideUniversalJsModuleLoader.kt b/packages/graalvm-js/src/main/kotlin/elide/runtime/lang/javascript/ElideUniversalJsModuleLoader.kt
index 29ce487b16..2c03131900 100644
--- a/packages/graalvm-js/src/main/kotlin/elide/runtime/lang/javascript/ElideUniversalJsModuleLoader.kt
+++ b/packages/graalvm-js/src/main/kotlin/elide/runtime/lang/javascript/ElideUniversalJsModuleLoader.kt
@@ -65,6 +65,7 @@ private val allElideModules = sortedSetOf(
"llm",
"llm/local",
"llm/remote",
+ "secrets",
)
// All TypeScript extensions.
@@ -439,7 +440,7 @@ internal class ElideUniversalJsModuleLoader private constructor(realm: JSRealm)
val mod = toModuleInfo(unprefixed)
return when (determineModuleStrategy(requested, referencingModule, builtin = mod)) {
- FALLBACK -> super.resolveImportedModule(referencingModule, moduleRequest)
+ FALLBACK -> resolveWithExportsFallback(referencingModule, moduleRequest, requested)
DELEGATED -> delegatedModuleCache.computeIfAbsent(unprefixed) {
resolveDelegatedImportedModule(referencingModule, moduleRequest, unprefixed)
}
@@ -449,6 +450,52 @@ internal class ElideUniversalJsModuleLoader private constructor(realm: JSRealm)
}
}
+ /**
+ * Try to resolve using package.json exports before falling back to GraalJS default behavior.
+ *
+ * GraalJS's NpmCompatibleESModuleLoader throws "Unsupported package exports" when it encounters
+ * packages with an `exports` field. This method intercepts npm package resolution and handles
+ * the exports field according to Node.js specification, supporting nested conditional exports.
+ */
+ private fun resolveWithExportsFallback(
+ referencingModule: ScriptOrModule,
+ moduleRequest: ModuleRequest,
+ specifier: String,
+ ): AbstractModuleRecord {
+ // Only try exports resolution for bare specifiers (npm packages)
+ if (!specifier.startsWith(".") && !specifier.startsWith("/") && ":" !in specifier) {
+ val parentPath = getReferencingModulePath(referencingModule)
+ if (parentPath != null) {
+ val resolved = PackageExportsResolver.tryResolveWithExports(
+ specifier,
+ parentPath,
+ realm.env,
+ realm,
+ )
+ if (resolved != null) {
+ return loadModuleFromFile(referencingModule, moduleRequest, resolved, resolved.path)
+ ?: super.resolveImportedModule(referencingModule, moduleRequest)
+ }
+ }
+ }
+
+ // Fall back to GraalJS default behavior
+ return super.resolveImportedModule(referencingModule, moduleRequest)
+ }
+
+ /**
+ * Get the file path of the referencing module.
+ */
+ private fun getReferencingModulePath(referencingModule: ScriptOrModule): TruffleFile? {
+ val source = referencingModule.source
+ val path = source?.path ?: realm.contextOptions.requireCwd
+ return if (path != null) {
+ realm.env.getPublicTruffleFile(path)
+ } else {
+ null
+ }
+ }
+
override fun loadModuleFromFile(
referrer: ScriptOrModule,
moduleRequest: ModuleRequest?,
diff --git a/packages/graalvm-js/src/main/kotlin/elide/runtime/lang/javascript/PackageExportsResolver.kt b/packages/graalvm-js/src/main/kotlin/elide/runtime/lang/javascript/PackageExportsResolver.kt
new file mode 100644
index 0000000000..b3bdbccff4
--- /dev/null
+++ b/packages/graalvm-js/src/main/kotlin/elide/runtime/lang/javascript/PackageExportsResolver.kt
@@ -0,0 +1,262 @@
+/*
+ * Copyright (c) 2024-2025 Elide Technologies, Inc.
+ *
+ * Licensed under the MIT license (the "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * https://opensource.org/license/mit/
+ *
+ * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on
+ * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+ * License for the specific language governing permissions and limitations under the License.
+ */
+
+package elide.runtime.lang.javascript
+
+import com.oracle.truffle.api.TruffleFile
+import com.oracle.truffle.api.TruffleLanguage
+import com.oracle.truffle.js.runtime.JSRealm
+import com.oracle.truffle.js.runtime.objects.JSDynamicObject
+import com.oracle.truffle.js.runtime.objects.JSObject
+import com.oracle.truffle.js.runtime.objects.Null
+import com.oracle.truffle.js.runtime.objects.Undefined
+import com.oracle.truffle.js.builtins.commonjs.CommonJSResolution
+
+/**
+ * Resolves package.json "exports" field according to Node.js conditional exports specification.
+ *
+ * This handles nested conditional exports like:
+ * ```json
+ * {
+ * "exports": {
+ * ".": {
+ * "import": {
+ * "types": "./dist/index.d.mts",
+ * "default": "./dist/index.mjs"
+ * },
+ * "require": {
+ * "types": "./dist/index.d.ts",
+ * "default": "./dist/index.js"
+ * }
+ * }
+ * }
+ * }
+ * ```
+ *
+ * @see Node.js Conditional Exports
+ */
+internal object PackageExportsResolver {
+ private const val NODE_MODULES = "node_modules"
+ private const val PACKAGE_JSON = "package.json"
+ private const val EXPORTS_PROPERTY = "exports"
+
+ // Default conditions for ESM imports, in priority order
+ private val ESM_CONDITIONS = listOf("import", "module", "default")
+
+ /**
+ * Try to resolve an npm package specifier using package.json exports.
+ *
+ * @param specifier The package specifier (e.g., "@discordjs/collection" or "lodash/get")
+ * @param parentPath The path of the importing module
+ * @param env The Truffle environment
+ * @param realm The JS realm
+ * @return The resolved file, or null if exports couldn't resolve it
+ */
+ fun tryResolveWithExports(
+ specifier: String,
+ parentPath: TruffleFile,
+ env: TruffleLanguage.Env,
+ realm: JSRealm,
+ ): TruffleFile? {
+ // Don't handle relative or absolute paths
+ if (specifier.startsWith(".") || specifier.startsWith("/")) {
+ return null
+ }
+
+ // Parse package name and subpath
+ val (packageName, subpath) = parsePackageSpecifier(specifier)
+
+ // Walk up directory tree looking for node_modules
+ var current: TruffleFile? = parentPath.parent
+ while (current != null) {
+ val nodeModulesDir = current.resolve(NODE_MODULES)
+ if (nodeModulesDir.exists() && nodeModulesDir.isDirectory()) {
+ val packageDir = nodeModulesDir.resolve(packageName)
+ if (packageDir.exists() && packageDir.isDirectory()) {
+ val resolved = resolvePackageExports(packageDir, subpath, env, realm)
+ if (resolved != null && resolved.exists() && !resolved.isDirectory()) {
+ return resolved
+ }
+ }
+ }
+ current = current.parent
+ }
+
+ return null
+ }
+
+ /**
+ * Parse a package specifier into package name and subpath.
+ *
+ * Examples:
+ * - "lodash" -> ("lodash", ".")
+ * - "lodash/get" -> ("lodash", "./get")
+ * - "@discordjs/collection" -> ("@discordjs/collection", ".")
+ * - "@discordjs/collection/dist" -> ("@discordjs/collection", "./dist")
+ */
+ private fun parsePackageSpecifier(specifier: String): Pair {
+ val parts = specifier.split("/")
+
+ return if (specifier.startsWith("@") && parts.size >= 2) {
+ // Scoped package: @scope/name or @scope/name/subpath
+ val packageName = "${parts[0]}/${parts[1]}"
+ val subpath = if (parts.size > 2) {
+ "./" + parts.drop(2).joinToString("/")
+ } else {
+ "."
+ }
+ packageName to subpath
+ } else {
+ // Regular package: name or name/subpath
+ val packageName = parts[0]
+ val subpath = if (parts.size > 1) {
+ "./" + parts.drop(1).joinToString("/")
+ } else {
+ "."
+ }
+ packageName to subpath
+ }
+ }
+
+ /**
+ * Resolve exports from a package directory.
+ */
+ private fun resolvePackageExports(
+ packageDir: TruffleFile,
+ subpath: String,
+ env: TruffleLanguage.Env,
+ realm: JSRealm,
+ ): TruffleFile? {
+ val packageJsonFile = packageDir.resolve(PACKAGE_JSON)
+ if (!packageJsonFile.exists()) {
+ return null
+ }
+
+ val packageJson = try {
+ CommonJSResolution.loadJsonObject(packageJsonFile, realm)
+ } catch (e: Exception) {
+ return null
+ }
+
+ if (packageJson == null || !JSObject.hasProperty(packageJson, EXPORTS_PROPERTY)) {
+ return null
+ }
+
+ val exports = JSObject.get(packageJson, EXPORTS_PROPERTY)
+ if (exports == null || exports == Null.instance || exports == Undefined.instance) {
+ return null
+ }
+
+ val resolvedPath = resolveExportsTarget(exports, subpath, ESM_CONDITIONS)
+ ?: return null
+
+ // Resolve the path relative to package directory
+ val normalizedPath = resolvedPath.removePrefix("./")
+ return packageDir.resolve(normalizedPath)
+ }
+
+ /**
+ * Resolve an exports target according to Node.js algorithm.
+ *
+ * The target can be:
+ * - A string: "./dist/index.mjs"
+ * - An object with conditions: { "import": "./index.mjs", "require": "./index.js" }
+ * - An object with subpaths: { ".": "./index.js", "./sub": "./sub.js" }
+ * - Nested conditions: { "import": { "types": "./index.d.ts", "default": "./index.js" } }
+ */
+ private fun resolveExportsTarget(
+ target: Any?,
+ subpath: String,
+ conditions: List,
+ ): String? {
+ return when {
+ // Null/undefined - no resolution
+ target == null || target == Null.instance || target == Undefined.instance -> null
+
+ // String target - direct path
+ target is String -> target
+
+ // TruffleString - convert and return
+ target is com.oracle.truffle.api.strings.TruffleString -> target.toJavaStringUncached()
+
+ // Object target - could be conditions or subpaths
+ target is JSDynamicObject -> resolveExportsObject(target, subpath, conditions)
+
+ // Unknown type
+ else -> null
+ }
+ }
+
+ /**
+ * Resolve an exports object, handling both condition maps and subpath maps.
+ */
+ private fun resolveExportsObject(
+ obj: JSDynamicObject,
+ subpath: String,
+ conditions: List,
+ ): String? {
+ // Check if this is a subpath map (keys start with ".") or condition map
+ val keys = getObjectKeys(obj)
+ val hasSubpaths = keys.any { it.startsWith(".") }
+ val hasConditions = keys.any { !it.startsWith(".") }
+
+ // Node.js spec: can't mix subpaths and conditions at same level
+ // If mixed, treat as conditions
+
+ return if (hasSubpaths && !hasConditions) {
+ // This is a subpath map - look up the subpath
+ val value = JSObject.get(obj, subpath)
+ if (value != null && value != Null.instance && value != Undefined.instance) {
+ resolveExportsTarget(value, ".", conditions)
+ } else {
+ // Try pattern matching (e.g., "./*" patterns) - not implemented yet
+ null
+ }
+ } else {
+ // This is a condition map - check conditions in order
+ for (condition in conditions) {
+ if (JSObject.hasProperty(obj, condition)) {
+ val value = JSObject.get(obj, condition)
+ val resolved = resolveExportsTarget(value, subpath, conditions)
+ if (resolved != null) {
+ return resolved
+ }
+ }
+ }
+
+ // Try "default" as fallback if not in conditions list
+ if ("default" !in conditions && JSObject.hasProperty(obj, "default")) {
+ val value = JSObject.get(obj, "default")
+ return resolveExportsTarget(value, subpath, conditions)
+ }
+
+ null
+ }
+ }
+
+ /**
+ * Get the keys of a JS object.
+ */
+ private fun getObjectKeys(obj: JSDynamicObject): List {
+ return try {
+ JSObject.enumerableOwnNames(obj).mapNotNull { key ->
+ when (key) {
+ is com.oracle.truffle.api.strings.TruffleString -> key.toJavaStringUncached()
+ else -> key?.toString()
+ }
+ }
+ } catch (e: Exception) {
+ emptyList()
+ }
+ }
+}
diff --git a/packages/graalvm/src/test/kotlin/elide/runtime/gvm/js/vfs/JsPackageExportsTest.kt b/packages/graalvm/src/test/kotlin/elide/runtime/gvm/js/vfs/JsPackageExportsTest.kt
new file mode 100644
index 0000000000..11733ea00f
--- /dev/null
+++ b/packages/graalvm/src/test/kotlin/elide/runtime/gvm/js/vfs/JsPackageExportsTest.kt
@@ -0,0 +1,323 @@
+/*
+ * Copyright (c) 2024-2025 Elide Technologies, Inc.
+ *
+ * Licensed under the MIT license (the "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * https://opensource.org/license/mit/
+ *
+ * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on
+ * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+ * License for the specific language governing permissions and limitations under the License.
+ */
+@file:Suppress("JSFileReferences", "JSUnresolvedFunction", "NpmUsedModulesInstalled")
+@file:OptIn(DelicateElideApi::class)
+
+package elide.runtime.gvm.js.vfs
+
+import java.nio.charset.StandardCharsets
+import java.nio.file.Files
+import kotlin.io.path.Path
+import kotlin.test.assertEquals
+import elide.runtime.core.DelicateElideApi
+import elide.runtime.gvm.internals.vfs.HostVFSImpl
+import elide.runtime.gvm.js.AbstractJsTest
+import elide.runtime.gvm.vfs.HostVFS
+import elide.testing.annotations.Test
+import elide.testing.annotations.TestCase
+import elide.util.UUID
+
+/**
+ * Tests for ESM-style import calls that resolve via package.json "exports" field.
+ *
+ * This tests the fix for nested conditional exports which GraalJS doesn't support natively.
+ *
+ * @see Issue #1793
+ * @see Node.js Conditional Exports
+ */
+@TestCase internal class JsPackageExportsTest : AbstractJsTest() {
+ private fun tempHostFs() = HostVFS.scopedTo(
+ Files.createTempDirectory("elide-vfs-${UUID.random()}").toAbsolutePath().toString(),
+ writable = true,
+ ) as HostVFSImpl
+
+ /**
+ * Test: Import from a package with simple string exports.
+ *
+ * package.json: { "exports": "./dist/index.mjs" }
+ */
+ @Test fun testSimpleStringExports() {
+ val fs = tempHostFs()
+ fs.setCurrentWorkingDirectory(Path("/"))
+ fs.createDirectory(fs.getPath("node_modules"))
+ fs.createDirectory(fs.getPath("node_modules/simple-exports"))
+ fs.createDirectory(fs.getPath("node_modules/simple-exports/dist"))
+
+ // Create package.json with simple string exports
+ val configPath = fs.getPath("node_modules/simple-exports/package.json")
+ fs.writeStream(configPath).use { stream ->
+ stream.write("""
+ {
+ "name": "simple-exports",
+ "version": "1.0.0",
+ "exports": "./dist/index.mjs"
+ }
+ """.trimIndent().toByteArray())
+ }
+
+ // Create the module file
+ val modulePath = fs.getPath("node_modules/simple-exports/dist/index.mjs")
+ fs.writeStream(modulePath).use { stream ->
+ stream.write("export const value = 'simple-string-export';".toByteArray())
+ }
+
+ // Create root package.json
+ val pkgPath = fs.getPath("package.json")
+ fs.writeStream(pkgPath).use { stream ->
+ stream.write("""{"name": "test", "type": "module"}""".toByteArray())
+ }
+
+ withHostFs(fs) {
+ // language=javascript
+ """
+ import { value } from "simple-exports";
+ test(value).isEqualTo("simple-string-export");
+ """
+ }.doesNotFail()
+ }
+
+ /**
+ * Test: Import from a package with flat conditional exports.
+ *
+ * package.json: { "exports": { "import": "./dist/index.mjs", "require": "./dist/index.js" } }
+ */
+ @Test fun testFlatConditionalExports() {
+ val fs = tempHostFs()
+ fs.setCurrentWorkingDirectory(Path("/"))
+ fs.createDirectory(fs.getPath("node_modules"))
+ fs.createDirectory(fs.getPath("node_modules/flat-conditional"))
+ fs.createDirectory(fs.getPath("node_modules/flat-conditional/dist"))
+
+ // Create package.json with flat conditional exports
+ val configPath = fs.getPath("node_modules/flat-conditional/package.json")
+ fs.writeStream(configPath).use { stream ->
+ stream.write("""
+ {
+ "name": "flat-conditional",
+ "version": "1.0.0",
+ "exports": {
+ "import": "./dist/index.mjs",
+ "require": "./dist/index.cjs",
+ "default": "./dist/index.mjs"
+ }
+ }
+ """.trimIndent().toByteArray())
+ }
+
+ // Create the ESM module file (should be selected for import)
+ val modulePath = fs.getPath("node_modules/flat-conditional/dist/index.mjs")
+ fs.writeStream(modulePath).use { stream ->
+ stream.write("export const value = 'flat-conditional-esm';".toByteArray())
+ }
+
+ // Create root package.json
+ val pkgPath = fs.getPath("package.json")
+ fs.writeStream(pkgPath).use { stream ->
+ stream.write("""{"name": "test", "type": "module"}""".toByteArray())
+ }
+
+ withHostFs(fs) {
+ // language=javascript
+ """
+ import { value } from "flat-conditional";
+ test(value).isEqualTo("flat-conditional-esm");
+ """
+ }.doesNotFail()
+ }
+
+ /**
+ * Test: Import from a package with nested conditional exports (the main fix).
+ *
+ * This is the pattern used by @discordjs packages that caused the original issue.
+ *
+ * package.json:
+ * {
+ * "exports": {
+ * ".": {
+ * "import": {
+ * "types": "./dist/index.d.mts",
+ * "default": "./dist/index.mjs"
+ * },
+ * "require": {
+ * "types": "./dist/index.d.ts",
+ * "default": "./dist/index.cjs"
+ * }
+ * }
+ * }
+ * }
+ */
+ @Test fun testNestedConditionalExports() {
+ val fs = tempHostFs()
+ fs.setCurrentWorkingDirectory(Path("/"))
+ fs.createDirectory(fs.getPath("node_modules"))
+ fs.createDirectory(fs.getPath("node_modules/nested-conditional"))
+ fs.createDirectory(fs.getPath("node_modules/nested-conditional/dist"))
+
+ // Create package.json with nested conditional exports (the problematic pattern)
+ val configPath = fs.getPath("node_modules/nested-conditional/package.json")
+ fs.writeStream(configPath).use { stream ->
+ stream.write("""
+ {
+ "name": "nested-conditional",
+ "version": "1.0.0",
+ "exports": {
+ ".": {
+ "import": {
+ "types": "./dist/index.d.mts",
+ "default": "./dist/index.mjs"
+ },
+ "require": {
+ "types": "./dist/index.d.ts",
+ "default": "./dist/index.cjs"
+ }
+ }
+ }
+ }
+ """.trimIndent().toByteArray())
+ }
+
+ // Create the ESM module file (should be selected via import -> default)
+ val modulePath = fs.getPath("node_modules/nested-conditional/dist/index.mjs")
+ fs.writeStream(modulePath).use { stream ->
+ stream.write("export const value = 'nested-conditional-esm';".toByteArray())
+ }
+
+ // Create root package.json
+ val pkgPath = fs.getPath("package.json")
+ fs.writeStream(pkgPath).use { stream ->
+ stream.write("""{"name": "test", "type": "module"}""".toByteArray())
+ }
+
+ withHostFs(fs) {
+ // language=javascript
+ """
+ import { value } from "nested-conditional";
+ test(value).isEqualTo("nested-conditional-esm");
+ """
+ }.doesNotFail()
+ }
+
+ /**
+ * Test: Import from a scoped package with nested conditional exports.
+ *
+ * This mimics packages like @discordjs/collection.
+ */
+ @Test fun testScopedPackageNestedExports() {
+ val fs = tempHostFs()
+ fs.setCurrentWorkingDirectory(Path("/"))
+ fs.createDirectory(fs.getPath("node_modules"))
+ fs.createDirectory(fs.getPath("node_modules/@testscope"))
+ fs.createDirectory(fs.getPath("node_modules/@testscope/collection"))
+ fs.createDirectory(fs.getPath("node_modules/@testscope/collection/dist"))
+
+ // Create package.json mimicking @discordjs/collection pattern
+ val configPath = fs.getPath("node_modules/@testscope/collection/package.json")
+ fs.writeStream(configPath).use { stream ->
+ stream.write("""
+ {
+ "name": "@testscope/collection",
+ "version": "2.1.1",
+ "exports": {
+ ".": {
+ "require": {
+ "types": "./dist/index.d.ts",
+ "default": "./dist/index.js"
+ },
+ "import": {
+ "types": "./dist/index.d.mts",
+ "default": "./dist/index.mjs"
+ }
+ }
+ },
+ "main": "./dist/index.js",
+ "module": "./dist/index.mjs"
+ }
+ """.trimIndent().toByteArray())
+ }
+
+ // Create the ESM module file
+ val modulePath = fs.getPath("node_modules/@testscope/collection/dist/index.mjs")
+ fs.writeStream(modulePath).use { stream ->
+ stream.write("""
+ export class Collection extends Map {
+ constructor() {
+ super();
+ this.name = "TestCollection";
+ }
+ }
+ """.trimIndent().toByteArray())
+ }
+
+ // Create root package.json
+ val pkgPath = fs.getPath("package.json")
+ fs.writeStream(pkgPath).use { stream ->
+ stream.write("""{"name": "test", "type": "module"}""".toByteArray())
+ }
+
+ withHostFs(fs) {
+ // language=javascript
+ """
+ import { Collection } from "@testscope/collection";
+ const c = new Collection();
+ test(c.name).isEqualTo("TestCollection");
+ """
+ }.doesNotFail()
+ }
+
+ /**
+ * Test: Import with "default" fallback when specific condition not available.
+ */
+ @Test fun testDefaultFallback() {
+ val fs = tempHostFs()
+ fs.setCurrentWorkingDirectory(Path("/"))
+ fs.createDirectory(fs.getPath("node_modules"))
+ fs.createDirectory(fs.getPath("node_modules/default-fallback"))
+ fs.createDirectory(fs.getPath("node_modules/default-fallback/dist"))
+
+ // Create package.json with only "default" (no "import" condition)
+ val configPath = fs.getPath("node_modules/default-fallback/package.json")
+ fs.writeStream(configPath).use { stream ->
+ stream.write("""
+ {
+ "name": "default-fallback",
+ "version": "1.0.0",
+ "exports": {
+ ".": {
+ "default": "./dist/index.mjs"
+ }
+ }
+ }
+ """.trimIndent().toByteArray())
+ }
+
+ // Create the module file
+ val modulePath = fs.getPath("node_modules/default-fallback/dist/index.mjs")
+ fs.writeStream(modulePath).use { stream ->
+ stream.write("export const value = 'default-fallback-value';".toByteArray())
+ }
+
+ // Create root package.json
+ val pkgPath = fs.getPath("package.json")
+ fs.writeStream(pkgPath).use { stream ->
+ stream.write("""{"name": "test", "type": "module"}""".toByteArray())
+ }
+
+ withHostFs(fs) {
+ // language=javascript
+ """
+ import { value } from "default-fallback";
+ test(value).isEqualTo("default-fallback-value");
+ """
+ }.doesNotFail()
+ }
+}