diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..e1215bc --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,34 @@ +name: Test suite +on: + push: + branches: + - master + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + strategy: + matrix: + meteorRelease: + - '--release 1.12.1' + - '--release 2.3' + - '--release 2.8.1' + - '--release 2.16' + # Latest version + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Install Node.js + uses: actions/setup-node@v4 + with: + node-version: '16.x' + + - name: Install Dependencies + run: | + curl https://install.meteor.com | /bin/sh + npm i -g @zodern/mtest + - name: Run Tests + run: | + mtest --package ./ --once ${{ matrix.meteorRelease }} \ No newline at end of file diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 787c8c3..0000000 --- a/.travis.yml +++ /dev/null @@ -1,6 +0,0 @@ -language: node_js -node_js: - - "12" - - "14" -before_install: - - "curl -L http://git.io/ejPSng | /bin/sh" diff --git a/History.md b/History.md index 8f65402..f4db22d 100644 --- a/History.md +++ b/History.md @@ -1,4 +1,18 @@ ## vNEXT + +## v0.6.0 + +- Code Format Refactor +- Changed Deps to Tracker (#49) +- Only show log output if running in development +- Added _timeSync Meteor Method for doing timesync over DDP instead of HTTP +- Auto switch to DDP after initial HTTP timesync to improve subsequent round trip times +- Added option TimeSync.forceDDP to always use DDP, even for first sync (which may be slow!) +- Shortened resync interval from 1 minute to 30 seconds when using DDP. +- Added tests for DDP and HTTP sync +- Added option to set the timesync URL using `TimeSync.setSyncUrl` +- Removed IE8 compat function + ## v0.5.5 - Added compatibility for Meteor 3.0-beta.7 @@ -32,11 +46,11 @@ ## v0.3.4 -- Explicitly pull in client-side `check` for Meteor 1.2 apps. +- Explicitly pull in client-side `check` for Meteor 1.2 apps. ## v0.3.3 -- Be more robust with sync url when outside of Cordova. (#30) +- Be more robust with sync url when outside of Cordova. (#30) ## v0.3.2 diff --git a/client/index.js b/client/index.js new file mode 100644 index 0000000..9bc8c21 --- /dev/null +++ b/client/index.js @@ -0,0 +1,6 @@ +import { TimeSync, SyncInternals } from './timesync-client'; + +export { + TimeSync, + SyncInternals, +}; \ No newline at end of file diff --git a/client/timesync-client.js b/client/timesync-client.js new file mode 100644 index 0000000..56bd804 --- /dev/null +++ b/client/timesync-client.js @@ -0,0 +1,194 @@ +import { Meteor } from 'meteor/meteor'; +import { Tracker } from 'meteor/tracker'; +import { HTTP } from 'meteor/http'; + +TimeSync = { + loggingEnabled: Meteor.isDevelopment, + forceDDP: false +}; + +function log( /* arguments */ ) { + if (TimeSync.loggingEnabled) { + Meteor._debug.apply(this, arguments); + } +} + +const defaultInterval = 1000; + +// Internal values, exported for testing +SyncInternals = { + offset: undefined, + roundTripTime: undefined, + offsetTracker: new Tracker.Dependency(), + syncTracker: new Tracker.Dependency(), + isSynced: false, + usingDDP: false, + timeTick: {}, + getDiscrepancy: function (lastTime, currentTime, interval) { + return currentTime - (lastTime + interval) + } +}; + +SyncInternals.timeTick[defaultInterval] = new Tracker.Dependency(); + +const maxAttempts = 5; +let attempts = 0; + +/* + This is an approximation of + http://en.wikipedia.org/wiki/Network_Time_Protocol + + If this turns out to be more accurate under the connect handlers, + we should try taking multiple measurements. + */ + +let syncUrl; + +TimeSync.setSyncUrl = function (url) { + if (url) { + syncUrl = url; + } else if (Meteor.isCordova || Meteor.isDesktop) { + // Only use Meteor.absoluteUrl for Cordova and Desktop; see + // https://github.com/meteor/meteor/issues/4696 + // https://github.com/mizzao/meteor-timesync/issues/30 + // Cordova should never be running out of a subdirectory... + syncUrl = Meteor.absoluteUrl('_timesync'); + } else { + // Support Meteor running in relative paths, based on computed root url prefix + // https://github.com/mizzao/meteor-timesync/pull/40 + const basePath = __meteor_runtime_config__.ROOT_URL_PATH_PREFIX || ''; + syncUrl = basePath + '/_timesync'; + } +}; +TimeSync.getSyncUrl = function () { + return syncUrl; +} +TimeSync.setSyncUrl(); + +const updateOffset = function () { + const t0 = Date.now(); + if (TimeSync.forceDDP || SyncInternals.useDDP) { + Meteor.call('_timeSync', function (err, res) { + handleResponse(t0, err, res); + }); + } else { + HTTP.get(syncUrl, function (err, res) { + handleResponse(t0, err, res); + }); + } +}; + +const handleResponse = function (t0, err, res) { + const t3 = Date.now(); // Grab this now + if (err) { + // We'll still use our last computed offset if is defined + log('Error syncing to server time: ', err); + if (++attempts <= maxAttempts) { + Meteor.setTimeout(TimeSync.resync, 1000); + } else { + log('Max number of time sync attempts reached. Giving up.'); + } + return; + } + + attempts = 0; // It worked + const response = res.content || res; + const ts = parseInt(response, 10); + SyncInternals.isSynced = true; + SyncInternals.offset = Math.round(((ts - t0) + (ts - t3)) / 2); + SyncInternals.roundTripTime = t3 - t0; // - (ts - ts) which is 0 + SyncInternals.offsetTracker.changed(); +} + +// Reactive variable for server time that updates every second. +TimeSync.serverTime = function (clientTime, interval) { + check(interval, Match.Optional(Match.Integer)); + // If a client time is provided, we don't need to depend on the tick. + if (!clientTime) getTickDependency(interval || defaultInterval).depend(); + + SyncInternals.offsetTracker.depend(); // depend on offset to enable reactivity + // Convert Date argument to epoch as necessary + return (+clientTime || Date.now()) + SyncInternals.offset; +}; + +// Reactive variable for the difference between server and client time. +TimeSync.serverOffset = function () { + SyncInternals.offsetTracker.depend(); + return SyncInternals.offset; +}; + +TimeSync.roundTripTime = function () { + SyncInternals.offsetTracker.depend(); + return SyncInternals.roundTripTime; +}; + +TimeSync.isSynced = function () { + SyncInternals.offsetTracker.depend(); + return SyncInternals.isSynced; +}; + +let resyncIntervalId = null; + +TimeSync.resync = function () { + if (resyncIntervalId !== null) Meteor.clearInterval(resyncIntervalId); + + updateOffset(); + resyncIntervalId = Meteor.setInterval(updateOffset, (SyncInternals.useDDP) ? 300000 : 600000); +}; + +// Run this as soon as we load, even before Meteor.startup() +// Run again whenever we reconnect after losing connection +let wasConnected = false; + +Tracker.autorun(function () { + const connected = Meteor.status().connected; + if (connected && !wasConnected) TimeSync.resync(); + wasConnected = connected; + SyncInternals.useDDP = connected; +}); + +// Resync if unexpected change by more than a few seconds. This needs to be +// somewhat lenient, or a CPU-intensive operation can trigger a re-sync even +// when the offset is still accurate. In any case, we're not going to be able to +// catch very small system-initiated NTP adjustments with this, anyway. +const tickCheckTolerance = 5000; + +let lastClientTime = Date.now(); + +// Set up a new interval for any amount of reactivity. +function getTickDependency(interval) { + + if (!SyncInternals.timeTick[interval]) { + const dep = new Tracker.Dependency(); + + Meteor.setInterval(function () { + dep.changed(); + }, interval); + + SyncInternals.timeTick[interval] = dep; + } + + return SyncInternals.timeTick[interval]; +} + +// Set up special interval for the default tick, which also watches for re-sync +Meteor.setInterval(function () { + const currentClientTime = Date.now(); + const discrepancy = SyncInternals.getDiscrepancy(lastClientTime, currentClientTime, defaultInterval); + + if (Math.abs(discrepancy) < tickCheckTolerance) { + // No problem here, just keep ticking along + SyncInternals.timeTick[defaultInterval].changed(); + } else { + // resync on major client clock changes + // based on http://stackoverflow.com/a/3367542/1656818 + log('Clock discrepancy detected. Attempting re-sync.'); + // Refuse to compute server time and try to guess new server offset. Guessing only works if the server time hasn't changed. + SyncInternals.offset = SyncInternals.offset - discrepancy; + SyncInternals.isSynced = false; + SyncInternals.offsetTracker.changed(); + TimeSync.resync(); + } + + lastClientTime = currentClientTime; +}, defaultInterval); diff --git a/package.js b/package.js index 4809d7f..5ae960e 100644 --- a/package.js +++ b/package.js @@ -1,12 +1,12 @@ Package.describe({ - name: "mizzao:timesync", - summary: "NTP-style time synchronization between server and client", - version: "0.5.5", + name: 'mizzao:timesync', + summary: 'NTP-style time synchronization between server and client', + version: '0.6.0', git: "https://github.com/Meteor-Community-Packages/meteor-timesync" }); Package.onUse(function (api) { - api.versionsFrom(["1.12", "2.3", '3.0-beta.7']); + api.versionsFrom(["1.12", "2.3"]); api.use([ 'check', @@ -14,27 +14,30 @@ Package.onUse(function (api) { 'http' ], 'client'); - api.use('webapp', 'server'); + api.use(['webapp'], 'server'); - api.use('ecmascript'); + api.use(['ecmascript']); // Our files - api.addFiles('timesync-server.js', 'server'); - api.addFiles('timesync-client.js', 'client'); + api.addFiles('server/index.js', 'server'); + api.addFiles('client/index.js', 'client'); api.export('TimeSync', 'client'); - api.export('SyncInternals', 'client', {testOnly: true} ); + api.export('SyncInternals', 'client', { + testOnly: true + }); }); Package.onTest(function (api) { api.use([ + 'ecmascript', 'tinytest', 'test-helpers' ]); - api.use(["tracker", "underscore"], 'client'); + api.use(['tracker', 'underscore'], 'client'); - api.use("mizzao:timesync"); + api.use('mizzao:timesync'); api.addFiles('tests/client.js', 'client'); }); diff --git a/server/index.js b/server/index.js new file mode 100644 index 0000000..ab8e570 --- /dev/null +++ b/server/index.js @@ -0,0 +1 @@ +import './timesync-server'; \ No newline at end of file diff --git a/timesync-server.js b/server/timesync-server.js similarity index 73% rename from timesync-server.js rename to server/timesync-server.js index 7950f17..d5ae687 100644 --- a/timesync-server.js +++ b/server/timesync-server.js @@ -1,18 +1,20 @@ +import { Meteor } from "meteor/meteor"; + // Use rawConnectHandlers so we get a response as quickly as possible // https://github.com/meteor/meteor/blob/devel/packages/webapp/webapp_server.js const url = new URL(Meteor.absoluteUrl("/_timesync")); WebApp.rawConnectHandlers.use(url.pathname, - function(req, res, next) { + function (req, res, next) { // Never ever cache this, otherwise weird times are shown on reload // http://stackoverflow.com/q/18811286/586086 - res.setHeader("Cache-Control", "no-cache, no-store, must-revalidate"); - res.setHeader("Pragma", "no-cache"); - res.setHeader("Expires", 0); + res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate'); + res.setHeader('Pragma', 'no-cache'); + res.setHeader('Expires', 0); // Avoid MIME type warnings in browsers - res.setHeader("Content-Type", "text/plain"); + res.setHeader('Content-Type', 'text/plain'); // Cordova lives in a local webserver, so it does CORS // we need to bless it's requests in order for it to accept our results @@ -30,3 +32,10 @@ WebApp.rawConnectHandlers.use(url.pathname, res.end(Date.now().toString()); } ); + +Meteor.methods({ + _timeSync: function () { + this.unblock(); + return Date.now(); + } +}); diff --git a/tests/client.js b/tests/client.js index 3a015b1..ee21267 100644 --- a/tests/client.js +++ b/tests/client.js @@ -1,15 +1,20 @@ -Tinytest.add("timesync - tick check - normal tick", function(test) { - var lastTime = 5000; - var currentTime = 6000; - var interval = 1000; +import { Meteor } from 'meteor/meteor'; +import { Tracker } from 'meteor/tracker'; +import { HTTP } from 'meteor/http'; +import { _ } from 'meteor/underscore'; + +Tinytest.add("timesync - tick check - normal tick", function (test) { + const lastTime = 5000; + const currentTime = 6000; + const interval = 1000; test.equal(SyncInternals.getDiscrepancy(lastTime, currentTime, interval), 0); }); -Tinytest.add("timesync - tick check - slightly off", function(test) { - var lastTime = 5000; - var currentTime = 6500; - var interval = 1000; +Tinytest.add("timesync - tick check - slightly off", function (test) { + const lastTime = 5000; + let currentTime = 6500; + const interval = 1000; test.equal(SyncInternals.getDiscrepancy(lastTime, currentTime, interval), 500); @@ -18,10 +23,10 @@ Tinytest.add("timesync - tick check - slightly off", function(test) { test.equal(SyncInternals.getDiscrepancy(lastTime, currentTime, interval), -500); }); -Tinytest.add("timesync - tick check - big jump", function(test) { - var lastTime = 5000; - var currentTime = 0; - var interval = 1000; +Tinytest.add("timesync - tick check - big jump", function (test) { + const lastTime = 5000; + let currentTime = 0; + const interval = 1000; test.equal(SyncInternals.getDiscrepancy(lastTime, currentTime, interval), -6000); @@ -34,10 +39,10 @@ Tinytest.add("timesync - tick check - big jump", function(test) { TODO: add tests for proper dependencies in reactive functions */ -Tinytest.addAsync("timesync - basic - initial sync", function(test, next) { +Tinytest.addAsync("timesync - basic - initial sync", function (test, next) { function success() { - var syncedTime = TimeSync.serverTime(); + const syncedTime = TimeSync.serverTime(); // Make sure the time exists test.isTrue(syncedTime); @@ -46,7 +51,7 @@ Tinytest.addAsync("timesync - basic - initial sync", function(test, next) { // always be true in PhantomJS tests where client/server are the same // machine, although it might fail in development environments, for example // when the server and client are different VMs. - test.isTrue( Math.abs(syncedTime - Date.now()) < 1000 ); + test.isTrue(Math.abs(syncedTime - Date.now()) < 1000); next(); } @@ -59,43 +64,45 @@ Tinytest.addAsync("timesync - basic - initial sync", function(test, next) { simplePoll(TimeSync.isSynced, success, fail, 5000, 100); }); -Tinytest.addAsync("timesync - basic - serverTime format", function(test, next) { +Tinytest.addAsync("timesync - basic - serverTime format", function (test, next) { - test.isTrue(_.isNumber( TimeSync.serverTime() )); + test.isTrue(_.isNumber(TimeSync.serverTime())); - test.isTrue(_.isNumber( TimeSync.serverTime(null) )); + test.isTrue(_.isNumber(TimeSync.serverTime(null))); // Accept Date as client time - test.isTrue(_.isNumber( TimeSync.serverTime(new Date()) )); + test.isTrue(_.isNumber(TimeSync.serverTime(new Date()))); // Accept epoch as client time - test.isTrue(_.isNumber( TimeSync.serverTime(Date.now()) )); + test.isTrue(_.isNumber(TimeSync.serverTime(Date.now()))); next(); }); -Tinytest.addAsync("timesync - basic - different sync intervals", function(test, next) { +Tinytest.addAsync("timesync - basic - different sync intervals", function (test, next) { - var aCount = 0, bCount = 0, cCount = 0; + let aCount = 0; + let bCount = 0; + let cCount = 0; - var a = Tracker.autorun(function () { + const a = Tracker.autorun(function () { TimeSync.serverTime(null, 500); aCount++; }); - var b = Tracker.autorun(function () { + const b = Tracker.autorun(function () { TimeSync.serverTime(); bCount++; }); - var c = Tracker.autorun(function () { + const c = Tracker.autorun(function () { TimeSync.serverTime(null, 2000); cCount++; }); - var testInterval = 4990; + const testInterval = 4990; - Meteor.setTimeout(function() { + Meteor.setTimeout(function () { test.equal(aCount, 10); // 0, 500, 1000, 1500 ... // not going to be 5 since the first tick won't generate this dep @@ -116,3 +123,39 @@ Tinytest.addAsync("timesync - basic - different sync intervals", function(test, }, testInterval); }); + +Tinytest.addAsync("timesync - basic - DDP timeSync", function (test, next) { + Meteor.call('_timeSync', function (err, res) { + if (err) { + test.fail(); + next(); + } + test.isTrue(_.isNumber(res)); + + // Make sure it's close to the current time on the client. This should + // always be true in PhantomJS tests where client/server are the same + // machine, although it might fail in development environments, for example + // when the server and client are different VMs. + test.isTrue(Math.abs(res - Date.now()) < 1000); + + next(); + }); +}); + +Tinytest.addAsync("timesync - basic - HTTP timeSync", function (test, next) { + const syncUrl = TimeSync.getSyncUrl(); + + test.isNotNull(syncUrl); + + HTTP.get(syncUrl, function (err, res) { + if (err) { + test.fail(); + next(); + } + test.isTrue(res.content); + const serverTime = parseInt(res.content,10); + test.isTrue(_.isNumber(serverTime)); + test.isTrue(Math.abs(serverTime - Date.now()) < 1000); + next(); + }); +}); diff --git a/timesync-client.js b/timesync-client.js deleted file mode 100644 index 8929055..0000000 --- a/timesync-client.js +++ /dev/null @@ -1,171 +0,0 @@ -//IE8 doesn't have Date.now() -Date.now = Date.now || function() { return +new Date; }; - -TimeSync = { - loggingEnabled: true -}; - -function log(/* arguments */) { - if (TimeSync.loggingEnabled) { - Meteor._debug.apply(this, arguments); - } -} - -var defaultInterval = 1000; - -// Internal values, exported for testing -SyncInternals = { - offset: undefined, - roundTripTime: undefined, - offsetDep: new Deps.Dependency(), - syncDep: new Deps.Dependency(), - isSynced: false, - timeTick: {}, - getDiscrepancy: function (lastTime, currentTime, interval) { - return currentTime - (lastTime + interval) - } -}; - -SyncInternals.timeTick[defaultInterval] = new Deps.Dependency(); - -var maxAttempts = 5; -var attempts = 0; - -/* - This is an approximation of - http://en.wikipedia.org/wiki/Network_Time_Protocol - - If this turns out to be more accurate under the connect handlers, - we should try taking multiple measurements. - */ - -var syncUrl; -if (Meteor.isCordova || Meteor.isDesktop) { - // Only use Meteor.absoluteUrl for Cordova and Desktop; see - // https://github.com/meteor/meteor/issues/4696 - // https://github.com/mizzao/meteor-timesync/issues/30 - // Cordova should never be running out of a subdirectory... - syncUrl = Meteor.absoluteUrl("_timesync"); -} -else { - // Support Meteor running in relative paths, based on computed root url prefix - // https://github.com/mizzao/meteor-timesync/pull/40 - const basePath = __meteor_runtime_config__.ROOT_URL_PATH_PREFIX || ''; - syncUrl = basePath + "/_timesync"; -} - -var updateOffset = function() { - var t0 = Date.now(); - - HTTP.get(syncUrl, function(err, response) { - var t3 = Date.now(); // Grab this now - if (err) { - // We'll still use our last computed offset if is defined - log("Error syncing to server time: ", err); - if (++attempts <= maxAttempts) - Meteor.setTimeout(TimeSync.resync, 1000); - else - log("Max number of time sync attempts reached. Giving up."); - return; - } - - attempts = 0; // It worked - - var ts = parseInt(response.content); - SyncInternals.isSynced = true; - SyncInternals.offset = Math.round(((ts - t0) + (ts - t3)) / 2); - SyncInternals.roundTripTime = t3 - t0; // - (ts - ts) which is 0 - SyncInternals.offsetDep.changed(); - }); -}; - -// Reactive variable for server time that updates every second. -TimeSync.serverTime = function(clientTime, interval) { - check(interval, Match.Optional(Match.Integer)); - // If a client time is provided, we don't need to depend on the tick. - if ( !clientTime ) getTickDependency(interval || defaultInterval).depend(); - - SyncInternals.offsetDep.depend(); // depend on offset to enable reactivity - // Convert Date argument to epoch as necessary - return (+clientTime || Date.now()) + SyncInternals.offset; -}; - -// Reactive variable for the difference between server and client time. -TimeSync.serverOffset = function() { - SyncInternals.offsetDep.depend(); - return SyncInternals.offset; -}; - -TimeSync.roundTripTime = function() { - SyncInternals.offsetDep.depend(); - return SyncInternals.roundTripTime; -}; - -TimeSync.isSynced = function() { - SyncInternals.offsetDep.depend(); - return SyncInternals.isSynced; -}; - -var resyncIntervalId = null; - -TimeSync.resync = function() { - if (resyncIntervalId !== null) Meteor.clearInterval(resyncIntervalId); - updateOffset(); - resyncIntervalId = Meteor.setInterval(updateOffset, 600000); -}; - -// Run this as soon as we load, even before Meteor.startup() -// Run again whenever we reconnect after losing connection -var wasConnected = false; - -Deps.autorun(function() { - var connected = Meteor.status().connected; - if ( connected && !wasConnected ) TimeSync.resync(); - wasConnected = connected; -}); - -// Resync if unexpected change by more than a few seconds. This needs to be -// somewhat lenient, or a CPU-intensive operation can trigger a re-sync even -// when the offset is still accurate. In any case, we're not going to be able to -// catch very small system-initiated NTP adjustments with this, anyway. -var tickCheckTolerance = 5000; - -var lastClientTime = Date.now(); - -// Set up a new interval for any amount of reactivity. -function getTickDependency(interval) { - - if ( !SyncInternals.timeTick[interval] ) { - var dep = new Deps.Dependency(); - - Meteor.setInterval(function() { - dep.changed(); - }, interval); - - SyncInternals.timeTick[interval] = dep; - } - - return SyncInternals.timeTick[interval]; -} - -// Set up special interval for the default tick, which also watches for re-sync -Meteor.setInterval(function() { - var currentClientTime = Date.now(); - - var discrepancy = SyncInternals.getDiscrepancy(lastClientTime, currentClientTime, defaultInterval); - if (Math.abs(discrepancy) < tickCheckTolerance) { - // No problem here, just keep ticking along - SyncInternals.timeTick[defaultInterval].changed(); - } else { - // resync on major client clock changes - // based on http://stackoverflow.com/a/3367542/1656818 - log("Clock discrepancy detected. Attempting re-sync."); - // Refuse to compute server time and try to guess new server offset. Guessing only works if the server time hasn't changed. - SyncInternals.offset = SyncInternals.offset - discrepancy; - SyncInternals.isSynced = false; - SyncInternals.offsetDep.changed(); - TimeSync.resync(); - } - - lastClientTime = currentClientTime; -}, defaultInterval);