diff --git a/package-lock.json b/package-lock.json index 916b3d90ead..23e77a4af23 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1022,6 +1022,12 @@ } } }, + "@google/events": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@google/events/-/events-5.1.1.tgz", + "integrity": "sha512-97u6AUfEXo6TxoBAdbziuhSL56+l69WzFahR6eTQE/bSjGPqT1+W4vS7eKaR7r60pGFrZZfqdFZ99uMbns3qgA==", + "dev": true + }, "@grpc/grpc-js": { "version": "1.1.8", "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.1.8.tgz", @@ -4770,9 +4776,9 @@ } }, "firebase-functions": { - "version": "3.15.6", - "resolved": "https://registry.npmjs.org/firebase-functions/-/firebase-functions-3.15.6.tgz", - "integrity": "sha512-VII8hV/jmg1L97SJrz7I0a4Heju4b2+4RxKBIcey5sU5pqV96acIjUOwn124GRZP9hStK36q8hm0W5s9lfQ3HQ==", + "version": "3.15.7", + "resolved": "https://registry.npmjs.org/firebase-functions/-/firebase-functions-3.15.7.tgz", + "integrity": "sha512-ZD7r8eoWWebgs+mTqfH8NLUT2C0f7/cyAvIA1RSUdBVQZN7MBBt3oSlN/rL3e+m6tdlJz6YbQ3hrOKOGjOVYvQ==", "dev": true, "requires": { "@types/cors": "^2.8.5", diff --git a/package.json b/package.json index 62bce283af7..ab0af126ce9 100644 --- a/package.json +++ b/package.json @@ -85,6 +85,7 @@ "dependencies": { "@google-cloud/pubsub": "^2.7.0", "@types/archiver": "^5.1.0", + "JSONStream": "^1.2.1", "abort-controller": "^3.0.0", "ajv": "^6.12.6", "archiver": "^5.0.0", @@ -110,7 +111,6 @@ "google-auth-library": "^6.1.3", "inquirer": "~6.3.1", "js-yaml": "^3.13.1", - "JSONStream": "^1.2.1", "jsonwebtoken": "^8.5.1", "leven": "^3.1.0", "lodash": "^4.17.21", @@ -143,6 +143,7 @@ "ws": "^7.2.3" }, "devDependencies": { + "@google/events": "^5.1.1", "@manifoldco/swagger-to-ts": "^2.0.0", "@types/body-parser": "^1.17.0", "@types/chai": "^4.2.12", diff --git a/scripts/emulator-tests/functionsEmulator.spec.ts b/scripts/emulator-tests/functionsEmulator.spec.ts index 262e7a58820..dd18394bbcd 100644 --- a/scripts/emulator-tests/functionsEmulator.spec.ts +++ b/scripts/emulator-tests/functionsEmulator.spec.ts @@ -3,7 +3,7 @@ import * as express from "express"; import * as sinon from "sinon"; import * as supertest from "supertest"; -import { EmulatedTriggerType } from "../../src/emulator/functionsEmulatorShared"; +import { SignatureType } from "../../src/emulator/functionsEmulatorShared"; import { FunctionsEmulator, InvokeRuntimeOpts } from "../../src/emulator/functionsEmulator"; import { Emulators } from "../../src/emulator/types"; import { RuntimeWorker } from "../../src/emulator/functionsRuntimeWorker"; @@ -89,7 +89,7 @@ function useFunctions(triggers: () => {}): void { functionsEmulator.startFunctionRuntime = ( triggerId: string, targetName: string, - triggerType: EmulatedTriggerType, + triggerType: SignatureType, proto?: any, runtimeOpts?: InvokeRuntimeOpts ): RuntimeWorker => { diff --git a/scripts/emulator-tests/functionsEmulatorRuntime.spec.ts b/scripts/emulator-tests/functionsEmulatorRuntime.spec.ts index 93201d36ff1..1a2888a178b 100644 --- a/scripts/emulator-tests/functionsEmulatorRuntime.spec.ts +++ b/scripts/emulator-tests/functionsEmulatorRuntime.spec.ts @@ -9,7 +9,7 @@ import * as sinon from "sinon"; import { EmulatorLog, Emulators } from "../../src/emulator/types"; import { FunctionRuntimeBundles, TIMEOUT_LONG, TIMEOUT_MED, MODULE_ROOT } from "./fixtures"; -import { FunctionsRuntimeBundle } from "../../src/emulator/functionsEmulatorShared"; +import { FunctionsRuntimeBundle, SignatureType } from "../../src/emulator/functionsEmulatorShared"; import { InvokeRuntimeOpts, FunctionsEmulator } from "../../src/emulator/functionsEmulator"; import { RuntimeWorker } from "../../src/emulator/functionsRuntimeWorker"; import { streamToString } from "../../src/utils"; @@ -41,6 +41,7 @@ async function countLogEntries(worker: RuntimeWorker): Promise<{ [key: string]: function startRuntimeWithFunctions( frb: FunctionsRuntimeBundle, triggers: () => {}, + signatureType: SignatureType, opts?: InvokeRuntimeOpts ): RuntimeWorker { const serializedTriggers = triggers.toString(); @@ -52,7 +53,7 @@ function startRuntimeWithFunctions( return functionsEmulator.startFunctionRuntime( frb.triggerId!, frb.targetName!, - frb.triggerType!, + signatureType, frb.proto, opts ); @@ -106,37 +107,45 @@ describe("FunctionsEmulator-Runtime", () => { describe("Stubs, Mocks, and Helpers (aka Magic, Glee, and Awesomeness)", () => { describe("_InitializeNetworkFiltering(...)", () => { it("should log outgoing unknown HTTP requests via 'http'", async () => { - const worker = startRuntimeWithFunctions(FunctionRuntimeBundles.onCreate, () => { - require("firebase-admin").initializeApp(); - return { - function_id: require("firebase-functions") - .firestore.document("test/test") - .onCreate(async () => { - await new Promise((resolve) => { - console.log(require("http").get.toString()); - require("http").get("http://example.com", resolve); - }); - }), - }; - }); + const worker = startRuntimeWithFunctions( + FunctionRuntimeBundles.onCreate, + () => { + require("firebase-admin").initializeApp(); + return { + function_id: require("firebase-functions") + .firestore.document("test/test") + .onCreate(async () => { + await new Promise((resolve) => { + console.log(require("http").get.toString()); + require("http").get("http://example.com", resolve); + }); + }), + }; + }, + "event" + ); const logs = await countLogEntries(worker); expect(logs["unidentified-network-access"]).to.gte(1); }).timeout(TIMEOUT_LONG); it("should log outgoing unknown HTTP requests via 'https'", async () => { - const worker = startRuntimeWithFunctions(FunctionRuntimeBundles.onCreate, () => { - require("firebase-admin").initializeApp(); - return { - function_id: require("firebase-functions") - .firestore.document("test/test") - .onCreate(async () => { - await new Promise((resolve) => { - require("https").get("https://example.com", resolve); - }); - }), - }; - }); + const worker = startRuntimeWithFunctions( + FunctionRuntimeBundles.onCreate, + () => { + require("firebase-admin").initializeApp(); + return { + function_id: require("firebase-functions") + .firestore.document("test/test") + .onCreate(async () => { + await new Promise((resolve) => { + require("https").get("https://example.com", resolve); + }); + }), + }; + }, + "event" + ); const logs = await countLogEntries(worker); @@ -144,18 +153,22 @@ describe("FunctionsEmulator-Runtime", () => { }).timeout(TIMEOUT_LONG); it("should log outgoing Google API requests", async () => { - const worker = startRuntimeWithFunctions(FunctionRuntimeBundles.onCreate, () => { - require("firebase-admin").initializeApp(); - return { - function_id: require("firebase-functions") - .firestore.document("test/test") - .onCreate(async () => { - await new Promise((resolve) => { - require("https").get("https://storage.googleapis.com", resolve); - }); - }), - }; - }); + const worker = startRuntimeWithFunctions( + FunctionRuntimeBundles.onCreate, + () => { + require("firebase-admin").initializeApp(); + return { + function_id: require("firebase-functions") + .firestore.document("test/test") + .onCreate(async () => { + await new Promise((resolve) => { + require("https").get("https://storage.googleapis.com", resolve); + }); + }), + }; + }, + "event" + ); const logs = await countLogEntries(worker); @@ -175,30 +188,38 @@ describe("FunctionsEmulator-Runtime", () => { }); it("should provide stubbed default app from initializeApp", async () => { - const worker = startRuntimeWithFunctions(FunctionRuntimeBundles.onCreate, () => { - require("firebase-admin").initializeApp(); - return { - function_id: require("firebase-functions") - .firestore.document("test/test") - .onCreate(DO_NOTHING), - }; - }); + const worker = startRuntimeWithFunctions( + FunctionRuntimeBundles.onCreate, + () => { + require("firebase-admin").initializeApp(); + return { + function_id: require("firebase-functions") + .firestore.document("test/test") + .onCreate(DO_NOTHING), + }; + }, + "event" + ); const logs = await countLogEntries(worker); expect(logs["default-admin-app-used"]).to.eq(1); }).timeout(TIMEOUT_MED); it("should provide a stubbed app with custom options", async () => { - const worker = startRuntimeWithFunctions(FunctionRuntimeBundles.onCreate, () => { - require("firebase-admin").initializeApp({ - custom: true, - }); - return { - function_id: require("firebase-functions") - .firestore.document("test/test") - .onCreate(DO_NOTHING), - }; - }); + const worker = startRuntimeWithFunctions( + FunctionRuntimeBundles.onCreate, + () => { + require("firebase-admin").initializeApp({ + custom: true, + }); + return { + function_id: require("firebase-functions") + .firestore.document("test/test") + .onCreate(DO_NOTHING), + }; + }, + "event" + ); let foundMatch = false; worker.runtime.events.on("log", (el: EmulatorLog) => { @@ -215,33 +236,41 @@ describe("FunctionsEmulator-Runtime", () => { }).timeout(TIMEOUT_MED); it("should provide non-stubbed non-default app from initializeApp", async () => { - const worker = startRuntimeWithFunctions(FunctionRuntimeBundles.onCreate, () => { - require("firebase-admin").initializeApp(); // We still need to initialize default for snapshots - require("firebase-admin").initializeApp({}, "non-default"); - return { - function_id: require("firebase-functions") - .firestore.document("test/test") - .onCreate(DO_NOTHING), - }; - }); + const worker = startRuntimeWithFunctions( + FunctionRuntimeBundles.onCreate, + () => { + require("firebase-admin").initializeApp(); // We still need to initialize default for snapshots + require("firebase-admin").initializeApp({}, "non-default"); + return { + function_id: require("firebase-functions") + .firestore.document("test/test") + .onCreate(DO_NOTHING), + }; + }, + "event" + ); const logs = await countLogEntries(worker); expect(logs["non-default-admin-app-used"]).to.eq(1); }).timeout(TIMEOUT_MED); it("should route all sub-fields accordingly", async () => { - const worker = startRuntimeWithFunctions(FunctionRuntimeBundles.onCreate, () => { - require("firebase-admin").initializeApp(); - return { - function_id: require("firebase-functions") - .firestore.document("test/test") - .onCreate(() => { - console.log( - JSON.stringify(require("firebase-admin").firestore.FieldValue.increment(4)) - ); - return Promise.resolve(); - }), - }; - }); + const worker = startRuntimeWithFunctions( + FunctionRuntimeBundles.onCreate, + () => { + require("firebase-admin").initializeApp(); + return { + function_id: require("firebase-functions") + .firestore.document("test/test") + .onCreate(() => { + console.log( + JSON.stringify(require("firebase-admin").firestore.FieldValue.increment(4)) + ); + return Promise.resolve(); + }), + }; + }, + "event" + ); worker.runtime.events.on("log", (el: EmulatorLog) => { if (el.level !== "USER") { @@ -257,17 +286,21 @@ describe("FunctionsEmulator-Runtime", () => { it("should expose Firestore prod when the emulator is not running", async () => { const frb = FunctionRuntimeBundles.onRequest; - const worker = startRuntimeWithFunctions(frb, () => { - const admin = require("firebase-admin"); - admin.initializeApp(); + const worker = startRuntimeWithFunctions( + frb, + () => { + const admin = require("firebase-admin"); + admin.initializeApp(); - return { - function_id: require("firebase-functions").https.onRequest((req: any, res: any) => { - res.json(admin.firestore()._settings); - return Promise.resolve(); - }), - }; - }); + return { + function_id: require("firebase-functions").https.onRequest((req: any, res: any) => { + res.json(admin.firestore()._settings); + return Promise.resolve(); + }), + }; + }, + "http" + ); const data = await callHTTPSFunction(worker, frb); const info = JSON.parse(data); @@ -285,17 +318,21 @@ describe("FunctionsEmulator-Runtime", () => { port: 9090, }); - const worker = startRuntimeWithFunctions(frb, () => { - const admin = require("firebase-admin"); - admin.initializeApp(); + const worker = startRuntimeWithFunctions( + frb, + () => { + const admin = require("firebase-admin"); + admin.initializeApp(); - return { - function_id: require("firebase-functions").https.onRequest((req: any, res: any) => { - res.json(admin.firestore()._settings); - return Promise.resolve(); - }), - }; - }); + return { + function_id: require("firebase-functions").https.onRequest((req: any, res: any) => { + res.json(admin.firestore()._settings); + return Promise.resolve(); + }), + }; + }, + "http" + ); const data = await callHTTPSFunction(worker, frb); const info = JSON.parse(data); @@ -308,18 +345,22 @@ describe("FunctionsEmulator-Runtime", () => { it("should expose RTDB prod when the emulator is not running", async () => { const frb = FunctionRuntimeBundles.onRequest; - const worker = startRuntimeWithFunctions(frb, () => { - const admin = require("firebase-admin"); - admin.initializeApp(); - - return { - function_id: require("firebase-functions").https.onRequest((req: any, res: any) => { - res.json({ - url: admin.database().ref().toString(), - }); - }), - }; - }); + const worker = startRuntimeWithFunctions( + frb, + () => { + const admin = require("firebase-admin"); + admin.initializeApp(); + + return { + function_id: require("firebase-functions").https.onRequest((req: any, res: any) => { + res.json({ + url: admin.database().ref().toString(), + }); + }), + }; + }, + "http" + ); const data = await callHTTPSFunction(worker, frb); const info = JSON.parse(data); @@ -334,18 +375,22 @@ describe("FunctionsEmulator-Runtime", () => { port: 9090, }); - const worker = startRuntimeWithFunctions(frb, () => { - const admin = require("firebase-admin"); - admin.initializeApp(); - - return { - function_id: require("firebase-functions").https.onRequest((req: any, res: any) => { - res.json({ - url: admin.database().ref().toString(), - }); - }), - }; - }); + const worker = startRuntimeWithFunctions( + frb, + () => { + const admin = require("firebase-admin"); + admin.initializeApp(); + + return { + function_id: require("firebase-functions").https.onRequest((req: any, res: any) => { + res.json({ + url: admin.database().ref().toString(), + }); + }), + }; + }, + "http" + ); const data = await callHTTPSFunction(worker, frb); const info = JSON.parse(data); @@ -360,16 +405,20 @@ describe("FunctionsEmulator-Runtime", () => { port: 9090, }); - const worker = startRuntimeWithFunctions(frb, () => { - const admin = require("firebase-admin"); - admin.initializeApp(); + const worker = startRuntimeWithFunctions( + frb, + () => { + const admin = require("firebase-admin"); + admin.initializeApp(); - return { - function_id: require("firebase-functions").https.onRequest((req: any, res: any) => { - res.json(JSON.parse(process.env.FIREBASE_CONFIG!)); - }), - }; - }); + return { + function_id: require("firebase-functions").https.onRequest((req: any, res: any) => { + res.json(JSON.parse(process.env.FIREBASE_CONFIG!)); + }), + }; + }, + "http" + ); const data = await callHTTPSFunction(worker, frb); const info = JSON.parse(data); @@ -378,16 +427,20 @@ describe("FunctionsEmulator-Runtime", () => { it("should return a real databaseURL when RTDB emulator is not running", async () => { const frb = _.cloneDeep(FunctionRuntimeBundles.onRequest); - const worker = startRuntimeWithFunctions(frb, () => { - const admin = require("firebase-admin"); - admin.initializeApp(); + const worker = startRuntimeWithFunctions( + frb, + () => { + const admin = require("firebase-admin"); + admin.initializeApp(); - return { - function_id: require("firebase-functions").https.onRequest((req: any, res: any) => { - res.json(JSON.parse(process.env.FIREBASE_CONFIG!)); - }), - }; - }); + return { + function_id: require("firebase-functions").https.onRequest((req: any, res: any) => { + res.json(JSON.parse(process.env.FIREBASE_CONFIG!)); + }), + }; + }, + "http" + ); const data = await callHTTPSFunction(worker, frb); const info = JSON.parse(data); @@ -409,21 +462,25 @@ describe("FunctionsEmulator-Runtime", () => { }); it("should tell the user if they've accessed a non-existent function field", async () => { - const worker = startRuntimeWithFunctions(FunctionRuntimeBundles.onCreate, () => { - require("firebase-admin").initializeApp(); - return { - function_id: require("firebase-functions") - .firestore.document("test/test") - .onCreate(() => { - // Exists - console.log(require("firebase-functions").config().real); - - // Does not exist - console.log(require("firebase-functions").config().foo); - console.log(require("firebase-functions").config().bar); - }), - }; - }); + const worker = startRuntimeWithFunctions( + FunctionRuntimeBundles.onCreate, + () => { + require("firebase-admin").initializeApp(); + return { + function_id: require("firebase-functions") + .firestore.document("test/test") + .onCreate(() => { + // Exists + console.log(require("firebase-functions").config().real); + + // Does not exist + console.log(require("firebase-functions").config().foo); + console.log(require("firebase-functions").config().bar); + }), + }; + }, + "event" + ); const logs = await countLogEntries(worker); expect(logs["functions-config-missing-value"]).to.eq(2); @@ -434,14 +491,18 @@ describe("FunctionsEmulator-Runtime", () => { describe("HTTPS", () => { it("should handle a GET request", async () => { const frb = FunctionRuntimeBundles.onRequest; - const worker = startRuntimeWithFunctions(frb, () => { - require("firebase-admin").initializeApp(); - return { - function_id: require("firebase-functions").https.onRequest((req: any, res: any) => { - res.json({ from_trigger: true }); - }), - }; - }); + const worker = startRuntimeWithFunctions( + frb, + () => { + require("firebase-admin").initializeApp(); + return { + function_id: require("firebase-functions").https.onRequest((req: any, res: any) => { + res.json({ from_trigger: true }); + }), + }; + }, + "http" + ); const data = await callHTTPSFunction(worker, frb); @@ -450,14 +511,18 @@ describe("FunctionsEmulator-Runtime", () => { it("should handle a POST request with form data", async () => { const frb = FunctionRuntimeBundles.onRequest; - const worker = startRuntimeWithFunctions(frb, () => { - require("firebase-admin").initializeApp(); - return { - function_id: require("firebase-functions").https.onRequest((req: any, res: any) => { - res.json(req.body); - }), - }; - }); + const worker = startRuntimeWithFunctions( + frb, + () => { + require("firebase-admin").initializeApp(); + return { + function_id: require("firebase-functions").https.onRequest((req: any, res: any) => { + res.json(req.body); + }), + }; + }, + "http" + ); const reqData = "name=sparky"; const data = await callHTTPSFunction( @@ -477,14 +542,18 @@ describe("FunctionsEmulator-Runtime", () => { it("should handle a POST request with JSON data", async () => { const frb = FunctionRuntimeBundles.onRequest; - const worker = startRuntimeWithFunctions(frb, () => { - require("firebase-admin").initializeApp(); - return { - function_id: require("firebase-functions").https.onRequest((req: any, res: any) => { - res.json(req.body); - }), - }; - }); + const worker = startRuntimeWithFunctions( + frb, + () => { + require("firebase-admin").initializeApp(); + return { + function_id: require("firebase-functions").https.onRequest((req: any, res: any) => { + res.json(req.body); + }), + }; + }, + "http" + ); const reqData = '{"name": "sparky"}'; const data = await callHTTPSFunction( @@ -504,14 +573,18 @@ describe("FunctionsEmulator-Runtime", () => { it("should handle a POST request with text data", async () => { const frb = FunctionRuntimeBundles.onRequest; - const worker = startRuntimeWithFunctions(frb, () => { - require("firebase-admin").initializeApp(); - return { - function_id: require("firebase-functions").https.onRequest((req: any, res: any) => { - res.json(req.body); - }), - }; - }); + const worker = startRuntimeWithFunctions( + frb, + () => { + require("firebase-admin").initializeApp(); + return { + function_id: require("firebase-functions").https.onRequest((req: any, res: any) => { + res.json(req.body); + }), + }; + }, + "http" + ); const reqData = "name is sparky"; const data = await callHTTPSFunction( @@ -531,14 +604,18 @@ describe("FunctionsEmulator-Runtime", () => { it("should handle a POST request with any other type", async () => { const frb = FunctionRuntimeBundles.onRequest; - const worker = startRuntimeWithFunctions(frb, () => { - require("firebase-admin").initializeApp(); - return { - function_id: require("firebase-functions").https.onRequest((req: any, res: any) => { - res.json(req.body); - }), - }; - }); + const worker = startRuntimeWithFunctions( + frb, + () => { + require("firebase-admin").initializeApp(); + return { + function_id: require("firebase-functions").https.onRequest((req: any, res: any) => { + res.json(req.body); + }), + }; + }, + "http" + ); const reqData = "name is sparky"; const data = await callHTTPSFunction( @@ -559,14 +636,18 @@ describe("FunctionsEmulator-Runtime", () => { it("should handle a POST request and store rawBody", async () => { const frb = FunctionRuntimeBundles.onRequest; - const worker = startRuntimeWithFunctions(frb, () => { - require("firebase-admin").initializeApp(); - return { - function_id: require("firebase-functions").https.onRequest((req: any, res: any) => { - res.send(req.rawBody); - }), - }; - }); + const worker = startRuntimeWithFunctions( + frb, + () => { + require("firebase-admin").initializeApp(); + return { + function_id: require("firebase-functions").https.onRequest((req: any, res: any) => { + res.send(req.rawBody); + }), + }; + }, + "http" + ); const reqData = "How are you?"; const data = await callHTTPSFunction( @@ -586,18 +667,22 @@ describe("FunctionsEmulator-Runtime", () => { it("should forward request to Express app", async () => { const frb = FunctionRuntimeBundles.onRequest; - const worker = startRuntimeWithFunctions(frb, () => { - require("firebase-admin").initializeApp(); - const app = require("express")(); - app.all("/", (req: express.Request, res: express.Response) => { - res.json({ - hello: req.header("x-hello"), + const worker = startRuntimeWithFunctions( + frb, + () => { + require("firebase-admin").initializeApp(); + const app = require("express")(); + app.all("/", (req: express.Request, res: express.Response) => { + res.json({ + hello: req.header("x-hello"), + }); }); - }); - return { - function_id: require("firebase-functions").https.onRequest(app), - }; - }); + return { + function_id: require("firebase-functions").https.onRequest(app), + }; + }, + "http" + ); const data = await callHTTPSFunction(worker, frb, { headers: { @@ -610,14 +695,18 @@ describe("FunctionsEmulator-Runtime", () => { it("should handle `x-forwarded-host`", async () => { const frb = FunctionRuntimeBundles.onRequest; - const worker = startRuntimeWithFunctions(frb, () => { - require("firebase-admin").initializeApp(); - return { - function_id: require("firebase-functions").https.onRequest((req: any, res: any) => { - res.json({ hostname: req.hostname }); - }), - }; - }); + const worker = startRuntimeWithFunctions( + frb, + () => { + require("firebase-admin").initializeApp(); + return { + function_id: require("firebase-functions").https.onRequest((req: any, res: any) => { + res.json({ hostname: req.hostname }); + }), + }; + }, + "http" + ); const data = await callHTTPSFunction(worker, frb, { headers: { @@ -630,14 +719,18 @@ describe("FunctionsEmulator-Runtime", () => { it("should report GMT time zone", async () => { const frb = FunctionRuntimeBundles.onRequest; - const worker = startRuntimeWithFunctions(frb, () => { - return { - function_id: require("firebase-functions").https.onRequest((req: any, res: any) => { - const now = new Date(); - res.json({ offset: now.getTimezoneOffset() }); - }), - }; - }); + const worker = startRuntimeWithFunctions( + frb, + () => { + return { + function_id: require("firebase-functions").https.onRequest((req: any, res: any) => { + const now = new Date(); + res.json({ offset: now.getTimezoneOffset() }); + }), + }; + }, + "http" + ); const data = await callHTTPSFunction(worker, frb); expect(JSON.parse(data)).to.deep.equal({ offset: 0 }); @@ -646,22 +739,26 @@ describe("FunctionsEmulator-Runtime", () => { describe("Cloud Firestore", () => { it("should provide Change for firestore.onWrite()", async () => { - const worker = startRuntimeWithFunctions(FunctionRuntimeBundles.onWrite, () => { - require("firebase-admin").initializeApp(); - return { - function_id: require("firebase-functions") - .firestore.document("test/test") - .onWrite((change: Change) => { - console.log( - JSON.stringify({ - before_exists: change.before.exists, - after_exists: change.after.exists, - }) - ); - return Promise.resolve(); - }), - }; - }); + const worker = startRuntimeWithFunctions( + FunctionRuntimeBundles.onWrite, + () => { + require("firebase-admin").initializeApp(); + return { + function_id: require("firebase-functions") + .firestore.document("test/test") + .onWrite((change: Change) => { + console.log( + JSON.stringify({ + before_exists: change.before.exists, + after_exists: change.after.exists, + }) + ); + return Promise.resolve(); + }), + }; + }, + "event" + ); worker.runtime.events.on("log", (el: EmulatorLog) => { if (el.level !== "USER") { @@ -676,22 +773,26 @@ describe("FunctionsEmulator-Runtime", () => { }).timeout(TIMEOUT_MED); it("should provide Change for firestore.onUpdate()", async () => { - const worker = startRuntimeWithFunctions(FunctionRuntimeBundles.onUpdate, () => { - require("firebase-admin").initializeApp(); - return { - function_id: require("firebase-functions") - .firestore.document("test/test") - .onUpdate((change: Change) => { - console.log( - JSON.stringify({ - before_exists: change.before.exists, - after_exists: change.after.exists, - }) - ); - return Promise.resolve(); - }), - }; - }); + const worker = startRuntimeWithFunctions( + FunctionRuntimeBundles.onUpdate, + () => { + require("firebase-admin").initializeApp(); + return { + function_id: require("firebase-functions") + .firestore.document("test/test") + .onUpdate((change: Change) => { + console.log( + JSON.stringify({ + before_exists: change.before.exists, + after_exists: change.after.exists, + }) + ); + return Promise.resolve(); + }), + }; + }, + "event" + ); worker.runtime.events.on("log", (el: EmulatorLog) => { if (el.level !== "USER") { @@ -705,21 +806,25 @@ describe("FunctionsEmulator-Runtime", () => { }).timeout(TIMEOUT_MED); it("should provide DocumentSnapshot for firestore.onDelete()", async () => { - const worker = startRuntimeWithFunctions(FunctionRuntimeBundles.onDelete, () => { - require("firebase-admin").initializeApp(); - return { - function_id: require("firebase-functions") - .firestore.document("test/test") - .onDelete((snap: DocumentSnapshot) => { - console.log( - JSON.stringify({ - snap_exists: snap.exists, - }) - ); - return Promise.resolve(); - }), - }; - }); + const worker = startRuntimeWithFunctions( + FunctionRuntimeBundles.onDelete, + () => { + require("firebase-admin").initializeApp(); + return { + function_id: require("firebase-functions") + .firestore.document("test/test") + .onDelete((snap: DocumentSnapshot) => { + console.log( + JSON.stringify({ + snap_exists: snap.exists, + }) + ); + return Promise.resolve(); + }), + }; + }, + "event" + ); worker.runtime.events.on("log", (el: EmulatorLog) => { if (el.level !== "USER") { @@ -733,21 +838,25 @@ describe("FunctionsEmulator-Runtime", () => { }).timeout(TIMEOUT_MED); it("should provide DocumentSnapshot for firestore.onCreate()", async () => { - const worker = startRuntimeWithFunctions(FunctionRuntimeBundles.onWrite, () => { - require("firebase-admin").initializeApp(); - return { - function_id: require("firebase-functions") - .firestore.document("test/test") - .onCreate((snap: DocumentSnapshot) => { - console.log( - JSON.stringify({ - snap_exists: snap.exists, - }) - ); - return Promise.resolve(); - }), - }; - }); + const worker = startRuntimeWithFunctions( + FunctionRuntimeBundles.onWrite, + () => { + require("firebase-admin").initializeApp(); + return { + function_id: require("firebase-functions") + .firestore.document("test/test") + .onCreate((snap: DocumentSnapshot) => { + console.log( + JSON.stringify({ + snap_exists: snap.exists, + }) + ); + return Promise.resolve(); + }), + }; + }, + "event" + ); worker.runtime.events.on("log", (el: EmulatorLog) => { if (el.level !== "USER") { @@ -764,14 +873,18 @@ describe("FunctionsEmulator-Runtime", () => { describe("Error handling", () => { it("Should handle regular functions for Express handlers", async () => { const frb = FunctionRuntimeBundles.onRequest; - const worker = startRuntimeWithFunctions(frb, () => { - require("firebase-admin").initializeApp(); - return { - function_id: require("firebase-functions").https.onRequest((req: any, res: any) => { - throw new Error("not a thing"); - }), - }; - }); + const worker = startRuntimeWithFunctions( + frb, + () => { + require("firebase-admin").initializeApp(); + return { + function_id: require("firebase-functions").https.onRequest((req: any, res: any) => { + throw new Error("not a thing"); + }), + }; + }, + "http" + ); const logs = countLogEntries(worker); @@ -786,17 +899,21 @@ describe("FunctionsEmulator-Runtime", () => { it("Should handle async functions for Express handlers", async () => { const frb = FunctionRuntimeBundles.onRequest; - const worker = startRuntimeWithFunctions(frb, () => { - require("firebase-admin").initializeApp(); - return { - function_id: require("firebase-functions").https.onRequest( - async (req: any, res: any) => { - await Promise.resolve(); // Required `await` for `async`. - return Promise.reject(new Error("not a thing")); - } - ), - }; - }); + const worker = startRuntimeWithFunctions( + frb, + () => { + require("firebase-admin").initializeApp(); + return { + function_id: require("firebase-functions").https.onRequest( + async (req: any, res: any) => { + await Promise.resolve(); // Required `await` for `async`. + return Promise.reject(new Error("not a thing")); + } + ), + }; + }, + "http" + ); const logs = countLogEntries(worker); @@ -811,17 +928,21 @@ describe("FunctionsEmulator-Runtime", () => { it("Should handle async/runWith functions for Express handlers", async () => { const frb = FunctionRuntimeBundles.onRequest; - const worker = startRuntimeWithFunctions(frb, () => { - require("firebase-admin").initializeApp(); - return { - function_id: require("firebase-functions") - .runWith({}) - .https.onRequest(async (req: any, res: any) => { - await Promise.resolve(); // Required `await` for `async`. - return Promise.reject(new Error("not a thing")); - }), - }; - }); + const worker = startRuntimeWithFunctions( + frb, + () => { + require("firebase-admin").initializeApp(); + return { + function_id: require("firebase-functions") + .runWith({}) + .https.onRequest(async (req: any, res: any) => { + await Promise.resolve(); // Required `await` for `async`. + return Promise.reject(new Error("not a thing")); + }), + }; + }, + "http" + ); const logs = countLogEntries(worker); diff --git a/scripts/integration-helpers/framework.ts b/scripts/integration-helpers/framework.ts index ebc12749a5c..c0104dd3e3a 100644 --- a/scripts/integration-helpers/framework.ts +++ b/scripts/integration-helpers/framework.ts @@ -8,6 +8,9 @@ const FIREBASE_PROJECT_ZONE = "us-central1"; * Markers this test looks for in the emulator process stdout * as one test for whether a cloud function was triggered. */ +/* Functions V2 */ +const PUBSUB_FUNCTION_V2_LOG = "========== PUBSUB V2 FUNCTION =========="; +/* Functions V1 */ const RTDB_FUNCTION_LOG = "========== RTDB FUNCTION =========="; const FIRESTORE_FUNCTION_LOG = "========== FIRESTORE FUNCTION =========="; const PUBSUB_FUNCTION_LOG = "========== PUBSUB FUNCTION =========="; @@ -44,10 +47,16 @@ export class TriggerEndToEndTest { storageEmulatorHost = "localhost"; storageEmulatorPort = 0; allEmulatorsStarted = false; + + /* Functions V1 */ rtdbTriggerCount = 0; firestoreTriggerCount = 0; pubsubTriggerCount = 0; authTriggerCount = 0; + + /* Functions V2 */ + pubsubV2TriggerCount = 0; + rtdbFromFirestore = false; firestoreFromRtdb = false; rtdbFromRtdb = false; @@ -88,6 +97,7 @@ export class TriggerEndToEndTest { }); cli.process?.stdout.on("data", (data) => { + /* Functions V1 */ if (data.includes(RTDB_FUNCTION_LOG)) { this.rtdbTriggerCount++; } @@ -100,6 +110,10 @@ export class TriggerEndToEndTest { if (data.includes(AUTH_FUNCTION_LOG)) { this.authTriggerCount++; } + /* Functions V2 */ + if (data.includes(PUBSUB_FUNCTION_LOG)) { + this.pubsubV2TriggerCount++; + } }); this.cliProcess = cli; diff --git a/scripts/triggers-end-to-end-tests/functions/index.js b/scripts/triggers-end-to-end-tests/functions/index.js index efbb927bb9b..abbb4473a4e 100644 --- a/scripts/triggers-end-to-end-tests/functions/index.js +++ b/scripts/triggers-end-to-end-tests/functions/index.js @@ -1,15 +1,26 @@ const admin = require("firebase-admin"); const functions = require("firebase-functions"); +let functionsV2; +try { + functionsV2 = require("firebase-functions/v2"); +} catch { + // TODO: firebase-functions/lib path is unsupported, but this is the only way to access the v2 namespace in Node 10. + // Remove this ugly hack once we cut support for Node 10. + functionsV2 = require("firebase-functions/lib/v2"); +} const { PubSub } = require("@google-cloud/pubsub"); /* * Log snippets that the driver program above checks for. Be sure to update * ../test.js if you plan on changing these. */ +/* Functions V1 */ const RTDB_FUNCTION_LOG = "========== RTDB FUNCTION =========="; const FIRESTORE_FUNCTION_LOG = "========== FIRESTORE FUNCTION =========="; const PUBSUB_FUNCTION_LOG = "========== PUBSUB FUNCTION =========="; const AUTH_FUNCTION_LOG = "========== AUTH FUNCTION =========="; +/* Functions V2 */ +const PUBSUB_FUNCTION_V2_LOG = "========== PUBSUB V2 FUNCTION =========="; /* * We install onWrite triggers for START_DOCUMENT_NAME in both the firestore and @@ -121,6 +132,13 @@ exports.pubsubReaction = functions.pubsub.topic(PUBSUB_TOPIC).onPublish((msg /* return true; }); +exports.pubsubv2reaction = functionsV2.pubsub.onMessagePublished(PUBSUB_TOPIC, (cloudevent) => { + console.log(PUBSUB_FUNCTION_V2_LOG); + console.log("Message", JSON.stringify(cloudevent.data.message.json)); + console.log("Attributes", JSON.stringify(cloudevent.data.message.attributes)); + return true; +}); + exports.pubsubScheduled = functions.pubsub.schedule("every mon 07:00").onRun((context) => { console.log(PUBSUB_FUNCTION_LOG); console.log("Resource", JSON.stringify(context.resource)); diff --git a/scripts/triggers-end-to-end-tests/tests.ts b/scripts/triggers-end-to-end-tests/tests.ts index b81372a219c..4f33215d3f6 100755 --- a/scripts/triggers-end-to-end-tests/tests.ts +++ b/scripts/triggers-end-to-end-tests/tests.ts @@ -197,6 +197,7 @@ describe("pubsub emulator function triggers", () => { it("should have have triggered cloud functions", () => { expect(test.pubsubTriggerCount).to.equal(1); + expect(test.pubsubV2TriggerCount).to.equal(1); }); it("should write to the scheduled pubsub emulator", async function (this) { diff --git a/src/emulator/events/types.ts b/src/emulator/events/types.ts index 7b3dcf187fe..4119607ed50 100644 --- a/src/emulator/events/types.ts +++ b/src/emulator/events/types.ts @@ -9,6 +9,7 @@ import * as _ from "lodash"; import { Resource } from "firebase-functions"; +import * as express from "express"; /** * Wire formal for v1beta1 EventFlow. @@ -38,6 +39,49 @@ export interface Event { data: any; } +/** + * A CloudEvent is a cross-platform format for encoding a serverless event. + * More information can be found in https://github.com/cloudevents/spec + */ +export interface CloudEvent { + /** Version of the CloudEvents spec for this event. */ + specversion: string; + + /** A globally unique ID for this event. */ + id: string; + + /** The resource which published this event. */ + source: string; + + /** The resource, provided by source, that this event relates to */ + subject?: string; + + /** The type of event that this represents. */ + type: string; + + /** When this event occurred. */ + time: string; + + /** Information about this specific event. */ + data: T; + + /** + * A map of template parameter name to value for subject strings. + * + * This map is only available on some event types that allow templates + * in the subject string, such as Firestore. When listening to a document + * template "/users/{uid}", an event with subject "/documents/users/1234" + * would have a params of {"uid": "1234"}. + * + * Params are generated inside the firebase-functions SDK and are not + * part of the CloudEvents spec nor the payload that a Cloud Function + * actually receives. + */ + params?: Record; +} + +export type CloudEventContext = Omit, "data" | "params">; + /** * Legacy AuthMode format. */ @@ -47,7 +91,7 @@ export interface AuthMode { } /** - * Utilities for determining event types. + * Utilities for operating on event types. */ export class EventUtils { static isEvent(proto: any): proto is Event { @@ -57,4 +101,24 @@ export class EventUtils { static isLegacyEvent(proto: any): proto is LegacyEvent { return _.has(proto, "data") && _.has(proto, "resource"); } + + static isBinaryCloudEvent(req: express.Request): boolean { + return !!( + req.header("ce-type") && + req.header("ce-specversion") && + req.header("ce-source") && + req.header("ce-id") + ); + } + + static extractBinaryCloudEventContext(req: express.Request): CloudEventContext { + const context: Partial = {}; + for (const name of Object.keys(req.headers)) { + if (name.startsWith("ce-")) { + const attributeName = name.substr("ce-".length) as keyof CloudEventContext; + context[attributeName] = req.header(name); + } + } + return context as CloudEventContext; + } } diff --git a/src/emulator/functionsEmulator.ts b/src/emulator/functionsEmulator.ts index 213fed1ecfc..4e3a82b2b09 100644 --- a/src/emulator/functionsEmulator.ts +++ b/src/emulator/functionsEmulator.ts @@ -26,7 +26,7 @@ import { ChildProcess, spawnSync } from "child_process"; import { emulatedFunctionsByRegion, EmulatedTriggerDefinition, - EmulatedTriggerType, + SignatureType, EventSchedule, EventTrigger, formatHost, @@ -34,6 +34,7 @@ import { FunctionsRuntimeBundle, FunctionsRuntimeFeatures, getFunctionService, + getSignatureType, HttpConstants, ParsedTriggerDefinition, } from "./functionsEmulatorShared"; @@ -53,6 +54,7 @@ import { getProjectAdminSdkConfigOrCached, } from "./adminSdkConfig"; import * as functionsEnv from "../functions/env"; +import { EventUtils } from "./events/types"; const EVENT_INVOKE = "functions:invoke"; @@ -229,7 +231,19 @@ export class FunctionsEmulator implements EmulatorInstance { const projectId = req.params.project_id; const reqBody = (req as RequestWithRawBody).rawBody; - const proto = JSON.parse(reqBody.toString()); + let proto = JSON.parse(reqBody.toString()); + + if (req.headers["content-type"]?.includes("cloudevent")) { + // Convert request payload to CloudEvent. + // TODO(taeold): Converting request payload to CloudEvent object should be done by the functions runtime. + // However, the Functions Emulator communicates with the runtime via socket not HTTP, and CE metadata + // embedded in HTTP header may get lost. Once the Functions Emulator is refactored to communicate to the + // runtime instances via HTTP, move this logic there. + if (EventUtils.isBinaryCloudEvent(req)) { + proto = EventUtils.extractBinaryCloudEventContext(req); + proto.data = req.body; + } + } this.workQueue.submit(() => { this.logger.log("DEBUG", `Accepted request ${req.method} ${req.url} --> ${triggerId}`); @@ -288,7 +302,7 @@ export class FunctionsEmulator implements EmulatorInstance { startFunctionRuntime( triggerId: string, targetName: string, - triggerType: EmulatedTriggerType, + signatureType: SignatureType, proto?: any, runtimeOpts?: InvokeRuntimeOpts ): RuntimeWorker { @@ -306,7 +320,6 @@ export class FunctionsEmulator implements EmulatorInstance { proto, triggerId, targetName, - triggerType, }; const opts = runtimeOpts || { nodeBinary: this.nodeBinary, @@ -315,7 +328,7 @@ export class FunctionsEmulator implements EmulatorInstance { const worker = this.invokeRuntime( runtimeBundle, opts, - this.getRuntimeEnvs({ targetName, triggerType }) + this.getRuntimeEnvs({ targetName, signatureType }) ); return worker; } @@ -485,6 +498,7 @@ export class FunctionsEmulator implements EmulatorInstance { } else if (definition.eventTrigger) { const service: string = getFunctionService(definition); const key = this.getTriggerKey(definition); + const signature = getSignatureType(definition); switch (service) { case Constants.SERVICE_FIRESTORE: @@ -506,6 +520,7 @@ export class FunctionsEmulator implements EmulatorInstance { definition.name, key, definition.eventTrigger, + signature, definition.schedule ); break; @@ -634,6 +649,7 @@ export class FunctionsEmulator implements EmulatorInstance { triggerName: string, key: string, eventTrigger: EventTrigger, + signatureType: SignatureType, schedule: EventSchedule | undefined ): Promise { const pubsubPort = EmulatorRegistry.getPort(Emulators.PUBSUB); @@ -659,7 +675,7 @@ export class FunctionsEmulator implements EmulatorInstance { } try { - await pubsubEmulator.addTrigger(topic, key); + await pubsubEmulator.addTrigger(topic, key, signatureType); return true; } catch (e) { return false; @@ -745,7 +761,6 @@ export class FunctionsEmulator implements EmulatorInstance { projectId: this.args.projectId, triggerId: "", targetName: "", - triggerType: undefined, emulators: { firestore: EmulatorRegistry.getInfo(Emulators.FIRESTORE), database: EmulatorRegistry.getInfo(Emulators.DATABASE), @@ -852,7 +867,7 @@ export class FunctionsEmulator implements EmulatorInstance { getSystemEnvs(triggerDef?: { targetName: string; - triggerType: EmulatedTriggerType; + signatureType: SignatureType; }): Record { const envs: Record = {}; @@ -865,9 +880,8 @@ export class FunctionsEmulator implements EmulatorInstance { if (triggerDef) { const service = triggerDef.targetName; const target = service.replace(/-/g, "."); - const mode = triggerDef.triggerType === EmulatedTriggerType.BACKGROUND ? "event" : "http"; envs.FUNCTION_TARGET = target; - envs.FUNCTION_SIGNATURE_TYPE = mode; + envs.FUNCTION_SIGNATURE_TYPE = triggerDef.signatureType; envs.K_SERVICE = service; } return envs; @@ -939,7 +953,7 @@ export class FunctionsEmulator implements EmulatorInstance { getRuntimeEnvs(triggerDef?: { targetName: string; - triggerType: EmulatedTriggerType; + signatureType: SignatureType; }): Record { return { ...this.getUserEnvs(), @@ -1079,7 +1093,7 @@ export class FunctionsEmulator implements EmulatorInstance { const worker = this.startFunctionRuntime( trigger.id, trigger.name, - EmulatedTriggerType.BACKGROUND, + getSignatureType(trigger), proto ); @@ -1218,12 +1232,7 @@ export class FunctionsEmulator implements EmulatorInstance { ); } } - const worker = this.startFunctionRuntime( - trigger.id, - trigger.name, - EmulatedTriggerType.HTTPS, - undefined - ); + const worker = this.startFunctionRuntime(trigger.id, trigger.name, "http", undefined); worker.onLogs((el: EmulatorLog) => { if (el.level === "FATAL") { diff --git a/src/emulator/functionsEmulatorRuntime.ts b/src/emulator/functionsEmulatorRuntime.ts index ddc0be5a89d..2963f4ba196 100644 --- a/src/emulator/functionsEmulatorRuntime.ts +++ b/src/emulator/functionsEmulatorRuntime.ts @@ -12,6 +12,8 @@ import { getEmulatedTriggersFromDefinitions, FunctionsRuntimeArgs, HttpConstants, + getSignatureType, + SignatureType, } from "./functionsEmulatorShared"; import { compareVersionStrings } from "./functionsEmulatorUtils"; import * as express from "express"; @@ -778,11 +780,16 @@ async function processHTTPS(frb: FunctionsRuntimeBundle, trigger: EmulatedTrigge async function processBackground( frb: FunctionsRuntimeBundle, - trigger: EmulatedTrigger + trigger: EmulatedTrigger, + signature: SignatureType ): Promise { const proto = frb.proto; logDebug("ProcessBackground", proto); + if (signature === "cloudevent") { + return runCloudEvent(proto, trigger.getRawFunction()); + } + // All formats of the payload should carry a "data" property. The "context" property does // not exist in all versions. Where it doesn't exist, context is everything besides data. const data = proto.data; @@ -826,6 +833,14 @@ async function runBackground(proto: any, func: CloudFunction): Promise }); } +async function runCloudEvent(event: unknown, func: CloudFunction): Promise { + logDebug("RunCloudEvent", event); + + await runFunction(() => { + return func(event); + }); +} + async function runHTTPS( args: any[], func: (a: express.Request, b: express.Response) => Promise @@ -887,9 +902,9 @@ async function invokeTrigger( const trigger = triggers[frb.triggerId]; logDebug("triggerDefinition", trigger.definition); - const mode = trigger.definition.httpsTrigger ? "HTTPS" : "BACKGROUND"; + const signature = getSignatureType(trigger.definition); - logDebug(`Running ${frb.triggerId} in mode ${mode}`); + logDebug(`Running ${frb.triggerId} in signature ${signature}`); let seconds = 0; const timerId = setInterval(() => { @@ -911,11 +926,12 @@ async function invokeTrigger( }, trigger.timeoutMs); } - switch (mode) { - case "BACKGROUND": - await processBackground(frb, triggers[frb.triggerId]); + switch (signature) { + case "event": + case "cloudevent": + await processBackground(frb, triggers[frb.triggerId], signature); break; - case "HTTPS": + case "http": await processHTTPS(frb, triggers[frb.triggerId]); break; } @@ -980,6 +996,7 @@ async function initializeRuntime( } else { require("../deploy/functions/runtimes/node/extractTriggers")(triggerModule, parsedDefinitions); } + const triggerDefinitions: EmulatedTriggerDefinition[] = emulatedFunctionsByRegion( parsedDefinitions ); diff --git a/src/emulator/functionsEmulatorShared.ts b/src/emulator/functionsEmulatorShared.ts index f9df87c673b..fc5b9340d90 100644 --- a/src/emulator/functionsEmulatorShared.ts +++ b/src/emulator/functionsEmulatorShared.ts @@ -4,13 +4,12 @@ import * as os from "os"; import * as path from "path"; import * as express from "express"; import * as fs from "fs"; + import { Constants } from "./constants"; import { InvokeRuntimeOpts } from "./functionsEmulator"; +import { FunctionsPlatform } from "../deploy/functions/backend"; -export enum EmulatedTriggerType { - BACKGROUND = "BACKGROUND", - HTTPS = "HTTPS", -} +export type SignatureType = "http" | "event" | "cloudevent"; export interface ParsedTriggerDefinition { entryPoint: string; @@ -22,6 +21,7 @@ export interface ParsedTriggerDefinition { eventTrigger?: EventTrigger; schedule?: EventSchedule; labels?: { [key: string]: any }; + platform?: FunctionsPlatform; } export interface EmulatedTriggerDefinition extends ParsedTriggerDefinition { @@ -55,7 +55,6 @@ export interface FunctionsRuntimeBundle { proto?: any; triggerId?: string; targetName?: string; - triggerType?: EmulatedTriggerType; emulators: { firestore?: { host: string; @@ -290,3 +289,13 @@ export function formatHost(info: { host: string; port: number }): string { return `${info.host}:${info.port}`; } } + +export function getSignatureType(def: EmulatedTriggerDefinition): SignatureType { + if (def.httpsTrigger) { + return "http"; + } + // TODO: As implemented, emulated CF3v1 functions cannot receive events in CloudEvent format, and emulated CF3v2 + // functions cannot receive events in legacy format. This conflicts with our goal of introducing a 'compat' layer + // that allows CF3v1 functions to target GCFv2 and vice versa. + return def.platform === "gcfv2" ? "cloudevent" : "event"; +} diff --git a/src/emulator/functionsEmulatorShell.ts b/src/emulator/functionsEmulatorShell.ts index c69009b78df..054b4e3ed48 100644 --- a/src/emulator/functionsEmulatorShell.ts +++ b/src/emulator/functionsEmulatorShell.ts @@ -1,6 +1,10 @@ import * as uuid from "uuid"; import { FunctionsEmulator } from "./functionsEmulator"; -import { EmulatedTriggerDefinition, EmulatedTriggerType } from "./functionsEmulatorShared"; +import { + EmulatedTriggerDefinition, + getSignatureType, + SignatureType, +} from "./functionsEmulatorShared"; import * as utils from "../utils"; import { logger } from "../logger"; import { FirebaseError } from "../error"; @@ -64,7 +68,7 @@ export class FunctionsEmulatorShell implements FunctionsShellController { data, }; - this.emu.startFunctionRuntime(trigger.id, trigger.name, EmulatedTriggerType.BACKGROUND, proto); + this.emu.startFunctionRuntime(trigger.id, trigger.name, getSignatureType(trigger), proto); } private getTrigger(name: string): EmulatedTriggerDefinition { diff --git a/src/emulator/pubsubEmulator.ts b/src/emulator/pubsubEmulator.ts index e3e8897575e..a6bd1e020fa 100644 --- a/src/emulator/pubsubEmulator.ts +++ b/src/emulator/pubsubEmulator.ts @@ -1,4 +1,5 @@ import * as uuid from "uuid"; +import { MessagePublishedData } from "@google/events/cloud/pubsub/v1/MessagePublishedData"; import { Message, PubSub, Subscription } from "@google-cloud/pubsub"; import * as api from "../api"; @@ -8,6 +9,7 @@ import { EmulatorInfo, EmulatorInstance, Emulators } from "../emulator/types"; import { Constants } from "./constants"; import { FirebaseError } from "../error"; import { EmulatorRegistry } from "./registry"; +import { SignatureType } from "./functionsEmulatorShared"; export interface PubsubEmulatorArgs { projectId: string; @@ -16,14 +18,19 @@ export interface PubsubEmulatorArgs { auto_download?: boolean; } +interface Trigger { + triggerKey: string; + signatureType: SignatureType; +} + export class PubsubEmulator implements EmulatorInstance { pubsub: PubSub; // Map of topic name to a list of functions to trigger - triggers: Map>; + triggersForTopic: Map; // Map of topic name to a PubSub subscription object - subscriptions: Map; + subscriptionForTopic: Map; private logger = EmulatorLogger.forEmulator(Emulators.PUBSUB); @@ -34,8 +41,8 @@ export class PubsubEmulator implements EmulatorInstance { projectId: this.args.projectId, }); - this.triggers = new Map(); - this.subscriptions = new Map(); + this.triggersForTopic = new Map(); + this.subscriptionForTopic = new Map(); } async start(): Promise { @@ -66,11 +73,18 @@ export class PubsubEmulator implements EmulatorInstance { return Emulators.PUBSUB; } - async addTrigger(topicName: string, trigger: string) { - this.logger.logLabeled("DEBUG", "pubsub", `addTrigger(${topicName}, ${trigger})`); + async addTrigger(topicName: string, triggerKey: string, signatureType: SignatureType) { + this.logger.logLabeled( + "DEBUG", + "pubsub", + `addTrigger(${topicName}, ${triggerKey}, ${signatureType})` + ); - const topicTriggers = this.triggers.get(topicName) || new Set(); - if (topicTriggers.has(topicName) && this.subscriptions.has(topicName)) { + const triggers = this.triggersForTopic.get(topicName) || []; + if ( + triggers.some((t) => t.triggerKey === triggerKey) && + this.subscriptionForTopic.has(topicName) + ) { this.logger.logLabeled("DEBUG", "pubsub", "Trigger already exists"); return; } @@ -105,20 +119,74 @@ export class PubsubEmulator implements EmulatorInstance { this.onMessage(topicName, message); }); - topicTriggers.add(trigger); - this.triggers.set(topicName, topicTriggers); - this.subscriptions.set(topicName, sub); + triggers.push({ triggerKey, signatureType }); + this.triggersForTopic.set(topicName, triggers); + this.subscriptionForTopic.set(topicName, sub); + } + + private getRequestOptions( + topic: string, + message: Message, + signatureType: SignatureType + ): Record { + const baseOpts = { + origin: `http://${EmulatorRegistry.getInfoHostString( + EmulatorRegistry.get(Emulators.FUNCTIONS)!.getInfo() + )}`, + }; + if (signatureType === "event") { + return { + ...baseOpts, + data: { + context: { + eventId: uuid.v4(), + resource: { + service: "pubsub.googleapis.com", + name: `projects/${this.args.projectId}/topics/${topic}`, + }, + eventType: "google.pubsub.topic.publish", + timestamp: message.publishTime.toISOString(), + }, + data: { + data: message.data, + attributes: message.attributes, + }, + }, + }; + } else if (signatureType === "cloudevent") { + const data: MessagePublishedData = { + message: { + messageId: message.id, + publishTime: message.publishTime, + attributes: message.attributes, + orderingKey: message.orderingKey, + data: message.data.toString("base64"), + }, + subscription: this.subscriptionForTopic.get(topic)!.name, + }; + const ce = { + specVersion: 1, + type: "google.cloud.pubsub.topic.v1.messagePublished", + source: `//pubsub.googleapis.com/projects/${this.args.projectId}/topics/${topic}`, + data, + }; + return { + ...baseOpts, + headers: { "Content-Type": "application/cloudevents+json; charset=UTF-8" }, + data: ce, + }; + } + throw new FirebaseError(`Unsupported trigger signature: ${signatureType}`); } private async onMessage(topicName: string, message: Message) { this.logger.logLabeled("DEBUG", "pubsub", `onMessage(${topicName}, ${message.id})`); - const topicTriggers = this.triggers.get(topicName); - if (!topicTriggers || topicTriggers.size === 0) { + const triggers = this.triggersForTopic.get(topicName); + if (!triggers || triggers.length === 0) { throw new FirebaseError(`No trigger for topic: ${topicName}`); } - const functionsEmu = EmulatorRegistry.get(Emulators.FUNCTIONS); - if (!functionsEmu) { + if (!EmulatorRegistry.get(Emulators.FUNCTIONS)) { throw new FirebaseError( `Attempted to execute pubsub trigger for topic ${topicName} but could not find Functions emulator` ); @@ -127,50 +195,24 @@ export class PubsubEmulator implements EmulatorInstance { this.logger.logLabeled( "DEBUG", "pubsub", - `Executing ${topicTriggers.size} matching triggers (${JSON.stringify( - Array.from(topicTriggers) + `Executing ${triggers.length} matching triggers (${JSON.stringify( + triggers.map((t) => t.triggerKey) )})` ); - // We need to do one POST request for each matching trigger and only - // 'ack' the message when they are all complete. - let remaining = topicTriggers.size; - for (const trigger of topicTriggers) { - const body = { - context: { - eventId: uuid.v4(), - resource: { - service: "pubsub.googleapis.com", - name: `projects/${this.args.projectId}/topics/${topicName}`, - }, - eventType: "google.pubsub.topic.publish", - timestamp: message.publishTime.toISOString(), - }, - data: { - data: message.data, - attributes: message.attributes, - }, - }; - + for (const { triggerKey, signatureType } of triggers) { + const reqOpts = this.getRequestOptions(topicName, message, signatureType); try { await api.request( "POST", - `/functions/projects/${this.args.projectId}/triggers/${trigger}`, - { - origin: `http://${EmulatorRegistry.getInfoHostString(functionsEmu.getInfo())}`, - data: body, - } + `/functions/projects/${this.args.projectId}/triggers/${triggerKey}`, + reqOpts ); } catch (e) { this.logger.logLabeled("DEBUG", "pubsub", e); } - - // If this is the last trigger we need to run, ack the message. - remaining--; - if (remaining <= 0) { - this.logger.logLabeled("DEBUG", "pubsub", `Acking message ${message.id}`); - message.ack(); - } } + this.logger.logLabeled("DEBUG", "pubsub", `Acking message ${message.id}`); + message.ack(); } } diff --git a/src/test/emulators/functionsRuntimeWorker.spec.ts b/src/test/emulators/functionsRuntimeWorker.spec.ts index 58f09a195b6..759e4baf2d0 100644 --- a/src/test/emulators/functionsRuntimeWorker.spec.ts +++ b/src/test/emulators/functionsRuntimeWorker.spec.ts @@ -4,7 +4,6 @@ import { EventEmitter } from "events"; import { FunctionsRuntimeArgs, FunctionsRuntimeBundle, - EmulatedTriggerType, } from "../../emulator/functionsEmulatorShared"; import { RuntimeWorker, @@ -91,7 +90,6 @@ class WorkerStateCounter { class MockRuntimeBundle implements FunctionsRuntimeBundle { projectId = "project-1234"; - triggerType = EmulatedTriggerType.HTTPS; cwd = "/home/users/dir"; emulators = {}; adminSdkConfig = {