镜像站点 · 本页由第三方 GitHub 只读镜像提供,非 GitHub 官方站点,不接受任何登录或凭据输入。前往 github.com
Skip to content

SEA: embedder main cannot dynamically import non-builtin modules on Node 25.5+ #62726

Description

@robertsLando

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
./app

Same failure occurs when:

  • mainFormat is set to "module" and the bootstrap uses await 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

  1. robertsLando commented on Apr 14, 2026

    @robertsLando
    ContributorAuthor

    Workaround: vm.USE_MAIN_CONTEXT_DEFAULT_LOADER as an escape hatch

    For 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.Script from inside the SEA main with importModuleDynamically: vm.constants.USE_MAIN_CONTEXT_DEFAULT_LOADER, and use dynamic import() inside that script. Node routes the referrer symbol through defaultImportModuleDynamicallyForScript (the real loader) instead of importModuleDynamicallyForEmbedder, so file URLs resolve normally and top-level await in the user entry works.

    Reduced from the original repro:

    // bootstrap.js (SEA main, CJS or ESM)
    'use strict';
    const vm = require('vm');
    const { pathToFileURL } = require('url');
    
    const entryUrl = pathToFileURL('/path/to/user.mjs').href;
    const script = new vm.Script(
      'import(' + JSON.stringify(entryUrl) + ')',
      { importModuleDynamically: vm.constants.USE_MAIN_CONTEXT_DEFAULT_LOADER }
    );
    script.runInThisContext().catch((err) => {
      console.error(err);
      process.exitCode = 1;
    });

    Verified on Node v25.9.0 against the same repro that originally failed:

    $ ./app
    (node:501750) ExperimentalWarning: vm.USE_MAIN_CONTEXT_DEFAULT_LOADER is an experimental feature and might change at any time
    hello from TLA user module
    

    Notes:

    • The one-shot ExperimentalWarning: vm.USE_MAIN_CONTEXT_DEFAULT_LOADER can be filtered out by wrapping process.emitWarning in the bootstrap if you don't want it leaking to end users of the packaged binary.
    • Works from both mainFormat: "commonjs" and mainFormat: "module" SEA mains (runInThisContext + the importModuleDynamically option are available to both).
    • Top-level await in the user entrypoint is preserved — unlike the Module.runMain() / require(esm) workaround, which rejects TLA modules.

    I've shipped this in yao-pkg/pkg for --sea builds targeting Node 25 (context: we need a staged bootstrap that mounts a VFS and patches dlopen/child_process/process.pkg before 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" in importModuleDynamicallyForEmbedder: would you prefer routing the embedder referrer symbol through defaultImportModuleDynamicallyForModule (i.e. the same path source_text_module_default_hdo uses), 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.

  2. thisalihassan commented on Apr 14, 2026

    @thisalihassan
    Contributor

    @joyeecheung This is a regression from #61654. Before that PR, CJS embedder code use vm_dynamic_import_default_internal so import() went through the default dynamic-import loader

    The simplest fix is changing importModuleDynamicallyForEmbedder to delegate todefaultImportModuleDynamicallyForScript but that also opens up import() for "mainFormat": "module" entrypoints

    So either:

    1. CJS-only fix: restore CJS to vm_dynamic_import_default_internal, leave ESM as-is
    2. Fix both: change the JS handler + update docs

    Happy to work on this

  3. joyeecheung commented on Apr 14, 2026

    @joyeecheung
    Member

    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.

  4. thisalihassan commented on Apr 15, 2026

    @thisalihassan
    Contributor

    @joyeecheung got it let's gate this behind an explicit configuration, I'm working on an implementation that adds anallowDynamicImportFromFileSystem boolean 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.

  5. github-actions commented on Jul 20, 2026

    @github-actions
    Contributor

    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.

  6. added
    staleIssues and PRs marked stale due to inactivity and scheduled for automatic closure.
    on Jul 20, 2026
  7. robertsLando commented on Jul 20, 2026

    @robertsLando
    ContributorAuthor

    no stale

  8. removed
    staleIssues and PRs marked stale due to inactivity and scheduled for automatic closure.
    on Jul 21, 2026
  9. EricMCornelius commented on Sep 25, 2026

    @EricMCornelius

    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.

  10. robertsLando commented on Sep 28, 2026

    @robertsLando
    ContributorAuthor

    please anyone taking care of this?

  11. thisalihassan commented on Sep 28, 2026

    @thisalihassan
    Contributor

    I will re-raise the PR

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions