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 @@ -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).
2 changes: 1 addition & 1 deletion src/commands/functions-config-export.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,11 +33,11 @@

const DEFAULT_SECRET_NAME = "FUNCTIONS_CONFIG_EXPORT";

function maskConfigValues(obj: any): any {

Check warning on line 36 in src/commands/functions-config-export.ts

View workflow job for this annotation

GitHub Actions / lint (24)

Unexpected any. Specify a different type

Check warning on line 36 in src/commands/functions-config-export.ts

View workflow job for this annotation

GitHub Actions / lint (24)

Unexpected any. Specify a different type
if (typeof obj === "object" && obj !== null && !Array.isArray(obj)) {
const masked: Record<string, any> = {};

Check warning on line 38 in src/commands/functions-config-export.ts

View workflow job for this annotation

GitHub Actions / lint (24)

Unexpected any. Specify a different type
for (const [key, value] of Object.entries(obj)) {

Check warning on line 39 in src/commands/functions-config-export.ts

View workflow job for this annotation

GitHub Actions / lint (24)

Unsafe argument of type `any` assigned to a parameter of type `{ [s: string]: unknown; } | ArrayLike<unknown>`
masked[key] = maskConfigValues(value);

Check warning on line 40 in src/commands/functions-config-export.ts

View workflow job for this annotation

GitHub Actions / lint (24)

Unsafe assignment of an `any` value
}
return masked;
}
Expand Down Expand Up @@ -114,7 +114,7 @@

if (latest) {
logBullet(
`Secret ${clc.bold(key)} already exists (latest version: ${clc.bold(latest.versionId)}, created: ${latest.createTime}).`,

Check warning on line 117 in src/commands/functions-config-export.ts

View workflow job for this annotation

GitHub Actions / lint (24)

Invalid type "string | undefined" of template literal expression
);
const proceed = await confirm({
message: "Do you want to add a new version to this secret?",
Expand Down Expand Up @@ -174,13 +174,13 @@
// Try to detect the firebase-functions version to see if we need to warn about defineJsonSecret
let sdkVersion: string | undefined;
try {
const functionsConfig = options.config.get("functions");

Check warning on line 177 in src/commands/functions-config-export.ts

View workflow job for this annotation

GitHub Actions / lint (24)

Unsafe assignment of an `any` value
const source = Array.isArray(functionsConfig)

Check warning on line 178 in src/commands/functions-config-export.ts

View workflow job for this annotation

GitHub Actions / lint (24)

Unsafe assignment of an `any` value
? functionsConfig[0]?.source

Check warning on line 179 in src/commands/functions-config-export.ts

View workflow job for this annotation

GitHub Actions / lint (24)

Unsafe member access .source on an `any` value
: functionsConfig?.source;

Check warning on line 180 in src/commands/functions-config-export.ts

View workflow job for this annotation

GitHub Actions / lint (24)

Unsafe member access .source on an `any` value
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
Expand Down
97 changes: 85 additions & 12 deletions src/deploy/functions/runtimes/node/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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;
}
}
Comment on lines +204 to +215

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

We can reduce nesting by flattening the conditional checks and combining the path resolution and existence check. This adheres to the repository style guide's rule to reduce nesting as much as possible.

    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;
      }
    }
References
  1. Reduce nesting as much as possible Code should avoid unnecessarily deep nesting or long periods of nesting. Handle edge cases early and exit or fold them into the general case. (link)


// 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] });
Expand All @@ -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(
Expand Down Expand Up @@ -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;
}
}
Comment thread
ajperel marked this conversation as resolved.

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"],
Expand Down
105 changes: 105 additions & 0 deletions src/deploy/functions/runtimes/node/utils.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>;
}

/**
* 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;
}
}
16 changes: 12 additions & 4 deletions src/deploy/functions/runtimes/node/versioning.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down Expand Up @@ -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) {
Expand Down
Loading