diff --git a/src/frameworks/compose/discover/runtime/node.ts b/src/frameworks/compose/discover/runtime/node.ts new file mode 100644 index 00000000000..976657deac9 --- /dev/null +++ b/src/frameworks/compose/discover/runtime/node.ts @@ -0,0 +1,212 @@ +import { readOrNull } from "../filesystem"; +import { FileSystem, FrameworkSpec, Runtime } from "../types"; +import { RuntimeSpec } from "../types"; +import { frameworkMatcher } from "../frameworkMatcher"; +import { LifecycleCommands } from "../types"; +import { Command } from "../types"; +import { FirebaseError } from "../../../../error"; +import { logger } from "../../../../../src/logger"; +import { conjoinOptions } from "../../../utils"; + +export interface PackageJSON { + dependencies?: Record; + devDependencies?: Record; + scripts?: Record; + engines?: Record; +} +type PackageManager = "npm" | "yarn"; + +const supportedNodeVersions: string[] = ["18"]; +const NODE_RUNTIME_ID = "nodejs"; +const PACKAGE_JSON = "package.json"; +const YARN_LOCK = "yarn.lock"; + +export class NodejsRuntime implements Runtime { + private readonly runtimeRequiredFiles: string[] = [PACKAGE_JSON]; + private readonly contentCache: Record = {}; + + // Checks if the codebase is using Node as runtime. + async match(fs: FileSystem): Promise { + const areAllFilesPresent = await Promise.all( + this.runtimeRequiredFiles.map((file) => fs.exists(file)) + ); + + return areAllFilesPresent.every((present) => present); + } + + getRuntimeName(): string { + return NODE_RUNTIME_ID; + } + + getNodeImage(engine: Record | undefined): string { + // If no version is mentioned explicitly, assuming application is compatible with latest version. + if (!engine || !engine.node) { + return `node:${supportedNodeVersions[supportedNodeVersions.length - 1]}-slim`; + } + const versionNumber = engine.node; + + if (!supportedNodeVersions.includes(versionNumber)) { + throw new FirebaseError( + `This integration expects Node version ${conjoinOptions( + supportedNodeVersions, + "or" + )}. You're running version ${versionNumber}, which is not compatible.` + ); + } + + return `node:${versionNumber}-slim`; + } + + async getPackageManager(fs: FileSystem): Promise { + try { + if (await fs.exists(YARN_LOCK)) { + return "yarn"; + } + + return "npm"; + } catch (error: any) { + logger.error("Failed to check files to identify package manager"); + throw error; + } + } + + getDependencies(packageJSON: PackageJSON): Record { + return { ...packageJSON.dependencies, ...packageJSON.devDependencies }; + } + + packageManagerInstallCommand(packageManager: PackageManager): string | undefined { + const packages: string[] = []; + if (packageManager === "yarn") { + packages.push("yarn"); + } + if (!packages.length) { + return undefined; + } + + return `npm install --global ${packages.join(" ")}`; + } + + installCommand(fs: FileSystem, packageManager: PackageManager): string { + let installCmd = "npm install"; + + if (packageManager === "yarn") { + installCmd = "yarn install"; + } + + return installCmd; + } + + async detectedCommands( + packageManager: PackageManager, + scripts: Record | undefined, + matchedFramework: FrameworkSpec | null, + fs: FileSystem + ): Promise { + return { + build: this.getBuildCommand(packageManager, scripts, matchedFramework), + dev: this.getDevCommand(packageManager, scripts, matchedFramework), + run: await this.getRunCommand(packageManager, scripts, matchedFramework, fs), + }; + } + + executeScript(packageManager: string, scriptName: string): string { + return `${packageManager} run ${scriptName}`; + } + + executeFrameworkCommand(packageManager: PackageManager, command: Command): Command { + if (packageManager === "npm" || packageManager === "yarn") { + command.cmd = "npx " + command.cmd; + } + + return command; + } + + getBuildCommand( + packageManager: PackageManager, + scripts: Record | undefined, + matchedFramework: FrameworkSpec | null + ): Command | undefined { + let buildCommand: Command = { cmd: "" }; + if (scripts?.build) { + buildCommand.cmd = this.executeScript(packageManager, "build"); + } else if (matchedFramework && matchedFramework.commands?.build) { + buildCommand = matchedFramework.commands.build; + buildCommand = this.executeFrameworkCommand(packageManager, buildCommand); + } + + return buildCommand.cmd === "" ? undefined : buildCommand; + } + + getDevCommand( + packageManager: PackageManager, + scripts: Record | undefined, + matchedFramework: FrameworkSpec | null + ): Command | undefined { + let devCommand: Command = { cmd: "", env: { NODE_ENV: "dev" } }; + if (scripts?.dev) { + devCommand.cmd = this.executeScript(packageManager, "dev"); + } else if (matchedFramework && matchedFramework.commands?.dev) { + devCommand = matchedFramework.commands.dev; + devCommand = this.executeFrameworkCommand(packageManager, devCommand); + } + + return devCommand.cmd === "" ? undefined : devCommand; + } + + async getRunCommand( + packageManager: PackageManager, + scripts: Record | undefined, + matchedFramework: FrameworkSpec | null, + fs: FileSystem + ): Promise { + let runCommand: Command = { cmd: "", env: { NODE_ENV: "production" } }; + if (scripts?.start) { + runCommand.cmd = this.executeScript(packageManager, "start"); + } else if (matchedFramework && matchedFramework.commands?.run) { + runCommand = matchedFramework.commands.run; + runCommand = this.executeFrameworkCommand(packageManager, runCommand); + } else if (scripts?.main) { + runCommand.cmd = `node ${scripts.main}`; + } else if (await fs.exists("index.js")) { + runCommand.cmd = `node index.js`; + } + + return runCommand.cmd === "" ? undefined : runCommand; + } + + async analyseCodebase(fs: FileSystem, allFrameworkSpecs: FrameworkSpec[]): Promise { + try { + const packageJSONRaw = await readOrNull(fs, PACKAGE_JSON); + let packageJSON: PackageJSON = {}; + if (packageJSONRaw) { + packageJSON = JSON.parse(packageJSONRaw) as PackageJSON; + } + const packageManager = await this.getPackageManager(fs); + const nodeImage = this.getNodeImage(packageJSON.engines); + const dependencies = this.getDependencies(packageJSON); + const matchedFramework = await frameworkMatcher( + NODE_RUNTIME_ID, + fs, + allFrameworkSpecs, + dependencies + ); + + const runtimeSpec: RuntimeSpec = { + id: NODE_RUNTIME_ID, + baseImage: nodeImage, + packageManagerInstallCommand: this.packageManagerInstallCommand(packageManager), + installCommand: this.installCommand(fs, packageManager), + detectedCommands: await this.detectedCommands( + packageManager, + packageJSON.scripts, + matchedFramework, + fs + ), + }; + + return runtimeSpec; + } catch (error: any) { + throw new FirebaseError(`Failed to parse engine: ${error}`); + } + } +} diff --git a/src/frameworks/compose/discover/types.ts b/src/frameworks/compose/discover/types.ts index 677e054ec27..b919e552c4b 100644 --- a/src/frameworks/compose/discover/types.ts +++ b/src/frameworks/compose/discover/types.ts @@ -6,7 +6,7 @@ export interface FileSystem { export interface Runtime { match(fs: FileSystem): Promise; getRuntimeName(): string; - analyseCodebase(fs: FileSystem, allFrameworkSpecs: FrameworkSpec[]): Promise; + analyseCodebase(fs: FileSystem, allFrameworkSpecs: FrameworkSpec[]): Promise; } export interface Command { diff --git a/src/test/frameworks/compose/discover/runtime/node.spec.ts b/src/test/frameworks/compose/discover/runtime/node.spec.ts new file mode 100644 index 00000000000..b2bbaee767c --- /dev/null +++ b/src/test/frameworks/compose/discover/runtime/node.spec.ts @@ -0,0 +1,241 @@ +import { MockFileSystem } from "../mockFileSystem"; +import { expect } from "chai"; +import { + NodejsRuntime, + PackageJSON, +} from "../../../../../frameworks/compose/discover/runtime/node"; +import { FrameworkSpec } from "../../../../../frameworks/compose/discover/types"; +import { FirebaseError } from "../../../../../error"; + +describe("NodejsRuntime", () => { + let nodeJSRuntime: NodejsRuntime; + let allFrameworks: FrameworkSpec[]; + + before(() => { + nodeJSRuntime = new NodejsRuntime(); + allFrameworks = [ + { + id: "express", + runtime: "nodejs", + requiredDependencies: [{ name: "express" }], + }, + { + id: "next", + runtime: "nodejs", + requiredDependencies: [{ name: "next" }], + requiredFiles: [["next.config.js"], "next.config.ts"], + embedsFrameworks: ["react"], + commands: { + dev: { + cmd: "next dev", + env: { NODE_ENV: "dev" }, + }, + }, + }, + ]; + }); + + describe("getNodeImage", () => { + it("should return a valid node Image", () => { + const version: Record = { + node: "18", + }; + const actualImage = nodeJSRuntime.getNodeImage(version); + const expectedImage = "node:18-slim"; + + expect(actualImage).to.deep.equal(expectedImage); + }); + }); + + describe("getPackageManager", () => { + it("should return yarn package manager", async () => { + const fileSystem = new MockFileSystem({ + "yarn.lock": "It is test file", + }); + const actual = await nodeJSRuntime.getPackageManager(fileSystem); + const expected = "yarn"; + + expect(actual).to.equal(expected); + }); + }); + + describe("getDependencies", () => { + it("should return direct and transitive dependencies", () => { + const packageJSON: PackageJSON = { + dependencies: { + express: "^4.18.2", + }, + devDependencies: { + nodemon: "^2.0.12", + mocha: "^9.1.1", + }, + }; + const actual = nodeJSRuntime.getDependencies(packageJSON); + const expected = { + express: "^4.18.2", + nodemon: "^2.0.12", + mocha: "^9.1.1", + }; + + expect(actual).to.deep.equal(expected); + }); + }); + + describe("detectedCommands", () => { + it("should prepend npx to framework commands", async () => { + const fs = new MockFileSystem({ + "package.json": "Test file", + }); + const matchedFramework: FrameworkSpec = { + id: "next", + runtime: "nodejs", + requiredDependencies: [], + commands: { + dev: { + cmd: "next dev", + env: { NODE_ENV: "dev" }, + }, + }, + }; + const scripts = { + build: "next build", + start: "next start", + }; + + const actual = await nodeJSRuntime.detectedCommands("yarn", scripts, matchedFramework, fs); + const expected = { + build: { + cmd: "yarn run build", + }, + dev: { + cmd: "npx next dev", + env: { NODE_ENV: "dev" }, + }, + run: { + cmd: "yarn run start", + env: { NODE_ENV: "production" }, + }, + }; + + expect(actual).to.deep.equal(expected); + }); + + it("should prefer scripts over framework commands", async () => { + const fs = new MockFileSystem({ + "package.json": "Test file", + }); + const matchedFramework: FrameworkSpec = { + id: "next", + runtime: "nodejs", + requiredDependencies: [], + commands: { + build: { + cmd: "next build testing", + }, + run: { + cmd: "next start testing", + env: { NODE_ENV: "production" }, + }, + dev: { + cmd: "next dev", + env: { NODE_ENV: "dev" }, + }, + }, + }; + const scripts = { + build: "next build", + start: "next start", + }; + + const actual = await nodeJSRuntime.detectedCommands("yarn", scripts, matchedFramework, fs); + const expected = { + build: { + cmd: "yarn run build", + }, + dev: { + cmd: "npx next dev", + env: { NODE_ENV: "dev" }, + }, + run: { + cmd: "yarn run start", + env: { NODE_ENV: "production" }, + }, + }; + + expect(actual).to.deep.equal(expected); + }); + }); + + describe("analyseCodebase", () => { + it("should return runtime specs", async () => { + const fileSystem = new MockFileSystem({ + "next.config.js": "For testing", + "next.config.ts": "For testing", + "package.json": JSON.stringify({ + scripts: { + build: "next build", + start: "next start", + }, + dependencies: { + next: "13.4.5", + react: "18.2.0", + }, + engines: { + node: "18", + }, + }), + }); + + const actual = await nodeJSRuntime.analyseCodebase(fileSystem, allFrameworks); + const expected = { + id: "nodejs", + baseImage: "node:18-slim", + packageManagerInstallCommand: undefined, + installCommand: "npm install", + detectedCommands: { + build: { + cmd: "npm run build", + }, + dev: { + cmd: "npx next dev", + env: { NODE_ENV: "dev" }, + }, + run: { + cmd: "npm run start", + env: { NODE_ENV: "production" }, + }, + }, + }; + + expect(actual).to.deep.equal(expected); + }); + + it("should return error", async () => { + const fileSystem = new MockFileSystem({ + "next.config.js": "For testing purpose.", + "next.config.ts": "For testing purpose.", + "package.json": JSON.stringify({ + scripts: { + build: "next build", + start: "next start", + }, + dependencies: { + // Having both express and next as dependencies. + express: "2.0.8", + next: "13.4.5", + react: "18.2.0", + }, + engines: { + node: "18", + }, + }), + }); + + // Failed with multiple framework matches + await expect(nodeJSRuntime.analyseCodebase(fileSystem, allFrameworks)).to.be.rejectedWith( + FirebaseError, + "Failed to parse engine" + ); + }); + }); +});