So, you want to run multiple node.js Google Cloud Functions locally at the same time using Google’s functions-framework?
You might have previously written cloud functions in Firebase, where the Firebase Local Emulator Suite allowed you to run all your functions simultaneously, on a single local server, with a single command (firebase emulators:start
).
The function framework does not provide an emulator that can do this out-of-the-box. However, you can very easily write one yourself, and approximate Firebase’s local development experience this way.
This approach combines your functions in a single Express “meta” app for development purposes only. You can still deploy the functions individually to Google Cloud.
Example setup
In this example I have the following directory structure:
├── package.json
└── src
├── index.js
└── functions
├── firstFunction.js
└── secondFunction.js
The function scripts
The two functions themselves are full-fledged Express.js handlers, like they would be in Firebase.
To test that the two functions can interact, the first function returns HTTP 302 redirect, which redirects to a GET request on the second function.
// src/functions/firstFunction.js
export const firstFunction = async (req, res) => {
res.redirect('/secondFunction');
}
// src/functions/secondFunction.js
export const secondFunction = async (req, res) => {
res.send("OK! You were redirected here.");
}
package.json
The package.json
refers to the src/index.js
as the main node script. We also need to tell the functions-framework to target the index
export within the index.js
module:
// package.json
...
"type": "module",
// Tells the functions-framework where to look for exports.
"main": "src/index.js",
"scripts": {
"start": "functions-framework --target=index", // Select target export
"debug": "functions-framework --target=index --debug"
}
...
index.js
The index.js
file is the core of this setup. It’s where we will expose all functions combined on a single local address, as well as expose functions individually.
// src/index.js
import express from "express"
import { firstFunction } from "./functions/firstFunction.js";
import { secondFunction } from "./functions/secondFunction.js";
// Solution to expose multiple cloud functions locally
const app = express();
app.use('/firstFunction', firstFunction);
app.use('/secondFunction', secondFunction);
export {app as index, firstFunction, secondFunction};
The local functions-framework will target the index
export exported from index.js
, see the package.json
above. The index
export is only meant for local development purposes, so we can run multiple functions at once locally.
We still export the individual functions too, so we can easily deploy both functions individually. See “Deploying functions individually” below.
Running the functions together
Now if we run npm run start
, a development server will start on http://localhost:8080
.
Running curl http://localhost:8080/firstFunction
will print OK! You were redirected here.
, demonstrating that both functions are running at the same time.
If you still want to test a function in isolation, you can run functions-framework --target=firstFunction
instead, after which you can call it as usual with curl http://localhost:8080
.
Deploying functions individually
Functions can still be individually deployed, with the gcloud
CLI:
gcloud functions deploy firstFunction --project=my-project --runtime nodejs16 --trigger-http --allow-unauthenticated --security-level=secure-always --region=eyour-region --entry-point=firstFunction --memory=128MB --timeout=60s
gcloud functions deploy secondFunction --project=my-project --runtime nodejs16 --trigger-http --allow-unauthenticated --security-level=secure-always --region=your-region --entry-point=secondFunction --memory=128MB --timeout=60s
The key here is --entrypoint firstFunction
flag, which is similar to --target
flag on the functions-framework
command. It selects the module export of the index script that should be seen as the entry point for the cloud function.
You could also deploy the index
export as a single function that combines all functions in one, but then you would have call /index/firstFunction
and /index/secondFunction
on the cloud, and you then can’t scale or modify the function runtimes individually anymore.
Caveats
Using Express “sub apps” this way is not a 100% watertight way to emulate multiple individual functions running in Google Cloud. There are some caveats.
req.originalUrl
⚠️
This property is much like
req.url
; however, it retains the original request URL, allowing you to rewritereq.url
freely for internal routing purposes. For example, the “mounting” feature of app.use() will rewritereq.url
to strip the mount point.
req.originalUrl
doesn’t behave as it would in a real production Google Cloud multi-function setup.
On Google Cloud, req.originalUrl
excludes the function name. This is likely due to some internal redirections that Google Cloud does.
With the index.js emulator proposed by this post, req.originalUrl
will still include the function name.
Make sure that your code does not depend on the value req.originalUrl
for some decisions. If it does, you might need to adapt this code.
req.path
behavior ✅
In Google Cloud, accessing req.path
in a function running on https://your-google-cloud-domain/firstFunction
will yield /
, and not /firstFunction
(somewhat suprisingly).
With the above caveat, you might wonder, does this behavior also not copy to our local emulator setup?
The answer is: this behavior remains the same. See the Express documentation for req.path:
When called from a middleware, the mount point is not included in
req.path
.
When we call app.use('/firstFunction', firstFunction);
, we register firstFunction
as application-level middleware onto the app
with the “mount point” being /firstFunction
.
More?
There might be other caveats I’m not aware of with this setup, but for now it suits my purposes, and the added local development convenience is worth any future complications.
References
Closing note: this solution is based on an answer in a related GitHub Issue thread that you might have seen already when researching this issue.
I wrote this post because that thread contains several approaches to this issue that were less relevant to my use-case (and, the thread was also filled with more drama than necessary). I hope developers used to the Firebase functions model find this setup suggestion helpful.
Top comments (0)