@dayflow/caldav

@dayflow/caldav๋Š” ์–ด๋Œ‘ํ„ฐ ์ค‘์‹ฌ์œผ๋กœ ์„ค๊ณ„๋œ ํ—ค๋“œ๋ฆฌ์Šค CalDAV ๋™๊ธฐํ™” ์—”์ง„์ž…๋‹ˆ๋‹ค. iCloud ์บ˜๋ฆฐ๋”, Nextcloud, Radicale, Fastmail์„ ๋น„๋กฏํ•ด RFC 4791์„ ๋”ฐ๋ฅด๋Š” ๋ชจ๋“  CalDAV ์„œ๋ฒ„์™€ ํ•จ๊ป˜ ๋™์ž‘ํ•ฉ๋‹ˆ๋‹ค.

์„ค์น˜

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

๋น ๋ฅธ ์‹œ์ž‘

import { useRef, useEffect } from 'react';
import {
  DayFlowCalendar,
  useCalendarApp,
  createMonthView,
} from '@dayflow/react';
import {
  attachCalDAVToDayFlow,
  createCalDAVAdapter,
  createCalDAVSync,
  type CalDAVDayFlowController,
} from '@dayflow/caldav';

function MyCalendar() {
  const calendar = useCalendarApp({
    views: [createMonthView()],
    calendars: [],
    events: [],
  });
  const controllerRef = useRef<CalDAVDayFlowController | null>(null);

  useEffect(() => {
    if (controllerRef.current) return;

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

    const sync = createCalDAVSync({ adapter });
    const controller = attachCalDAVToDayFlow(calendar.app, sync, {
      writable: true,
      maxConcurrentCalendars: 4,
      onSyncComplete: delta => {
        console.log(
          `Sync done: +${delta.events.added} ~${delta.events.updated} -${delta.events.deleted}`
        );
      },
    });

    controllerRef.current = controller;
    controller.start();

    return () => {
      controller.stop();
      controllerRef.current = null;
    };
  }, [calendar.app]);

  return <DayFlowCalendar calendar={calendar} />;
}
<template>
  <DayFlowCalendar :calendar="calendar" />
</template>

<script setup>
  import { onMounted, onBeforeUnmount } from 'vue';
  import { DayFlowCalendar, useCalendarApp } from '@dayflow/vue';
  import { createMonthView } from '@dayflow/core';
  import {
    attachCalDAVToDayFlow,
    createCalDAVAdapter,
    createCalDAVSync,
  } from '@dayflow/caldav';

  const calendar = useCalendarApp({
    views: [createMonthView()],
    calendars: [],
    events: [],
  });

  let controller;

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

    const sync = createCalDAVSync({ adapter });
    controller = attachCalDAVToDayFlow(calendar.app, sync, {
      writable: true,
      maxConcurrentCalendars: 4,
      onSyncComplete: delta => {
        console.log(
          `Sync done: +${delta.events.added} ~${delta.events.updated} -${delta.events.deleted}`
        );
      },
    });

    controller.start();
  });

  onBeforeUnmount(() => {
    controller?.stop();
  });
</script>
import { Component, OnInit, OnDestroy } from '@angular/core';
import { CalendarApp, createMonthView } from '@dayflow/core';
import { DayFlowCalendarModule } from '@dayflow/angular';
import {
  attachCalDAVToDayFlow,
  createCalDAVAdapter,
  createCalDAVSync,
  type CalDAVDayFlowController,
} from '@dayflow/caldav';

@Component({
  selector: 'app-root',
  standalone: true,
  imports: [DayFlowCalendarModule],
  template: `<dayflow-calendar [calendar]="calendar"></dayflow-calendar>`,
})
export class AppComponent implements OnInit, OnDestroy {
  calendar = new CalendarApp({
    views: [createMonthView()],
    calendars: [],
    events: [],
  });

  private controller?: CalDAVDayFlowController;

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

    const sync = createCalDAVSync({ adapter });
    this.controller = attachCalDAVToDayFlow(this.calendar, sync, {
      writable: true,
      maxConcurrentCalendars: 4,
      onSyncComplete: delta => {
        console.log(
          `Sync done: +${delta.events.added} ~${delta.events.updated} -${delta.events.deleted}`
        );
      },
    });

    this.controller.start();
  }

  ngOnDestroy() {
    this.controller?.stop();
  }
}
<script>
  import { onMount, onDestroy } from 'svelte';
  import { DayFlowCalendar, useCalendarApp } from '@dayflow/svelte';
  import { createMonthView } from '@dayflow/core';
  import {
    attachCalDAVToDayFlow,
    createCalDAVAdapter,
    createCalDAVSync,
  } from '@dayflow/caldav';

  const calendar = useCalendarApp({
    views: [createMonthView()],
    calendars: [],
    events: [],
  });

  let controller;

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

    const sync = createCalDAVSync({ adapter });
    controller = attachCalDAVToDayFlow(calendar.app, sync, {
      writable: true,
      maxConcurrentCalendars: 4,
      onSyncComplete: delta => {
        console.log(
          `Sync done: +${delta.events.added} ~${delta.events.updated} -${delta.events.deleted}`
        );
      },
    });

    controller.start();
  });

  onDestroy(() => {
    controller?.stop();
  });
</script>

<DayFlowCalendar {calendar} />

Calendar Home ํƒ์ƒ‰

iCloud์ฒ˜๋Ÿผ ๋™์  ํƒ์ƒ‰์ด ํ•„์š”ํ•œ ์„œ๋ฒ„์—์„œ๋Š” createCalDAVAdapterFromServer๋ฅผ ์‚ฌ์šฉํ•˜์„ธ์š”. 2๋‹จ๊ณ„ PROPFIND๋ฅผ ์ˆ˜ํ–‰ํ•˜๊ณ  ์ค€๋น„๋œ ์–ด๋Œ‘ํ„ฐ๋ฅผ ํ•œ ๋ฒˆ์— ๋ฐ˜ํ™˜ํ•ฉ๋‹ˆ๋‹ค.

import {
  createCalDAVAdapterFromServer,
  ICLOUD_CALDAV_SERVER,
} from '@dayflow/caldav';

const adapter = await createCalDAVAdapterFromServer(ICLOUD_CALDAV_SERVER, {
  fetch: proxiedFetch,
});

์บ์‹ฑ์ด๋‚˜ ๋กœ๊น… ๋•Œ๋ฌธ์— home URL์ด ๋”ฐ๋กœ ํ•„์š”ํ•˜๋‹ค๋ฉด ํ•˜์œ„ ์ˆ˜์ค€ ํ•จ์ˆ˜์ธ discoverCalendarHome์„ ์‚ฌ์šฉํ•˜์„ธ์š”:

import {
  discoverCalendarHome,
  createCalDAVAdapter,
  ICLOUD_CALDAV_SERVER,
} from '@dayflow/caldav';

const calendarHomeUrl = await discoverCalendarHome(
  ICLOUD_CALDAV_SERVER,
  authenticatedFetch
);
const adapter = createCalDAVAdapter({
  calendarHomeUrl,
  fetch: authenticatedFetch,
});

discoverCalendarHome์€ 2๋‹จ๊ณ„ PROPFIND๋ฅผ ์ˆ˜ํ–‰ํ•ฉ๋‹ˆ๋‹ค:

  1. ์„œ๋ฒ„ ๋ฃจํŠธ์—์„œ current-user-principal์„ ๊ฐ€์ ธ์˜ต๋‹ˆ๋‹ค
  2. principal URL์—์„œ calendar-home-set์„ ๊ฐ€์ ธ์˜ต๋‹ˆ๋‹ค

fetch ์ธ์ž๋Š” ์ธ์ฆ ์ •๋ณด๋ฅผ ํ•จ๊ป˜ ์‹ค์–ด ๋ณด๋‚ด์•ผ ํ•ฉ๋‹ˆ๋‹ค. ํƒ์ƒ‰ ์š”์ฒญ๋„ ๋‹ค๋ฅธ CalDAV ์š”์ฒญ๊ณผ ๋งˆ์ฐฌ๊ฐ€์ง€๋กœ ์ธ์ฆ์ด ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค.

์ œ๊ณต์ž ํ”„๋ฆฌ์…‹

๋‚ด์žฅ ํ”„๋ฆฌ์…‹์„ ์‚ฌ์šฉํ•˜๋ฉด ์•Œ๋ ค์ง„ ์ œ๊ณต์ž์— ๋งž๋Š” calendarHomeUrl์„ ์†์‰ฝ๊ฒŒ ๊ตฌ์„ฑํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค:

import {
  ICLOUD_CALDAV_SERVER,
  createCalDAVAdapterFromServer,
  nextcloudConfig,
  radicaleConfig,
  fastmailConfig,
  createCalDAVAdapter,
} from '@dayflow/caldav';

// iCloud โ€” must always discover dynamically
const icloudAdapter = await createCalDAVAdapterFromServer(
  ICLOUD_CALDAV_SERVER,
  { fetch: proxiedFetch }
);

// Nextcloud
const nextcloudAdapter = createCalDAVAdapter({
  ...nextcloudConfig('https://nextcloud.example.com', 'alice'),
  fetch: proxiedFetch,
});

// Radicale
const radicaleAdapter = createCalDAVAdapter({
  ...radicaleConfig('https://radicale.example.com', 'alice'),
  fetch: proxiedFetch,
});

// Fastmail
const fastmailAdapter = createCalDAVAdapter({
  ...fastmailConfig('https://caldav.fastmail.com/dav', 'alice@fastmail.com'),
  fetch: proxiedFetch,
});

iCloud ์บ˜๋ฆฐ๋”

iCloud CalDAV์—๋Š” Apple ID ๋น„๋ฐ€๋ฒˆํ˜ธ๊ฐ€ ์•„๋‹ˆ๋ผ ์•ฑ ์ „์šฉ ๋น„๋ฐ€๋ฒˆํ˜ธ๊ฐ€ ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค. appleid.apple.com โ†’ ๋กœ๊ทธ์ธ ๋ฐ ๋ณด์•ˆ โ†’ ์•ฑ ์ „์šฉ ์•”ํ˜ธ์—์„œ ๋ฐœ๊ธ‰ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

iCloud๋Š” ๋ธŒ๋ผ์šฐ์ € CORS๋ฅผ ์ง€์›ํ•˜์ง€ ์•Š์œผ๋ฏ€๋กœ, ํƒ์ƒ‰์„ ํฌํ•จํ•œ ๋ชจ๋“  ์š”์ฒญ์— ๋ฐฑ์—”๋“œ ํ”„๋ก์‹œ๊ฐ€ ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค.

์–ด๋Œ‘ํ„ฐ๊ฐ€ ์ž๋™์œผ๋กœ ์ฒ˜๋ฆฌํ•ด ์ฃผ๋Š” iCloud ๊ณ ์œ  ๋™์ž‘:

  • 8์ž๋ฆฌ RGBA 16์ง„ ์ƒ‰์ƒ(#RRGGBBAA) โ€” ์•ŒํŒŒ ์ฑ„๋„์ด ์ œ๊ฑฐ๋ฉ๋‹ˆ๋‹ค
  • ์กฐ๊ฑด๋ถ€ ์“ฐ๊ธฐ(If-Match ETag)๊ฐ€ ์ •์ƒ ๋™์ž‘ํ•ฉ๋‹ˆ๋‹ค
  • ์ข…์ผ ์ด๋ฒคํŠธ๋Š” ํ‘œ์ค€ VALUE=DATE ํ˜•์‹์„ ์‚ฌ์šฉํ•ฉ๋‹ˆ๋‹ค
  • ์‹œ๊ฐ„๋Œ€๊ฐ€ ์ง€์ •๋œ ์ด๋ฒคํŠธ๋Š” ํ‘œ์ค€ TZID ๋งค๊ฐœ๋ณ€์ˆ˜๋ฅผ ์‚ฌ์šฉํ•ฉ๋‹ˆ๋‹ค

Nextcloud

๊ณ„์ • ๋น„๋ฐ€๋ฒˆํ˜ธ๊ฐ€ ์•„๋‹ˆ๋ผ ๊ฐœ์ธ ์„ค์ • โ†’ ๋ณด์•ˆ์—์„œ ๋ฐœ๊ธ‰ํ•œ ์•ฑ ๋น„๋ฐ€๋ฒˆํ˜ธ๋ฅผ ์‚ฌ์šฉํ•˜์„ธ์š”.

Nextcloud๋Š” current-user-privilege-set์„ ์˜ฌ๋ฐ”๋ฅด๊ฒŒ ๋ฐ˜ํ™˜ํ•˜๋ฏ€๋กœ ์ฝ๊ธฐยท์“ฐ๊ธฐ ๊ถŒํ•œ์ด ์ž๋™์œผ๋กœ ๊ฐ์ง€๋ฉ๋‹ˆ๋‹ค. CORS๋Š” ์ง€์›ํ•˜์ง€ ์•Š์œผ๋‹ˆ ํ”„๋ก์‹œ๋ฅผ ์‚ฌ์šฉํ•˜์„ธ์š”.

Radicale

Radicale์€ ๋ณดํ†ต current-user-privilege-set์„ ๋ฐ˜ํ™˜ํ•˜์ง€ ์•Š๊ธฐ ๋•Œ๋ฌธ์—, ์–ด๋Œ‘ํ„ฐ๋Š” ํƒ์ƒ‰๋œ ์บ˜๋ฆฐ๋”๋ฅผ ๊ธฐ๋ณธ์ ์œผ๋กœ ์ฝ๊ธฐ ์ „์šฉ์œผ๋กœ ์ฒ˜๋ฆฌํ•ฉ๋‹ˆ๋‹ค. ์‚ฌ์šฉ์ž์—๊ฒŒ ์“ฐ๊ธฐ ๊ถŒํ•œ์ด ์žˆ๋‹ค๋Š” ๊ฒƒ์„ ์•ˆ๋‹ค๋ฉด ํƒ์ƒ‰ ํ›„ CalendarType์— readOnly: false๋ฅผ ์ง€์ •ํ•˜์„ธ์š”.

Fastmail

Fastmail์€ ํ‘œ์ค€ CalDAV๋ฅผ ์ง€์›ํ•ฉ๋‹ˆ๋‹ค. fastmailConfig์— Fastmail ์ด๋ฉ”์ผ ์ฃผ์†Œ๋ฅผ ์‚ฌ์šฉ์ž ์ด๋ฆ„์œผ๋กœ ์ „๋‹ฌํ•˜์„ธ์š”.

๋กœ์ปฌ ์บ์‹œ๋กœ ์ดˆ๊ธฐํ™”ํ•˜๊ธฐ

๋™๊ธฐํ™”๋œ ์ด๋ฒคํŠธ๋ฅผ ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค๋‚˜ ๋กœ์ปฌ ์ €์žฅ์†Œ(์˜ˆ: Supabase, IndexedDB)์— ๋ณด๊ด€ํ•œ๋‹ค๋ฉด, ์ฒซ ์›๊ฒฉ ๋™๊ธฐํ™” ์ „์— DayFlow๋ฅผ ๋ฏธ๋ฆฌ ์ฑ„์›Œ ์บ˜๋ฆฐ๋”๋ฅผ ์ฆ‰์‹œ ํ‘œ์‹œํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค:

const controller = attachCalDAVToDayFlow(calendar.app, sync, {
  getInitialSnapshot: async () => {
    const { calendars, events } = await loadFromLocalDB();
    return { calendars, events };
  },
  onSyncComplete: delta => {
    // Save what changed to your local store
    console.log(
      `+${delta.events.added} ~${delta.events.updated} -${delta.events.deleted}`
    );
  },
  onWriteComplete: (operation, event) => {
    // Update local store after a successful write-back
    persistEvent(operation, event);
  },
});

getInitialSnapshot์€ CalDAV ์š”์ฒญ์ด ์ผ์–ด๋‚˜๊ธฐ ์ „, start() ๋„์ค‘ ํ•œ ๋ฒˆ๋งŒ ํ˜ธ์ถœ๋ฉ๋‹ˆ๋‹ค. ๋ฐฑ๊ทธ๋ผ์šด๋“œ์—์„œ ๋™๊ธฐํ™”๊ฐ€ ๋„๋Š” ๋™์•ˆ DayFlow๋Š” ์บ์‹œ๋œ ๋ฐ์ดํ„ฐ๋กœ ๋ Œ๋”๋ง๋ฉ๋‹ˆ๋‹ค. getInitialSnapshot์—์„œ ๋ฐœ์ƒํ•œ ์˜ค๋ฅ˜๋Š” onError๋กœ ์ „๋‹ฌ๋˜๋ฉฐ ์›๊ฒฉ ๋™๊ธฐํ™”๋ฅผ ์ค‘๋‹จ์‹œํ‚ค์ง€ ์•Š์Šต๋‹ˆ๋‹ค.

์›๊ฒฉ ์Šค๋ƒ…์ˆ ์ง์ ‘ ์ ์šฉํ•˜๊ธฐ

๋ธŒ๋ผ์šฐ์ €๊ฐ€ ์•„๋‹ˆ๋ผ ๋ฐฑ์—”๋“œ ์ž‘์—…์—์„œ ๋™๊ธฐํ™”ํ•˜๋Š” ๋“ฑ ๋™๊ธฐํ™” ํ๋ฆ„์„ ์ง์ ‘ ๊ด€๋ฆฌํ•˜๋Š” ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜์ด๋ผ๋ฉด, applyRemoteSnapshot์œผ๋กœ ์›๊ฒฉ ์ด๋ฒคํŠธ ๋ฌถ์Œ์„ ์ œ๊ณต์ž์—๊ฒŒ ๋‹ค์‹œ ์ „์†กํ•˜์ง€ ์•Š๊ณ  DayFlow์— ์ ์šฉํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค:

import { applyRemoteSnapshot, getCalDAVMeta } from '@dayflow/caldav';

const delta = await applyRemoteSnapshot(
  calendar.app,
  { calendars, events },
  {
    // Identify events owned by this provider so stale ones are cleaned up
    isOwnedEvent: event => Boolean(getCalDAVMeta(event)),
    isOwnedCalendar: calendar => calendar.source === 'iCloud',
    snapshotMode: 'authoritative',

    // Optionally preserve local in-progress edits during optimistic sync
    resolveConflict: (remote, local) =>
      mergeLocalEditsOntoRemote(remote, local),
  }
);

console.log(delta.events.added, delta.events.updated, delta.events.deleted);

applyRemoteSnapshot์€ ๋“ค์–ด์˜จ ์Šค๋ƒ…์ˆ๊ณผ ํ˜„์žฌ ์•ฑ ์ƒํƒœ์˜ ์ฐจ์ด๋ฅผ ๊ณ„์‚ฐํ•œ ๋’ค, ๋™๊ธฐํ™” ๋ฃจํ”„๋ฅผ ๋ง‰๊ธฐ ์œ„ํ•ด source: 'remote'๋กœ ๋ณ€๊ฒฝ ์‚ฌํ•ญ์„ ์ ์šฉํ•ฉ๋‹ˆ๋‹ค. ๋ฐ˜ํ™˜๊ฐ’์€ ์ž‘์—…๋ณ„ ๊ฑด์ˆ˜๋ฅผ ๋‹ด์€ RemoteSnapshotDelta์ž…๋‹ˆ๋‹ค.

์Šค๋ƒ…์ˆ์€ ๊ธฐ๋ณธ์ ์œผ๋กœ ๋ถ€๋ถ„ ์Šค๋ƒ…์ˆ์œผ๋กœ ์ทจ๊ธ‰๋˜๋ฏ€๋กœ, ์Šค๋ƒ…์ˆ์— ์—†๋Š” ๋กœ์ปฌ ์†Œ์œ  ๋ ˆ์ฝ”๋“œ๋Š” ๋ณด์กด๋ฉ๋‹ˆ๋‹ค. snapshotMode: 'authoritative'๋Š” ์Šค๋ƒ…์ˆ์ด ์ œ๊ณต์ž๊ฐ€ ์†Œ์œ ํ•œ ๋ชจ๋“  ์บ˜๋ฆฐ๋”์™€ ์ด๋ฒคํŠธ๋ฅผ ์˜จ์ „ํžˆ ๋‹ด๊ณ  ์žˆ์„ ๋•Œ๋งŒ ์ „๋‹ฌํ•˜์„ธ์š”. ๋ฒ”์œ„๊ฐ€ ์ œํ•œ๋˜์—ˆ๊ฑฐ๋‚˜ ํ•„ํ„ฐ๋งยทํŽ˜์ด์ง€ ์ฒ˜๋ฆฌ๋œ ์‘๋‹ต์—๋Š” ๊ธฐ๋ณธ ๋ถ€๋ถ„ ๋ชจ๋“œ๋ฅผ ์œ ์ง€ํ•˜๊ฑฐ๋‚˜, deleteMissingEvents: false์™€ deleteMissingCalendars: false๋ฅผ ๋ช…์‹œ์ ์œผ๋กœ ์ง€์ •ํ•˜์„ธ์š”.

๋ฐฑ์—”๋“œ ํ”„๋ก์‹œ

CalDAV ์„œ๋ฒ„๋Š” ๋ธŒ๋ผ์šฐ์ € CORS๋ฅผ ์ง€์›ํ•˜์ง€ ์•Š์œผ๋ฏ€๋กœ ๋ธŒ๋ผ์šฐ์ €๊ฐ€ ์ง์ ‘ ํ˜ธ์ถœํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค. ๋‹ค์Œ ์—ญํ• ์„ ํ•˜๋Š” ๋ฐฑ์—”๋“œ ํ”„๋ก์‹œ๊ฐ€ ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค:

  1. DayFlow๊ฐ€ ๋ณด๋‚ธ ์š”์ฒญ(JSON์œผ๋กœ ๊ฐ์‹ผ CalDAV ์š”์ฒญ)์„ ์ˆ˜์‹ ํ•ฉ๋‹ˆ๋‹ค
  2. ์ž๊ฒฉ ์ฆ๋ช…(Basic ์ธ์ฆ, ํ† ํฐ ๋“ฑ)์„ ์ฃผ์ž…ํ•ฉ๋‹ˆ๋‹ค
  3. CalDAV ์„œ๋ฒ„๋กœ ์ „๋‹ฌํ•˜๊ณ  ์‘๋‹ต์„ ๋Œ๋ ค์ค๋‹ˆ๋‹ค
// proxy.mjs (minimal Node.js example)
import { createServer } from 'node:http';

const {
  CALDAV_BASE_URL, // e.g. https://caldav.icloud.com
  CALDAV_USERNAME,
  CALDAV_PASSWORD,
  PORT = '3001',
} = process.env;

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

const CORS_HEADERS = {
  'Access-Control-Allow-Origin': 'http://localhost:5173',
  'Access-Control-Allow-Methods': 'POST, OPTIONS',
  'Access-Control-Allow-Headers': 'Content-Type',
  'Access-Control-Expose-Headers': 'ETag, DAV',
};

createServer(async (req, res) => {
  if (req.method === 'OPTIONS') {
    res.writeHead(204, CORS_HEADERS);
    res.end();
    return;
  }

  const chunks = [];
  for await (const chunk of req) chunks.push(chunk);
  const { url, init } = JSON.parse(Buffer.concat(chunks).toString());

  if (!url.startsWith(CALDAV_BASE_URL)) {
    res.writeHead(403);
    res.end();
    return;
  }
  if (!ALLOWED_METHODS.has(init?.method ?? 'PROPFIND')) {
    res.writeHead(405);
    res.end();
    return;
  }

  const upstream = await fetch(url, {
    ...init,
    headers: {
      ...init?.headers,
      Authorization:
        'Basic ' +
        Buffer.from(`${CALDAV_USERNAME}:${CALDAV_PASSWORD}`).toString('base64'),
    },
  });

  const body = await upstream.text();
  res.writeHead(upstream.status, {
    'Content-Type': upstream.headers.get('Content-Type') ?? 'text/xml',
    ETag: upstream.headers.get('ETag') ?? '',
    ...CORS_HEADERS,
  });
  res.end(body);
}).listen(Number(PORT));

์ž๊ฒฉ ์ฆ๋ช…์„ ํ™˜๊ฒฝ ๋ณ€์ˆ˜์— ๋‹ด์•„ ํ”„๋ก์‹œ๋ฅผ ์‹คํ–‰ํ•˜์„ธ์š”:

CALDAV_BASE_URL=https://caldav.icloud.com \
CALDAV_USERNAME=alice@icloud.com \
CALDAV_PASSWORD=xxxx-xxxx-xxxx-xxxx \
node proxy.mjs

์˜ต์…˜ ๋ ˆํผ๋Ÿฐ์Šค

attachCalDAVToDayFlow ์˜ต์…˜

์˜ต์…˜ํƒ€์ž…๊ธฐ๋ณธ๊ฐ’์„ค๋ช…
writablebooleantrue๋กœ์ปฌ ๋ณ€๊ฒฝ ์‚ฌํ•ญ์„ CalDAV ์„œ๋ฒ„์— ๋‹ค์‹œ ์“ฐ๋„๋ก ํ—ˆ์šฉํ•ฉ๋‹ˆ๋‹ค.
refreshOnVisibleRangeChangebooleantrue์‚ฌ์šฉ์ž๊ฐ€ ์ƒˆ๋กœ์šด ๋‚ ์งœ ๋ฒ”์œ„๋กœ ์ด๋™ํ•˜๋ฉด ๋‹ค์‹œ ๋™๊ธฐํ™”ํ•ฉ๋‹ˆ๋‹ค.
maxConcurrentCalendarsnumber4๋™์‹œ์— ๋™๊ธฐํ™”ํ•  ์›๊ฒฉ ์บ˜๋ฆฐ๋”์˜ ์ตœ๋Œ€ ๊ฐœ์ˆ˜์ž…๋‹ˆ๋‹ค.
eventMode.recurring'read-only''read-only'๋ฐ˜๋ณต ์ด๋ฒคํŠธ๋Š” ์ฝ๊ธฐ ์ „์šฉ์ž…๋‹ˆ๋‹ค. 'read-only'๋งŒ ์ง€์›ํ•ฉ๋‹ˆ๋‹ค.
onError(error, context) => voidโ€”๋™๊ธฐํ™”๋‚˜ ์“ฐ๊ธฐ๊ฐ€ ์‹คํŒจํ•  ๋•Œ ํ˜ธ์ถœ๋ฉ๋‹ˆ๋‹ค.
getInitialSnapshot() => Promise<{ events, calendars }>โ€”์ฒซ ์›๊ฒฉ ๋™๊ธฐํ™” ์ „์— ๋กœ์ปฌ ์บ์‹œ๋กœ DayFlow๋ฅผ ์ฑ„์›๋‹ˆ๋‹ค. ์˜ค๋ฅ˜๋Š” onError๋กœ ์ „๋‹ฌ๋ฉ๋‹ˆ๋‹ค.
onSyncComplete(delta: CalDAVSyncDelta) => voidโ€”๋™๊ธฐํ™”๊ฐ€ ์„ฑ๊ณตํ•  ๋•Œ๋งˆ๋‹ค ๋ณ€๊ฒฝ ๊ฑด์ˆ˜์™€ ํ•จ๊ป˜ ํ˜ธ์ถœ๋ฉ๋‹ˆ๋‹ค.
onWriteComplete(operation, event) => voidโ€”๋กœ์ปฌ ๋ณ€๊ฒฝ์ด CalDAV ์„œ๋ฒ„์— ์„ฑ๊ณต์ ์œผ๋กœ ๋ฐ˜์˜๋œ ๋’ค ํ˜ธ์ถœ๋ฉ๋‹ˆ๋‹ค.
createEventId(input) => string๋„ค์ž„์ŠคํŽ˜์ด์Šค ์ ์šฉ์›๊ฒฉ CalDAV ์ด๋ฒคํŠธ์˜ DayFlow id๋ฅผ ๋งŒ๋“ญ๋‹ˆ๋‹ค. ์ถฉ๋Œ์„ ํ”ผํ•˜๊ธฐ ์œ„ํ•ด ๊ธฐ๋ณธ์ ์œผ๋กœ ์ œ๊ณต์ž ๋ฒ”์œ„์˜ id๋ฅผ ์‚ฌ์šฉํ•ฉ๋‹ˆ๋‹ค.

CalDAVErrorContext์—๋Š” operation('list-calendars' | 'initial-sync' | 'range-sync' | 'create' | 'update' | 'delete'), calendarId, eventId๊ฐ€ ๋“ค์–ด ์žˆ์Šต๋‹ˆ๋‹ค.

CalDAVSyncDelta:

type CalDAVSyncDelta = {
  calendars: { added: number; updated: number; deleted: number };
  events: { added: number; updated: number; deleted: number };
};

createCalDAVAdapter ์˜ต์…˜

์˜ต์…˜ํƒ€์ž…ํ•„์ˆ˜ ์—ฌ๋ถ€์„ค๋ช…
calendarHomeUrlstringโœ“CalDAV calendar home ์ปฌ๋ ‰์…˜์˜ URL์ž…๋‹ˆ๋‹ค.
fetchfunctionโœ“์ธ์ฆ์ด ์ ์šฉ๋œ fetch ํ•จ์ˆ˜์ž…๋‹ˆ๋‹ค. ๋ฐฑ์—”๋“œ ํ”„๋ก์‹œ๋ฅผ ๊ฑฐ์น˜๋„๋ก ๊ตฌ์„ฑํ•˜์„ธ์š”.

createCalDAVAdapterFromServer ์˜ต์…˜

๋งค๊ฐœ๋ณ€์ˆ˜ํƒ€์ž…์„ค๋ช…
serverUrlstringCalDAV ์„œ๋ฒ„์˜ ๋ฃจํŠธ URL์ž…๋‹ˆ๋‹ค(์˜ˆ: ICLOUD_CALDAV_SERVER).
optionsobjectcalendarHomeUrl์„ ์ œ์™ธํ•˜๋ฉด createCalDAVAdapter์˜ ์˜ต์…˜๊ณผ ๊ฐ™์Šต๋‹ˆ๋‹ค.

๋‚ด๋ถ€์ ์œผ๋กœ ํƒ์ƒ‰์„ ์ˆ˜ํ–‰ํ•˜๊ณ  ๋ฐ”๋กœ ์‚ฌ์šฉํ•  ์ˆ˜ ์žˆ๋Š” CalDAVAdapter๋ฅผ ๋ฐ˜ํ™˜ํ•ฉ๋‹ˆ๋‹ค.

์ปจํŠธ๋กค๋Ÿฌ API

// Discover remote calendars, load initial events, and subscribe to DayFlow changes
await controller.start();

// Unsubscribe all listeners (does not clear DayFlow state)
controller.stop();

// Re-sync all calendars using the last known visible range
await controller.refresh();

// Re-sync a specific calendar
await controller.refresh({ calendarId: 'my-calendar-id' });

// Re-sync with an explicit date range
await controller.refresh({
  range: { start: new Date('2025-01-01'), end: new Date('2025-02-01') },
});

// Get current sync status
const status = controller.getStatus();
// { state: 'idle' | 'syncing' | 'error', lastSyncedAt?: Date, error?: unknown }

์ €์žฅ์†Œ ์ธํ„ฐํŽ˜์ด์Šค

createCalDAVSync๋Š” ๋™๊ธฐํ™” ํ† ํฐ, ์บ˜๋ฆฐ๋” ctag, ETag๋ฅผ ํŽ˜์ด์ง€ ์ƒˆ๋กœ ๊ณ ์นจ ํ›„์—๋„ ์œ ์ง€ํ•˜๊ธฐ ์œ„ํ•œ ์„ ํƒ์  storage ๊ฐ์ฒด๋ฅผ ๋ฐ›์Šต๋‹ˆ๋‹ค. ์ œ๊ณตํ•˜์ง€ ์•Š์œผ๋ฉด ๋™๊ธฐํ™” ์ƒํƒœ๊ฐ€ ๋ฉ”๋ชจ๋ฆฌ์—๋งŒ ๋‚จ์Šต๋‹ˆ๋‹ค. ํ…Œ์ŠคํŠธ๋‚˜ ๋ฐ๋ชจ์—๋Š” ๊ทธ ๊ธฐ๋ณธ๊ฐ’์ด ํŽธ๋ฆฌํ•˜์ง€๋งŒ, ์šด์˜ ํ™˜๊ฒฝ์—์„œ๋Š” ์ฆ๋ถ„ ๋™๊ธฐํ™”์™€ ์กฐ๊ฑด๋ถ€ ์“ฐ๊ธฐ๊ฐ€ ์ƒˆ๋กœ ๊ณ ์นจ ํ›„์—๋„ ์ด์–ด์ง€๋„๋ก ์ง€์† ์ €์žฅ์†Œ๋ฅผ ์ œ๊ณตํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค.

import { createCalDAVSync, type CalDAVStorage } from '@dayflow/caldav';

const storage: CalDAVStorage = {
  getSyncToken: async calendarId =>
    localStorage.getItem(`sync:${calendarId}`) ?? null,
  setSyncToken: async (calendarId, token) => {
    if (token) localStorage.setItem(`sync:${calendarId}`, token);
    else localStorage.removeItem(`sync:${calendarId}`);
  },

  getCtag: async calendarId =>
    localStorage.getItem(`ctag:${calendarId}`) ?? null,
  setCtag: async (calendarId, ctag) =>
    localStorage.setItem(`ctag:${calendarId}`, ctag),

  getEtag: async href => localStorage.getItem(`etag:${href}`) ?? null,
  setEtag: async (href, etag) => localStorage.setItem(`etag:${href}`, etag),
  deleteEtag: async href => localStorage.removeItem(`etag:${href}`),

  getEventState: async id =>
    JSON.parse(localStorage.getItem(`event:${id}`) ?? 'null'),
  setEventState: async (id, state) =>
    localStorage.setItem(`event:${id}`, JSON.stringify(state)),
  deleteEventState: async id => localStorage.removeItem(`event:${id}`),

  clearCalendar: async calendarId => {
    for (const key of Object.keys(localStorage)) {
      if (
        key.startsWith(`sync:${calendarId}`) ||
        key.startsWith(`ctag:${calendarId}`)
      ) {
        localStorage.removeItem(key);
      }
    }
  },
};

const sync = createCalDAVSync({
  adapter,
  storage,
  // Optional: split broad visible-range REPORTs into smaller windows.
  rangeChunkDays: 31,
});

์บ˜๋ฆฐ๋”์˜ ctag ๊ฐ’์„ ์‚ฌ์šฉํ•  ์ˆ˜ ์žˆ์œผ๋ฉด, ๋งˆ์ง€๋ง‰์œผ๋กœ ๋๋‚œ ๋™๊ธฐํ™” ์ดํ›„ ์ปฌ๋ ‰์…˜์ด ๋ฐ”๋€Œ์ง€ ์•Š์€ ๊ฒฝ์šฐ ์ปฌ๋ ‰์…˜ ์ „์ฒด ๋™๊ธฐํ™”๊ฐ€ ๋„คํŠธ์›Œํฌ ์ž‘์—…์„ ๊ฑด๋„ˆ๋›ธ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. ํ‘œ์‹œ ๋ฒ”์œ„ ๋™๊ธฐํ™”๋Š” ์š”์ฒญํ•œ ๋ฒ”์œ„๋ฅผ ํ•ญ์ƒ ์กฐํšŒํ•˜๋ฏ€๋กœ, ์ƒˆ ๋ฒ”์œ„๋กœ ์ด๋™ํ•˜๋ฉด ์•„์ง ๋ณด์ง€ ๋ชปํ•œ ์ด๋ฒคํŠธ๋ฅผ ๋ถˆ๋Ÿฌ์˜ฌ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

์ด๋ฒคํŠธ ์‹๋ณ„์ž

DayFlow ๋ฐ”์ธ๋”ฉ์€ ๊ธฐ๋ณธ์ ์œผ๋กœ ์›๊ฒฉ CalDAV ์ด๋ฒคํŠธ์— ์ œ๊ณต์ž ๋ฒ”์œ„์˜ id๋ฅผ ์‚ฌ์šฉํ•ฉ๋‹ˆ๋‹ค:

import { createNamespacedCalDAVEventId } from '@dayflow/caldav';

const controller = attachCalDAVToDayFlow(calendar.app, sync, {
  createEventId: createNamespacedCalDAVEventId,
});

๋งคํผ๋ฅผ ์ง์ ‘ ํ˜ธ์ถœํ•˜๋ฉด, ํŒฉํ† ๋ฆฌ๋ฅผ ์ „๋‹ฌํ•˜์ง€ ์•Š๋Š” ํ•œ UID๋ฅผ id๋กœ ์“ฐ๋Š” ๊ธฐ์กด ๋ฐฉ์‹์ด ์œ ์ง€๋ฉ๋‹ˆ๋‹ค:

const event = mapCalDAVEventToDayFlow(data, {
  createEventId: createNamespacedCalDAVEventId,
});

๋•๋ถ„์— ๋กœ์ปฌ ์ด๋ฒคํŠธ๋‚˜ ๋‹ค๋ฅธ ์ œ๊ณต์ž์™€ id๊ฐ€ ์ถฉ๋Œํ•˜์ง€ ์•Š์œผ๋ฉด์„œ๋„, ๋™๊ธฐํ™” ์ค‘์—๋Š” CalDAV ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ๋กœ ๊ธฐ์กด ์ด๋ฒคํŠธ๋ฅผ ์ •ํ™•ํžˆ ๋Œ€์กฐํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

์ด ํŽ˜์ด์ง€์˜ ๋‚ด์šฉ