Skip to content

Commit

Permalink
App Hosting Emulator Prototype (#7505)
Browse files Browse the repository at this point in the history
  • Loading branch information
mathu97 authored Sep 4, 2024
1 parent d65aed8 commit 0e2ab54
Show file tree
Hide file tree
Showing 11 changed files with 173 additions and 0 deletions.
12 changes: 12 additions & 0 deletions schema/firebase-config.json
Original file line number Diff line number Diff line change
Expand Up @@ -357,6 +357,18 @@
"emulators": {
"additionalProperties": false,
"properties": {
"apphosting": {
"additionalProperties": false,
"properties": {
"host": {
"type": "string"
},
"port": {
"type": "number"
}
},
"type": "object"
},
"auth": {
"additionalProperties": false,
"properties": {
Expand Down
48 changes: 48 additions & 0 deletions src/emulator/apphosting/index.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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<void> {
this.logger.logLabeled("INFO", Emulators.APPHOSTING, "connecting apphosting emulator");
return Promise.resolve();
}

stop(): Promise<void> {
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;
}
}
33 changes: 33 additions & 0 deletions src/emulator/apphosting/serve.spec.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
});
38 changes: 38 additions & 0 deletions src/emulator/apphosting/serve.ts
Original file line number Diff line number Diff line change
@@ -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<boolean> {
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());
}
3 changes: 3 additions & 0 deletions src/emulator/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -22,6 +23,7 @@ export const FIND_AVAILBLE_PORT_BY_DEFAULT: Record<Emulators, boolean> = {
hub: true,
logging: true,
hosting: true,
apphosting: true,
functions: false,
firestore: false,
database: false,
Expand All @@ -39,6 +41,7 @@ export const EMULATOR_DESCRIPTION: Record<Emulators, string> = {
hub: "emulator hub",
logging: "Logging Emulator",
hosting: "Hosting Emulator",
apphosting: "App Hosting Emulator",
functions: "Functions Emulator",
firestore: "Firestore Emulator",
database: "Database Emulator",
Expand Down
20 changes: 20 additions & 0 deletions src/emulator/controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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({
Expand Down
2 changes: 2 additions & 0 deletions src/emulator/portUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,8 @@ const EMULATOR_CAN_LISTEN_ON_PRIMARY_ONLY: Record<PortName, boolean> = {

// Only one hostname possible in .server mode, can switch to middleware later.
hosting: true,

apphosting: true,
};

export interface EmulatorListenConfig {
Expand Down
6 changes: 6 additions & 0 deletions src/emulator/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
3 changes: 3 additions & 0 deletions src/emulator/types.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { ChildProcess } from "child_process";
import { EventEmitter } from "events";
import * as experiments from "../experiments";

export enum Emulators {
AUTH = "auth",
Expand All @@ -8,6 +9,7 @@ export enum Emulators {
FIRESTORE = "firestore",
DATABASE = "database",
HOSTING = "hosting",
APPHOSTING = "apphosting",
PUBSUB = "pubsub",
UI = "ui",
LOGGING = "logging",
Expand Down Expand Up @@ -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,
Expand Down
4 changes: 4 additions & 0 deletions src/experiments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
4 changes: 4 additions & 0 deletions src/firebaseConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,10 @@ export type EmulatorsConfig = {
host?: string;
port?: number;
};
apphosting?: {
host?: string;
port?: number;
};
pubsub?: {
host?: string;
port?: number;
Expand Down

0 comments on commit 0e2ab54

Please sign in to comment.