Skip to content

Commit b86a9a6

Browse files
authored
Add SDK autoinit to the App Hosting emulator (#8582)
Setup environment variables and trip the Firebase JS SDK's postinstall step. This permits autoinit for both Firebase Admin SDK and JS SDK.
1 parent a4af88c commit b86a9a6

File tree

5 files changed

+157
-9
lines changed

5 files changed

+157
-9
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,4 +5,5 @@
55
- Add GCP API client functions to support App Hosting deploy from source feature. (#8545)
66
- Changed firebase init template for functions to pin runtime version on init. (#8553)
77
- Fix an issue where updating a Cloud Function that retires would add incorrect fields to the updateMask. (#8560)
8+
- Add SDK autoinit capabilities to App Hosting emulator. (#8582)
89
- Provision App Hosting compute service account during init flow. (#8580)

src/emulator/apphosting/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
import { EmulatorInfo, EmulatorInstance, Emulators } from "../types";
22
import { start as apphostingStart } from "./serve";
33
import { logger } from "./developmentServer";
4+
45
interface AppHostingEmulatorArgs {
56
projectId?: string;
7+
backendId?: string;
68
options?: any;
79
port?: number;
810
host?: string;
@@ -20,6 +22,7 @@ export class AppHostingEmulator implements EmulatorInstance {
2022
async start(): Promise<void> {
2123
const { hostname, port } = await apphostingStart({
2224
projectId: this.args.projectId,
25+
backendId: this.args.backendId,
2326
port: this.args.port,
2427
startCommand: this.args.startCommand,
2528
rootDirectory: this.args.rootDirectory,

src/emulator/apphosting/serve.ts

Lines changed: 127 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
import { isIPv4 } from "net";
77
import * as clc from "colorette";
88
import { checkListenable } from "../portUtils";
9-
import { detectStartCommand } from "./developmentServer";
9+
import { detectPackageManager, detectStartCommand } from "./developmentServer";
1010
import { DEFAULT_HOST, DEFAULT_PORTS } from "../constants";
1111
import { spawnWithCommandString } from "../../init/spawn";
1212
import { logger } from "./developmentServer";
@@ -17,10 +17,17 @@ import { EmulatorRegistry } from "../registry";
1717
import { setEnvVarsForEmulators } from "../env";
1818
import { FirebaseError } from "../../error";
1919
import * as secrets from "../../gcp/secretManager";
20-
import { logLabeledError } from "../../utils";
20+
import { logLabeledError, logLabeledWarning } from "../../utils";
21+
import * as apphosting from "../../gcp/apphosting";
22+
import { Constants } from "../constants";
23+
import { constructDefaultWebSetup, WebConfig } from "../../fetchWebSetup";
24+
import { AppPlatform, getAppConfig } from "../../management/apps";
25+
import { spawnSync } from "child_process";
26+
import { gte as semverGte } from "semver";
2127

2228
interface StartOptions {
2329
projectId?: string;
30+
backendId?: string;
2431
port?: number;
2532
startCommand?: string;
2633
rootDirectory?: string;
@@ -41,7 +48,13 @@ export async function start(options?: StartOptions): Promise<{ hostname: string;
4148
port += 1;
4249
}
4350

44-
await serve(options?.projectId, port, options?.startCommand, options?.rootDirectory);
51+
await serve(
52+
options?.projectId,
53+
options?.backendId,
54+
port,
55+
options?.startCommand,
56+
options?.rootDirectory,
57+
);
4558

4659
return { hostname, port };
4760
}
@@ -102,6 +115,7 @@ async function loadSecret(project: string | undefined, name: string): Promise<st
102115
*/
103116
async function serve(
104117
projectId: string | undefined,
118+
backendId: string | undefined,
105119
port: number,
106120
startCommand?: string,
107121
backendRelativeDir?: string,
@@ -115,11 +129,34 @@ async function serve(
115129
value.value ? value.value : await loadSecret(projectId, value.secret!),
116130
]);
117131

118-
const environmentVariablesToInject = {
132+
const environmentVariablesToInject: NodeJS.ProcessEnv = {
133+
NODE_ENV: process.env.NODE_ENV,
119134
...getEmulatorEnvs(),
120135
...Object.fromEntries(await Promise.all(resolveEnv)),
136+
FIREBASE_APP_HOSTING: "1",
137+
X_GOOGLE_TARGET_PLATFORM: "fah",
138+
GCLOUD_PROJECT: projectId,
139+
PROJECT_ID: projectId,
121140
PORT: port.toString(),
122141
};
142+
143+
const packageManager = await detectPackageManager(backendRoot).catch(() => undefined);
144+
if (packageManager === "pnpm") {
145+
// TODO(jamesdaniels) look into pnpm support for autoinit
146+
logLabeledWarning("apphosting", `Firebase JS SDK autoinit does not currently support PNPM.`);
147+
} else {
148+
const webappConfig = await getBackendAppConfig(projectId, backendId);
149+
if (webappConfig) {
150+
environmentVariablesToInject["FIREBASE_WEBAPP_CONFIG"] ||= JSON.stringify(webappConfig);
151+
environmentVariablesToInject["FIREBASE_CONFIG"] ||= JSON.stringify({
152+
databaseURL: webappConfig.databaseURL,
153+
storageBucket: webappConfig.storageBucket,
154+
projectId: webappConfig.projectId,
155+
});
156+
}
157+
await tripFirebasePostinstall(backendRoot, environmentVariablesToInject);
158+
}
159+
123160
if (startCommand) {
124161
logger.logLabeled(
125162
"BULLET",
@@ -167,3 +204,89 @@ export function getEmulatorEnvs(): Record<string, string> {
167204

168205
return envs;
169206
}
207+
208+
type Dependency = {
209+
name: string;
210+
version: string;
211+
path: string;
212+
dependencies?: Record<string, Dependency>;
213+
};
214+
215+
async function tripFirebasePostinstall(
216+
rootDirectory: string,
217+
env: NodeJS.ProcessEnv,
218+
): Promise<void> {
219+
const npmLs = spawnSync("npm", ["ls", "@firebase/util", "--json", "--long"], {
220+
cwd: rootDirectory,
221+
shell: process.platform === "win32",
222+
});
223+
if (!npmLs.stdout) {
224+
return;
225+
}
226+
const npmLsResults = JSON.parse(npmLs.stdout.toString().trim());
227+
const dependenciesToSearch: Dependency[] = Object.values(npmLsResults.dependencies);
228+
const firebaseUtilPaths: string[] = [];
229+
for (const dependency of dependenciesToSearch) {
230+
if (
231+
dependency.name === "@firebase/util" &&
232+
semverGte(dependency.version, "1.11.0") &&
233+
firebaseUtilPaths.indexOf(dependency.path) === -1
234+
) {
235+
firebaseUtilPaths.push(dependency.path);
236+
}
237+
if (dependency.dependencies) {
238+
dependenciesToSearch.push(...Object.values(dependency.dependencies));
239+
}
240+
}
241+
242+
await Promise.all(
243+
firebaseUtilPaths.map(
244+
(path) =>
245+
new Promise<void>((resolve) => {
246+
spawnSync("npm", ["run", "postinstall"], {
247+
cwd: path,
248+
env,
249+
stdio: "ignore",
250+
shell: process.platform === "win32",
251+
});
252+
resolve();
253+
}),
254+
),
255+
);
256+
}
257+
258+
async function getBackendAppConfig(
259+
projectId?: string,
260+
backendId?: string,
261+
): Promise<WebConfig | undefined> {
262+
if (!projectId) {
263+
return undefined;
264+
}
265+
266+
if (Constants.isDemoProject(projectId)) {
267+
return constructDefaultWebSetup(projectId);
268+
}
269+
270+
if (!backendId) {
271+
return undefined;
272+
}
273+
274+
const backendsList = await apphosting.listBackends(projectId, "-").catch(() => undefined);
275+
const backend = backendsList?.backends.find(
276+
(b) => apphosting.parseBackendName(b.name).id === backendId,
277+
);
278+
279+
if (!backend) {
280+
logLabeledWarning(
281+
"apphosting",
282+
`Unable to lookup details for backend ${backendId}. Firebase SDK autoinit will not be available.`,
283+
);
284+
return undefined;
285+
}
286+
287+
if (!backend.appId) {
288+
return undefined;
289+
}
290+
291+
return (await getAppConfig(backend.appId, AppPlatform.WEB)) as WebConfig;
292+
}

src/emulator/controller.ts

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,8 @@ import { TasksEmulator } from "./tasksEmulator";
6161
import { AppHostingEmulator } from "./apphosting";
6262
import { sendVSCodeMessage, VSCODE_MESSAGE } from "../dataconnect/webhook";
6363
import { dataConnectLocalConnString } from "../api";
64+
import { AppHostingSingle } from "../firebaseConfig";
65+
import { resolveProjectPath } from "../projectPath";
6466

6567
const START_LOGGING_EMULATOR = utils.envOverride(
6668
"START_LOGGING_EMULATOR",
@@ -968,11 +970,28 @@ export async function startAll(
968970
* app hosting emulator may depend on other emulators (i.e auth, firestore,
969971
* storage, etc).
970972
*/
971-
const apphostingConfig = options.config.src.emulators?.[Emulators.APPHOSTING];
973+
const apphostingEmulatorConfig = options.config.src.emulators?.[Emulators.APPHOSTING];
972974

973975
if (listenForEmulator.apphosting) {
976+
const rootDirectory = apphostingEmulatorConfig?.rootDirectory;
977+
const backendRoot = resolveProjectPath({}, rootDirectory ?? "./");
978+
979+
// It doesn't seem as though App Hosting emulator supports multiple backends, infer the correct one
980+
// from the root directory.
981+
let apphostingConfig: AppHostingSingle | undefined;
982+
if (Array.isArray(options.config.src.apphosting)) {
983+
const matchingAppHostingConfig = options.config.src.apphosting.filter(
984+
(config) => resolveProjectPath({}, path.join(".", config.rootDir ?? "/")) === backendRoot,
985+
);
986+
if (matchingAppHostingConfig.length === 1) {
987+
apphostingConfig = matchingAppHostingConfig[0];
988+
}
989+
} else {
990+
apphostingConfig = options.config.src.apphosting;
991+
}
992+
974993
const apphostingAddr = legacyGetFirstAddr(Emulators.APPHOSTING);
975-
if (apphostingConfig?.startCommandOverride) {
994+
if (apphostingEmulatorConfig?.startCommandOverride) {
976995
const apphostingLogger = EmulatorLogger.forEmulator(Emulators.APPHOSTING);
977996
apphostingLogger.logLabeled(
978997
"WARN",
@@ -982,10 +1001,12 @@ export async function startAll(
9821001
}
9831002
const apphostingEmulator = new AppHostingEmulator({
9841003
projectId: options.project,
1004+
backendId: apphostingConfig?.backendId,
9851005
host: apphostingAddr.host,
9861006
port: apphostingAddr.port,
987-
startCommand: apphostingConfig?.startCommand || apphostingConfig?.startCommandOverride,
988-
rootDirectory: apphostingConfig?.rootDirectory,
1007+
startCommand:
1008+
apphostingEmulatorConfig?.startCommand || apphostingEmulatorConfig?.startCommandOverride,
1009+
rootDirectory,
9891010
options,
9901011
});
9911012

src/init/spawn.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,7 @@ export function spawnWithOutput(cmd: string, args: string[]): Promise<string> {
8787
export function spawnWithCommandString(
8888
cmd: string,
8989
projectDir: string,
90-
environmentVariables?: Record<string, string>,
90+
environmentVariables?: NodeJS.ProcessEnv,
9191
): Promise<void> {
9292
return new Promise<void>((resolve, reject) => {
9393
const installer = spawn(cmd, {

0 commit comments

Comments
 (0)