Architecture

Comment fonctionne le système de messagerie de ThisIsHumanMade

Firebase Realtime DB, app native macOS et iOS en SwiftUI, push notifications via FCM et Cloud Functions. Autopsie technique d'un chat "humain-first" construit sans backend custom.

Cet article documente un projet entièrement construit avec l'IA. Le code - Swift, JavaScript, Cloud Functions - a été génère par Claude Code. Mais l'IA ne décide de rien toute seule. C'est moi qui choisis l'architecture, qui valide chaque décision technique, qui debugge quand ça déraille. L'IA est l'instrument, le développeur reste le chef d'orchestre. Et c'est justement parce que j'ai 20 ans de dev derrière moi que je sais quoi lui demander, quand la corriger, et où elle se trompe.

Sur thisishumanmade.com, quand un visiteur écrit un message dans le chat, je reçois une notification push sur mon iPhone - même si l'app est tuée. Je réponds, et le visiteur voit ma réponse en temps réel. Pas de Crisp, pas d'Intercom, pas de SaaS. Juste Firebase, du Swift et une Cloud Function de 50 lignes.

Voici comment tout ça s'articule.

App HumanMade sur macOS - menu bar avec sidebar conversations et chat
L'app macOS : status, liste des conversations, et chat en temps réel.

Vue d'ensemble

Le système tient en 4 composants :

  1. Le site web - un chat widget en vanilla JS qui écrit dans Firebase Realtime DB
  2. Firebase Realtime DB - la base de données temps réel qui synchronise tout
  3. L'app native - SwiftUI, disponible sur macOS (menu bar) et iOS (NavigationStack)
  4. Une Cloud Function - déclenchée à chaque nouveau message, envoie un push via FCM
Visiteur Admin (Emmanuel) thisishumanmade.com App macOS App iOS | | | | write message | observe() | observe() v v v +------------------------------------------+ | Firebase Realtime Database | | /conversations/{id}/messages/{msgId} | +------------------------------------------+ | | onValueCreated (trigger) v +---------------------+ | Cloud Function | | notifyNewMessage | +---------------------+ | | admin.messaging().send() v +---------------------+ | FCM --> APNs | +---------------------+ | v Push notification sur iPhone

Le site web : chat widget en vanilla JS

Le chat sur le site est un widget JavaScript sans framework. Quand un visiteur ouvre la page, un conversationId est génère et stocke dans sessionStorage. Chaque message est écrit dans Firebase Realtime DB via le SDK JavaScript :

// Ecriture d'un message visiteur
const msgRef = push(ref(db, `conversations/${convId}/messages`));
set(msgRef, {
  text: message,
  sender: visitorName,
  timestamp: Date.now()
});

Le choix de sessionStorage (et non localStorage) est délibéré : un refresh garde la conversation, mais un nouvel onglet en crée une nouvelle. Le visiteur peut ainsi avoir des conversations indépendantes.

Le SDK Firebase écoute aussi les réponses en temps réel avec onChildAdded, ce qui affiche les réponses instantanément sans polling.

Firebase Realtime DB : la colonne vertébrale

Toute la donnée transite par Firebase Realtime Database. Pas de backend custom, pas d'API REST à maintenir. La structure est simple :

{
  "status": {
    "current": "available",
    "updatedAt": 1707840000000
  },
  "statusConfig": {
    "available": { "label": "Disponible", "emoji": "🟢", "chatMessage": "..." },
    "busy":      { "label": "Occupe",     "emoji": "đźź ", "chatMessage": "..." },
    "offline":   { "label": "Absent",     "emoji": "⚪",  "chatMessage": "..." }
  },
  "conversations": {
    "-ABC123": {
      "visitorName": "Marie",
      "startedAt": 1707840000000,
      "lastMessageAt": 1707840060000,
      "status": "active",
      "messages": {
        "-MSG001": { "text": "Bonjour !", "sender": "Marie", "timestamp": ... },
        "-MSG002": { "text": "Salut Marie", "sender": "emmanuel", "timestamp": ... }
      }
    }
  },
  "admin": {
    "fcmToken": "dhOeso..."
  }
}

Pourquoi Realtime DB et pas Firestore ?

Pour ce cas d'usage (1 admin, quelques visiteurs simultanés, données simples), Realtime Database est plus adapte que Firestore :

  • Latence - RTDB est optimisĂ© pour les petites donnĂ©es temps rĂ©el (~10ms vs ~50ms pour Firestore)
  • Pricing - facture au bandwidth + storage, pas au nombre de reads (un observer RTDB compte comme 1 read, pas N)
  • SSE natif - le protocole Server-Sent Events permet de brancher n'importe quel client REST sans SDK

Firestore brille pour les queries complexes, les indexes composes et le scaling horizontal. Ici, on n'a besoin de rien de tout ça.

Règles de sécurité

{
  "rules": {
    "conversations": { ".read": true, ".write": true },
    "admin": {
      "fcmToken": { ".read": false, ".write": true }
    }
  }
}

Le FCM token est en write-only. L'app iOS peut l'écrire, mais aucun client ne peut le lire. Seule la Cloud Function y accède - via l'Admin SDK qui bypass les règles de sécurité. Cela empêche un visiteur de récupérer le token pour envoyer des pushes non autorises.

L'app macOS : menu bar avec SwiftUI

L'app macOS vit dans la menu bar. Pas de dock icon, pas de fenêtre permanente - juste une icône discrète avec un badge de messages non lus.

Elle utilise le Firebase iOS SDK (qui fonctionne aussi sur macOS via Swift Package Manager) et maintient un observer .observe(.value) sur le nœud /conversations. Chaque changement dans la DB déclenche un callback instantané.

// Observer temps reel sur toutes les conversations
conversationsHandle = db.child("conversations")
    .observe(.value) { snapshot in
        // Parse + diff + notification locale si nouveau message
    }

Quand un nouveau message visiteur arrive, l'app :

  1. Détecte les messages inconnus par diff d'IDs (Set existant vs nouveau)
  2. Incremente le compteur unreadCount
  3. Envoie une notification locale via UNUserNotificationCenter

Sur macOS, pas besoin de push FCM - l'app est toujours vivante dans la menu bar. Les observers Firebase ne meurent jamais.

L'app iOS : même code, problèmes différents

L'app iOS partage 90% du code grâce à #if os(iOS) / #if os(macOS). Les vues sont dans des extensions conditionnelles :

struct MenuBarView: View {
    var body: some View {
        #if os(iOS)
        iOSBody      // NavigationStack
        #else
        macOSBody    // HStack avec sidebar
        #endif
    }
}

Le problème du background iOS

Sur macOS, les observers Firebase tournent indéfiniment. Sur iOS, c'est une autre histoire :

  • iOS suspend l'app ~30 secondes après le passage en background
  • Les connexions rĂ©seau sont coupĂ©es - les observers Firebase meurent
  • Les messages s'accumulent et arrivent tous d'un coup Ă  la rĂ©ouverture

C'est pour ça que les notifications locales ne suffisent pas sur iOS. Il faut un système server-side qui pousse les notifications même quand l'app est morte.

FCM + Cloud Function : les push qui marchent vraiment

La solution est en deux pièces :

1. L'app iOS s'enregistre auprès de FCM

Au lancement, l'app demande un token APNs Ă  Apple, le passe Ă  Firebase Cloud Messaging, et stocke le FCM token dans la DB :

// iOSAppDelegate.swift
class iOSAppDelegate: NSObject, UIApplicationDelegate, MessagingDelegate {
    func application(_ application: UIApplication,
                     didFinishLaunchingWithOptions ...) -> Bool {
        FirebaseApp.configure()
        Messaging.messaging().delegate = self
        application.registerForRemoteNotifications()
        return true
    }

    // Apple donne un token APNs binaire -> on le passe a FCM
    func application(_ application: UIApplication,
                     didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
        Messaging.messaging().apnsToken = deviceToken
    }

    // FCM echange le token APNs contre son propre token string
    func messaging(_ messaging: Messaging,
                   didReceiveRegistrationToken fcmToken: String?) {
        guard let token = fcmToken else { return }
        // Stocke dans Firebase RTDB a /admin/fcmToken
        FirebaseService.shared.storeFCMToken(token)
    }
}

Le point clé : il y a deux tokens différents. Le token APNs est un blob binaire d'Apple. FCM l'échange contre son propre token string. C'est ce token FCM que la Cloud Function utilise.

2. La Cloud Function envoie le push

Une Cloud Function onValueCreated se déclenche à chaque nouveau message dans la DB :

// functions/index.js
const { onValueCreated } = require("firebase-functions/v2/database");

exports.notifyNewMessage = onValueCreated(
  { ref: "/conversations/{convId}/messages/{msgId}", region: "us-central1" },
  async (event) => {
    const message = event.data.val();

    // Ignore nos propres reponses
    if (message.sender === "emmanuel") return null;

    const visitorName = await getVisitorName(event.params.convId);
    const fcmToken = await getFCMToken();
    if (!fcmToken) return null;

    await getMessaging().send({
      token: fcmToken,
      notification: { title: visitorName, body: message.text },
      data: { conversationId: event.params.convId },
      apns: { payload: { aps: { sound: "default", badge: 1 } } }
    });
  }
);

Points importants :

  • onValueCreated ne se dĂ©clenche qu'Ă  la crĂ©ation d'un nĹ“ud, pas Ă  la modification - pas de double notification si un message est Ă©ditĂ©
  • Le filtre sender === "emmanuel" Ă©vite de se notifier soi-mĂŞme
  • La rĂ©gion us-central1 est obligatoire - les triggers RTDB doivent ĂŞtre dans la mĂŞme rĂ©gion que la base de donnĂ©es
  • Si le token est invalide (app desintallee), la fonction le nettoie automatiquement de la DB

Le flux complet d'une notification

1. Visiteur tapé "Bonjour" sur le site 2. Firebase JS SDK écrit dans /conversations/{id}/messages/{newId} 3. Cloud Function notifyNewMessage se déclenche (~200ms) 4. La fonction lit le fcmToken et appelle admin.messaging().send() 5. FCM transmet a APNs (serveurs Apple) 6. APNs pousse la notification sur l'iPhone 7. L'utilisateur tapé la notification -> l'app s'ouvre sur la conversation

Éviter les doublons de notifications

Sans précaution, sur iOS on recevrait deux notifications quand l'app est en foreground :

  • Une notification locale (depuis l'observer Firebase qui tourne encore)
  • Une notification push (depuis la Cloud Function via FCM)

La solution est simple : sur iOS, la méthode sendNotification() fait un return immédiat. Les notifications locales sont désactivées - FCM s'en charge :

private func sendNotification(title: String, body: String, conversationId: String) {
    #if os(iOS)
    return  // FCM gere les push sur iOS
    #else
    // macOS : notification locale classique
    let content = UNMutableNotificationContent()
    content.title = title
    content.body = body
    content.sound = .default
    UNUserNotificationCenter.current().add(...)
    #endif
}

Gestion du badge et navigation

Badge sur l'icĂ´ne

La Cloud Function envoie badge: 1 dans le payload APNs. Quand l'utilisateur ouvre l'app, le badge se remet à zéro via scenePhase :

// HumanMadeApp.swift
WindowGroup { ... }
.onChange(of: scenePhase) { phase in
    if phase == .active {
        UNUserNotificationCenter.current().setBadgeCount(0)
    }
}

Tap sur la notification -> bonne conversation

Le payload FCM inclut data.conversationId. Quand l'utilisateur tapé la notification, le NotificationDelegate récupère l'ID et le stocke dans une published property. Le NavigationStack écoute ce changement et push la bonne vue :

// NotificationDelegate
func userNotificationCenter(_ center: ..., didReceive response: ...) {
    let convId = response.notification.request.content.userInfo["conversationId"]
    FirebaseService.shared.pendingConversationId = convId
}

// MenuBarView (iOS)
.onChange(of: firebase.pendingConversationId) { convId in
    guard let convId else { return }
    firebase.pendingConversationId = nil
    navigationPath = NavigationPath()  // Reset
    navigationPath.append(convId)      // Push la conversation
}

Indicateur de frappe ("est en train d'écrire...")

Quand je tapé une réponse sur l'app iOS, le visiteur sur le site voit apparaitre les trois petits points animes - exactement comme sur iMessage ou WhatsApp. L'implémentation repose sur un nœud éphémère dans Firebase.

Le nœud Firebase

Chaque conversation peut contenir un nœud typing. Ce nœud n'existe que quand Emmanuel est en train de taper - il est supprime des qu'il arrête ou envoie le message :

"conversations": {
  "-ABC123": {
    "messages": { ... },
    "typing": true    // existe uniquement pendant la frappe
  }
}

L'utilisation de removeValue() plutôt que setValue(false) est délibérée : ça évite de stocker des données inutiles. Le nœud n'existe que quand il à une raison d'exister.

Côté iOS : détection de la frappe

L'app observe les changements dans le champ texte via .onChange(of: replyText). À chaque frappe, on écrit typing: true dans Firebase et on relance un timer de 2 secondes :

// ChatView.swift - detection de frappe
TextField("Repondre...", text: $replyText)
    .onChange(of: replyText) { newValue in
        if !newValue.trimmingCharacters(in: .whitespaces).isEmpty {
            firebase.setTyping(conversationId: conversationId, isTyping: true)
        }
    }

// A l'envoi : reset immediat
func sendReply() {
    firebase.setTyping(conversationId: conversationId, isTyping: false)
    firebase.sendReply(conversationId: conversationId, text: text)
}

Le debounce côté service

Le FirebaseService géré un timer interne. Chaque appel à setTyping(isTyping: true) relance le timer de 2 secondes. Si l'utilisateur arrête de taper, le timer expire et supprime le nœud automatiquement :

// FirebaseService.swift
func setTyping(conversationId: String, isTyping: Bool) {
    let typingRef = db.child("conversations/\(conversationId)/typing")

    if isTyping {
        typingRef.setValue(true)
        typingRef.onDisconnectRemoveValue()  // securite : cleanup si crash

        typingTimer?.cancel()
        let timer = DispatchWorkItem {
            typingRef.removeValue()
        }
        typingTimer = timer
        DispatchQueue.main.asyncAfter(deadline: .now() + 2.0, execute: timer)
    } else {
        typingTimer?.cancel()
        typingRef.removeValue()
    }
}

Le onDisconnectRemoveValue() est une sécurité : si l'app crash ou perd sa connexion, Firebase supprime automatiquement le nœud typing côté serveur. Sans ça, le visiteur verrait un fantôme "en train d'écrire..." indéfiniment.

Côté site web : affichage des points

Le site écoute le nœud typing avec un listener onValue. Quand la valeur passe à true, les trois points animes apparaissent. Quand elle disparait, ils se cachent :

// Listener sur le noeud typing
fb.onValue(fb.ref(fb.db, 'conversations/' + convId + '/typing'), function(snapshot) {
  if (snapshot.val() === true) {
    if (!document.getElementById('typingIndicator')) showTyping();
  } else {
    hideTyping();
  }
});

Les fonctions showTyping() et hideTyping() existaient déjà pour la séquence d'intro - on les réutilise telles quelles pour l'indicateur temps réel.

Le flux complet

1. Emmanuel tapé dans le TextField sur iOS 2. .onChange(of: replyText) détecte la frappe 3. setTyping(isTyping: true) écrit /conversations/{id}/typing: true 4. Le site web reçoit le changement via onValue (~100ms) 5. showTyping() affiche les trois points animes 6. Emmanuel envoie le message ou arrête de taper (2s) 7. setTyping(isTyping: false) supprime le nœud 8. hideTyping() cache les points

Structure du projet

thisishumanmade.com/ | |-- website/ | |-- index.html Chat widget (vanilla JS + Firebase SDK) | |-- firebase.json Config Firebase (database + functions) |-- database.rules.json Règles de sécurité RTDB | |-- functions/ Cloud Function (Node.js) | |-- index.js notifyNewMessage | |-- package.json | |-- mac-app-xcode/ |-- HumanMade/ |-- HumanMadeApp.swift @main + iOSAppDelegate (FCM) |-- HumanMade.entitlements aps-environment |-- Services/ | |-- FirebaseService.swift Observers + storeFCMToken() |-- Views/ | |-- MenuBarView.swift UI partagée iOS/macOS | |-- ChatView.swift Conversation |-- Models/ |-- Conversation.swift Modèles de données

Stack technique

  • Site web - HTML/CSS/JS vanilla, Firebase JS SDK v11 (modules ES)
  • Base de donnĂ©es - Firebase Realtime Database (plan Blaze, coĂ»t rĂ©el : 0 euros/mois)
  • App native - SwiftUI (multi-plateforme macOS + iOS), Firebase iOS SDK 12.9
  • Push notifications - Firebase Cloud Messaging + APNs
  • Cloud Function - Node.js 20, firebase-functions v5, trigger onValueCreated
  • Auth - aucune (single-admin, règles DB ouvertes, token FCM protège en write-only)

Ce que ça coute

Avec le plan Blaze (pay-as-you-go) :

  • Realtime Database - free tier : 1 Go stockage, 10 Go/mois transfer. Largement suffisant.
  • Cloud Functions - free tier : 2 millions invocations/mois. Pour quelques messages par jour, c'est ~0.
  • FCM - gratuit, illimitĂ©.

Coût total : 0 euros/mois. Le seul investissement est le compte Apple Developer a 99 euros/an (nécessaire pour les push APNs).