Write your own Cloud Functions
Put your app's Cloud Functions in functions/src/custom/ and export them from functions/src/custom/index.ts. They deploy next to the core functions under their own names, so they can never collide with one.
Add a function
-
Write the function in its own file, such as
functions/src/custom/lessons.ts:import { onCall, HttpsError } from 'firebase-functions/v2/https'; import { db } from '../init.js'; export const completeLesson = onCall(async (request) => { const userDocId = request.auth?.token['arccms_uid']; if (!userDocId) throw new HttpsError('unauthenticated', 'Please sign in.'); const lessonId = String(request.data?.lessonId ?? ''); if (!lessonId) throw new HttpsError('invalid-argument', 'lessonId is required.'); await db.doc(`users/${userDocId}/progress/${lessonId}`).set({ done: true }, { merge: true }); return { ok: true }; }); -
Export it from
functions/src/custom/index.ts. The functions use ES modules, so imports end in.js:export * from './lessons.js'; -
If it is a callable, add its name to
functions/src/custom/public-callables.txt, one per line:completeLesson -
Deploy just that function (Deploy only what changed). The first time, add
--probeto check that the new callable is reachable (What public-callables.txt is for):npm run deploy -- --only functions:arccms:arccms.custom.completeLesson --probe
npm run dev talks to your real Firebase project; there is no emulator for functions. A function you changed is not live until it is deployed, so browser tests of it before the deploy use the old version.
How functions are named
functions/src/index.ts exports every Arc CMS function as one group, arccms, and functions/src/all.ts exports your file as a group inside it, custom. So a function you export as completeLesson deploys as arccms-custom-completeLesson.
| Where | The name you use |
|---|---|
| Deployed function | arccms-custom-completeLesson |
Browser, with arcCallable | custom-completeLesson |
| Deploy target | functions:arccms:arccms.custom.completeLesson |
public-callables.txt | completeLesson |
Call it from the browser
Use arcCallable from src/app/core/config/arc-functions.ts. It adds the arccms- prefix, so you pass custom- and the name:
import { inject } from '@angular/core';
import { Functions } from '@angular/fire/functions';
import { arcCallable } from '../../app/core/config/arc-functions';
private functions = inject(Functions);
complete(lessonId: string) {
return arcCallable<{ lessonId: string }, { ok: boolean }>(this.functions, 'custom-completeLesson')({ lessonId });
}
Who is calling
A callable reads the caller from request.auth. Two claims on the token matter:
| Claim | Holds |
|---|---|
arccms_uid | The id of the person's user record, users/{userDocId}. It is not the Auth uid |
arccms_role | The Arc CMS role, such as admin |
For an admin-only callable, check the role with isArcAdmin, which the app kit exports. The kit, functions/src/app-kit/index.ts, is the supported way to use core from your functions: accounts, claims, sessions and sign-in tokens (see The app kit):
import { isArcAdmin } from '../app-kit/index.js';
if (!isArcAdmin(request.auth?.token)) throw new HttpsError('permission-denied', 'Admin access required.');
See Roles and claims and Follow the account contract.
Use the right database
An install can use a named Firestore database instead of (default). Two rules keep your code on the right one:
- Reads and writes go through
dbfromfunctions/src/init.ts. Never callgetFirestore()yourself. - Firestore triggers pass their path through
arcDocument()fromfunctions/src/arc-config.ts, which binds the trigger to the install's database at deploy time.
import { onDocumentCreated } from 'firebase-functions/v2/firestore';
import { db } from '../init.js';
import { arcDocument } from '../arc-config.js';
export const onLessonDone = onDocumentCreated(
arcDocument('users/{userDocId}/progress/{lessonId}'),
async (event) => {
const { userDocId } = event.params;
// update the person's totals with db ...
},
);
Trigger options go next to it: { ...arcDocument('Lessons/{id}'), timeoutSeconds: 540 }. A test fails when any function file calls getFirestore() outside functions/src/init.ts, or declares a Firestore trigger without arcDocument(); it covers your files too.
functions/src/init.ts also exports owner (Firebase Auth) and storage.
React to what happens to a person
When core finishes its own work on a person, it writes a document to AppEvents with a type, such as user.signed_up, user.deleted, user.signInDeleted, payment.succeeded, payment.failed or waitlist.joined. React with your own trigger and return at once for other types:
export const onSignedUp = onDocumentCreated(arcDocument('AppEvents/{id}'), async (event) => {
const e = event.data?.data();
if (e?.['type'] !== 'user.signed_up') return;
// e.userId is the Auth uid: create the person's starter data
});
This is safer than a second trigger on a core collection such as users. If you do add one, it must not rely on core's trigger having run, must write only its own fields or subcollections, must be safe to run twice, and must return at once when the change does not concern it.
Follow the app's features
If your function calls into a feature, check it first with isFeatureOn('payments') from functions/src/feature-flags.ts (Choose your app's features).
Settings for your functions
Read a setting with Firebase's defineString from firebase-functions/params, as core does in functions/src/arc-config.ts. Put the value in the per-project file functions/.env.YOUR_PROJECT: npm run arc:configure writes its own keys there and keeps every other line. For a secret, use Firebase's defineSecret so the value stays out of the repository.
Do not add your keys to functions/.env. It is a core file, so npm run check:core reports your change, and an Arc CMS update can conflict with it.
Keep cold starts short
A function that has not run for a while starts from cold, and every function loads the whole functions codebase as it starts: core, the app's features and your functions. A slow library imported at the top of any file therefore slows the start of every function, sign-in included, and not only the one that uses it.
- Import a large library inside the function that uses it:
const { google } = await import('googleapis');. A test fails ifgoogleapisorcheeriois imported at the top of a file; types can still be imported withimport type. - To parse HTML, use
loadHtml()fromfunctions/src/shared/lazy-cheerio.tsin place ofcheerio.load(). It loads cheerio the first time it is called. - For a callable a page is about to use, the page can start it early with a call that does nothing:
isWarmUp()andWARMinfunctions/src/auth/warmUp.ts, as the sign-in page does (Sign-in).
Do not give a function a region of its own: every function runs in the install's region, next to its database (functions/src/region.ts, see Regions), and a test fails on a function that sets one.
Keeping a function always running (minInstances) removes cold starts, but is billed all the time, for each function; Arc CMS does not do it.
What public-callables.txt is for
Firebase makes a callable publicly invocable when it creates it. If that step fails, for example in a deploy that timed out, every browser call to the function fails with HTTP 403, and a later deploy does not fix it. functions/scripts/check-callable-access.sh calls each callable and reports the blocked ones with the command that fixes them.
- The guided
npm run deploychecks every callable the deploy created. It recognizes your callables by the names inpublic-callables.txt, so a callable missing from the file is not checked. --probeadded to a deploy that includes functions checks every callable in the build afterwards, and reports a listed callable that was not deployed.
Lines starting with # are comments.
Test your functions
Put a spec next to your code, such as functions/src/custom/__tests__/lessons.spec.ts, and run the whole suite from the repository root:
npm run test
The functions have no test script of their own. Mock init and the Firebase trigger module, then call the handler directly:
import { describe, expect, it, vi } from 'vitest';
vi.mock('../../init', () => ({ db: { doc: vi.fn(() => ({ set: vi.fn() })) } }));
vi.mock('firebase-functions/v2/https', () => ({
onCall: vi.fn((handler: unknown) => handler),
HttpsError: class extends Error {
constructor(public code: string, message: string) { super(message); }
},
}));
import { completeLesson } from '../lessons.js';
describe('completeLesson', () => {
it('refuses a caller who is not signed in', async () => {
await expect((completeLesson as any)({ data: {} })).rejects.toMatchObject({ code: 'unauthenticated' });
});
});
Core specs in functions/src/__tests__/ show more patterns, including an in-memory Firestore in functions/src/__tests__/helpers/memoryFirestore.ts. See Testing.
Deploy
Deploy only the functions you changed while you work (Deploy only what changed). A deploy of every function also removes the functions of features you turned off; npm run deploy asks first (Deploy). The full list of core functions is in Cloud Functions.