Skip to content

Commit

Permalink
Enable a function to be emulated in multiple regions (firebase#3364)
Browse files Browse the repository at this point in the history
  • Loading branch information
kmcnellis authored and devpeerapong committed Dec 14, 2021
1 parent 47716ba commit 9d00d72
Show file tree
Hide file tree
Showing 13 changed files with 229 additions and 97 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
@@ -0,0 +1 @@
- Enable running functions in multiple regions in the emulator.
15 changes: 10 additions & 5 deletions scripts/emulator-tests/fixtures.ts
Expand Up @@ -41,7 +41,8 @@ export const FunctionRuntimeBundles: { [key: string]: FunctionsRuntimeBundle } =
},
},
},
triggerId: "function_id",
triggerId: "us-central1-function_id",
targetName: "function_id",
projectId: "fake-project-id",
},
onWrite: {
Expand Down Expand Up @@ -80,7 +81,8 @@ export const FunctionRuntimeBundles: { [key: string]: FunctionsRuntimeBundle } =
},
},
},
triggerId: "function_id",
triggerId: "us-central1-function_id",
targetName: "function_id",
projectId: "fake-project-id",
},
onDelete: {
Expand Down Expand Up @@ -119,7 +121,8 @@ export const FunctionRuntimeBundles: { [key: string]: FunctionsRuntimeBundle } =
},
},
},
triggerId: "function_id",
triggerId: "us-central1-function_id",
targetName: "function_id",
projectId: "fake-project-id",
},
onUpdate: {
Expand Down Expand Up @@ -170,7 +173,8 @@ export const FunctionRuntimeBundles: { [key: string]: FunctionsRuntimeBundle } =
timestamp: "2019-05-15T16:21:15.148831Z",
},
},
triggerId: "function_id",
triggerId: "us-central1-function_id",
targetName: "function_id",
projectId: "fake-project-id",
},
onRequest: {
Expand All @@ -185,7 +189,8 @@ export const FunctionRuntimeBundles: { [key: string]: FunctionsRuntimeBundle } =
},
},
cwd: MODULE_ROOT,
triggerId: "function_id",
triggerId: "us-central1-function_id",
targetName: "function_id",
projectId: "fake-project-id",
},
};
Expand Down
64 changes: 62 additions & 2 deletions scripts/emulator-tests/functionsEmulator.spec.ts
Expand Up @@ -35,12 +35,32 @@ functionsEmulator.nodeBinary = process.execPath;
functionsEmulator.setTriggersForTesting([
{
name: "function_id",
id: "us-central1-function_id",
region: "us-central1",
entryPoint: "function_id",
httpsTrigger: {},
labels: {},
},
{
name: "function_id",
id: "europe-west2-function_id",
region: "europe-west2",
entryPoint: "function_id",
httpsTrigger: {},
labels: {},
},
{
name: "function_id",
id: "europe-west3-function_id",
region: "europe-west3",
entryPoint: "function_id",
httpsTrigger: {},
labels: {},
},
{
name: "callable_function_id",
id: "us-central1-callable_function_id",
region: "us-central1",
entryPoint: "callable_function_id",
httpsTrigger: {},
labels: {
Expand All @@ -49,6 +69,8 @@ functionsEmulator.setTriggersForTesting([
},
{
name: "nested-function_id",
id: "us-central1-nested-function_id",
region: "us-central1",
entryPoint: "nested.function_id",
httpsTrigger: {},
labels: {},
Expand All @@ -63,19 +85,20 @@ function useFunctions(triggers: () => {}): void {
// eslint-disable-next-line @typescript-eslint/unbound-method
functionsEmulator.startFunctionRuntime = (
triggerId: string,
targetName: string,
triggerType: EmulatedTriggerType,
proto?: any,
runtimeOpts?: InvokeRuntimeOpts
): RuntimeWorker => {
return startFunctionRuntime(triggerId, triggerType, proto, {
return startFunctionRuntime(triggerId, targetName, triggerType, proto, {
nodeBinary: process.execPath,
serializedTriggers,
});
};
}

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

it("should route requests to /:project_id/:other-region/:trigger_id to the region's HTTPS Function", async () => {
useFunctions(() => {
require("firebase-admin").initializeApp();
return {
function_id: require("firebase-functions")
.region("us-central1", "europe-west2")
.https.onRequest((req: express.Request, res: express.Response) => {
res.json({ path: req.path });
}),
};
});

await supertest(functionsEmulator.createHubServer())
.get("/fake-project-id/europe-west2/function_id")
.expect(200)
.then((res) => {
expect(res.body.path).to.deep.equal("/");
});
}).timeout(TIMEOUT_LONG);

it("should 404 when a function doesn't exist in the region", async () => {
useFunctions(() => {
require("firebase-admin").initializeApp();
return {
function_id: require("firebase-functions")
.region("us-central1", "europe-west2")
.https.onRequest((req: express.Request, res: express.Response) => {
res.json({ path: req.path });
}),
};
});

await supertest(functionsEmulator.createHubServer())
.get("/fake-project-id/us-east1/function_id")
.expect(404);
}).timeout(TIMEOUT_LONG);

it("should route requests to /:project_id/:region/:trigger_id/ to HTTPS Function", async () => {
useFunctions(() => {
require("firebase-admin").initializeApp();
Expand Down
60 changes: 37 additions & 23 deletions src/emulator/functionsEmulator.ts
Expand Up @@ -24,11 +24,12 @@ import * as spawn from "cross-spawn";
import { ChildProcess, spawnSync } from "child_process";
import {
EmulatedTriggerDefinition,
ParsedTriggerDefinition,
EmulatedTriggerType,
FunctionsRuntimeArgs,
FunctionsRuntimeBundle,
FunctionsRuntimeFeatures,
getFunctionRegion,
emulatedFunctionsByRegion,
getFunctionService,
HttpConstants,
EventTrigger,
Expand Down Expand Up @@ -75,7 +76,7 @@ export interface FunctionsEmulatorArgs {
debugPort?: number;
env?: { [key: string]: string };
remoteEmulators?: { [key: string]: EmulatorInfo };
predefinedTriggers?: EmulatedTriggerDefinition[];
predefinedTriggers?: ParsedTriggerDefinition[];
nodeMajorVersion?: number; // Lets us specify the node version when emulating extensions.
}

Expand All @@ -99,7 +100,7 @@ export interface FunctionsRuntimeInstance {
export interface InvokeRuntimeOpts {
nodeBinary: string;
serializedTriggers?: string;
extensionTriggers?: EmulatedTriggerDefinition[];
extensionTriggers?: ParsedTriggerDefinition[];
env?: { [key: string]: string };
ignore_warnings?: boolean;
}
Expand Down Expand Up @@ -221,8 +222,10 @@ export class FunctionsEmulator implements EmulatorInstance {
const httpsFunctionRoutes = [httpsFunctionRoute, `${httpsFunctionRoute}/*`];

const backgroundHandler: express.RequestHandler = (req, res) => {
const region = req.params.region;
const triggerId = req.params.trigger_name;
const projectId = req.params.project_id;

const reqBody = (req as RequestWithRawBody).rawBody;
const proto = JSON.parse(reqBody.toString());

Expand Down Expand Up @@ -282,6 +285,7 @@ export class FunctionsEmulator implements EmulatorInstance {

startFunctionRuntime(
triggerId: string,
targetName: string,
triggerType: EmulatedTriggerType,
proto?: any,
runtimeOpts?: InvokeRuntimeOpts
Expand All @@ -299,6 +303,7 @@ export class FunctionsEmulator implements EmulatorInstance {
nodeMajorVersion: this.args.nodeMajorVersion,
proto,
triggerId,
targetName,
triggerType,
};
const opts = runtimeOpts || {
Expand Down Expand Up @@ -395,7 +400,7 @@ export class FunctionsEmulator implements EmulatorInstance {
*
* TODO(abehaskins): Gracefully handle removal of deleted function definitions
*/
async loadTriggers(force: boolean = false) {
async loadTriggers(force = false): Promise<void> {
// Before loading any triggers we need to make sure there are no 'stale' workers
// in the pool that would cause us to run old code.
this.workerPool.refresh();
Expand All @@ -411,8 +416,13 @@ export class FunctionsEmulator implements EmulatorInstance {
"SYSTEM",
"triggers-parsed"
);
const triggerDefinitions = triggerParseEvent.data
.triggerDefinitions as EmulatedTriggerDefinition[];
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
const parsedDefinitions = triggerParseEvent.data
.triggerDefinitions as ParsedTriggerDefinition[];

const triggerDefinitions: EmulatedTriggerDefinition[] = emulatedFunctionsByRegion(
parsedDefinitions
);

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

if (definition.httpsTrigger) {
// TODO(samstern): Right now we only emulate each function in one region, but it's possible
// that a developer is running the same function in multiple regions.
const region = getFunctionRegion(definition);
const { host, port } = this.getInfo();
added = true;
url = FunctionsEmulator.getHttpFunctionUrl(
host,
port,
this.args.projectId,
definition.name,
region
definition.region
);
} else if (definition.eventTrigger) {
const service: string = getFunctionService(definition);
Expand Down Expand Up @@ -499,12 +506,12 @@ export class FunctionsEmulator implements EmulatorInstance {

if (ignored) {
const msg = `function ignored because the ${type} emulator does not exist or is not running.`;
this.logger.logLabeled("BULLET", `functions[${definition.name}]`, msg);
this.logger.logLabeled("BULLET", `functions[${definition.id}]`, msg);
} else {
const msg = url
? `${clc.bold(type)} function initialized (${url}).`
: `${clc.bold(type)} function initialized.`;
this.logger.logLabeled("SUCCESS", `functions[${definition.name}]`, msg);
this.logger.logLabeled("SUCCESS", `functions[${definition.id}]`, msg);
}
}
}
Expand Down Expand Up @@ -683,14 +690,9 @@ export class FunctionsEmulator implements EmulatorInstance {
return record.def;
}

getTriggerDefinitionByName(triggerName: string): EmulatedTriggerDefinition | undefined {
const record = Object.values(this.triggers).find((r) => r.def.name === triggerName);
return record?.def;
}

getTriggerKey(def: EmulatedTriggerDefinition): string {
// For background triggers we attach the current generation as a suffix
return def.eventTrigger ? def.name + "-" + this.triggerGeneration : def.name;
return def.eventTrigger ? `${def.id}-${this.triggerGeneration}` : def.id;
}

addTriggerRecord(
Expand All @@ -713,6 +715,7 @@ export class FunctionsEmulator implements EmulatorInstance {
cwd: this.args.functionsDir,
projectId: this.args.projectId,
triggerId: "",
targetName: "",
triggerType: undefined,
emulators: {
firestore: EmulatorRegistry.getInfo(Emulators.FIRESTORE),
Expand Down Expand Up @@ -922,7 +925,12 @@ export class FunctionsEmulator implements EmulatorInstance {

const trigger = this.getTriggerDefinitionByKey(triggerKey);
const service = getFunctionService(trigger);
const worker = this.startFunctionRuntime(trigger.name, EmulatedTriggerType.BACKGROUND, proto);
const worker = this.startFunctionRuntime(
trigger.id,
trigger.name,
EmulatedTriggerType.BACKGROUND,
proto
);

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

private async handleHttpsTrigger(req: express.Request, res: express.Response) {
const method = req.method;
const triggerId = req.params.trigger_name;
const region = req.params.region;
const triggerName = req.params.trigger_name;
const triggerId = `${region}-${triggerName}`;

if (!this.triggers[triggerId]) {
res
Expand Down Expand Up @@ -1057,8 +1067,12 @@ export class FunctionsEmulator implements EmulatorInstance {
);
}
}

const worker = this.startFunctionRuntime(trigger.name, EmulatedTriggerType.HTTPS, undefined);
const worker = this.startFunctionRuntime(
trigger.id,
trigger.name,
EmulatedTriggerType.HTTPS,
undefined
);

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

Expand Down

0 comments on commit 9d00d72

Please sign in to comment.