diff --git a/doc/api/single-executable-applications.md b/doc/api/single-executable-applications.md index c63b8c8f57c0c3..e78043c73757e3 100644 --- a/doc/api/single-executable-applications.md +++ b/doc/api/single-executable-applications.md @@ -111,6 +111,7 @@ The configuration currently reads the following top-level fields: { "main": "/path/to/bundled/script.js", "mainFormat": "commonjs", // Default: "commonjs", options: "commonjs", "module" + "allowDynamicImportFromFileSystem": false, // Default: false "executable": "/path/to/node/binary", // Optional, if not specified, uses the current Node.js binary "output": "/path/to/write/the/generated/executable", "disableExperimentalSEAWarning": true, // Default: false @@ -451,9 +452,12 @@ injected main script with the following properties: -When using `"mainFormat": "module"`, `import()` can be used to dynamically -load built-in modules. Attempting to use `import()` to load modules from -the file system will throw an error. +By default, `import()` in the injected main script can only load built-in +modules. When `allowDynamicImportFromFileSystem` is set to `true` in the SEA +configuration, `import()` can also load modules from the file system. This +works for both `"mainFormat": "commonjs"` and `"mainFormat": "module"` +entry points. Relative specifiers are resolved relative to the directory of the +executable. ### Using native addons in the injected main script diff --git a/lib/internal/modules/esm/utils.js b/lib/internal/modules/esm/utils.js index f977bfaf57498f..e71b9b6536d981 100644 --- a/lib/internal/modules/esm/utils.js +++ b/lib/internal/modules/esm/utils.js @@ -48,6 +48,7 @@ const { } = require('internal/modules/helpers'); let defaultConditions; +let seaBinding; /** * Returns the default conditions for ES module loading. * @returns {object} @@ -68,6 +69,10 @@ function getDefaultConditionsSet() { return defaultConditionsSet; } +function getSeaBinding() { + return seaBinding ??= internalBinding('sea'); +} + /** * Initializes the default conditions for ESM module loading. * This function is called during pre-execution, before any user code is run. @@ -238,15 +243,25 @@ function getBuiltinModuleWrapForEmbedder(specifier) { } /** - * Get the built-in module dynamically for embedder ESM. + * Handle dynamic import for embedder entry points. + * SEA can opt into the default loader via explicit configuration; other embedder + * entry points continue to be limited to built-in modules. * @param {string} specifier - The module specifier string. * @param {number} phase - The module import phase. Ignored for now. * @param {Record} attributes - The import attributes object. Ignored for now. * @param {string|null|undefined} referrerName - name of the referrer. - * @returns {import('internal/modules/esm/loader.js').ModuleExports} - The imported module object. + * @returns {Promise} - The imported module object. */ function importModuleDynamicallyForEmbedder(specifier, phase, attributes, referrerName) { - // Ignore phase and attributes for embedder ESM for now, because this only supports loading builtins. + const { isSeaDynamicImportFromFileSystemEnabled } = getSeaBinding(); + if (isSeaDynamicImportFromFileSystemEnabled()) { + return defaultImportModuleDynamicallyForScript( + specifier, + phase, + attributes, + referrerName, + ); + } return getBuiltinModuleWrapForEmbedder(specifier).getNamespace(); } diff --git a/src/node_sea.cc b/src/node_sea.cc index 1be41e6f14146e..b6d2770a720aa6 100644 --- a/src/node_sea.cc +++ b/src/node_sea.cc @@ -283,6 +283,18 @@ void IsExperimentalSeaWarningNeeded(const FunctionCallbackInfo& args) { sea_resource.flags & SeaFlags::kDisableExperimentalSeaWarning)); } +void IsSeaDynamicImportFromFileSystemEnabled( + const FunctionCallbackInfo& args) { + if (!IsSingleExecutable()) { + args.GetReturnValue().Set(false); + return; + } + + SeaResource sea_resource = FindSingleExecutableResource(); + args.GetReturnValue().Set(static_cast( + sea_resource.flags & SeaFlags::kAllowDynamicImportFromFileSystem)); +} + std::tuple FixupArgsForSEA(int argc, char** argv) { // Repeats argv[0] at position 1 on argv as a replacement for the missing // entry point file path. @@ -444,6 +456,18 @@ std::optional ParseSingleExecutableConfig( if (use_code_cache_value) { result.flags |= SeaFlags::kUseCodeCache; } + } else if (key == "allowDynamicImportFromFileSystem") { + bool allow_dynamic_import_from_file_system; + if (field.value().get_bool().get(allow_dynamic_import_from_file_system)) { + FPrintF(stderr, + "\"allowDynamicImportFromFileSystem\" field of %s is not a " + "Boolean\n", + config_path); + return std::nullopt; + } + if (allow_dynamic_import_from_file_system) { + result.flags |= SeaFlags::kAllowDynamicImportFromFileSystem; + } } else if (key == "assets") { simdjson::ondemand::object assets_object; if (field.value().get_object().get(assets_object)) { @@ -918,6 +942,10 @@ void Initialize(Local target, target, "isExperimentalSeaWarningNeeded", IsExperimentalSeaWarningNeeded); + SetMethod(context, + target, + "isSeaDynamicImportFromFileSystemEnabled", + IsSeaDynamicImportFromFileSystemEnabled); SetMethod(context, target, "getAsset", GetAsset); SetMethod(context, target, "getAssetKeys", GetAssetKeys); } @@ -925,6 +953,7 @@ void Initialize(Local target, void RegisterExternalReferences(ExternalReferenceRegistry* registry) { registry->Register(IsSea); registry->Register(IsExperimentalSeaWarningNeeded); + registry->Register(IsSeaDynamicImportFromFileSystemEnabled); registry->Register(GetAsset); registry->Register(GetAssetKeys); } diff --git a/src/node_sea.h b/src/node_sea.h index dd0b89db841eed..e5f235d59d7d23 100644 --- a/src/node_sea.h +++ b/src/node_sea.h @@ -30,6 +30,7 @@ enum class SeaFlags : uint32_t { kUseCodeCache = 1 << 2, kIncludeAssets = 1 << 3, kIncludeExecArgv = 1 << 4, + kAllowDynamicImportFromFileSystem = 1 << 5, }; enum class SeaExecArgvExtension : uint8_t { diff --git a/test/fixtures/sea/dynamic-import-cjs/sea-config-opt-in-code-cache.json b/test/fixtures/sea/dynamic-import-cjs/sea-config-opt-in-code-cache.json new file mode 100644 index 00000000000000..f98b7fc34f0f40 --- /dev/null +++ b/test/fixtures/sea/dynamic-import-cjs/sea-config-opt-in-code-cache.json @@ -0,0 +1,7 @@ +{ + "main": "sea.js", + "output": "sea", + "allowDynamicImportFromFileSystem": true, + "useCodeCache": true, + "disableExperimentalSEAWarning": true +} diff --git a/test/fixtures/sea/dynamic-import-cjs/sea-config-opt-in.json b/test/fixtures/sea/dynamic-import-cjs/sea-config-opt-in.json new file mode 100644 index 00000000000000..188b24f49226c0 --- /dev/null +++ b/test/fixtures/sea/dynamic-import-cjs/sea-config-opt-in.json @@ -0,0 +1,6 @@ +{ + "main": "sea.js", + "output": "sea", + "allowDynamicImportFromFileSystem": true, + "disableExperimentalSEAWarning": true +} diff --git a/test/fixtures/sea/dynamic-import-cjs/sea-config.json b/test/fixtures/sea/dynamic-import-cjs/sea-config.json new file mode 100644 index 00000000000000..9042b70cdabd9f --- /dev/null +++ b/test/fixtures/sea/dynamic-import-cjs/sea-config.json @@ -0,0 +1,5 @@ +{ + "main": "sea.js", + "output": "sea", + "disableExperimentalSEAWarning": true +} diff --git a/test/fixtures/sea/dynamic-import-cjs/sea.js b/test/fixtures/sea/dynamic-import-cjs/sea.js new file mode 100644 index 00000000000000..23da19b2484d6f --- /dev/null +++ b/test/fixtures/sea/dynamic-import-cjs/sea.js @@ -0,0 +1,15 @@ +const { pathToFileURL } = require('node:url'); +const { dirname, join } = require('node:path'); + +const userURL = pathToFileURL( + join(dirname(process.execPath), 'user.mjs'), +).href; + +import(userURL) + .then(({ message }) => { + console.log(message); + }) + .catch((err) => { + console.error(err); + process.exit(1); + }); diff --git a/test/fixtures/sea/dynamic-import-cjs/user.mjs b/test/fixtures/sea/dynamic-import-cjs/user.mjs new file mode 100644 index 00000000000000..9ef893d4f677f5 --- /dev/null +++ b/test/fixtures/sea/dynamic-import-cjs/user.mjs @@ -0,0 +1,3 @@ +await Promise.resolve(); + +export const message = 'CJS SEA dynamic import executed successfully'; diff --git a/test/fixtures/sea/dynamic-import-esm/sea-config-opt-in.json b/test/fixtures/sea/dynamic-import-esm/sea-config-opt-in.json new file mode 100644 index 00000000000000..4c92b2f9dbd21b --- /dev/null +++ b/test/fixtures/sea/dynamic-import-esm/sea-config-opt-in.json @@ -0,0 +1,7 @@ +{ + "main": "sea.mjs", + "output": "sea", + "mainFormat": "module", + "allowDynamicImportFromFileSystem": true, + "disableExperimentalSEAWarning": true +} diff --git a/test/fixtures/sea/dynamic-import-esm/sea-config.json b/test/fixtures/sea/dynamic-import-esm/sea-config.json new file mode 100644 index 00000000000000..e5ee27ff7f4c85 --- /dev/null +++ b/test/fixtures/sea/dynamic-import-esm/sea-config.json @@ -0,0 +1,6 @@ +{ + "main": "sea.mjs", + "output": "sea", + "mainFormat": "module", + "disableExperimentalSEAWarning": true +} diff --git a/test/fixtures/sea/dynamic-import-esm/sea.mjs b/test/fixtures/sea/dynamic-import-esm/sea.mjs new file mode 100644 index 00000000000000..b45f81eaecc987 --- /dev/null +++ b/test/fixtures/sea/dynamic-import-esm/sea.mjs @@ -0,0 +1,2 @@ +const { message } = await import('./user.mjs'); +console.log(message); diff --git a/test/fixtures/sea/dynamic-import-esm/user.mjs b/test/fixtures/sea/dynamic-import-esm/user.mjs new file mode 100644 index 00000000000000..571307565caadd --- /dev/null +++ b/test/fixtures/sea/dynamic-import-esm/user.mjs @@ -0,0 +1,3 @@ +await Promise.resolve(); + +export const message = 'ESM SEA dynamic import executed successfully'; diff --git a/test/sea/test-build-sea-invalid-boolean-fields.js b/test/sea/test-build-sea-invalid-boolean-fields.js index 1bf7c693b8a164..359e27c8f0c758 100644 --- a/test/sea/test-build-sea-invalid-boolean-fields.js +++ b/test/sea/test-build-sea-invalid-boolean-fields.js @@ -72,3 +72,24 @@ skipIfBuildSEAIsNotSupported(); stderr: /"useCodeCache" field of .*invalid-useCodeCache\.json is not a Boolean/, }); } + +// Test: Invalid "allowDynamicImportFromFileSystem" type (should be Boolean) +{ + tmpdir.refresh(); + const config = tmpdir.resolve('invalid-allowDynamicImportFromFileSystem.json'); + writeFileSync(config, ` +{ + "main": "bundle.js", + "output": "sea", + "allowDynamicImportFromFileSystem": "true" +} + `, 'utf8'); + spawnSyncAndAssert( + process.execPath, + ['--build-sea', config], { + cwd: tmpdir.path, + }, { + status: 1, + stderr: /"allowDynamicImportFromFileSystem" field of .*invalid-allowDynamicImportFromFileSystem\.json is not a Boolean/, + }); +} diff --git a/test/sea/test-single-executable-application-dynamic-import-cjs-code-cache.js b/test/sea/test-single-executable-application-dynamic-import-cjs-code-cache.js new file mode 100644 index 00000000000000..4a7d5146fe380f --- /dev/null +++ b/test/sea/test-single-executable-application-dynamic-import-cjs-code-cache.js @@ -0,0 +1,35 @@ +'use strict'; + +require('../common'); + +// This tests that a CommonJS SEA entry point can dynamically import a user +// module from the file system when explicitly enabled, even with code cache. + +const { + buildSEA, + skipIfBuildSEAIsNotSupported, +} = require('../common/sea'); + +skipIfBuildSEAIsNotSupported(); + +const tmpdir = require('../common/tmpdir'); +const fixtures = require('../common/fixtures'); +const { spawnSyncAndExitWithoutError } = require('../common/child_process'); + +tmpdir.refresh(); + +const outputFile = buildSEA(fixtures.path('sea', 'dynamic-import-cjs'), { + configPath: 'sea-config-opt-in-code-cache.json', +}); + +spawnSyncAndExitWithoutError( + outputFile, + { + env: { + NODE_DEBUG_NATIVE: 'SEA', + ...process.env, + }, + }, + { + stdout: /CJS SEA dynamic import executed successfully/, + }); diff --git a/test/sea/test-single-executable-application-dynamic-import-cjs-default.js b/test/sea/test-single-executable-application-dynamic-import-cjs-default.js new file mode 100644 index 00000000000000..f0f7551ec52cf3 --- /dev/null +++ b/test/sea/test-single-executable-application-dynamic-import-cjs-default.js @@ -0,0 +1,34 @@ +'use strict'; + +require('../common'); + +// This tests that a CommonJS SEA entry point cannot dynamically import a user +// module from the file system without explicit configuration. + +const { + buildSEA, + skipIfBuildSEAIsNotSupported, +} = require('../common/sea'); + +skipIfBuildSEAIsNotSupported(); + +const tmpdir = require('../common/tmpdir'); +const fixtures = require('../common/fixtures'); +const { spawnSyncAndAssert } = require('../common/child_process'); + +tmpdir.refresh(); + +const outputFile = buildSEA(fixtures.path('sea', 'dynamic-import-cjs')); + +spawnSyncAndAssert( + outputFile, + { + env: { + NODE_DEBUG_NATIVE: 'SEA', + ...process.env, + }, + }, + { + status: 1, + stderr: /ERR_UNKNOWN_BUILTIN_MODULE/, + }); diff --git a/test/sea/test-single-executable-application-dynamic-import-cjs.js b/test/sea/test-single-executable-application-dynamic-import-cjs.js new file mode 100644 index 00000000000000..ce7539e8a1dbeb --- /dev/null +++ b/test/sea/test-single-executable-application-dynamic-import-cjs.js @@ -0,0 +1,35 @@ +'use strict'; + +require('../common'); + +// This tests that a CommonJS SEA entry point can use dynamic import() to +// load a user module from the file system. + +const { + buildSEA, + skipIfBuildSEAIsNotSupported, +} = require('../common/sea'); + +skipIfBuildSEAIsNotSupported(); + +const tmpdir = require('../common/tmpdir'); +const fixtures = require('../common/fixtures'); +const { spawnSyncAndExitWithoutError } = require('../common/child_process'); + +tmpdir.refresh(); + +const outputFile = buildSEA(fixtures.path('sea', 'dynamic-import-cjs'), { + configPath: 'sea-config-opt-in.json', +}); + +spawnSyncAndExitWithoutError( + outputFile, + { + env: { + NODE_DEBUG_NATIVE: 'SEA', + ...process.env, + }, + }, + { + stdout: /CJS SEA dynamic import executed successfully/, + }); diff --git a/test/sea/test-single-executable-application-dynamic-import-esm-default.js b/test/sea/test-single-executable-application-dynamic-import-esm-default.js new file mode 100644 index 00000000000000..9b63d0b00f515e --- /dev/null +++ b/test/sea/test-single-executable-application-dynamic-import-esm-default.js @@ -0,0 +1,34 @@ +'use strict'; + +require('../common'); + +// This tests that an ESM SEA entry point cannot dynamically import a user +// module from the file system without explicit configuration. + +const { + buildSEA, + skipIfBuildSEAIsNotSupported, +} = require('../common/sea'); + +skipIfBuildSEAIsNotSupported(); + +const tmpdir = require('../common/tmpdir'); +const fixtures = require('../common/fixtures'); +const { spawnSyncAndAssert } = require('../common/child_process'); + +tmpdir.refresh(); + +const outputFile = buildSEA(fixtures.path('sea', 'dynamic-import-esm')); + +spawnSyncAndAssert( + outputFile, + { + env: { + NODE_DEBUG_NATIVE: 'SEA', + ...process.env, + }, + }, + { + status: 1, + stderr: /ERR_UNKNOWN_BUILTIN_MODULE/, + }); diff --git a/test/sea/test-single-executable-application-dynamic-import-esm.js b/test/sea/test-single-executable-application-dynamic-import-esm.js new file mode 100644 index 00000000000000..4d0f329bedba39 --- /dev/null +++ b/test/sea/test-single-executable-application-dynamic-import-esm.js @@ -0,0 +1,35 @@ +'use strict'; + +require('../common'); + +// This tests that an ESM SEA entry point can use dynamic import() with a +// relative specifier resolved from the executable location. + +const { + buildSEA, + skipIfBuildSEAIsNotSupported, +} = require('../common/sea'); + +skipIfBuildSEAIsNotSupported(); + +const tmpdir = require('../common/tmpdir'); +const fixtures = require('../common/fixtures'); +const { spawnSyncAndExitWithoutError } = require('../common/child_process'); + +tmpdir.refresh(); + +const outputFile = buildSEA(fixtures.path('sea', 'dynamic-import-esm'), { + configPath: 'sea-config-opt-in.json', +}); + +spawnSyncAndExitWithoutError( + outputFile, + { + env: { + NODE_DEBUG_NATIVE: 'SEA', + ...process.env, + }, + }, + { + stdout: /ESM SEA dynamic import executed successfully/, + });