diff --git a/package-lock.json b/package-lock.json index 00c3be98..6e12abdf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -25652,29 +25652,27 @@ } }, "packages/@apphosting/build": { - "version": "0.1.0", + "version": "0.1.1", "license": "Apache-2.0", "dependencies": { "@apphosting/discover": "*", "colorette": "^2.0.20", "commander": "^11.1.0", "npm-pick-manifest": "^9.0.0", - "ts-node": "^10.9.1" + "ts-node": "*" }, "bin": { "apphosting-local-build": "dist/bin/localbuild.js", "build": "dist/bin/build.js" }, "devDependencies": { - "@types/commander": "*", "@types/fs-extra": "*", "@types/mocha": "*", "@types/tmp": "*", "mocha": "*", - "next": "~14.0.0", "semver": "*", "tmp": "*", - "ts-mocha": "*", + "ts-mocha": "^11.1.0", "ts-node": "*", "typescript": "*", "verdaccio": "^5.30.3" @@ -25735,6 +25733,29 @@ "node": "^16.14.0 || >=18.0.0" } }, + "packages/@apphosting/build/node_modules/ts-mocha": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/ts-mocha/-/ts-mocha-11.1.0.tgz", + "integrity": "sha512-yT7FfzNRCu8ZKkYvAOiH01xNma/vLq6Vit7yINKYFNVP8e5UyrYXSOMIipERTpzVKJQ4Qcos5bQo1tNERNZevQ==", + "dev": true, + "license": "MIT", + "bin": { + "ts-mocha": "bin/ts-mocha" + }, + "engines": { + "node": ">= 6.X.X" + }, + "peerDependencies": { + "mocha": "^3.X.X || ^4.X.X || ^5.X.X || ^6.X.X || ^7.X.X || ^8.X.X || ^9.X.X || ^10.X.X || ^11.X.X", + "ts-node": "^7.X.X || ^8.X.X || ^9.X.X || ^10.X.X", + "tsconfig-paths": "^4.X.X" + }, + "peerDependenciesMeta": { + "tsconfig-paths": { + "optional": true + } + } + }, "packages/@apphosting/common": { "version": "0.0.7", "license": "Apache-2.0" diff --git a/packages/@apphosting/adapter-angular/src/interface.ts b/packages/@apphosting/adapter-angular/src/interface.ts index 201b7257..355a6797 100644 --- a/packages/@apphosting/adapter-angular/src/interface.ts +++ b/packages/@apphosting/adapter-angular/src/interface.ts @@ -9,27 +9,6 @@ export interface OutputBundleOptions { needsServerGenerated: boolean; } -// Environment variable schema for bundle.yaml outputted by angular adapter -export interface EnvironmentVariable { - variable: string; - value: string; - availability: Availability.Runtime; // currently support RUNTIME only -} - -// defines whether the environment variable is buildtime, runtime or both -export enum Availability { - Buildtime = "BUILD", - Runtime = "RUNTIME", -} - -// Metadata schema for bundle.yaml outputted by angular adapter -export interface Metadata { - adapterPackageName: string; - adapterVersion: string; - framework: string; - frameworkVersion: string; -} - // valid manifest schema export interface ValidManifest { errors: string[]; diff --git a/packages/@apphosting/build/.gitignore b/packages/@apphosting/build/.gitignore deleted file mode 100644 index 66e7f6a2..00000000 --- a/packages/@apphosting/build/.gitignore +++ /dev/null @@ -1 +0,0 @@ -/e2e \ No newline at end of file diff --git a/packages/@apphosting/build/e2e/.gitignore b/packages/@apphosting/build/e2e/.gitignore new file mode 100644 index 00000000..cb1d07bf --- /dev/null +++ b/packages/@apphosting/build/e2e/.gitignore @@ -0,0 +1 @@ +runs \ No newline at end of file diff --git a/packages/@apphosting/build/e2e/adapter-builds.spec.ts b/packages/@apphosting/build/e2e/adapter-builds.spec.ts new file mode 100644 index 00000000..832190b0 --- /dev/null +++ b/packages/@apphosting/build/e2e/adapter-builds.spec.ts @@ -0,0 +1,32 @@ +import * as assert from "assert"; +import { posix } from "path"; +import pkg from "@apphosting/common"; +import { scenarios } from "./scenarios.ts"; +import fsExtra from "fs-extra"; +import { parse as parseYaml } from "yaml"; + +const { readFileSync, mkdirp, rmdir, readJSONSync } = fsExtra; +const { OutputBundleConfig } = pkg; + +const scenario = process.env.SCENARIO; +if (!scenario) { + throw new Error("SCENARIO environment variable expected"); +} + +const runId = process.env.RUN_ID; +if (!runId) { + throw new Error("RUN_ID environment variable expected"); +} + +const bundleYaml = posix.join(process.cwd(), "e2e", "runs", runId, ".apphosting", "bundle.yaml"); +describe("supported framework apps", () => { + it("apps have bundle.yaml correctly generated", async () => { + const bundle = parseYaml(readFileSync(bundleYaml, "utf8")) as OutputBundleConfig; + + assert.deepStrictEqual(scenarios.get(scenario).expectedBundleYaml.runConfig, bundle.runConfig); + assert.deepStrictEqual( + scenarios.get(scenario).expectedBundleYaml.metadata.adapterPackageName, + bundle.metadata.adapterPackageName, + ); + }); +}); diff --git a/packages/@apphosting/build/e2e/run-local-build.ts b/packages/@apphosting/build/e2e/run-local-build.ts new file mode 100644 index 00000000..a09bad2d --- /dev/null +++ b/packages/@apphosting/build/e2e/run-local-build.ts @@ -0,0 +1,104 @@ +import { cp } from "fs/promises"; +import promiseSpawn from "@npmcli/promise-spawn"; +import { dirname, join, relative } from "path"; +import { fileURLToPath } from "url"; +import { parse as parseYaml } from "yaml"; +import { spawn } from "child_process"; +import fsExtra from "fs-extra"; +import { scenarios } from "./scenarios.ts"; + +const { readFileSync, mkdirp, rmdir } = fsExtra; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +const errors: any[] = []; + +await rmdir(join(__dirname, "runs"), { recursive: true }).catch(() => undefined); + +// Run each scenario +for (const [scenarioName, scenario] of scenarios) { + console.log( + `\n\n${"=".repeat(80)}\n${" ".repeat( + 5, + )}RUNNING SCENARIO: ${scenarioName.toUpperCase()}${" ".repeat(5)}\n${"=".repeat(80)}`, + ); + + const runId = `${scenarioName}-${Math.random().toString().split(".")[1]}`; + const cwd = join(__dirname, "runs", runId); + await mkdirp(cwd); + + const starterTemplateDir = scenarioName.includes("nextjs") + ? "../../../starters/nextjs/basic" + : "../../../starters/angular/basic"; + console.log(`[${runId}] Copying ${starterTemplateDir} to working directory`); + await cp(starterTemplateDir, cwd, { recursive: true }); + + // Run scenario-specific setup if provided + if (scenario.setup) { + console.log(`[${runId}] Running setup for ${scenarioName}`); + await scenario.setup(cwd); + } + + console.log(`[${runId}] > npm ci --silent --no-progress`); + await promiseSpawn("npm", ["ci", "--silent", "--no-progress"], { + cwd, + stdio: "inherit", + shell: true, + }); + + const buildScript = relative(cwd, join(__dirname, "../dist/bin/localbuild.js")); + const buildLogPath = join(cwd, "build.log"); + console.log(`[${runId}] > node ${buildScript} (output written to ${buildLogPath})`); + + const packageJson = JSON.parse(readFileSync(join(cwd, "package.json"), "utf-8")); + const frameworkVersion = scenarioName.includes("nextjs") + ? packageJson.dependencies.next.replace("^", "") + : JSON.parse( + readFileSync(join(cwd, "node_modules", "@angular", "core", "package.json"), "utf-8"), + ).version; + + try { + await promiseSpawn("node", [buildScript, ...scenario.inputs], { + cwd, + stdioString: true, + stdio: "pipe", + shell: true, + env: { + ...process.env, + FRAMEWORK_VERSION: frameworkVersion, + }, + }).then((result) => { + // Write stdout and stderr to the log file + fsExtra.writeFileSync(buildLogPath, result.stdout + result.stderr); + }); + + try { + // Determine which test files to run + const testPattern = scenario.tests + ? scenario.tests.map((test) => `e2e/${test}`).join(" ") + : "e2e/*.spec.ts"; + + console.log(`> SCENARIO=${scenarioName} ts-mocha -p tsconfig.json ${testPattern}`); + + await promiseSpawn("ts-mocha", ["-p", "tsconfig.json", ...testPattern.split(" ")], { + shell: true, + stdio: "inherit", + env: { + ...process.env, + SCENARIO: scenarioName, + RUN_ID: runId, + }, + }); + } catch (e) { + errors.push(e); + } + } catch (e) { + console.error(`Error in scenario ${scenarioName}:`, e); + errors.push(e); + } + + if (errors.length) { + console.error(errors); + process.exit(1); + } +} diff --git a/packages/@apphosting/build/e2e/scenarios.ts b/packages/@apphosting/build/e2e/scenarios.ts new file mode 100644 index 00000000..0275e8d8 --- /dev/null +++ b/packages/@apphosting/build/e2e/scenarios.ts @@ -0,0 +1,45 @@ +import pkg from "@apphosting/common"; +const { OutputBundleConfig } = pkg; + +interface Scenario { + name: string; // Name of the scenario + inputs: string[]; + expectedBundleYaml: OutputBundleConfig; + tests?: string[]; // List of test files to run +} + +export const scenarios = new Map([ + [ + "nextjs-app", + { + inputs: ["./", "--framework", "nextjs"], + expectedBundleYaml: { + version: "v1", + runConfig: { + runCommand: "node .next/standalone/server.js", + }, + metadata: { + adapterPackageName: "@apphosting/adapter-nextjs", + }, + }, + tests: ["adapter-builds.spec.ts"], + }, + ], + [ + "angular-app", + { + inputs: ["./", "--framework", "angular"], + expectedBundleYaml: { + version: "v1", + runConfig: { + runCommand: "node dist/firebase-app-hosting-angular/server/server.mjs", + environmentVariables: [], + }, + metadata: { + adapterPackageName: "@apphosting/adapter-angular", + }, + }, + tests: ["adapter-builds.spec.ts"], + }, + ], +]); diff --git a/packages/@apphosting/build/package.json b/packages/@apphosting/build/package.json index babd8c4a..a9f4050d 100644 --- a/packages/@apphosting/build/package.json +++ b/packages/@apphosting/build/package.json @@ -21,7 +21,9 @@ "type": "module", "sideEffects": false, "scripts": { - "build": "rm -rf dist && tsc && chmod +x ./dist/bin/*" + "build": "rm -rf dist && tsc && chmod +x ./dist/bin/*", + "test": "npm run test:functional", + "test:functional": "node --loader ts-node/esm ./e2e/run-local-build.ts" }, "exports": { ".": { diff --git a/packages/@apphosting/build/src/adapter-builds.ts b/packages/@apphosting/build/src/adapter-builds.ts new file mode 100644 index 00000000..e4525f57 --- /dev/null +++ b/packages/@apphosting/build/src/adapter-builds.ts @@ -0,0 +1,39 @@ +import { spawn } from "child_process"; +import { yellow, bgRed, bold } from "colorette"; + +export async function adapterBuild(projectRoot: string, framework: string) { + // TODO(#382): We are using the latest framework adapter versions, but in the future + // we should parse the framework version and use the matching adapter version. + const adapterName = `@apphosting/adapter-${framework}`; + const packumentResponse = await fetch(`https://registry.npmjs.org/${adapterName}`); + if (!packumentResponse.ok) + throw new Error( + `Failed to fetch ${adapterName}: ${packumentResponse.status} ${packumentResponse.statusText}`, + ); + const packument = await packumentResponse.json(); + const adapterVersion = packument?.["dist-tags"]?.["latest"]; + if (!adapterVersion) { + throw new Error(`Could not find 'latest' dist-tag for ${adapterName}`); + } + // TODO(#382): should check for existence of adapter in app's package.json and use that version instead. + + console.log(" 🔥", bgRed(` ${adapterName}@${yellow(bold(adapterVersion))} `), "\n"); + + const buildCommand = `apphosting-adapter-${framework}-build`; + await new Promise((resolve, reject) => { + const child = spawn("npx", ["-y", "-p", `${adapterName}@${adapterVersion}`, buildCommand], { + cwd: projectRoot, + shell: true, + stdio: "inherit", + }); + + child.on("error", reject); + + child.on("exit", (code) => { + if (code !== 0) { + reject(new Error(`framework adapter build failed with error code ${code}.`)); + } + resolve(); + }); + }); +} diff --git a/packages/@apphosting/build/src/bin/localbuild.ts b/packages/@apphosting/build/src/bin/localbuild.ts index ed284df9..d8629197 100644 --- a/packages/@apphosting/build/src/bin/localbuild.ts +++ b/packages/@apphosting/build/src/bin/localbuild.ts @@ -1,46 +1,28 @@ #! /usr/bin/env node -import { spawn } from "child_process"; +import { SupportedFrameworks, ApphostingConfig } from "@apphosting/common"; +import { adapterBuild } from "../adapter-builds.js"; +import { parse as parseYaml } from "yaml"; +import fsExtra from "fs-extra"; +import { join } from "path"; import { program } from "commander"; -import { yellow, bgRed, bold } from "colorette"; -// TODO(#382): add framework option later or incorporate micro-discovery. -// TODO(#382): parse apphosting.yaml for environment variables / secrets. -// TODO(#382): parse apphosting.yaml for runConfig and include in buildSchema -// TODO(#382): Support custom build and run commands (parse and pass run command to build schema). +export const { readFileSync } = fsExtra; + program .argument("", "path to the project's root directory") - .action(async (projectRoot: string) => { - const framework = "nextjs"; - // TODO(#382): We are using the latest framework adapter versions, but in the future - // we should parse the framework version and use the matching adapter version. - const adapterName = `@apphosting/adapter-nextjs`; - const packumentResponse = await fetch(`https://registry.npmjs.org/${adapterName}`); - if (!packumentResponse.ok) throw new Error(`Something went wrong fetching ${adapterName}`); - const packument = await packumentResponse.json(); - const adapterVersion = packument?.["dist-tags"]?.["latest"]; - if (!adapterVersion) { - throw new Error(`Could not find 'latest' dist-tag for ${adapterName}`); - } - // TODO(#382): should check for existence of adapter in app's package.json and use that version instead. + .option("--framework ") + .action(async (projectRoot, opts) => { + // TODO(#382): support other apphosting.*.yaml files. - console.log(" 🔥", bgRed(` ${adapterName}@${yellow(bold(adapterVersion))} `), "\n"); - - const buildCommand = `apphosting-adapter-${framework}-build`; - await new Promise((resolve, reject) => { - const child = spawn("npx", ["-y", "-p", `${adapterName}@${adapterVersion}`, buildCommand], { - cwd: projectRoot, - shell: true, - stdio: "inherit", - }); + // TODO(#382): parse apphosting.yaml for environment variables / secrets needed during build time. + if (opts.framework && SupportedFrameworks.includes(opts.framework)) { + // TODO(#382): Skip this if there's a custom build command in apphosting.yaml. + adapterBuild(projectRoot, opts.framework); + } - child.on("exit", (code) => { - if (code !== 0) { - reject(new Error(`framework adapter build failed with error code ${code}.`)); - } - resolve(); - }); - }); - // TODO(#382): parse bundle.yaml and apphosting.yaml and output a buildschema somewhere. + // TODO(#382): Parse apphosting.yaml to set custom run command in bundle.yaml + // TODO(#382): parse apphosting.yaml for environment variables / secrets needed during runtime to include in the bunde.yaml + // TODO(#382): parse apphosting.yaml for runConfig to include in bundle.yaml }); program.parse(); diff --git a/packages/@apphosting/build/tsconfig.json b/packages/@apphosting/build/tsconfig.json index 3041a86e..96a70af3 100644 --- a/packages/@apphosting/build/tsconfig.json +++ b/packages/@apphosting/build/tsconfig.json @@ -8,7 +8,7 @@ }, "include": [ "src/index.ts", - "src/bin/*.ts", + "src/bin/*.ts" ], "exclude": [ "src/*.spec.ts" diff --git a/packages/@apphosting/common/src/index.ts b/packages/@apphosting/common/src/index.ts index 7ad051de..f888baa7 100644 --- a/packages/@apphosting/common/src/index.ts +++ b/packages/@apphosting/common/src/index.ts @@ -2,6 +2,11 @@ import { spawn } from "child_process"; import * as fs from "node:fs"; import * as path from "node:path"; +// List of apphosting supported frameworks. +export const SupportedFrameworks = ["nextjs", "angular"]; + +// **** OutputBundleConfig interfaces **** + // Output bundle metadata specifications to be written to bundle.yaml export interface OutputBundleConfig { version: "v1"; @@ -41,9 +46,31 @@ export interface Metadata { frameworkVersion?: string; } +// **** Apphosting Config interfaces **** + +export interface ApphostingConfig { + runconfig?: ApphostingRunConfig; + env?: EnvVarConfig[]; + scripts?: Script; + outputFiles?: OutputFiles; +} + +export interface ApphostingRunConfig { + minInstances?: number; + maxInstances?: number; + concurrency?: number; +} + +export interface Script { + buildCommand?: string; + runCommand?: string; +} + +// **** Shared interfaces **** + // Optional outputFiles to configure outputFiles and optimize server files + static assets. // If this is not set then all of the source code will be uploaded -interface OutputFiles { +export interface OutputFiles { serverApp: ServerApp; } @@ -59,14 +86,15 @@ export interface EnvVarConfig { variable: string; // Value associated with the variable value: string; - // Where the variable will be available, for now only RUNTIME is supported - availability: Availability.Runtime[]; + // Where the variable will be available + availability: Availability[]; } // Represents where environment variables are made available export enum Availability { // Runtime environment variables are available on the server when the app is run Runtime = "RUNTIME", + Build = "BUILD", } // Options to configure the build of a framework application