From 6e4842c19f5d1c68159851f42bfd812887f1de56 Mon Sep 17 00:00:00 2001 From: Andy Perelson Date: Fri, 24 Jul 2026 22:45:55 +0000 Subject: [PATCH] feat(functions): add full support for Yarn 2+ (PnP and pnpm nodeLinkers) This commit comprehensively adds native CLI support for executing and resolving Firebase Functions inside strict Yarn 2+ ecosystems (specifically Plug'n'Play zero-install mechanisms and pnpm-emulated physical symlinked layouts). - Introduces robust `.pnp.cjs` hook parsing to resolve `node_modules` maps instantly in strictly sandboxed Yarn architectures. - Aggressively injects `--require .pnp.cjs` into `env.NODE_OPTIONS` to guarantee spawned emulator processes correctly inherit virtual file system patching. - Simplifies dependency resolution search to securely evaluate the function's structural payload inside `[sourceDir, projectDir]`. - Implements strict `PackageJson` typing to ensure safe SDK version queries. - Fixes `#10813` by bypassing unhandled `require.resolve()` traps that historically crashed direct natively booted CLI processes. - Updates SdkVersion checks to also handle Yarn2+ cases. Scenarios Tested - Yarn Workspaces strict `pnp` nodeLinker mode. - Yarn Workspaces `pnpm` nodeLinker mode. - Direct native Node CLI executions (`node firebase.js`) over varied isolated virtual package boundaries. - Emulator successful startup - Tested with both latest and older firebase-functions SDK versions Test Commands - `firebase deploy --only functions` - `firebase emulators:start` TAG=agy CONV=a5e2bab9-4423-4b50-82ed-188ecaca31ab --- CHANGELOG.md | 1 + src/commands/functions-config-export.ts | 2 +- src/deploy/functions/runtimes/node/index.ts | 97 ++++++++++++++-- src/deploy/functions/runtimes/node/utils.ts | 105 ++++++++++++++++++ .../functions/runtimes/node/versioning.ts | 16 ++- 5 files changed, 204 insertions(+), 17 deletions(-) create mode 100644 src/deploy/functions/runtimes/node/utils.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 48bdcbac17f..61fc6216a19 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,3 +2,4 @@ - Fixes Storage Emulator to support JSON uploads larger than 100KB without hanging or throwing 413 error (#8355) - Add `extdeprecationwarnings` experiment to display phased deprecation notices and guidance across `ext:*` CLI commands. - Fixes Data Connect emulator crash when in-flight GraphQL requests are cancelled (#10821) +- Add support for emulating and deploying functions using Yarn 2+. Fixes issue where the Cloud Functions emulator failed to start in Yarn 2+ PnP and pnpm nodeLinker environments by adding explicit Yarn resolution hooks (#10813). diff --git a/src/commands/functions-config-export.ts b/src/commands/functions-config-export.ts index 784855be5bb..f568af9e5f5 100644 --- a/src/commands/functions-config-export.ts +++ b/src/commands/functions-config-export.ts @@ -180,7 +180,7 @@ export const command = new Command("functions:config:export") : functionsConfig?.source; if (source) { const sourceDir = options.config.path(source); - sdkVersion = getFunctionsSDKVersion(sourceDir); + sdkVersion = getFunctionsSDKVersion(sourceDir, options.config.projectDir); } } catch (e) { // ignore error, just show the warning if we can't detect the version diff --git a/src/deploy/functions/runtimes/node/index.ts b/src/deploy/functions/runtimes/node/index.ts index 17f843f924f..b2a6303cb61 100644 --- a/src/deploy/functions/runtimes/node/index.ts +++ b/src/deploy/functions/runtimes/node/index.ts @@ -22,6 +22,7 @@ import { DelegateContext } from ".."; import * as supported from "../supported"; import * as validate from "./validate"; import * as versioning from "./versioning"; +import * as utils from "./utils"; import { fileExistsSync } from "../../../../fsutils"; @@ -79,7 +80,7 @@ export class Delegate { _sdkVersion: string | undefined = undefined; get sdkVersion(): string { if (this._sdkVersion === undefined) { - this._sdkVersion = versioning.getFunctionsSDKVersion(this.sourceDir) || ""; + this._sdkVersion = versioning.getFunctionsSDKVersion(this.sourceDir, this.projectDir) || ""; } return this._sdkVersion; } @@ -168,17 +169,52 @@ export class Delegate { // Location of the binary included in the Firebase Functions SDK // differs depending on the developer's setup and choice of package manager. // - // We'll try few routes in the following order: + // We'll search for the emulator binary in the following order: // - // 1. $SOURCE_DIR/node_modules/.bin/firebase-functions - // 2. $PROJECT_DIR/node_modules/.bin/firebase-functions - // 3. node_modules closest to the resolved path ${require.resolve("firebase-functions")} - // 4. (2) but ignore .pnpm directory + // 1. Native Yarn Plug'n'Play evaluation (via `.pnp.cjs`) + // 2. $SOURCE_DIR/node_modules/.bin/firebase-functions + // 3. $PROJECT_DIR/node_modules/.bin/firebase-functions + // 4. node_modules closest to the resolved path ${require.resolve("firebase-functions")} + // 5. (4) but backing out of any internal `.pnpm` virtual stores to hit the top-level // - // (1) works for most package managers (npm, yarn[no-hoist]). - // (2) works for some monorepo setup. - // (3) handles cases where developer prefers monorepo setup or bundled function code. - // (4) handles issue with some .pnpm setup (see https://github.com/firebase/firebase-tools/issues/5517) + // For locations 2-5, we look for two distinct structural patterns: + // A) The standard `.bin/firebase-functions` shell wrapper. + // B) The direct literal JS payload (`firebase-functions/lib/bin/firebase-functions.js`). + // + // Pattern (B) is a critical fallback. Native `pnpm` and Yarn `nodeLinker: pnpm` environments + // rely heavily on physical symlinking to save disk space, but they frequently skip compiling + // `.bin/` wrappers for nested dependencies, causing Pattern (A) to fail. + // + // (1) must be checked *before* any `require.resolve()` calls. If the CLI was launched natively + // (without yarn), `require.resolve()` will silently fail to find the zip archive and traverse + // all the way up to global directories, loading the wrong package. Querying `.pnp.cjs` first + // protects against this. + // (2) works for most basic package managers (npm, yarn[no-hoist]). + // (3) works for standard monorepos pulling functions up to the root. + // (4) handles custom monorepos or uniquely bundled function code. + // (5) handles issue with some pnpm setups where `require.resolve` traps us inside a `.pnpm` shadow-folder. + + // 1. Native Yarn PnP hook (.pnp.cjs) + // Aggressively query the PnP mapping securely first! + const resolved = utils.resolvePnpModulePackageJson( + this.sourceDir, + this.projectDir, + "firebase-functions", + ); + if (resolved) { + const { pkgPath, packageJson } = resolved; + const relativeBinPath = packageJson.bin?.["firebase-functions"]; + const jsBinPath = relativeBinPath ? path.join(pkgPath, relativeBinPath) : undefined; + if (jsBinPath && fileExistsSync(jsBinPath)) { + logger.debug(`Found firebase-functions binary internally via Yarn PnP at '${jsBinPath}'`); + // When we return this zip path, spawnFunctionsProcess constructs a new `spawn(process.execPath)` + // containing our payload. Because the child environment correctly inherits `NODE_OPTIONS`, it + // boots up with an identically patched `fs` architecture, safely executing the zipped script! + return jsBinPath; + } + } + + // 2. Check standard monorepo node_modules scopes and naive resolution const sourceNodeModulesPath = path.join(this.sourceDir, "node_modules"); const projectNodeModulesPath = path.join(this.projectDir, "node_modules"); const sdkPath = require.resolve("firebase-functions", { paths: [this.sourceDir] }); @@ -196,6 +232,18 @@ export class Delegate { logger.debug(`Found firebase-functions binary at '${binPath}'`); return binPath; } + + const directJsPath = path.join( + nodeModulesPath, + "firebase-functions", + "lib", + "bin", + "firebase-functions.js", + ); + if (fileExistsSync(directJsPath)) { + logger.debug(`Found firebase-functions binary directly at '${directJsPath}'`); + return directJsPath; + } } throw new FirebaseError( @@ -223,8 +271,33 @@ export class Delegate { env.CLOUD_RUNTIME_CONFIG = JSON.stringify(config); } - const binPath = this.findFunctionsBinary(); - const childProcess = spawn(binPath, [this.sourceDir], { + if (process.env.NODE_OPTIONS) { + env.NODE_OPTIONS = process.env.NODE_OPTIONS; + } + // If we detect a Yarn PnP environment, we append the `.pnp.cjs` hook to the child's NODE_OPTIONS. + // This strictly ensures that if the firebase CLI was booted purely natively without `yarn` + // (e.g. `npx firebase deploy`), the spawned emulator process will still correctly inherit + // the virtual file system APIs necessary to evaluate zippered dependencies. + for (const searchDir of [this.sourceDir, this.projectDir]) { + const pnpHookPath = path.join(searchDir, ".pnp.cjs"); + if (fs.existsSync(pnpHookPath)) { + env.NODE_OPTIONS = [env.NODE_OPTIONS || "", `--require "${pnpHookPath}"`].join(" ").trim(); + break; + } + } + + let binPath = this.findFunctionsBinary(); + const spawnArgs = [this.sourceDir]; + + // On Windows, if we bypass the .cmd shell wrapper and return the raw .js payload, + // the OS will fail to execute it natively. This conditionally rewrites the spawn + // configuration to explicitly target Node for cross-platform execution. + if (binPath.endsWith(".js")) { + spawnArgs.unshift(binPath); + binPath = process.execPath; + } + + const childProcess = spawn(binPath, spawnArgs, { env, cwd: this.sourceDir, stdio: [/* stdin=*/ "ignore", /* stdout=*/ "pipe", /* stderr=*/ "pipe"], diff --git a/src/deploy/functions/runtimes/node/utils.ts b/src/deploy/functions/runtimes/node/utils.ts new file mode 100644 index 00000000000..c8b1272e73e --- /dev/null +++ b/src/deploy/functions/runtimes/node/utils.ts @@ -0,0 +1,105 @@ +import * as fs from "fs"; +import * as path from "path"; +import { logger } from "../../../../logger"; + +/** + * Encapsulates the logic of dynamically interrogating a strict Yarn Plug'n'Play environment + * to discover the absolute physical path of a given module. + * + * In strict Yarn PnP environments (where node_modules does not exist), Yarn downloads + * dependencies as zipped archives deeply tucked into `.yarn/cache/`. To run Node scripts, Yarn + * automatically injects `NODE_OPTIONS=--require .pnp.cjs` into the NodeJS boot sequence, which + * natively monkey-patches Node's `fs` and `require` modules to intercept and resolve absolute paths + * (even paths ending in `.zip/`) entirely from memory. + * @param sourceDir the user's source code directory. + * @param moduleName the package to resolve (e.g. "firebase-functions"). + */ +function resolvePnpModulePath( + sourceDir: string, + projectDir: string, + moduleName: string, +): string | undefined { + try { + const searchDirs = sourceDir === projectDir ? [sourceDir] : [sourceDir, projectDir]; + for (const searchDir of searchDirs) { + const pnpHookPath = path.join(searchDir, ".pnp.cjs"); + if (!fs.existsSync(pnpHookPath)) { + continue; + } + // Inline the API types to satisfy TypeScipt and duck-type the PnP Hook. + interface PnpApi { + setup?(): void; + resolveToUnqualified(item: string, dir: string): string | null; + } + // eslint-disable-next-line @typescript-eslint/no-var-requires + const pnpapi = require(pnpHookPath) as PnpApi; + + // Dynamically invoke the setup API if present. This monkeypatches the native fs module + // in the CLI process to seamlessly support zipped boundaries if the CLI was booted + // without yarn (e.g., executing `node ../firebase-tools/lib/bin/firebase.js`). + if (typeof pnpapi.setup === "function") { + pnpapi.setup(); + } + const pkgPath = pnpapi.resolveToUnqualified(moduleName, path.join(sourceDir, "package.json")); + if (pkgPath) { + return pkgPath; + } + } + } catch (e) { + logger.debug( + `resolvePnpModulePath encountered error querying Yarn PnP API for ${moduleName}:`, + e, + ); + } + return undefined; +} + +export interface PackageJson { + name: string; + version: string; + bin?: Record; +} + +/** + * Returns the path to the PnP resolved package and its parsed package.json if using Yarn PnP with a local `.pnp.cjs` hook and undefined otherwise. + * @param sourceDir the user's source code directory. + * @param moduleName the package to resolve (e.g. "firebase-functions"). + */ +export function resolvePnpModulePackageJson( + sourceDir: string, + projectDir: string, + moduleName: string, +): { pkgPath: string; packageJson: PackageJson } | undefined { + // Query the local `.pnp.cjs` hook API to discover the absolute (often zipped) path mapped to the library. + const pkgPath = resolvePnpModulePath(sourceDir, projectDir, moduleName); + if (!pkgPath) { + return undefined; + } + + const pkgJsonPath = path.join(pkgPath, "package.json"); + // Even if `pkgJsonPath` is technically a `.zip/` path on the OS, these synchronous file lookups + // succeed natively because the hosting Node process has been monkey-patched by Yarn! + if (!fs.existsSync(pkgJsonPath)) { + return undefined; + } + + try { + // eslint-disable-next-line @typescript-eslint/no-var-requires + const packageJson = require(pkgJsonPath) as PackageJson; + + // We self-defined PackageJson interface above, so if the API changes + // this interface could be wrong. Validate it lightly here. + if ( + typeof packageJson.name !== "string" || + typeof packageJson.version !== "string" || + (packageJson.bin && typeof packageJson.bin !== "object") + ) { + throw new Error("invalid PackageJson object"); + } + + return { pkgPath, packageJson }; + } catch (e) { + logger.debug(`Error reading package.json for ${moduleName}:`, e); + return undefined; + } +} diff --git a/src/deploy/functions/runtimes/node/versioning.ts b/src/deploy/functions/runtimes/node/versioning.ts index c92b1f8e88d..1d9a08ea37c 100644 --- a/src/deploy/functions/runtimes/node/versioning.ts +++ b/src/deploy/functions/runtimes/node/versioning.ts @@ -7,6 +7,7 @@ import * as semver from "semver"; import { logger } from "../../../../logger"; import * as utils from "../../../../utils"; +import { resolvePnpModulePackageJson } from "./utils"; interface NpmShowResult { "dist-tags": { @@ -61,13 +62,20 @@ export function findModuleVersion(name: string, resolvedPath: string): string | * @return version string (e.g. "3.1.2"), or void if firebase-functions is not in package.json * or if we had trouble getting the version. */ -export function getFunctionsSDKVersion(sourceDir: string): string | undefined { +export function getFunctionsSDKVersion(sourceDir: string, projectDir: string): string | undefined { try { + // If strict Yarn PnP is detected, aggressively query the PnP mapping securely first! + // This protects users executing a global unpatched CLI directly over a strict Plug'n'Play virtual configuration + // from accidentally picking up a globally installed firebase-functions package if require.resolve traverses up the tree. + const resolved = resolvePnpModulePackageJson(sourceDir, projectDir, "firebase-functions"); + if (resolved?.packageJson.version) { + return resolved.packageJson.version; + } + return findModuleVersion( "firebase-functions", - // Find the entry point of the firebase-function module. require.resolve works for project directories using - // npm, yarn (1), or yarn (1) workspaces. Does not support yarn (2) since GCF doesn't support it anyway: - // https://issuetracker.google.com/issues/213632942. + // Find the entry point of the firebase-functions module. `require.resolve` works natively across + // npm, pnpm, and yarn nodeLinker environments. require.resolve("firebase-functions", { paths: [sourceDir] }), ); } catch (e: any) {