Skip to content

Commit 347fa56

Browse files
feat: Support multi-instance function deployments
This change introduces the ability to deploy multiple instances of the same function source, each with its own configuration. This is achieved by allowing multiple function configurations in `firebase.json`. If multiple configurations are present, each must have a unique `codebase` identifier. This change is the first step towards implementing "Function Kits", which will allow developers to easily add pre-built backend functionality to their Firebase projects. This change is backward-compatible. Existing `firebase.json` configurations will continue to work as before.
1 parent f6c9ed1 commit 347fa56

File tree

10 files changed

+108
-14
lines changed

10 files changed

+108
-14
lines changed

schema/firebase-config.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -276,6 +276,9 @@
276276
}
277277
]
278278
},
279+
"prefix": {
280+
"type": "string"
281+
},
279282
"runtime": {
280283
"enum": [
281284
"nodejs18",

scripts/emulator-tests/functionsEmulator.spec.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -760,6 +760,48 @@ describe("FunctionsEmulator", function () {
760760
}).timeout(TIMEOUT_MED);
761761
});
762762

763+
it("should support multiple codebases with the same source and apply prefixes", async () => {
764+
const backend1: EmulatableBackend = {
765+
...TEST_BACKEND,
766+
codebase: "one",
767+
prefix: "prefix-one",
768+
};
769+
const backend2: EmulatableBackend = {
770+
...TEST_BACKEND,
771+
codebase: "two",
772+
prefix: "prefix-two",
773+
};
774+
775+
emu = new FunctionsEmulator({
776+
projectId: TEST_PROJECT_ID,
777+
projectDir: MODULE_ROOT,
778+
emulatableBackends: [backend1, backend2],
779+
verbosity: "QUIET",
780+
debugPort: false,
781+
});
782+
783+
await writeSource(() => {
784+
return {
785+
functionId: require("firebase-functions").https.onRequest(
786+
(req: express.Request, res: express.Response) => {
787+
res.json({ path: req.path });
788+
},
789+
),
790+
};
791+
});
792+
793+
await emu.start();
794+
await emu.connect();
795+
796+
await supertest(emu.createHubServer())
797+
.get(`/${TEST_PROJECT_ID}/us-central1/prefix-one-functionId`)
798+
.expect(200);
799+
800+
await supertest(emu.createHubServer())
801+
.get(`/${TEST_PROJECT_ID}/us-central1/prefix-two-functionId`)
802+
.expect(200);
803+
});
804+
763805
describe("user-defined environment variables", () => {
764806
let cleanup: (() => Promise<void>) | undefined;
765807

src/deploy/functions/build.spec.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -224,6 +224,27 @@ describe("toBackend", () => {
224224
expect(endpointDef.func.serviceAccount).to.equal("service-account-1@");
225225
}
226226
});
227+
228+
it("should apply the prefix to the function name", () => {
229+
const desiredBuild: build.Build = build.of({
230+
func: {
231+
platform: "gcfv1",
232+
region: ["us-central1"],
233+
project: "project",
234+
runtime: "nodejs16",
235+
entryPoint: "func",
236+
httpsTrigger: {},
237+
},
238+
});
239+
const backend = build.toBackend(desiredBuild, {}, "my-prefix");
240+
expect(Object.keys(backend.endpoints).length).to.equal(1);
241+
const regionalEndpoints = Object.values(backend.endpoints)[0];
242+
const endpoint = Object.values(regionalEndpoints)[0];
243+
expect(endpoint).to.not.equal(undefined);
244+
if (endpoint) {
245+
expect(endpoint.id).to.equal("my-prefix-func");
246+
}
247+
});
227248
});
228249

229250
describe("envWithType", () => {

src/deploy/functions/build.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -287,6 +287,7 @@ interface ResolveBackendOpts {
287287
userEnvs: Record<string, string>;
288288
nonInteractive?: boolean;
289289
isEmulator?: boolean;
290+
prefix?: string;
290291
}
291292

292293
/**
@@ -316,7 +317,7 @@ export async function resolveBackend(
316317
}
317318
writeUserEnvs(toWrite, opts.userEnvOpt);
318319

319-
return { backend: toBackend(opts.build, paramValues), envs: paramValues };
320+
return { backend: toBackend(opts.build, paramValues, opts.prefix), envs: paramValues };
320321
}
321322

322323
// Exported for testing
@@ -446,6 +447,7 @@ class Resolver {
446447
export function toBackend(
447448
build: Build,
448449
paramValues: Record<string, params.ParamValue>,
450+
prefix?: string,
449451
): backend.Backend {
450452
const r = new Resolver(paramValues);
451453
const bkEndpoints: Array<backend.Endpoint> = [];
@@ -481,7 +483,7 @@ export function toBackend(
481483
throw new FirebaseError("platform can't be undefined");
482484
}
483485
const bkEndpoint: backend.Endpoint = {
484-
id: endpointId,
486+
id: prefix ? `${prefix}-${endpointId}` : endpointId,
485487
project: bdEndpoint.project,
486488
region: region,
487489
entryPoint: bdEndpoint.entryPoint,

src/deploy/functions/prepare.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,7 @@ export async function prepare(
135135
userEnvs,
136136
nonInteractive: options.nonInteractive,
137137
isEmulator: false,
138+
prefix: config.prefix,
138139
});
139140

140141
let hasEnvsFromParams = false;

src/emulator/controller.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -544,6 +544,7 @@ export async function startAll(
544544
functionsDir,
545545
runtime,
546546
codebase: cfg.codebase,
547+
prefix: cfg.prefix,
547548
env: {
548549
...options.extDevEnv,
549550
},

src/emulator/functionsEmulator.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,7 @@ export interface EmulatableBackend {
8787
env: Record<string, string>;
8888
secretEnv: backend.SecretEnvVar[];
8989
codebase: string;
90+
prefix?: string;
9091
predefinedTriggers?: ParsedTriggerDefinition[];
9192
runtime?: Runtime;
9293
bin?: string;
@@ -570,6 +571,7 @@ export class FunctionsEmulator implements EmulatorInstance {
570571
userEnvs,
571572
nonInteractive: false,
572573
isEmulator: true,
574+
prefix: emulatableBackend.prefix,
573575
});
574576
const discoveredBackend = resolution.backend;
575577
const endpoints = backend.allEndpoints(discoveredBackend);

src/firebaseConfig.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,7 @@ export type FunctionConfig = {
170170
ignore?: string[];
171171
runtime?: ActiveRuntime;
172172
codebase?: string;
173+
prefix?: string;
173174
} & Deployable;
174175

175176
export type FunctionsConfig = FunctionConfig | FunctionConfig[];

src/functions/projectConfig.spec.ts

Lines changed: 24 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -42,10 +42,22 @@ describe("projectConfig", () => {
4242
);
4343
});
4444

45-
it("fails validation given config w/ duplicate source", () => {
46-
expect(() =>
47-
projectConfig.validate([TEST_CONFIG_0, { ...TEST_CONFIG_0, codebase: "unique-codebase" }]),
48-
).to.throw(FirebaseError, /source must be unique/);
45+
it("passes validation for multi-instance config with same source", () => {
46+
const config = [
47+
{ source: "foo", codebase: "bar" },
48+
{ source: "foo", codebase: "baz" },
49+
];
50+
expect(projectConfig.validate(config as projectConfig.NormalizedConfig)).to.deep.equal(
51+
config,
52+
);
53+
});
54+
55+
it("fails validation for multi-instance config with missing codebase", () => {
56+
const config = [{ source: "foo", codebase: "bar" }, { source: "foo" }];
57+
expect(() => projectConfig.validate(config as projectConfig.NormalizedConfig)).to.throw(
58+
FirebaseError,
59+
/Each functions config must have a unique 'codebase' field/,
60+
);
4961
});
5062

5163
it("fails validation given codebase name with capital letters", () => {
@@ -72,6 +84,14 @@ describe("projectConfig", () => {
7284
]),
7385
).to.throw(FirebaseError, /Invalid codebase name/);
7486
});
87+
88+
it("should allow a single function in an array to have a default codebase", () => {
89+
const config = [{ source: "foo" }];
90+
const expected = [{ source: "foo", codebase: "default" }];
91+
expect(projectConfig.validate(config as projectConfig.NormalizedConfig)).to.deep.equal(
92+
expected,
93+
);
94+
});
7595
});
7696

7797
describe("normalizeAndValidate", () => {
@@ -104,13 +124,6 @@ describe("projectConfig", () => {
104124
);
105125
});
106126

107-
it("fails validation given config w/ duplicate source", () => {
108-
expect(() => projectConfig.normalizeAndValidate([TEST_CONFIG_0, TEST_CONFIG_0])).to.throw(
109-
FirebaseError,
110-
/functions.source must be unique/,
111-
);
112-
});
113-
114127
it("fails validation given config w/ duplicate codebase", () => {
115128
expect(() =>
116129
projectConfig.normalizeAndValidate([

src/functions/projectConfig.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,8 +76,16 @@ export function assertUnique(
7676
* Validate functions config.
7777
*/
7878
export function validate(config: NormalizedConfig): ValidatedConfig {
79+
if (config.length > 1) {
80+
for (const c of config) {
81+
if (!c.codebase) {
82+
throw new FirebaseError(
83+
"Each functions config must have a unique 'codebase' field when defining multiple functions.",
84+
);
85+
}
86+
}
87+
}
7988
const validated = config.map((cfg) => validateSingle(cfg)) as ValidatedConfig;
80-
assertUnique(validated, "source");
8189
assertUnique(validated, "codebase");
8290
return validated;
8391
}

0 commit comments

Comments
 (0)