Skip to content

Commit 92e1809

Browse files
authored
Enable a function to be emulated in multiple regions (#3364)
1 parent ba41c67 commit 92e1809

13 files changed

+229
-97
lines changed

CHANGELOG.md

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
- Enable running functions in multiple regions in the emulator.

scripts/emulator-tests/fixtures.ts

+10-5
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,8 @@ export const FunctionRuntimeBundles: { [key: string]: FunctionsRuntimeBundle } =
4141
},
4242
},
4343
},
44-
triggerId: "function_id",
44+
triggerId: "us-central1-function_id",
45+
targetName: "function_id",
4546
projectId: "fake-project-id",
4647
},
4748
onWrite: {
@@ -80,7 +81,8 @@ export const FunctionRuntimeBundles: { [key: string]: FunctionsRuntimeBundle } =
8081
},
8182
},
8283
},
83-
triggerId: "function_id",
84+
triggerId: "us-central1-function_id",
85+
targetName: "function_id",
8486
projectId: "fake-project-id",
8587
},
8688
onDelete: {
@@ -119,7 +121,8 @@ export const FunctionRuntimeBundles: { [key: string]: FunctionsRuntimeBundle } =
119121
},
120122
},
121123
},
122-
triggerId: "function_id",
124+
triggerId: "us-central1-function_id",
125+
targetName: "function_id",
123126
projectId: "fake-project-id",
124127
},
125128
onUpdate: {
@@ -170,7 +173,8 @@ export const FunctionRuntimeBundles: { [key: string]: FunctionsRuntimeBundle } =
170173
timestamp: "2019-05-15T16:21:15.148831Z",
171174
},
172175
},
173-
triggerId: "function_id",
176+
triggerId: "us-central1-function_id",
177+
targetName: "function_id",
174178
projectId: "fake-project-id",
175179
},
176180
onRequest: {
@@ -185,7 +189,8 @@ export const FunctionRuntimeBundles: { [key: string]: FunctionsRuntimeBundle } =
185189
},
186190
},
187191
cwd: MODULE_ROOT,
188-
triggerId: "function_id",
192+
triggerId: "us-central1-function_id",
193+
targetName: "function_id",
189194
projectId: "fake-project-id",
190195
},
191196
};

scripts/emulator-tests/functionsEmulator.spec.ts

+62-2
Original file line numberDiff line numberDiff line change
@@ -35,12 +35,32 @@ functionsEmulator.nodeBinary = process.execPath;
3535
functionsEmulator.setTriggersForTesting([
3636
{
3737
name: "function_id",
38+
id: "us-central1-function_id",
39+
region: "us-central1",
40+
entryPoint: "function_id",
41+
httpsTrigger: {},
42+
labels: {},
43+
},
44+
{
45+
name: "function_id",
46+
id: "europe-west2-function_id",
47+
region: "europe-west2",
48+
entryPoint: "function_id",
49+
httpsTrigger: {},
50+
labels: {},
51+
},
52+
{
53+
name: "function_id",
54+
id: "europe-west3-function_id",
55+
region: "europe-west3",
3856
entryPoint: "function_id",
3957
httpsTrigger: {},
4058
labels: {},
4159
},
4260
{
4361
name: "callable_function_id",
62+
id: "us-central1-callable_function_id",
63+
region: "us-central1",
4464
entryPoint: "callable_function_id",
4565
httpsTrigger: {},
4666
labels: {
@@ -49,6 +69,8 @@ functionsEmulator.setTriggersForTesting([
4969
},
5070
{
5171
name: "nested-function_id",
72+
id: "us-central1-nested-function_id",
73+
region: "us-central1",
5274
entryPoint: "nested.function_id",
5375
httpsTrigger: {},
5476
labels: {},
@@ -63,19 +85,20 @@ function useFunctions(triggers: () => {}): void {
6385
// eslint-disable-next-line @typescript-eslint/unbound-method
6486
functionsEmulator.startFunctionRuntime = (
6587
triggerId: string,
88+
targetName: string,
6689
triggerType: EmulatedTriggerType,
6790
proto?: any,
6891
runtimeOpts?: InvokeRuntimeOpts
6992
): RuntimeWorker => {
70-
return startFunctionRuntime(triggerId, triggerType, proto, {
93+
return startFunctionRuntime(triggerId, targetName, triggerType, proto, {
7194
nodeBinary: process.execPath,
7295
serializedTriggers,
7396
});
7497
};
7598
}
7699

77100
describe("FunctionsEmulator-Hub", () => {
78-
it("should route requests to /:project_id/:region/:trigger_id to HTTPS Function", async () => {
101+
it("should route requests to /:project_id/us-central1/:trigger_id to default region HTTPS Function", async () => {
79102
useFunctions(() => {
80103
require("firebase-admin").initializeApp();
81104
return {
@@ -95,6 +118,43 @@ describe("FunctionsEmulator-Hub", () => {
95118
});
96119
}).timeout(TIMEOUT_LONG);
97120

121+
it("should route requests to /:project_id/:other-region/:trigger_id to the region's HTTPS Function", async () => {
122+
useFunctions(() => {
123+
require("firebase-admin").initializeApp();
124+
return {
125+
function_id: require("firebase-functions")
126+
.region("us-central1", "europe-west2")
127+
.https.onRequest((req: express.Request, res: express.Response) => {
128+
res.json({ path: req.path });
129+
}),
130+
};
131+
});
132+
133+
await supertest(functionsEmulator.createHubServer())
134+
.get("/fake-project-id/europe-west2/function_id")
135+
.expect(200)
136+
.then((res) => {
137+
expect(res.body.path).to.deep.equal("/");
138+
});
139+
}).timeout(TIMEOUT_LONG);
140+
141+
it("should 404 when a function doesn't exist in the region", async () => {
142+
useFunctions(() => {
143+
require("firebase-admin").initializeApp();
144+
return {
145+
function_id: require("firebase-functions")
146+
.region("us-central1", "europe-west2")
147+
.https.onRequest((req: express.Request, res: express.Response) => {
148+
res.json({ path: req.path });
149+
}),
150+
};
151+
});
152+
153+
await supertest(functionsEmulator.createHubServer())
154+
.get("/fake-project-id/us-east1/function_id")
155+
.expect(404);
156+
}).timeout(TIMEOUT_LONG);
157+
98158
it("should route requests to /:project_id/:region/:trigger_id/ to HTTPS Function", async () => {
99159
useFunctions(() => {
100160
require("firebase-admin").initializeApp();

src/emulator/functionsEmulator.ts

+37-23
Original file line numberDiff line numberDiff line change
@@ -24,11 +24,12 @@ import * as spawn from "cross-spawn";
2424
import { ChildProcess, spawnSync } from "child_process";
2525
import {
2626
EmulatedTriggerDefinition,
27+
ParsedTriggerDefinition,
2728
EmulatedTriggerType,
2829
FunctionsRuntimeArgs,
2930
FunctionsRuntimeBundle,
3031
FunctionsRuntimeFeatures,
31-
getFunctionRegion,
32+
emulatedFunctionsByRegion,
3233
getFunctionService,
3334
HttpConstants,
3435
EventTrigger,
@@ -75,7 +76,7 @@ export interface FunctionsEmulatorArgs {
7576
debugPort?: number;
7677
env?: { [key: string]: string };
7778
remoteEmulators?: { [key: string]: EmulatorInfo };
78-
predefinedTriggers?: EmulatedTriggerDefinition[];
79+
predefinedTriggers?: ParsedTriggerDefinition[];
7980
nodeMajorVersion?: number; // Lets us specify the node version when emulating extensions.
8081
}
8182

@@ -99,7 +100,7 @@ export interface FunctionsRuntimeInstance {
99100
export interface InvokeRuntimeOpts {
100101
nodeBinary: string;
101102
serializedTriggers?: string;
102-
extensionTriggers?: EmulatedTriggerDefinition[];
103+
extensionTriggers?: ParsedTriggerDefinition[];
103104
env?: { [key: string]: string };
104105
ignore_warnings?: boolean;
105106
}
@@ -221,8 +222,10 @@ export class FunctionsEmulator implements EmulatorInstance {
221222
const httpsFunctionRoutes = [httpsFunctionRoute, `${httpsFunctionRoute}/*`];
222223

223224
const backgroundHandler: express.RequestHandler = (req, res) => {
225+
const region = req.params.region;
224226
const triggerId = req.params.trigger_name;
225227
const projectId = req.params.project_id;
228+
226229
const reqBody = (req as RequestWithRawBody).rawBody;
227230
const proto = JSON.parse(reqBody.toString());
228231

@@ -282,6 +285,7 @@ export class FunctionsEmulator implements EmulatorInstance {
282285

283286
startFunctionRuntime(
284287
triggerId: string,
288+
targetName: string,
285289
triggerType: EmulatedTriggerType,
286290
proto?: any,
287291
runtimeOpts?: InvokeRuntimeOpts
@@ -299,6 +303,7 @@ export class FunctionsEmulator implements EmulatorInstance {
299303
nodeMajorVersion: this.args.nodeMajorVersion,
300304
proto,
301305
triggerId,
306+
targetName,
302307
triggerType,
303308
};
304309
const opts = runtimeOpts || {
@@ -395,7 +400,7 @@ export class FunctionsEmulator implements EmulatorInstance {
395400
*
396401
* TODO(abehaskins): Gracefully handle removal of deleted function definitions
397402
*/
398-
async loadTriggers(force: boolean = false) {
403+
async loadTriggers(force = false): Promise<void> {
399404
// Before loading any triggers we need to make sure there are no 'stale' workers
400405
// in the pool that would cause us to run old code.
401406
this.workerPool.refresh();
@@ -411,8 +416,13 @@ export class FunctionsEmulator implements EmulatorInstance {
411416
"SYSTEM",
412417
"triggers-parsed"
413418
);
414-
const triggerDefinitions = triggerParseEvent.data
415-
.triggerDefinitions as EmulatedTriggerDefinition[];
419+
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
420+
const parsedDefinitions = triggerParseEvent.data
421+
.triggerDefinitions as ParsedTriggerDefinition[];
422+
423+
const triggerDefinitions: EmulatedTriggerDefinition[] = emulatedFunctionsByRegion(
424+
parsedDefinitions
425+
);
416426

417427
// When force is true we set up all triggers, otherwise we only set up
418428
// triggers which have a unique function name
@@ -434,17 +444,14 @@ export class FunctionsEmulator implements EmulatorInstance {
434444
let url: string | undefined = undefined;
435445

436446
if (definition.httpsTrigger) {
437-
// TODO(samstern): Right now we only emulate each function in one region, but it's possible
438-
// that a developer is running the same function in multiple regions.
439-
const region = getFunctionRegion(definition);
440447
const { host, port } = this.getInfo();
441448
added = true;
442449
url = FunctionsEmulator.getHttpFunctionUrl(
443450
host,
444451
port,
445452
this.args.projectId,
446453
definition.name,
447-
region
454+
definition.region
448455
);
449456
} else if (definition.eventTrigger) {
450457
const service: string = getFunctionService(definition);
@@ -499,12 +506,12 @@ export class FunctionsEmulator implements EmulatorInstance {
499506

500507
if (ignored) {
501508
const msg = `function ignored because the ${type} emulator does not exist or is not running.`;
502-
this.logger.logLabeled("BULLET", `functions[${definition.name}]`, msg);
509+
this.logger.logLabeled("BULLET", `functions[${definition.id}]`, msg);
503510
} else {
504511
const msg = url
505512
? `${clc.bold(type)} function initialized (${url}).`
506513
: `${clc.bold(type)} function initialized.`;
507-
this.logger.logLabeled("SUCCESS", `functions[${definition.name}]`, msg);
514+
this.logger.logLabeled("SUCCESS", `functions[${definition.id}]`, msg);
508515
}
509516
}
510517
}
@@ -683,14 +690,9 @@ export class FunctionsEmulator implements EmulatorInstance {
683690
return record.def;
684691
}
685692

686-
getTriggerDefinitionByName(triggerName: string): EmulatedTriggerDefinition | undefined {
687-
const record = Object.values(this.triggers).find((r) => r.def.name === triggerName);
688-
return record?.def;
689-
}
690-
691693
getTriggerKey(def: EmulatedTriggerDefinition): string {
692694
// For background triggers we attach the current generation as a suffix
693-
return def.eventTrigger ? def.name + "-" + this.triggerGeneration : def.name;
695+
return def.eventTrigger ? `${def.id}-${this.triggerGeneration}` : def.id;
694696
}
695697

696698
addTriggerRecord(
@@ -713,6 +715,7 @@ export class FunctionsEmulator implements EmulatorInstance {
713715
cwd: this.args.functionsDir,
714716
projectId: this.args.projectId,
715717
triggerId: "",
718+
targetName: "",
716719
triggerType: undefined,
717720
emulators: {
718721
firestore: EmulatorRegistry.getInfo(Emulators.FIRESTORE),
@@ -922,7 +925,12 @@ export class FunctionsEmulator implements EmulatorInstance {
922925

923926
const trigger = this.getTriggerDefinitionByKey(triggerKey);
924927
const service = getFunctionService(trigger);
925-
const worker = this.startFunctionRuntime(trigger.name, EmulatedTriggerType.BACKGROUND, proto);
928+
const worker = this.startFunctionRuntime(
929+
trigger.id,
930+
trigger.name,
931+
EmulatedTriggerType.BACKGROUND,
932+
proto
933+
);
926934

927935
return new Promise((resolve, reject) => {
928936
if (projectId !== this.args.projectId) {
@@ -1018,7 +1026,9 @@ export class FunctionsEmulator implements EmulatorInstance {
10181026

10191027
private async handleHttpsTrigger(req: express.Request, res: express.Response) {
10201028
const method = req.method;
1021-
const triggerId = req.params.trigger_name;
1029+
const region = req.params.region;
1030+
const triggerName = req.params.trigger_name;
1031+
const triggerId = `${region}-${triggerName}`;
10221032

10231033
if (!this.triggers[triggerId]) {
10241034
res
@@ -1057,8 +1067,12 @@ export class FunctionsEmulator implements EmulatorInstance {
10571067
);
10581068
}
10591069
}
1060-
1061-
const worker = this.startFunctionRuntime(trigger.name, EmulatedTriggerType.HTTPS, undefined);
1070+
const worker = this.startFunctionRuntime(
1071+
trigger.id,
1072+
trigger.name,
1073+
EmulatedTriggerType.HTTPS,
1074+
undefined
1075+
);
10621076

10631077
worker.onLogs((el: EmulatorLog) => {
10641078
if (el.level === "FATAL") {
@@ -1087,7 +1101,7 @@ export class FunctionsEmulator implements EmulatorInstance {
10871101
// req.url = /:projectId/:region/:trigger_name/*
10881102
const url = new URL(`${req.protocol}://${req.hostname}${req.url}`);
10891103
const path = `${url.pathname}${url.search}`.replace(
1090-
new RegExp(`\/${this.args.projectId}\/[^\/]*\/${triggerId}\/?`),
1104+
new RegExp(`\/${this.args.projectId}\/[^\/]*\/${triggerName}\/?`),
10911105
"/"
10921106
);
10931107

0 commit comments

Comments
 (0)