Remote-Synchronisierung – Überblick
DayFlow bietet Headless-Sync-Pakete, um Ihren Kalender mit externen Servern zu verbinden:
@dayflow/caldav– CalDAV-Sync-Engine. Funktioniert mit iCloud Calendar, Nextcloud, Radicale, Fastmail und jedem CalDAV-Server nach RFC 4791.@dayflow/google-sync– Sync-Engine für die REST-API von Google Calendar.@dayflow/outlook-sync– Sync-Engine für Microsoft Outlook Calendar über die Microsoft-Graph-API.@dayflow/sync-core– anbieterneutrale Abgleichshilfen für eigene Anbieter und backend-gesteuerte Synchronisierung.
Die meisten Anwendungen sollten mit einem Anbieterpaket beginnen (@dayflow/caldav, @dayflow/google-sync oder @dayflow/outlook-sync). Zu @dayflow/sync-core greifen Sie nur, wenn Sie ein eigenes Anbieterpaket bauen, Remote-Daten in einem Backend-Job abgleichen oder anbieterneutrale Audit- bzw. Verlaufsänderungen benötigen.
Entwurfsprinzipien
Keine Zugangsdaten im Browser
Die Anbieterpakete nehmen weder Passwörter noch Tokens oder OAuth-Secrets direkt entgegen. Die gesamte Authentifizierung liegt in Ihrem Backend. Der Browser spricht ausschließlich mit Ihrem eigenen Proxy-Server.
Browser (DayFlow) → Your backend proxy → CalDAV / Google / Outlook APIDayFlow sieht die Zugangsdaten Ihrer Nutzenden also nie, und Sie behalten die volle Kontrolle über Ihre Authentifizierungsstrategie.
Adapterbasierter Transport
Jedes Anbieterpaket erhält für den Transport eine von Ihnen bereitgestellte fetch-Funktion. Sie übergeben eine authentifizierte fetch-Funktion, und die Sync-Engine führt ihre Anfragen darüber aus, ohne zu wissen, welche Zugangsdaten sie verwendet.
const adapter = createCalDAVAdapter({
calendarHomeUrl: 'https://caldav.example.com/calendars/alice/',
fetch: (url, init) =>
fetch('/api/caldav-proxy', {
method: 'POST',
body: JSON.stringify({ url, init }),
}),
});Beobachtbar, ohne Abläufe vorzuschreiben
Anbieterpakete melden strukturierte Callbacks, statt die Persistenz zu übernehmen:
onSyncComplete(delta)– wird nach jeder Synchronisierung mit den Änderungszählern aufgerufen, sodass Ihr Store reagieren kann, ohne erneut zu vergleichen.onWriteComplete(operation, event)– wird nach jeder erfolgreichen Rückschreibung aufgerufen, sodass Sie Ihre lokale Datenbank mit den vom Server vergebenen IDs aktualisieren können.getInitialSnapshot()– wird einmal beim Start aufgerufen, um DayFlow noch vor jeder API-Anfrage aus Ihrem lokalen Cache zu befüllen, damit der Kalender sofort erscheint.
Die Speicherung liegt bei Ihnen. Die Pakete sagen Ihnen nur, was passiert ist.
Headless
Anbieterpakete liefern keine Anmeldeformulare, keine OAuth-Popups, keine Speicherung von Zugangsdaten und keinen gehosteten Sync-Dienst. Sie stellen die Sync-Infrastruktur bereit; das Anmeldeerlebnis gestaltet Ihre Anwendung.
Nur lesen oder Änderungen zurückschreiben
Anbieterpakete unterstützen:
- Nur-Lese-Modus (
writable: false) – synchronisiert Termine vom Server nach DayFlow, ohne zurückzuschreiben. - Rückschreibe-Modus (
writable: true, Voreinstellung) – überträgt lokale Neuanlagen, Änderungen und Löschungen auf den Server.
Vollständiges CalDAV-Beispiel
Dieses Beispiel zeigt den kompletten Aufbau einer produktionsnahen CalDAV-Integration:
- Ein Backend-Proxy hält die Zugangsdaten und leitet CalDAV-Methoden weiter.
- Das Frontend erzeugt eine
fetch-Funktion, die über den Proxy läuft. - Der CalDAV-Adapter findet die Calendar-Home-URL der Nutzenden oder bekommt sie übergeben.
- DayFlow wird aus einem lokalen Cache befüllt und anschließend im Hintergrund synchronisiert.
- Lokale Änderungen werden nur zurückgeschrieben, wenn der Remote-Kalender beschreibbar ist.
1. Installation
2. Das Backend konfigurieren
Verwenden Sie anbieterspezifische App-Passwörter, wo es sie gibt. Diese Werte gehören niemals in den Browser.
# 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/Hinweise je Anbieter:
| Anbieter | Empfohlene Einrichtung |
|---|---|
| iCloud | Verwenden Sie ein app-spezifisches Apple-Passwort und createCalDAVAdapterFromServer(ICLOUD_CALDAV_SERVER, ...) für die dynamische Home-Ermittlung. |
| Nextcloud | Nutzen Sie nextcloudConfig(host, username), wenn der Benutzername bereits bekannt ist; erzeugen Sie ein App-Passwort in den persönlichen Einstellungen. |
| Radicale | Manche Server liefern keine Berechtigungen. DayFlow behandelt unklare Kalender als schreibgeschützt, sofern der Adapter nichts anderes meldet. |
| Fastmail | Verwenden Sie fastmailConfig('https://caldav.fastmail.com/dav', email) zusammen mit einem Backend-Proxy. |
3. Eine Proxy-Route hinzufügen
Diese Next.js-App-Router-Route nimmt in JSON verpackte CalDAV-Anfragen aus dem Browser entgegen, ergänzt Basic Auth, beschränkt die Ziel-URL auf Ihre konfigurierte CalDAV-Basis-URL und gibt die XML/ICS-Antwort des Servers zurück.
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,
});
}Laden Sie bei Mehrbenutzerprodukten die Zugangsdaten aus dem verschlüsselten Kontodatensatz der angemeldeten Person statt aus prozessweiten Umgebungsvariablen. Die entscheidende Regel bleibt: DayFlow schickt CalDAV-Protokollanfragen an Ihr Backend, und Ihr Backend ergänzt die Zugangsdaten.
4. Dauerhaften Sync-Speicher anlegen
createCalDAVSync läuft auch mit reinem Arbeitsspeicher, doch Produktivanwendungen sollten Sync-Tokens, ETags und die Remote-Referenzen der Termine dauerhaft ablegen. Ohne dauerhaften Speicher geht bei jedem Neuladen der Stand der inkrementellen Synchronisierung samt Metadaten für bedingte Schreibvorgänge verloren.
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);
}
}
},
};
}Greifen Sie zu IndexedDB oder Ihrer eigenen Datenbank, wenn Sie einen geräteübergreifenden Sync-Cache, Verschlüsselung, umfangreiche Terminhistorien oder eine Bereinigung auf Kontoebene brauchen.
5. CalDAV an DayFlow anbinden
Dieses React-Beispiel nutzt die Ermittlung im iCloud-Stil. Ersetzen Sie den Block zur Adaptererzeugung durch nextcloudConfig, radicaleConfig oder fastmailConfig, wenn Sie die Calendar-Home-URL des Anbieters bereits kennen.
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} />;
}Das Beispiel verweist auf zwei Persistenzhelfer, die zur Anwendung gehören:
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. Das Rückschreiben überprüfen
Testen Sie diese Abläufe beim Zielanbieter, bevor Sie das Rückschreiben für echte Nutzende aktivieren:
| Ablauf | Erwartetes Ergebnis |
|---|---|
| Erstes Laden | Die Remote-Kalender erscheinen, danach werden die Termine des sichtbaren Bereichs geladen. |
| Zum nächsten Monat blättern | refreshOnVisibleRangeChange holt diesen Bereich. |
| Lokalen Termin anlegen | Entfernt entsteht eine .ics-Ressource, der lokale Termin erhält die CalDAV-Metadaten href und etag. |
| Lokalen Termin bearbeiten | Die Aktualisierung nutzt das aktuelle ETag und frischt danach die lokalen CalDAV-Metadaten auf. |
| Lokalen Termin löschen | Die Remote-Ressource wird gelöscht, sofern der Kalender beschreibbar ist. |
| Wiederkehrenden Termin bearbeiten | Die DayFlow-Anbindung behandelt wiederkehrende Termine als schreibgeschützt. |
| Bearbeitung in einem schreibgeschützten Kalender | Es wird gar nicht erst geschrieben, wenn der Remote-Kalender keine Rechte zum Anlegen, Ändern oder Löschen hat. |
Checkliste für den Produktivbetrieb
| Bereich | Worauf zu achten ist |
|---|---|
| Zugangsdaten | Anbieter-Zugangsdaten ausschließlich serverseitig speichern; App-Passwörter nutzen, wo verfügbar; beim Trennen wechseln oder widerrufen. |
| Proxy-Allowlist | Ziel-URLs auf die konfigurierte Basis-URL des Anbieters beschränken; nur PROPFIND, REPORT, PUT und DELETE zulassen. |
| Dauerhafter Sync-Speicher | CalDAVStorage je Konto persistieren, damit Sync-Tokens, ETags und Remote-hrefs ein Neuladen überstehen. |
| Lokaler Cache | getInitialSnapshot für einen schnellen ersten Aufbau nutzen, Zugangsdaten und Sync-Metadaten aber aus dem Termin-Cache heraushalten. |
| Termin-Identität | createNamespacedCalDAVEventId beibehalten, sofern kein Migrationsplan besteht; es verhindert Kollisionen mit lokalen Terminen und anderen Anbietern. |
| Teil-Snapshots | Bei bereichsbegrenzten Anbieterantworten den voreingestellten Teilmodus behalten; snapshotMode: 'authoritative' nur bei vollständigen Snapshots. |
| Anbieter-Eigenheiten | iCloud braucht die Ermittlung; Nextcloud sollte App-Passwörter nutzen; Radicale wirkt womöglich schreibgeschützt; Fastmail verwendet den Pfad /dav/principals/user/.... |
Was nicht unterstützt wird
| Einschränkung | Detail |
|---|---|
| Bearbeiten wiederkehrender Termine | Wiederkehrende Termine sind schreibgeschützt. Bearbeiten, Ziehen oder Löschen einzelner Vorkommen ist gesperrt. |
| Eingebautes OAuth | OAuth-Abläufe, Token-Erneuerung und die Speicherung von Zugangsdaten liegen bei Ihnen. |
| Offline-Betrieb | Anbieterpakete setzen eine bestehende Netzwerkverbindung voraus. |
| Konflikt-UI | ETag-Konflikte (412) werden automatisch wiederholt; eine Oberfläche zur manuellen Konfliktlösung ist nicht enthalten. |
| CalDAV und CORS | CalDAV-Server unterstützen kein Browser-CORS – ein Backend-Proxy ist erforderlich. |
| Google und CORS | Die Google-Calendar-API unterstützt CORS, doch Tokens aus dem Browser zu senden ist ein Sicherheitsrisiko – nutzen Sie einen Proxy. |