Skip to content
Open
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,4 @@
- Added local machine config persistence to `appcheck:debugtokens:create`, `--local` support to `appcheck:debugtokens:list`, and `appcheck:debugtokens:prune` to clean up stale local debug tokens.
- Fixed `database:import` failing with an unhandled promise rejection when a batch write fails.
- Fixed emulator crashes when a Firestore rules reload or emulator HTTP handler throws.
- Fixed `firebase deploy` failing to find the Cloud Functions SDK when the package manager does not create a `node_modules/.bin/firebase-functions` file, such as Bun on Windows. (#11386)
136 changes: 136 additions & 0 deletions src/deploy/functions/runtimes/node/index.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { expect } from "chai";
import * as sinon from "sinon";
import * as fs from "fs";
import * as os from "os";
import * as path from "path";

import * as node from ".";
Expand Down Expand Up @@ -136,4 +138,138 @@ describe("NodeDelegate", () => {
expect(detectFromYamlMock).to.have.been.called;
});
});
describe("findFunctionsBinary", () => {
const BIN_ENTRY = path.join("lib", "bin", "firebase-functions.js");
let projectDir: string;
let sourceDir: string;

function installSdk(storeDir: string, binShims: string[], bin: unknown): string {
const nodeModules = path.join(sourceDir, "node_modules");
const sdkDir = path.join(nodeModules, storeDir, "firebase-functions");
fs.mkdirSync(path.join(sdkDir, "lib", "v2"), { recursive: true });
fs.mkdirSync(path.join(sdkDir, "lib", "bin"), { recursive: true });
fs.writeFileSync(path.join(sdkDir, "lib", "v2", "index.js"), "");
fs.writeFileSync(path.join(sdkDir, BIN_ENTRY), "");
fs.writeFileSync(
path.join(sdkDir, "package.json"),
JSON.stringify({ name: "firebase-functions", main: "lib/v2/index.js", bin }),
);
if (storeDir) {
fs.symlinkSync(sdkDir, path.join(nodeModules, "firebase-functions"), "junction");
}
fs.mkdirSync(path.join(nodeModules, ".bin"), { recursive: true });
for (const shim of binShims) {
fs.writeFileSync(path.join(nodeModules, ".bin", shim), "");
}
return fs.realpathSync(sdkDir);
}

function sdkMain(sdkDir: string): string {
return path.join(sdkDir, "lib", "v2", "index.js");
}

function findFunctionsBinary(): { binary: string[]; bin: string } {
const delegate = new node.Delegate(PROJECT_ID, projectDir, sourceDir, "nodejs24");

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

🟡 Nit: [Testing Discipline] Testing private helper via index bracket notation

Rationale:
src/deploy/functions/runtimes/node/index.spec.ts#L164-L166
Reaching into the private method via delegate["findFunctionsBinary"]() bypasses TypeScript's type visibility controls. While pragmatic here because spawnFunctionsProcess initiates a child process, exporting findSdkBinEntry directly with an @internal JSDoc annotation would provide a clean, typed unit test seam without needing index notation on Delegate.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Thanks, agreed it reads much cleaner. In 27cf57e findSdkBinEntry is exported with @internal and the bin entry cases (Bun store, .pnpm store, string bin, no bin, missing file, malformed package.json) now test it directly. I kept three small findFunctionsBinary tests for the parts that live on the Delegate: the .bin shim winning, pairing the entry with this.bin, and the error when nothing is found. Would you rather I drop those so there is no bracket access at all? Happy either way.

return { binary: delegate["findFunctionsBinary"](), bin: delegate.bin };
}

beforeEach(() => {
projectDir = fs.mkdtempSync(path.join(os.tmpdir(), "functions-binary-"));
sourceDir = path.join(projectDir, "functions");
fs.mkdirSync(sourceDir);
});

afterEach(() => {
fs.rmSync(projectDir, { recursive: true, force: true });
});

it("prefers the node_modules/.bin shim when it exists", () => {
installSdk("", ["firebase-functions"], {
"firebase-functions": "lib/bin/firebase-functions.js",
});
const { binary } = findFunctionsBinary();
expect(binary).to.deep.equal([
path.join(sourceDir, "node_modules", ".bin", "firebase-functions"),
]);
});
Comment on lines +186 to +194

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

Update the assertion to destructure the 'binary' property from the updated helper.

Suggested change
it("prefers the node_modules/.bin shim when it exists", () => {
installSdk("", ["firebase-functions"], {
"firebase-functions": "lib/bin/firebase-functions.js",
});
expect(findFunctionsBinary()).to.deep.equal([
path.join(sourceDir, "node_modules", ".bin", "firebase-functions"),
]);
});
it("prefers the node_modules/.bin shim when it exists", () => {
installSdk("", ["firebase-functions"], {
"firebase-functions": "lib/bin/firebase-functions.js",
});
const { binary } = findFunctionsBinary();
expect(binary).to.deep.equal([
path.join(sourceDir, "node_modules", ".bin", "firebase-functions"),
]);
});

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Done in 6e136d7.


it("runs the SDK bin entry with the resolved node binary when no shim exists", () => {
const sdkDir = installSdk(
path.join(".bun", "firebase-functions@7.4.0+abc", "node_modules"),
["firebase-functions.exe", "firebase-functions.bunx"],
{ "firebase-functions": "lib/bin/firebase-functions.js" },
);
const { binary, bin } = findFunctionsBinary();
expect(binary).to.deep.equal([bin, path.join(sdkDir, BIN_ENTRY)]);
});

it("throws when the SDK declares no bin entry and no shim exists", () => {
installSdk("", [], undefined);
expect(() => findFunctionsBinary()).to.throw(
FirebaseError,
"Failed to find location of Firebase Functions SDK",
);
});

it("passes the source directory to the SDK bin entry", async () => {
const sdkDir = installSdk("", [], "lib/bin/firebase-functions.js");
fs.writeFileSync(
path.join(sdkDir, BIN_ENTRY),
"process.stdout.write(JSON.stringify(process.argv.slice(2)));",
);
const delegate = new node.Delegate(PROJECT_ID, projectDir, sourceDir, "nodejs24");
const child = delegate["spawnFunctionsProcess"]({}, {});
let stdout = "";
child.stdout?.on("data", (chunk: Buffer) => (stdout += chunk.toString("utf8")));
await new Promise((resolve) => child.on("close", resolve));
expect(stdout).to.equal(JSON.stringify([sourceDir]));
});

describe("findSdkBinEntry", () => {
it("finds the bin entry in a Bun store", () => {
const sdkDir = installSdk(
path.join(".bun", "firebase-functions@7.4.0+abc", "node_modules"),
["firebase-functions.exe", "firebase-functions.bunx"],
{ "firebase-functions": "lib/bin/firebase-functions.js" },
);
expect(node.findSdkBinEntry(sdkMain(sdkDir))).to.equal(path.join(sdkDir, BIN_ENTRY));
});

it("finds the bin entry in a .pnpm store", () => {
const sdkDir = installSdk(
path.join(".pnpm", "firebase-functions@7.4.0", "node_modules"),
["firebase-functions.cmd", "firebase-functions.ps1"],
{ "firebase-functions": "lib/bin/firebase-functions.js" },
);
expect(node.findSdkBinEntry(sdkMain(sdkDir))).to.equal(path.join(sdkDir, BIN_ENTRY));
});

it("accepts a bin entry declared as a single path", () => {
const sdkDir = installSdk("", [], "lib/bin/firebase-functions.js");
expect(node.findSdkBinEntry(sdkMain(sdkDir))).to.equal(path.join(sdkDir, BIN_ENTRY));
});

it("returns undefined when the SDK declares no bin entry", () => {
const sdkDir = installSdk("", [], undefined);
expect(node.findSdkBinEntry(sdkMain(sdkDir))).to.be.undefined;
});

it("returns undefined when the bin entry points to a missing file", () => {
const sdkDir = installSdk("", [], { "firebase-functions": "lib/bin/missing.js" });
expect(node.findSdkBinEntry(sdkMain(sdkDir))).to.be.undefined;
});

it("skips a malformed package.json and keeps walking up", () => {
const sdkDir = installSdk("", [], "lib/bin/firebase-functions.js");
fs.writeFileSync(path.join(sdkDir, "lib", "v2", "package.json"), "{");
expect(node.findSdkBinEntry(sdkMain(sdkDir))).to.equal(path.join(sdkDir, BIN_ENTRY));
});

it("returns undefined when the SDK package.json is malformed", () => {
const sdkDir = installSdk("", [], "lib/bin/firebase-functions.js");
fs.writeFileSync(path.join(sdkDir, "package.json"), "");
expect(node.findSdkBinEntry(sdkMain(sdkDir))).to.be.undefined;
});
});
});
});
49 changes: 44 additions & 5 deletions src/deploy/functions/runtimes/node/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import * as semver from "semver";
import * as spawn from "cross-spawn";
import { ChildProcess } from "child_process";

import { FirebaseError } from "../../../../error";
import { FirebaseError, getErrMsg } from "../../../../error";
import { getRuntimeChoice } from "./parseRuntimeAndValidateSDK";
import { logger } from "../../../../logger";
import {
Expand Down Expand Up @@ -53,6 +53,39 @@ export async function tryCreateDelegate(context: DelegateContext): Promise<Deleg
return new Delegate(context.projectId, context.projectDir, context.sourceDir, runtime);
}

type SdkPackageJson = { name?: string; bin?: string | Record<string, string> };

/** @internal */
export function findSdkBinEntry(sdkPath: string): string | undefined {
for (
let dir = path.dirname(sdkPath);
path.basename(dir) !== "node_modules" && path.dirname(dir) !== dir;
dir = path.dirname(dir)
) {
const packageJsonPath = path.join(dir, "package.json");
if (!fileExistsSync(packageJsonPath)) {
continue;
}
let pkg: SdkPackageJson | null;
try {
pkg = JSON.parse(fs.readFileSync(packageJsonPath, "utf8")) as SdkPackageJson | null;
} catch (err: unknown) {
logger.debug(`Failed to read ${packageJsonPath}: ${getErrMsg(err)}`);
continue;
}
if (pkg?.name !== "firebase-functions") {
continue;
}
const entry = typeof pkg.bin === "string" ? pkg.bin : pkg.bin?.["firebase-functions"];
if (typeof entry !== "string" || !entry) {
return undefined;
}
const binEntry = path.join(dir, entry);
return fileExistsSync(binEntry) ? binEntry : undefined;
}
return undefined;
}

// TODO(inlined): Consider moving contents in parseRuntimeAndValidateSDK and validate around.
// Those two files are currently pretty coupled (e.g. they borrow error messages from each other)
// and both files load package.json. Maybe the delegate should be constructed with a package.json and
Expand Down Expand Up @@ -158,7 +191,7 @@ export class Delegate {
return Promise.resolve(() => Promise.resolve());
}

private findFunctionsBinary(): string {
private findFunctionsBinary(): string[] {
// Location of the binary included in the Firebase Functions SDK
// differs depending on the developer's setup and choice of package manager.
//
Expand Down Expand Up @@ -188,10 +221,16 @@ export class Delegate {
const binPath = path.join(nodeModulesPath, ".bin", "firebase-functions");
if (fileExistsSync(binPath)) {
logger.debug(`Found firebase-functions binary at '${binPath}'`);
return binPath;
return [binPath];
}
}

const binEntry = findSdkBinEntry(sdkPath);
if (binEntry) {
logger.debug(`Found firebase-functions bin entry at '${binEntry}'`);
return [this.bin, binEntry];
}
Comment on lines +228 to +232

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

Instead of hardcoding the global 'node' binary from the PATH, we should use 'this.bin' (which resolves to the correct Node.js binary version/path via 'getNodeBinary()'). This ensures consistency with the rest of the class and respects the requested runtime version, local cache, or standalone CLI bundle.

Suggested change
const binEntry = findSdkBinEntry(sdkPath);
if (binEntry) {
logger.debug(`Found firebase-functions bin entry at '${binEntry}'`);
return ["node", binEntry];
}
const binEntry = findSdkBinEntry(sdkPath);
if (binEntry) {
logger.debug("Found firebase-functions bin entry at '" + binEntry + "'");
return [this.bin, binEntry];
}
References
  1. Reuse or extend existing utilities and helpers rather than writing bespoke inline logic. (link)

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Good catch, done in 6e136d7. this.bin resolves to process.execPath or the local node_modules/node, which is also what the emulator spawns functions with, and it no longer depends on node being on PATH (not guaranteed for Bun only setups on Windows).


throw new FirebaseError(
"Failed to find location of Firebase Functions SDK. " +
"Please file a bug on Github (https://github.com/firebase/firebase-tools/).",
Expand All @@ -217,8 +256,8 @@ export class Delegate {
env.CLOUD_RUNTIME_CONFIG = JSON.stringify(config);
}

const binPath = this.findFunctionsBinary();
const childProcess = spawn(binPath, [this.sourceDir], {
const [command, ...args] = this.findFunctionsBinary();
const childProcess = spawn(command, [...args, this.sourceDir], {
env,
cwd: this.sourceDir,
stdio: [/* stdin=*/ "ignore", /* stdout=*/ "pipe", /* stderr=*/ "pipe"],
Expand Down
Loading