@remit/calendar-service 0.0.11 → 0.0.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,420 @@
1
+ import { createHash } from "node:crypto";
2
+ import type {
3
+ CalendarCollectionItem,
4
+ ICalendarUnitOfWork,
5
+ } from "@remit/data-ports";
6
+ import ICAL from "ical.js";
7
+ import { type CalendarResult, calendarFailure } from "./errors.js";
8
+ import { readCalendarComponent, serializeCalendar } from "./parse.js";
9
+ import { deleteCalendarObject, putCalendarObject } from "./put.js";
10
+
11
+ export const CALENDAR_SUBSCRIPTION_TIMEOUT_MS = 30_000;
12
+
13
+ export const CALENDAR_SUBSCRIPTION_MAX_BYTES = 10 * 1024 * 1024;
14
+
15
+ const SUBSCRIPTION_ERROR_MAX_LENGTH = 512;
16
+
17
+ const SCHEMES: Record<string, string> = {
18
+ "http:": "http:",
19
+ "https:": "https:",
20
+ "webcal:": "https:",
21
+ "webcals:": "https:",
22
+ };
23
+
24
+ export type CalendarFeedFetcher = (
25
+ url: string,
26
+ init: RequestInit,
27
+ ) => Promise<Response>;
28
+
29
+ export type CalendarFeedFetch =
30
+ | { ok: true; value: string }
31
+ | { ok: false; reason: string };
32
+
33
+ export interface CalendarFeedResource {
34
+ uid: string;
35
+ resourceName: string;
36
+ icalData: string;
37
+ }
38
+
39
+ export interface CalendarFeedSplit {
40
+ resources: CalendarFeedResource[];
41
+ unreadable: number;
42
+ }
43
+
44
+ export interface CalendarFeedWrite {
45
+ /** The subscription was paused while its feed was being read, so nothing was written. */
46
+ paused: boolean;
47
+ written: number;
48
+ unchanged: number;
49
+ removed: number;
50
+ skipped: number;
51
+ }
52
+
53
+ export interface CalendarSubscriptionTarget {
54
+ accountConfigId: string;
55
+ calendarId: string;
56
+ }
57
+
58
+ export type CalendarSubscriptionRefresh =
59
+ | { ok: true; value: CalendarFeedWrite }
60
+ | { ok: false; reason: string };
61
+
62
+ /**
63
+ * The URL a subscription is fetched from. `webcal:` is what calendar products
64
+ * hand out for "subscribe", and means the same address over HTTPS.
65
+ */
66
+ export const readSubscriptionUrl = (raw: string): CalendarResult<string> => {
67
+ const trimmed = raw.trim();
68
+ if (!URL.canParse(trimmed)) {
69
+ return calendarFailure(
70
+ "InvalidSubscriptionUrl",
71
+ "the subscription address is not a URL",
72
+ );
73
+ }
74
+ const url = new URL(trimmed);
75
+ const scheme = SCHEMES[url.protocol];
76
+ if (!scheme) {
77
+ return calendarFailure(
78
+ "InvalidSubscriptionUrl",
79
+ `a subscription address starts with http, https or webcal, not ${url.protocol.replace(/:$/, "")}`,
80
+ );
81
+ }
82
+ if (url.username !== "" || url.password !== "") {
83
+ return calendarFailure(
84
+ "InvalidSubscriptionUrl",
85
+ "a subscription address carries no user name or password",
86
+ );
87
+ }
88
+ const address = new URL(`${scheme}${trimmed.slice(url.protocol.length)}`);
89
+ return { ok: true, value: address.toString() };
90
+ };
91
+
92
+ const truncate = (reason: string): string =>
93
+ reason.length <= SUBSCRIPTION_ERROR_MAX_LENGTH
94
+ ? reason
95
+ : `${reason.slice(0, SUBSCRIPTION_ERROR_MAX_LENGTH - 1)}…`;
96
+
97
+ const unreachableReason = (error: unknown): string => {
98
+ if (error instanceof Error && error.name === "TimeoutError") {
99
+ return `the feed did not answer within ${CALENDAR_SUBSCRIPTION_TIMEOUT_MS / 1000} seconds`;
100
+ }
101
+ const cause =
102
+ error instanceof Error && error.cause instanceof Error
103
+ ? error.cause
104
+ : undefined;
105
+ const code =
106
+ cause && "code" in cause && typeof cause.code === "string"
107
+ ? cause.code
108
+ : undefined;
109
+ return code
110
+ ? `the feed could not be reached (${code})`
111
+ : "the feed could not be reached";
112
+ };
113
+
114
+ /**
115
+ * Reads a subscription's feed. Every failure is a returned reason that a
116
+ * person can act on and that never repeats the URL: for a Google secret
117
+ * address the URL is the whole credential, and the reason is stored on the
118
+ * collection and drawn in the calendar view.
119
+ */
120
+ export const fetchCalendarFeed = async (
121
+ url: string,
122
+ fetcher: CalendarFeedFetcher = fetch,
123
+ ): Promise<CalendarFeedFetch> => {
124
+ const response = await fetcher(url, {
125
+ headers: { accept: "text/calendar, */*;q=0.5" },
126
+ redirect: "follow",
127
+ signal: AbortSignal.timeout(CALENDAR_SUBSCRIPTION_TIMEOUT_MS),
128
+ }).then(
129
+ (value) => ({ ok: true, value }) as const,
130
+ (error: unknown) =>
131
+ ({ ok: false, reason: unreachableReason(error) }) as const,
132
+ );
133
+ if (!response.ok) return response;
134
+
135
+ const { status } = response.value;
136
+ if (status < 200 || status > 299) {
137
+ await response.value.body?.cancel();
138
+ return { ok: false, reason: `the feed answered HTTP ${status}` };
139
+ }
140
+
141
+ const declared = Number(response.value.headers.get("content-length") ?? "0");
142
+ if (declared > CALENDAR_SUBSCRIPTION_MAX_BYTES) {
143
+ await response.value.body?.cancel();
144
+ return { ok: false, reason: TOO_LARGE };
145
+ }
146
+
147
+ const { body } = response.value;
148
+ if (!body) return { ok: true, value: "" };
149
+ return readCapped(body).then(
150
+ (read) => read,
151
+ (error: unknown) => ({ ok: false, reason: unreachableReason(error) }),
152
+ );
153
+ };
154
+
155
+ const TOO_LARGE = "the feed is larger than 10 MB";
156
+
157
+ /**
158
+ * Reads a body a chunk at a time and stops at the cap. A feed sent chunked
159
+ * carries no length to refuse up front, so counting as it arrives is the only
160
+ * thing that keeps an endless body out of memory.
161
+ */
162
+ const readCapped = async (
163
+ body: ReadableStream<Uint8Array>,
164
+ ): Promise<CalendarFeedFetch> => {
165
+ const reader = body.getReader();
166
+ const decoder = new TextDecoder();
167
+ let received = 0;
168
+ let text = "";
169
+ for (;;) {
170
+ const { done, value } = await reader.read();
171
+ if (done) break;
172
+ received += value.byteLength;
173
+ if (received > CALENDAR_SUBSCRIPTION_MAX_BYTES) {
174
+ await reader.cancel();
175
+ return { ok: false, reason: TOO_LARGE };
176
+ }
177
+ text += decoder.decode(value, { stream: true });
178
+ }
179
+ return { ok: true, value: text + decoder.decode() };
180
+ };
181
+
182
+ /**
183
+ * A resource as it would compare across two fetches. DTSTAMP is when the
184
+ * provider exported the feed, not when the event changed — Google restamps it
185
+ * on every fetch — so it is left out, or every refresh would rewrite every
186
+ * event.
187
+ */
188
+ const comparable = (icalData: string): string =>
189
+ icalData.replace(/^DTSTAMP[;:][^\r\n]*\r?\n/gim, "");
190
+
191
+ const resourceNameOf = (uid: string): string =>
192
+ `${createHash("sha256").update(uid, "utf8").digest("hex").slice(0, 40)}.ics`;
193
+
194
+ const zonesReferencedBy = (events: ICAL.Component[]): Set<string> => {
195
+ const zones = new Set<string>();
196
+ for (const event of events) {
197
+ for (const property of event.getAllProperties()) {
198
+ const tzid = property.getParameter("tzid");
199
+ if (typeof tzid === "string") zones.add(tzid);
200
+ }
201
+ }
202
+ return zones;
203
+ };
204
+
205
+ /**
206
+ * Cuts a whole feed into one resource per UID, the shape the calendar write
207
+ * path stores: a series and its RECURRENCE-ID overrides travel together, with
208
+ * the VTIMEZONEs they name, so a DST-crossing series resolves against the zone
209
+ * the provider published.
210
+ *
211
+ * The feed's calendar-level properties are kept on every resource except
212
+ * METHOD, which a stored resource never carries. A VEVENT with no UID has
213
+ * nothing to key it by across refreshes, so it is counted and left out.
214
+ */
215
+ export const splitCalendarFeed = async (
216
+ icalData: string,
217
+ ): Promise<CalendarResult<CalendarFeedSplit>> => {
218
+ const read = await readCalendarComponent(icalData);
219
+ if (!read.ok) return read;
220
+ const feed = read.value;
221
+ if (feed.name !== "vcalendar") {
222
+ return calendarFailure(
223
+ "NotACalendar",
224
+ feed.name
225
+ ? `expected a VCALENDAR, found ${feed.name.toUpperCase()}`
226
+ : "expected a VCALENDAR, found nothing",
227
+ );
228
+ }
229
+
230
+ const timezones = feed.getAllSubcomponents("vtimezone");
231
+ const byUid = new Map<string, ICAL.Component[]>();
232
+ let unreadable = 0;
233
+ for (const event of feed.getAllSubcomponents("vevent")) {
234
+ const uid = event.getFirstPropertyValue("uid");
235
+ if (typeof uid !== "string" || uid === "") {
236
+ unreadable += 1;
237
+ continue;
238
+ }
239
+ byUid.set(uid, [...(byUid.get(uid) ?? []), event]);
240
+ }
241
+
242
+ const resources = [...byUid].map(([uid, events]) => {
243
+ const calendar = new ICAL.Component("vcalendar");
244
+ for (const property of feed.getAllProperties()) {
245
+ if (property.name === "method") continue;
246
+ calendar.addProperty(new ICAL.Property(property.toJSON()));
247
+ }
248
+ const zones = zonesReferencedBy(events);
249
+ for (const timezone of timezones) {
250
+ const tzid = timezone.getFirstPropertyValue("tzid");
251
+ if (typeof tzid === "string" && zones.has(tzid)) {
252
+ calendar.addSubcomponent(new ICAL.Component(timezone.toJSON()));
253
+ }
254
+ }
255
+ for (const event of events) {
256
+ calendar.addSubcomponent(new ICAL.Component(event.toJSON()));
257
+ }
258
+ return {
259
+ uid,
260
+ resourceName: resourceNameOf(uid),
261
+ icalData: serializeCalendar(calendar),
262
+ };
263
+ });
264
+
265
+ return { ok: true, value: { resources, unreadable } };
266
+ };
267
+
268
+ const feedErrorOf = (write: CalendarFeedWrite): string =>
269
+ write.skipped === 0
270
+ ? ""
271
+ : `${write.skipped} ${write.skipped === 1 ? "event" : "events"} in the feed could not be stored`;
272
+
273
+ /**
274
+ * Makes a subscribed collection hold what the feed holds, in one unit.
275
+ *
276
+ * Every resource goes through `putCalendarObject`, the one write path, so a
277
+ * subscribed event is stored, projected and expanded the way any other event
278
+ * is. A resource whose bytes have not changed is left alone, and one the feed
279
+ * no longer carries is removed. A resource the write path refuses is counted
280
+ * and recorded on the collection rather than failing the whole refresh.
281
+ */
282
+ export const writeCalendarFeed = async (
283
+ unitOfWork: ICalendarUnitOfWork,
284
+ target: CalendarSubscriptionTarget,
285
+ split: CalendarFeedSplit,
286
+ now: number,
287
+ ): Promise<CalendarFeedWrite> =>
288
+ unitOfWork.transaction(async (repos) => {
289
+ const current = await repos.calendarCollection.get(
290
+ target.accountConfigId,
291
+ target.calendarId,
292
+ );
293
+ if (!current.subscriptionEnabled) {
294
+ return { paused: true, written: 0, unchanged: 0, removed: 0, skipped: 0 };
295
+ }
296
+ const stored = await repos.calendarObject.listByCalendar(target.calendarId);
297
+ const storedByName = new Map(
298
+ stored.map((object) => [object.resourceName, object]),
299
+ );
300
+ const kept = new Set<string>();
301
+ const write: CalendarFeedWrite = {
302
+ paused: false,
303
+ written: 0,
304
+ unchanged: 0,
305
+ removed: 0,
306
+ skipped: split.unreadable,
307
+ };
308
+
309
+ for (const resource of split.resources) {
310
+ const existing = storedByName.get(resource.resourceName);
311
+ if (
312
+ existing &&
313
+ comparable(existing.icalData) === comparable(resource.icalData)
314
+ ) {
315
+ kept.add(resource.resourceName);
316
+ write.unchanged += 1;
317
+ continue;
318
+ }
319
+ const put = await putCalendarObject(unitOfWork, {
320
+ accountConfigId: target.accountConfigId,
321
+ calendarId: target.calendarId,
322
+ resourceName: resource.resourceName,
323
+ icalData: resource.icalData,
324
+ });
325
+ if (!put.ok) {
326
+ write.skipped += 1;
327
+ continue;
328
+ }
329
+ kept.add(resource.resourceName);
330
+ write.written += 1;
331
+ }
332
+
333
+ for (const object of stored) {
334
+ if (kept.has(object.resourceName)) continue;
335
+ await deleteCalendarObject(unitOfWork, {
336
+ accountConfigId: target.accountConfigId,
337
+ calendarId: target.calendarId,
338
+ calendarObjectId: object.calendarObjectId,
339
+ });
340
+ write.removed += 1;
341
+ }
342
+
343
+ await repos.calendarCollection.update(
344
+ target.accountConfigId,
345
+ target.calendarId,
346
+ {
347
+ subscriptionCheckedAt: now,
348
+ subscriptionFetchedAt: now,
349
+ subscriptionError: truncate(feedErrorOf(write)),
350
+ },
351
+ );
352
+ return write;
353
+ });
354
+
355
+ /** Records a refresh that stored nothing, and why. Every stored event stays. */
356
+ export const recordCalendarFeedFailure = async (
357
+ unitOfWork: ICalendarUnitOfWork,
358
+ target: CalendarSubscriptionTarget,
359
+ reason: string,
360
+ now: number,
361
+ ): Promise<void> => {
362
+ await unitOfWork.transaction((repos) =>
363
+ repos.calendarCollection.update(target.accountConfigId, target.calendarId, {
364
+ subscriptionCheckedAt: now,
365
+ subscriptionError: truncate(reason),
366
+ }),
367
+ );
368
+ };
369
+
370
+ /** Fetches and cuts a feed, or answers why it cannot be stored. */
371
+ export const readCalendarFeed = async (
372
+ url: string,
373
+ fetcher: CalendarFeedFetcher = fetch,
374
+ ): Promise<
375
+ { ok: true; value: CalendarFeedSplit } | { ok: false; reason: string }
376
+ > => {
377
+ const fetched = await fetchCalendarFeed(url, fetcher);
378
+ if (!fetched.ok) return fetched;
379
+ const split = await splitCalendarFeed(fetched.value);
380
+ if (!split.ok) {
381
+ return {
382
+ ok: false,
383
+ reason: `the feed is not a calendar: ${split.error.message}`,
384
+ };
385
+ }
386
+ return split;
387
+ };
388
+
389
+ /**
390
+ * One scheduled refresh of one subscription. A feed that cannot be read
391
+ * records its reason on the collection and removes nothing.
392
+ */
393
+ export const refreshCalendarSubscription = async (
394
+ unitOfWork: ICalendarUnitOfWork,
395
+ collection: CalendarCollectionItem,
396
+ now: number,
397
+ fetcher: CalendarFeedFetcher = fetch,
398
+ ): Promise<CalendarSubscriptionRefresh> => {
399
+ const target = {
400
+ accountConfigId: collection.accountConfigId,
401
+ calendarId: collection.calendarId,
402
+ };
403
+ const feed = await readCalendarFeed(collection.subscriptionUrl, fetcher);
404
+ if (!feed.ok) {
405
+ await recordCalendarFeedFailure(unitOfWork, target, feed.reason, now);
406
+ return feed;
407
+ }
408
+ const write = await writeCalendarFeed(unitOfWork, target, feed.value, now);
409
+ return { ok: true, value: write };
410
+ };
411
+
412
+ /**
413
+ * Whether a subscription is due a refresh: its last fetch, successful or not,
414
+ * is at least `intervalMs` old.
415
+ */
416
+ export const isSubscriptionDue = (
417
+ collection: CalendarCollectionItem,
418
+ now: number,
419
+ intervalMs: number,
420
+ ): boolean => now - collection.subscriptionCheckedAt >= intervalMs;
@@ -36,6 +36,11 @@ const collection = (
36
36
  source: CalendarSource.Default,
37
37
  timezone: "",
38
38
  syncSequence: 1,
39
+ subscriptionUrl: "",
40
+ subscriptionEnabled: false,
41
+ subscriptionCheckedAt: 0,
42
+ subscriptionFetchedAt: 0,
43
+ subscriptionError: "",
39
44
  createdAt: 0,
40
45
  updatedAt: 0,
41
46
  ...overrides,