@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.
- package/package.json +2 -1
- package/src/accept.test.ts +44 -0
- package/src/accept.ts +27 -5
- package/src/errors.ts +3 -1
- package/src/feeds/google-secret-address.ics +99 -0
- package/src/index.ts +19 -0
- package/src/memory-store.ts +10 -0
- package/src/parse.ts +2 -2
- package/src/subscribe.test.ts +578 -0
- package/src/subscribe.ts +420 -0
- package/src/window.test.ts +5 -0
package/src/subscribe.ts
ADDED
|
@@ -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;
|
package/src/window.test.ts
CHANGED
|
@@ -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,
|