Synchronisation distante : vue d'ensemble

DayFlow fournit des paquets de synchronisation sans interface pour relier votre calendrier à des serveurs distants :

  • @dayflow/caldav — moteur de synchronisation CalDAV. Compatible avec iCloud Calendar, Nextcloud, Radicale, Fastmail et tout serveur CalDAV conforme à la RFC 4791.
  • @dayflow/google-sync — moteur de synchronisation via l'API REST de Google Calendar.
  • @dayflow/outlook-sync — moteur de synchronisation avec Microsoft Outlook Calendar via l'API Microsoft Graph.
  • @dayflow/sync-core — utilitaires de réconciliation indépendants du fournisseur, pour vos propres fournisseurs et la synchronisation pilotée par le backend.

La plupart des applications devraient commencer par un paquet fournisseur (@dayflow/caldav, @dayflow/google-sync ou @dayflow/outlook-sync). Ne passez à @dayflow/sync-core que si vous construisez votre propre paquet fournisseur, si vous réconciliez des données distantes dans un traitement backend, ou si vous avez besoin de changements d'audit indépendants du fournisseur.

Principes de conception

Aucun identifiant dans le navigateur

Les paquets fournisseurs n'acceptent ni mots de passe, ni tokens, ni secrets OAuth en direct. Toute l'authentification réside dans votre backend. Le navigateur ne parle qu'à votre propre proxy.

Browser (DayFlow)  →  Your backend proxy  →  CalDAV / Google / Outlook API

DayFlow ne voit donc jamais les identifiants de vos utilisateurs, et vous gardez la maîtrise complète de votre stratégie d'authentification.

Le transport d'abord, par adaptateur

Chaque paquet fournisseur reçoit une fonction fetch que vous fournissez pour le transport. Vous injectez un fetch authentifié et le moteur de synchronisation exécute les requêtes à travers lui, sans savoir quels identifiants y sont attachés.

const adapter = createCalDAVAdapter({
  calendarHomeUrl: 'https://caldav.example.com/calendars/alice/',
  fetch: (url, init) =>
    fetch('/api/caldav-proxy', {
      method: 'POST',
      body: JSON.stringify({ url, init }),
    }),
});

Observable, non prescriptif

Les paquets fournisseurs émettent des callbacks structurés plutôt que de gérer la persistance :

  • onSyncComplete(delta) — appelé après chaque synchronisation avec le décompte des changements, pour que votre store réagisse sans recalculer les écarts.
  • onWriteComplete(operation, event) — appelé après chaque écriture distante réussie, pour que vous puissiez mettre à jour votre base locale avec les identifiants attribués par le serveur.
  • getInitialSnapshot() — appelé une fois au démarrage pour amorcer DayFlow depuis votre cache local avant toute requête, afin que le calendrier s'affiche instantanément.

Le stockage vous appartient. Les paquets vous disent simplement ce qui est arrivé.

Sans interface imposée

Les paquets fournisseurs n'embarquent ni formulaire de connexion, ni popup OAuth, ni stockage d'identifiants, ni service de synchronisation hébergé. Ils exposent l'infrastructure de synchronisation ; l'expérience d'authentification reste à votre charge.

Lecture seule ou synchronisation bidirectionnelle

Les paquets fournisseurs prennent en charge :

  • le mode lecture seule (writable: false) — synchronise les événements du serveur vers DayFlow, sans rien lui renvoyer ;
  • le mode bidirectionnel (writable: true, par défaut) — répercute sur le serveur les créations, mises à jour et suppressions locales.

Exemple CalDAV complet

Cet exemple montre la forme complète d'une intégration CalDAV de qualité production :

  1. Un proxy backend conserve les identifiants et relaie les méthodes CalDAV.
  2. Le frontend crée une fonction fetch qui passe par le proxy.
  3. L'adaptateur CalDAV découvre ou reçoit l'URL du calendar home de l'utilisateur.
  4. DayFlow est amorcé depuis un cache local, puis synchronisé en arrière-plan.
  5. Les modifications locales ne sont répercutées que si le calendrier distant l'autorise.

1. Installation

npm install @dayflow/caldav
pnpm add @dayflow/caldav
yarn add @dayflow/caldav
bun add @dayflow/caldav

2. Configurer le backend

Utilisez des mots de passe d'application propres au fournisseur lorsque c'est possible. N'envoyez jamais ces valeurs au navigateur.

# iCloud
CALDAV_BASE_URL=https://caldav.icloud.com/
CALDAV_USERNAME=alice@icloud.com
CALDAV_PASSWORD=xxxx-xxxx-xxxx-xxxx

# Nextcloud
# CALDAV_BASE_URL=https://nextcloud.example.com/remote.php/dav/
# CALDAV_USERNAME=alice
# CALDAV_PASSWORD=nextcloud-app-password

# Radicale
# CALDAV_BASE_URL=https://radicale.example.com/

# Fastmail
# CALDAV_BASE_URL=https://caldav.fastmail.com/dav/

Notes par fournisseur :

FournisseurConfiguration recommandée
iCloudUtilisez un mot de passe d'application Apple et createCalDAVAdapterFromServer(ICLOUD_CALDAV_SERVER, ...) pour la découverte dynamique du home.
NextcloudUtilisez nextcloudConfig(host, username) si vous connaissez déjà le nom d'utilisateur ; générez un mot de passe d'application dans les paramètres personnels.
RadicaleCertains serveurs ne renvoient pas les permissions. DayFlow considère les calendriers ambigus comme étant en lecture seule, sauf indication contraire de l'adaptateur.
FastmailUtilisez fastmailConfig('https://caldav.fastmail.com/dav', email) avec un proxy backend.

3. Ajouter une route de proxy

Cette route App Router Next.js accepte des requêtes CalDAV encapsulées en JSON depuis le navigateur, injecte l'authentification Basic, restreint l'URL amont à l'URL de base CalDAV configurée et renvoie la réponse XML/ICS du serveur.

app/api/caldav/route.ts
export const runtime = 'nodejs';

const ALLOWED_METHODS = new Set(['PROPFIND', 'REPORT', 'PUT', 'DELETE']);

function requireEnv(name: string): string {
  const value = process.env[name];
  if (!value) throw new Error(`${name} is required`);
  return value;
}

function corsHeaders() {
  return {
    'Access-Control-Allow-Origin':
      process.env.APP_ORIGIN ?? 'http://localhost:3000',
    'Access-Control-Allow-Methods': 'POST, OPTIONS',
    'Access-Control-Allow-Headers': 'Content-Type',
    'Access-Control-Expose-Headers': 'ETag, DAV',
  };
}

export function OPTIONS() {
  return new Response(null, { status: 204, headers: corsHeaders() });
}

export async function POST(request: Request) {
  const baseUrl = requireEnv('CALDAV_BASE_URL');
  const username = requireEnv('CALDAV_USERNAME');
  const password = requireEnv('CALDAV_PASSWORD');
  const { url, init = {} } = (await request.json()) as {
    url: string;
    init?: RequestInit;
  };

  const target = new URL(url);
  const base = new URL(baseUrl);
  const method = init.method ?? 'PROPFIND';

  if (!target.href.startsWith(base.href)) {
    return new Response('Forbidden upstream URL', {
      status: 403,
      headers: corsHeaders(),
    });
  }
  if (!ALLOWED_METHODS.has(method)) {
    return new Response('Method not allowed', {
      status: 405,
      headers: corsHeaders(),
    });
  }

  const headers = new Headers(init.headers);
  headers.set(
    'Authorization',
    `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`
  );

  const upstream = await fetch(target.href, {
    method,
    headers,
    body: typeof init.body === 'string' ? init.body : undefined,
  });

  const responseHeaders = new Headers(corsHeaders());
  for (const name of ['Content-Type', 'ETag', 'DAV', 'Last-Modified']) {
    const value = upstream.headers.get(name);
    if (value) responseHeaders.set(name, value);
  }

  return new Response(await upstream.text(), {
    status: upstream.status,
    headers: responseHeaders,
  });
}

Pour un produit multi-utilisateurs, chargez les identifiants depuis l'enregistrement chiffré du compte connecté plutôt que depuis des variables d'environnement du processus. La règle reste la même : DayFlow envoie les requêtes du protocole CalDAV à votre backend, et c'est votre backend qui ajoute les identifiants.

4. Créer un stockage de synchronisation durable

createCalDAVSync peut fonctionner en mémoire, mais une application en production devrait persister les tokens de synchronisation, les ETags et les références distantes des événements. Sans stockage durable, chaque rechargement perd l'état de synchronisation incrémentale et les métadonnées d'écriture conditionnelle.

caldav-storage.ts
import type { CalDAVStorage } from '@dayflow/caldav';

const key = (accountId: string, name: string, id: string) =>
  `dayflow:caldav:${accountId}:${name}:${id}`;

export function createLocalCalDAVStorage(accountId: string): CalDAVStorage {
  return {
    getSyncToken: async calendarId =>
      localStorage.getItem(key(accountId, 'sync-token', calendarId)),
    setSyncToken: async (calendarId, token) => {
      const storageKey = key(accountId, 'sync-token', calendarId);
      if (token) localStorage.setItem(storageKey, token);
      else localStorage.removeItem(storageKey);
    },
    getCtag: async calendarId =>
      localStorage.getItem(key(accountId, 'ctag', calendarId)),
    setCtag: async (calendarId, ctag) => {
      localStorage.setItem(key(accountId, 'ctag', calendarId), ctag);
    },
    getEtag: async href => localStorage.getItem(key(accountId, 'etag', href)),
    setEtag: async (href, etag) => {
      localStorage.setItem(key(accountId, 'etag', href), etag);
    },
    deleteEtag: async href => {
      localStorage.removeItem(key(accountId, 'etag', href));
    },
    getEventState: async eventId =>
      JSON.parse(
        localStorage.getItem(key(accountId, 'event-state', eventId)) ?? 'null'
      ),
    setEventState: async (eventId, state) => {
      localStorage.setItem(
        key(accountId, 'event-state', eventId),
        JSON.stringify(state)
      );
    },
    deleteEventState: async eventId => {
      localStorage.removeItem(key(accountId, 'event-state', eventId));
    },
    clearCalendar: async calendarId => {
      const prefix = `dayflow:caldav:${accountId}:`;
      for (const storageKey of Object.keys(localStorage)) {
        if (!storageKey.startsWith(prefix)) continue;

        const value = localStorage.getItem(storageKey);
        const isCalendarKey =
          storageKey === key(accountId, 'sync-token', calendarId) ||
          storageKey === key(accountId, 'ctag', calendarId) ||
          storageKey.startsWith(key(accountId, 'etag', calendarId));
        let isEventStateForCalendar = false;
        if (storageKey.startsWith(`${prefix}event-state:`) && value) {
          try {
            isEventStateForCalendar =
              JSON.parse(value).calendarId === calendarId;
          } catch {
            isEventStateForCalendar = false;
          }
        }

        if (isCalendarKey || isEventStateForCalendar) {
          localStorage.removeItem(storageKey);
        }
      }
    },
  };
}

Utilisez IndexedDB ou votre propre base si vous avez besoin d'un cache de synchronisation multi-appareils, de chiffrement, d'historiques d'événements volumineux ou d'un nettoyage au niveau du compte.

5. Rattacher CalDAV à DayFlow

Cet exemple React utilise la découverte façon iCloud. Remplacez le bloc de création de l'adaptateur par nextcloudConfig, radicaleConfig ou fastmailConfig lorsque vous connaissez déjà l'URL du calendar home du fournisseur.

CalDAVCalendar.tsx
import { useEffect, useRef } from 'react';
import {
  DayFlowCalendar,
  createMonthView,
  useCalendarApp,
} from '@dayflow/react';
import {
  ICLOUD_CALDAV_SERVER,
  attachCalDAVToDayFlow,
  createCalDAVAdapter,
  createCalDAVAdapterFromServer,
  createCalDAVSync,
  createNamespacedCalDAVEventId,
  nextcloudConfig,
  type CalDAVDayFlowController,
  type CalDAVSyncStatus,
} from '@dayflow/caldav';
import { createLocalCalDAVStorage } from './caldav-storage';

type Provider = 'icloud' | 'nextcloud';

type Props = {
  accountId: string;
  provider: Provider;
  nextcloudHost?: string;
  nextcloudUsername?: string;
  onStatusChange?: (status: CalDAVSyncStatus) => void;
};

export function CalDAVCalendar({
  accountId,
  provider,
  nextcloudHost,
  nextcloudUsername,
  onStatusChange,
}: Props) {
  const calendar = useCalendarApp({
    views: [createMonthView()],
    calendars: [],
    events: [],
  });
  const controllerRef = useRef<CalDAVDayFlowController | null>(null);

  useEffect(() => {
    let disposed = false;

    const proxiedFetch = (url: string, init?: RequestInit) =>
      fetch('/api/caldav', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ url, init }),
      });

    async function startSync() {
      const adapter =
        provider === 'icloud'
          ? await createCalDAVAdapterFromServer(ICLOUD_CALDAV_SERVER, {
              fetch: proxiedFetch,
            })
          : createCalDAVAdapter({
              ...nextcloudConfig(nextcloudHost!, nextcloudUsername!),
              fetch: proxiedFetch,
            });

      if (disposed) return;

      const sync = createCalDAVSync({
        adapter,
        storage: createLocalCalDAVStorage(accountId),
        rangeChunkDays: 31,
      });

      const controller = attachCalDAVToDayFlow(calendar.app, sync, {
        writable: true,
        refreshOnVisibleRangeChange: true,
        maxConcurrentCalendars: 4,
        createEventId: createNamespacedCalDAVEventId,
        eventMode: {
          recurring: 'read-only',
        },
        getInitialSnapshot: async () => {
          const cached = await loadCachedCalDAVSnapshot(accountId);
          return cached ?? { calendars: [], events: [] };
        },
        onSyncComplete: async delta => {
          await persistSyncedDayFlowState(accountId, calendar.app);
          console.info('CalDAV sync complete', delta);
        },
        onWriteComplete: async () => {
          await persistSyncedDayFlowState(accountId, calendar.app);
        },
        onError: (error, context) => {
          console.error('CalDAV sync failed', context, error);
          onStatusChange?.(controller.getStatus());
        },
      });

      controllerRef.current = controller;
      await controller.start();
      onStatusChange?.(controller.getStatus());
    }

    startSync().catch(error => {
      console.error('Failed to start CalDAV sync', error);
    });

    return () => {
      disposed = true;
      controllerRef.current?.stop();
      controllerRef.current = null;
    };
  }, [
    accountId,
    calendar.app,
    nextcloudHost,
    nextcloudUsername,
    onStatusChange,
    provider,
  ]);

  return <DayFlowCalendar calendar={calendar} />;
}

L'exemple s'appuie sur deux fonctions de persistance appartenant à l'application :

import type { ICalendarApp } from '@dayflow/core';

async function loadCachedCalDAVSnapshot(accountId: string) {
  // Return { calendars, events } from IndexedDB, Supabase, your backend, etc.
  // Return null when there is no cache yet.
  return null;
}

async function persistSyncedDayFlowState(accountId: string, app: ICalendarApp) {
  // Persist app.getCalendars() and app.getAllEvents() into your local store.
  // Keep this separate from CalDAVStorage, which stores sync metadata only.
}

6. Vérifier la propagation des modifications

Avant d'activer la synchronisation bidirectionnelle pour de vrais utilisateurs, testez ces scénarios sur le fournisseur visé :

ScénarioRésultat attendu
Chargement initialLes calendriers distants apparaissent, puis les événements de la plage visible se chargent.
Aller au mois suivantrefreshOnVisibleRangeChange récupère cette plage.
Créer un événement localUne ressource .ics est créée à distance et l'événement local reçoit les métadonnées CalDAV href et etag.
Modifier un événement localLa mise à jour utilise le dernier ETag et rafraîchit les métadonnées CalDAV locales après la réponse du serveur.
Supprimer un événement localLa ressource distante est supprimée si le calendrier est accessible en écriture.
Modifier un événement récurrentLe binding DayFlow traite les événements récurrents en lecture seule.
Modifier dans un calendrier en lecture seuleAucune écriture n'est tentée si le calendrier distant n'a pas les droits de création, de mise à jour ou de suppression.

Liste de contrôle avant production

DomaineÀ vérifier
IdentifiantsStockez les identifiants uniquement côté serveur ; utilisez des mots de passe d'application quand ils existent ; changez-les ou révoquez-les à la déconnexion.
Liste blanche du proxyRestreignez les URL amont à l'URL de base du fournisseur configurée ; n'autorisez que PROPFIND, REPORT, PUT et DELETE.
Stockage durablePersistez CalDAVStorage par compte afin que tokens, ETags et hrefs distants survivent aux rechargements.
Cache localUtilisez getInitialSnapshot pour un premier affichage rapide, mais gardez identifiants et métadonnées de synchronisation hors du cache d'événements.
Identité des événementsConservez createNamespacedCalDAVEventId sauf plan de migration : il évite les collisions avec les événements locaux et les autres fournisseurs.
Instantanés partielsConservez le mode partiel par défaut pour les réponses limitées à une plage ; n'utilisez snapshotMode: 'authoritative' que pour des instantanés complets.
Particularités des fournisseursiCloud exige la découverte ; Nextcloud demande un mot de passe d'application ; Radicale peut paraître en lecture seule ; Fastmail utilise le chemin /dav/principals/user/....

Ce qui n'est pas pris en charge

LimitationDétail
Modification des événements récurrentsLes événements récurrents sont en lecture seule. Les modifier, les déplacer ou supprimer une occurrence est bloqué.
OAuth intégréLes flux OAuth, le rafraîchissement des tokens et le stockage des identifiants sont à votre charge.
Mode hors ligneLes paquets fournisseurs nécessitent une connexion réseau active.
Interface de conflitsLes conflits d'ETag (412) sont retentés automatiquement ; aucune interface de résolution manuelle n'est fournie.
CORS CalDAVLes serveurs CalDAV ne gèrent pas le CORS navigateur — un proxy backend est indispensable.
CORS GoogleL'API Google Calendar gère le CORS, mais envoyer des tokens depuis le navigateur est un risque de sécurité — utilisez un proxy.

Dans cette page