diff --git a/schema/firebase-config.json b/schema/firebase-config.json index 9eb0f6a618f..5bc1a802dc7 100644 --- a/schema/firebase-config.json +++ b/schema/firebase-config.json @@ -276,6 +276,9 @@ } ] }, + "prefix": { + "type": "string" + }, "runtime": { "enum": [ "nodejs18", diff --git a/scripts/emulator-tests/functionsEmulator.spec.ts b/scripts/emulator-tests/functionsEmulator.spec.ts index fc1e04ecd77..1e10fee9ef6 100644 --- a/scripts/emulator-tests/functionsEmulator.spec.ts +++ b/scripts/emulator-tests/functionsEmulator.spec.ts @@ -760,6 +760,48 @@ describe("FunctionsEmulator", function () { }).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) | undefined; diff --git a/src/deploy/functions/prepare.spec.ts b/src/deploy/functions/prepare.spec.ts index 0cb7a35a8c9..6bdba52ca6a 100644 --- a/src/deploy/functions/prepare.spec.ts +++ b/src/deploy/functions/prepare.spec.ts @@ -1,13 +1,18 @@ 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 { RUNTIMES } from "./runtimes/supported"; +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 = { @@ -16,7 +21,7 @@ describe("prepare", () => { region: "region", project: "project", entryPoint: "entry", - runtime: "nodejs16", + runtime: "nodejs22", }; const ENDPOINT: backend.Endpoint = { @@ -24,6 +29,60 @@ describe("prepare", () => { 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 = { @@ -304,7 +363,7 @@ describe("prepare", () => { region: "us-central1", project: "project", entryPoint: "entry", - runtime: "nodejs16", + runtime: "nodejs22", httpsTrigger: {}, }; @@ -314,7 +373,7 @@ describe("prepare", () => { region: "us-central1", project: "project", entryPoint: "entry", - runtime: "nodejs16", + runtime: "nodejs22", callableTrigger: { genkitAction: "action", }, @@ -333,7 +392,7 @@ describe("prepare", () => { region: "us-central1", project: "project", entryPoint: "entry", - runtime: "nodejs16", + runtime: "nodejs22", callableTrigger: { genkitAction: "action", }, diff --git a/src/deploy/functions/prepare.ts b/src/deploy/functions/prepare.ts index a4546a5e1ad..018fb267c28 100644 --- a/src/deploy/functions/prepare.ts +++ b/src/deploy/functions/prepare.ts @@ -473,13 +473,21 @@ 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 build = 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, }); + if (codebaseConfig.prefix) { + const newEndpoints: Record = {}; + for (const id of Object.keys(build.endpoints)) { + newEndpoints[`${codebaseConfig.prefix}-${id}`] = build.endpoints[id]; + } + build.endpoints = newEndpoints; + } + wantBuilds[codebase] = build; wantBuilds[codebase].runtime = codebaseConfig.runtime; } return wantBuilds; diff --git a/src/emulator/controller.ts b/src/emulator/controller.ts index 17dfb6a302c..85578f513c9 100755 --- a/src/emulator/controller.ts +++ b/src/emulator/controller.ts @@ -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; } @@ -544,6 +542,7 @@ export async function startAll( functionsDir, runtime, codebase: cfg.codebase, + prefix: cfg.prefix, env: { ...options.extDevEnv, }, diff --git a/src/emulator/functionsEmulator.ts b/src/emulator/functionsEmulator.ts index c8f69de5343..1eaf3dcc699 100644 --- a/src/emulator/functionsEmulator.ts +++ b/src/emulator/functionsEmulator.ts @@ -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"; @@ -87,6 +88,7 @@ export interface EmulatableBackend { env: Record; secretEnv: backend.SecretEnvVar[]; codebase: string; + prefix?: string; predefinedTriggers?: ParsedTriggerDefinition[]; runtime?: Runtime; bin?: string; @@ -563,6 +565,13 @@ export class FunctionsEmulator implements EmulatorInstance { ); await this.loadDynamicExtensionBackends(); } + if (emulatableBackend.prefix) { + const newEndpoints: Record = {}; + for (const id of Object.keys(discoveredBuild.endpoints)) { + newEndpoints[`${emulatableBackend.prefix}-${id}`] = discoveredBuild.endpoints[id]; + } + discoveredBuild.endpoints = newEndpoints; + } const resolution = await resolveBackend({ build: discoveredBuild, firebaseConfig: JSON.parse(firebaseConfig), diff --git a/src/firebaseConfig.ts b/src/firebaseConfig.ts index b0c46bee651..9378f927ff7 100644 --- a/src/firebaseConfig.ts +++ b/src/firebaseConfig.ts @@ -170,6 +170,7 @@ export type FunctionConfig = { ignore?: string[]; runtime?: ActiveRuntime; codebase?: string; + prefix?: string; } & Deployable; export type FunctionsConfig = FunctionConfig | FunctionConfig[]; diff --git a/src/functions/projectConfig.spec.ts b/src/functions/projectConfig.spec.ts index de9e8d97b84..7f2dd86f85a 100644 --- a/src/functions/projectConfig.spec.ts +++ b/src/functions/projectConfig.spec.ts @@ -42,10 +42,43 @@ describe("projectConfig", () => { ); }); - it("fails validation given config w/ duplicate source", () => { - expect(() => - projectConfig.validate([TEST_CONFIG_0, { ...TEST_CONFIG_0, codebase: "unique-codebase" }]), - ).to.throw(FirebaseError, /source must be unique/); + it("passes validation for multi-instance config with same source", () => { + const config: projectConfig.NormalizedConfig = [ + { source: "foo", codebase: "bar" }, + { source: "foo", codebase: "baz", prefix: "prefix-two" }, + ]; + expect(projectConfig.validate(config)).to.deep.equal(config); + }); + + it("passes validation for multi-instance config with one missing codebase", () => { + const config: projectConfig.NormalizedConfig = [ + { source: "foo", codebase: "bar", prefix: "bar-prefix" }, + { source: "foo" }, + ]; + const expected = [ + { source: "foo", codebase: "bar", prefix: "bar-prefix" }, + { source: "foo", codebase: "default" }, + ]; + expect(projectConfig.validate(config)).to.deep.equal(expected); + }); + + it("fails validation for multi-instance config with missing codebase and a default codebase", () => { + const config: projectConfig.NormalizedConfig = [ + { source: "foo", codebase: "default" }, + { source: "foo" }, + ]; + expect(() => projectConfig.validate(config)).to.throw( + FirebaseError, + /functions.codebase must be unique but 'default' was used more than once./, + ); + }); + + it("fails validation for multi-instance config with multiple missing codebases", () => { + const config: projectConfig.NormalizedConfig = [{ source: "foo" }, { source: "foo" }]; + expect(() => projectConfig.validate(config)).to.throw( + FirebaseError, + /functions.codebase must be unique but 'default' was used more than once./, + ); }); it("fails validation given codebase name with capital letters", () => { @@ -72,6 +105,48 @@ describe("projectConfig", () => { ]), ).to.throw(FirebaseError, /Invalid codebase name/); }); + + it("fails validation given prefix with invalid characters", () => { + expect(() => projectConfig.validate([{ ...TEST_CONFIG_0, prefix: "abc.efg" }])).to.throw( + FirebaseError, + /Invalid prefix/, + ); + }); + + it("fails validation given prefix with capital letters", () => { + expect(() => projectConfig.validate([{ ...TEST_CONFIG_0, prefix: "ABC" }])).to.throw( + FirebaseError, + /Invalid prefix/, + ); + }); + + it("fails validation given a duplicate source/prefix pair", () => { + const config: projectConfig.NormalizedConfig = [ + { source: "foo", codebase: "bar", prefix: "a" }, + { source: "foo", codebase: "baz", prefix: "a" }, + ]; + expect(() => projectConfig.validate(config)).to.throw( + FirebaseError, + /More than one functions config specifies the same source directory \('foo'\) and prefix \('a'\)/, + ); + }); + + it("fails validation for multi-instance config with same source and no prefixes", () => { + const config: projectConfig.NormalizedConfig = [ + { source: "foo", codebase: "bar" }, + { source: "foo", codebase: "baz" }, + ]; + expect(() => projectConfig.validate(config)).to.throw( + FirebaseError, + /More than one functions config specifies the same source directory \('foo'\) and prefix \(''\)/, + ); + }); + + it("should allow a single function in an array to have a default codebase", () => { + const config: projectConfig.NormalizedConfig = [{ source: "foo" }]; + const expected = [{ source: "foo", codebase: "default" }]; + expect(projectConfig.validate(config)).to.deep.equal(expected); + }); }); describe("normalizeAndValidate", () => { @@ -104,13 +179,6 @@ describe("projectConfig", () => { ); }); - it("fails validation given config w/ duplicate source", () => { - expect(() => projectConfig.normalizeAndValidate([TEST_CONFIG_0, TEST_CONFIG_0])).to.throw( - FirebaseError, - /functions.source must be unique/, - ); - }); - it("fails validation given config w/ duplicate codebase", () => { expect(() => projectConfig.normalizeAndValidate([ diff --git a/src/functions/projectConfig.ts b/src/functions/projectConfig.ts index 313db95d48a..2c0b8a6231f 100644 --- a/src/functions/projectConfig.ts +++ b/src/functions/projectConfig.ts @@ -37,6 +37,20 @@ export function validateCodebase(codebase: string): void { } } +/** + * Check that the prefix contains only allowed characters. + */ +export function validatePrefix(prefix: string): void { + if (prefix.length > 30) { + throw new FirebaseError("Invalid prefix. Prefix must be 30 characters or less."); + } +if (!/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/.test(prefix)) { + throw new FirebaseError( + "Invalid prefix. Prefix can contain only lowercase letters, numeric characters, and dashes, and cannot start or end with a dash.", + ); +} +} + function validateSingle(config: FunctionConfig): ValidatedSingle { if (!config.source) { throw new FirebaseError("codebase source must be specified"); @@ -45,6 +59,9 @@ function validateSingle(config: FunctionConfig): ValidatedSingle { config.codebase = DEFAULT_CODEBASE; } validateCodebase(config.codebase); + if (config.prefix) { + validatePrefix(config.prefix); + } return { ...config, source: config.source, codebase: config.codebase }; } @@ -72,13 +89,30 @@ export function assertUnique( } } +function assertUniqueSourcePrefixPair(config: ValidatedConfig): void { + const sourcePrefixPairs = new Set(); + for (const c of config) { + const key = `${c.source}-${c.prefix || ""}`; + if (sourcePrefixPairs.has(key)) { + throw new FirebaseError( + `More than one functions config specifies the same source directory ('${ + c.source + }') and prefix ('${ + c.prefix ?? "" + }'). Please add a unique 'prefix' to each function configuration that shares this source to resolve the conflict.`, + ); + } + sourcePrefixPairs.add(key); + } +} + /** * Validate functions config. */ export function validate(config: NormalizedConfig): ValidatedConfig { const validated = config.map((cfg) => validateSingle(cfg)) as ValidatedConfig; - assertUnique(validated, "source"); assertUnique(validated, "codebase"); + assertUniqueSourcePrefixPair(validated); return validated; }