Skip to content

Commit c573c6a

Browse files
committed
create a prototype version of local builds that uses the universal maker
1 parent 63a529e commit c573c6a

3 files changed

Lines changed: 186 additions & 4 deletions

File tree

src/apphosting/localbuilds.spec.ts

Lines changed: 69 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
import * as sinon from "sinon";
22
import { expect } from "chai";
33
import * as localBuildModule from "@apphosting/build";
4-
import { localBuild } from "./localbuilds";
4+
import { localBuild, runUniversalMaker } from "./localbuilds";
55
import * as secrets from "./secrets";
66
import { EnvMap } from "./yaml";
7+
import * as childProcess from "child_process";
8+
import * as fs from "fs";
79

810
describe("localBuild", () => {
911
afterEach(() => {
@@ -13,6 +15,7 @@ describe("localBuild", () => {
1315
it("returns the expected output", async () => {
1416
const bundleConfig = {
1517
version: "v1" as const,
18+
1619
runConfig: {
1720
runCommand: "npm run build:prod",
1821
},
@@ -202,4 +205,69 @@ describe("localBuild", () => {
202205
expect(confirmStub).to.have.been.calledOnce;
203206
});
204207
});
208+
209+
describe("runUniversalMaker", () => {
210+
it("should successfully execute Universal Maker and parse output", () => {
211+
process.env.UNIVERSAL_MAKER_BINARY = "/path/to/universal_maker";
212+
const spawnStub = sinon
213+
.stub(childProcess, "spawnSync")
214+
.returns({} as unknown as childProcess.SpawnSyncReturns<string>);
215+
sinon.stub(fs, "existsSync").returns(true);
216+
const readFileSyncStub = sinon.stub(fs, "readFileSync").returns(
217+
JSON.stringify({
218+
command: "npm",
219+
args: ["run", "start"],
220+
language: "nodejs",
221+
runtime: "nodejs22",
222+
envVars: { PORT: 3000 },
223+
}),
224+
);
225+
226+
const output = runUniversalMaker("./", "nextjs");
227+
228+
expect(output).to.deep.equal({
229+
metadata: {
230+
language: "nodejs",
231+
runtime: "nodejs22",
232+
framework: "nextjs",
233+
},
234+
runConfig: {
235+
runCommand: "npm run start",
236+
environmentVariables: [{ variable: "PORT", value: "3000", availability: ["RUNTIME"] }],
237+
},
238+
outputFiles: {
239+
serverApp: {
240+
include: [".apphosting"],
241+
},
242+
},
243+
});
244+
245+
sinon.assert.calledOnce(spawnStub);
246+
sinon.assert.calledOnce(readFileSyncStub);
247+
delete process.env.UNIVERSAL_MAKER_BINARY;
248+
});
249+
250+
it("should raise clear FirebaseError when UNIVERSAL_MAKER_BINARY is undefined", () => {
251+
delete process.env.UNIVERSAL_MAKER_BINARY;
252+
253+
expect(() => runUniversalMaker("./")).to.throw(
254+
"Please specify the path to your Universal Maker binary by establishing the UNIVERSAL_MAKER_BINARY environment variable.",
255+
);
256+
});
257+
258+
it("should raise clear FirebaseError on permission errors within child execution", () => {
259+
process.env.UNIVERSAL_MAKER_BINARY = "/path/to/universal_maker";
260+
sinon.stub(childProcess, "spawnSync").callsFake(() => {
261+
const err = new Error("EACCES exception") as NodeJS.ErrnoException;
262+
err.code = "EACCES";
263+
264+
throw err;
265+
});
266+
267+
expect(() => runUniversalMaker("./")).to.throw(
268+
"Failed to execute the Universal Maker binary due to permission constraints. Please assure you have set chmod +x on your file.",
269+
);
270+
delete process.env.UNIVERSAL_MAKER_BINARY;
271+
});
272+
});
205273
});

src/apphosting/localbuilds.ts

Lines changed: 111 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,115 @@
1+
import * as childProcess from "child_process";
2+
import * as fs from "fs";
3+
import * as path from "path";
14
import { BuildConfig, Env } from "../gcp/apphosting";
25
import { localBuild as localAppHostingBuild } from "@apphosting/build";
36
import { EnvMap } from "./yaml";
47
import { loadSecret } from "./secrets";
58
import { confirm } from "../prompt";
69
import { FirebaseError } from "../error";
10+
import * as experiments from "../experiments";
11+
12+
interface UniversalMakerOutput {
13+
command: string;
14+
args: string[];
15+
language: string;
16+
runtime: string;
17+
envVars?: Record<string, string | number | boolean>;
18+
}
19+
20+
/**
21+
* Runs the Universal Maker binary to build the project.
22+
*/
23+
export function runUniversalMaker(projectRoot: string, framework?: string): AppHostingBuildOutput {
24+
if (!process.env.UNIVERSAL_MAKER_BINARY) {
25+
throw new FirebaseError(
26+
"Please specify the path to your Universal Maker binary by establishing the UNIVERSAL_MAKER_BINARY environment variable.",
27+
);
28+
}
29+
30+
try {
31+
childProcess.spawnSync(
32+
process.env.UNIVERSAL_MAKER_BINARY,
33+
["-application_dir", projectRoot, "-output_dir", projectRoot, "-output_format", "json"],
34+
{
35+
env: {
36+
...process.env,
37+
X_GOOGLE_TARGET_PLATFORM: "fah",
38+
FIREBASE_OUTPUT_BUNDLE_DIR: ".apphosting",
39+
NPM_CONFIG_REGISTRY: "https://registry.npmjs.org/",
40+
},
41+
stdio: "inherit",
42+
},
43+
);
44+
} catch (e) {
45+
if (e && typeof e === "object" && "code" in e && e.code === "EACCES") {
46+
throw new FirebaseError(
47+
"Failed to execute the Universal Maker binary due to permission constraints. Please assure you have set chmod +x on your file.",
48+
);
49+
}
50+
throw e;
51+
}
52+
53+
const outputFilePath = path.join(projectRoot, "build_output.json");
54+
if (!fs.existsSync(outputFilePath)) {
55+
throw new FirebaseError(
56+
`Universal Maker did not produce the expected output file at ${outputFilePath}`,
57+
);
58+
}
59+
60+
const outputRaw = fs.readFileSync(outputFilePath, "utf-8");
61+
let umOutput: UniversalMakerOutput;
62+
try {
63+
umOutput = JSON.parse(outputRaw) as UniversalMakerOutput;
64+
} catch (e) {
65+
throw new FirebaseError(`Failed to parse build_output.json: ${(e as Error).message}`);
66+
}
67+
68+
return {
69+
metadata: {
70+
language: umOutput.language,
71+
runtime: umOutput.runtime,
72+
framework: framework || "nextjs",
73+
},
74+
runConfig: {
75+
runCommand: `${umOutput.command} ${umOutput.args.join(" ")}`,
76+
environmentVariables: Object.entries(umOutput.envVars || {}).map(([k, v]) => ({
77+
variable: k,
78+
value: String(v),
79+
availability: ["RUNTIME"],
80+
})),
81+
},
82+
outputFiles: {
83+
serverApp: {
84+
include: [".apphosting"],
85+
},
86+
},
87+
};
88+
}
89+
90+
export interface AppHostingBuildOutput {
91+
metadata: Record<string, string | number | boolean>;
92+
93+
runConfig: {
94+
runCommand?: string;
95+
environmentVariables?: Array<{
96+
variable: string;
97+
value: string;
98+
availability: string[];
99+
}>;
100+
};
101+
outputFiles?: {
102+
serverApp: {
103+
include: string[];
104+
};
105+
};
106+
}
7107

8108
/**
9109
* Triggers a local build of your App Hosting codebase.
10110
*
11111
* This function orchestrates the build process using the App Hosting build adapter.
112+
*
12113
* It detects the framework (though currently defaults/assumes 'nextjs' in some contexts),
13114
* generates the necessary build artifacts, and returns metadata about the build.
14115
* @param projectId - The project ID to use for resolving secrets.
@@ -62,9 +163,16 @@ export async function localBuild(
62163
process.env[key] = value;
63164
}
64165

65-
let apphostingBuildOutput;
166+
let apphostingBuildOutput: AppHostingBuildOutput;
66167
try {
67-
apphostingBuildOutput = await localAppHostingBuild(projectRoot, framework);
168+
if (experiments.isEnabled("universalMaker")) {
169+
apphostingBuildOutput = runUniversalMaker(projectRoot, framework);
170+
} else {
171+
apphostingBuildOutput = (await localAppHostingBuild(
172+
projectRoot,
173+
framework,
174+
)) as unknown as AppHostingBuildOutput;
175+
}
68176
} finally {
69177
for (const key in process.env) {
70178
if (!(key in originalEnv)) {
@@ -87,7 +195,7 @@ export async function localBuild(
87195
value,
88196
availability,
89197
}),
90-
);
198+
) as unknown as Env[] | undefined;
91199

92200
return {
93201
outputFiles: apphostingBuildOutput.outputFiles?.serverApp.include ?? [],

src/experiments.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,12 @@ export const ALL_EXPERIMENTS = experiments({
148148
default: false,
149149
public: false,
150150
},
151+
universalMaker: {
152+
shortDescription: "Opt-in to Universal Maker standalone binary local builds",
153+
default: false,
154+
public: false,
155+
},
156+
151157
abiu: {
152158
shortDescription: "Enable App Hosting ABIU and runtime selection",
153159
default: false,

0 commit comments

Comments
 (0)