Skip to content

Commit 40f0dad

Browse files
feat: add file-based function discovery mode (#1711)
* 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). * test: add comprehensive tests for stdio discovery - Add test fixture for broken syntax error handling - Refactor tests to use unified DiscoveryResult interface - Parameterize tests with nested loops to test all cases with both discovery methods - Add error handling tests for both HTTP and stdio discovery - All stdio discovery tests passing (16/16) * fix: resolve linting errors in firebase-functions.ts - Mark runStdioDiscovery() promise with void operator - Move handleQuitquitquit function to program root - Fix TypeScript warnings for error handling - Add eslint-disable comment for Express async handler * fix: address PR review comments for stdio discovery - Add proper error handling for non-Error objects - Define MANIFEST_PREFIX and MANIFEST_ERROR_PREFIX constants - Fix HTTP discovery retry logic regression in tests - Make regex patterns multiline-safe for manifest and error parsing - Add timeout handling for stdio discovery in tests * fix: resolve linting errors * fix: clean up broken-syntax fixture to match other fixtures - Remove unnecessary dependencies from package.json - Remove package-lock.json - The npm link is handled by run.sh script * fix: address additional PR review comments - Use process.exitCode instead of process.exit() to prevent race conditions with stderr buffer flushing - Refactor test to define discoveryMethods array once to reduce code duplication * remove extraneous comments * fix: resolve prettier formatting issue * refactor: use XML-style tags for stdio discovery output - Replace magic string prefixes with self-documenting XML tags - Add explicit start/end markers to handle OS buffering edge cases - Make regex parsing more robust and less greedy - Addresses review comment about regex being too greedy * fix: resolve prettier formatting for long regex lines * Update src/bin/firebase-functions.ts Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> * linter. * refactor: replace stdio discovery with file-based manifest output - Remove XML-style stdio discovery mode - Add FUNCTIONS_MANIFEST_OUTPUT_PATH environment variable support - Write manifest directly to specified file path when env var is set - Update tests to use file-based discovery instead of stdio parsing - Simplify error handling with direct stderr output * fix: resolve lint and formatting issues * feat: add robust path validation and error handling for file output - Validate output directory exists and is writable before attempting manifest generation - Provide specific error messages for common failure cases (ENOENT, EACCES) - Check directory permissions early to fail fast with helpful guidance - Improve error messages to help users resolve issues quickly * fix: address PR review comments for test reliability - Wait for process termination after kill(9) to prevent orphaned processes - Use fs.mkdtemp() instead of Date.now() for unique temp directories - Clean up temp directories after tests complete - Add proper error cleanup for temp directories * fix: Gracefully kill child processes in tests Ensures that child processes spawned during tests are properly killed and awaited, preventing orphaned processes in the CI environment. --------- Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
1 parent aec3457 commit 40f0dad

File tree

4 files changed

+218
-91
lines changed

4 files changed

+218
-91
lines changed
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
const functions = require("firebase-functions");
2+
3+
// This will cause a syntax error
4+
exports.broken = functions.https.onRequest((request, response) => {
5+
response.send("Hello from Firebase!"
6+
}); // Missing closing parenthesis
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
{
2+
"name": "broken-syntax"
3+
}

scripts/bin-test/test.ts

Lines changed: 139 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import * as subprocess from "child_process";
22
import * as path from "path";
33
import { promisify } from "util";
4+
import * as fs from "fs/promises";
5+
import * as os from "os";
46

57
import { expect } from "chai";
68
import * as yaml from "js-yaml";
@@ -105,7 +107,13 @@ const BASE_STACK = {
105107
interface Testcase {
106108
name: string;
107109
modulePath: string;
108-
expected: Record<string, any>;
110+
expected: Record<string, unknown>;
111+
}
112+
113+
interface DiscoveryResult {
114+
success: boolean;
115+
manifest?: Record<string, unknown>;
116+
error?: string;
109117
}
110118

111119
async function retryUntil(
@@ -134,102 +142,134 @@ async function retryUntil(
134142
await Promise.race([retry, timedOut]);
135143
}
136144

137-
async function startBin(
138-
tc: Testcase,
139-
debug?: boolean
140-
): Promise<{ port: number; cleanup: () => Promise<void> }> {
145+
async function runHttpDiscovery(modulePath: string): Promise<DiscoveryResult> {
141146
const getPort = promisify(portfinder.getPort) as () => Promise<number>;
142147
const port = await getPort();
143148

144149
const proc = subprocess.spawn("npx", ["firebase-functions"], {
145-
cwd: path.resolve(tc.modulePath),
150+
cwd: path.resolve(modulePath),
146151
env: {
147152
PATH: process.env.PATH,
148-
GLCOUD_PROJECT: "test-project",
153+
GCLOUD_PROJECT: "test-project",
149154
PORT: port.toString(),
150155
FUNCTIONS_CONTROL_API: "true",
151156
},
152157
});
153-
if (!proc) {
154-
throw new Error("Failed to start firebase functions");
155-
}
156-
proc.stdout?.on("data", (chunk: Buffer) => {
157-
console.log(chunk.toString("utf8"));
158-
});
159-
proc.stderr?.on("data", (chunk: Buffer) => {
160-
console.log(chunk.toString("utf8"));
161-
});
162158

163-
await retryUntil(async () => {
164-
try {
165-
await fetch(`http://localhost:${port}/__/functions.yaml`);
166-
} catch (e) {
167-
if (e?.code === "ECONNREFUSED") {
168-
return false;
159+
try {
160+
// Wait for server to be ready
161+
await retryUntil(async () => {
162+
try {
163+
await fetch(`http://localhost:${port}/__/functions.yaml`);
164+
return true;
165+
} catch (e: unknown) {
166+
const error = e as { code?: string };
167+
if (error.code === "ECONNREFUSED") {
168+
// This is an expected error during server startup, so we should retry.
169+
return false;
170+
}
171+
// Any other error is unexpected and should fail the test immediately.
172+
throw e;
169173
}
170-
throw e;
174+
}, TIMEOUT_L);
175+
176+
const res = await fetch(`http://localhost:${port}/__/functions.yaml`);
177+
const body = await res.text();
178+
179+
if (res.status === 200) {
180+
const manifest = yaml.load(body) as Record<string, unknown>;
181+
return { success: true, manifest };
182+
} else {
183+
return { success: false, error: body };
171184
}
172-
return true;
173-
}, TIMEOUT_L);
185+
} finally {
186+
if (proc.pid) {
187+
proc.kill(9);
188+
await new Promise<void>((resolve) => proc.on("exit", resolve));
189+
}
190+
}
191+
}
174192

175-
if (debug) {
176-
proc.stdout?.on("data", (data: unknown) => {
177-
console.log(`[${tc.name} stdout] ${data}`);
193+
async function runFileDiscovery(modulePath: string): Promise<DiscoveryResult> {
194+
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "firebase-functions-test-"));
195+
const outputPath = path.join(tempDir, "manifest.json");
196+
197+
return new Promise((resolve, reject) => {
198+
const proc = subprocess.spawn("npx", ["firebase-functions"], {
199+
cwd: path.resolve(modulePath),
200+
env: {
201+
PATH: process.env.PATH,
202+
GCLOUD_PROJECT: "test-project",
203+
FUNCTIONS_MANIFEST_OUTPUT_PATH: outputPath,
204+
},
178205
});
179206

180-
proc.stderr?.on("data", (data: unknown) => {
181-
console.log(`[${tc.name} stderr] ${data}`);
207+
let stderr = "";
208+
209+
proc.stderr?.on("data", (chunk: Buffer) => {
210+
stderr += chunk.toString("utf8");
182211
});
183-
}
184212

185-
return {
186-
port,
187-
cleanup: async () => {
188-
process.kill(proc.pid, 9);
189-
await retryUntil(async () => {
213+
const timeoutId = setTimeout(async () => {
214+
if (proc.pid) {
215+
proc.kill(9);
216+
await new Promise<void>((resolve) => proc.on("exit", resolve));
217+
}
218+
resolve({ success: false, error: `File discovery timed out after ${TIMEOUT_M}ms` });
219+
}, TIMEOUT_M);
220+
221+
proc.on("close", async (code) => {
222+
clearTimeout(timeoutId);
223+
224+
if (code === 0) {
190225
try {
191-
process.kill(proc.pid, 0);
192-
} catch {
193-
// process.kill w/ signal 0 will throw an error if the pid no longer exists.
194-
return Promise.resolve(true);
226+
const manifestJson = await fs.readFile(outputPath, "utf8");
227+
const manifest = JSON.parse(manifestJson) as Record<string, unknown>;
228+
await fs.rm(tempDir, { recursive: true }).catch(() => {
229+
// Ignore errors
230+
});
231+
resolve({ success: true, manifest });
232+
} catch (e) {
233+
resolve({ success: false, error: `Failed to read manifest file: ${e}` });
195234
}
196-
return Promise.resolve(false);
197-
}, TIMEOUT_L);
198-
},
199-
};
235+
} else {
236+
const errorLines = stderr.split("\n").filter((line) => line.trim());
237+
const errorMessage = errorLines.join(" ") || "No error message found";
238+
resolve({ success: false, error: errorMessage });
239+
}
240+
});
241+
242+
proc.on("error", (err) => {
243+
clearTimeout(timeoutId);
244+
// Clean up temp directory on error
245+
fs.rm(tempDir, { recursive: true }).catch(() => {
246+
// Ignore errors
247+
});
248+
reject(err);
249+
});
250+
});
200251
}
201252

202253
describe("functions.yaml", function () {
203254
// eslint-disable-next-line @typescript-eslint/no-invalid-this
204255
this.timeout(TIMEOUT_XL);
205256

206-
function runTests(tc: Testcase) {
207-
let port: number;
208-
let cleanup: () => Promise<void>;
209-
210-
before(async () => {
211-
const r = await startBin(tc);
212-
port = r.port;
213-
cleanup = r.cleanup;
214-
});
257+
const discoveryMethods = [
258+
{ name: "http", fn: runHttpDiscovery },
259+
{ name: "file", fn: runFileDiscovery },
260+
];
215261

216-
after(async () => {
217-
await cleanup?.();
218-
});
219-
220-
it("functions.yaml returns expected Manifest", async function () {
262+
function runDiscoveryTests(
263+
tc: Testcase,
264+
discoveryFn: (path: string) => Promise<DiscoveryResult>
265+
) {
266+
it("returns expected manifest", async function () {
221267
// eslint-disable-next-line @typescript-eslint/no-invalid-this
222268
this.timeout(TIMEOUT_M);
223269

224-
const res = await fetch(`http://localhost:${port}/__/functions.yaml`);
225-
const text = await res.text();
226-
let parsed: any;
227-
try {
228-
parsed = yaml.load(text);
229-
} catch (err) {
230-
throw new Error(`Failed to parse functions.yaml: ${err}`);
231-
}
232-
expect(parsed).to.be.deep.equal(tc.expected);
270+
const result = await discoveryFn(tc.modulePath);
271+
expect(result.success).to.be.true;
272+
expect(result.manifest).to.deep.equal(tc.expected);
233273
});
234274
}
235275

@@ -320,7 +360,11 @@ describe("functions.yaml", function () {
320360

321361
for (const tc of testcases) {
322362
describe(tc.name, () => {
323-
runTests(tc);
363+
for (const discovery of discoveryMethods) {
364+
describe(`${discovery.name} discovery`, () => {
365+
runDiscoveryTests(tc, discovery.fn);
366+
});
367+
}
324368
});
325369
}
326370
});
@@ -350,7 +394,33 @@ describe("functions.yaml", function () {
350394

351395
for (const tc of testcases) {
352396
describe(tc.name, () => {
353-
runTests(tc);
397+
for (const discovery of discoveryMethods) {
398+
describe(`${discovery.name} discovery`, () => {
399+
runDiscoveryTests(tc, discovery.fn);
400+
});
401+
}
402+
});
403+
}
404+
});
405+
406+
describe("error handling", () => {
407+
const errorTestcases = [
408+
{
409+
name: "broken syntax",
410+
modulePath: "./scripts/bin-test/sources/broken-syntax",
411+
expectedError: "missing ) after argument list",
412+
},
413+
];
414+
415+
for (const tc of errorTestcases) {
416+
describe(tc.name, () => {
417+
for (const discovery of discoveryMethods) {
418+
it(`${discovery.name} discovery handles error correctly`, async () => {
419+
const result = await discovery.fn(tc.modulePath);
420+
expect(result.success).to.be.false;
421+
expect(result.error).to.include(tc.expectedError);
422+
});
423+
}
354424
});
355425
}
356426
});

src/bin/firebase-functions.ts

Lines changed: 70 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@
2424

2525
import * as http from "http";
2626
import * as express from "express";
27+
import * as fs from "fs/promises";
28+
import * as path from "path";
2729
import { loadStack } from "../runtime/loader";
2830
import { stackToWire } from "../runtime/manifest";
2931

@@ -49,34 +51,80 @@ if (args.length > 1) {
4951
functionsDir = args[0];
5052
}
5153

52-
let server: http.Server = undefined;
53-
const app = express();
54-
55-
function handleQuitquitquit(req: express.Request, res: express.Response) {
54+
function handleQuitquitquit(req: express.Request, res: express.Response, server: http.Server) {
5655
res.send("ok");
5756
server.close();
5857
}
5958

60-
app.get("/__/quitquitquit", handleQuitquitquit);
61-
app.post("/__/quitquitquit", handleQuitquitquit);
62-
63-
if (process.env.FUNCTIONS_CONTROL_API === "true") {
64-
app.get("/__/functions.yaml", async (req, res) => {
59+
if (process.env.FUNCTIONS_MANIFEST_OUTPUT_PATH) {
60+
void (async () => {
61+
const outputPath = process.env.FUNCTIONS_MANIFEST_OUTPUT_PATH;
6562
try {
63+
// Validate the output path
64+
const dir = path.dirname(outputPath);
65+
try {
66+
await fs.access(dir, fs.constants.W_OK);
67+
} catch (e) {
68+
console.error(
69+
`Error: Cannot write to directory '${dir}': ${e instanceof Error ? e.message : String(e)}`
70+
);
71+
console.error("Please ensure the directory exists and you have write permissions.");
72+
process.exit(1);
73+
}
74+
6675
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}`);
76+
const wireFormat = stackToWire(stack);
77+
await fs.writeFile(outputPath, JSON.stringify(wireFormat, null, 2));
78+
process.exit(0);
79+
} catch (e: any) {
80+
if (e.code === "ENOENT") {
81+
console.error(`Error: Directory '${path.dirname(outputPath)}' does not exist.`);
82+
console.error("Please create the directory or specify a valid path.");
83+
} else if (e.code === "EACCES") {
84+
console.error(`Error: Permission denied writing to '${outputPath}'.`);
85+
console.error("Please check file permissions or choose a different location.");
86+
} else if (e.message?.includes("Failed to generate manifest")) {
87+
console.error(e.message);
88+
} else {
89+
console.error(
90+
`Failed to generate manifest from function source: ${
91+
e instanceof Error ? e.message : String(e)
92+
}`
93+
);
94+
}
95+
if (e instanceof Error && e.stack) {
96+
console.error(e.stack);
97+
}
98+
process.exit(1);
7299
}
73-
});
74-
}
100+
})();
101+
} else {
102+
let server: http.Server = undefined;
103+
const app = express();
75104

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

81-
console.log("Serving at port", port);
82-
server = app.listen(port);
108+
if (process.env.FUNCTIONS_CONTROL_API === "true") {
109+
// eslint-disable-next-line @typescript-eslint/no-misused-promises
110+
app.get("/__/functions.yaml", async (req, res) => {
111+
try {
112+
const stack = await loadStack(functionsDir);
113+
res.setHeader("content-type", "text/yaml");
114+
res.send(JSON.stringify(stackToWire(stack)));
115+
} catch (e) {
116+
console.error(e);
117+
const errorMessage = e instanceof Error ? e.message : String(e);
118+
res.status(400).send(`Failed to generate manifest from function source: ${errorMessage}`);
119+
}
120+
});
121+
}
122+
123+
let port = 8080;
124+
if (process.env.PORT) {
125+
port = Number.parseInt(process.env.PORT);
126+
}
127+
128+
console.log("Serving at port", port);
129+
server = app.listen(port);
130+
}

0 commit comments

Comments
 (0)