diff --git a/changelog.txt b/changelog.txt index 6fb2205f1f0..92bd0a184d8 100644 --- a/changelog.txt +++ b/changelog.txt @@ -1,6 +1,12 @@ * **BREAKING** The CLI no longer supports being run in Node 6 environments. -* **BREAKING** RTDB Emulator comes up with open rules by default -* Modify RTDB emulator to publish triggers to the functions emulator -* Modify functions emulator to invoke RTDB emulator triggers +* **BREAKING** RTDB Emulator comes up with open rules by default. +* Modify RTDB emulator to publish triggers to the functions emulator. +* Modify functions emulator to invoke RTDB emulator triggers. * Engines field is now required in package.json for functions deploys. -* Update functions init templates to use firebase-functions v3 and firebase-admin v8. \ No newline at end of file +* Fix bug where raw body was missing on HTTPS function request. +* Fix bug with Functions Emulator and `firebase-admin@8.0.0`. +* Fix req.baseUrl in Functions Emulator. +* Add `FUNCTIONS_EMULATOR` environmental variable when running in Functions Emulator. +* Set FIRESTORE_EMULATOR_HOST env var in "emulators:exec". +* Fix bug in printing Function logs when the text payload was undefined. +* Update functions init templates to use firebase-functions v3 and firebase-admin v8. diff --git a/package.json b/package.json index 96bc32ad33b..7e582bfedfc 100644 --- a/package.json +++ b/package.json @@ -84,7 +84,7 @@ "fs-extra": "^0.23.1", "glob": "^7.1.2", "google-auto-auth": "^0.7.2", - "inquirer": "^0.12.0", + "inquirer": "^6.3.1", "is": "^3.2.1", "jsonschema": "^1.0.2", "jsonwebtoken": "^8.2.1", @@ -113,7 +113,7 @@ "@types/express": "^4.16.0", "@types/fs-extra": "^5.0.5", "@types/glob": "^7.1.1", - "@types/inquirer": "^6.0.0", + "@types/inquirer": "^6.0.3", "@types/lodash": "^4.14.118", "@types/mocha": "^5.2.5", "@types/nock": "^9.3.0", @@ -132,9 +132,8 @@ "coveralls": "^3.0.1", "eslint": "^5.7.0", "eslint-plugin-prettier": "^3.0.0", - "firebase-admin": "^7.3.0", + "firebase-admin": "^7.0.0", "firebase-functions": "^2.2.1", - "firebase-functions-test": "^0.1.6", "mocha": "^5.0.5", "nock": "^9.3.3", "nyc": "^14.0.0", diff --git a/src/RulesDeploy.ts b/src/RulesDeploy.ts index c7611a35e7d..843a4aee955 100644 --- a/src/RulesDeploy.ts +++ b/src/RulesDeploy.ts @@ -7,7 +7,7 @@ import logger = require("./logger"); import FirebaseError = require("./error"); import utils = require("./utils"); -import * as prompt from "./prompt"; +import { prompt } from "./prompt"; import { ListRulesetsEntry, Release, RulesetFile } from "./gcp/rules"; // The status code the Firebase Rules backend sends to indicate too many rulesets. diff --git a/src/auth.js b/src/auth.js index 15b1822f8bc..ef10fb48a13 100644 --- a/src/auth.js +++ b/src/auth.js @@ -14,7 +14,7 @@ var api = require("./api"); var configstore = require("./configstore"); var FirebaseError = require("./error"); var logger = require("./logger"); -var prompt = require("./prompt"); +var { prompt } = require("./prompt"); var scopes = require("./scopes"); portfinder.basePort = 9005; diff --git a/src/commands/database-remove.js b/src/commands/database-remove.js index e16640b973d..0130d2b2aac 100644 --- a/src/commands/database-remove.js +++ b/src/commands/database-remove.js @@ -7,7 +7,7 @@ var DatabaseRemove = require("../database/remove").default; var api = require("../api"); var utils = require("../utils"); -var prompt = require("../prompt"); +var { prompt } = require("../prompt"); var clc = require("cli-color"); var _ = require("lodash"); diff --git a/src/commands/database-set.js b/src/commands/database-set.js index 6232692683f..e09c4578531 100644 --- a/src/commands/database-set.js +++ b/src/commands/database-set.js @@ -12,7 +12,7 @@ var utils = require("../utils"); var clc = require("cli-color"); var logger = require("../logger"); var fs = require("fs"); -var prompt = require("../prompt"); +var { prompt } = require("../prompt"); var _ = require("lodash"); module.exports = new Command("database:set [infile]") diff --git a/src/commands/database-update.js b/src/commands/database-update.js index de3724b0a21..3a596745120 100644 --- a/src/commands/database-update.js +++ b/src/commands/database-update.js @@ -12,7 +12,7 @@ var utils = require("../utils"); var clc = require("cli-color"); var logger = require("../logger"); var fs = require("fs"); -var prompt = require("../prompt"); +var { prompt } = require("../prompt"); var _ = require("lodash"); module.exports = new Command("database:update [infile]") diff --git a/src/commands/emulators-exec.ts b/src/commands/emulators-exec.ts index 5ad1227bba3..05412e895be 100644 --- a/src/commands/emulators-exec.ts +++ b/src/commands/emulators-exec.ts @@ -21,8 +21,10 @@ async function runScript(script: string): Promise { const firestoreInstance = EmulatorRegistry.get(Emulators.FIRESTORE); if (firestoreInstance) { const info = firestoreInstance.getInfo(); - const hostString = `${info.host}:${info.port}`; - env[FirestoreEmulator.FIRESTORE_EMULATOR_ENV] = hostString; + const address = `${info.host}:${info.port}`; + + env[FirestoreEmulator.FIRESTORE_EMULATOR_ENV] = address; + env[FirestoreEmulator.FIRESTORE_EMULATOR_ENV_ALT] = address; } const proc = childProcess.spawn(script, { diff --git a/src/commands/firestore-delete.js b/src/commands/firestore-delete.js index 39228a1e839..c555aceaa0c 100644 --- a/src/commands/firestore-delete.js +++ b/src/commands/firestore-delete.js @@ -3,7 +3,7 @@ var clc = require("cli-color"); var Command = require("../command"); var FirestoreDelete = require("../firestore/delete"); -var prompt = require("../prompt"); +var { prompt } = require("../prompt"); var requirePermissions = require("../requirePermissions"); var utils = require("../utils"); diff --git a/src/commands/functions-delete.js b/src/commands/functions-delete.js index ee411173177..3477c3899b9 100644 --- a/src/commands/functions-delete.js +++ b/src/commands/functions-delete.js @@ -9,7 +9,7 @@ var functionsConfig = require("../functionsConfig"); var functionsDelete = require("../functionsDelete"); var getProjectId = require("../getProjectId"); var helper = require("../functionsDeployHelper"); -var prompt = require("../prompt"); +var { prompt } = require("../prompt"); var requirePermissions = require("../requirePermissions"); var utils = require("../utils"); diff --git a/src/commands/functions-log.ts b/src/commands/functions-log.ts index 8206e72a627..c213a477d98 100644 --- a/src/commands/functions-log.ts +++ b/src/commands/functions-log.ts @@ -46,9 +46,9 @@ module.exports = new Command("functions:log") const entry = entries[i]; logger.info( entry.timestamp, - entry.severity.substring(0, 1), - entry.resource.labels.function_name + ":", - entry.textPayload + _.get(entry, "severity", "?").substring(0, 1), + _.get(entry, "resource.labels.function_name") + ":", + _.get(entry, "textPayload", "") ); } if (_.isEmpty(entries)) { diff --git a/src/commands/hosting-disable.js b/src/commands/hosting-disable.js index 8d2f7814eae..47b397ad644 100644 --- a/src/commands/hosting-disable.js +++ b/src/commands/hosting-disable.js @@ -5,7 +5,7 @@ var requireInstance = require("../requireInstance"); var requirePermissions = require("../requirePermissions"); var api = require("../api"); var utils = require("../utils"); -var prompt = require("../prompt"); +var { prompt } = require("../prompt"); var clc = require("cli-color"); module.exports = new Command("hosting:disable") diff --git a/src/commands/init.js b/src/commands/init.js index 996e2c69209..9d15f472350 100644 --- a/src/commands/init.js +++ b/src/commands/init.js @@ -10,7 +10,7 @@ var Config = require("../config"); var fsutils = require("../fsutils"); var init = require("../init"); var logger = require("../logger"); -var prompt = require("../prompt"); +var { prompt, promptOnce } = require("../prompt"); var requireAuth = require("../requireAuth"); var utils = require("../utils"); @@ -71,28 +71,28 @@ module.exports = new Command("init [feature]") var choices = [ { - name: "database", - label: "Database: Deploy Firebase Realtime Database Rules", + value: "database", + name: "Database: Deploy Firebase Realtime Database Rules", checked: false, }, { - name: "firestore", - label: "Firestore: Deploy rules and create indexes for Firestore", + value: "firestore", + name: "Firestore: Deploy rules and create indexes for Firestore", checked: false, }, { - name: "functions", - label: "Functions: Configure and deploy Cloud Functions", + value: "functions", + name: "Functions: Configure and deploy Cloud Functions", checked: false, }, { - name: "hosting", - label: "Hosting: Configure and deploy Firebase Hosting sites", + value: "hosting", + name: "Hosting: Configure and deploy Firebase Hosting sites", checked: false, }, { - name: "storage", - label: "Storage: Deploy Cloud Storage security rules", + value: "storage", + name: "Storage: Deploy Cloud Storage security rules", checked: false, }, ]; @@ -101,7 +101,7 @@ module.exports = new Command("init [feature]") // HACK: Windows Node has issues with selectables as the first prompt, so we // add an extra confirmation prompt that fixes the problem if (process.platform === "win32") { - next = prompt.once({ + next = promptOnce({ type: "confirm", message: "Are you ready to proceed?", }); @@ -128,16 +128,11 @@ module.exports = new Command("init [feature]") message: "Which Firebase CLI features do you want to set up for this folder? " + "Press Space to select features, then Enter to confirm your choices.", - choices: prompt.convertLabeledListChoices(choices), + choices: choices, }, ]); }) .then(function() { - if (!setup.featureArg) { - setup.features = setup.features.map(function(feat) { - return prompt.listLabelToValue(feat, choices); - }); - } if (setup.features.length === 0) { return utils.reject( "Must select at least one feature. Use " + diff --git a/src/commands/login.js b/src/commands/login.js index e811f2afbae..ed15b5abb05 100644 --- a/src/commands/login.js +++ b/src/commands/login.js @@ -5,7 +5,7 @@ var logger = require("../logger"); var configstore = require("../configstore"); var clc = require("cli-color"); var utils = require("../utils"); -var prompt = require("../prompt"); +var { prompt } = require("../prompt"); var auth = require("../auth"); diff --git a/src/commands/open.js b/src/commands/open.js index 69f93a1c954..c0ddd75aaa7 100644 --- a/src/commands/open.js +++ b/src/commands/open.js @@ -7,7 +7,7 @@ var open = require("opn"); var api = require("../api"); var Command = require("../command"); var logger = require("../logger"); -var prompt = require("../prompt"); +var { prompt } = require("../prompt"); var requirePermissions = require("../requirePermissions"); var requireInstance = require("../requireInstance"); var utils = require("../utils"); diff --git a/src/commands/tools-migrate.js b/src/commands/tools-migrate.js index 7256ed88717..76266761e2a 100644 --- a/src/commands/tools-migrate.js +++ b/src/commands/tools-migrate.js @@ -7,7 +7,7 @@ var Command = require("../command"); var Config = require("../config"); var identifierToProjectId = require("../identifierToProjectId"); var logger = require("../logger"); -var prompt = require("../prompt"); +var { promptOnce } = require("../prompt"); var requireAuth = require("../requireAuth"); var utils = require("../utils"); @@ -83,7 +83,7 @@ module.exports = new Command("tools:migrate") if (options.confirm) { next = Promise.resolve(true); } else { - next = prompt.once({ + next = promptOnce({ type: "confirm", message: "Write new config to " + clc.underline("firebase.json") + "?", default: true, diff --git a/src/commands/use.js b/src/commands/use.js index c30eb242e52..42fe8a3af5d 100644 --- a/src/commands/use.js +++ b/src/commands/use.js @@ -7,7 +7,7 @@ var firebaseApi = require("../firebaseApi"); var clc = require("cli-color"); var utils = require("../utils"); var _ = require("lodash"); -var prompt = require("../prompt"); +var { prompt } = require("../prompt"); var listAliases = function(options) { if (options.rc.hasProjects) { diff --git a/src/config.js b/src/config.js index dfa4746c044..d58f6e4f124 100644 --- a/src/config.js +++ b/src/config.js @@ -11,7 +11,7 @@ var FirebaseError = require("./error"); var fsutils = require("./fsutils"); var loadCJSON = require("./loadCJSON"); var parseBoltRules = require("./parseBoltRules"); -var prompt = require("./prompt"); +var { promptOnce } = require("./prompt"); var { resolveProjectPath } = require("./projectPath"); var utils = require("./utils"); @@ -216,7 +216,7 @@ Config.prototype.askWriteProjectFile = function(p, content) { var writeTo = this.path(p); var next; if (fsutils.fileExistsSync(writeTo)) { - next = prompt.once({ + next = promptOnce({ type: "confirm", message: "File " + clc.underline(p) + " already exists. Overwrite?", default: false, diff --git a/src/deploy/functions/release.js b/src/deploy/functions/release.js index 19df716a30f..f2454a03330 100644 --- a/src/deploy/functions/release.js +++ b/src/deploy/functions/release.js @@ -15,7 +15,7 @@ var utils = require("../../utils"); var helper = require("../../functionsDeployHelper"); var runtimeSelector = require("../../runtimeChoiceSelector"); var { getAppEngineLocation } = require("../../functionsConfig"); -var prompt = require("../../prompt"); +var { promptOnce } = require("../../prompt"); var { createOrUpdateSchedulesAndTopics } = require("./createOrUpdateSchedulesAndTopics"); var deploymentTool = require("../../deploymentTool"); @@ -338,7 +338,7 @@ module.exports = function(context, options, payload) { const next = options.force ? Promise.resolve(true) - : prompt.once({ + : promptOnce({ type: "confirm", name: "confirm", default: false, diff --git a/src/emulator/firestoreEmulator.ts b/src/emulator/firestoreEmulator.ts index 54c149bb6f1..1a9407445fe 100644 --- a/src/emulator/firestoreEmulator.ts +++ b/src/emulator/firestoreEmulator.ts @@ -11,7 +11,8 @@ export interface FirestoreEmulatorArgs { } export class FirestoreEmulator implements EmulatorInstance { - static FIRESTORE_EMULATOR_ENV = "FIREBASE_FIRESTORE_EMULATOR_ADDRESS"; + static FIRESTORE_EMULATOR_ENV = "FIRESTORE_EMULATOR_HOST"; + static FIRESTORE_EMULATOR_ENV_ALT = "FIREBASE_FIRESTORE_EMULATOR_ADDRESS"; constructor(private args: FirestoreEmulatorArgs) {} diff --git a/src/emulator/functionsEmulator.ts b/src/emulator/functionsEmulator.ts index 3de608b1072..90183d7887a 100644 --- a/src/emulator/functionsEmulator.ts +++ b/src/emulator/functionsEmulator.ts @@ -27,7 +27,6 @@ import { import { EmulatorRegistry } from "./registry"; import { EventEmitter } from "events"; import * as stream from "stream"; -import { trimFunctionPath } from "./functionsEmulatorUtils"; import { EmulatorLogger, Verbosity } from "./emulatorLogger"; const EVENT_INVOKE = "functions:invoke"; @@ -177,7 +176,7 @@ export class FunctionsEmulator implements EmulatorInstance { const runtimeReq = http.request( { method, - path: "/" + trimFunctionPath(req.url), + path: req.url || "/", headers: req.headers, socketPath: runtime.metadata.socketPath, }, @@ -296,9 +295,6 @@ export class FunctionsEmulator implements EmulatorInstance { }"\n - Learn more at https://firebase.google.com/docs/functions/local-emulator` ); break; - case "default-admin-app-used": - utils.logBullet(`Your code has been provided a "firebase-admin" instance.`); - break; case "non-default-admin-app-used": EmulatorLogger.log( "WARN", @@ -344,17 +340,29 @@ You can probably fix this by running "npm install ${ `The Cloud Functions directory you specified does not have a "package.json" file, so we can't load it.` ); break; - case "missing-package-json": - utils.logWarning( - `The Cloud Functions directory you specified does not have a "package.json" file, so we can't load it.` - ); - break; case "admin-auto-initialized": utils.logBullet( "Your code does not appear to initialize the 'firebase-admin' module, so we've done it automatically.\n" + " - Learn more: https://firebase.google.com/docs/admin/setup" ); break; + case "function-code-resolution-failed": + EmulatorLogger.log("WARN", systemLog.data.error); + const helper = ["We were unable to load your functions code. (see above)"]; + if (systemLog.data.isPotentially.wrong_directory) { + helper.push(` - There is no "package.json" file in your functions directory.`); + } + if (systemLog.data.isPotentially.typescript) { + helper.push( + " - It appears your code is written in Typescript, which must be compiled before emulation." + ); + } + if (systemLog.data.isPotentially.uncompiled) { + helper.push( + ` - You may be able to run "npm run build" in your functions directory to resolve this.` + ); + } + utils.logWarning(helper.join("\n")); default: // Silence } diff --git a/src/emulator/functionsEmulatorRuntime.ts b/src/emulator/functionsEmulatorRuntime.ts index 4510210d3dd..9bd1e40b511 100644 --- a/src/emulator/functionsEmulatorRuntime.ts +++ b/src/emulator/functionsEmulatorRuntime.ts @@ -12,50 +12,44 @@ import { getTemporarySocketPath, } from "./functionsEmulatorShared"; import * as express from "express"; -import { spawnSync } from "child_process"; 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"; let app: admin.app.App; let adminModuleProxy: typeof admin; -/* - This method is a hacky way of "resolving" Node modules. Normally, when you "require()" a package, - Node looks for that package in a set of locations or paths. The logic varies slightly between - Nodejs versions and there's no consistent way to make sure the exact same resolution is happening - all the time. - - Since functionsEmulatorRuntime.js lives inside the firebase-tools installation, it's always tempted - to resolve from the same places that firebase-tools gets it's modules. Normally this is fine, but - in order to provide mocks to a developer's functions we need to resolve modules as if we were in the - same filesystem location as the user's code. Some versions of Node let us do this by calling - require.resolve() with a list of paths to look in, but that's not a fix for all versions. - - slowRequireResolve works around this by spinning up another node process which doesn't have a - file path to look at for resolutions (code is passed via -e flag) so it uses the cwd instead. - This allows us to easily resolve modules as if we had code in that folder. Sadly, this is incredibly - sllooooow. It's about 100-200ms per resolution, which means the majority of time spent on a - Cloud Function invocation is spent right here. - - It's made even worse because we occasionally need to resolve a dependency as if we were a different - depedency, so that requires two slowRequireResolves - it's bad. - - For the initial release of the emulator, we went for consistency and simplicity over execution speed - going forward, there's many paths to look into for optimization, for example we could cache results - in an inter-process memory store, look into ways to help node believe our runtime is in the user's - code directory, or move to native require.resolve on newer node versions and deal with the inconsistencies - between versions. - */ -function slowRequireResolve(moduleName: string, cwd?: string): string { - const resolver = `console.log(require.resolve("${moduleName}"))`; - const result = spawnSync(process.execPath, ["-e", resolver], { - cwd: path.resolve(cwd || process.cwd()), - }); +function isFeatureEnabled( + frb: FunctionsRuntimeBundle, + feature: keyof FunctionsRuntimeFeatures +): boolean { + return frb.disabled_features ? !frb.disabled_features[feature] : true; +} - return result.stdout.toString().trim(); +function NoOp(): false { + return false; +} + +async function requireAsync(moduleName: string, opts?: { paths: string[] }): Promise { + return require(require.resolve(moduleName, opts)); +} + +async function requireResolveAsync( + moduleName: string, + opts?: { paths: string[] } +): Promise { + return require.resolve(moduleName, opts); +} + +function isConstructor(obj: any): boolean { + return !!obj.prototype && !!obj.prototype.constructor.name; +} + +function isExists(obj: any): boolean { + return obj !== undefined; } /* @@ -154,15 +148,7 @@ class Proxied { } } -function isConstructor(obj: any): boolean { - return !!obj.prototype && !!obj.prototype.constructor.name; -} - -function isExists(obj: any): boolean { - return obj !== undefined; -} - -function verifyDeveloperNodeModules(frb: FunctionsRuntimeBundle): boolean { +async function verifyDeveloperNodeModules(frb: FunctionsRuntimeBundle): Promise { let pkg; try { pkg = require(`${frb.cwd}/package.json`); @@ -174,7 +160,6 @@ function verifyDeveloperNodeModules(frb: FunctionsRuntimeBundle): boolean { const modBundles = [ { name: "firebase-admin", isDev: false, minVersion: 7 }, { name: "firebase-functions", isDev: false, minVersion: 2 }, - { name: "firebase-functions-test", isDev: true, minVersion: 0 }, ]; for (const modBundle of modBundles) { @@ -193,10 +178,11 @@ function verifyDeveloperNodeModules(frb: FunctionsRuntimeBundle): boolean { /* Once we know it's in the package.json, make sure it's actually `npm install`ed */ - let modResolution: string; - try { - modResolution = slowRequireResolve(modBundle.name, frb.cwd); - } catch (err) { + const modResolution = await requireResolveAsync(modBundle.name, { paths: [frb.cwd] }).catch( + NoOp + ); + + if (!modResolution) { new EmulatorLog("SYSTEM", "uninstalled-module", "", modBundle).log(); return false; } @@ -244,9 +230,9 @@ function InitializeNetworkFiltering(frb: FunctionsRuntimeBundle): void { try { const gcFirestore = findModuleRoot( "@google-cloud/firestore", - slowRequireResolve("@google-cloud/firestore", frb.cwd) + require.resolve("@google-cloud/firestore", { paths: [frb.cwd] }) ); - const gaxPath = slowRequireResolve("google-gax", gcFirestore); + const gaxPath = require.resolve("google-gax", { paths: [gcFirestore] }); const gaxModule = { module: require(gaxPath), path: ["GrpcClient"], @@ -259,7 +245,7 @@ function InitializeNetworkFiltering(frb: FunctionsRuntimeBundle): void { new EmulatorLog( "DEBUG", "runtime-status", - `Couldn't find google-cloud/firestore or google-gax` + `Couldn't find @google-cloud/firestore or google-gax, this may be okay if using @google-cloud/firestore@2.0.0` ).log(); } @@ -280,7 +266,7 @@ function InitializeNetworkFiltering(frb: FunctionsRuntimeBundle): void { .map((arg) => { if (typeof arg === "string") { try { - const _ = new URL(arg); + const url = new URL(arg); return arg; } catch (err) { return; @@ -330,7 +316,6 @@ function InitializeNetworkFiltering(frb: FunctionsRuntimeBundle): void { new EmulatorLog("DEBUG", "runtime-status", "Outgoing network have been stubbed.", results).log(); } - /* This stub handles a very specific use-case, when a developer (incorrectly) provides a HTTPS handler which returns a promise. In this scenario, we can't catch errors which get raised in user code, @@ -343,8 +328,10 @@ function InitializeNetworkFiltering(frb: FunctionsRuntimeBundle): void { The relevant firebase-functions code is: https://github.com/firebase/firebase-functions/blob/9e3bda13565454543b4c7b2fd10fb627a6a3ab97/src/providers/https.ts#L66 */ -function InitializeFirebaseFunctionsStubs(functionsDir: string): void { - const firebaseFunctionsResolution = slowRequireResolve("firebase-functions", functionsDir); +async function InitializeFirebaseFunctionsStubs(functionsDir: string): Promise { + const firebaseFunctionsResolution = await requireResolveAsync("firebase-functions", { + paths: [functionsDir], + }); const firebaseFunctionsRoot = findModuleRoot("firebase-functions", firebaseFunctionsResolution); const httpsProviderResolution = path.join(firebaseFunctionsRoot, "lib/providers/https"); @@ -372,6 +359,31 @@ function InitializeFirebaseFunctionsStubs(functionsDir: string): void { }; } +/* + @google-cloud/firestore@2.0.0 made a breaking change which swapped relying on "grpc" to "@grpc/grpc-js" + which has a slightly different signature. We need to detect the firestore version to know which version + of grpc to pass as a credential. + */ +async function getGRPCInsecureCredential(frb: FunctionsRuntimeBundle): Promise { + const firestorePackageJSON = require(path.join( + findModuleRoot( + "@google-cloud/firestore", + require.resolve("@google-cloud/firestore", { paths: [frb.cwd] }) + ), + "package.json" + )); + + if (firestorePackageJSON.version.startsWith("1")) { + const grpc = await requireAsync("grpc", { paths: [frb.cwd] }).catch(NoOp); + new EmulatorLog("SYSTEM", "runtime-status", "using grpc-native for admin credential").log(); + return grpc.credentials.createInsecure(); + } else { + const grpc = await requireAsync("@grpc/grpc-js", { paths: [frb.cwd] }).catch(NoOp); + new EmulatorLog("SYSTEM", "runtime-status", "using grpc-js for admin credential").log(); + return grpc.ServerCredentials.createInsecure(); + } +} + /* This stub is the most important and one of the only non-optional stubs. This feature redirects writes from the admin SDK back into emulated resources. Currently, this is only Firestore writes. @@ -384,13 +396,17 @@ function InitializeFirebaseFunctionsStubs(functionsDir: string): void { failing in some way and admin is attempting to access prod resources. This error isn't pretty, but it's hard to catch and better than accidentally talking to prod. */ -function InitializeFirebaseAdminStubs(frb: FunctionsRuntimeBundle): typeof admin { - const adminResolution = slowRequireResolve("firebase-admin", frb.cwd); - const grpc = require(slowRequireResolve("grpc", frb.cwd)); - +async function InitializeFirebaseAdminStubs(frb: FunctionsRuntimeBundle): Promise { + const adminResolution = await requireResolveAsync("firebase-admin", { paths: [frb.cwd] }); const localAdminModule = require(adminResolution); let hasInitializedSettings = false; + /* + If we can't get sslCreds that means either grpc or grpc-js doesn't exist. If this is the save, + then there's probably something really wrong (like a failed node-gyp build). If that's the case + we should silently fail here and allow the error to raise in user-code so they can debug appropriately. + */ + const sslCreds = await getGRPCInsecureCredential(frb).catch(NoOp); const initializeSettings = (userSettings: any) => { const isEnabled = isFeatureEnabled(frb, "admin_stubs"); @@ -408,7 +424,10 @@ function InitializeFirebaseAdminStubs(frb: FunctionsRuntimeBundle): typeof admin port: frb.ports.firestore, servicePath: "localhost", service: "firestore.googleapis.com", - sslCreds: grpc.credentials.createInsecure(), + sslCreds, + customHeaders: { + Authorization: "Bearer owner", + }, ...userSettings, }); } else if (!frb.ports.firestore && frb.triggerId) { @@ -478,6 +497,7 @@ function ProtectEnvironmentalVariables(): void { function InitializeEnvironmentalVariables(projectId: string): void { process.env.GCLOUD_PROJECT = 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 @@ -489,8 +509,10 @@ function InitializeEnvironmentalVariables(projectId: string): void { }); } -function InitializeFunctionsConfigHelper(functionsDir: string): void { - const functionsResolution = slowRequireResolve("firebase-functions", functionsDir); +async function InitializeFunctionsConfigHelper(functionsDir: string): Promise { + const functionsResolution = await requireResolveAsync("firebase-functions", { + paths: [functionsDir], + }); const ff = require(functionsResolution); new EmulatorLog("DEBUG", "runtime-status", "Checked functions.config()", { @@ -523,8 +545,17 @@ function InitializeFunctionsConfigHelper(functionsDir: string): void { ff.config = () => proxiedConfig; } +/* + Retains a reference to the raw body buffer to allow access to the raw body for things like request + signature validation. This is used as the "verify" function in body-parser options. +*/ +function rawBodySaver(req: express.Request, res: express.Response, buf: Buffer): void { + (req as any).rawBody = buf; +} + async function ProcessHTTPS(frb: FunctionsRuntimeBundle, trigger: EmulatedTrigger): Promise { const ephemeralServer = express(); + const functionRouter = express.Router(); const socketPath = getTemporarySocketPath(process.pid); await new Promise((resolveEphemeralServer, rejectEphemeralServer) => { @@ -533,6 +564,7 @@ async function ProcessHTTPS(frb: FunctionsRuntimeBundle, trigger: EmulatedTrigge new EmulatorLog("DEBUG", "runtime-status", `Ephemeral server used!`).log(); const func = trigger.getRawFunction(); + new EmulatorLog("DEBUG", "runtime-status", "Body" + (req as any).rawBody).log(); res.on("finish", () => { instance.close(); resolveEphemeralServer(); @@ -545,13 +577,17 @@ async function ProcessHTTPS(frb: FunctionsRuntimeBundle, trigger: EmulatedTrigge }; ephemeralServer.enable("trust proxy"); - ephemeralServer.use(bodyParser.json({})); - ephemeralServer.use(bodyParser.text({})); - ephemeralServer.use(bodyParser.urlencoded({ extended: true })); - ephemeralServer.use(bodyParser.raw({ type: "*/*" })); + functionRouter.use(bodyParser.json({ verify: rawBodySaver })); + functionRouter.use(bodyParser.text({ verify: rawBodySaver })); + functionRouter.use(bodyParser.urlencoded({ extended: true, verify: rawBodySaver })); + functionRouter.use(bodyParser.raw({ type: "*/*", verify: rawBodySaver })); - ephemeralServer.all("/*", handler); + functionRouter.all("*", handler); + ephemeralServer.use( + [`/${frb.projectId}/${frb.triggerId}`, `/${frb.projectId}/:region/${frb.triggerId}`], + functionRouter + ); const instance = ephemeralServer.listen(socketPath, () => { new EmulatorLog("SYSTEM", "runtime-status", "ready", { socketPath }).log(); }); @@ -638,11 +674,34 @@ async function RunHTTPS( }); } -function isFeatureEnabled( - frb: FunctionsRuntimeBundle, - feature: keyof FunctionsRuntimeFeatures -): boolean { - return frb.disabled_features ? !frb.disabled_features[feature] : true; +/* + This method attempts to help a developer whose code can't be loaded by suggesting + possible fixes based on the files in their functions directory. + */ +async function moduleResolutionDetective(frb: FunctionsRuntimeBundle, error: Error): Promise { + /* + These files could all potentially exist, if they don't then the value in the map will be + falsey, so we just catch to keep from throwing. + */ + const clues = { + tsconfigJSON: await requireAsync("./tsconfig.json", { paths: [frb.cwd] }).catch(NoOp), + packageJSON: await requireAsync("./package.json", { paths: [frb.cwd] }).catch(NoOp), + }; + + const isPotentially = { + typescript: false, + uncompiled: false, + wrong_directory: false, + }; + + isPotentially.typescript = !!clues.tsconfigJSON; + isPotentially.wrong_directory = !clues.packageJSON; + isPotentially.uncompiled = !!_.get(clues.packageJSON, "scripts.build", false); + + new EmulatorLog("SYSTEM", "function-code-resolution-failed", "", { + isPotentially, + error: error.stack, + }).log(); } async function main(): Promise { @@ -668,9 +727,9 @@ async function main(): Promise { `Disabled runtime features: ${JSON.stringify(frb.disabled_features)}` ).log(); - const verified = verifyDeveloperNodeModules(frb); + const verified = await verifyDeveloperNodeModules(frb); if (!verified) { - // If we can't verify the node modules, then just leave, soemthing bad will happen during runtime. + // If we can't verify the node modules, then just leave, something bad will happen during runtime. new EmulatorLog( "INFO", "runtime-status", @@ -689,11 +748,11 @@ async function main(): Promise { } if (isFeatureEnabled(frb, "functions_config_helper")) { - InitializeFunctionsConfigHelper(frb.cwd); + await InitializeFunctionsConfigHelper(frb.cwd); } - InitializeFirebaseFunctionsStubs(frb.cwd); - InitializeFirebaseAdminStubs(frb); + await InitializeFirebaseFunctionsStubs(frb.cwd); + await InitializeFirebaseAdminStubs(frb); let triggers: EmulatedTriggerMap; const triggerDefinitions: EmulatedTriggerDefinition[] = []; @@ -703,7 +762,12 @@ async function main(): Promise { /* tslint:disable:no-eval */ triggerModule = eval(serializedFunctionTrigger)(); } else { - triggerModule = require(frb.cwd); + try { + triggerModule = require(frb.cwd); + } catch (err) { + await moduleResolutionDetective(frb, err); + return; + } } require("../extractTriggers")(triggerModule, triggerDefinitions); diff --git a/src/emulator/functionsEmulatorShared.ts b/src/emulator/functionsEmulatorShared.ts index 5a11cf851e2..bcb40518c41 100644 --- a/src/emulator/functionsEmulatorShared.ts +++ b/src/emulator/functionsEmulatorShared.ts @@ -1,9 +1,7 @@ import * as _ from "lodash"; import * as logger from "../logger"; -import * as FirebaseFunctionsTest from "firebase-functions-test"; import * as parseTriggers from "../parseTriggers"; import * as utils from "../utils"; -import { WrappedFunction } from "firebase-functions-test/lib/main"; import { CloudFunction } from "firebase-functions"; import * as os from "os"; import * as path from "path"; @@ -87,10 +85,6 @@ export class EmulatedTrigger { const func = _.get(this.module, this.definition.entryPoint); return func.__emulator_func || func; } - - getWrappedFunction(fft: typeof FirebaseFunctionsTest): WrappedFunction { - return fft().wrap(this.getRawFunction()); - } } export async function getTriggersFromDirectory( diff --git a/src/emulator/functionsEmulatorUtils.ts b/src/emulator/functionsEmulatorUtils.ts index 77eff75be3d..ef4295f485a 100644 --- a/src/emulator/functionsEmulatorUtils.ts +++ b/src/emulator/functionsEmulatorUtils.ts @@ -3,9 +3,6 @@ Please be careful when adding require/imports to this file, it is pulled into fu which is ran in a separate node process, so it is likely to have unintended side-effects for you. */ -// Safe import because it's standard in Node -import * as url from "url"; - const wildcardRegex = new RegExp("{[^/{}]*}"); const wildcardKeyRegex = new RegExp("^{(.+)}$"); @@ -66,13 +63,3 @@ export function removePathSegments(path: string, count: number): string { .slice(count) .join("/"); } - -/** - * The full URL to an emulated function is /project/region/path(/subpath)?params but the function - * does not need to know about anything before the subpath. - */ -export function trimFunctionPath(path: string): string { - // Use the URL library to separate query params for later reconstruction. - const fakeURL = new url.URL(path, "https://example.com"); - return removePathSegments(fakeURL.pathname, 3) + fakeURL.search; -} diff --git a/src/init/features/database.js b/src/init/features/database.js index f01ac1ea73a..abaed39b971 100644 --- a/src/init/features/database.js +++ b/src/init/features/database.js @@ -2,7 +2,7 @@ var clc = require("cli-color"); var api = require("../../api"); -var prompt = require("../../prompt"); +var { prompt, promptOnce } = require("../../prompt"); var logger = require("../../logger"); var utils = require("../../utils"); var fsutils = require("../../fsutils"); @@ -76,7 +76,7 @@ module.exports = function(setup, config) { " Do you want to overwrite it with the Database Rules for " + clc.bold(instance) + " from the Firebase Console?"; - return prompt.once({ + return promptOnce({ type: "confirm", message: msg, default: false, diff --git a/src/init/features/firestore.ts b/src/init/features/firestore.ts index 826ba0e3641..9d749f9f300 100644 --- a/src/init/features/firestore.ts +++ b/src/init/features/firestore.ts @@ -5,11 +5,10 @@ import FirebaseError = require("../../error"); import gcp = require("../../gcp"); import iv2 = require("../../firestore/indexes"); import fsutils = require("../../fsutils"); -import prompt = require("../../prompt"); +import { prompt, promptOnce } from "../../prompt"; import logger = require("../../logger"); import utils = require("../../utils"); import requireAccess = require("../../requireAccess"); -import scopes = require("../../scopes"); const indexes = new iv2.FirestoreIndexes(); @@ -49,7 +48,7 @@ async function initRules(setup: any, config: any): Promise { clc.bold(filename) + " already exists." + " Do you want to overwrite it with the Firestore Rules from the Firebase Console?"; - return prompt.once({ + return promptOnce({ type: "confirm", message: msg, default: false, @@ -121,7 +120,7 @@ async function initIndexes(setup: any, config: any): Promise { clc.bold(filename) + " already exists." + " Do you want to overwrite it with the Firestore Indexes from the Firebase Console?"; - return prompt.once({ + return promptOnce({ type: "confirm", message: msg, default: false, diff --git a/src/init/features/functions/index.js b/src/init/features/functions/index.js index 30dce8f7762..b94346b30df 100644 --- a/src/init/features/functions/index.js +++ b/src/init/features/functions/index.js @@ -5,7 +5,7 @@ var clc = require("cli-color"); var _ = require("lodash"); var logger = require("../../../logger"); -var prompt = require("../../../prompt"); +var { prompt } = require("../../../prompt"); var enableApi = require("../../../ensureApiEnabled").enable; var requireAccess = require("../../../requireAccess"); var scopes = require("../../../scopes"); diff --git a/src/init/features/functions/javascript.js b/src/init/features/functions/javascript.js index 0b367590225..aadac5dd83c 100644 --- a/src/init/features/functions/javascript.js +++ b/src/init/features/functions/javascript.js @@ -5,7 +5,7 @@ var fs = require("fs"); var path = require("path"); var npmDependencies = require("./npm-dependencies"); -var prompt = require("../../../prompt"); +var { prompt } = require("../../../prompt"); var TEMPLATE_ROOT = path.resolve(__dirname, "../../../../templates/init/functions/javascript/"); var INDEX_TEMPLATE = fs.readFileSync(path.join(TEMPLATE_ROOT, "index.js"), "utf8"); diff --git a/src/init/features/functions/npm-dependencies.js b/src/init/features/functions/npm-dependencies.js index 924443c0dc1..718f8d72675 100644 --- a/src/init/features/functions/npm-dependencies.js +++ b/src/init/features/functions/npm-dependencies.js @@ -3,7 +3,7 @@ var spawn = require("cross-spawn"); var logger = require("../../../logger"); -var prompt = require("../../../prompt"); +var { prompt } = require("../../../prompt"); exports.askInstallDependencies = function(setup, config) { return prompt(setup.functions, [ diff --git a/src/init/features/functions/typescript.js b/src/init/features/functions/typescript.js index 70e5c47bbeb..58fafa34a3d 100644 --- a/src/init/features/functions/typescript.js +++ b/src/init/features/functions/typescript.js @@ -5,7 +5,7 @@ var fs = require("fs"); var path = require("path"); var npmDependencies = require("./npm-dependencies"); -var prompt = require("../../../prompt"); +var { prompt } = require("../../../prompt"); var TEMPLATE_ROOT = path.resolve(__dirname, "../../../../templates/init/functions/typescript/"); var PACKAGE_LINTING_TEMPLATE = fs.readFileSync( diff --git a/src/init/features/hosting.js b/src/init/features/hosting.js index eef1e90b213..2be8ee9ed6d 100644 --- a/src/init/features/hosting.js +++ b/src/init/features/hosting.js @@ -5,7 +5,7 @@ var fs = require("fs"); var api = require("../../api"); var logger = require("../../logger"); -var prompt = require("../../prompt"); +var { prompt } = require("../../prompt"); var INDEX_TEMPLATE = fs.readFileSync( __dirname + "/../../../templates/init/hosting/index.html", diff --git a/src/init/features/project.js b/src/init/features/project.js index 9e9355ef5e8..29f89e6a607 100644 --- a/src/init/features/project.js +++ b/src/init/features/project.js @@ -4,7 +4,7 @@ var clc = require("cli-color"); var _ = require("lodash"); var firebaseApi = require("../../firebaseApi"); -var prompt = require("../../prompt"); +var { promptOnce } = require("../../prompt"); var logger = require("../../logger"); var utils = require("../../utils"); @@ -45,12 +45,13 @@ function _getProject(options) { return firebaseApi.listProjects().then(function(projects) { var choices = projects.filter((project) => !!project).map((project) => { return { - name: project.projectId, - label: project.projectId + " (" + project.displayName + ")", + name: project.projectId + " (" + project.displayName + ")", + value: project.projectId, }; }); choices = _.orderBy(choices, ["name"], ["asc"]); - var nameOptions = [NO_PROJECT].concat(_.map(choices, "label")).concat([NEW_PROJECT]); + choices.unshift({ name: NO_PROJECT, value: NO_PROJECT }); + choices.push({ name: NEW_PROJECT, value: NEW_PROJECT }); if (choices.length >= 25) { utils.logBullet( @@ -61,34 +62,30 @@ function _getProject(options) { ); } - return prompt - .once({ - type: "list", - name: "id", - message: "Select a default Firebase project for this directory:", - validate: function(answer) { - if (!_.includes(nameOptions, answer)) { - return "Must specify a Firebase to which you have access."; - } - return true; - }, - choices: nameOptions, - }) - .then(function(label) { - if (label === NEW_PROJECT || label === NO_PROJECT) { - return { - id: label, - }; + return promptOnce({ + type: "list", + name: "id", + message: "Select a default Firebase project for this directory:", + validate: function(answer) { + if (!_.includes(choices, answer)) { + return "Must specify a Firebase to which you have access."; } + return true; + }, + choices: choices, + }).then(function(id) { + if (id === NEW_PROJECT || id === NO_PROJECT) { + return { id: id }; + } - var id = prompt.listLabelToValue(label, choices); - const project = projects.find((p) => p.projectId === id); - return { - id: id, - label: label, - instance: _.get(project, "resources.realtimeDatabaseInstance"), - }; - }); + const project = projects.find((p) => p.projectId === id); + const label = choices.find((p) => p.value === id).name; + return { + id: id, + label: label, + instance: _.get(project, "resources.realtimeDatabaseInstance"), + }; + }); }); } diff --git a/src/init/features/storage.js b/src/init/features/storage.js index 706956a8920..08843999cea 100644 --- a/src/init/features/storage.js +++ b/src/init/features/storage.js @@ -3,7 +3,7 @@ var clc = require("cli-color"); var fs = require("fs"); -var prompt = require("../../prompt"); +var { prompt } = require("../../prompt"); var logger = require("../../logger"); var RULES_TEMPLATE = fs.readFileSync( diff --git a/src/init/index.js b/src/init/index.js index 35a44b14eac..9e730e94441 100644 --- a/src/init/index.js +++ b/src/init/index.js @@ -14,7 +14,7 @@ var init = function(setup, config, options) { return utils.reject( clc.bold(nextFeature) + " is not a valid feature. Must be one of " + - _.without(_.keys(features), "project").join(",") + _.without(_.keys(features), "project").join(", ") ); } diff --git a/src/prompt.js b/src/prompt.js deleted file mode 100644 index 7bcd56db3b2..00000000000 --- a/src/prompt.js +++ /dev/null @@ -1,63 +0,0 @@ -"use strict"; - -var inquirer = require("inquirer"); -var _ = require("lodash"); -var FirebaseError = require("./error"); - -var prompt = function(options, questions) { - return new Promise(function(resolve, reject) { - var prompts = []; - for (var i = 0; i < questions.length; i++) { - if (!options[questions[i].name]) { - prompts.push(questions[i]); - } - } - - if (prompts.length && options.nonInteractive) { - return reject( - new FirebaseError( - "Missing required options (" + - _.uniq(_.map(prompts, "name")).join(", ") + - ") while running in non-interactive mode", - { - children: prompts, - exit: 1, - } - ) - ); - } - - return inquirer.prompt(prompts, function(answers) { - _.forEach(answers, function(v, k) { - options[k] = v; - }); - return resolve(options); - }); - }); -}; - -/** - * Allow a one-off prompt when we don't need to ask a bunch of questions. - */ -prompt.once = function(question) { - question.name = question.name || "question"; - return prompt({}, [question]).then(function(answers) { - return answers[question.name]; - }); -}; - -prompt.convertLabeledListChoices = function(choices) { - return choices.map(function(choice) { - return { checked: choice.checked, name: choice.label }; - }); -}; - -prompt.listLabelToValue = function(label, choices) { - for (var i = 0; i < choices.length; i++) { - if (choices[i].label === label) { - return choices[i].name; - } - } -}; - -module.exports = prompt; diff --git a/src/prompt.ts b/src/prompt.ts new file mode 100644 index 00000000000..723f4d3a4a2 --- /dev/null +++ b/src/prompt.ts @@ -0,0 +1,60 @@ +import * as inquirer from "inquirer"; +import * as _ from "lodash"; + +import * as FirebaseError from "./error"; + +/** + * Question type for inquirer. See + * https://www.npmjs.com/package/inquirer#question + */ +export type Question = inquirer.Question; + +/** + * prompt is used to prompt the user for values. Specifically, any `name` of a + * provided question will be checked against the `options` object. If `name` + * exists as a key in `options`, it will *not* be prompted for. If `options` + * contatins `nonInteractive = true`, then any `question.name` that does not + * have a value in `options` will cause an error to be returned. Once the values + * are queried, the values for them are put onto the `options` object, and the + * answers are returned. + * @param options The options object passed through by Command. + * @param questions `Question`s to ask the user. + * @return The answers, keyed by the `name` of the `Question`. + */ +export async function prompt(options: { [key: string]: any }, questions: Question[]): Promise { + const prompts = []; + for (const question of questions) { + if (question.name && !options[question.name]) { + prompts.push(question); + } + } + + if (prompts.length && options.nonInteractive) { + const missingOptions = _.uniq(_.map(prompts, "name")).join(", "); + throw new FirebaseError( + `Missing required options (${missingOptions}) while running in non-interactive mode`, + { + children: prompts, + exit: 1, + } + ); + } + + const answers = await inquirer.prompt(prompts); + // lodash's forEach's call back is (value, key); this is not a typo. + _.forEach(answers, (v, k) => { + options[k] = v; + }); + return answers; +} + +/** + * Quick version of `prompt` to ask a single question. + * @param question The question (of life, the universe, and everything). + * @return The value as returned by `inquirer` for that quesiton. + */ +export async function promptOnce(question: Question): Promise { + question.name = question.name || "question"; + const answers = await prompt({}, [question]); + return answers[question.name]; +} diff --git a/src/test/emulators/functionsEmulator.spec.ts b/src/test/emulators/functionsEmulator.spec.ts index 47cb8287261..faa7534fc49 100644 --- a/src/test/emulators/functionsEmulator.spec.ts +++ b/src/test/emulators/functionsEmulator.spec.ts @@ -32,13 +32,13 @@ function UseFunctions(triggers: () => {}): void { } describe("FunctionsEmulator-Hub", () => { - it("should route requests to /:project_id/:trigger_id to HTTPS Function", async () => { + it("should route requests to /:project_id/:region/:trigger_id to HTTPS Function", async () => { UseFunctions(() => { require("firebase-admin").initializeApp(); return { function_id: require("firebase-functions").https.onRequest( (req: express.Request, res: express.Response) => { - res.json({ hello: "world" }); + res.json({ path: req.path }); } ), }; @@ -47,14 +47,58 @@ describe("FunctionsEmulator-Hub", () => { await supertest( FunctionsEmulator.createHubServer(FunctionRuntimeBundles.template, process.execPath) ) - .get("/fake-project-id/us-central-1f/function_id") + .get("/fake-project-id/us-central1/function_id") .expect(200) .then((res) => { - expect(res.body).to.deep.equal({ hello: "world" }); + expect(res.body.path).to.deep.equal("/"); + }); + }).timeout(TIMEOUT_LONG); + + it("should route requests to /:project_id/:region/:trigger_id/ to HTTPS Function", async () => { + UseFunctions(() => { + require("firebase-admin").initializeApp(); + return { + function_id: require("firebase-functions").https.onRequest( + (req: express.Request, res: express.Response) => { + res.json({ path: req.path }); + } + ), + }; + }); + + await supertest( + FunctionsEmulator.createHubServer(FunctionRuntimeBundles.template, process.execPath) + ) + .get("/fake-project-id/us-central1/function_id/") + .expect(200) + .then((res) => { + expect(res.body.path).to.deep.equal("/"); + }); + }).timeout(TIMEOUT_LONG); + + it("should route requests to /:project_id/:region/:trigger_id/a/b to HTTPS Function", async () => { + UseFunctions(() => { + require("firebase-admin").initializeApp(); + return { + function_id: require("firebase-functions").https.onRequest( + (req: express.Request, res: express.Response) => { + res.json({ path: req.path }); + } + ), + }; + }); + + await supertest( + FunctionsEmulator.createHubServer(FunctionRuntimeBundles.template, process.execPath) + ) + .get("/fake-project-id/us-central1/function_id/a/b") + .expect(200) + .then((res) => { + expect(res.body.path).to.deep.equal("/a/b"); }); }).timeout(TIMEOUT_LONG); - it("should rewrite req.path to hide /:project_id/:trigger_id", async () => { + it("should rewrite req.path to hide /:project_id/:region/:trigger_id", async () => { UseFunctions(() => { require("firebase-admin").initializeApp(); return { @@ -69,13 +113,35 @@ describe("FunctionsEmulator-Hub", () => { await supertest( FunctionsEmulator.createHubServer(FunctionRuntimeBundles.template, process.execPath) ) - .get("/fake-project-id/us-central-1f/function_id/sub/route/a") + .get("/fake-project-id/us-central1/function_id/sub/route/a") .expect(200) .then((res) => { expect(res.body.path).to.eq("/sub/route/a"); }); }).timeout(TIMEOUT_LONG); + it("should rewrite req.baseUrl to show /:project_id/:region/:trigger_id", async () => { + UseFunctions(() => { + require("firebase-admin").initializeApp(); + return { + function_id: require("firebase-functions").https.onRequest( + (req: express.Request, res: express.Response) => { + res.json({ baseUrl: req.baseUrl }); + } + ), + }; + }); + + await supertest( + FunctionsEmulator.createHubServer(FunctionRuntimeBundles.template, process.execPath) + ) + .get("/fake-project-id/us-central1/function_id/sub/route/a") + .expect(200) + .then((res) => { + expect(res.body.baseUrl).to.eq("/fake-project-id/us-central1/function_id"); + }); + }).timeout(TIMEOUT_LONG); + it("should route request body", async () => { UseFunctions(() => { require("firebase-admin").initializeApp(); @@ -91,7 +157,7 @@ describe("FunctionsEmulator-Hub", () => { await supertest( FunctionsEmulator.createHubServer(FunctionRuntimeBundles.template, process.execPath) ) - .post("/fake-project-id/us-central-1f/function_id/sub/route/a") + .post("/fake-project-id/us-central1/function_id/sub/route/a") .send({ hello: "world" }) .expect(200) .then((res) => { @@ -114,7 +180,7 @@ describe("FunctionsEmulator-Hub", () => { await supertest( FunctionsEmulator.createHubServer(FunctionRuntimeBundles.template, process.execPath) ) - .get("/fake-project-id/us-central-1f/function_id/sub/route/a?hello=world") + .get("/fake-project-id/us-central1/function_id/sub/route/a?hello=world") .expect(200) .then((res) => { expect(res.body).to.deep.equal({ hello: "world" }); diff --git a/src/test/emulators/functionsEmulatorRuntime.spec.ts b/src/test/emulators/functionsEmulatorRuntime.spec.ts index c62da255bed..7aa3aace26f 100644 --- a/src/test/emulators/functionsEmulatorRuntime.spec.ts +++ b/src/test/emulators/functionsEmulatorRuntime.spec.ts @@ -221,7 +221,7 @@ describe("FunctionsEmulator-Runtime", () => { request( { socketPath: runtime.metadata.socketPath, - path: "/", + path: `/${onRequestCopy.projectId}/us-central1/${onRequestCopy.triggerId}`, }, (res) => { let data = ""; @@ -339,7 +339,8 @@ describe("FunctionsEmulator-Runtime", () => { describe("Runtime", () => { describe("HTTPS", () => { it("should handle a GET request", async () => { - const runtime = InvokeRuntimeWithFunctions(FunctionRuntimeBundles.onRequest, () => { + const frb = FunctionRuntimeBundles.onRequest; + const runtime = InvokeRuntimeWithFunctions(frb, () => { require("firebase-admin").initializeApp(); return { function_id: require("firebase-functions").https.onRequest( @@ -355,7 +356,7 @@ describe("FunctionsEmulator-Runtime", () => { request( { socketPath: runtime.metadata.socketPath, - path: "/", + path: `/${frb.projectId}/us-central1/${frb.triggerId}/`, }, (res) => { let data = ""; @@ -372,7 +373,8 @@ describe("FunctionsEmulator-Runtime", () => { }).timeout(TIMEOUT_MED); it("should handle a POST request with form data", async () => { - const runtime = InvokeRuntimeWithFunctions(FunctionRuntimeBundles.onRequest, () => { + const frb = FunctionRuntimeBundles.onRequest; + const runtime = InvokeRuntimeWithFunctions(frb, () => { require("firebase-admin").initializeApp(); return { function_id: require("firebase-functions").https.onRequest( @@ -390,7 +392,7 @@ describe("FunctionsEmulator-Runtime", () => { const req = request( { socketPath: runtime.metadata.socketPath, - path: "/", + path: `/${frb.projectId}/us-central1/${frb.triggerId}`, method: "post", headers: { "Content-Type": "application/x-www-form-urlencoded", @@ -414,7 +416,8 @@ describe("FunctionsEmulator-Runtime", () => { }).timeout(TIMEOUT_MED); it("should handle a POST request with JSON data", async () => { - const runtime = InvokeRuntimeWithFunctions(FunctionRuntimeBundles.onRequest, () => { + const frb = FunctionRuntimeBundles.onRequest; + const runtime = InvokeRuntimeWithFunctions(frb, () => { require("firebase-admin").initializeApp(); return { function_id: require("firebase-functions").https.onRequest( @@ -431,7 +434,7 @@ describe("FunctionsEmulator-Runtime", () => { const req = request( { socketPath: runtime.metadata.socketPath, - path: "/", + path: `/${frb.projectId}/us-central1/${frb.triggerId}`, method: "post", headers: { "Content-Type": "application/json", @@ -455,7 +458,8 @@ describe("FunctionsEmulator-Runtime", () => { }).timeout(TIMEOUT_MED); it("should handle a POST request with text data", async () => { - const runtime = InvokeRuntimeWithFunctions(FunctionRuntimeBundles.onRequest, () => { + const frb = FunctionRuntimeBundles.onRequest; + const runtime = InvokeRuntimeWithFunctions(frb, () => { require("firebase-admin").initializeApp(); return { function_id: require("firebase-functions").https.onRequest( @@ -472,7 +476,7 @@ describe("FunctionsEmulator-Runtime", () => { const req = request( { socketPath: runtime.metadata.socketPath, - path: "/", + path: `/${frb.projectId}/us-central1/${frb.triggerId}`, method: "post", headers: { "Content-Type": "text/plain", @@ -496,7 +500,8 @@ describe("FunctionsEmulator-Runtime", () => { }).timeout(TIMEOUT_MED); it("should handle a POST request with any other type", async () => { - const runtime = InvokeRuntimeWithFunctions(FunctionRuntimeBundles.onRequest, () => { + const frb = FunctionRuntimeBundles.onRequest; + const runtime = InvokeRuntimeWithFunctions(frb, () => { require("firebase-admin").initializeApp(); return { function_id: require("firebase-functions").https.onRequest( @@ -513,7 +518,7 @@ describe("FunctionsEmulator-Runtime", () => { const req = request( { socketPath: runtime.metadata.socketPath, - path: "/", + path: `/${frb.projectId}/us-central1/${frb.triggerId}`, method: "post", headers: { "Content-Type": "gibber/ish", @@ -537,8 +542,51 @@ describe("FunctionsEmulator-Runtime", () => { await runtime.exit; }).timeout(TIMEOUT_MED); + it("should handle a POST request and store rawBody", async () => { + const frb = FunctionRuntimeBundles.onRequest; + const runtime = InvokeRuntimeWithFunctions(frb, () => { + require("firebase-admin").initializeApp(); + return { + function_id: require("firebase-functions").https.onRequest( + async (req: any, res: any) => { + res.send(req.rawBody); + } + ), + }; + }); + + await runtime.ready; + await new Promise((resolve) => { + const reqData = "How are you?"; + const req = request( + { + socketPath: runtime.metadata.socketPath, + path: `/${frb.projectId}/us-central1/${frb.triggerId}`, + method: "post", + headers: { + "Content-Type": "gibber/ish", + "Content-Length": reqData.length, + }, + }, + (res) => { + let data = ""; + res.on("data", (chunk) => (data += chunk)); + res.on("end", () => { + expect(data).to.equal(reqData); + resolve(); + }); + } + ); + req.write(reqData); + req.end(); + }); + + await runtime.exit; + }).timeout(TIMEOUT_MED); + it("should forward request to Express app", async () => { - const runtime = InvokeRuntimeWithFunctions(FunctionRuntimeBundles.onRequest, () => { + const frb = FunctionRuntimeBundles.onRequest; + const runtime = InvokeRuntimeWithFunctions(frb, () => { require("firebase-admin").initializeApp(); const app = require("express")(); app.get("/", (req: express.Request, res: express.Response) => { @@ -554,7 +602,7 @@ describe("FunctionsEmulator-Runtime", () => { const req = request( { socketPath: runtime.metadata.socketPath, - path: "/?hello=world", + path: `/${frb.projectId}/us-central1/${frb.triggerId}?hello=world`, method: "get", }, (res) => { @@ -573,7 +621,8 @@ describe("FunctionsEmulator-Runtime", () => { }).timeout(TIMEOUT_MED); it("should handle `x-forwarded-host`", async () => { - const runtime = InvokeRuntimeWithFunctions(FunctionRuntimeBundles.onRequest, () => { + const frb = FunctionRuntimeBundles.onRequest; + const runtime = InvokeRuntimeWithFunctions(frb, () => { require("firebase-admin").initializeApp(); return { function_id: require("firebase-functions").https.onRequest( @@ -589,7 +638,7 @@ describe("FunctionsEmulator-Runtime", () => { request( { socketPath: runtime.metadata.socketPath, - path: "/", + path: `/${frb.projectId}/us-central1/${frb.triggerId}`, headers: { "x-forwarded-host": "real-hostname", }, @@ -727,7 +776,8 @@ describe("FunctionsEmulator-Runtime", () => { describe("Error handling", () => { it("Should handle regular functions for Express handlers", async () => { - const runtime = InvokeRuntimeWithFunctions(FunctionRuntimeBundles.onRequest, () => { + const frb = FunctionRuntimeBundles.onRequest; + const runtime = InvokeRuntimeWithFunctions(frb, () => { require("firebase-admin").initializeApp(); return { function_id: require("firebase-functions").https.onRequest((req: any, res: any) => { @@ -743,7 +793,7 @@ describe("FunctionsEmulator-Runtime", () => { request( { socketPath: runtime.metadata.socketPath, - path: "/", + path: `/${frb.projectId}/us-central1/${frb.triggerId}`, }, (res) => { res.on("end", resolve); @@ -759,7 +809,8 @@ describe("FunctionsEmulator-Runtime", () => { }).timeout(TIMEOUT_MED); it("Should handle async functions for Express handlers", async () => { - const runtime = InvokeRuntimeWithFunctions(FunctionRuntimeBundles.onRequest, () => { + const frb = FunctionRuntimeBundles.onRequest; + const runtime = InvokeRuntimeWithFunctions(frb, () => { require("firebase-admin").initializeApp(); return { function_id: require("firebase-functions").https.onRequest( @@ -777,7 +828,7 @@ describe("FunctionsEmulator-Runtime", () => { request( { socketPath: runtime.metadata.socketPath, - path: "/", + path: `/${frb.projectId}/us-central1/${frb.triggerId}`, }, (res) => { res.on("end", resolve); @@ -793,7 +844,8 @@ describe("FunctionsEmulator-Runtime", () => { }).timeout(TIMEOUT_MED); it("Should handle async/runWith functions for Express handlers", async () => { - const runtime = InvokeRuntimeWithFunctions(FunctionRuntimeBundles.onRequest, () => { + const frb = FunctionRuntimeBundles.onRequest; + const runtime = InvokeRuntimeWithFunctions(frb, () => { require("firebase-admin").initializeApp(); return { function_id: require("firebase-functions") @@ -810,7 +862,7 @@ describe("FunctionsEmulator-Runtime", () => { request( { socketPath: runtime.metadata.socketPath, - path: "/", + path: `/${frb.projectId}/us-central1/${frb.triggerId}`, }, (res) => { res.on("end", resolve); diff --git a/src/test/emulators/functionsEmulatorUtils.spec.ts b/src/test/emulators/functionsEmulatorUtils.spec.ts index 3fd13794d19..96974c7f997 100644 --- a/src/test/emulators/functionsEmulatorUtils.spec.ts +++ b/src/test/emulators/functionsEmulatorUtils.spec.ts @@ -2,7 +2,6 @@ import { expect } from "chai"; import { extractParamsFromPath, isValidWildcardMatch, - trimFunctionPath, trimSlashes, } from "../../emulator/functionsEmulatorUtils"; @@ -94,24 +93,4 @@ describe("FunctionsEmulatorUtils", () => { expect(trimSlashes("///a////b//c/")).to.equal("a/b/c"); }); }); - - describe("trimFunctionPath", () => { - it("should remove the beginning of a function URL", () => { - expect(trimFunctionPath("/projectid/us-central1/functionPath")).to.equal(""); - }); - it("should not care about leading slashes", () => { - expect(trimFunctionPath("projectid/us-central1/functionPath")).to.equal(""); - }); - it("should preserve query parameters", () => { - expect(trimFunctionPath("/projectid/us-central1/functionPath?foo=bar")).to.equal("?foo=bar"); - }); - it("should preserve subpaths", () => { - expect(trimFunctionPath("/projectid/us-central1/functionPath/x/y")).to.equal("x/y"); - }); - it("should preserve subpaths with query parameters", () => { - expect(trimFunctionPath("/projectid/us-central1/functionPath/x/y?foo=bar")).to.equal( - "x/y?foo=bar" - ); - }); - }); }); diff --git a/src/test/prompt.spec.ts b/src/test/prompt.spec.ts new file mode 100644 index 00000000000..7db86ff4499 --- /dev/null +++ b/src/test/prompt.spec.ts @@ -0,0 +1,74 @@ +import { expect } from "chai"; +import * as sinon from "sinon"; +import * as inquirer from "inquirer"; + +import * as FirebaseError from "../error"; +import * as prompt from "../prompt"; + +describe("prompt", () => { + let inquirerStub: sinon.SinonStub; + const PROMPT_RESPONSES = { + lint: true, + project: "the-best-project-ever", + }; + + beforeEach(() => { + // Stub inquirer to return a set of fake answers. + inquirerStub = sinon.stub(inquirer, "prompt").resolves(PROMPT_RESPONSES); + }); + + afterEach(() => { + sinon.restore(); + }); + + describe("prompt", () => { + it("should error if questions are asked in nonInteractive environment", async () => { + const o = { nonInteractive: true }; + const qs: prompt.Question[] = [{ name: "foo" }]; + + expect(prompt.prompt(o, qs)).to.be.rejectedWith(FirebaseError, /required.+non\-interactive/); + }); + + it("should utilize inquirer to prompt for the questions", async () => { + const qs: prompt.Question[] = [ + { + name: "foo", + message: "this is a test", + }, + ]; + + await prompt.prompt({}, qs); + + expect(inquirerStub).calledOnceWithExactly(qs); + }); + + it("should add the new values to the options object", async () => { + const options = { hello: "world" }; + const qs: prompt.Question[] = [ + { + name: "foo", + message: "this is a test", + }, + ]; + + await prompt.prompt(options, qs); + + expect(options).to.deep.equal(Object.assign({ hello: "world" }, PROMPT_RESPONSES)); + }); + }); + + describe("promptOnce", () => { + it("should provide a name if one is not provided", async () => { + await prompt.promptOnce({ message: "foo" }); + + expect(inquirerStub).calledOnceWith([{ name: "question", message: "foo" }]); + }); + + it("should return the value for the given name", async () => { + const r = await prompt.promptOnce({ name: "lint" }); + + expect(r).to.equal(true); + expect(inquirerStub).calledOnce; + }); + }); +});