Files
kandev/apps/web/lib/utils/strict-timestamp.ts
T
2026-09-06 15:14:49 +01:00

67 lines
2.9 KiB
TypeScript

// Strict wire format: full RFC3339 with an explicit offset or UTC marker.
// Date.parse is permissive, so shape validation must happen before any date
// formatting or malformed values can be normalized into a different instant.
const RFC3339_PATTERN =
/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.(\d+))?(Z|[+-](\d{2}):(\d{2}))$/;
const DAYS_IN_MONTH = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
/** Whether the year is a leap year in the proleptic Gregorian calendar. */
function isLeapYear(year: number): boolean {
return (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0;
}
/** Whether the day exists in the month, including leap-day validation. */
function validDayForMonth(year: number, month: number, day: number): boolean {
if (day < 1) return false;
const maxDay = month === 2 && isLeapYear(year) ? 29 : DAYS_IN_MONTH[month - 1];
return day <= maxDay;
}
/** Returns the UTC offset in seconds for a matched RFC3339 zone. */
function parseOffsetSeconds(
zone: string,
offsetHourText: string | undefined,
offsetMinuteText: string | undefined,
): number | null {
if (zone === "Z") return 0;
const hour = Number(offsetHourText);
const minute = Number(offsetMinuteText);
if (hour > 23 || minute > 59) return null;
return (zone.startsWith("-") ? -1 : 1) * (hour * 60 + minute) * 60;
}
/**
* Parses an RFC3339/RFC3339Nano timestamp into epoch nanoseconds.
*
* Missing, empty, malformed, and semantically invalid values return `null`.
* Calendar and time components are validated explicitly because Date.parse
* normalizes values such as February 30th instead of rejecting them.
*/
export function parseStrictRfc3339Timestamp(value: string | undefined): bigint | null {
if (!value) return null;
const match = RFC3339_PATTERN.exec(value);
if (!match) return null;
const year = Number(match[1]);
const month = Number(match[2]);
if (month < 1 || month > 12) return null;
if (!validDayForMonth(year, month, Number(match[3]))) return null;
if (Number(match[4]) > 23 || Number(match[5]) > 59 || Number(match[6]) > 59) {
return null;
}
const offsetSeconds = parseOffsetSeconds(match[8], match[9], match[10]);
if (offsetSeconds === null) return null;
// RFC3339Nano caps the fraction at 9 digits; longer fractions would
// overflow into the seconds component when padded.
if (match[7] !== undefined && match[7].length > 9) return null;
// setUTCFullYear handles years 0-99 correctly; Date.UTC maps them to
// 1900-1999 and would therefore normalize an otherwise valid timestamp.
const utc = new Date(0);
utc.setUTCFullYear(year, month - 1, Number(match[3]));
utc.setUTCHours(Number(match[4]), Number(match[5]), Number(match[6]), 0);
const wholeSeconds = BigInt(Math.floor(utc.getTime() / 1000)) - BigInt(offsetSeconds);
const fractionNs = BigInt((match[7] ?? "").padEnd(9, "0"));
return wholeSeconds * BigInt(1_000_000_000) + fractionNs;
}