diff --git a/changelog.txt b/changelog.txt index e69de29bb2d..09ae64994f4 100644 --- a/changelog.txt +++ b/changelog.txt @@ -0,0 +1,2 @@ +* Fixes a bug where the functions emulator did not work with `firebase-functions` versions `3.x.x`. +* Make the Functions emulator automatically point to the RTDB emulator when running. \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 3262a786f44..63ef6d0dcd4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -769,9 +769,9 @@ } }, "@types/jsonwebtoken": { - "version": "7.2.8", - "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-7.2.8.tgz", - "integrity": "sha512-XENN3YzEB8D6TiUww0O8SRznzy1v+77lH7UmuN54xq/IHIsyWjWOzZuFFTtoiRuaE782uAoRwBe/wwow+vQXZw==", + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-8.3.2.tgz", + "integrity": "sha512-Mkjljd9DTpkPlrmGfTJvcP4aBU7yO2QmW7wNVhV4/6AEUxYoacqU7FJU/N0yFEHTsIrE4da3rUrjrR5ejicFmA==", "dev": true, "requires": { "@types/node": "*" @@ -3031,18 +3031,18 @@ } }, "firebase-functions": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/firebase-functions/-/firebase-functions-2.3.1.tgz", - "integrity": "sha512-MPeuzGFLS63VqfGErgf+kbZinXE4SoK+jxXh1NBWrjYUjXlOJBKRmVNAFJoQB8wzCvOtBuDYjGcnBNz6sKXsfw==", + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/firebase-functions/-/firebase-functions-3.0.1.tgz", + "integrity": "sha512-ByVuJXQcV6dvOlbNWdPq9JfIuAiwJgQwevFkuRf1OepUqH1eIMtu16bXl5s/gTODvUSHyiYEIMYeCG4hekOmtg==", "dev": true, "requires": { - "@types/cors": "^2.8.1", + "@types/cors": "^2.8.5", "@types/express": "^4.11.1", - "@types/jsonwebtoken": "^7.2.6", - "@types/lodash": "^4.14.34", + "@types/jsonwebtoken": "^8.3.2", + "@types/lodash": "^4.14.133", "cors": "^2.8.4", - "express": "^4.16.2", - "jsonwebtoken": "^8.2.1", + "express": "^4.17.1", + "jsonwebtoken": "^8.3.2", "lodash": "^4.6.1" } }, diff --git a/package.json b/package.json index 9ecfa924514..b1c408c2257 100644 --- a/package.json +++ b/package.json @@ -130,7 +130,7 @@ "eslint-plugin-prettier": "^3.0.0", "firebase": "^2.4.2", "firebase-admin": "^8.1.0", - "firebase-functions": "^2.2.1", + "firebase-functions": "^3.0.1", "mocha": "^5.0.5", "nock": "^9.3.3", "nyc": "^14.0.0", diff --git a/src/emulator/events/types.ts b/src/emulator/events/types.ts index 935c0402ae0..7b3dcf187fe 100644 --- a/src/emulator/events/types.ts +++ b/src/emulator/events/types.ts @@ -57,30 +57,4 @@ export class EventUtils { static isLegacyEvent(proto: any): proto is LegacyEvent { return _.has(proto, "data") && _.has(proto, "resource"); } - - static convertFromLegacy(event: LegacyEvent, service: string): Event { - // TODO(samstern): Unclear what we should do with "params" and "authMode" - return { - context: { - eventId: event.eventId || "", - timestamp: event.timestamp || "", - eventType: event.eventType || "", - resource: { - name: event.resource || "", - service, - }, - }, - data: event.data, - }; - } - - static convertToLegacy(event: Event): LegacyEvent { - return { - eventId: event.context.eventId, - timestamp: event.context.timestamp, - eventType: event.context.eventType, - resource: event.context.resource.name, - data: event.data, - }; - } } diff --git a/src/emulator/functionsEmulator.ts b/src/emulator/functionsEmulator.ts index 53ad18a3cb8..1520e70a028 100644 --- a/src/emulator/functionsEmulator.ts +++ b/src/emulator/functionsEmulator.ts @@ -259,6 +259,7 @@ export class FunctionsEmulator implements EmulatorInstance { ...bundleTemplate, ports: { firestore: EmulatorRegistry.getPort(Emulators.FIRESTORE), + database: EmulatorRegistry.getPort(Emulators.DATABASE), }, proto, triggerId, @@ -613,6 +614,17 @@ You can probably fix this by running "npm install ${ reject(); return; } + + if (res.statusCode === 200) { + EmulatorLogger.logLabeled( + "SUCCESS", + "functions", + `Trigger "${ + definition.name + }" has been acknowledged by the Realtime Database emulator.` + ); + } + resolve(); } ); @@ -652,7 +664,7 @@ You can probably fix this by running "npm install ${ return; } - if (JSON.stringify(JSON.parse(body)) === "{}") { + if (res.statusCode === 200) { EmulatorLogger.logLabeled( "SUCCESS", "functions", diff --git a/src/emulator/functionsEmulatorRuntime.ts b/src/emulator/functionsEmulatorRuntime.ts index 3b175b244fd..09057b176ab 100644 --- a/src/emulator/functionsEmulatorRuntime.ts +++ b/src/emulator/functionsEmulatorRuntime.ts @@ -1,5 +1,5 @@ import { EmulatorLog } from "./types"; -import { DeploymentOptions } from "firebase-functions"; +import { CloudFunction, DeploymentOptions } from "firebase-functions"; import { EmulatedTrigger, EmulatedTriggerDefinition, @@ -8,14 +8,12 @@ import { FunctionsRuntimeBundle, FunctionsRuntimeFeatures, getEmulatedTriggersFromDefinitions, - getFunctionService, getTemporarySocketPath, } from "./functionsEmulatorShared"; import * as express from "express"; import * as path from "path"; import * as admin from "firebase-admin"; import * as bodyParser from "body-parser"; -import { EventUtils } from "./events/types"; import { URL } from "url"; import * as _ from "lodash"; @@ -52,6 +50,42 @@ function isExists(obj: any): boolean { return obj !== undefined; } +/** + * See admin.credential.Credential. + */ +function makeFakeCredentials(): any { + return { + getAccessToken: () => { + return Promise.resolve({ + expires_in: 1000000, + access_token: "owner", + }); + }, + + // TODO: Should we fill in the parts of the certificate we like? + getCertificate: () => { + return {}; + }, + }; +} + +interface PackageJSON { + dependencies: { [name: string]: any }; + devDependencies: { [name: string]: any }; +} + +interface ModuleResolution { + declared: boolean; + installed: boolean; + version?: string; +} + +interface ModuleVersion { + major: number; + minor: number; + patch: number; +} + /* This helper is used to create mocks for Firebase SDKs. It simplifies creation of Proxy objects by allowing us to easily overide some or all of an objects methods. When placed back into require's @@ -148,52 +182,66 @@ class Proxied { } } +async function resolveDeveloperNodeModule( + frb: FunctionsRuntimeBundle, + pkg: PackageJSON, + name: string +): Promise { + const dependencies = pkg.dependencies; + const devDependencies = pkg.devDependencies; + const isInPackageJSON = dependencies[name] || devDependencies[name]; + + // If there's no reference to the module in their package.json, prompt them to install it + if (!isInPackageJSON) { + return { declared: false, installed: false }; + } + + // Once we know it's in the package.json, make sure it's actually `npm install`ed + const modResolution = await requireResolveAsync(name, { paths: [frb.cwd] }).catch(NoOp); + + if (!modResolution) { + return { declared: true, installed: false }; + } + + const modPackageJSON = require(path.join(findModuleRoot(name, modResolution), "package.json")); + + return { + declared: true, + installed: true, + version: modPackageJSON.version, + }; +} + async function verifyDeveloperNodeModules(frb: FunctionsRuntimeBundle): Promise { - let pkg; - try { - pkg = require(`${frb.cwd}/package.json`); - } catch (err) { + const pkg = requirePackageJson(frb); + if (!pkg) { new EmulatorLog("SYSTEM", "missing-package-json", "").log(); return false; } const modBundles = [ - { name: "firebase-admin", isDev: false, minVersion: 7 }, - { name: "firebase-functions", isDev: false, minVersion: 2 }, + { name: "firebase-admin", isDev: false, minVersion: 8 }, + { name: "firebase-functions", isDev: false, minVersion: 3 }, ]; for (const modBundle of modBundles) { - const dependencies = pkg.dependencies || {}; - const devDependencies = pkg.devDependencies || {}; - const isInPackageJSON = dependencies[modBundle.name] || devDependencies[modBundle.name]; + const resolution = await resolveDeveloperNodeModule(frb, pkg, modBundle.name); /* If there's no reference to the module in their package.json, prompt them to install it */ - if (!isInPackageJSON) { + if (!resolution.declared) { new EmulatorLog("SYSTEM", "missing-module", "", modBundle).log(); return false; } - /* - Once we know it's in the package.json, make sure it's actually `npm install`ed - */ - const modResolution = await requireResolveAsync(modBundle.name, { paths: [frb.cwd] }).catch( - NoOp - ); - - if (!modResolution) { + if (!resolution.installed) { new EmulatorLog("SYSTEM", "uninstalled-module", "", modBundle).log(); return false; } - const modPackageJSON = require(path.join( - findModuleRoot(modBundle.name, modResolution), - "package.json" - )); - const modMajorVersion = parseInt((modPackageJSON.version || "0").split("."), 10); - - if (modMajorVersion < modBundle.minVersion) { + const versionInfo = parseVersionString(resolution.version); + if (versionInfo.major < modBundle.minVersion) { new EmulatorLog("SYSTEM", "out-of-date-module", "", modBundle).log(); return false; } @@ -202,6 +250,38 @@ async function verifyDeveloperNodeModules(frb: FunctionsRuntimeBundle): Promise< return true; } +/** + * Get the developer's package.json file. + */ +function requirePackageJson(frb: FunctionsRuntimeBundle): PackageJSON | undefined { + try { + const pkg = require(`${frb.cwd}/package.json`); + return { + dependencies: pkg.dependencies || {}, + devDependencies: pkg.devDependencies || {}, + }; + } catch (err) { + return undefined; + } +} + +/** + * Parse a semver version string into parts, filling in 0s where empty. + */ +function parseVersionString(version?: string): ModuleVersion { + const parts = (version || "0").split("."); + + // Make sure "parts" always has 3 elements. Extras are ignored. + parts.push("0"); + parts.push("0"); + + return { + major: parseInt(parts[0], 10), + minor: parseInt(parts[1], 10), + patch: parseInt(parts[2], 10), + }; +} + /* We mock out a ton of different paths that we can take to network I/O. It doesn't matter if they overlap (like TLS and HTTPS) because the dev will either whitelist, block, or allow for one @@ -438,6 +518,7 @@ async function InitializeFirebaseAdminStubs(frb: FunctionsRuntimeBundle): Promis "'default credentials' error." ).log(); } + hasInitializedSettings = true; }; @@ -448,9 +529,19 @@ async function InitializeFirebaseAdminStubs(frb: FunctionsRuntimeBundle): Promis return adminModuleTarget.initializeApp(opts, appName); } - new EmulatorLog("SYSTEM", "default-admin-app-used", "").log(); + const config = JSON.parse(process.env.FIREBASE_CONFIG || "{}"); + new EmulatorLog("SYSTEM", "default-admin-app-used", `config=${config}`).log(); + + // TODO: Is there any possible harm in this? + config.credential = makeFakeCredentials(); + + if (frb.ports.database) { + config.databaseURL = `http://localhost:${frb.ports.database}?ns=${frb.projectId}`; + new EmulatorLog("SYSTEM", `Overriding database URL: ${config.databaseURL}`, "").log(); + } + app = adminModuleTarget.initializeApp({ - ...JSON.parse(process.env.FIREBASE_CONFIG || "{}"), + ...config, ...opts, }); return app; @@ -495,13 +586,12 @@ function ProtectEnvironmentalVariables(): void { process.env.GOOGLE_APPLICATION_CREDENTIALS = ""; } -function InitializeEnvironmentalVariables(projectId: string): void { - process.env.GCLOUD_PROJECT = projectId; +function InitializeEnvironmentalVariables(frb: FunctionsRuntimeBundle): void { + process.env.GCLOUD_PROJECT = frb.projectId; process.env.FUNCTIONS_EMULATOR = "true"; - /* - Do our best to provide reasonable FIREBASE_CONFIG, based on firebase-functions implementation - https://github.com/firebase/firebase-functions/blob/master/src/index.ts#L70 - */ + + // Do our best to provide reasonable FIREBASE_CONFIG, based on firebase-functions implementation + // https://github.com/firebase/firebase-functions/blob/59d6a7e056a7244e700dc7b6a180e25b38b647fd/src/setup.ts#L45 process.env.FIREBASE_CONFIG = JSON.stringify({ databaseURL: process.env.DATABASE_URL || `https://${process.env.GCLOUD_PROJECT}.firebaseio.com`, storageBucket: process.env.STORAGE_BUCKET_URL || `${process.env.GCLOUD_PROJECT}.appspot.com`, @@ -622,32 +712,31 @@ async function ProcessBackground( ): Promise { new EmulatorLog("SYSTEM", "runtime-status", "ready").log(); - let proto = frb.proto; - const service = getFunctionService(trigger.definition); + const proto = frb.proto; + new EmulatorLog( + "DEBUG", + "runtime-status", + `ProcessBackground: proto=${JSON.stringify(proto)}` + ).log(); - // TODO: This is a workaround for - // https://github.com/firebase/firebase-tools/issues/1288 - if (service === "firestore.googleapis.com") { - if (EventUtils.isEvent(proto)) { - const legacyProto = EventUtils.convertToLegacy(proto); - new EmulatorLog( - "DEBUG", - "runtime-status", - `[firestore] converting to a v1beta1 event: old=${JSON.stringify( - proto - )}, new=${JSON.stringify(legacyProto)}` - ).log(); - proto = legacyProto; - } else { - new EmulatorLog( - "DEBUG", - "runtime-status", - `[firestore] Got legacy proto ${JSON.stringify(proto)}` - ).log(); - } + // 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; + delete proto.data; + const context = proto.context ? proto.context : proto; + + // This is due to the fact that the Firestore emulator sends payloads in a newer + // format than production firestore. + if (context.resource && context.resource.name) { + new EmulatorLog( + "DEBUG", + "runtime-status", + `ProcessBackground: lifting resource.name from resource ${JSON.stringify(context.resource)}` + ).log(); + context.resource = context.resource.name; } - await RunBackground(proto, trigger.getRawFunction()); + await RunBackground({ data, context }, trigger.getRawFunction()); } /** @@ -675,11 +764,11 @@ async function Run(func: () => Promise): Promise { } } -async function RunBackground(proto: any, func: (proto: any) => Promise): Promise { +async function RunBackground(proto: any, func: CloudFunction): Promise { new EmulatorLog("DEBUG", "runtime-status", `RunBackground: proto=${JSON.stringify(proto)}`).log(); await Run(() => { - return func(proto); + return func(proto.data, proto.context); }); } @@ -760,7 +849,7 @@ async function main(): Promise { return; } - InitializeEnvironmentalVariables(frb.projectId); + InitializeEnvironmentalVariables(frb); if (isFeatureEnabled(frb, "protect_env")) { ProtectEnvironmentalVariables(); } diff --git a/src/emulator/functionsEmulatorShared.ts b/src/emulator/functionsEmulatorShared.ts index bcb40518c41..51188da073a 100644 --- a/src/emulator/functionsEmulatorShared.ts +++ b/src/emulator/functionsEmulatorShared.ts @@ -35,6 +35,7 @@ export interface FunctionsRuntimeBundle { triggerId?: string; ports: { firestore?: number; + database?: number; }; disabled_features?: FunctionsRuntimeFeatures; cwd: string;