sdxc

Type to search, or start from one of these:

@sdxc/icalendar

Read and write iCalendar documents, with recurrence rules and time zones

npm add @sdxc/icalendar
pnpm add @sdxc/icalendar
yarn add @sdxc/icalendar
bun add @sdxc/icalendar
Used by
uptime

Read and write iCalendar documents, with recurrence rules and time zones.

Installation

npm add @sdxc/icalendar

Fallible functions return a Result from @sdxc/result, and zone conversion comes from @sdxc/dates; both install alongside this package.

RFC 5545 iCalendar is the format every calendar client subscribes to and imports. Events, alarms and time zones are typed over a generic component and property layer, so VTODO, VJOURNAL, VFREEBUSY and X- properties survive a parse and stringify round trip untouched. The reader is lenient and the writer strict: structural errors fail with the line they start on, while a missing UID or a value that does not parse becomes a warning and the property is kept verbatim. The writer folds at 75 octets without splitting a UTF-8 character, escapes TEXT, caret-encodes parameters (RFC 6868), and always writes VERSION:2.0.

ImportWhat it holds
@sdxc/icalendarparse, parseAll, stringify, toInstant, utc, calendarResponse, the ICalendar types
@sdxc/icalendar/rruleparseRecurrence, stringifyRecurrence, occurrences
@sdxc/icalendar/timezonevtimezone, a VTIMEZONE built from Intl
@sdxc/icalendar/itiprequest, cancel, reply, readReply, nextSequence, calendarPart (RFC 5546)

A repeated local hour resolves to its first instant and a skipped one is read with the offset before the gap, as RFC 5545 §3.3.5 prescribes.

Usage

Write A Feed

UTC date-times need no VTIMEZONE, so a feed of one-off events is just events.

import { calendarResponse, utc } from "@sdxc/icalendar";

return calendarResponse({
	productId: "-//example//status//EN",
	name: "Example status: maintenance",
	url: "https://status.example.com/",
	refreshInterval: { hours: 1 },
	timeZones: [],
	events: [
		{
			uid: "42@status.example.com",
			dtstamp: new Date(row.updatedAt),
			start: utc(row.startsAt),
			end: utc(row.endsAt),
			summary: "Database upgrade",
			sequence: 3,
			properties: [],
		},
	],
	components: [],
	properties: [],
});
BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//example//status//EN
NAME:Example status: maintenance
X-WR-CALNAME:Example status: maintenance
URL:https://status.example.com/
REFRESH-INTERVAL;VALUE=DURATION:PT1H
X-PUBLISHED-TTL:PT1H
BEGIN:VEVENT
UID:42@status.example.com
DTSTAMP:20260920T081500Z
DTSTART:20260923T100000Z
DTEND:20260923T120000Z
SUMMARY:Database upgrade
SEQUENCE:3
END:VEVENT
END:VCALENDAR

Read A Calendar

import { parse, toInstant } from "@sdxc/icalendar";
import { isFailure } from "@sdxc/result";

let parsed = parse(await response.text());
if (isFailure(parsed)) return parsed; // ICalendarParseError with .line

let { calendar, warnings } = parsed.data;
for (let event of calendar.events) {
	let start = toInstant(event.start, calendar); // null for DATE or floating values
}

Expand A Recurrence

import { occurrences } from "@sdxc/icalendar/rrule";

let result = occurrences(event, {
	from: Date.now(),
	to: Date.now() + 30 * 86_400_000,
	calendar, // resolves TZIDs through its VTIMEZONEs
});
// { status: "success", data: [{ start, end }, ...] }

Write A Zoned Recurring Event

A rule whose wall clock follows a zone needs TZID plus a VTIMEZONE, or it drifts by an hour across DST.

import { stringify } from "@sdxc/icalendar";
import { vtimezone } from "@sdxc/icalendar/timezone";
import { unwrap } from "@sdxc/result";

let zone = unwrap(
	vtimezone("Europe/Madrid", { from: Date.UTC(2026, 0, 1), to: Date.UTC(2028, 0, 1) }),
);

stringify({
	productId: "-//example//team//EN",
	timeZones: [zone],
	events: [
		{
			uid: "standup@example.com",
			dtstamp: new Date(),
			start: {
				type: "date-time",
				wall: { year: 2026, month: 1, day: 5, hour: 9, minute: 30, second: 0 },
				zone: { tzid: "Europe/Madrid" },
			},
			duration: { minutes: 15 },
			recurrence: { frequency: "WEEKLY", byDay: [{ weekday: "MO" }, { weekday: "WE" }] },
			properties: [],
		},
	],
	components: [],
	properties: [],
});

API

@sdxc/icalendar

parse(source: string): Result<ICalendar.Parsed, ICalendarParseError>

Reads the first VCALENDAR. CRLF, LF and CR line endings are accepted and a byte order mark is skipped. Returns the typed calendar and warnings, each prefixed with its line ("Line 2: VEVENT has no DTSTAMP").

What the reader types, and what it keeps verbatim:

InputResult
VEVENT with a readable DTSTARTcalendar.events
VEVENT without onecalendar.components, with a warning unless METHOD lets it go¹
VTIMEZONE with a TZID and readable observancescalendar.timeZones
any other componentcalendar.components
a property the model does not namethe owner's properties
a known property whose value does not parse (STATUS:MAYBE)the owner's properties, with a warning
missing UID / DTSTAMPuid: "" / an invalid dtstamp, with a warning; written back missing
ATTENDEE/ORGANIZER parameters beyond CN, ROLE, PARTSTAT, RSVPthe user's parameters
X-WR-CALNAME, X-WR-CALDESC, X-PUBLISHED-TTLname, description, refreshInterval when RFC 7986's are absent

¹ RFC 5546 lets a REPLY, CANCEL, REFRESH or DECLINECOUNTER identify its event by UID alone; readReply reads such an event from components.

Typed text properties keep only the parameters their field models; SUMMARY;LANGUAGE=en reads as its text.

parseAll(source: string): Result<ICalendar.Parsed[], ICalendarParseError>

Every VCALENDAR in the text, for files that concatenate several.

stringify(calendar: ICalendar.Calendar): string

CRLF lines folded at 75 octets, VERSION and PRODID first, then the RFC 7986 fields each followed by its X-WR-/X-PUBLISHED-TTL alias, the calendar's untyped properties, the time zones, the events and the untyped components. Every TZID a date-time uses needs its VTIMEZONE in timeZones. An event with both end and duration writes DTEND. RDATE/EXDATE values sharing their parameters share a line.

toInstant(value: ICalendar.DateValue, calendar?: ICalendar.Calendar): number | null

The instant a DATE-TIME names. A TZID resolves through the calendar's VTIMEZONE of that name first, expanding its observance rules, and through Intl second. A DATE, a floating time, or a TZID neither resolves gives null.

utc(instant: number | Date): ICalendar.DateValue

A UTC DATE-TIME, whole seconds.

calendarResponse(calendar, init?: ResponseInit & { filename?: string }): Response

Content-Type: text/calendar; charset=utf-8, plus ; method=REQUEST (or whatever calendar.method is). filename adds Content-Disposition: attachment, with an RFC 8187 filename* for names outside ASCII. Headers in init are kept and win.

escapeText(text: string): string, unescapeText(value: string): string

TEXT escaping (\\, \;, \,, \n; \N accepted on read), for untyped Property values, which are stored as written.

MEDIA_TYPE

"text/calendar".

ICalendarParseError

The structural failure (an unmatched BEGIN/END, a content line without :, no VCALENDAR), with the 1-based physical line it starts on.

ICalendar types

TypeShape
CalendarproductId, method?, name?, description?, url?, refreshInterval?, timeZones, events, components, properties
Eventuid, dtstamp, start, end? or duration?, text fields, status?, transparency?, sequence?, recurrence?, recurrenceDates?, exceptionDates?, recurrenceId?, organizer?, attendees?, alarms?, properties
DateValue{ type: "date", year, month, day } or { type: "date-time", wall, zone: "utc" | "floating" | { tzid } }
Period{ type: "period", start, end?, duration? }, an RDATE;VALUE=PERIOD
Durationnegative?, weeks?, days? (nominal), hours?, minutes?, seconds? (exact)
RecurrenceRulefrequency, interval?, count? or until?, bySecond? … bySetPosition?, weekStart?
Alarmaction, trigger: { before, related? } | { at }, description?, summary?, attendees?, repeat?, properties?
TimeZone, Observancetzid, observances with kind, local start, offsetFrom/offsetTo in minutes, name?, recurrence?, recurrenceDates?
Component, Propertythe generic layer: name, properties, components; name, parameters, value as written

An alarm's trigger.before is the time before the start (or end): { minutes: 15 } is written TRIGGER:-PT15M, and a negative one fires after.

@sdxc/icalendar/rrule

parseRecurrence(value: string): Result<ICalendar.RecurrenceRule, RecurrenceRuleError>

Reads an RRULE value, case-insensitively. An unknown or repeated part, an out-of-range number, or COUNT beside UNTIL fails.

RecurrenceRuleError

An RRULE that does not parse, carries out-of-range parts, or cannot be expanded.

stringifyRecurrence(rule: ICalendar.RecurrenceRule): string

FREQ, INTERVAL, COUNT/UNTIL, BYSECOND through BYSETPOS, WKST.

occurrences(event, options): Result<{ start: number; end: number }[], RecurrenceRuleError>

The occurrences overlapping [from, to), earliest first, at most limit (default 1,000): DTSTART, which RFC 5545 counts as the first instance, the rule's instances, and the RDATEs, minus every EXDATE. Each occurrence lasts as long as the event: a DTEND gives an exact length, a DURATION a nominal one (a day across DST keeps its local time), and neither gives a DATE one day.

OccurrenceOptions is the options type:

OptionMeaning
fromepoch ms, inclusive; occurrences still running then are included
toepoch ms, exclusive
limitmost occurrences returned, 1_000 by default
calendarresolves TZIDs through its VTIMEZONEs before Intl
timeZonethe IANA zone floating times and DATEs are read in, "UTC" by default

Expansion jumps straight to the window for rules without COUNT, stops at to, UNTIL, COUNT or limit, and fails rather than walking more than 500,000 periods. It fails as well for a TZID nothing resolves and for a typed rule with out-of-range parts.

@sdxc/icalendar/timezone

vtimezone(tzid: string, span: { from: number; to: number }): Result<ICalendar.TimeZone, TimeZoneError>

A VTIMEZONE for an IANA zone: the transition in force when the span starts, then one observance per offset change until it ends, each with an explicit onset and no rule. DAYLIGHT marks an offset above the year's standard one, in either hemisphere; names come from Intl (EST, GMT+9). Past the span the last observance's offset holds, so the span should cover every date-time the calendar writes in that zone.

TimeZoneError

A zone Intl does not know, or an empty span.

@sdxc/icalendar/itip

iTIP (RFC 5546) scheduling messages for one event. Each builder returns a calendar with its METHOD set, ready for stringify or calendarPart, or an ITipError naming the constraint the event breaks.

ITipError

An event that cannot carry the method asked of it, or a message that is not a REPLY.

request(event, options: ITip.Options): Result<ICalendar.Calendar, ITipError>

METHOD:REQUEST, inviting the attendees or sending them a revision. The event needs a UID, an organizer and at least one attendee, and cannot be CANCELLED. An attendee with neither participation nor rsvp gets PARTSTAT=NEEDS-ACTION and RSVP=TRUE (FALSE for a NON-PARTICIPANT); a missing SUMMARY is written empty, as the method requires. SEQUENCE is sent as given; derive it with nextSequence.

cancel(event, options: ITip.CancelOptions): Result<ICalendar.Calendar, ITipError>

METHOD:CANCEL, with SEQUENCE one past the event's. Without options.attendees it cancels the whole event for every attendee with STATUS:CANCELLED; with them it uninvites just those addresses (compared case-insensitively, mailto: optional) and writes no status. Attendees lose PARTSTAT and RSVP, and alarms are dropped.

reply(event, options: ITip.ReplyOptions): Result<ICalendar.Calendar, ITipError>

METHOD:REPLY from options.attendee with options.participation, echoing the request's UID, SEQUENCE and RECURRENCE-ID unchanged. The attendee keeps the name and parameters the request gave them; an address the request did not invite replies as itself. options.comment becomes COMMENT.

readReply(source: string | ICalendar.Calendar): Result<ITip.Reply[], ITipError>

One { uid, sequence, dtstamp, recurrenceId?, organizer?, attendee } per VEVENT of a METHOD:REPLY, typed or without DTSTART. A missing SEQUENCE reads as 0. Text that does not parse (the ICalendarParseError is the cause), another method, no VEVENT, or a VEVENT without exactly one ATTENDEE or without a UID fail.

nextSequence(previous: ICalendar.Event, next: ICalendar.Event): number

The §2.1.4 rule: one more than previous.sequence when start, end, duration, recurrence, recurrenceDates, exceptionDates or status changed by value, the same otherwise. A change the organizer judges significant too, like a new location far away, is theirs to bump.

calendarPart(calendar, options?: { filename?: string }): ITip.CalendarPart

{ method, content, filename? } for a mailer: the calendar's method (PUBLISH when unset) and its text. The shape is what @sdxc/mail's calendar option takes, written as a text/calendar; method=… alternative part.

TypeShape
ITip.OptionsproductId, dtstamp? (now by default), timeZones?
ITip.CancelOptionsOptions plus attendees?, the addresses to uninvite
ITip.ReplyOptionsOptions plus attendee, participation, comment?
ITip.Replyuid, sequence, dtstamp, recurrenceId?, organizer?, attendee
ITip.CalendarPartmethod, content, filename?

Pattern: Invite, Update And Cancel By Mail

import { utc } from "@sdxc/icalendar";
import { calendarPart, cancel, nextSequence, request } from "@sdxc/icalendar/itip";
import { unwrap } from "@sdxc/result";

let productId = "-//example//ops//EN";
let invitation = unwrap(request(event, { productId }));
await mailer.send({
	to,
	subject,
	html,
	calendar: calendarPart(invitation, { filename: "invite.ics" }),
});

let moved = { ...event, start: utc(newStart), end: utc(newEnd) };
moved.sequence = nextSequence(event, moved);
await mailer.send({
	to,
	subject,
	html,
	calendar: calendarPart(unwrap(request(moved, { productId }))),
});

await mailer.send({
	to,
	subject,
	html,
	calendar: calendarPart(unwrap(cancel(moved, { productId }))),
});

The recipient's client matches the three messages by UID, and the higher SEQUENCE of the move and the cancellation replaces what it holds.

Pattern: Record An Attendee's Answer

import { readReply } from "@sdxc/icalendar/itip";
import { isFailure } from "@sdxc/result";

let replies = readReply(await attachment.text());
if (isFailure(replies)) return replies;
for (let { uid, sequence, attendee } of replies.data) {
	await saveAnswer(uid, attendee.address, attendee.participation, sequence);
}

A reply to an older SEQUENCE answers a revision the attendee has since been sent again; compare it with the current one before trusting it.

Pattern: A Maintenance Feed With Stable Revisions

import { calendarResponse, utc } from "@sdxc/icalendar";

let events = windows.map((window) => ({
	uid: `${window.id}@status`,
	dtstamp: new Date(window.updatedAt),
	lastModified: new Date(window.updatedAt),
	sequence: Math.floor((window.updatedAt - window.createdAt) / 1000),
	start: utc(window.startsAt),
	end: utc(window.endedEarlyAt ?? window.endsAt),
	summary: window.name,
	properties: [],
}));

return calendarResponse({
	productId: "-//example//status//EN",
	timeZones: [],
	events,
	components: [],
	properties: [],
});

SEQUENCE comes from something that only grows (seconds between creation and the last edit), so clients replace their copy on every change. A single-window download is the same calendar with one event and { filename: "maintenance.ics" }; the shared UID makes a client holding both show one event. UTC needs no VTIMEZONE; reach for TZID only when a recurrence must follow a zone's wall clock.

Pattern: "Last Day Of The Month" Recurrences

BYMONTHDAY=31 skips short months. The clamp is a set position over the candidate days:

let rule = { frequency: "MONTHLY", byMonthDay: [28, 29, 30, 31], bySetPosition: [-1] } as const;

Pattern: Is Anything Active Now

import { occurrences } from "@sdxc/icalendar/rrule";
import { isSuccess } from "@sdxc/result";

let now = Date.now();
let result = occurrences(event, { from: now, to: now + 1, limit: 1 });
let active = isSuccess(result) && result.data.length > 0;