carddav
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:
| Field | Type | Description |
|---|---|---|
account | string? | Named account from context config (default account if omitted) |
addressBook | string? | Address book display name (account/context default, else the first book) |
action | 'save' | 'create' | 'update' | 'delete'? | Destination role. Absent = read (.from/.enrich) |
limit | number? | Read only: maximum number of contacts |
target | (ex) => { url?, uid? }? | Write/delete: resolve the target when the body lacks uid/url |
description | string? | Human-readable description for route discovery |
keywords | string[]? | 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)
| Member | Type | Description |
|---|---|---|
VCard.wrap(body) | VCard | Wrap a plain body (edits write through) |
VCard.create(version?) | VCard | Wrapper over a fresh, empty body |
VCard.parse(raw) / parseVCard(raw) | VCard | Parse a single card (throws on a collection) |
VCard.serialize(body) | string | Serialize a plain body |
data | VCardBody | The underlying plain body |
version | string | vCard version (default "3.0") |
uid | string? (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 / remove | this | Replace-all / append / delete by name |
clone() | VCard | Deep, independent copy |
toString() | string | Serialize .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).