Skip to content

feat: add file-based function discovery mode #1711

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 18 commits into from
Jul 15, 2025
Merged
Show file tree
Hide file tree
Changes from 9 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions scripts/bin-test/sources/broken-syntax/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
const functions = require("firebase-functions");

// This will cause a syntax error
exports.broken = functions.https.onRequest((request, response) => {
response.send("Hello from Firebase!"
}); // Missing closing parenthesis
3 changes: 3 additions & 0 deletions scripts/bin-test/sources/broken-syntax/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"name": "broken-syntax"
}
197 changes: 126 additions & 71 deletions scripts/bin-test/test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,13 @@ const BASE_STACK = {
interface Testcase {
name: string;
modulePath: string;
expected: Record<string, any>;
expected: Record<string, unknown>;
}

interface DiscoveryResult {
success: boolean;
manifest?: Record<string, unknown>;
error?: string;
}

async function retryUntil(
Expand Down Expand Up @@ -134,102 +140,121 @@ async function retryUntil(
await Promise.race([retry, timedOut]);
}

async function startBin(
tc: Testcase,
debug?: boolean
): Promise<{ port: number; cleanup: () => Promise<void> }> {
async function runHttpDiscovery(modulePath: string): Promise<DiscoveryResult> {
const getPort = promisify(portfinder.getPort) as () => Promise<number>;
const port = await getPort();

const proc = subprocess.spawn("npx", ["firebase-functions"], {
cwd: path.resolve(tc.modulePath),
cwd: path.resolve(modulePath),
env: {
PATH: process.env.PATH,
GLCOUD_PROJECT: "test-project",
GCLOUD_PROJECT: "test-project",
PORT: port.toString(),
FUNCTIONS_CONTROL_API: "true",
},
});
if (!proc) {
throw new Error("Failed to start firebase functions");
}
proc.stdout?.on("data", (chunk: Buffer) => {
console.log(chunk.toString("utf8"));
});
proc.stderr?.on("data", (chunk: Buffer) => {
console.log(chunk.toString("utf8"));
});

await retryUntil(async () => {
try {
await fetch(`http://localhost:${port}/__/functions.yaml`);
} catch (e) {
if (e?.code === "ECONNREFUSED") {
return false;
try {
// Wait for server to be ready
await retryUntil(async () => {
try {
await fetch(`http://localhost:${port}/__/functions.yaml`);
return true;
} catch (e: unknown) {
const error = e as { code?: string };
if (error.code === "ECONNREFUSED") {
// This is an expected error during server startup, so we should retry.
return false;
}
// Any other error is unexpected and should fail the test immediately.
throw e;
}
throw e;
}, TIMEOUT_L);

const res = await fetch(`http://localhost:${port}/__/functions.yaml`);
const body = await res.text();

if (res.status === 200) {
const manifest = yaml.load(body) as Record<string, unknown>;
return { success: true, manifest };
} else {
return { success: false, error: body };
}
return true;
}, TIMEOUT_L);
} finally {
proc.kill(9);
}
}

if (debug) {
proc.stdout?.on("data", (data: unknown) => {
console.log(`[${tc.name} stdout] ${data}`);
async function runStdioDiscovery(modulePath: string): Promise<DiscoveryResult> {
return new Promise((resolve, reject) => {
const proc = subprocess.spawn("npx", ["firebase-functions"], {
cwd: path.resolve(modulePath),
env: {
PATH: process.env.PATH,
GCLOUD_PROJECT: "test-project",
FUNCTIONS_CONTROL_API: "true",
FUNCTIONS_DISCOVERY_MODE: "stdio",
},
});

proc.stderr?.on("data", (data: unknown) => {
console.log(`[${tc.name} stderr] ${data}`);
let stderr = "";

proc.stderr?.on("data", (chunk: Buffer) => {
stderr += chunk.toString("utf8");
});
}

return {
port,
cleanup: async () => {
process.kill(proc.pid, 9);
await retryUntil(async () => {
try {
process.kill(proc.pid, 0);
} catch {
// process.kill w/ signal 0 will throw an error if the pid no longer exists.
return Promise.resolve(true);
}
return Promise.resolve(false);
}, TIMEOUT_L);
},
};
}
const timeoutId = setTimeout(() => {
proc.kill(9);
reject(new Error(`Stdio discovery timed out after ${TIMEOUT_M}ms`));
}, TIMEOUT_M);

describe("functions.yaml", function () {
// eslint-disable-next-line @typescript-eslint/no-invalid-this
this.timeout(TIMEOUT_XL);
proc.on("close", () => {
clearTimeout(timeoutId);
const manifestMatch = stderr.match(/__FIREBASE_FUNCTIONS_MANIFEST__:([\s\S]+)/);
if (manifestMatch) {
const base64 = manifestMatch[1];
const manifestJson = Buffer.from(base64, "base64").toString("utf8");
const manifest = JSON.parse(manifestJson) as Record<string, unknown>;
resolve({ success: true, manifest });
return;
}

function runTests(tc: Testcase) {
let port: number;
let cleanup: () => Promise<void>;
const errorMatch = stderr.match(/__FIREBASE_FUNCTIONS_MANIFEST_ERROR__:([\s\S]+)/);
if (errorMatch) {
resolve({ success: false, error: errorMatch[1] });
return;
}

before(async () => {
const r = await startBin(tc);
port = r.port;
cleanup = r.cleanup;
resolve({ success: false, error: "No manifest or error found" });
});

after(async () => {
await cleanup?.();
proc.on("error", (err) => {
clearTimeout(timeoutId);
reject(err);
});
});
}

it("functions.yaml returns expected Manifest", async function () {
describe("functions.yaml", function () {
// eslint-disable-next-line @typescript-eslint/no-invalid-this
this.timeout(TIMEOUT_XL);

const discoveryMethods = [
{ name: "http", fn: runHttpDiscovery },
{ name: "stdio", fn: runStdioDiscovery },
];

function runDiscoveryTests(
tc: Testcase,
discoveryFn: (path: string) => Promise<DiscoveryResult>
) {
it("returns expected manifest", async function () {
// eslint-disable-next-line @typescript-eslint/no-invalid-this
this.timeout(TIMEOUT_M);

const res = await fetch(`http://localhost:${port}/__/functions.yaml`);
const text = await res.text();
let parsed: any;
try {
parsed = yaml.load(text);
} catch (err) {
throw new Error(`Failed to parse functions.yaml: ${err}`);
}
expect(parsed).to.be.deep.equal(tc.expected);
const result = await discoveryFn(tc.modulePath);
expect(result.success).to.be.true;
expect(result.manifest).to.deep.equal(tc.expected);
});
}

Expand Down Expand Up @@ -320,7 +345,11 @@ describe("functions.yaml", function () {

for (const tc of testcases) {
describe(tc.name, () => {
runTests(tc);
for (const discovery of discoveryMethods) {
describe(`${discovery.name} discovery`, () => {
runDiscoveryTests(tc, discovery.fn);
});
}
});
}
});
Expand Down Expand Up @@ -350,7 +379,33 @@ describe("functions.yaml", function () {

for (const tc of testcases) {
describe(tc.name, () => {
runTests(tc);
for (const discovery of discoveryMethods) {
describe(`${discovery.name} discovery`, () => {
runDiscoveryTests(tc, discovery.fn);
});
}
});
}
});

describe("error handling", () => {
const errorTestcases = [
{
name: "broken syntax",
modulePath: "./scripts/bin-test/sources/broken-syntax",
expectedError: "missing ) after argument list",
},
];

for (const tc of errorTestcases) {
describe(tc.name, () => {
for (const discovery of discoveryMethods) {
it(`${discovery.name} discovery handles error correctly`, async () => {
const result = await discovery.fn(tc.modulePath);
expect(result.success).to.be.false;
expect(result.error).to.include(tc.expectedError);
});
}
});
}
});
Expand Down
74 changes: 51 additions & 23 deletions src/bin/firebase-functions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,34 +49,62 @@ if (args.length > 1) {
functionsDir = args[0];
}

let server: http.Server = undefined;
const app = express();
const MANIFEST_PREFIX = "__FIREBASE_FUNCTIONS_MANIFEST__:";
const MANIFEST_ERROR_PREFIX = "__FIREBASE_FUNCTIONS_MANIFEST_ERROR__:";

function handleQuitquitquit(req: express.Request, res: express.Response) {
async function runStdioDiscovery() {
try {
const stack = await loadStack(functionsDir);
const wireFormat = stackToWire(stack);
const manifestJson = JSON.stringify(wireFormat);
const base64 = Buffer.from(manifestJson).toString("base64");
process.stderr.write(`${MANIFEST_PREFIX}${base64}\n`);
process.exitCode = 0;
} catch (e) {
console.error("Failed to generate manifest from function source:", e);
const message = e instanceof Error ? e.message : String(e);
process.stderr.write(`${MANIFEST_ERROR_PREFIX}${message}\n`);
process.exitCode = 1;
}
}

function handleQuitquitquit(req: express.Request, res: express.Response, server: http.Server) {
res.send("ok");
server.close();
}

app.get("/__/quitquitquit", handleQuitquitquit);
app.post("/__/quitquitquit", handleQuitquitquit);
if (
process.env.FUNCTIONS_CONTROL_API === "true" &&
process.env.FUNCTIONS_DISCOVERY_MODE === "stdio"
) {
void runStdioDiscovery();
} else {
let server: http.Server = undefined;
const app = express();

if (process.env.FUNCTIONS_CONTROL_API === "true") {
app.get("/__/functions.yaml", async (req, res) => {
try {
const stack = await loadStack(functionsDir);
res.setHeader("content-type", "text/yaml");
res.send(JSON.stringify(stackToWire(stack)));
} catch (e) {
console.error(e);
res.status(400).send(`Failed to generate manifest from function source: ${e}`);
}
});
}
app.get("/__/quitquitquit", (req, res) => handleQuitquitquit(req, res, server));
app.post("/__/quitquitquit", (req, res) => handleQuitquitquit(req, res, server));

let port = 8080;
if (process.env.PORT) {
port = Number.parseInt(process.env.PORT);
}
if (process.env.FUNCTIONS_CONTROL_API === "true") {
// eslint-disable-next-line @typescript-eslint/no-misused-promises
app.get("/__/functions.yaml", async (req, res) => {
try {
const stack = await loadStack(functionsDir);
res.setHeader("content-type", "text/yaml");
res.send(JSON.stringify(stackToWire(stack)));
} catch (e) {
console.error(e);
const errorMessage = e instanceof Error ? e.message : String(e);
res.status(400).send(`Failed to generate manifest from function source: ${errorMessage}`);
}
});
}

let port = 8080;
if (process.env.PORT) {
port = Number.parseInt(process.env.PORT);
}

console.log("Serving at port", port);
server = app.listen(port);
console.log("Serving at port", port);
server = app.listen(port);
}
Loading