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 all 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
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ firebase-debug.log
firebase-debug.*.log
npm-debug.log
ui-debug.log
test_output.log
scripts/emulator-tests/functions/index.js
yarn.lock
.npmrc

Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
- Added prefix support for multi-instance Cloud Functions extension parameters. (#8911)
- Fixed a bug when `firebase deploy --only dataconnect` doesn't include GQL in nested folders (#8981)
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
46 changes: 46 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,52 @@
}).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",
};

const prefixEmu = 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 });
},
),
};
});

try {
await registry.EmulatorRegistry.start(prefixEmu);
await prefixEmu.connect();

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

await supertest(prefixEmu.createHubServer())
.get(`/${TEST_PROJECT_ID}/us-central1/prefix-two-functionId`)
.expect(200);
} finally {
await registry.EmulatorRegistry.stop(Emulators.FUNCTIONS);
}
});

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

Expand Down
117 changes: 117 additions & 0 deletions src/deploy/functions/build.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -293,3 +293,120 @@ 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"]);
});

it("should prefix secret names in secretEnvironmentVariables", () => {
const testBuild: build.Build = {
endpoints: {
func1: {
region: "us-central1",
project: "test-project",
platform: "gcfv2",
runtime: "nodejs18",
entryPoint: "func1",
httpsTrigger: {},
secretEnvironmentVariables: [
{ key: "API_KEY", secret: "api-secret", projectId: "test-project" },
{ key: "DB_PASSWORD", secret: "db-secret", projectId: "test-project" },
],
},
func2: {
region: "us-west1",
project: "test-project",
platform: "gcfv1",
runtime: "nodejs16",
entryPoint: "func2",
httpsTrigger: {},
secretEnvironmentVariables: [
{ key: "SERVICE_TOKEN", secret: "service-secret", projectId: "test-project" },
],
},
},
params: [],
requiredAPIs: [],
};

build.applyPrefix(testBuild, "staging");

expect(Object.keys(testBuild.endpoints).sort()).to.deep.equal([
"staging-func1",
"staging-func2",
]);
expect(testBuild.endpoints["staging-func1"].secretEnvironmentVariables).to.deep.equal([
{ key: "API_KEY", secret: "staging-api-secret", projectId: "test-project" },
{ key: "DB_PASSWORD", secret: "staging-db-secret", projectId: "test-project" },
]);
expect(testBuild.endpoints["staging-func2"].secretEnvironmentVariables).to.deep.equal([
{ key: "SERVICE_TOKEN", secret: "staging-service-secret", projectId: "test-project" },
]);
});

it("throws if combined function id exceeds 63 characters", () => {
const longId = "a".repeat(34); // with 30-char prefix + dash = 65 total
const testBuild: build.Build = build.of({
[longId]: {
region: "us-central1",
project: "test-project",
platform: "gcfv2",
runtime: "nodejs18",
entryPoint: longId,
httpsTrigger: {},
},
});
const longPrefix = "p".repeat(30);
expect(() => build.applyPrefix(testBuild, longPrefix)).to.throw(/exceeds 63 characters/);
});

it("throws if prefix makes function id invalid (must start with a letter)", () => {
const testBuild: build.Build = build.of({
func: {
region: "us-central1",
project: "test-project",
platform: "gcfv2",
runtime: "nodejs18",
entryPoint: "func",
httpsTrigger: {},
},
});
expect(() => build.applyPrefix(testBuild, "1abc")).to.throw(
/Function names must start with a letter/,
);
});
});
41 changes: 41 additions & 0 deletions src/deploy/functions/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -321,6 +321,9 @@ export async function resolveBackend(
}

// Exported for testing
/**
*
*/
export function envWithTypes(
definedParams: params.Param[],
rawEnvs: Record<string, string>,
Expand Down Expand Up @@ -651,3 +654,41 @@ function discoverTrigger(endpoint: Endpoint, region: string, r: Resolver): backe
}
assertExhaustive(endpoint);
}

/**
* Prefixes all endpoint IDs and secret names in a build with a given prefix.
* This ensures that functions and their associated secrets from different codebases
* remain isolated and don't conflict when deployed to the same project.
*/
export function applyPrefix(build: Build, prefix: string): void {
if (!prefix) {
return;
}
const newEndpoints: Record<string, Endpoint> = {};
for (const [id, endpoint] of Object.entries(build.endpoints)) {
const newId = `${prefix}-${id}`;

// Enforce function id constraints early for clearer errors.
if (newId.length > 63) {
throw new FirebaseError(
`Function id '${newId}' exceeds 63 characters after applying prefix '${prefix}'. Please shorten the prefix or function name.`,
);
}
const fnIdRegex = /^[a-zA-Z][a-zA-Z0-9_-]{0,62}$/;
if (!fnIdRegex.test(newId)) {
throw new FirebaseError(
`Function id '${newId}' is invalid after applying prefix '${prefix}'. Function names must start with a letter and can contain letters, numbers, underscores, and hyphens, with a maximum length of 63 characters.`,
);
}

newEndpoints[newId] = endpoint;

if (endpoint.secretEnvironmentVariables) {
endpoint.secretEnvironmentVariables = endpoint.secretEnvironmentVariables.map((secret) => ({
...secret,
secret: `${prefix}-${secret.secret}`,
}));
}
}
build.endpoints = newEndpoints;
}
Loading
Loading