carddav

← All adapters

carddav(options?: CardDAVReadOptions): Source<VCardBody> & Destination<unknown, VCardBody[]>
carddav(options: CardDAVWriteOptions): Destination<VCardBody, CardDAVWriteResult>
carddav(options: CardDAVDeleteOptions): Destination<unknown, CardDAVDeleteResult>

Read and write contacts over CardDAV. Defaults to Apple iCloud Contacts (https://contacts.icloud.com) but works with any CardDAV server (Fastmail, Nextcloud, Google). The role is chosen by an action flag, the same way the mail adapter selects its mode: no action reads, action writes or deletes.

The body is a plain VCardBody (a version plus a property list), not a typed contact object. Wrap it in a VCard for ergonomic reads and edits, then read .data to put the plain body back, exactly like working with parsed JSON from an HTTP endpoint. DAV identity (url/uid/etag) lives on the exchange headers (routecraft.carddav.*), not the body, the same way the mail adapter carries its envelope. Reading is lossless, so a read-modify-write keeps everything you did not change.

Requires the optional peer tsdav (DAV client): bun add tsdav. A missing peer raises RC5017 with an install hint.

Credentials live in context config as named accounts. For iCloud, username is your Apple ID and appPassword is an app-specific password (not your account password).

import { defineConfig } from '@routecraft/routecraft'

export default defineConfig({
  carddav: {
    accounts: {
      default: {
        username: process.env.ICLOUD_ID!,
        appPassword: process.env.ICLOUD_APP_PW!,
      },
      work: {
        username: 'me@work.com',
        appPassword: process.env.WORK_APP_PW!,
        serverUrl: 'https://dav.fastmail.com', // per-account override
        addressBook: 'Colleagues',             // per-account default book
      },
    },
    serverUrl: 'https://contacts.icloud.com',   // global default
    addressBook: 'Card',                        // global default book
  },
})

Read (.from()): no action. Emits one VCardBody per address-book entry. This is a one-shot fetch-all; pair it with a scheduler for periodic reads.

craft()
  .id('contacts-export')
  .from(carddav())
  .transform((body) => {
    const card = VCard.wrap(body)
    return { name: card.text('FN'), email: card.text('EMAIL') }
  })
  .to(log())

craft().from(carddav({ account: 'work', addressBook: 'Colleagues', limit: 500 })).to(...)

Read (.enrich()): no action. Fetches all contacts and merges them onto the triggering exchange (the default aggregator spreads the array onto the body with numeric keys, as with mail; pass replace() for a VCardBody[] body).

craft()
  .from(cron('0 2 * * *'))
  .enrich(carddav())
  .to(writeCsv('contacts.csv'))

Write (.to()): a write serializes the whole body and replaces the card; it does not merge. Because reading is lossless, a read-modify-write keeps every property you did not touch, and removing a property removes it from the card, exactly like an UPDATE of a database row. action: 'save' upserts: it writes to the routecraft.carddav.url header when present, otherwise creates. 'create' always inserts (injecting a UID if absent). 'update' writes to that url header and raises RC5014 if none is resolvable, so read the card first (the read sets the url/etag headers). Update and delete send the read-time routecraft.carddav.etag header as an If-Match precondition, so a concurrent change on the server surfaces as a non-retryable conflict (RC5030) instead of silently overwriting.

// Read a card, edit one property, write it back. Everything else is preserved.
craft()
  .id('add-birthday')
  .from(carddav())
  .transform((body) => VCard.wrap(body).set('BDAY', '1990-05-21').data)
  .to(carddav({ action: 'update' }))

Delete (.to()): action: 'delete' removes the contact resolved from the read headers (routecraft.carddav.url/uid), the body's UID, or a custom target extractor. Returns CardDAVDeleteResult. No match raises RC5014.

craft()
  .from(carddav())
  .filter((body) => isStale(VCard.wrap(body)))
  .to(carddav({ action: 'delete' }))   // url comes from the read headers

// Or resolve the target explicitly:
.to(carddav({ action: 'delete', target: (ex) => ({ url: myUrlFor(ex) }) }))

Options:

FieldTypeDescription
accountstring?Named account from context config (default account if omitted)
addressBookstring?Address book display name (account/context default, else the first book)
action'save' | 'create' | 'update' | 'delete'?Destination role. Absent = read (.from/.enrich)
limitnumber?Read only: maximum number of contacts
target(ex) => { url?, uid? }?Write/delete: resolve the target when the body lacks uid/url
descriptionstring?Human-readable description for route discovery
keywordsstring[]?Keywords for route discovery

The VCard document

The body is a plain VCardBody: a version and an ordered list of properties ({ name, group?, params, value }, where value is the escaped wire form). It is just data, so it survives structuredClone, JSON.stringify, queues, and tap with nothing lost. There is no typed Contact projection; because the body is the protocol, a read never silently drops data, and a write persists exactly what you hand back. Line order, parameter-name casing, and escaping in the output are canonical, not byte-identical to the input, but nothing is lost.

Wrap a body in a VCard for ergonomic reads and edits. The wrapper edits the underlying data in place; .data gives the plain body back.

import { VCard } from '@routecraft/routecraft'

const card = VCard.parse(rawVCardString)   // a wrapper; .data is the plain body
//   VCard.wrap(body)    — wrap a body the source emitted
//   VCard.create()      — start a fresh, empty card

card.text('FN')                 // "Jane Q Doe"  (decoded value of the first FN)
card.uid                        // "ABC-123"     (= text('UID'))
card.get('TEL')                 // every TEL property (views)
card.first('EMAIL')?.param('type')          // first TYPE param value
card.first('N')?.components()   // ['Doe','Jane','Q','','']  (structured value split)

card.set('NOTE', 'synced from CRM')         // replace all NOTE with one
card.add('TEL', '+15551234567', { params: [{ name: 'type', value: 'work' }] })
card.remove('X-CUSTOM-FIELD')   // drop a property entirely

card.data                       // the plain VCardBody to put on the exchange
card.toString()                 // serialize to wire form

VCard (wrapper)

MemberTypeDescription
VCard.wrap(body)VCardWrap a plain body (edits write through)
VCard.create(version?)VCardWrapper over a fresh, empty body
VCard.parse(raw) / parseVCard(raw)VCardParse a single card (throws on a collection)
VCard.serialize(body)stringSerialize a plain body
dataVCardBodyThe underlying plain body
versionstringvCard version (default "3.0")
uidstring? (get/set)Shortcut for UID
get(name) / first(name)VCardProperty[] / VCardProperty?Lookup by name (case-insensitive)
text(name) / values(name)string? / string[]Decoded value(s) of a property
set / add / removethisReplace-all / append / delete by name
clone()VCardDeep, independent copy
toString()stringSerialize .data

VCardProperty (a view over one property) { name, group?, params, value, raw, components(sep?), setComponents(parts, sep?), param(name) }value is the decoded text (escapes resolved); raw is the escaped wire value; components() splits a structured value (N, ADR, ORG) on unescaped separators. params is { name, value }[], preserved verbatim.

Bring your own type. If you want a typed shape, derive it in a .transform() and validate with your schema of choice, the same way you would with JSON from an HTTP endpoint:

.from(carddav())
.transform((body) => {
  const card = VCard.wrap(body)
  return { uid: card.uid, name: card.text('FN'), emails: card.values('EMAIL') }
})

Exchange headers on read: routecraft.carddav.url, routecraft.carddav.uid, routecraft.carddav.etag, routecraft.carddav.account. These carry the DAV identity used to target updates and deletes.

Known names: VCARD and VPARAM are convenience constants for the standard vCard property and parameter names (e.g. card.text(VCARD.FN)), with KnownProperty / KnownParam union types. They are values for autocomplete and typo-safety, not a constraint: every method still accepts an arbitrary string, so any property works.

Exports: VCard, VCardProperty, parseVCard, VCARD, VPARAM (values); VCardBody, VCardPropertyData, CardDAVOptions, CardDAVReadOptions, CardDAVWriteOptions, CardDAVDeleteOptions, CardDAVContextConfig, CardDAVAccountConfig, CardDAVAction, CardDAVTargetExtractor, CardDAVWriteResult, CardDAVDeleteResult, VCardParam, VCardPropertyOptions, KnownProperty, KnownParam, CardDAVClientManager, CARDDAV_CLIENT_MANAGER (types).