Suite of AT Protocol TypeScript libraries built on web standards
1/* Validates datetime string against atproto Lexicon 'datetime' format.
2 * Syntax is described at: https://atproto.com/specs/lexicon#datetime
3 */
4export const ensureValidDatetime = (dtStr: string): void => {
5 const date = new Date(dtStr);
6 // must parse as ISO 8601; this also verifies semantics like month is not 13 or 00
7 if (isNaN(date.getTime())) {
8 throw new InvalidDatetimeError("datetime did not parse as ISO 8601");
9 }
10 if (date.toISOString().startsWith("-")) {
11 throw new InvalidDatetimeError("datetime normalized to a negative time");
12 }
13 // regex and other checks for RFC-3339
14 if (
15 !/^[0-9]{4}-[01][0-9]-[0-3][0-9]T[0-2][0-9]:[0-6][0-9]:[0-6][0-9](.[0-9]{1,20})?(Z|([+-][0-2][0-9]:[0-5][0-9]))$/
16 .test(
17 dtStr,
18 )
19 ) {
20 throw new InvalidDatetimeError("datetime didn't validate via regex");
21 }
22 if (dtStr.length > 64) {
23 throw new InvalidDatetimeError("datetime is too long (64 chars max)");
24 }
25 if (dtStr.endsWith("-00:00")) {
26 throw new InvalidDatetimeError(
27 'datetime can not use "-00:00" for UTC timezone',
28 );
29 }
30 if (dtStr.startsWith("000")) {
31 throw new InvalidDatetimeError(
32 "datetime so close to year zero not allowed",
33 );
34 }
35};
36
37/* Same logic as ensureValidDatetime(), but returns a boolean instead of throwing an exception.
38 */
39export const isValidDatetime = (dtStr: string): boolean => {
40 try {
41 ensureValidDatetime(dtStr);
42 } catch (err) {
43 if (err instanceof InvalidDatetimeError) {
44 return false;
45 }
46 throw err;
47 }
48
49 return true;
50};
51
52/* Takes a flexible datetime string and normalizes representation.
53 *
54 * This function will work with any valid atproto datetime (eg, anything which isValidDatetime() is true for). It *additionally* is more flexible about accepting datetimes that don't comply to RFC 3339, or are missing timezone information, and normalizing them to a valid datetime.
55 *
56 * One use-case is a consistent, sortable string. Another is to work with older invalid createdAt datetimes.
57 *
58 * Successful output will be a valid atproto datetime with millisecond precision (3 sub-second digits) and UTC timezone with trailing 'Z' syntax. Throws `InvalidDatetimeError` if the input string could not be parsed as a datetime, even with permissive parsing.
59 *
60 * Expected output format: YYYY-MM-DDTHH:mm:ss.sssZ
61 */
62export const normalizeDatetime = (dtStr: string): string => {
63 if (isValidDatetime(dtStr)) {
64 const outStr = new Date(dtStr).toISOString();
65 if (isValidDatetime(outStr)) {
66 return outStr;
67 }
68 }
69
70 // check if this permissive datetime is missing a timezone
71 if (!/.*(([+-]\d\d:?\d\d)|[a-zA-Z])$/.test(dtStr)) {
72 const date = new Date(dtStr + "Z");
73 if (!isNaN(date.getTime())) {
74 const tzStr = date.toISOString();
75 if (isValidDatetime(tzStr)) {
76 return tzStr;
77 }
78 }
79 }
80
81 // finally try parsing as simple datetime
82 const date = new Date(dtStr);
83 if (isNaN(date.getTime())) {
84 throw new InvalidDatetimeError(
85 "datetime did not parse as any timestamp format",
86 );
87 }
88 const isoStr = date.toISOString();
89 if (isValidDatetime(isoStr)) {
90 return isoStr;
91 } else {
92 throw new InvalidDatetimeError(
93 "datetime normalized to invalid timestamp string",
94 );
95 }
96};
97
98/* Variant of normalizeDatetime() which always returns a valid datetime strings.
99 *
100 * If a InvalidDatetimeError is encountered, returns the UNIX epoch time as a UTC datetime (1970-01-01T00:00:00.000Z).
101 */
102export const normalizeDatetimeAlways = (dtStr: string): string => {
103 try {
104 return normalizeDatetime(dtStr);
105 } catch (err) {
106 if (err instanceof InvalidDatetimeError) {
107 return new Date(0).toISOString();
108 }
109 throw err;
110 }
111};
112
113/* Indicates a datetime string did not pass full atproto Lexicon datetime string format checks.
114 */
115export class InvalidDatetimeError extends Error {}