Skip to content
Draft
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
53 changes: 52 additions & 1 deletion src/apphosting/utils.ts
Original file line number Diff line number Diff line change
@@ -1,7 +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 <environment> given an apphosting.<environment>.yaml file
Expand Down Expand Up @@ -73,3 +76,51 @@ export function getAutoinitEnvVars(webappConfig: WebConfig | undefined): Record<
}),
};
}

/**
* Reads and parses the package.json file in the specified directory.
*/
export async function parsePackageJson(packageJsonPath: string): Promise<PackageJson | undefined> {
if (!(await fs.pathExists(packageJsonPath))) {
return undefined;
}
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 {
dependencies?: Record<string, string>;
devDependencies?: Record<string, string>;
}

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<Framework | undefined> {
const packageJsonPath = path.join(appDir, "package.json");
const pkg = await parsePackageJson(packageJsonPath);
if (!pkg) {
return undefined;
}

const deps = { ...pkg.dependencies, ...pkg.devDependencies };
if (deps["next"]) {
return Framework.NEXTJS;
}
if (deps["@angular/core"]) {
return Framework.ANGULAR;
}

return undefined;
}
94 changes: 93 additions & 1 deletion src/deploy/apphosting/prepare.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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", () => {
Expand Down Expand Up @@ -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;
});
});
});
12 changes: 10 additions & 2 deletions src/deploy/apphosting/prepare.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -184,6 +184,14 @@ export default async function (context: Context, options: Options): Promise<void
experiments.assertEnabled("apphostinglocalbuilds", "locally build App Hosting backends");
logLabeledBullet("apphosting", `Starting local build for backend ${cfg.backendId}`);

const appDir = path.join(options.projectRoot || "", cfg.rootDir || "");
const framework = await apphostingUtils.detectFramework(appDir);
if (framework !== apphostingUtils.Framework.NEXTJS) {
throw new FirebaseError(
`Local builds are only supported for Next.js apps. Detected framework: ${framework || "unknown"}`,
);
}

await injectEnvVarsFromApphostingConfig(
configs.filter((c) => c.backendId === cfg.backendId),
options,
Expand Down Expand Up @@ -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 };
Expand Down
Loading