Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
295 changes: 295 additions & 0 deletions src/utils/http-date.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,295 @@
/**
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is grabbed straight from undici, do we really need to have a copy of this in our code? Is there a 3rd party library we can use? I really want to avoid us from maintaining such kind of code.

Copy link
Member

@MattIPv4 MattIPv4 May 21, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@nodejs/undici I hope you don't mind the ping, but is this date parsing logic something y'all would consider exposing?

It looks like a couple of utils are currently exposed: https://github.com/nodejs/undici/blob/674ae24d6e61a3ef62b4b3e8677fcae52c0fa068/index.js#L60-L63

So, would it be viable to add https://github.com/nodejs/undici/blob/674ae24d6e61a3ef62b4b3e8677fcae52c0fa068/lib/util/date.js to that?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is grabbed straight from undici, do we really need to have a copy of this in our code? Is there a 3rd party library we can use? I really want to avoid us from maintaining such kind of code.

Why not? We have parsing logic already for other things such as the range header

* Valid months that are allowed to be in an IMF date
*/
const IMF_DAYS = ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun'];

/**
* Specific locations that are expected to be spaces in an IMF date
*/
const IMF_SPACES = [4, 7, 11, 16, 25];

/**
* Valid months that are allowed to be in an IMF date
*/
const IMF_MONTHS = [
'jan',
'feb',
'mar',
'apr',
'may',
'jun',
'jul',
'aug',
'sep',
'oct',
'nov',
'dec',
];

/**
* Specific locations that are expected to be colons in an IMF date
*/
const IMF_COLONS = [19, 22];

/**
* Specific locations that are expected to be spaces in an asctime() date
*/
const ASCTIME_SPACES = [3, 7, 10, 19];

/**
* Valid days allowed in an RF850 date
*/
const RFC850_DAYS = [
'monday',
'tuesday',
'wednesday',
'thursday',
'friday',
'saturday',
'sunday',
];

/**
* @see https://www.rfc-editor.org/rfc/rfc9110.html#name-date-time-formats
*/
export function parseHttpDate(
date: string | null,
now = new Date()
): Date | undefined {
// Sun, 06 Nov 1994 08:49:37 GMT ; IMF-fixdate
// Sun Nov 6 08:49:37 1994 ; ANSI C's asctime() format
// Sunday, 06-Nov-94 08:49:37 GMT ; obsolete RFC 850 format

if (date === null) {
return undefined;
}

date = date.toLowerCase();

switch (date[3]) {
case ',':
return parseImfDate(date);
case ' ':
return parseAscTimeDate(date);
default:
return parseRfc850Date(date, now);
}
}

/**
* @see https://httpwg.org/specs/rfc9110.html#preferred.date.format
*/
function parseImfDate(date: string): Date | undefined {
if (date.length !== 29) {
return undefined;
}

if (!date.endsWith('gmt')) {
// Unsupported timezone
return undefined;
}

// Ensure there are spaces in the expected locations
for (const spaceInx of IMF_SPACES) {
if (date[spaceInx] !== ' ') {
return undefined;
}
}

// Ensure there are colons in the expected locations
for (const colonIdx of IMF_COLONS) {
if (date[colonIdx] !== ':') {
return undefined;
}
}

const dayName = date.substring(0, 3);
if (!IMF_DAYS.includes(dayName)) {
return undefined;
}

const dayString = date.substring(5, 7);
const day = Number.parseInt(dayString);
if (isNaN(day) || (day < 10 && dayString[0] !== '0')) {
// Not a number, 0, or it's less than 10 and didn't start with a 0
return undefined;
}

const month = date.substring(8, 11);
const monthIdx = IMF_MONTHS.indexOf(month);
if (monthIdx === -1) {
return undefined;
}

const year = Number.parseInt(date.substring(12, 16));
if (isNaN(year)) {
return undefined;
}

const hourString = date.substring(17, 19);
const hour = Number.parseInt(hourString);
if (isNaN(hour) || (hour < 10 && hourString[0] !== '0')) {
return undefined;
}

const minuteString = date.substring(20, 22);
const minute = Number.parseInt(minuteString);
if (isNaN(minute) || (minute < 10 && minuteString[0] !== '0')) {
return undefined;
}

const secondString = date.substring(23, 25);
const second = Number.parseInt(secondString);
if (isNaN(second) || (second < 10 && secondString[0] !== '0')) {
return undefined;
}

return new Date(Date.UTC(year, monthIdx, day, hour, minute, second));
}

/**
* @see https://httpwg.org/specs/rfc9110.html#obsolete.date.formats
*/
function parseAscTimeDate(date: string): Date | undefined {
// This is assumed to be in UTC

if (date.length !== 24) {
return undefined;
}

// Ensure there are spaces in the expected locations
for (const spaceIdx of ASCTIME_SPACES) {
if (date[spaceIdx] !== ' ') {
return undefined;
}
}

const dayName = date.substring(0, 3);
if (!IMF_DAYS.includes(dayName)) {
return undefined;
}

const month = date.substring(4, 7);
const monthIdx = IMF_MONTHS.indexOf(month);
if (monthIdx === -1) {
return undefined;
}

const dayString = date.substring(8, 10);
const day = Number.parseInt(dayString);
if (isNaN(day) || (day < 10 && dayString[0] !== ' ')) {
return undefined;
}

const hourString = date.substring(11, 13);
const hour = Number.parseInt(hourString);
if (isNaN(hour) || (hour < 10 && hourString[0] !== '0')) {
return undefined;
}

const minuteString = date.substring(14, 16);
const minute = Number.parseInt(minuteString);
if (isNaN(minute) || (minute < 10 && minuteString[0] !== '0')) {
return undefined;
}

const secondString = date.substring(17, 19);
const second = Number.parseInt(secondString);
if (isNaN(second) || (second < 10 && secondString[0] !== '0')) {
return undefined;
}

const year = Number.parseInt(date.substring(20, 24));
if (isNaN(year)) {
return undefined;
}

return new Date(Date.UTC(year, monthIdx, day, hour, minute, second));
}

/**
* @see https://httpwg.org/specs/rfc9110.html#obsolete.date.formats
*/
function parseRfc850Date(date: string, now = new Date()): Date | undefined {
if (!date.endsWith('gmt')) {
// Unsupported timezone
return undefined;
}

const commaIndex = date.indexOf(',');
if (commaIndex === -1) {
return undefined;
}

if (date.length - commaIndex - 1 !== 23) {
return undefined;
}

const dayName = date.substring(0, commaIndex);
if (!RFC850_DAYS.includes(dayName)) {
return undefined;
}

// Ensure there are spaces, dashes, and colons in the expected locations
if (
date[commaIndex + 1] !== ' ' ||
date[commaIndex + 4] !== '-' ||
date[commaIndex + 8] !== '-' ||
date[commaIndex + 11] !== ' ' ||
date[commaIndex + 14] !== ':' ||
date[commaIndex + 17] !== ':' ||
date[commaIndex + 20] !== ' '
) {
return undefined;
}

const dayString = date.substring(commaIndex + 2, commaIndex + 4);
const day = Number.parseInt(dayString);
if (isNaN(day) || (day < 10 && dayString[0] !== '0')) {
// Not a number, or it's less than 10 and didn't start with a 0
return undefined;
}

const month = date.substring(commaIndex + 5, commaIndex + 8);
const monthIdx = IMF_MONTHS.indexOf(month);
if (monthIdx === -1) {
return undefined;
}

// As of this point year is just the decade (i.e. 94)
let year = Number.parseInt(date.substring(commaIndex + 9, commaIndex + 11));
if (isNaN(year)) {
return undefined;
}

const currentYear = now.getUTCFullYear();
const currentDecade = currentYear % 100;
const currentCentury = Math.floor(currentYear / 100);

if (year > currentDecade && year - currentDecade >= 50) {
// Over 50 years in future, go to previous century
year += (currentCentury - 1) * 100;
} else {
year += currentCentury * 100;
}

const hourString = date.substring(commaIndex + 12, commaIndex + 14);
const hour = Number.parseInt(hourString);
if (isNaN(hour) || (hour < 10 && hourString[0] !== '0')) {
return undefined;
}

const minuteString = date.substring(commaIndex + 15, commaIndex + 17);
const minute = Number.parseInt(minuteString);
if (isNaN(minute) || (minute < 10 && minuteString[0] !== '0')) {
return undefined;
}

const secondString = date.substring(commaIndex + 18, commaIndex + 20);
const second = Number.parseInt(secondString);
if (isNaN(second) || (second < 10 && secondString[0] !== '0')) {
return undefined;
}

return new Date(Date.UTC(year, monthIdx, day, hour, minute, second));
}
17 changes: 4 additions & 13 deletions src/utils/request.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { parseHttpDate } from './http-date';

/**
* Etags will have quotes removed from them
* R2 supports every conditional header except `If-Range`
Expand Down Expand Up @@ -30,7 +32,7 @@ export function parseUrl(request: Request): URL | undefined {
}

export function parseConditionalHeaders(headers: Headers): ConditionalHeaders {
const ifModifiedSince = getDateFromHeader(headers.get('if-modified-since'));
const ifModifiedSince = parseHttpDate(headers.get('if-modified-since'));

const ifMatch = headers.has('if-match')
? headers.get('if-match')!.replaceAll('"', '')
Expand All @@ -40,9 +42,7 @@ export function parseConditionalHeaders(headers: Headers): ConditionalHeaders {
? headers.get('if-none-match')!.replaceAll('"', '')
: undefined;

const ifUnmodifiedSince = getDateFromHeader(
headers.get('if-unmodified-since')
);
const ifUnmodifiedSince = parseHttpDate(headers.get('if-unmodified-since'));

const range = headers.has('range')
? parseRangeHeader(headers.get('range')!)
Expand All @@ -57,15 +57,6 @@ export function parseConditionalHeaders(headers: Headers): ConditionalHeaders {
};
}

function getDateFromHeader(dateString: string | null): Date | undefined {
if (dateString === null) {
return undefined;
}

const date = new Date(dateString);
return !isNaN(date.getTime()) ? date : undefined;
}

/**
* @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Range
* @returns undefined if header is invalid
Expand Down
1 change: 1 addition & 0 deletions tests/unit/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,6 @@ import './utils/object.test';
import './utils/path.test';
import './utils/request.test';
import './utils/memo.test';
import './utils/http-date.test';
import './router/router.test';
import './middleware/substituteMiddleware.test';
Loading
Loading