parent
a7f9771de1
commit
96f392c475
@ -0,0 +1,2 @@ |
||||
.build* |
||||
.versions |
||||
@ -0,0 +1,5 @@ |
||||
language: node_js |
||||
node_js: |
||||
- "0.10" |
||||
before_install: |
||||
- "curl -L http://git.io/ejPSng | /bin/sh" |
||||
@ -0,0 +1,72 @@ |
||||
## vNEXT |
||||
|
||||
## v0.3.4 |
||||
|
||||
- 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) |
||||
|
||||
## v0.3.2 |
||||
|
||||
- Fix issue when used in Cordova. (#22, #26, #27) |
||||
|
||||
## v0.3.1 |
||||
|
||||
- Fix an issue where `TimeSync.serverTime` returned an erroneous value when passed a `Date` (instead of an epoch). (#23) |
||||
|
||||
## v0.3.0 |
||||
|
||||
- `TimeSync.serverTime` now supports an optional second `updateInterval` argument, causing the reactive value to update less frequently. (#10) |
||||
- `TimeSync.loggingEnabled` can be now set to false to suppress client log output. (#21) |
||||
- Explicitly set MIME type on timesync endpoint. (#17, #18) |
||||
|
||||
## v0.2.2 |
||||
|
||||
- **Updated for Meteor 0.9.** |
||||
- Further adjust clock watching tolerance to be less sensitive to CPU. |
||||
|
||||
## v0.2.1 |
||||
|
||||
- Re-sync automatically after a reconnection. |
||||
- Adjust clock watching tolerance so as to be less sensitive to heavy client CPU usage. |
||||
|
||||
## v0.2.0 |
||||
|
||||
- Clock change watching is now on by default (it's very lightweight and only involves grabbing and checking a `Date`). |
||||
- Invalidate offset value and dependent time computations when we detect a clock change. |
||||
- Added a `Date.now` shim for earlier versions of IE. |
||||
- Reorganized code for testing and added some basic tests. |
||||
|
||||
## v0.1.6 |
||||
|
||||
- Added the optional `TimeSync.watchClockChanges` which can resync if a client's clock is detected to have significantly changed. |
||||
- Added retry attempts to syncing, making it more robust over a hot code reload among other situations. |
||||
|
||||
## v0.1.5 |
||||
|
||||
- Use `WebApp.rawConnectHandlers` as a less janky way of getting our date request handled first. |
||||
- Fixed an issue where a cached reload could result in a wacky time offset due to the server time being cached. |
||||
|
||||
## v0.1.4 |
||||
|
||||
- Switch to JS at the request of @raix and @arunoda ;-) |
||||
- Use a middleware handler, spliced into the top of the connect stack, instead of a Meteor method to avoid arbitrary method blocking delay. This improves accuracy significantly. |
||||
- Compute a RTT value in `TimeSync.roundTripTime` as well as a time offset. |
||||
|
||||
## v0.1.3 |
||||
|
||||
- Ensure that the computed offset is always an integer number of milliseconds. |
||||
|
||||
## v0.1.2 |
||||
|
||||
- Added the `TimeSync.resync` function that triggers a resync with the server. |
||||
|
||||
## v0.1.1 |
||||
|
||||
- Added the reactive function `TimeSync.isSynced` to determine if an initial sync has taken place. |
||||
|
||||
## v0.1.0 |
||||
|
||||
- First release. |
||||
@ -0,0 +1,21 @@ |
||||
The MIT License (MIT) |
||||
|
||||
Copyright (c) 2015 Andrew Mao |
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy |
||||
of this software and associated documentation files (the "Software"), to deal |
||||
in the Software without restriction, including without limitation the rights |
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell |
||||
copies of the Software, and to permit persons to whom the Software is |
||||
furnished to do so, subject to the following conditions: |
||||
|
||||
The above copyright notice and this permission notice shall be included in |
||||
all copies or substantial portions of the Software. |
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR |
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, |
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE |
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER |
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, |
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN |
||||
THE SOFTWARE. |
||||
@ -0,0 +1,38 @@ |
||||
meteor-timesync [](https://travis-ci.org/mizzao/meteor-timesync) |
||||
=============== |
||||
|
||||
NTP-style time synchronization between server and client, and facilities to |
||||
use server time reactively in Meteor applications. |
||||
|
||||
## What's this do? |
||||
|
||||
Meteor clients don't necessarily have accurate timestamps relative to your server. This package computes and maintains an offset between server and client, allowing server timestamps to be used on the client (especially for displaying time differences). It also provides facilities to use time reactively in your application. |
||||
|
||||
There is a demo as part of the user-status app at http://user-status.meteor.com. |
||||
|
||||
## Installation |
||||
|
||||
``` |
||||
meteor add mizzao:timesync |
||||
``` |
||||
|
||||
## Usage |
||||
|
||||
- `TimeSync.serverTime(clientTime, updateInterval)`: returns the server time for a given client time, as a UTC/Unix timestamp. A reactive variable which changes with the computed offset, and updates continually. Pass in `clientTime` optionally to specify a particular time on the client, instead of reactively depending on the current time. Pass in `updateInterval` to change the rate (in milliseconds) at which the reactive variable updates; the default value is 1000 (1 second). |
||||
- `TimeSync.serverOffset()`: returns the current time difference between the server and the client. Reactively updates as the offset is recomputed. |
||||
- `TimeSync.roundTripTime()`: The round trip ping to the server. Also reactive. |
||||
- `TimeSync.isSynced()`: Reactive variable that determines if an initial sync has taken place. |
||||
- `TimeSync.resync()`: Re-triggers a sync with the server. Can be useful because the initial sync often takes place during a lot of traffic with the server and could be less accurate. |
||||
- `TimeSync.loggingEnabled`: defaults to `true`, set this to `false` to suppress diagnostic syncing messages on the client. |
||||
|
||||
To use the above functions in a non-reactive context, use [`Deps.nonreactive`](http://docs.meteor.com/#deps_nonreactive). This is useful if you are displaying a lot of timestamps or differences on a page and you don't want them to be constantly recomputed on the client. However, displaying time reactively should be pretty efficient with Meteor 0.8.0+ (Blaze). |
||||
|
||||
Note that `TimeSync.serverTime` returns a timestamp, not a `Date`, but you can easily construct a date with `new Date(TimeSync.serverTime(...))`. |
||||
|
||||
You can also use something like `TimeSync.serverTime(null, 5000)` to get a reactive time value that only updates at 5 second intervals. All reactive time variables with the same value of `updateInterval` are guaranteed to be invalidated at the same time. |
||||
|
||||
## Notes |
||||
|
||||
- This library is a crude approximation of NTP, at the moment. It's empirically shown to be accurate to under 100 ms on the meteor.com servers. |
||||
- We could definitely do something smarter and more accurate, with multiple measurements and exponentially weighted updating. |
||||
- Check out the moment library [packaged for meteor](https://github.com/acreeger/meteor-moment) for formatting and displaying the differences computed by this package. |
||||
@ -0,0 +1,38 @@ |
||||
Package.describe({ |
||||
name: "mizzao:timesync", |
||||
summary: "NTP-style time synchronization between server and client", |
||||
version: "0.3.4", |
||||
git: "https://github.com/mizzao/meteor-timesync.git" |
||||
}); |
||||
|
||||
Package.onUse(function (api) { |
||||
api.versionsFrom("1.2.0.1"); |
||||
|
||||
api.use([ |
||||
'check', |
||||
'tracker', |
||||
'http' |
||||
], 'client'); |
||||
|
||||
api.use('webapp', 'server'); |
||||
|
||||
// Our files
|
||||
api.addFiles('timesync-server.js', 'server'); |
||||
api.addFiles('timesync-client.js', 'client'); |
||||
|
||||
api.export('TimeSync', 'client'); |
||||
api.export('SyncInternals', 'client', {testOnly: true} ); |
||||
}); |
||||
|
||||
Package.onTest(function (api) { |
||||
api.use([ |
||||
'tinytest', |
||||
'test-helpers' |
||||
]); |
||||
|
||||
api.use(["tracker", "underscore"], 'client'); |
||||
|
||||
api.use("mizzao:timesync"); |
||||
|
||||
api.addFiles('tests/client.js', 'client'); |
||||
}); |
||||
@ -0,0 +1,121 @@ |
||||
Tinytest.add("timesync - tick check - normal tick", function(test) { |
||||
var lastTime = 5000; |
||||
var currentTime = 6000; |
||||
var interval = 1000; |
||||
var tolerance = 1000; |
||||
|
||||
test.equal(SyncInternals.timeCheck(lastTime, currentTime, interval, tolerance), true); |
||||
}); |
||||
|
||||
Tinytest.add("timesync - tick check - slightly off", function(test) { |
||||
var lastTime = 5000; |
||||
var currentTime = 6500; |
||||
var interval = 1000; |
||||
var tolerance = 1000; |
||||
|
||||
test.equal(SyncInternals.timeCheck(lastTime, currentTime, interval, tolerance), true); |
||||
|
||||
currentTime = 5500; |
||||
|
||||
test.equal(SyncInternals.timeCheck(lastTime, currentTime, interval, tolerance), true); |
||||
}); |
||||
|
||||
Tinytest.add("timesync - tick check - big jump", function(test) { |
||||
var lastTime = 5000; |
||||
var currentTime = 0; |
||||
var interval = 1000; |
||||
var tolerance = 1000; |
||||
|
||||
test.equal(SyncInternals.timeCheck(lastTime, currentTime, interval, tolerance), false); |
||||
|
||||
currentTime = 10000; |
||||
|
||||
test.equal(SyncInternals.timeCheck(lastTime, currentTime, interval, tolerance), false); |
||||
}); |
||||
|
||||
/* |
||||
TODO: add tests for proper dependencies in reactive functions |
||||
*/ |
||||
|
||||
Tinytest.addAsync("timesync - basic - initial sync", function(test, next) { |
||||
|
||||
function success() { |
||||
var syncedTime = TimeSync.serverTime(); |
||||
|
||||
// Make sure the time exists
|
||||
test.isTrue(syncedTime); |
||||
|
||||
// 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(syncedTime - Date.now()) < 1000 ); |
||||
|
||||
next(); |
||||
} |
||||
|
||||
function fail() { |
||||
test.fail(); |
||||
next(); |
||||
} |
||||
|
||||
simplePoll(TimeSync.isSynced, success, fail, 5000, 100); |
||||
}); |
||||
|
||||
Tinytest.addAsync("timesync - basic - serverTime format", function(test, next) { |
||||
|
||||
test.isTrue(_.isNumber( TimeSync.serverTime() )); |
||||
|
||||
test.isTrue(_.isNumber( TimeSync.serverTime(null) )); |
||||
|
||||
// Accept Date as client time
|
||||
test.isTrue(_.isNumber( TimeSync.serverTime(new Date()) )); |
||||
|
||||
// Accept epoch as client time
|
||||
test.isTrue(_.isNumber( TimeSync.serverTime(Date.now()) )); |
||||
|
||||
next(); |
||||
}); |
||||
|
||||
Tinytest.addAsync("timesync - basic - different sync intervals", function(test, next) { |
||||
|
||||
var aCount = 0, bCount = 0, cCount = 0; |
||||
|
||||
var a = Tracker.autorun(function () { |
||||
TimeSync.serverTime(null, 500); |
||||
aCount++; |
||||
}); |
||||
|
||||
var b = Tracker.autorun(function () { |
||||
TimeSync.serverTime(); |
||||
bCount++; |
||||
}); |
||||
|
||||
var c = Tracker.autorun(function () { |
||||
TimeSync.serverTime(null, 2000); |
||||
cCount++; |
||||
}); |
||||
|
||||
var testInterval = 5000; |
||||
|
||||
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
|
||||
test.equal(bCount, 6); |
||||
test.equal(cCount, 3); // 0, 2000, 4000
|
||||
|
||||
test.isTrue(SyncInternals.timeTick[500]); |
||||
test.isTrue(SyncInternals.timeTick[1000]); |
||||
test.isTrue(SyncInternals.timeTick[2000]); |
||||
|
||||
test.equal(Object.keys(SyncInternals.timeTick).length, 3); |
||||
|
||||
a.stop(); |
||||
b.stop(); |
||||
c.stop(); |
||||
|
||||
next() |
||||
}, testInterval); |
||||
|
||||
}); |
||||
@ -0,0 +1,175 @@ |
||||
/* eslint-disable */ |
||||
//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(), |
||||
timeTick: {}, |
||||
|
||||
timeCheck: function (lastTime, currentTime, interval, tolerance) { |
||||
if (Math.abs(currentTime - lastTime - interval) < tolerance) { |
||||
// Everything is A-OK
|
||||
return true; |
||||
} |
||||
// We're no longer in sync.
|
||||
return false; |
||||
} |
||||
}; |
||||
|
||||
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. |
||||
*/ |
||||
|
||||
// Only use Meteor.absoluteUrl for Cordova; see
|
||||
// https://github.com/meteor/meteor/issues/4696
|
||||
// https://github.com/mizzao/meteor-timesync/issues/30
|
||||
var syncUrl = "/_timesync"; |
||||
if (__meteor_runtime_config__.ROOT_URL_PATH_PREFIX) { |
||||
syncUrl = __meteor_runtime_config__.ROOT_URL_PATH_PREFIX + syncUrl; |
||||
} |
||||
|
||||
if (Meteor.isCordova) { |
||||
syncUrl = Meteor.absoluteUrl("_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.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 we don't know the offset, we can't provide the server time.
|
||||
if ( !TimeSync.isSynced() ) return undefined; |
||||
// If a client time is provided, we don't need to depend on the tick.
|
||||
if ( !clientTime ) getTickDependency(interval || defaultInterval).depend(); |
||||
|
||||
// SyncInternals.offsetDep.depend(); implicit as we call isSynced()
|
||||
// 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.offset !== undefined; |
||||
}; |
||||
|
||||
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(); |
||||
|
||||
if ( SyncInternals.timeCheck( |
||||
lastClientTime, currentClientTime, defaultInterval, 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.
|
||||
SyncInternals.offset = undefined; |
||||
SyncInternals.offsetDep.changed(); |
||||
TimeSync.resync(); |
||||
} |
||||
|
||||
lastClientTime = currentClientTime; |
||||
}, defaultInterval); |
||||
|
||||
@ -0,0 +1,28 @@ |
||||
/* eslint-disable */ |
||||
// Use rawConnectHandlers so we get a response as quickly as possible
|
||||
// https://github.com/meteor/meteor/blob/devel/packages/webapp/webapp_server.js
|
||||
|
||||
var syncUrl = "/_timesync"; |
||||
if (__meteor_runtime_config__.ROOT_URL_PATH_PREFIX) { |
||||
syncUrl = __meteor_runtime_config__.ROOT_URL_PATH_PREFIX + syncUrl; |
||||
} |
||||
|
||||
WebApp.rawConnectHandlers.use(syncUrl, |
||||
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); |
||||
|
||||
// Avoid MIME type warnings in browsers
|
||||
res.setHeader("Content-Type", "text/plain"); |
||||
|
||||
// Cordova lives in meteor.local, so it does CORS
|
||||
if (req.headers && req.headers.origin === 'http://meteor.local') { |
||||
res.setHeader('Access-Control-Allow-Origin', 'http://meteor.local'); |
||||
} |
||||
|
||||
res.end(Date.now().toString()); |
||||
} |
||||
); |
||||
Loading…
Reference in new issue