Revoir le stream !

Pour un Youtubeur à 500k abonnés, je réalise un quiz, avec Angular, où les participants répondent en temps réel. C'est l'occasion, en direct, de réaliser et de voir ensemble des développements d'Angular: Streaming Resources, gestion du temps réel, utilisation de l'IA pour générer l'UI, etc.

Skip to content

Vous souhaitez recevoir de l'aide sur ce sujet ? rejoignez la communauté Angular.fr sur Discord.

PartyKit avec Angular : Créez des applications temps réel collaboratives

Imaginez que vous développez une application de dessin collaboratif où plusieurs utilisateurs peuvent dessiner en même temps sur la même toile. Chaque trait dessiné par un utilisateur doit apparaître instantanément sur l'écran des autres utilisateurs. C'est exactement ce type d'interaction en temps réel que PartyKit permet de réaliser facilement !

Qu'est-ce que PartyKit ?

PartyKit est une plateforme qui simplifie considérablement le développement d'applications collaboratives en temps réel. Au lieu de gérer vous-même toute l'infrastructure complexe nécessaire pour les communications en temps réel, PartyKit s'occupe de tout :

Avantages clés

  • Simplicité d'utilisation : Ajoutez des fonctionnalités temps réel avec quelques lignes de code
  • Scalabilité automatique : Supporte des milliers d'utilisateurs grâce au réseau edge computing
  • JavaScript natif : Écrivez votre code serveur en JavaScript (ou optionnellement en WebAssembly)
  • Tout inclus : Outils de développement local, déploiements de preview, gestion des secrets...

Cas d'utilisation

PartyKit est particulièrement adapté pour :

  • Les applications collaboratives (tableaux blancs, éditeurs de code, éditeurs de texte)
  • Les jeux multijoueurs
  • Les chats en temps réel
  • Les curseurs collaboratifs
  • Les bots IA persistants
  • Les compteurs de réactions en direct
  • Les applications de sondage en direct

Comment ça fonctionne ?

PartyKit utilise une architecture client-serveur où :

  1. Chaque "room" est une instance isolée qui gère son propre état
  2. Les clients se connectent via WebSocket pour une communication bidirectionnelle
  3. Le serveur peut persister les données et gérer la logique métier
  4. Tout est automatiquement distribué sur le réseau edge pour des performances optimales

Qui utilise PartyKit ?

De nombreuses applications populaires utilisent déjà PartyKit :

  • BlockNote : Un éditeur de texte riche open source
  • tldraw : Un tableau blanc collaboratif
  • Stately : Des outils visuels pour la logique applicative
  • SiteGPT : Pour alimenter des agents IA

Installation et configuration

Commençons par installer les dépendances nécessaires :

Installation

bash
npm install partysocket@latest

1. Création du service PartyKit

Le service PartyKit est responsable de :

  • La gestion de la connexion WebSocket
  • La synchronisation de l'état entre les clients
  • L'envoi et la réception des messages
  • La gestion des événements en temps réel

Structure du service

1. Gestion de l'état avec les signaux

Les signaux permettent une mise à jour réactive de l'interface utilisateur. L'état est en lecture seule pour les composants, seul le service peut le modifier.

2. Configuration de la connexion

ts
this.socket = new PartySocket({
  host: 'votre-projet.username.partykit.dev', // ou localhost:1999 en développement
  room: 'ma-salle-collaborative',
  id: `user-${Math.random().toString(36).slice(2, 9)}`
});
  • host : URL du serveur PartyKit
  • room : Identifiant de la salle de collaboration (les clients dans la même room sont connectés)
  • id : Identifiant unique pour chaque connexion client

Les rooms

Les rooms sont des espaces isolés où :

  • Chaque room a son propre état et ses propres participants
  • Les messages sont diffusés uniquement aux clients de la même room
  • Parfait pour séparer différentes sessions collaboratives

3. Gestion des événements

ts
this.socket.addEventListener('open', () => {
  
});

this.socket.addEventListener('close', () => {
 
});

this.socket.addEventListener('message', (event) => {
});

Les événements WebSocket permettent de :

  • Détecter l'état de la connexion (open/close)
  • Recevoir et traiter les messages (message)
  • Mettre à jour l'état local en fonction des messages reçus

4. Communication client-serveur

ts
sendMessage<T>(type: string, payload: T): void {
  this.socket.send(JSON.stringify({
    type,
    payload
  }));
}

La communication est structurée avec :

  • Un type pour identifier l'action
  • Un payload contenant les données
  • Une sérialisation JSON pour la transmission

Points essentiels

  1. Connexions :

    • Une connexion unique par instance client
    • Gestion automatique des reconnexions par PartyKit
  2. Messages :

    • Format JSON pour la transmission
    • Structure type/payload pour le routage des messages
    • Validation des données côté serveur
  3. État :

    • Centralisé dans le service
    • Mis à jour via les signaux
    • Propagé aux composants en lecture seule

Créons maintenant le service :

ts
import { Injectable, signal } from '@angular/core';
import PartySocket from 'partysocket';
import { User } from './user.interface';

@Injectable({
  providedIn: 'root'
})
export class PartyKitService {
  // Signal pour gérer l'état de la connexion
  private connectionStatusSignal = signal<'connected' | 'disconnected'>('disconnected');
  public connectionStatus = this.connectionStatusSignal.asReadonly();

  // Signal pour la liste des utilisateurs connectés
  private usersSignal = signal<User[]>([]);
  public users = this.usersSignal.asReadonly();

  private socket: PartySocket;

  constructor() {
    /**
     * Initialisation de la connexion PartyKit
     * - host: l'URL de votre serveur PartyKit
     * - room: l'identifiant unique de la "salle" de collaboration
     * - id: identifiant unique pour ce client (optionnel)
     */
    this.socket = new PartySocket({
      host: 'votre-projet.username.partykit.dev', // ou localhost:1999 en développement
      room: 'ma-salle-collaborative'
    });

    // Gestion des événements de connexion
    this.socket.addEventListener('open', () => {
      this.connectionStatusSignal.set('connected');
    });

    this.socket.addEventListener('close', () => {
      this.connectionStatusSignal.set('disconnected');
    });

    /**
     * Écoute des messages entrants
     * Chaque message est typé et contient des données spécifiques
     */
    this.socket.addEventListener('message', (event) => {
      const data = JSON.parse(event.data);
      
      switch (data.type) {
        case 'users-update':
          this.usersSignal.set(data.users);
          break;
        // Ajoutez d'autres cas selon vos besoins
      }
    });
  }

  /**
   * Envoie un message au serveur PartyKit
   * @param type - Le type de message
   * @param payload - Les données à envoyer
   */
  sendMessage<T>(type: string, payload: T): void {
    this.socket.send(JSON.stringify({
      type,
      payload
    }));
  }

  /**
   * Met à jour les propriétés de connexion
   * Utile pour changer de salle ou de serveur
   */
  updateConnection(options: { room?: string; host?: string }) {
    this.socket.updateProperties(options);
    this.socket.reconnect();
  }
}

2. Utilisation dans un composant

Voici comment utiliser le service PartyKit dans un composant Angular :

ts
import { Component, inject } from '@angular/core';
import { PartyKitService } from '../../core/services/partykit.service';

@Component({
  selector: 'app-collaborative',
  standalone: true,
  template: `
    @if (partyKitService.connectionStatus() === 'connected') {
      <div class="status-connected">Connecté à la salle collaborative</div>
    } @else {
      <div class="status-disconnected">Déconnecté</div>
    }

    <div class="users-list">
      @for (user of partyKitService.users(); track user.id) {
        <div class="user-card">
          <span class="user-name">{{ user.name }}</span>
          <span class="user-status">En ligne</span>
        </div>
      }
    </div>

    <button (click)="sendUserAction()">
      Envoyer une action
    </button>
  `
})
export class CollaborativeComponent {
  partyKitService = inject(PartyKitService);

  /**
   * Exemple d'envoi d'une action utilisateur
   * Peut être utilisé pour envoyer des mises à jour en temps réel
   */
  sendUserAction() {
    this.partyKitService.sendMessage('user-action', {
      timestamp: Date.now(),
      action: 'click'
    });
  }
}

Bonnes pratiques

  1. Gestion des reconnexions : PartyKit gère automatiquement les reconnexions en cas de perte de connexion, mais vous pouvez personnaliser ce comportement :
ts
const socket = new PartySocket({
  host: 'votre-projet.partykit.dev',
  room: 'ma-salle',
  query: async () => ({
    // Ajoutez des paramètres dynamiques pour l'authentification
    token: await getAuthToken()
  })
});
  1. Typage des messages : Créez des interfaces pour vos messages pour une meilleure maintenabilité :
ts
interface Message<T> {
  type: string;
  payload: T;
}

interface UserAction {
  timestamp: number;
  action: string;
}

Sécurité

  1. Ne stockez jamais d'informations sensibles dans les messages PartyKit
  2. Implémentez une authentification appropriée
  3. Validez toujours les données reçues avant de les utiliser

Cas d'utilisation avancés

1. Curseurs collaboratifs

Pour implémenter des curseurs collaboratifs où chaque utilisateur peut voir la position des autres en temps réel :

ts
import { Component, inject, signal } from '@angular/core';

interface CursorPosition {
  x: number;
  y: number;
  userId: string;
  username: string;
}

@Component({
  template: `
    <div 
      class="collaborative-area"
      (mousemove)="onMouseMove($event)"
    >
      @for (cursor of cursors(); track cursor.userId) {
        <div 
          class="cursor" 
          [style.left.px]="cursor.x"
          [style.top.px]="cursor.y"
        >
          {{ cursor.username }}
        </div>
      }
    </div>
  `
})
export class CollaborativeCursorsComponent {
  private partyKitService = inject(PartyKitService);
  cursors = signal<CursorPosition[]>([]);

  onMouseMove(event: MouseEvent) {
    const position: CursorPosition = {
      x: event.clientX,
      y: event.clientY,
      userId: 'mon-id',
      username: 'Mon Nom'
    };
    
    this.partyKitService.sendMessage('cursor-move', position);
  }
}

2. Chat en temps réel

Pour implémenter un chat en temps réel entre les utilisateurs :

ts
import { ReactiveFormsModule } from '@angular/forms';
import { Component, inject, signal } from '@angular/core';
import { FormControl } from '@angular/forms';

interface ChatMessage {
  id: string;
  userId: string;
  username: string;
  content: string;
  timestamp: number;
}

@Component({
  imports: [ReactiveFormsModule],
  template: `
    <div class="chat-container">
      <div class="messages">
        @for (message of messages(); track message.id) {
          <div class="message" [class.own-message]="message.userId === currentUserId">
            <strong>{{ message.username }}</strong>
            <p>{{ message.content }}</p>
            <small>{{ message.timestamp | date:'short' }}</small>
          </div>
        }
      </div>
      
      <form (submit)="sendMessage()">
        <input 
          type="text" 
          [formControl]="messageInput"
          placeholder="Votre message..."
        >
        <button type="submit">Envoyer</button>
      </form>
    </div>
  `
})
export class ChatComponent {
  private partyKitService = inject(PartyKitService);
  messages = signal<ChatMessage[]>([]);
  messageInput = new FormControl('');
  
  sendMessage(event: Event) {
    if (!this.messageInput.value) return;
    
    const message: ChatMessage = {
      id: crypto.randomUUID(),
      userId: 'mon-id',
      username: 'Mon Nom',
      content: this.messageInput.value,
      timestamp: Date.now()
    };
    
    this.partyKitService.sendMessage('chat-message', message);
    this.messageInput.reset();
  }
}

crypto.randomUUID()

ts
// Préférez crypto.randomUUID()
const id = crypto.randomUUID();

// Plutôt que Math.random()
const id = Math.random().toString(36).slice(2, 9);
  • crypto.randomUUID() génère un UUID v4 cryptographiquement sûr
  • Garantit une meilleure unicité que Math.random()
  • Évite les collisions d'ID, cruciales dans un contexte temps réel
  • Supporté par tous les navigateurs modernes

Code du serveur PartyKit [Cadeau]

ts
import type * as Party from "partykit/server";

interface Message<T> {
  type: string;
  payload: T;
}

interface User {
  id: string;
  name: string;
  email: string;
}

interface CursorPosition {
  x: number;
  y: number;
  userId: string;
  username: string;
}

interface ChatMessage {
  id: string;
  userId: string;
  username: string;
  content: string;
  timestamp: number;
}

export default class Server implements Party.Server {
  constructor(readonly room: Party.Room) {}

  // Liste des utilisateurs connectés
  private users = new Map<string, User>();
  // Positions des curseurs
  private cursors = new Map<string, CursorPosition>();
  // Messages du chat
  private messages: ChatMessage[] = [];

  // Initialisation du serveur
  async onStart() {
    // Charger les messages depuis le stockage
    const storedMessages = await this.room.storage.get<ChatMessage[]>("messages");
    if (storedMessages) {
      this.messages = storedMessages;
    }
  }

  // Nouvelle connexion
  async onConnect(conn: Party.Connection, ctx: Party.ConnectionContext) {
    // Envoyer l'état actuel au nouveau client
    conn.send(JSON.stringify({
      type: "users-update",
      payload: Array.from(this.users.values())
    }));

    conn.send(JSON.stringify({
      type: "cursors-update",
      payload: Array.from(this.cursors.values())
    }));

    conn.send(JSON.stringify({
      type: "chat-history",
      payload: this.messages
    }));
  }

  // Réception d'un message
  async onMessage(message: string, sender: Party.Connection) {
    const data = JSON.parse(message) as Message<any>;

    switch (data.type) {
      case "user-update":
        const user = data.payload as User;
        this.users.set(sender.id, user);
        this.room.broadcast(JSON.stringify({
          type: "users-update",
          payload: Array.from(this.users.values())
        }), [sender.id]);
        break;

      case "cursor-move":
        const position = data.payload as CursorPosition;
        this.cursors.set(sender.id, position);
        this.room.broadcast(JSON.stringify({
          type: "cursors-update",
          payload: Array.from(this.cursors.values())
        }), [sender.id]);
        break;

      case "chat-message":
        const chatMessage = data.payload as ChatMessage;
        this.messages.push(chatMessage);
        // Garder seulement les 100 derniers messages
        if (this.messages.length > 100) {
          this.messages = this.messages.slice(-100);
        }
        // Sauvegarder dans le stockage
        await this.room.storage.put("messages", this.messages);
        // Diffuser à tous les clients
        this.room.broadcast(JSON.stringify({
          type: "chat-message",
          payload: chatMessage
        }));
        break;
    }
  }

  // Déconnexion
  async onClose(conn: Party.Connection) {
    this.users.delete(conn.id);
    this.cursors.delete(conn.id);
    
    this.room.broadcast(JSON.stringify({
      type: "users-update",
      payload: Array.from(this.users.values())
    }));

    this.room.broadcast(JSON.stringify({
      type: "cursors-update",
      payload: Array.from(this.cursors.values())
    }));
  }
}
json
{
  "name": "mon-projet-collaboratif",
  "main": "server.ts",
  "parties": {
    "main": "server.ts"
  }
}

Voir https://docs.partykit.io/quickstart pour plus d'informations sur la configuration du serveur PartyKit et le déploiement.