Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
355 changes: 75 additions & 280 deletions npm-shrinkwrap.json

Large diffs are not rendered by default.

10 changes: 4 additions & 6 deletions scripts/firepit-builder/pipeline.js
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ if (styles.headless) {

configTemplate.headless = true;
echo(`module.exports = ` + JSON.stringify(configTemplate)).to("config.js");
npm("run", "pkg");
npm("run", "build:sea");
ls("dist/firepit-*").forEach((file) => {
mv(file, path.join("dist", path.basename(file).replace("firepit", "firebase-tools")));
});
Expand All @@ -93,7 +93,7 @@ if (styles.headful) {

configTemplate.headless = false;
echo(`module.exports = ` + JSON.stringify(configTemplate)).to("config.js");
npm("run", "pkg");
npm("run", "build:sea");

ls("dist/firepit-*").forEach((file) => {
mv(file, path.join("dist", path.basename(file).replace("firepit", "firebase-tools-instant")));
Expand Down Expand Up @@ -123,11 +123,9 @@ if (isPublishing) {
}

echo("-- Artifacts");
rm("-rf", "/tmp/firepit_artifacts");

const outputDir = path.join(tempdir().toString(), "firepit_artifacts");
echo(outputDir);
mkdir(outputDir);
rm("-rf", outputDir);
mkdir("-p", outputDir);
mv("dist/*", outputDir);
cd(outputDir);
console.log(
Expand Down
98 changes: 98 additions & 0 deletions standalone/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
# Firepit Standalone Executable Builder

Firepit packages `firebase-tools` into a Single Executable Application (SEA)
using Node.js 26 native `--build-sea` capabilities.

## Prerequisites

- **Node.js 26.0.0+** installed (required for native `--build-sea`).
- **macOS / Linux / Windows** operating system.
- **Xcode Command Line Tools** (macOS only, for `codesign` and `lipo`).

## Quick Start (Local Development)

To build a standalone executable for your current architecture:

```bash
# Ensure Node 26 is active
nvm use 26

# Build the executable
npm run build:sea

# Run the binary
./dist/firepit-darwin-arm64 --version
```

If Node 26 is not your default active Node version, set `NODE_BIN`:

```bash
NODE_BIN=/path/to/node26/bin/node node build-sea.js
```

## Architecture & Cross-Compilation

Firepit builds native Single Executable Applications for `arm64` and `x64`
architectures.

### Building `arm64` Binaries (Apple Silicon)

Run the build using an `arm64` Node 26 runtime:

```bash
NODE_BIN=/Users/$USER/.nvm/versions/node/v26.6.0/bin/node \
node build-sea.js
```

Output: `dist/firepit-darwin-arm64`

### Building `x64` Binaries (Intel / Rosetta)

Run the build using an `x64` Node 26 runtime:

```bash
NODE_BIN=/Users/$USER/.nvm/versions/node/v26.6.0-x64/bin/node \
node build-sea.js
```

Output: `dist/firepit-darwin-x64`

## Creating Universal Binaries (macOS Universal 2)

To combine `arm64` and `x64` macOS executables into a single Universal 2
binary using Apple's `lipo` utility:

```bash
# 1. Combine binaries with lipo
lipo -create -output dist/firebase-tools-darwin-universal \
dist/firepit-darwin-arm64 \
dist/firepit-darwin-x64

# 2. Re-sign the universal binary with ad-hoc signature
codesign --sign - --force dist/firebase-tools-darwin-universal

# 3. Test execution
./dist/firebase-tools-darwin-universal --version
```

Verify architecture support with `file`:

```bash
file dist/firebase-tools-darwin-universal
```

Expected output:
`dist/firebase-tools-darwin-universal:`
` Mach-O universal binary with 2 architectures`

## Production Release Pipeline

To build official release tarballs and headless binaries end-to-end:

```bash
cd ../scripts/firepit-builder
NODE_BIN=/path/to/node26/bin/node node ./pipeline.js \
--package="/path/to/firebase-tools"
```

Artifacts are output to `/tmp/firepit_artifacts/`.
98 changes: 98 additions & 0 deletions standalone/build-sea.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
const fs = require("fs");
const path = require("path");
const { execSync } = require("child_process");

const standaloneDir = __dirname;
const distDir = path.join(standaloneDir, "dist");
const vendorDir = path.join(standaloneDir, "vendor");

const configPath = path.join(standaloneDir, "config.js");
if (!fs.existsSync(configPath)) {
fs.copyFileSync(path.join(standaloneDir, "config.template.js"), configPath);
}

const esbuildBin = path.join(standaloneDir, "node_modules", ".bin", "esbuild");

function getNodeBinaryPath() {
if (process.env.NODE_BIN && fs.existsSync(process.env.NODE_BIN)) {
return process.env.NODE_BIN;
}
return process.execPath;
}

const hostNodeBin = getNodeBinaryPath();
console.log(`[build-sea] Using host Node binary: ${hostNodeBin}`);

console.log("[build-sea] 1. Bundling firepit JS with esbuild...");
const bundlePath = path.join(distDir, "firepit-bundle.js");
execSync(
`"${esbuildBin}" "${path.join(standaloneDir, "firepit.js")}" --bundle --platform=node --target=node26 --outfile="${bundlePath}"`,
{ stdio: "inherit", cwd: standaloneDir }
);
Comment on lines +28 to +31

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

Instead of spawning a child process to run the esbuild CLI, which can be fragile on Windows due to shell execution and file extension differences, you can use the esbuild JavaScript API directly. This is faster, more reliable, and fully cross-platform.

require("esbuild").buildSync({
  entryPoints: [path.join(standaloneDir, "firepit.js")],
  bundle: true,
  platform: "node",
  target: "node26",
  outfile: bundlePath,
});


const welcomeSrc = path.join(standaloneDir, "welcome.js");
if (fs.existsSync(welcomeSrc)) {
fs.copyFileSync(welcomeSrc, path.join(distDir, "welcome.js"));
}

console.log("[build-sea] 2. Packaging vendor directory...");
const vendorTarPath = path.join(distDir, "vendor.tar.gz");

// Ensure vendor bin directory contains standard Node binary
const vendorBinDir = path.join(vendorDir, "bin");
if (fs.existsSync(vendorDir)) {
if (!fs.existsSync(vendorBinDir)) {
fs.mkdirSync(vendorBinDir, { recursive: true });
}
const isWin = process.platform === "win32";
const nodeBinName = isWin ? "node.exe" : "node";
const targetNodeBin = path.join(vendorBinDir, nodeBinName);
fs.copyFileSync(hostNodeBin, targetNodeBin);
fs.chmodSync(targetNodeBin, 0o755);
execSync(
`tar -czf "${vendorTarPath}" --exclude="*.map" --exclude="*.md" --exclude="*.ts" --exclude="*.d.ts" --exclude="test" --exclude="tests" --exclude="docs" -C "${vendorDir}" .`,
{ stdio: "inherit" }
);
} else {
// Minimal dummy archive if vendor directory does not exist yet
const tempVendor = path.join(distDir, "temp_vendor");
fs.mkdirSync(tempVendor, { recursive: true });
execSync(`tar -czf "${vendorTarPath}" -C "${tempVendor}" .`, { stdio: "inherit" });
fs.rmSync(tempVendor, { recursive: true, force: true });
}

console.log("[build-sea] 3. Building Node 26 Single Executable Application...");
const isWin = process.platform === "win32";
const isMac = process.platform === "darwin";
let arch = process.arch;
try {
const fileOut = execSync(`file "${hostNodeBin}"`, { encoding: "utf8" });
if (fileOut.includes("x86_64")) arch = "x64";
else if (fileOut.includes("arm64")) arch = "arm64";
} catch (e) {}
Comment on lines +67 to +72

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

On macOS, the official Node.js binaries are typically universal binaries containing both x86_64 and arm64 slices. Because fileOut.includes("x86_64") is checked first, arch will always be overwritten to "x64" on Apple Silicon Macs running a universal Node binary. We should check if the binary is universal first, and if so, fall back to process.arch.

Suggested change
let arch = process.arch;
try {
const fileOut = execSync(`file "${hostNodeBin}"`, { encoding: "utf8" });
if (fileOut.includes("x86_64")) arch = "x64";
else if (fileOut.includes("arm64")) arch = "arm64";
} catch (e) {}
let arch = process.arch;
try {
const fileOut = execSync('file "' + hostNodeBin + '"', { encoding: 'utf8' });
if (!fileOut.includes("universal")) {
if (fileOut.includes("x86_64")) arch = "x64";
else if (fileOut.includes("arm64")) arch = "arm64";
}
} catch (e) {}

let targetBinaryName = `firepit-${process.platform}-${arch}`;
if (isWin) targetBinaryName += ".exe";
const targetBinaryPath = path.join(distDir, targetBinaryName);

const seaConfigPath = path.join(distDir, "sea-config.json");
const seaConfig = {
main: bundlePath,
output: targetBinaryPath,
executable: hostNodeBin,
disableExperimentalSEAWarning: true,
assets: {
"vendor.tar.gz": vendorTarPath
}
};
fs.writeFileSync(seaConfigPath, JSON.stringify(seaConfig, null, 2));

// Native Node 26 --build-sea workflow
execSync(`"${hostNodeBin}" --build-sea "${seaConfigPath}"`, { stdio: "inherit" });

if (isMac) {
console.log(`[build-sea] Ad-hoc signing macOS binary ${targetBinaryName}...`);
execSync(`codesign --sign - --force "${targetBinaryPath}"`, { stdio: "inherit" });
}

fs.chmodSync(targetBinaryPath, 0o755);
console.log(`[build-sea] Successfully created SEA binary: ${targetBinaryPath}`);
2 changes: 1 addition & 1 deletion standalone/config.template.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ module.exports = {
which allows the binary to spawn a terminal on Windows and Mac. The is the
behavior for desktop users.
*/
headless: false,
headless: true,

/*
This is generally set to "firebase-tools@latest" however a custom value
Expand Down
Loading
Loading