Repository navigation
SEA: embedder main cannot dynamically import non-builtin modules on Node 25.5+ #62726
Description
Version
v25.9.0 (also reproduces on v25.7.0, v25.8.x). Does not reproduce on v24.x.
Platform
Linux 6.17.0-19-generic x86_64
Subsystem
sea, esm, embedding
What steps will reproduce the bug?
Minimal repro, no external tools beyond postject:
mkdir -p /tmp/sea-repro && cd /tmp/sea-repro
cat > user.mjs <<'JS'
console.log('hello from user module');
JS
cat > bootstrap.js <<'JS'
import('file:///tmp/sea-repro/user.mjs').catch(err => {
console.error(err);
process.exit(1);
});
JS
cat > sea-config.json <<'JSON'
{
"main": "bootstrap.js",
"output": "sea-prep.blob",
"disableExperimentalSEAWarning": true
}
JSON
node --experimental-sea-config sea-config.json
cp "$(command -v node)" ./app
npx postject ./app NODE_SEA_BLOB sea-prep.blob \
--sentinel-fuse NODE_SEA_FUSE_fce680ab2cc467b6e072b8b5df1996b2
./appSame failure occurs when:
mainFormatis set to"module"and the bootstrap usesawait import('file:///...')- a CJS bootstrap calls
require('/abs/path.js')on a non-builtin path
How often does it reproduce? Is there a required condition?
100% on Node 25.5+. The identical repro prints hello from user module on Node 24.14.0, so this is a regression introduced somewhere in the 25.5+ window (around the --build-sea landing in #61167 and the mainFormat: "module" support in #61813).
What is the expected behavior?
Dynamic import() (and require() of absolute paths) from the SEA main should resolve through the normal module loader. This is how SEA worked from v20 through v24 and is what enables the common pattern of a small bootstrap baked into the SEA that stages setup (VFS overlays, monkey-patches, diagnostics, ...) and then hands control off to a real user entrypoint on disk.
What do you see instead?
Error [ERR_UNKNOWN_BUILTIN_MODULE]: No such built-in module: file:///tmp/sea-repro/user.mjs
at loadBuiltinModuleForEmbedder (node:internal/modules/helpers:165:9)
at getBuiltinModuleWrapForEmbedder (node:internal/modules/esm/utils:237:10)
at importModuleDynamicallyForEmbedder (node:internal/modules/esm/utils:250:10)
at importModuleDynamicallyCallback (node:internal/modules/esm/utils:282:12)
at bootstrap.js:1:1
at embedderRunCjs (node:internal/main/embedding:93:10)
at embedderRunEntryPoint (node:internal/main/embedding:128:12) {
code: 'ERR_UNKNOWN_BUILTIN_MODULE'
}
Root cause
From lib/internal/modules/esm/utils.js in v25.9.0:
// For embedder entry point ESM, only allow built-in modules.
if (referrerSymbol === embedder_module_hdo) {
return importModuleDynamicallyForEmbedder(specifier, phase, attributes, referrerName);
}function importModuleDynamicallyForEmbedder(specifier, phase, attributes, referrerName) {
// Ignore phase and attributes for embedder ESM for now, because this only supports loading builtins.
return getBuiltinModuleWrapForEmbedder(specifier).getNamespace();
}The CJS path has the same limitation: embedderRequire in lib/internal/main/embedding.js routes through loadBuiltinModuleForEmbedder, so require('/abs/path') from a CJS SEA main on Node 25.5+ also throws ERR_UNKNOWN_BUILTIN_MODULE.
This means the SEA main — whether mainFormat: "commonjs" or mainFormat: "module" — can only load builtin modules via its own require/import(), and cannot hand off to a user script.
Additional information
Workaround (for anyone hitting this in the wild): obtain Module via require('module') (which succeeds because module is a builtin), set process.argv[1] to the real entrypoint, then call Module.runMain(). Module.runMain uses the real CJS loader and, on Node 22.12+, transparently handles ESM entries via require(esm). Caveat: user entrypoints that use top-level await cannot go through this path — require(esm) rejects them — so there is currently no way to load a TLA-using ESM user entrypoint from an SEA main on Node 25.5+.
Request: route importModuleDynamicallyForEmbedder and embedderRequire through the default loaders (the same path source_text_module_default_hdo / vm_dynamic_import_default_internal use), so embedders — including SEA — can keep using dynamic import() / require() to hand off to a user entrypoint after setup.
Context: I'm the maintainer of yao-pkg/pkg (the maintained fork of vercel/pkg); pkg's enhanced SEA mode builds a small bootstrap that mounts a VFS and then hands off to the user entrypoint, which is exactly the pattern this regression breaks.
Activity
robertsLando commentedon Apr 14, 2026
Workaround:
vm.USE_MAIN_CONTEXT_DEFAULT_LOADERas an escape hatchFor anyone else hitting this while the issue is being triaged, there is a (user-land) workaround that survives the embedder restriction: compile a tiny
vm.Scriptfrom inside the SEA main withimportModuleDynamically: vm.constants.USE_MAIN_CONTEXT_DEFAULT_LOADER, and use dynamicimport()inside that script. Node routes the referrer symbol throughdefaultImportModuleDynamicallyForScript(the real loader) instead ofimportModuleDynamicallyForEmbedder, so file URLs resolve normally and top-levelawaitin the user entry works.Reduced from the original repro:
Verified on Node v25.9.0 against the same repro that originally failed:
Notes:
ExperimentalWarning: vm.USE_MAIN_CONTEXT_DEFAULT_LOADERcan be filtered out by wrappingprocess.emitWarningin the bootstrap if you don't want it leaking to end users of the packaged binary.mainFormat: "commonjs"andmainFormat: "module"SEA mains (runInThisContext+ theimportModuleDynamicallyoption are available to both).awaitin the user entrypoint is preserved — unlike theModule.runMain()/require(esm)workaround, which rejects TLA modules.I've shipped this in yao-pkg/pkg for
--seabuilds targeting Node 25 (context: we need a staged bootstrap that mounts a VFS and patchesdlopen/child_process/process.pkgbefore handing control off to the real user entry). Happy to help test a proper upstream fix when one lands.Side note on the
"for now"inimportModuleDynamicallyForEmbedder: would you prefer routing the embedder referrer symbol throughdefaultImportModuleDynamicallyForModule(i.e. the same pathsource_text_module_default_hdouses), or gating it behind an explicit opt-in on the SEA config side (e.g.{ "allowUserLandImports": true })? Happy to put up a PR either way.fix(sea): harden SEA pipeline — CJS bootstrap for ESM+TLA, blob gener…
feat(sea): re-enable native ESM main pending nodejs/node#62726
thisalihassan commentedon Apr 14, 2026
@joyeecheung This is a regression from #61654. Before that PR, CJS embedder code use
vm_dynamic_import_default_internalso import() went through the default dynamic-import loaderThe simplest fix is changing
importModuleDynamicallyForEmbedderto delegate todefaultImportModuleDynamicallyForScriptbut that also opens up import() for "mainFormat": "module" entrypointsSo either:
Happy to work on this
docs(sea): align README + ARCHITECTURE with single-bootstrap dispatch
feat: enhanced SEA mode with walker integration and VFS (#229)
joyeecheung commentedon Apr 14, 2026
I feel that we should do it via a different, explicit configuration - by documentation, CJS is not supposed to be able to load any file on disk in SEA without some explicit request (e.g. for require, it has to explicitly
createRequire()). It otherwise can be considered a bug e.g. if you place a file in some path next to an SEA, it can start to consume it even if from the docs, you are certain it's not going to be affected by on-disk files; depending on your security model, being affected by random files next to the executable when you expect everything to be airtight can in itself be a vulnerability. It's better to explicitly establish the contract via a config.thisalihassan commentedon Apr 15, 2026
@joyeecheung got it let's gate this behind an explicit configuration, I'm working on an implementation that adds an
allowDynamicImportFromFileSystemboolean field to the SEA config.This applies to both "mainFormat": "commonjs" and "mainFormat": "module" entry points. The developer has to explicitly opt-in
{ "main": "bootstrap.js", "output": "my-app", "allowDynamicImportFromFileSystem": true }Happy to adjust the approach based on your feedback before I put up a PR.
github-actions commentedon Jul 20, 2026
This issue has been marked as stale due to 90 days of inactivity.
It will be automatically closed in 30 days if no further activity occurs. If this is still relevant, please leave a comment or update it to keep it open.
robertsLando commentedon Jul 20, 2026
no stale
feat(standalone): bundle a pinned compiler into the single-executable (…
fix(sea): inline @clack/prompts via noExternal in tsdown config
feat(tui): add umactually tui subcommand; purge 19 comparison layouts (…
EricMCornelius commentedon Sep 25, 2026
This appears to be a significant regression in 26.x vs. prior.
For my purposes, all
import('data:text/javascript;base64,<b64encodeduri>')dynamic imports are now failing within the SEA.robertsLando commentedon Sep 28, 2026
please anyone taking care of this?
thisalihassan commentedon Sep 28, 2026
I will re-raise the PR