diff --git a/schema/firebase-config.json b/schema/firebase-config.json index e2a2f727298..3c067ed1562 100644 --- a/schema/firebase-config.json +++ b/schema/firebase-config.json @@ -357,6 +357,18 @@ "emulators": { "additionalProperties": false, "properties": { + "apphosting": { + "additionalProperties": false, + "properties": { + "host": { + "type": "string" + }, + "port": { + "type": "number" + } + }, + "type": "object" + }, "auth": { "additionalProperties": false, "properties": { diff --git a/src/emulator/apphosting/index.ts b/src/emulator/apphosting/index.ts new file mode 100644 index 00000000000..170bd7b88b7 --- /dev/null +++ b/src/emulator/apphosting/index.ts @@ -0,0 +1,48 @@ +import { EmulatorLogger } from "../emulatorLogger"; +import { EmulatorInfo, EmulatorInstance, Emulators } from "../types"; +import { start as apphostingStart } from "./serve"; +interface AppHostingEmulatorArgs { + options?: any; + port?: number; + host?: string; +} + +/** + * An emulator instance for Firebase's App Hosting product. This class provides a simulated + * environment for testing App Hosting features locally. + */ +export class AppHostingEmulator implements EmulatorInstance { + private logger = EmulatorLogger.forEmulator(Emulators.APPHOSTING); + constructor(private args: AppHostingEmulatorArgs) {} + + async start(): Promise { + this.args.options.host = this.args.host; + this.args.options.port = this.args.port; + + this.logger.logLabeled("INFO", Emulators.APPHOSTING, "starting apphosting emulator"); + const { port } = await apphostingStart(this.args.options); + this.logger.logLabeled("INFO", Emulators.APPHOSTING, `serving on port ${port}`); + } + + connect(): Promise { + this.logger.logLabeled("INFO", Emulators.APPHOSTING, "connecting apphosting emulator"); + return Promise.resolve(); + } + + stop(): Promise { + this.logger.logLabeled("INFO", Emulators.APPHOSTING, "stopping apphosting emulator"); + return Promise.resolve(); + } + + getInfo(): EmulatorInfo { + return { + name: Emulators.APPHOSTING, + host: this.args.host!, + port: this.args.port!, + }; + } + + getName(): Emulators { + return Emulators.APPHOSTING; + } +} diff --git a/src/emulator/apphosting/serve.spec.ts b/src/emulator/apphosting/serve.spec.ts new file mode 100644 index 00000000000..750df4e41a6 --- /dev/null +++ b/src/emulator/apphosting/serve.spec.ts @@ -0,0 +1,33 @@ +import * as portUtils from "../portUtils"; +import * as sinon from "sinon"; +import * as spawn from "../../init/spawn"; +import { expect } from "chai"; +import * as serve from "./serve"; + +describe("serve", () => { + let checkListenableStub: sinon.SinonStub; + let wrapSpawnStub: sinon.SinonStub; + + beforeEach(() => { + checkListenableStub = sinon.stub(portUtils, "checkListenable"); + wrapSpawnStub = sinon.stub(spawn, "wrapSpawn"); + }); + + afterEach(() => { + checkListenableStub.restore(); + wrapSpawnStub.restore(); + }); + + describe("start", () => { + it("should only select an available port to serve", async () => { + checkListenableStub.onFirstCall().returns(false); + checkListenableStub.onSecondCall().returns(false); + checkListenableStub.onThirdCall().returns(true); + + wrapSpawnStub.returns(Promise.resolve()); + + const res = await serve.start({ host: "127.0.0.1", port: 5000 }); + expect(res.port).to.equal(5002); + }); + }); +}); diff --git a/src/emulator/apphosting/serve.ts b/src/emulator/apphosting/serve.ts new file mode 100644 index 00000000000..f32b6bb6def --- /dev/null +++ b/src/emulator/apphosting/serve.ts @@ -0,0 +1,38 @@ +/** + * Start the App Hosting server. + * @param options the Firebase CLI options. + */ +import { isIPv4 } from "net"; +import { checkListenable } from "../portUtils"; +import { wrapSpawn } from "../../init/spawn"; + +/** + * Spins up a project locally by running the project's dev command. + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export async function start(options: any): Promise<{ port: number }> { + let port = options.port; + while (!(await availablePort(options.host, port))) { + port += 1; + } + + serve(options, port); + + return { port }; +} + +function availablePort(host: string, port: number): Promise { + return checkListenable({ + address: host, + port, + family: isIPv4(host) ? "IPv4" : "IPv6", + }); +} + +/** + * Exported for unit testing + */ +export async function serve(options: any, port: string) { + // TODO: update to support other package managers and frameworks other than NextJS + await wrapSpawn("npm", ["run", "dev", "--", "-H", options.host, "-p", port], process.cwd()); +} diff --git a/src/emulator/constants.ts b/src/emulator/constants.ts index ffe02cc3664..20932d907bb 100644 --- a/src/emulator/constants.ts +++ b/src/emulator/constants.ts @@ -7,6 +7,7 @@ export const DEFAULT_PORTS: { [s in Emulators]: number } = { hosting: 5000, functions: 5001, extensions: 5001, // The Extensions Emulator runs on the same port as the Functions Emulator + apphosting: 5002, firestore: 8080, pubsub: 8085, database: 9000, @@ -22,6 +23,7 @@ export const FIND_AVAILBLE_PORT_BY_DEFAULT: Record = { hub: true, logging: true, hosting: true, + apphosting: true, functions: false, firestore: false, database: false, @@ -39,6 +41,7 @@ export const EMULATOR_DESCRIPTION: Record = { hub: "emulator hub", logging: "Logging Emulator", hosting: "Hosting Emulator", + apphosting: "App Hosting Emulator", functions: "Functions Emulator", firestore: "Firestore Emulator", database: "Database Emulator", diff --git a/src/emulator/controller.ts b/src/emulator/controller.ts index 7fbe76888f9..ae002e4aaa8 100755 --- a/src/emulator/controller.ts +++ b/src/emulator/controller.ts @@ -58,6 +58,7 @@ import { PubsubEmulator } from "./pubsubEmulator"; import { StorageEmulator } from "./storage"; import { readFirebaseJson } from "../dataconnect/fileUtils"; import { TasksEmulator } from "./tasksEmulator"; +import { AppHostingEmulator } from "./apphosting"; const START_LOGGING_EMULATOR = utils.envOverride( "START_LOGGING_EMULATOR", @@ -886,6 +887,25 @@ export async function startAll( await startEmulator(hostingEmulator); } + /** + * Similar to the Hosting emulator, the App Hosting emulator should also + * start after the other emulators. This is because the service running on + * app hosting emulator may depend on other emulators (i.e auth, firestore, + * storage, etc). + */ + if (experiments.isEnabled("emulatorapphosting")) { + if (listenForEmulator.apphosting) { + const apphostingAddr = legacyGetFirstAddr(Emulators.APPHOSTING); + const apphostingEmulator = new AppHostingEmulator({ + host: apphostingAddr.host, + port: apphostingAddr.port, + options, + }); + + await startEmulator(apphostingEmulator); + } + } + if (listenForEmulator.logging) { const loggingAddr = legacyGetFirstAddr(Emulators.LOGGING); const loggingEmulator = new LoggingEmulator({ diff --git a/src/emulator/portUtils.ts b/src/emulator/portUtils.ts index e3db91120ba..8d3d9233745 100644 --- a/src/emulator/portUtils.ts +++ b/src/emulator/portUtils.ts @@ -209,6 +209,8 @@ const EMULATOR_CAN_LISTEN_ON_PRIMARY_ONLY: Record = { // Only one hostname possible in .server mode, can switch to middleware later. hosting: true, + + apphosting: true, }; export interface EmulatorListenConfig { diff --git a/src/emulator/registry.ts b/src/emulator/registry.ts index aa955d72624..28be17d4c20 100644 --- a/src/emulator/registry.ts +++ b/src/emulator/registry.ts @@ -79,6 +79,12 @@ export class EmulatorRegistry { // Hosting is next because it can trigger functions. hosting: 2, + /** App Hosting should be shut down next. Users should not be interacting + * with their app while its being shut down as the app may using the + * background trigger emulators below. + */ + apphosting: 2.1, + // All background trigger emulators are equal here, so we choose // an order for consistency. database: 3.0, diff --git a/src/emulator/types.ts b/src/emulator/types.ts index b1566f5a747..e3d48dc9693 100644 --- a/src/emulator/types.ts +++ b/src/emulator/types.ts @@ -1,5 +1,6 @@ import { ChildProcess } from "child_process"; import { EventEmitter } from "events"; +import * as experiments from "../experiments"; export enum Emulators { AUTH = "auth", @@ -8,6 +9,7 @@ export enum Emulators { FIRESTORE = "firestore", DATABASE = "database", HOSTING = "hosting", + APPHOSTING = "apphosting", PUBSUB = "pubsub", UI = "ui", LOGGING = "logging", @@ -48,6 +50,7 @@ export const ALL_SERVICE_EMULATORS = [ Emulators.FIRESTORE, Emulators.DATABASE, Emulators.HOSTING, + ...(experiments.isEnabled("emulatorapphosting") ? [Emulators.APPHOSTING] : []), Emulators.PUBSUB, Emulators.STORAGE, Emulators.EVENTARC, diff --git a/src/experiments.ts b/src/experiments.ts index 08eb211761d..f5cdfe2e3b4 100644 --- a/src/experiments.ts +++ b/src/experiments.ts @@ -78,6 +78,10 @@ export const ALL_EXPERIMENTS = experiments({ emulatoruisnapshot: { shortDescription: "Load pre-release versions of the emulator UI", }, + emulatorapphosting: { + shortDescription: "App Hosting emulator", + public: false, + }, // Hosting experiments webframeworks: { diff --git a/src/firebaseConfig.ts b/src/firebaseConfig.ts index 1ddd9e478bb..0f2260ed701 100644 --- a/src/firebaseConfig.ts +++ b/src/firebaseConfig.ts @@ -203,6 +203,10 @@ export type EmulatorsConfig = { host?: string; port?: number; }; + apphosting?: { + host?: string; + port?: number; + }; pubsub?: { host?: string; port?: number;