Lately when I'm building a hobby app, and it's still in the mvp phase, I try to spend as little money as possible. I've learned my lesson from past failed projects. For this phase, Firebase is ideal.
If your app's backend is simple enough, use firebase functions. If you later decide to switch to express or nestjs, you can reuse most of those functions.
You get 2 million free invocations every month. After that you're charged 0.4$ per million calls.
Firebase functions are google cloud functions that are integrated into the Firebase ecosystem. Most of the time I write functions that handle requests and response, very similar to Express route handler. Firebase takes care of deployment, scaling and connecting to other Firebase services. That means you don't need to deal with setting up permissioons, just hit:
firebase deploy --only functionsFunctions spin up on demand. The first request is always slower, also known as "cold start". Not a deal breaker, but if speed is important to you, keep that in mind. Since backend spins up on demand, it's very important that you keep your dependencies small.
For example, nestjs is too heavy to run in a function. It should be hosted separately.
Types of Firebase Functions that I use
Callable functions
The simplest types of functions. Similar to an express handler, and they use underlying Express Request. Besides the express usual, you have immediate access if the request is authenticated(req.auth).
onCall((req) => {
return { message: 'Hello world' }
})Trigger functions
These run automatically when something happens in Firebase. For example, I have a trigger when user is created to add him to mailing list:
export const addUserToNewsletter = functionsv1.auth.user().onCreate(
async (user) => {
// process user
});Full list here https://firebase.google.com/docs/functions/1st-gen/auth-events
Scheduled functions
These run on a schedule like cron jobs. You can run them every hour, day, or any custom interval. You could for example run a periodical purge of inactive firebase users:
export const purgeFreeAuthUsers = onSchedule('every 24 hours', async () => {
// find inactive users
// purge related assets like firestore, storage
// log summary of deleted so admin can review it in cloud run
})There are also http functions that I haven't had a need for yet, so moving on.
Firebase V2 vs V1
v2 is newer generation, and you should use it when you can. One thing that stands out is improved cold start time.
Only time you have to use v1 is when it's not supported on v2, like auth triggers.
Monitoring functions
You can view what's going on with your functions in google cloud logging and I suggest that you use firebase logger.
As usual with logging, you would log errors and important parts of the process.
For example, important part of my purgeFreeAuthUsers is the summary like
import { logger } from 'firebase-functions';
logger.info(`Deleted=${deleted} Auth users (free & ≥ ${config.days} days old).`);Reading configuration
There are 2 methods you can use to pass config to firebase functions:
- .env - use a dotenv file, firebase deploy will read from it and that's it, or use a parametrized environment configuration, where firebase will fail to deploy if you don't define the specified environment variables.
- google secret manager - the
defineSecretparameter calls from google secret manager. This way you can dynamically change your configuration without re-deploying. It's the method I use. I find it most practical to keep secrets versioning in google secret manager. You need to enable google secret manager service in apis and define dependency to your secrets from function call:
const mySecret = defineSecret('MY_SECRET');
export const myFunction = onCall({
secrets: [mySecret]
}, (req) => {
});There was a 3rd method, you'd call firebase cli to set configuration and there was no other way to view configuration without using firebase cli. It's deprecated, so either .env or secret manager.
Validating functions
I don't trust user input. I prefer to use zod for input validation. Anything that goes into request, needs to pass through a zod schema.
const parsed = schema.safeParse(req.data);
if (!parsed.success) {
logger.error('Input validation failed', {
data: req.data,
issues: parsed.error.issues.map((zodIssue) => ({
path: zodIssue.path.join('.'),
code: zodIssue.code,
message: zodIssue.message,
})),
});
throw new HttpsError('invalid-argument', 'Invalid request data', {
issues: parsed.error.issues.map((zodIssue) => ({
path: zodIssue.path.join('.'),
code: zodIssue.code,
message: zodIssue.message,
})),
});
}
// processAuthenticating requests
If the user is authenticated with firebase, your request object will contain an `auth` property. You can reject everything else:
onCall(opts, async (request: CallableRequest<T>) => {
const requestUserId = request.auth?.uid;
if (!requestUserId) {
throw new HttpsError('unauthenticated', 'Not logged in!');
}
// process
});Adding Middleware
There's no standard for adding middleware in functions as far as I know it, so I cracked something myself. I create higher order functional middleware. Simple template for middleware is:
type Handler<T = any, R = any, S = unknown> =
(req: CallableRequest<T>, res?: CallableResponse<S>) => R | Promise<R>
export const withAuthorization =
<T, R>(next: Handler<T, R>): Handler<T, R> =>
async (request, res) => {
// TODO: Do something
return next(request, res)
}I looked into the signature of onCall and extracted a handler type. My middleware needs to accept that handler, but do something before it, and maybe inject for next handler in the chain.
And then my 2 authorization/validation middlewares are:
export const withAuthorization =
<T, R>(next: Handler<T, R>): Handler<T, R> =>
async (request, res) => {
const requestUserId = request.auth?.uid;
logger.info('Request auth:', request.auth);
if (!requestUserId) {
throw new HttpsError('unauthenticated', 'Not logged in!');
}
return next(request, res)
}
export const withValidation =
<T extends Record<string, any>, R>(schema: ZodObject<T>) =>
(next: Handler<T, R>): Handler<T, R> =>
async (request, res) => {
const parsed = schema.safeParse(request.data);
if (!parsed.success) {
logger.error('Input validation failed', {
data: request.data,
issues: parsed.error.issues.map((zodIssue) => ({
path: zodIssue.path.join('.'),
code: zodIssue.code,
message: zodIssue.message,
})),
});
throw new HttpsError('invalid-argument', 'Invalid request data');
}
return next({...request, data: parsed.data} as CallableRequest<T>, res)
}In my last withValidation, you can see I modified the request data to return the zod parsed result.
Middlewares are used like this:
onCall(withAuthorization(
withValidation(SayHelloSchema)((request) => {
const {name} = request.data;
logger.info(`sayHello called with name: ${name}`);
return {message: `Hello, ${name}!`};
})));You can chain as many as you like, just follow the same pattern...
For my full train of thought, look at my Youtube video about this matter.
Modularizing
Good practice is to modularize your functions when the module starts getting big. I rarely do it right away if I don't yet see the right structure.
I think that modularizing too early, just for the sake of small modules, is a trap. Logic is hidden behind abstractions and then it's hard to see what's in front of me.
That said, I hate repetition and my code is always written with refactoring in mind.
Local Development
You don't need to deploy firebase functions just to use them in development. Use a backend emulator.
Start your functions with
firebase init emulators
firebase emulators:startIn the react native app add:
if (__DEV__) {
const host = Platform.OS === 'android' ? '10.0.2.2': 'localhost';
connectFunctionsEmulator(getFunctions(), host, 5001);
}Android emulator doesn't recognize localhost, but it does 10.0.2.2.
You can emulate all firebase services. Just choose the services when you init emulators and link to them from the app.
Unit Testing
For unit testing I use jest.
npm install --save-dev jest ts-jest @types/jest
npx ts-jest config:initIf you are testing a callable, your test will be something like:
import { OpenAI } from 'openai';
import askOracle from '../askOracle';
import { CallableRequest } from 'firebase-functions/https'; // adjust path
// Don't call real implementations of services
jest.mock('firebase-functions', () => ({
logger: { info: jest.fn() },
}));
describe('myFunctionCall', () => {
const mockReq = {
data: {
name: 'Hello'
},
};
it('returns message', async () => {
const result = await myFunctionCall.run(mockReq as CallableRequest);
expect(result).toEqual({ message: 'Hello.' });
});
});
Integration Testing
For e2e test I like to use firebase emulator instead of calling a real firebase cloud. It's not pure integrational testing, but it's close enough.
If you load configuration from google secret manager, then create a .env file and write your configuration there. Emulator will try reading the configuration from secret manager and it will fail, resulting in empty data. Then it's fallbacked to .env file.
Example of testing onCall function:
describe('Check function-name', () => {
it('should return a real function-name response', async () => {
const result = await axios.post('http://127.0.0.1:5001/project-id/project-region/function-name', {
data: {
// pass input data
}
})
const result = reply.data.result;
expect(result).toBeTruthy();
})
});If you enforce app check, you will just send a fake debug token in header to avoid 401. Emulator fakes the app check.
const result = await axios.post('http://127.0.0.1:5001/project-id/project-region/function-name', {
data: {
// pass input data
}
}, {
headers: { 'X-Firebase-AppCheck': 'fake-debug-token' },
})Integration Testing in Github Actions
It's a bit messy but working setup, I use firebase emulator service to act as a backend. And I need to wait for it to bootstrap before doing requests. I'll just post the important snip of the github workflow setup here:
- name: Run Integration Tests
run: |
npm run build --prefix functions
npx firebase emulators:start --only functions & sleep 10
until curl -s 127.0.0.1:4400/emulators | grep -q '"functions"'; do sleep 1; done
echo "Emulator is up."
npm run test --prefix functions
pkill -f "firebase"You can always double check with local github runner before submitting to github actions.
I have a Youtube video where I walk through all of this and build a chatgpt article summarizer.
Github: https://github.com/amarjanica/ai-article-summarizer-app
Youtube: https://youtu.be/kG_2m5TXQ20
