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() + } +}