Skip to content

Commit e0d6028

Browse files
committed
feat: add stdio-based function discovery mode
Adds an alternative discovery mechanism that outputs function manifests via stderr instead of starting an HTTP server. This improves reliability by avoiding issues where module loading blocks the HTTP endpoint from becoming available. When FUNCTIONS_DISCOVERY_MODE=stdio is set: - Outputs base64-encoded manifest to stderr with __FIREBASE_FUNCTIONS_MANIFEST__: prefix - Outputs errors to stderr with __FIREBASE_FUNCTIONS_MANIFEST_ERROR__: prefix - Exits immediately without starting HTTP server - Maintains backward compatibility (HTTP remains default) Includes comprehensive tests that verify both HTTP and stdio discovery work correctly for all test cases (commonjs, esm, various configurations).
1 parent 534505c commit e0d6028

File tree

2 files changed

+154
-29
lines changed

2 files changed

+154
-29
lines changed

scripts/bin-test/test.ts

Lines changed: 109 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -199,11 +199,46 @@ async function startBin(
199199
};
200200
}
201201

202+
async function runStdioDiscovery(
203+
modulePath: string
204+
): Promise<{ stdout: string; stderr: string; exitCode: number | null }> {
205+
return new Promise((resolve, reject) => {
206+
const proc = subprocess.spawn("npx", ["firebase-functions"], {
207+
cwd: path.resolve(modulePath),
208+
env: {
209+
PATH: process.env.PATH,
210+
GCLOUD_PROJECT: "test-project",
211+
FUNCTIONS_CONTROL_API: "true",
212+
FUNCTIONS_DISCOVERY_MODE: "stdio",
213+
},
214+
});
215+
216+
let stdout = "";
217+
let stderr = "";
218+
219+
proc.stdout?.on("data", (chunk: Buffer) => {
220+
stdout += chunk.toString("utf8");
221+
});
222+
223+
proc.stderr?.on("data", (chunk: Buffer) => {
224+
stderr += chunk.toString("utf8");
225+
});
226+
227+
proc.on("close", (code) => {
228+
resolve({ stdout, stderr, exitCode: code });
229+
});
230+
231+
proc.on("error", (err) => {
232+
reject(err);
233+
});
234+
});
235+
}
236+
202237
describe("functions.yaml", function () {
203238
// eslint-disable-next-line @typescript-eslint/no-invalid-this
204239
this.timeout(TIMEOUT_XL);
205240

206-
function runTests(tc: Testcase) {
241+
function runHttpDiscoveryTests(tc: Testcase) {
207242
let port: number;
208243
let cleanup: () => Promise<void>;
209244

@@ -233,6 +268,31 @@ describe("functions.yaml", function () {
233268
});
234269
}
235270

271+
function runStdioDiscoveryTests(tc: Testcase) {
272+
it("discovers functions via stdio", async function () {
273+
// eslint-disable-next-line @typescript-eslint/no-invalid-this
274+
this.timeout(TIMEOUT_M);
275+
276+
const result = await runStdioDiscovery(tc.modulePath);
277+
278+
// Should exit successfully
279+
expect(result.exitCode).to.equal(0);
280+
281+
// Should not start HTTP server
282+
expect(result.stdout).to.not.contain("Serving at port");
283+
284+
// Should output manifest to stderr
285+
const manifestMatch = result.stderr.match(/__FIREBASE_FUNCTIONS_MANIFEST__:(.+)/);
286+
expect(manifestMatch).to.not.be.null;
287+
288+
// Decode and verify manifest
289+
const base64 = manifestMatch![1];
290+
const manifestJson = Buffer.from(base64, "base64").toString("utf8");
291+
const manifest = JSON.parse(manifestJson);
292+
expect(manifest).to.deep.equal(tc.expected);
293+
});
294+
}
295+
236296
describe("commonjs", function () {
237297
// eslint-disable-next-line @typescript-eslint/no-invalid-this
238298
this.timeout(TIMEOUT_L);
@@ -320,7 +380,13 @@ describe("functions.yaml", function () {
320380

321381
for (const tc of testcases) {
322382
describe(tc.name, () => {
323-
runTests(tc);
383+
describe("http discovery", () => {
384+
runHttpDiscoveryTests(tc);
385+
});
386+
387+
describe("stdio discovery", () => {
388+
runStdioDiscoveryTests(tc);
389+
});
324390
});
325391
}
326392
});
@@ -350,8 +416,48 @@ describe("functions.yaml", function () {
350416

351417
for (const tc of testcases) {
352418
describe(tc.name, () => {
353-
runTests(tc);
419+
describe("http discovery", () => {
420+
runHttpDiscoveryTests(tc);
421+
});
422+
423+
describe("stdio discovery", () => {
424+
runStdioDiscoveryTests(tc);
425+
});
354426
});
355427
}
356428
});
429+
430+
describe("stdio discovery error handling", function () {
431+
it("outputs error for broken module", async function () {
432+
// Create a temporary broken module
433+
const fs = require("fs");
434+
const brokenModulePath = path.join(__dirname, "temp-broken-module");
435+
436+
try {
437+
// Create directory and files
438+
fs.mkdirSync(brokenModulePath, { recursive: true });
439+
fs.writeFileSync(
440+
path.join(brokenModulePath, "package.json"),
441+
JSON.stringify({ name: "broken-module", main: "index.js" })
442+
);
443+
fs.writeFileSync(
444+
path.join(brokenModulePath, "index.js"),
445+
"const functions = require('firebase-functions');\nsyntax error here"
446+
);
447+
448+
const result = await runStdioDiscovery(brokenModulePath);
449+
450+
// Should exit with error
451+
expect(result.exitCode).to.equal(1);
452+
453+
// Should output error to stderr
454+
const errorMatch = result.stderr.match(/__FIREBASE_FUNCTIONS_MANIFEST_ERROR__:(.+)/);
455+
expect(errorMatch).to.not.be.null;
456+
expect(errorMatch![1]).to.contain("Unexpected identifier");
457+
} finally {
458+
// Cleanup
459+
fs.rmSync(brokenModulePath, { recursive: true, force: true });
460+
}
461+
});
462+
});
357463
});

src/bin/firebase-functions.ts

Lines changed: 45 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -49,34 +49,53 @@ if (args.length > 1) {
4949
functionsDir = args[0];
5050
}
5151

52-
let server: http.Server = undefined;
53-
const app = express();
54-
55-
function handleQuitquitquit(req: express.Request, res: express.Response) {
56-
res.send("ok");
57-
server.close();
52+
async function runStdioDiscovery() {
53+
try {
54+
const stack = await loadStack(functionsDir);
55+
const wireFormat = stackToWire(stack);
56+
const manifestJson = JSON.stringify(wireFormat);
57+
const base64 = Buffer.from(manifestJson).toString("base64");
58+
process.stderr.write(`__FIREBASE_FUNCTIONS_MANIFEST__:${base64}\n`);
59+
process.exit(0);
60+
} catch (e) {
61+
console.error("Failed to generate manifest from function source:", e);
62+
process.stderr.write(`__FIREBASE_FUNCTIONS_MANIFEST_ERROR__:${e.message}\n`);
63+
process.exit(1);
64+
}
5865
}
5966

60-
app.get("/__/quitquitquit", handleQuitquitquit);
61-
app.post("/__/quitquitquit", handleQuitquitquit);
67+
if (process.env.FUNCTIONS_CONTROL_API === "true" && process.env.FUNCTIONS_DISCOVERY_MODE === "stdio") {
68+
runStdioDiscovery();
69+
} else {
70+
let server: http.Server = undefined;
71+
const app = express();
6272

63-
if (process.env.FUNCTIONS_CONTROL_API === "true") {
64-
app.get("/__/functions.yaml", async (req, res) => {
65-
try {
66-
const stack = await loadStack(functionsDir);
67-
res.setHeader("content-type", "text/yaml");
68-
res.send(JSON.stringify(stackToWire(stack)));
69-
} catch (e) {
70-
console.error(e);
71-
res.status(400).send(`Failed to generate manifest from function source: ${e}`);
72-
}
73-
});
74-
}
73+
function handleQuitquitquit(req: express.Request, res: express.Response) {
74+
res.send("ok");
75+
server.close();
76+
}
7577

76-
let port = 8080;
77-
if (process.env.PORT) {
78-
port = Number.parseInt(process.env.PORT);
79-
}
78+
app.get("/__/quitquitquit", handleQuitquitquit);
79+
app.post("/__/quitquitquit", handleQuitquitquit);
8080

81-
console.log("Serving at port", port);
82-
server = app.listen(port);
81+
if (process.env.FUNCTIONS_CONTROL_API === "true") {
82+
app.get("/__/functions.yaml", async (req, res) => {
83+
try {
84+
const stack = await loadStack(functionsDir);
85+
res.setHeader("content-type", "text/yaml");
86+
res.send(JSON.stringify(stackToWire(stack)));
87+
} catch (e) {
88+
console.error(e);
89+
res.status(400).send(`Failed to generate manifest from function source: ${e}`);
90+
}
91+
});
92+
}
93+
94+
let port = 8080;
95+
if (process.env.PORT) {
96+
port = Number.parseInt(process.env.PORT);
97+
}
98+
99+
console.log("Serving at port", port);
100+
server = app.listen(port);
101+
}

0 commit comments

Comments
 (0)