diff --git a/package.json b/package.json index c49083e2a..87a2f6203 100644 --- a/package.json +++ b/package.json @@ -61,6 +61,7 @@ "jest-worker@^28.1.3": "patch:jest-worker@npm%3A28.1.3#./.yarn/patches/jest-worker-npm-28.1.3-5d0ff9006c.patch" }, "dependencies": { + "@date-fns/tz": "^1.1.2", "@ethereumjs/tx": "^4.2.0", "@metamask/superstruct": "^3.1.0", "@noble/hashes": "^1.3.1", diff --git a/src/index.test.ts b/src/index.test.ts index 9e36ce705..525640355 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -27,6 +27,7 @@ describe('index', () => { "HexAddressStruct", "HexChecksumAddressStruct", "HexStruct", + "InvalidIso8601Date", "JsonRpcErrorStruct", "JsonRpcFailureStruct", "JsonRpcIdStruct", @@ -131,6 +132,7 @@ describe('index', () => { "object", "parseCaipAccountId", "parseCaipChainId", + "parseIso8601DateTime", "remove0x", "satisfiesVersionRange", "signedBigIntToBytes", diff --git a/src/index.ts b/src/index.ts index d3f0813ce..5b1c7950f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -18,3 +18,4 @@ export * from './promise'; export * from './time'; export * from './transaction-types'; export * from './versions'; +export * from './iso8601-date'; diff --git a/src/iso8601-date.test.ts b/src/iso8601-date.test.ts new file mode 100644 index 000000000..dd63cb396 --- /dev/null +++ b/src/iso8601-date.test.ts @@ -0,0 +1,97 @@ +import { TZDate } from '@date-fns/tz'; +import assert from 'assert'; + +import { InvalidIso8601Date, parseIso8601DateTime } from './iso8601-date'; + +describe('parseDateTime', () => { + it.each([ + ['2020', new Date(2020, 0)], + ['2021-02', new Date(2021, 1)], + ['2022-10', new Date(2022, 9)], + ['2023-11-02', new Date(2023, 10, 2)], + ['2024-12T01', new Date(2024, 11, 1, 1)], + ['2025-01T02:01', new Date(2025, 0, 1, 2, 1)], + ['2026-02-02T03', new Date(2026, 1, 2, 3)], + ['2027-03-03T04:02', new Date(2027, 2, 3, 4, 2)], + ['2027-03-03T04:02:01', new Date(2027, 2, 3, 4, 2, 1)], + ['20280404', new Date(2028, 3, 4)], + ['20290505T05', new Date(2029, 4, 5, 5)], + ['20300606T0603', new Date(2030, 5, 6, 6, 3)], + ['20310707T070402', new Date(2031, 6, 7, 7, 4, 2)], + ])('parses %s local-time correctly', (testIso, expectedDate) => { + const result = parseIso8601DateTime(testIso); + + expect(result).toBeInstanceOf(Date); + expect(result.toISOString()).toStrictEqual(expectedDate.toISOString()); + }); + + it.each([ + ['2023-04-04T04Z', new TZDate(2023, 3, 4, 4, '+00:00')], + ['2023-04-04T04-01', new TZDate(2023, 3, 4, 4, '-01:00')], + ['2023-04-04T04+02', new TZDate(2023, 3, 4, 4, '+02:00')], + ['2023-04-04T04-01:01', new TZDate(2023, 3, 4, 4, '-01:01')], + ['2023-04-04T04+02:02', new TZDate(2023, 3, 4, 4, '+02:02')], + + ['2023-04-04T04:04Z', new TZDate(2023, 3, 4, 4, 4, '+00:00')], + ['2023-04-04T04:04-01', new TZDate(2023, 3, 4, 4, 4, '-01:00')], + ['2023-04-04T04:04+02', new TZDate(2023, 3, 4, 4, 4, '+02:00')], + ['2023-04-04T04:04-01:01', new TZDate(2023, 3, 4, 4, 4, '-01:01')], + ['2023-04-04T04:04+02:02', new TZDate(2023, 3, 4, 4, 4, '+02:02')], + + ['2023-04-04T04:04:04Z', new TZDate(2023, 3, 4, 4, 4, 4, '+00:00')], + ['2023-04-04T04:04:04-01', new TZDate(2023, 3, 4, 4, 4, 4, '-01:00')], + ['2023-04-04T04:04:04+02', new TZDate(2023, 3, 4, 4, 4, 4, '+02:00')], + ['2023-04-04T04:04:04-01:01', new TZDate(2023, 3, 4, 4, 4, 4, '-01:01')], + ['2023-04-04T04:04:04+02:02', new TZDate(2023, 3, 4, 4, 4, 4, '+02:02')], + ])('parses %s with time-zone correctly', (testIso, expectedDate) => { + const result = parseIso8601DateTime(testIso); + + assert(result instanceof TZDate); + expect(result.timeZone).toStrictEqual(expectedDate.timeZone); + expect(result.toISOString()).toStrictEqual(expectedDate.toISOString()); + }); + + it.each([ + '', + '0', + '00', + '000', + '0000a', + '2020-0', + '2020-00', + '2020-01a', + '202001', + '2020-01-00', + '2020-01-01a', + '2020-0101', + '202001-01', + '2020-01-01T', + '2020-01-01T0000', + '2020-01-01T00:00a', + '2020-01-01T00:0000', + '00:00', + '2020:01', + '2020-01:01', + '2020-01-01T00:00A', + '2020-01-01T00:00Za', + '2020-01-01T00:00+a', + '2020-01-01T00:00-a', + '2020-01-01T00:00+0', + '2020-01-01T00:00-0', + '2020-01-01T00:00+00a', + '2020-01-01T00:00+00:0', + '2020-01-01T00:00-00:0', + '2020-01-01T00:00+00:00a', + '2020-01-01T00:00-00:00a', + '2020-', + '2020-01-01T24', + '2020-01-01T23:60', + '2020-01-01T23:59:60', + '2020-01-01T23:59:59-13', + '2020-01-01T23:59:59+13', + '2020-01-01T23:59:59-11:60', + '2020-01-01T23:59:59a', + ])('throws on invalid date time "%s"', (testIso) => { + expect(() => parseIso8601DateTime(testIso)).toThrow(InvalidIso8601Date); + }); +}); diff --git a/src/iso8601-date.ts b/src/iso8601-date.ts new file mode 100644 index 000000000..f47266cbe --- /dev/null +++ b/src/iso8601-date.ts @@ -0,0 +1,256 @@ +import { TZDate } from '@date-fns/tz/date'; + +import { assert } from './assert'; + +enum ParseDateState { + Year = 1, + Month = 2, + CalendarDate = 3, + Hour = 4, + Minute = 5, + Second = 6, + Timezone = 7, + TimezoneHour = 8, + TimezoneMinute = 9, + End = 0, +} +const END = Symbol('END'); + +const RE_YEAR = /[0-9]{4}/u; +const RE_MONTH = /(?:0[1-9])|(?:1[0-2])/u; +const RE_DATE = /(?:[1-9])|(?:[1-2][0-9])|(?:3[0-1])/u; +const RE_HOUR = /(?:[0-1][0-9])|(?:2[0-3])/u; +const RE_MINUTE_SECOND = /[0-5][0-9]/u; +const RE_Z_HOUR = /(?:0[0-9])|(?:1[0-2])/u; + +export const InvalidIso8601Date = new Error('Invalid ISO-8601 date'); + +/** + * Parses ISO-8601 date time string. + * + * @throws {InvalidIso8601Date} Is the input value is not correct. + * @param value - An ISO-8601 formatted string. + * @returns A date if value is in local-time, a TZDate if timezone data provided. + */ +export function parseIso8601DateTime(value: string): Date | TZDate { + let at = 0; + let hasSeparators: boolean | null = null; + let state: ParseDateState = ParseDateState.Year; + + const consume = () => { + if (at >= value.length) { + throw InvalidIso8601Date; + } + const char = value[at] as string; + at += 1; + return char; + }; + const peek = () => { + if (at >= value.length) { + return END; + } + return value[at] as string; + }; + const skip = (count = 1) => { + assert(at + count <= value.length, 'Invalid ISO-8601 parser state'); + at += count; + }; + const consumeSeparator = (sep: '-' | ':') => { + const next = peek(); + assert(next !== END, 'Invalid ISO-8601 parser state'); + if (next === '-' || next === ':') { + if (hasSeparators === false || next !== sep) { + throw InvalidIso8601Date; + } + hasSeparators = true; + skip(); + } else { + if (hasSeparators === true) { + throw InvalidIso8601Date; + } + hasSeparators = false; + } + }; + + let year: undefined | string; + let month: undefined | string; + let date: undefined | string; + let hours: undefined | string; + let minutes: undefined | string; + let seconds: undefined | string; + let timezone: null | string = null; // null, "Z", "+", "-" + let offsetHours: undefined | string; + let offsetMinutes: undefined | string; + + while (state !== ParseDateState.End) { + switch (state) { + case ParseDateState.Year: + year = ''; + for (let i = 0; i < 4; i++) { + year += consume(); + } + if (!RE_YEAR.test(year)) { + throw InvalidIso8601Date; + } + if (peek() === END) { + state = ParseDateState.End; + } else { + consumeSeparator('-'); + state = ParseDateState.Month; + } + break; + case ParseDateState.Month: + month = consume() + consume(); + if (!RE_MONTH.test(month)) { + throw InvalidIso8601Date; + } + + // YYYYMM is not a valid ISO-8601 + // it requires a separator: YYYY-MM + if (hasSeparators === false && (peek() === END || peek() === 'T')) { + throw InvalidIso8601Date; + } else if (peek() === END) { + state = ParseDateState.End; + } else if (peek() === 'T') { + state = ParseDateState.Hour; + } else { + consumeSeparator('-'); + state = ParseDateState.CalendarDate; + } + break; + case ParseDateState.CalendarDate: + date = consume() + consume(); + if (!RE_DATE.test(date)) { + throw InvalidIso8601Date; + } + + if (peek() === END) { + state = ParseDateState.End; + } else { + state = ParseDateState.Hour; + } + break; + case ParseDateState.Hour: + if (consume() !== 'T') { + throw InvalidIso8601Date; + } + + hours = consume() + consume(); + if (!RE_HOUR.test(hours)) { + throw InvalidIso8601Date; + } + + if (peek() === END) { + state = ParseDateState.End; + } else if (['Z', '-', '+'].includes(peek() as string)) { + state = ParseDateState.Timezone; + } else { + consumeSeparator(':'); + state = ParseDateState.Minute; + } + break; + case ParseDateState.Minute: + minutes = consume() + consume(); + if (!RE_MINUTE_SECOND.test(minutes)) { + throw InvalidIso8601Date; + } + + if (peek() === END) { + state = ParseDateState.End; + } else if (['Z', '-', '+'].includes(peek() as string)) { + state = ParseDateState.Timezone; + } else { + consumeSeparator(':'); + state = ParseDateState.Second; + } + break; + case ParseDateState.Second: + seconds = consume() + consume(); + if (!RE_MINUTE_SECOND.test(seconds)) { + throw InvalidIso8601Date; + } + + if (peek() === END) { + state = ParseDateState.End; + } else { + state = ParseDateState.Timezone; + } + break; + case ParseDateState.Timezone: + timezone = consume(); + + if (timezone === 'Z') { + if (peek() !== END) { + throw InvalidIso8601Date; + } + state = ParseDateState.End; + } else if (['-', '+'].includes(timezone)) { + state = ParseDateState.TimezoneHour; + } else { + throw InvalidIso8601Date; + } + break; + case ParseDateState.TimezoneHour: + offsetHours = consume() + consume(); + if (!RE_Z_HOUR.test(offsetHours)) { + throw InvalidIso8601Date; + } + + if (peek() === END) { + state = ParseDateState.End; + } else { + consumeSeparator(':'); + state = ParseDateState.TimezoneMinute; + } + break; + case ParseDateState.TimezoneMinute: + offsetMinutes = consume() + consume(); + if (!RE_MINUTE_SECOND.test(offsetMinutes)) { + throw InvalidIso8601Date; + } + + if (peek() === END) { + state = ParseDateState.End; + } else { + // Garbage at the end + throw InvalidIso8601Date; + } + break; + /* istanbul ignore next */ + default: + assert(false, 'Invalid ISO-8601 parser state'); + } + } + + assert(year !== undefined, 'Invalid ISO-8601 parser state'); + month = month ?? '01'; + date = date ?? '01'; + hours = hours ?? '00'; + minutes = minutes ?? '00'; + seconds = seconds ?? '00'; + offsetHours = offsetHours ?? '00'; + offsetMinutes = offsetMinutes ?? '00'; + + if (timezone !== null) { + if (timezone === 'Z') { + timezone = '+'; + } + return new TZDate( + parseInt(year, 10), + parseInt(month, 10) - 1, + parseInt(date, 10), + parseInt(hours, 10), + parseInt(minutes, 10), + parseInt(seconds, 10), + `${timezone}${offsetHours}:${offsetMinutes}`, + ); + } + return new Date( + parseInt(year, 10), + parseInt(month, 10) - 1, + parseInt(date, 10), + parseInt(hours, 10), + parseInt(minutes, 10), + parseInt(seconds, 10), + ); +} diff --git a/src/node.test.ts b/src/node.test.ts index 5f7d5ed4d..e01c4813f 100644 --- a/src/node.test.ts +++ b/src/node.test.ts @@ -27,6 +27,7 @@ describe('node', () => { "HexAddressStruct", "HexChecksumAddressStruct", "HexStruct", + "InvalidIso8601Date", "JsonRpcErrorStruct", "JsonRpcFailureStruct", "JsonRpcIdStruct", @@ -136,6 +137,7 @@ describe('node', () => { "object", "parseCaipAccountId", "parseCaipChainId", + "parseIso8601DateTime", "readFile", "readJsonFile", "remove0x", diff --git a/tsconfig.json b/tsconfig.json index 01e09f861..678cefba1 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -10,7 +10,11 @@ "noErrorTruncation": true, "noUncheckedIndexedAccess": true, "strict": true, - "target": "es2020" + "target": "es2020", + // TODO(ritave): A temporary measure to support date-fns + // which has a problem with CommonJS support + // https://github.com/date-fns/tz/issues/21 + "skipLibCheck": true }, "exclude": ["./dist/**/*"] } diff --git a/yarn.lock b/yarn.lock index 591cd2d1d..41718498c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -426,6 +426,13 @@ __metadata: languageName: node linkType: hard +"@date-fns/tz@npm:^1.1.2": + version: 1.1.2 + resolution: "@date-fns/tz@npm:1.1.2" + checksum: 4b2b3d3f062e456c51d5dec5332266c27d9a136352f75fdfd5769be25bcd02ababd9b5e809ae3f9c5e8c3fad1831d11a79a03c87b0f566a48bf9fec084df7665 + languageName: node + linkType: hard + "@es-joy/jsdoccomment@npm:~0.36.1": version: 0.36.1 resolution: "@es-joy/jsdoccomment@npm:0.36.1" @@ -1065,6 +1072,7 @@ __metadata: version: 0.0.0-use.local resolution: "@metamask/utils@workspace:." dependencies: + "@date-fns/tz": ^1.1.2 "@ethereumjs/tx": ^4.2.0 "@lavamoat/allow-scripts": ^3.0.4 "@lavamoat/preinstall-always-fail": ^1.0.0