From 7245aa10b054c4c3ac1f651e388ee711e561009e Mon Sep 17 00:00:00 2001 From: Adrian Edwards <17362949+MoralCode@users.noreply.github.com> Date: Thu, 4 Feb 2021 18:15:12 -0800 Subject: [PATCH 01/14] use promises inside fetchSampleImplementation to make it properly async --- serverDate.js | 26 +++++++++++++++----------- 1 file changed, 15 insertions(+), 11 deletions(-) diff --git a/serverDate.js b/serverDate.js index 4dc65b6..ac1307a 100644 --- a/serverDate.js +++ b/serverDate.js @@ -5,20 +5,24 @@ const fetchSampleImplementation = async () => { const requestDate = new Date(); - const { headers, ok, statusText } = await fetch(window.location, { + return fetch(window.location, { cache: `no-store`, method: `HEAD`, - }); - - if (!ok) { - throw new Error(`Bad date sample from server: ${statusText}`); - } + }) + .then( result => { + const { headers, ok, statusText } = result + + if (!ok) { + throw new Error(`Bad date sample from server: ${statusText}`); + } - return { - requestDate, - responseDate: new Date(), - serverDate: new Date(headers.get(`Date`)), - }; + return { + requestDate, + responseDate: new Date(), + serverDate: new Date(headers.get(`Date`)), + }; + }) + .catch((error) => console.error(error)) }; export const getServerDate = async ( From ffffa8a0c2d359279b0a0de6925f8f12f177d32a Mon Sep 17 00:00:00 2001 From: Adrian Edwards <17362949+MoralCode@users.noreply.github.com> Date: Thu, 4 Feb 2021 21:15:10 -0800 Subject: [PATCH 02/14] add async functions to create promises for sampling --- serverDate.js | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/serverDate.js b/serverDate.js index ac1307a..319c84b 100644 --- a/serverDate.js +++ b/serverDate.js @@ -56,3 +56,29 @@ export const getServerDate = async ( return best; }; + + +/** + * creates a promise that delays a set number of milliseconds + * + * @param {*} delayTime the number of milliseconds to delay + */ +const createDelay = (delayTime) => { + return new Promise((resolve) => { + setTimeout(() => { + resolve(); + }, delayTime); + }) +} + + +/** + * create a promise that delays, and then makes a new request as a sample + * + * @param {*} delayTime how long to delay in milliseconds + * @returns a promise that waits the specified time and then fetches a new sample + */ +const createSample = (delayTime) => { + return createDelay(delayTime) + .then(fetchSampleImplementation) +} \ No newline at end of file From ab800663d344deb8e7cb786dc1e191c622b374ae Mon Sep 17 00:00:00 2001 From: Adrian Edwards <17362949+MoralCode@users.noreply.github.com> Date: Thu, 4 Feb 2021 21:16:03 -0800 Subject: [PATCH 03/14] add helper to determine when a sample has detected a change in server time --- serverDate.js | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/serverDate.js b/serverDate.js index 319c84b..ee3a769 100644 --- a/serverDate.js +++ b/serverDate.js @@ -81,4 +81,16 @@ const createDelay = (delayTime) => { const createSample = (delayTime) => { return createDelay(delayTime) .then(fetchSampleImplementation) -} \ No newline at end of file +} + + +/** + * Determine whether two samples capture a change in the server's Datetime + * + * @param {*} lastSample the older sample + * @param {*} thisSamplethe newer sample + * @returns boolean indicating whether the server's date value changed between these requests + */ +const hasCapturedTick = (lastSample, thisSample) => { + return lastSample.serverDate.getTime() !== thisSample.serverDate.getTime() +} From 0207df1676f491c2f58d03a6d7a3f634dbf89454 Mon Sep 17 00:00:00 2001 From: Adrian Edwards <17362949+MoralCode@users.noreply.github.com> Date: Thu, 4 Feb 2021 21:17:35 -0800 Subject: [PATCH 04/14] add function to repeatedly sample until a change in server time is detected --- serverDate.js | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/serverDate.js b/serverDate.js index ee3a769..407a677 100644 --- a/serverDate.js +++ b/serverDate.js @@ -84,6 +84,36 @@ const createSample = (delayTime) => { } +/** + * Reppeatedly collect samples until a change in server date is detected + * + * @param {*} delayTime how long to wait in milliseconds between samples. higher values create fewer requests, but also decrease the precision of estimates made from them + * @param {*} sampleList a array to push samples onto + * @returns a promise that repeatedly collects samples until the server time changes + */ +const repeatedSample = (delayTime, sampleList) => { + return createSample(delayTime) + //store the sample + .then((sample) => { + sampleList.push(sample) + }) + //conditionally schedule a new + .then((sample) => { + + const { requestDate, responseDate, serverDate } = sample + //if the server dates of the last 2 samples dont match, then we captured a request before and after the servers time ticked to the next second and we can stop making requests + + if (!hasCapturedTick( + sampleList[sampleList.lastIndexOf() - 1], + sampleList[sampleList.lastIndexOf()] + )) { + return repeatedSample(delayTime, sampleList) + } + }) + +} + + /** * Determine whether two samples capture a change in the server's Datetime * From ca2d48e2cc940c9bf30233b1efd0b59794077e49 Mon Sep 17 00:00:00 2001 From: Adrian Edwards <17362949+MoralCode@users.noreply.github.com> Date: Thu, 4 Feb 2021 21:18:19 -0800 Subject: [PATCH 05/14] add function for estimating server time based on a before and after sample pair that detected a server time change --- serverDate.js | 43 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/serverDate.js b/serverDate.js index 407a677..a24f871 100644 --- a/serverDate.js +++ b/serverDate.js @@ -124,3 +124,46 @@ const repeatedSample = (delayTime, sampleList) => { const hasCapturedTick = (lastSample, thisSample) => { return lastSample.serverDate.getTime() !== thisSample.serverDate.getTime() } + + +/** + * Calculates an estimate for server based on two samples, one from before the server time changed, and one after + * + * this function does not account for latency because that calculation often assumes too many things about the users network environment. Even this method is not perfect in this regard. + * + * @param {*} sampleBefore an object containing the requestDate, responseDate, and server date value from before the serevr date value changed + * @param {*} sampleAfter an object containing the requestDate, responseDate, and server date value from after the serevr date value changed + * @returns an object with an estimate of the servers date along with an offset from the current time and an uncertainty value to denote the precision of the estimate + */ +const estimateServerTime = (sampleBefore, sampleAfter) => { + + let offset = 0; + //the date in seconds is most accurate the moment it ticks (or very soon after) + let date = sampleAfter.serverDate; + //this is treates as +/-, so its half of the total width of possibility + let uncertainty = 500; + + + if (!hasCapturedTick(sampleBefore, sampleAfter)) { + console.error(`A tick was not captured in the samples provided. cannot calculate a more accurate server time. falling back to the server-provided date.`); + + return { date, offset, uncertainty } + } + + //otherwise, without making assumptions, the moment at which the server ticked to the next second must have happened anywhere between the sending of the previous sample and the receiving of the sample that detected the change + //see: https://github.com/NodeGuy/server-date/issues/41 + + // get an upper limit for time duration in which the time could have changed on the server and produced this result + const tickWindow = sampleBefore.requestDate.getTime() - sampleAfter.responseDate.getTime() + + //divide by 2 because uncertainty is in a single direction + uncertainty = tickWindow / 2; + //because we dont know the relationship between server time and local time precisely, we must guess, and placing it in the middle of the uncertainty tickWindow seems reasonable. + date = new Date(sampleBefore.requestDate.getTime() + uncertainty) + + //the responseDate is the soonest possible time we could have known about the new server time. and thus the most accurate, so the difference between that and our estimated server time is the offset that needs to be applied to the localtime to approximate the server time to within +/- the uncertainty value. + offset = date - responseDate + + return { date, offset, uncertainty } + +} From b429e0399deef7201ce671284c0fa2dedb90e9d3 Mon Sep 17 00:00:00 2001 From: Adrian Edwards <17362949+MoralCode@users.noreply.github.com> Date: Thu, 4 Feb 2021 21:23:57 -0800 Subject: [PATCH 06/14] tweak parameters to pass down the sampling promise generator function into the new sampling method --- serverDate.js | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/serverDate.js b/serverDate.js index a24f871..59ac7b2 100644 --- a/serverDate.js +++ b/serverDate.js @@ -76,11 +76,12 @@ const createDelay = (delayTime) => { * create a promise that delays, and then makes a new request as a sample * * @param {*} delayTime how long to delay in milliseconds + * @param {*} samplePromise a promise that executes the collection of a sample * @returns a promise that waits the specified time and then fetches a new sample */ -const createSample = (delayTime) => { +const createSample = (delayTime, samplePromise) => { return createDelay(delayTime) - .then(fetchSampleImplementation) + .then(samplePromise) } @@ -88,11 +89,12 @@ const createSample = (delayTime) => { * Reppeatedly collect samples until a change in server date is detected * * @param {*} delayTime how long to wait in milliseconds between samples. higher values create fewer requests, but also decrease the precision of estimates made from them + * @param {*} samplePromise a promise that executes the collection of a sample * @param {*} sampleList a array to push samples onto * @returns a promise that repeatedly collects samples until the server time changes */ -const repeatedSample = (delayTime, sampleList) => { - return createSample(delayTime) +const repeatedSample = (delayTime, sampleList, samplePromise) => { + return createSample(delayTime, samplePromise) //store the sample .then((sample) => { sampleList.push(sample) From 7a7e28aff611745d9bbb7c714983f7e07944c38c Mon Sep 17 00:00:00 2001 From: Adrian Edwards <17362949+MoralCode@users.noreply.github.com> Date: Thu, 4 Feb 2021 21:24:16 -0800 Subject: [PATCH 07/14] update getServerDate to sample using the new method --- serverDate.js | 42 +++++++++++++++--------------------------- 1 file changed, 15 insertions(+), 27 deletions(-) diff --git a/serverDate.js b/serverDate.js index 59ac7b2..65b09d5 100644 --- a/serverDate.js +++ b/serverDate.js @@ -25,36 +25,24 @@ const fetchSampleImplementation = async () => { .catch((error) => console.error(error)) }; +/** + * create an estimate for the server's time by analyzing the moment when the `Date` HTTP header ticks to the next second. This allows for the possibility of a substantial improvement over the 1-second accuracy of HTTP Date + * + * @returns an object with an estimate of the servers date along with an offset from the current time and an uncertainty value to denote the precision of the estimate + */ export const getServerDate = async ( { fetchSample } = { fetchSample: fetchSampleImplementation } ) => { - let best = { uncertainty: Number.MAX_VALUE }; - - // Fetch 10 samples to increase the chance of getting one with low - // uncertainty. - for (let index = 0; index < 10; index++) { - try { - const { requestDate, responseDate, serverDate } = await fetchSample(); - - // We don't get milliseconds back from the Date header so there's - // uncertainty of at least half a second in either direction. - const uncertainty = (responseDate - requestDate) / 2 + 500; - - if (uncertainty < best.uncertainty) { - const date = new Date(serverDate.getTime() + 500); - - best = { - date, - offset: date - responseDate, - uncertainty, - }; - } - } catch (exception) { - console.warn(exception); - } - } - return best; + let samples = []; + //100 milliseconds seems like a reasonable delay between samples. Higher numbers mean less calls to the server, but also lower precision + await repeatedSample(100, samples, fetchSample); + + //estimate the time based on the last two samples + return estimateServerTime( + samples[samples.lastIndexOf() - 1], + samples[samples.lastIndexOf()] + ) }; @@ -81,7 +69,7 @@ const createDelay = (delayTime) => { */ const createSample = (delayTime, samplePromise) => { return createDelay(delayTime) - .then(samplePromise) + .then(samplePromise()) } From b51a49fcde84f77b446a0b60e8dc3a8cde6c983d Mon Sep 17 00:00:00 2001 From: Adrian Edwards <17362949+MoralCode@users.noreply.github.com> Date: Sat, 6 Feb 2021 08:43:52 -0800 Subject: [PATCH 08/14] Add a function to deduplicate the fetching of samples from the end of the array --- serverDate.js | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/serverDate.js b/serverDate.js index 65b09d5..190b58e 100644 --- a/serverDate.js +++ b/serverDate.js @@ -40,8 +40,8 @@ export const getServerDate = async ( //estimate the time based on the last two samples return estimateServerTime( - samples[samples.lastIndexOf() - 1], - samples[samples.lastIndexOf()] + reverseIndex(samples, 1), + reverseIndex(samples, 0) ) }; @@ -94,8 +94,8 @@ const repeatedSample = (delayTime, sampleList, samplePromise) => { //if the server dates of the last 2 samples dont match, then we captured a request before and after the servers time ticked to the next second and we can stop making requests if (!hasCapturedTick( - sampleList[sampleList.lastIndexOf() - 1], - sampleList[sampleList.lastIndexOf()] + reverseIndex(sampleList, 1), + reverseIndex(sampleList, 0) )) { return repeatedSample(delayTime, sampleList) } @@ -103,6 +103,16 @@ const repeatedSample = (delayTime, sampleList, samplePromise) => { } +/** + * A function that enables elements to be retrieved from the end of an array + * + * @param {*} array the array to retrieve elements from + * @param {*} indexFromEnd the index of the position to fetch, starting from the end of the array + * @returns + */ +const reverseIndex = (array, indexFromEnd) => { + return array[array.length - 1 - indexFromEnd] +} /** * Determine whether two samples capture a change in the server's Datetime From c5a0e47833dedad8e45878d59ec4dd6cb203bcb8 Mon Sep 17 00:00:00 2001 From: Adrian Edwards <17362949+MoralCode@users.noreply.github.com> Date: Sat, 6 Feb 2021 08:52:14 -0800 Subject: [PATCH 09/14] unnecessary function call this gets automatically called i think --- serverDate.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/serverDate.js b/serverDate.js index 190b58e..16f1c5d 100644 --- a/serverDate.js +++ b/serverDate.js @@ -69,7 +69,7 @@ const createDelay = (delayTime) => { */ const createSample = (delayTime, samplePromise) => { return createDelay(delayTime) - .then(samplePromise()) + .then(samplePromise) } From f8d72d4288678b2be1930d3a7fd7e96c4a1769f6 Mon Sep 17 00:00:00 2001 From: Adrian Edwards <17362949+MoralCode@users.noreply.github.com> Date: Sat, 6 Feb 2021 08:53:15 -0800 Subject: [PATCH 10/14] pass in the sampling promise when recursing --- serverDate.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/serverDate.js b/serverDate.js index 16f1c5d..4460b09 100644 --- a/serverDate.js +++ b/serverDate.js @@ -97,7 +97,7 @@ const repeatedSample = (delayTime, sampleList, samplePromise) => { reverseIndex(sampleList, 1), reverseIndex(sampleList, 0) )) { - return repeatedSample(delayTime, sampleList) + return repeatedSample(delayTime, sampleList, samplePromise) } }) From 6ec82637d55fa82f7dea4077ffbdc4b705ca614d Mon Sep 17 00:00:00 2001 From: Adrian Edwards <17362949+MoralCode@users.noreply.github.com> Date: Sat, 6 Feb 2021 08:53:29 -0800 Subject: [PATCH 11/14] fix responseDate not defined error --- serverDate.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/serverDate.js b/serverDate.js index 4460b09..8bb0d0c 100644 --- a/serverDate.js +++ b/serverDate.js @@ -162,7 +162,7 @@ const estimateServerTime = (sampleBefore, sampleAfter) => { date = new Date(sampleBefore.requestDate.getTime() + uncertainty) //the responseDate is the soonest possible time we could have known about the new server time. and thus the most accurate, so the difference between that and our estimated server time is the offset that needs to be applied to the localtime to approximate the server time to within +/- the uncertainty value. - offset = date - responseDate + offset = date - sampleAfter.responseDate return { date, offset, uncertainty } From 37595f8d06f8ffb2c05d2b49353b4ff31c81b2a1 Mon Sep 17 00:00:00 2001 From: Adrian Edwards <17362949+MoralCode@users.noreply.github.com> Date: Sat, 6 Feb 2021 08:54:02 -0800 Subject: [PATCH 12/14] handle checking for a tick with only one sample --- serverDate.js | 1 + 1 file changed, 1 insertion(+) diff --git a/serverDate.js b/serverDate.js index 8bb0d0c..bb12e4a 100644 --- a/serverDate.js +++ b/serverDate.js @@ -122,6 +122,7 @@ const reverseIndex = (array, indexFromEnd) => { * @returns boolean indicating whether the server's date value changed between these requests */ const hasCapturedTick = (lastSample, thisSample) => { + if (!lastSample) return false; return lastSample.serverDate.getTime() !== thisSample.serverDate.getTime() } From 15aaacbae7538bde30df9e68465e9e52b8776ef4 Mon Sep 17 00:00:00 2001 From: Adrian Edwards <17362949+MoralCode@users.noreply.github.com> Date: Sat, 6 Feb 2021 09:59:12 -0800 Subject: [PATCH 13/14] remove unused sample parameter from promise --- serverDate.js | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/serverDate.js b/serverDate.js index bb12e4a..da9fea5 100644 --- a/serverDate.js +++ b/serverDate.js @@ -88,9 +88,8 @@ const repeatedSample = (delayTime, sampleList, samplePromise) => { sampleList.push(sample) }) //conditionally schedule a new - .then((sample) => { + .then(() => { - const { requestDate, responseDate, serverDate } = sample //if the server dates of the last 2 samples dont match, then we captured a request before and after the servers time ticked to the next second and we can stop making requests if (!hasCapturedTick( From 97492f45a12408ce3109b51a11afe56c3484229d Mon Sep 17 00:00:00 2001 From: Adrian Edwards <17362949+MoralCode@users.noreply.github.com> Date: Sat, 6 Feb 2021 10:07:23 -0800 Subject: [PATCH 14/14] remove dumb server date calculation --- serverDate.js | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/serverDate.js b/serverDate.js index da9fea5..0f28838 100644 --- a/serverDate.js +++ b/serverDate.js @@ -158,9 +158,7 @@ const estimateServerTime = (sampleBefore, sampleAfter) => { //divide by 2 because uncertainty is in a single direction uncertainty = tickWindow / 2; - //because we dont know the relationship between server time and local time precisely, we must guess, and placing it in the middle of the uncertainty tickWindow seems reasonable. - date = new Date(sampleBefore.requestDate.getTime() + uncertainty) - + //the responseDate is the soonest possible time we could have known about the new server time. and thus the most accurate, so the difference between that and our estimated server time is the offset that needs to be applied to the localtime to approximate the server time to within +/- the uncertainty value. offset = date - sampleAfter.responseDate