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

  1. 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 };
    });
  2. Export it from functions/src/custom/index.ts. The functions use ES modules, so imports end in .js:

    export * from './lessons.js';
  3. If it is a callable, add its name to functions/src/custom/public-callables.txt, one per line:

    completeLesson
  4. Deploy just that function (Deploy only what changed). The first time, add --probe to 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.

WhereThe name you use
Deployed functionarccms-custom-completeLesson
Browser, with arcCallablecustom-completeLesson
Deploy targetfunctions:arccms:arccms.custom.completeLesson
public-callables.txtcompleteLesson

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:

ClaimHolds
arccms_uidThe id of the person's user record, users/{userDocId}. It is not the Auth uid
arccms_roleThe 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:

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.

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.

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.