Skip to content

Commit 0e2ab54

Browse files
authored
App Hosting Emulator Prototype (#7505)
1 parent d65aed8 commit 0e2ab54

File tree

11 files changed

+173
-0
lines changed

11 files changed

+173
-0
lines changed

schema/firebase-config.json

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -357,6 +357,18 @@
357357
"emulators": {
358358
"additionalProperties": false,
359359
"properties": {
360+
"apphosting": {
361+
"additionalProperties": false,
362+
"properties": {
363+
"host": {
364+
"type": "string"
365+
},
366+
"port": {
367+
"type": "number"
368+
}
369+
},
370+
"type": "object"
371+
},
360372
"auth": {
361373
"additionalProperties": false,
362374
"properties": {

src/emulator/apphosting/index.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { EmulatorLogger } from "../emulatorLogger";
2+
import { EmulatorInfo, EmulatorInstance, Emulators } from "../types";
3+
import { start as apphostingStart } from "./serve";
4+
interface AppHostingEmulatorArgs {
5+
options?: any;
6+
port?: number;
7+
host?: string;
8+
}
9+
10+
/**
11+
* An emulator instance for Firebase's App Hosting product. This class provides a simulated
12+
* environment for testing App Hosting features locally.
13+
*/
14+
export class AppHostingEmulator implements EmulatorInstance {
15+
private logger = EmulatorLogger.forEmulator(Emulators.APPHOSTING);
16+
constructor(private args: AppHostingEmulatorArgs) {}
17+
18+
async start(): Promise<void> {
19+
this.args.options.host = this.args.host;
20+
this.args.options.port = this.args.port;
21+
22+
this.logger.logLabeled("INFO", Emulators.APPHOSTING, "starting apphosting emulator");
23+
const { port } = await apphostingStart(this.args.options);
24+
this.logger.logLabeled("INFO", Emulators.APPHOSTING, `serving on port ${port}`);
25+
}
26+
27+
connect(): Promise<void> {
28+
this.logger.logLabeled("INFO", Emulators.APPHOSTING, "connecting apphosting emulator");
29+
return Promise.resolve();
30+
}
31+
32+
stop(): Promise<void> {
33+
this.logger.logLabeled("INFO", Emulators.APPHOSTING, "stopping apphosting emulator");
34+
return Promise.resolve();
35+
}
36+
37+
getInfo(): EmulatorInfo {
38+
return {
39+
name: Emulators.APPHOSTING,
40+
host: this.args.host!,
41+
port: this.args.port!,
42+
};
43+
}
44+
45+
getName(): Emulators {
46+
return Emulators.APPHOSTING;
47+
}
48+
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import * as portUtils from "../portUtils";
2+
import * as sinon from "sinon";
3+
import * as spawn from "../../init/spawn";
4+
import { expect } from "chai";
5+
import * as serve from "./serve";
6+
7+
describe("serve", () => {
8+
let checkListenableStub: sinon.SinonStub;
9+
let wrapSpawnStub: sinon.SinonStub;
10+
11+
beforeEach(() => {
12+
checkListenableStub = sinon.stub(portUtils, "checkListenable");
13+
wrapSpawnStub = sinon.stub(spawn, "wrapSpawn");
14+
});
15+
16+
afterEach(() => {
17+
checkListenableStub.restore();
18+
wrapSpawnStub.restore();
19+
});
20+
21+
describe("start", () => {
22+
it("should only select an available port to serve", async () => {
23+
checkListenableStub.onFirstCall().returns(false);
24+
checkListenableStub.onSecondCall().returns(false);
25+
checkListenableStub.onThirdCall().returns(true);
26+
27+
wrapSpawnStub.returns(Promise.resolve());
28+
29+
const res = await serve.start({ host: "127.0.0.1", port: 5000 });
30+
expect(res.port).to.equal(5002);
31+
});
32+
});
33+
});

src/emulator/apphosting/serve.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
/**
2+
* Start the App Hosting server.
3+
* @param options the Firebase CLI options.
4+
*/
5+
import { isIPv4 } from "net";
6+
import { checkListenable } from "../portUtils";
7+
import { wrapSpawn } from "../../init/spawn";
8+
9+
/**
10+
* Spins up a project locally by running the project's dev command.
11+
*/
12+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
13+
export async function start(options: any): Promise<{ port: number }> {
14+
let port = options.port;
15+
while (!(await availablePort(options.host, port))) {
16+
port += 1;
17+
}
18+
19+
serve(options, port);
20+
21+
return { port };
22+
}
23+
24+
function availablePort(host: string, port: number): Promise<boolean> {
25+
return checkListenable({
26+
address: host,
27+
port,
28+
family: isIPv4(host) ? "IPv4" : "IPv6",
29+
});
30+
}
31+
32+
/**
33+
* Exported for unit testing
34+
*/
35+
export async function serve(options: any, port: string) {
36+
// TODO: update to support other package managers and frameworks other than NextJS
37+
await wrapSpawn("npm", ["run", "dev", "--", "-H", options.host, "-p", port], process.cwd());
38+
}

src/emulator/constants.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ export const DEFAULT_PORTS: { [s in Emulators]: number } = {
77
hosting: 5000,
88
functions: 5001,
99
extensions: 5001, // The Extensions Emulator runs on the same port as the Functions Emulator
10+
apphosting: 5002,
1011
firestore: 8080,
1112
pubsub: 8085,
1213
database: 9000,
@@ -22,6 +23,7 @@ export const FIND_AVAILBLE_PORT_BY_DEFAULT: Record<Emulators, boolean> = {
2223
hub: true,
2324
logging: true,
2425
hosting: true,
26+
apphosting: true,
2527
functions: false,
2628
firestore: false,
2729
database: false,
@@ -39,6 +41,7 @@ export const EMULATOR_DESCRIPTION: Record<Emulators, string> = {
3941
hub: "emulator hub",
4042
logging: "Logging Emulator",
4143
hosting: "Hosting Emulator",
44+
apphosting: "App Hosting Emulator",
4245
functions: "Functions Emulator",
4346
firestore: "Firestore Emulator",
4447
database: "Database Emulator",

src/emulator/controller.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ import { PubsubEmulator } from "./pubsubEmulator";
5858
import { StorageEmulator } from "./storage";
5959
import { readFirebaseJson } from "../dataconnect/fileUtils";
6060
import { TasksEmulator } from "./tasksEmulator";
61+
import { AppHostingEmulator } from "./apphosting";
6162

6263
const START_LOGGING_EMULATOR = utils.envOverride(
6364
"START_LOGGING_EMULATOR",
@@ -886,6 +887,25 @@ export async function startAll(
886887
await startEmulator(hostingEmulator);
887888
}
888889

890+
/**
891+
* Similar to the Hosting emulator, the App Hosting emulator should also
892+
* start after the other emulators. This is because the service running on
893+
* app hosting emulator may depend on other emulators (i.e auth, firestore,
894+
* storage, etc).
895+
*/
896+
if (experiments.isEnabled("emulatorapphosting")) {
897+
if (listenForEmulator.apphosting) {
898+
const apphostingAddr = legacyGetFirstAddr(Emulators.APPHOSTING);
899+
const apphostingEmulator = new AppHostingEmulator({
900+
host: apphostingAddr.host,
901+
port: apphostingAddr.port,
902+
options,
903+
});
904+
905+
await startEmulator(apphostingEmulator);
906+
}
907+
}
908+
889909
if (listenForEmulator.logging) {
890910
const loggingAddr = legacyGetFirstAddr(Emulators.LOGGING);
891911
const loggingEmulator = new LoggingEmulator({

src/emulator/portUtils.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,8 @@ const EMULATOR_CAN_LISTEN_ON_PRIMARY_ONLY: Record<PortName, boolean> = {
209209

210210
// Only one hostname possible in .server mode, can switch to middleware later.
211211
hosting: true,
212+
213+
apphosting: true,
212214
};
213215

214216
export interface EmulatorListenConfig {

src/emulator/registry.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,12 @@ export class EmulatorRegistry {
7979
// Hosting is next because it can trigger functions.
8080
hosting: 2,
8181

82+
/** App Hosting should be shut down next. Users should not be interacting
83+
* with their app while its being shut down as the app may using the
84+
* background trigger emulators below.
85+
*/
86+
apphosting: 2.1,
87+
8288
// All background trigger emulators are equal here, so we choose
8389
// an order for consistency.
8490
database: 3.0,

src/emulator/types.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { ChildProcess } from "child_process";
22
import { EventEmitter } from "events";
3+
import * as experiments from "../experiments";
34

45
export enum Emulators {
56
AUTH = "auth",
@@ -8,6 +9,7 @@ export enum Emulators {
89
FIRESTORE = "firestore",
910
DATABASE = "database",
1011
HOSTING = "hosting",
12+
APPHOSTING = "apphosting",
1113
PUBSUB = "pubsub",
1214
UI = "ui",
1315
LOGGING = "logging",
@@ -48,6 +50,7 @@ export const ALL_SERVICE_EMULATORS = [
4850
Emulators.FIRESTORE,
4951
Emulators.DATABASE,
5052
Emulators.HOSTING,
53+
...(experiments.isEnabled("emulatorapphosting") ? [Emulators.APPHOSTING] : []),
5154
Emulators.PUBSUB,
5255
Emulators.STORAGE,
5356
Emulators.EVENTARC,

src/experiments.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,10 @@ export const ALL_EXPERIMENTS = experiments({
7878
emulatoruisnapshot: {
7979
shortDescription: "Load pre-release versions of the emulator UI",
8080
},
81+
emulatorapphosting: {
82+
shortDescription: "App Hosting emulator",
83+
public: false,
84+
},
8185

8286
// Hosting experiments
8387
webframeworks: {

0 commit comments

Comments
 (0)