From 14258f2b8c7b2b35f4f17560906326c0296abdb5 Mon Sep 17 00:00:00 2001 From: Aryan Falahatpisheh Date: Fri, 1 May 2026 12:39:52 -0400 Subject: [PATCH 1/2] Add framework detection for local builds. We want to limit local builds to just nextjs to start. We use their dependencies to detect if they're using angular or nextjs. --- src/apphosting/utils.ts | 51 +++++++++++++++ src/deploy/apphosting/prepare.spec.ts | 94 ++++++++++++++++++++++++++- src/deploy/apphosting/prepare.ts | 12 +++- 3 files changed, 154 insertions(+), 3 deletions(-) diff --git a/src/apphosting/utils.ts b/src/apphosting/utils.ts index 72e85b0f414..ad9638d4693 100644 --- a/src/apphosting/utils.ts +++ b/src/apphosting/utils.ts @@ -2,6 +2,8 @@ import { FirebaseError } from "../error"; import { APPHOSTING_BASE_YAML_FILE, APPHOSTING_YAML_FILE_REGEX } from "./config"; import { WebConfig } from "../fetchWebSetup"; import * as prompt from "../prompt"; +import * as fs from "fs-extra"; +import * as path from "path"; /** * Returns given an apphosting..yaml file @@ -73,3 +75,52 @@ export function getAutoinitEnvVars(webappConfig: WebConfig | undefined): Record< }), }; } + +/** + * Reads the package.json file in the specified directory. + */ +async function readPackageJson(packageJsonPath: string): Promise { + if (!(await fs.pathExists(packageJsonPath))) { + return undefined; + } + return fs.readFile(packageJsonPath, "utf-8"); +} + +export interface PackageJson { + dependencies?: Record; + devDependencies?: Record; +} + +export enum Framework { + NEXTJS = "nextjs", + ANGULAR = "angular", +} + +/** + * Detects the framework based on package.json dependencies. + * Returns Framework.NEXTJS or Framework.ANGULAR if detected, otherwise undefined. + */ +export async function detectFramework(appDir: string): Promise { + const packageJsonPath = path.join(appDir, "package.json"); + const content = await readPackageJson(packageJsonPath); + if (!content) { + return undefined; + } + + let pkg: PackageJson; + try { + pkg = JSON.parse(content) as PackageJson; + } catch (e) { + return undefined; + } + + const deps = { ...pkg.dependencies, ...pkg.devDependencies }; + if (deps["next"]) { + return Framework.NEXTJS; + } + if (deps["@angular/core"]) { + return Framework.ANGULAR; + } + + return undefined; +} diff --git a/src/deploy/apphosting/prepare.spec.ts b/src/deploy/apphosting/prepare.spec.ts index a20831f0186..a02644d1847 100644 --- a/src/deploy/apphosting/prepare.spec.ts +++ b/src/deploy/apphosting/prepare.spec.ts @@ -18,9 +18,10 @@ import * as localbuilds from "../../apphosting/localbuilds"; import * as managementApps from "../../management/apps"; import * as experiments from "../../experiments"; import * as getProjectNumber from "../../getProjectNumber"; +import * as fs from "fs-extra"; +import * as apphostingUtils from "../../apphosting/utils"; import * as resourceManager from "../../gcp/resourceManager"; import * as apphostingConfig from "../../apphosting/config"; -import * as apphostingUtils from "../../apphosting/utils"; import { AppHostingYamlConfig, EnvMap } from "../../apphosting/yaml"; import { Options } from "../../options"; import { AppHostingSingle } from "../../firebaseConfig"; @@ -85,6 +86,7 @@ describe("apphosting", () => { assertEnabledStub = sinon.stub(experiments, "assertEnabled").returns(); sinon.stub(experiments, "isEnabled").returns(true); sinon.stub(getProjectNumber, "getProjectNumber").resolves("123456789"); + sinon.stub(apphostingUtils, "detectFramework").resolves(apphostingUtils.Framework.NEXTJS); addServiceAccountToRolesStub = sinon .stub(resourceManager, "addServiceAccountToRoles") .resolves(); @@ -460,6 +462,37 @@ describe("apphosting", () => { expect(assertEnabledStub).to.not.have.been.calledWith("apphostinglocalbuilds"); }); + + it("throws an error for localBuild when framework is not Next.js", async () => { + const optsWithLocalBuild = { + ...opts, + config: new Config({ + apphosting: { + backendId: "foo", + rootDir: "/", + ignore: [], + localBuild: true, + }, + }), + }; + const context = initializeContext(); + listBackendsStub.resolves({ + backends: [ + { + name: "projects/my-project/locations/us-central1/backends/foo", + }, + ], + }); + + (apphostingUtils.detectFramework as sinon.SinonStub).resolves( + apphostingUtils.Framework.ANGULAR, + ); + + await expect(prepare(context, optsWithLocalBuild)).to.be.rejectedWith( + FirebaseError, + "Local builds are only supported for Next.js apps", + ); + }); }); describe("getBackendConfigs", () => { @@ -641,4 +674,63 @@ describe("apphosting", () => { expect(runtimeEnv["foo"]["AUTO_VAR_1"]?.value).to.equal("auto1"); }); }); + + describe("detectFramework", () => { + let pathExistsStub: sinon.SinonStub; + let readFileStub: sinon.SinonStub; + + beforeEach(() => { + pathExistsStub = sinon.stub(fs, "pathExists"); + readFileStub = sinon.stub(fs, "readFile"); + // Restore the stub from the outer beforeEach to test the real implementation + (apphostingUtils.detectFramework as sinon.SinonStub).restore(); + }); + + it("returns nextjs when next is in dependencies", async () => { + pathExistsStub.resolves(true); + readFileStub.resolves(JSON.stringify({ dependencies: { next: "13.0.0" } })); + + const framework = await apphostingUtils.detectFramework("/"); + expect(framework).to.equal(apphostingUtils.Framework.NEXTJS); + }); + + it("returns angular when @angular/core is in dependencies", async () => { + pathExistsStub.resolves(true); + readFileStub.resolves(JSON.stringify({ dependencies: { "@angular/core": "15.0.0" } })); + + const framework = await apphostingUtils.detectFramework("/"); + expect(framework).to.equal(apphostingUtils.Framework.ANGULAR); + }); + + it("returns nextjs when next is in devDependencies", async () => { + pathExistsStub.resolves(true); + readFileStub.resolves(JSON.stringify({ devDependencies: { next: "13.0.0" } })); + + const framework = await apphostingUtils.detectFramework("/"); + expect(framework).to.equal(apphostingUtils.Framework.NEXTJS); + }); + + it("returns undefined when no framework is detected", async () => { + pathExistsStub.resolves(true); + readFileStub.resolves(JSON.stringify({ dependencies: { lodash: "4.0.0" } })); + + const framework = await apphostingUtils.detectFramework("/"); + expect(framework).to.be.undefined; + }); + + it("returns undefined when package.json does not exist", async () => { + pathExistsStub.resolves(false); + + const framework = await apphostingUtils.detectFramework("/"); + expect(framework).to.be.undefined; + }); + + it("returns undefined when package.json is invalid JSON", async () => { + pathExistsStub.resolves(true); + readFileStub.resolves("invalid json"); + + const framework = await apphostingUtils.detectFramework("/"); + expect(framework).to.be.undefined; + }); + }); }); diff --git a/src/deploy/apphosting/prepare.ts b/src/deploy/apphosting/prepare.ts index 4862cda5e75..83d24223917 100644 --- a/src/deploy/apphosting/prepare.ts +++ b/src/deploy/apphosting/prepare.ts @@ -27,7 +27,7 @@ import { localBuild } from "../../apphosting/localbuilds"; import { Context } from "./args"; import { FirebaseError } from "../../error"; import * as managementApps from "../../management/apps"; -import { getAutoinitEnvVars } from "../../apphosting/utils"; +import * as apphostingUtils from "../../apphosting/utils"; import * as experiments from "../../experiments"; import { logger } from "../../logger"; @@ -184,6 +184,14 @@ export default async function (context: Context, options: Options): Promise c.backendId === cfg.backendId), options, @@ -273,7 +281,7 @@ export async function injectAutoInitEnvVars( )) as WebConfig; // We inject autoinit env vars into the build and runtime env vars. - const autoinitVars = getAutoinitEnvVars(webappConfig); + const autoinitVars = apphostingUtils.getAutoinitEnvVars(webappConfig); for (const [envVarName, envVarValue] of Object.entries(autoinitVars)) { buildEnv[cfg.backendId][envVarName] ??= { value: envVarValue }; runtimeEnv[cfg.backendId][envVarName] ??= { value: envVarValue }; From 7090be974924ff0061f719915b6452741e881dde Mon Sep 17 00:00:00 2001 From: Aryan Falahatpisheh Date: Mon, 4 May 2026 14:58:27 -0400 Subject: [PATCH 2/2] Improve readability, just use a parsePackageJson function --- src/apphosting/utils.ts | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/src/apphosting/utils.ts b/src/apphosting/utils.ts index ad9638d4693..f2cb7b7d07e 100644 --- a/src/apphosting/utils.ts +++ b/src/apphosting/utils.ts @@ -1,9 +1,10 @@ -import { FirebaseError } from "../error"; +import { FirebaseError, getErrMsg } from "../error"; import { APPHOSTING_BASE_YAML_FILE, APPHOSTING_YAML_FILE_REGEX } from "./config"; import { WebConfig } from "../fetchWebSetup"; import * as prompt from "../prompt"; import * as fs from "fs-extra"; import * as path from "path"; +import { logger } from "../logger"; /** * Returns given an apphosting..yaml file @@ -77,13 +78,19 @@ export function getAutoinitEnvVars(webappConfig: WebConfig | undefined): Record< } /** - * Reads the package.json file in the specified directory. + * Reads and parses the package.json file in the specified directory. */ -async function readPackageJson(packageJsonPath: string): Promise { +export async function parsePackageJson(packageJsonPath: string): Promise { if (!(await fs.pathExists(packageJsonPath))) { return undefined; } - return fs.readFile(packageJsonPath, "utf-8"); + try { + const content = await fs.readFile(packageJsonPath, "utf-8"); + return JSON.parse(content) as PackageJson; + } catch (err: unknown) { + logger.debug(`Failed to read or parse package.json at ${packageJsonPath}: ${getErrMsg(err)}`); + return undefined; + } } export interface PackageJson { @@ -102,15 +109,8 @@ export enum Framework { */ export async function detectFramework(appDir: string): Promise { const packageJsonPath = path.join(appDir, "package.json"); - const content = await readPackageJson(packageJsonPath); - if (!content) { - return undefined; - } - - let pkg: PackageJson; - try { - pkg = JSON.parse(content) as PackageJson; - } catch (e) { + const pkg = await parsePackageJson(packageJsonPath); + if (!pkg) { return undefined; }