Skip to content

feat: Add support for function prefixes #8911

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 24 commits into from
Aug 19, 2025
Merged
Show file tree
Hide file tree
Changes from 11 commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
347fa56
feat: Support multi-instance function deployments
google-labs-jules[bot] Jul 30, 2025
78949b3
feat: Add validation for function prefixes and source/prefix pairs
taeold Jul 30, 2025
57f6250
Update src/functions/projectConfig.ts
taeold Jul 30, 2025
09aea9f
refactor: Improve validation logic and test clarity
taeold Jul 30, 2025
2de1802
fix: refactor prefix processing at one level higher.
taeold Jul 30, 2025
fbe84b7
fix: Show clear error when Functions emulator fails to start due to c…
taeold Aug 14, 2025
8de3c66
fix: ts error.
taeold Aug 14, 2025
4b2bc0d
style: run formatter.
taeold Aug 14, 2025
1478f66
Merge remote-tracking branch 'origin' into feat-multi-instance-functions
taeold Aug 14, 2025
95964cb
feat: Add applyPrefix function for multi-instance functions
taeold Aug 14, 2025
a7697b5
refactor: improve applyPrefix tests and remove redundant assignment
taeold Aug 14, 2025
c54603a
feat: Apply prefix to secret names in multi-instance functions
taeold Aug 15, 2025
556ecfe
Merge branch 'master' into feat-multi-instance-functions
taeold Aug 18, 2025
3220384
functions: add prefix validation and ID guard; apply prefix to secret…
taeold Aug 18, 2025
3c90c9d
fix: regenerate firebase.json schema.
taeold Aug 18, 2025
0d88c10
fix: fix emulator integration test.
taeold Aug 18, 2025
1a0479f
fix: emulator test for prefix functionality and remove auto-generated…
taeold Aug 18, 2025
99fe883
fix: remove auto-generated scripts/emulator-tests/functions/index.js
taeold Aug 18, 2025
0d2cc63
docs: add helpful comments to FunctionConfig properties
taeold Aug 18, 2025
6f3149b
docs: update applyPrefix comment to mention secret name prefixing
taeold Aug 18, 2025
9778139
fix: fix emulator integration test and prevent process hanging
taeold Aug 18, 2025
673a058
fix: address PR feedback for multi-instance functions support
taeold Aug 19, 2025
e674ce9
Merge branch 'master' into feat-multi-instance-functions
taeold Aug 19, 2025
24c971f
style: run formatter.
taeold Aug 19, 2025
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
3 changes: 3 additions & 0 deletions schema/firebase-config.json
Original file line number Diff line number Diff line change
Expand Up @@ -279,6 +279,9 @@
}
]
},
"prefix": {
"type": "string"
},
"runtime": {
"enum": [
"nodejs18",
Expand Down
42 changes: 42 additions & 0 deletions scripts/emulator-tests/functionsEmulator.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
import * as secretManager from "../../src/gcp/secretManager";

if ((process.env.DEBUG || "").toLowerCase().includes("spec")) {
const dropLogLevels = (info: logform.TransformableInfo) => info.message;

Check warning on line 22 in scripts/emulator-tests/functionsEmulator.spec.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Missing return type on function
logger.add(
new winston.transports.Console({
level: "debug",
Expand Down Expand Up @@ -49,7 +49,7 @@
// bin: path.join(MODULE_ROOT, "node_modules/.bin/ts-node"),
};

async function setupEnvFiles(envs: Record<string, string>) {

Check warning on line 52 in scripts/emulator-tests/functionsEmulator.spec.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Missing return type on function
const envFiles: string[] = [];
for (const [filename, data] of Object.entries(envs)) {
const envPath = path.join(FUNCTIONS_DIR, filename);
Expand Down Expand Up @@ -760,6 +760,48 @@
}).timeout(TIMEOUT_MED);
});

it("should support multiple codebases with the same source and apply prefixes", async () => {
const backend1: EmulatableBackend = {
...TEST_BACKEND,
codebase: "one",
prefix: "prefix-one",
};
const backend2: EmulatableBackend = {
...TEST_BACKEND,
codebase: "two",
prefix: "prefix-two",
};

emu = new FunctionsEmulator({
projectId: TEST_PROJECT_ID,
projectDir: MODULE_ROOT,
emulatableBackends: [backend1, backend2],
verbosity: "QUIET",
debugPort: false,
});

await writeSource(() => {
return {
functionId: require("firebase-functions").https.onRequest(
(req: express.Request, res: express.Response) => {
res.json({ path: req.path });
},
),
};
});

await emu.start();
await emu.connect();

await supertest(emu.createHubServer())
.get(`/${TEST_PROJECT_ID}/us-central1/prefix-one-functionId`)
.expect(200);

await supertest(emu.createHubServer())
.get(`/${TEST_PROJECT_ID}/us-central1/prefix-two-functionId`)
.expect(200);
});

describe("user-defined environment variables", () => {
let cleanup: (() => Promise<void>) | undefined;

Expand Down
39 changes: 39 additions & 0 deletions src/deploy/functions/build.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -293,3 +293,42 @@ describe("envWithType", () => {
expect(out.WHOOPS_SECRET.asString()).to.equal("super-secret");
});
});

describe("applyPrefix", () => {
const createTestBuild = (): build.Build => ({
endpoints: {
func1: {
region: "us-central1",
project: "test-project",
platform: "gcfv2",
runtime: "nodejs18",
entryPoint: "func1",
httpsTrigger: {},
},
func2: {
region: "us-west1",
project: "test-project",
platform: "gcfv1",
runtime: "nodejs16",
entryPoint: "func2",
httpsTrigger: {},
},
},
params: [],
requiredAPIs: [],
});

it("should update endpoint keys with prefix", () => {
const testBuild = createTestBuild();
build.applyPrefix(testBuild, "test");
expect(Object.keys(testBuild.endpoints).sort()).to.deep.equal(["test-func1", "test-func2"]);
expect(testBuild.endpoints["test-func1"].entryPoint).to.equal("func1");
expect(testBuild.endpoints["test-func2"].entryPoint).to.equal("func2");
});

it("should do nothing for an empty prefix", () => {
const testBuild = createTestBuild();
build.applyPrefix(testBuild, "");
expect(Object.keys(testBuild.endpoints).sort()).to.deep.equal(["func1", "func2"]);
});
});
14 changes: 14 additions & 0 deletions src/deploy/functions/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -650,3 +650,17 @@ function discoverTrigger(endpoint: Endpoint, region: string, r: Resolver): backe
}
assertExhaustive(endpoint);
}

/**
* Prefixes all endpoint IDs in a build with a given prefix.
*/
export function applyPrefix(build: Build, prefix: string): void {
if (!prefix) {
return;
}
const newEndpoints: Record<string, Endpoint> = {};
for (const id of Object.keys(build.endpoints)) {
newEndpoints[`${prefix}-${id}`] = build.endpoints[id];
}
build.endpoints = newEndpoints;
}
74 changes: 66 additions & 8 deletions src/deploy/functions/prepare.spec.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,17 @@
import { expect } from "chai";

import * as backend from "./backend";
import * as sinon from "sinon";
import * as build from "./build";
import * as prepare from "./prepare";
import * as runtimes from "./runtimes";
import { RuntimeDelegate } from "./runtimes";
import { FirebaseError } from "../../error";
import { Options } from "../../options";
import { ValidatedConfig } from "../../functions/projectConfig";
import * as backend from "./backend";
import * as ensureApiEnabled from "../../ensureApiEnabled";
import * as serviceusage from "../../gcp/serviceusage";
import { BEFORE_CREATE_EVENT, BEFORE_SIGN_IN_EVENT } from "../../functions/events/v1";
import * as sinon from "sinon";
import * as prompt from "../../prompt";
import { FirebaseError } from "../../error";

describe("prepare", () => {
const ENDPOINT_BASE: Omit<backend.Endpoint, "httpsTrigger"> = {
Expand All @@ -16,14 +20,68 @@ describe("prepare", () => {
region: "region",
project: "project",
entryPoint: "entry",
runtime: "nodejs16",
runtime: "nodejs22",
};

const ENDPOINT: backend.Endpoint = {
...ENDPOINT_BASE,
httpsTrigger: {},
};

describe("loadCodebases", () => {
let sandbox: sinon.SinonSandbox;
let runtimeDelegateStub: RuntimeDelegate;
let discoverBuildStub: sinon.SinonStub;

beforeEach(() => {
sandbox = sinon.createSandbox();
discoverBuildStub = sandbox.stub();
runtimeDelegateStub = {
language: "nodejs",
runtime: "nodejs22",
bin: "node",
validate: sandbox.stub().resolves(),
build: sandbox.stub().resolves(),
watch: sandbox.stub().resolves(() => Promise.resolve()),
discoverBuild: discoverBuildStub,
};
discoverBuildStub.resolves(
build.of({
test: {
platform: "gcfv2",
entryPoint: "test",
project: "project",
runtime: "nodejs22",
httpsTrigger: {},
},
}),
);
sandbox.stub(runtimes, "getRuntimeDelegate").resolves(runtimeDelegateStub);
});

afterEach(() => {
sandbox.restore();
});

it("should apply the prefix to the function name", async () => {
const config: ValidatedConfig = [
{ source: "source", codebase: "codebase", prefix: "my-prefix", runtime: "nodejs22" },
];
const options = {
config: {
path: (p: string) => p,
},
projectId: "project",
} as unknown as Options;
const firebaseConfig = { projectId: "project" };
const runtimeConfig = {};

const builds = await prepare.loadCodebases(config, options, firebaseConfig, runtimeConfig);

expect(Object.keys(builds.codebase.endpoints)).to.deep.equal(["my-prefix-test"]);
});
});

describe("inferDetailsFromExisting", () => {
it("merges env vars if .env is not used", () => {
const oldE = {
Expand Down Expand Up @@ -304,7 +362,7 @@ describe("prepare", () => {
region: "us-central1",
project: "project",
entryPoint: "entry",
runtime: "nodejs16",
runtime: "nodejs22",
httpsTrigger: {},
};

Expand All @@ -314,7 +372,7 @@ describe("prepare", () => {
region: "us-central1",
project: "project",
entryPoint: "entry",
runtime: "nodejs16",
runtime: "nodejs22",
callableTrigger: {
genkitAction: "action",
},
Expand All @@ -333,7 +391,7 @@ describe("prepare", () => {
region: "us-central1",
project: "project",
entryPoint: "entry",
runtime: "nodejs16",
runtime: "nodejs22",
callableTrigger: {
genkitAction: "action",
},
Expand Down
7 changes: 4 additions & 3 deletions src/deploy/functions/prepare.ts
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,7 @@ export async function prepare(
}

for (const endpoint of backend.allEndpoints(wantBackend)) {
endpoint.environmentVariables = { ...wantBackend.environmentVariables } || {};
endpoint.environmentVariables = { ...(wantBackend.environmentVariables || {}) };
let resource: string;
if (endpoint.platform === "gcfv1") {
resource = `projects/${endpoint.project}/locations/${endpoint.region}/functions/${endpoint.id}`;
Expand Down Expand Up @@ -475,14 +475,15 @@ export async function loadCodebases(
"functions",
`Loading and analyzing source code for codebase ${codebase} to determine what to deploy`,
);
wantBuilds[codebase] = await runtimeDelegate.discoverBuild(runtimeConfig, {
const discoveredBuild = await runtimeDelegate.discoverBuild(runtimeConfig, {
...firebaseEnvs,
// Quota project is required when using GCP's Client-based APIs
// Some GCP client SDKs, like Vertex AI, requires appropriate quota project setup
// in order for .init() calls to succeed.
GOOGLE_CLOUD_QUOTA_PROJECT: projectId,
});
wantBuilds[codebase].runtime = codebaseConfig.runtime;
build.applyPrefix(discoveredBuild, codebaseConfig.prefix || "");
wantBuilds[codebase] = discoveredBuild;
}
return wantBuilds;
}
Expand Down
9 changes: 4 additions & 5 deletions src/emulator/controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -170,18 +170,16 @@ export function shouldStart(options: Options, name: Emulators): boolean {
);
}

// Don't start the functions emulator if we can't find the source directory
// Don't start the functions emulator if we can't validate the functions config
if (name === Emulators.FUNCTIONS && emulatorInTargets) {
try {
normalizeAndValidate(options.config.src.functions);
return true;
} catch (err: any) {
EmulatorLogger.forEmulator(Emulators.FUNCTIONS).logLabeled(
"WARN",
"ERROR",
"functions",
`The functions emulator is configured but there is no functions source directory. Have you run ${clc.bold(
"firebase init functions",
)}?`,
`Failed to start Functions emulator: ${err.message}`,
);
return false;
}
Expand Down Expand Up @@ -544,6 +542,7 @@ export async function startAll(
functionsDir,
runtime,
codebase: cfg.codebase,
prefix: cfg.prefix,
env: {
...options.extDevEnv,
},
Expand Down
5 changes: 4 additions & 1 deletion src/emulator/functionsEmulator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,8 +53,9 @@ import {
import { functionIdsAreValid } from "../deploy/functions/validate";
import { Extension, ExtensionSpec, ExtensionVersion } from "../extensions/types";
import { accessSecretVersion } from "../gcp/secretManager";
import * as runtimes from "../deploy/functions/runtimes";
import * as backend from "../deploy/functions/backend";
import * as build from "../deploy/functions/build";
import * as runtimes from "../deploy/functions/runtimes";
import * as functionsEnv from "../functions/env";
import { AUTH_BLOCKING_EVENTS, BEFORE_CREATE_EVENT } from "../functions/events/v1";
import { BlockingFunctionsConfig } from "../gcp/identityPlatform";
Expand Down Expand Up @@ -87,6 +88,7 @@ export interface EmulatableBackend {
env: Record<string, string>;
secretEnv: backend.SecretEnvVar[];
codebase: string;
prefix?: string;
predefinedTriggers?: ParsedTriggerDefinition[];
runtime?: Runtime;
bin?: string;
Expand Down Expand Up @@ -563,6 +565,7 @@ export class FunctionsEmulator implements EmulatorInstance {
);
await this.loadDynamicExtensionBackends();
}
build.applyPrefix(discoveredBuild, emulatableBackend.prefix || "");
const resolution = await resolveBackend({
build: discoveredBuild,
firebaseConfig: JSON.parse(firebaseConfig),
Expand Down
1 change: 1 addition & 0 deletions src/firebaseConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,7 @@ export type FunctionConfig = {
ignore?: string[];
runtime?: ActiveRuntime;
codebase?: string;
prefix?: string;
} & Deployable;

export type FunctionsConfig = FunctionConfig | FunctionConfig[];
Expand Down
Loading
Loading