Skip to content

feat(standalone): migrate standalone binaries to Node 26 Single Executable Applications (SEAs) - #10899

Open
joehan wants to merge 3 commits into
mainfrom
jh-sea-hybrid
Open

feat(standalone): migrate standalone binaries to Node 26 Single Executable Applications (SEAs)#10899
joehan wants to merge 3 commits into
mainfrom
jh-sea-hybrid

Conversation

@joehan

@joehan joehan commented Aug 6, 2026

Copy link
Copy Markdown
Member

Description

This PR migrates the firebase-tools standalone binaries to use Node.js 26 native Single Executable Applications (--build-sea) and esbuild, completely replacing the deprecated @yao-pkg/pkg compiler and eliminating the need for external binary injection tools like postject.


Architectural Highlights

  1. Native Node 26 SEA Packaging (--build-sea):

    • Compiles firepit.js and welcome.js with esbuild (--bundle --platform=node --target=node26).
    • Uses Node 26 native SEA compilation to inject JavaScript bundles and compressed assets into the executable without third-party binary injectors.
  2. In-Memory Subprocess Routing (No Duplicate Node Binary):

    • When child processes are spawned via child_process.fork() or firebase is:node, firepit.js intercepts script arguments at the entrypoint and resolves them via createRequire.
    • Avoids packaging a duplicate 100 MB Node runtime binary inside the asset tarball, saving ~40 MB compressed download size and ~100 MB on-disk extraction footprint.
  3. Native Inline Shell Polyfill:

    • Replaces the heavy shelljs and user-home runtime dependencies with a native cross-platform polyfill (mkdir, rm, cp, chmod, ln, ls, cat, exec) backed by Node fs, os.homedir(), and child_process.spawnSync.
  4. Multi-Target Build Script & macOS Universal 2 Binaries:

    • standalone/build-sea.js downloads official Node 26 binaries and builds executables for:
      • Linux x86_64 (firebase-tools-linux)
      • macOS Apple Silicon (firebase-tools-macos-arm64)
      • macOS Intel (firebase-tools-macos-x64)
      • macOS Universal 2 combined via lipo and ad-hoc signed with codesign (firebase-tools-macos)
      • Windows x86_64 (firebase-tools-win.exe)
    • Supports --current-only for fast local development iteration.
  5. Release Pipeline Integration:

    • Updated scripts/firepit-builder/pipeline.js to build SEA artifacts, package all macOS targets, and generate SHA256 checksums in SHA256SUMS.txt.
  6. Documentation:

    • Comprehensive documentation added in standalone/README.md.

Verification & Testing

  • Automated test suite executed against the compiled binaries covering:
    • Cold boot initialization & help menu display
    • Version command reporting (15.26.0)
    • Subprocess inline evaluation (firebase is:node -e)
    • Subprocess core module resolution (firebase is:node -p)
    • Embedded NPM execution (firebase is:npm --version)
    • External child script execution via fork()
    • Exit code propagation
    • Release pipeline build verification via pipeline.js

…table Applications (SEAs)

- Replace legacy @yao-pkg/pkg with native Node 26 --build-sea and esbuild
- Intercept child process execution via in-memory script routing with createRequire to eliminate duplicate Node binary
- Replace shelljs runtime dependency with native fs/child_process shell polyfill
- Add multi-target build script supporting Linux x64, macOS Universal 2 (arm64 + x64 with lipo), and Windows x64
- Update firepit-builder pipeline to produce SEA artifacts and SHA256 checksums
- Document architecture and build workflows in standalone/README.md
@wiz-9635d3485b

wiz-9635d3485b Bot commented Aug 6, 2026

Copy link
Copy Markdown

Wiz Scan Summary

Scanner Findings
Vulnerability Finding Vulnerabilities -
Data Finding Sensitive Data -
Secret Finding Secrets -
IaC Misconfiguration IaC Misconfigurations -
SAST Finding SAST Findings 13 Medium 31 Low
Software Management Finding Software Management Findings -
Total 13 Medium 31 Low

View scan details in Wiz

To detect these findings earlier in the dev lifecycle, try the Wiz Code extension for VS Code, JetBrains, or Visual Studio.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request migrates the Firepit standalone executable builder from @yao-pkg/pkg to Node.js 26's native Single Executable Application (--build-sea) capabilities and esbuild, removing external dependencies like shelljs in favor of a native inline shell polyfill. Feedback on these changes highlights several critical cross-platform and reliability issues: Unix-specific commands (cp -R and find) are used in build-sea.js which will fail on Windows; unconsumed response streams during HTTP redirects or errors in downloadFile can cause socket leaks; and reading directories without verifying they are files in the pipeline script poses an EISDIR crash risk. Additionally, in firepit.js, executing child scripts via createRequire fails to set up require.main properly, and ImitateNode() does not account for leading Node.js options.

Comment thread standalone/build-sea.js
Comment on lines +180 to +182
execSync(
`cp -R "${path.join(vendorDir, "node_modules")}"/* "${targetNodeModules}/"`
);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Using the Unix-specific cp -R command via execSync will fail on Windows environments. Since this script is intended to support Windows (win.exe target), we should use Node's native, cross-platform fs.cpSync API instead.

    fs.cpSync(path.join(vendorDir, "node_modules"), targetNodeModules, { recursive: true });

Comment thread standalone/build-sea.js
Comment on lines +207 to +211
if (fs.existsSync(prodNodeModules)) {
execSync(
`cp -R "${prodNodeModules}"/* "${targetNodeModules}/" 2>/dev/null || true`
);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Using cp -R via execSync is not cross-platform and will fail on Windows. Use Node's native fs.cpSync instead.

Suggested change
if (fs.existsSync(prodNodeModules)) {
execSync(
`cp -R "${prodNodeModules}"/* "${targetNodeModules}/" 2>/dev/null || true`
);
}
if (fs.existsSync(prodNodeModules)) {
try {
fs.cpSync(prodNodeModules, targetNodeModules, { recursive: true });
} catch (e) {}
}

Comment thread standalone/build-sea.js
Comment on lines +219 to +223
if (fs.existsSync(rootNodeModules)) {
execSync(
`cp -R "${rootNodeModules}"/* "${targetNodeModules}/" 2>/dev/null || true`
);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Using cp -R via execSync is not cross-platform and will fail on Windows. Use Node's native fs.cpSync instead.

Suggested change
if (fs.existsSync(rootNodeModules)) {
execSync(
`cp -R "${rootNodeModules}"/* "${targetNodeModules}/" 2>/dev/null || true`
);
}
if (fs.existsSync(rootNodeModules)) {
try {
fs.cpSync(rootNodeModules, targetNodeModules, { recursive: true });
} catch (e) {}
}

Comment thread standalone/build-sea.js
Comment on lines +232 to +234
try {
execSync(`find "${assetsLibDir}" -name "*.node" -delete 2>/dev/null || true`, { stdio: "ignore" });
} catch (e) {}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The find command is Unix-specific and will fail or execute the wrong utility on Windows. Since this build script is designed to run cross-platform, we should use a native Node.js recursive file search and deletion instead.

Suggested change
try {
execSync(`find "${assetsLibDir}" -name "*.node" -delete 2>/dev/null || true`, { stdio: "ignore" });
} catch (e) {}
try {
const removeNativeAddons = (dir) => {
if (!fs.existsSync(dir)) return;
const list = fs.readdirSync(dir);
for (const file of list) {
const fullPath = path.join(dir, file);
const stat = fs.statSync(fullPath);
if (stat.isDirectory()) {
removeNativeAddons(fullPath);
} else if (file.endsWith(".node")) {
fs.unlinkSync(fullPath);
}
}
};
removeNativeAddons(assetsLibDir);
} catch (e) {}

Comment thread standalone/firepit.js
Comment on lines +338 to +344
try {
const scriptRequire = createRequire(resolvedScriptPath);
scriptRequire(resolvedScriptPath);
} catch (err) {
console.error(err);
process.exit(1);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Executing the child script via createRequire and requiring it directly does not set up the script as the main module (require.main). Many Node.js scripts rely on require.main === module checks to execute their entrypoint logic, which will fail under this implementation.

Using Node's native Module.runMain() solves this by properly setting up the module context and executing it as the main entrypoint.

Suggested change
try {
const scriptRequire = createRequire(resolvedScriptPath);
scriptRequire(resolvedScriptPath);
} catch (err) {
console.error(err);
process.exit(1);
}
try {
process.argv[1] = resolvedScriptPath;
const Module = require("module");
Module.runMain();
} catch (err) {
console.error(err);
process.exit(1);
}

Comment thread standalone/firepit.js
Comment on lines 595 to 600
return new Promise(resolve => {
const cmd = fork(nodeArgs[0], nodeArgs.slice(1), {
const target = path.resolve(nodeArgs[0]);
const cmd = fork(target, nodeArgs.slice(1), {
stdio: "inherit",
env: process.env
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

In ImitateNode(), passing nodeArgs[0] directly to path.resolve assumes that the first argument is always the script path. However, Node.js is often invoked with options (e.g., --require, --max-old-space-size, --inspect), which will cause path.resolve to resolve the option as a file path and fail to execute.

We should parse and separate the leading Node.js options into execArgv and pass them to fork accordingly.

  return new Promise(resolve => {
    const execArgv = [];
    let scriptIndex = 0;
    while (scriptIndex < nodeArgs.length) {
      const arg = nodeArgs[scriptIndex];
      if (arg.startsWith("-")) {
        execArgv.push(arg);
        if ((arg === "-r" || arg === "--require" || arg === "--import") && scriptIndex + 1 < nodeArgs.length) {
          execArgv.push(nodeArgs[scriptIndex + 1]);
          scriptIndex += 2;
        } else {
          scriptIndex += 1;
        }
      } else {
        break;
      }
    }

    const scriptPath = nodeArgs[scriptIndex];
    const scriptArgs = nodeArgs.slice(scriptIndex + 1);
    const target = scriptPath ? path.resolve(scriptPath) : "";
    const cmd = fork(target, scriptArgs, {
      stdio: "inherit",
      env: process.env,
      execArgv
    });

Comment thread standalone/build-sea.js
Comment on lines +73 to +80
if (response.statusCode === 301 || response.statusCode === 302) {
get(response.headers.location);
return;
}
if (response.statusCode !== 200) {
reject(new Error(`Failed to download ${currentUrl}: HTTP ${response.statusCode}`));
return;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

In downloadFile, when a redirect (301 or 302) or a non-200 status code is encountered, the response stream is not consumed or resumed. In Node.js, failing to consume the response stream can cause memory leaks and keep sockets open, potentially hanging the build process.

Please call response.resume() before returning or rejecting to ensure the stream is properly closed and resources are freed.

Suggested change
if (response.statusCode === 301 || response.statusCode === 302) {
get(response.headers.location);
return;
}
if (response.statusCode !== 200) {
reject(new Error(`Failed to download ${currentUrl}: HTTP ${response.statusCode}`));
return;
}
if (response.statusCode === 301 || response.statusCode === 302) {
response.resume();
get(response.headers.location);
return;
}
if (response.statusCode !== 200) {
response.resume();
reject(new Error(`Failed to download ${currentUrl}: HTTP ${response.statusCode}`));
return;
}

Comment on lines +140 to +145
ls("firebase-tools*").forEach((file) => {
if (file.endsWith(".json") || file.endsWith(".txt") || file.endsWith(".js") || file.endsWith(".tar.gz")) return;
const data = fs.readFileSync(file);
const hash = crypto.createHash("sha256").update(data).digest("hex");
sha256Lines.push(`${hash} ${file}`);
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

If there are any directories matching the firebase-tools* pattern in the workspace, calling fs.readFileSync(file) on them will throw an EISDIR error and crash the pipeline.

We should add a defensive check using fs.statSync(file).isFile() to ensure we only read files.

ls("firebase-tools*").forEach((file) => {
  if (file.endsWith(".json") || file.endsWith(".txt") || file.endsWith(".js") || file.endsWith(".tar.gz")) return;
  if (!fs.statSync(file).isFile()) return;
  const data = fs.readFileSync(file);
  const hash = crypto.createHash("sha256").update(data).digest("hex");
  sha256Lines.push(`${hash}  ${file}`);
});

Comment thread standalone/build-sea.js
// Production release pipeline mode
console.log("[build-sea] Using vendor/node_modules from pipeline...");
execSync(
`cp -R "${path.join(vendorDir, "node_modules")}"/* "${targetNodeModules}/"`
Comment thread standalone/build-sea.js
const prodNodeModules = path.join(tmpPackDir, "node_modules");
if (fs.existsSync(prodNodeModules)) {
execSync(
`cp -R "${prodNodeModules}"/* "${targetNodeModules}/" 2>/dev/null || true`
Comment thread standalone/build-sea.js
const rootNodeModules = path.join(standaloneDir, "node_modules");
if (fs.existsSync(rootNodeModules)) {
execSync(
`cp -R "${rootNodeModules}"/* "${targetNodeModules}/" 2>/dev/null || true`
joehan added 2 commits August 7, 2026 18:07
…al binary creation via rcodesign in Linux builds and Dockerfile
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants