Repository navigation
fix(functions): run the SDK bin entry when no .bin shim exists #11387
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
cb39406
6e136d7
27cf57e
5c3587e
39af01c
e6a5ccf
98024ce
5c98803
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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 "."; | ||||||||||||||||||||||||||||||||||||
|
|
@@ -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"); | ||||||||||||||||||||||||||||||||||||
| 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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Update the assertion to destructure the 'binary' property from the updated helper.
Suggested change
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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; | ||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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 { | ||||||||||||||||||||||
|
|
@@ -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 | ||||||||||||||||||||||
|
|
@@ -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. | ||||||||||||||||||||||
| // | ||||||||||||||||||||||
|
|
@@ -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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 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
References
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Good catch, done in 6e136d7. |
||||||||||||||||||||||
|
|
||||||||||||||||||||||
| throw new FirebaseError( | ||||||||||||||||||||||
| "Failed to find location of Firebase Functions SDK. " + | ||||||||||||||||||||||
| "Please file a bug on Github (https://github.com/firebase/firebase-tools/).", | ||||||||||||||||||||||
|
|
@@ -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"], | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
There was a problem hiding this comment.
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 becausespawnFunctionsProcessinitiates a child process, exportingfindSdkBinEntrydirectly with an@internalJSDoc annotation would provide a clean, typed unit test seam without needing index notation onDelegate.There was a problem hiding this comment.
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
findSdkBinEntryis exported with@internaland the bin entry cases (Bun store, .pnpm store, string bin, no bin, missing file, malformed package.json) now test it directly. I kept three smallfindFunctionsBinarytests for the parts that live on the Delegate: the .bin shim winning, pairing the entry withthis.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.