UNPKG

makedrive

Version:
444 lines (384 loc) 14.4 kB
/** * MakeDrive is a single/shared Filer filesystem instance with * manual- and auto-sync'ing features. A client first gets the * filesystem instance like so: * * var fs = MakeDrive.fs(); * * Multiple calls to MakeDrive.fs() will return the same instance. * * A number of configuration options can be passed to the fs() function. * These include: * * - manual=true - by default the filesystem syncs automatically in * the background. This disables it. * * - memory=<Boolean> - by default we use a persistent store (indexeddb * or websql). Using memory=true overrides and uses a temporary ram disk. * * - provider=<Object> - a Filer data provider to use instead of the * default provider normally used. The provider given should already * be instantiated (i.e., don't pass a constructor function). * * - forceCreate=<Boolean> - by default we return the same fs instance with * every call to MakeDrive.fs(). In some cases it is necessary to have * multiple instances. Using forceCreate=true does this. * * - interval=<Number> - by default, the filesystem syncs every 15 seconds if * auto syncing is turned on otherwise the interval between syncs can be * specified in ms. * * Various bits of Filer are available on MakeDrive, including: * * - MakeDrive.Buffer * - MakeDrive.Path * - MakeDrive.Errors * * The filesystem instance returned by MakeDrive.fs() also includes * a new property `sync`. The fs.sync property is an EventEmitter * which emits the following events: * * - 'error': an error occured while connecting/syncing. The error * object is passed as the first arg to the event. * * - 'connected': a connection was established with the sync server * * - 'disconnected': the connection to the sync server was lost, either * due to the client or server. * * - 'syncing': a sync with the server has begun. A subsequent 'completed' * or 'error' event should follow at some point, indicating whether * or not the sync was successful. * * - 'completed': a sync has completed and was successful. * * * The `sync` property also exposes a number of methods, including: * * - connect(url, [token]): try to connect to the specified sync server URL. * An 'error' or 'connected' event will follow, depending on success. If the * token parameter is provided, that authentication token will be used. Otherwise * the client will try to obtain one from the server's /api/sync route. This * requires the user to be authenticated previously with Webmaker. * * - disconnect(): disconnect from the sync server. * * - request(path): request a sync with the server for the specified * path. Such requests may or may not be processed right away. * * * Finally, the `sync` propery also exposes a `state`, which is the * current sync state and can be one of: * * sync.SYNC_DISCONNECTED = "SYNC DISCONNECTED" (also the initial state) * sync.SYNC_CONNECTING = "SYNC CONNECTING" * sync.SYNC_CONNECTED = "SYNC CONNECTED" * sync.SYNC_SYNCING = "SYNC SYNCING" * sync.SYNC_ERROR = "SYNC ERROR" */ var SyncManager = require('./sync-manager.js'); var SyncFileSystem = require('./sync-filesystem.js'); var Filer = require('../../lib/filer.js'); var EventEmitter = require('events').EventEmitter; var resolvePath = require('../../lib/sync-path-resolver.js').resolveFromArray; var log = require('./logger.js'); var MakeDrive = {}; // Expose the logging api, so users can set log level MakeDrive.log = log; // Expose bits of Filer that clients will need on MakeDrive MakeDrive.Buffer = Filer.Buffer; MakeDrive.Path = Filer.Path; MakeDrive.Errors = Filer.Errors; module.exports = MakeDrive; function createFS(options) { options.manual = options.manual === true; options.memory = options.memory === true; options.autoReconnect = options.autoReconnect !== false; // Use a supplied provider, in-memory RAM disk, or Fallback provider (default). if(options.memory) { log.debug('Using Filer Memory provider for fs'); options.provider = new Filer.FileSystem.providers.Memory('makedrive'); } if(!options.provider) { log.debug('Using Fallback provider for fs'); options.provider = new Filer.FileSystem.providers.Fallback('makedrive'); } else { log.debug('Using user-provided provider for fs', options.provider); } // Our fs instance is a modified Filer fs, with extra sync awareness // for conflict mediation, etc. We keep an internal reference to the // raw Filer fs, and use the SyncFileSystem instance externally. var _fs = new Filer.FileSystem(options, function(err) { // FS creation errors will be logged for now for debugging purposes if(err) { log.error('Filesystem initialization error', err); } }); var fs = new SyncFileSystem(_fs); var sync = fs.sync = new EventEmitter(); var manager; // Auto-sync handles var autoSync; var pathCache; // Path that needs to be used for an upstream sync // to sync files that were determined to be more // up-to-date on the client during a downstream sync var upstreamPath; // State of the sync connection sync.SYNC_DISCONNECTED = "SYNC DISCONNECTED"; sync.SYNC_CONNECTING = "SYNC CONNECTING"; sync.SYNC_CONNECTED = "SYNC CONNECTED"; sync.SYNC_SYNCING = "SYNC SYNCING"; sync.SYNC_ERROR = "SYNC ERROR"; // Intitially we are not connected sync.state = sync.SYNC_DISCONNECTED; // Optionally warn when closing the window if still syncing function windowCloseHandler(event) { if(!options.windowCloseWarning) { return; } if(sync.state !== sync.SYNC_SYNCING) { return; } var confirmationMessage = "Sync currently underway, are you sure you want to close?"; (event || global.event).returnValue = confirmationMessage; return confirmationMessage; } function cleanupManager() { if(!manager) { return; } log.debug('Closing manager'); manager.close(); manager = null; } function requestSync(path) { // If we're not connected (or are already syncing), ignore this request if(sync.state === sync.SYNC_DISCONNECTED || sync.state === sync.SYNC_ERROR) { sync.emit('error', new Error('Invalid state. Expected ' + sync.SYNC_CONNECTED + ', got ' + sync.state)); log.warn('Tried to sync in invalid state: ' + sync.state); return; } // If there were no changes to the filesystem and // no path was passed to sync, ignore this request if(!fs.pathToSync && !path) { log.debug('Skipping sync request, no changes to sync'); return; } // If a path was passed sync using it if(path) { log.info('Requesting sync for ' + path); return manager.syncPath(path); } // Cache the path that needs to be synced for error recovery pathCache = fs.pathToSync; fs.pathToSync = null; log.info('Requesting sync for ' + pathCache); manager.syncPath(pathCache); } // Turn on auto-syncing if its not already on sync.auto = function(interval) { var syncInterval = interval|0 > 0 ? interval|0 : 15 * 1000; log.debug('Starting automatic syncing mode every ' + syncInterval + 'ms'); if(autoSync) { clearInterval(autoSync); } autoSync = setInterval(sync.request, syncInterval); }; // Turn off auto-syncing and turn on manual syncing sync.manual = function() { log.debug('Starting manual syncing mode'); if(autoSync) { clearInterval(autoSync); autoSync = null; } }; // The server stopped our upstream sync mid-way through. sync.onInterrupted = function() { fs.pathToSync = pathCache; sync.state = sync.SYNC_CONNECTED; sync.emit('error', new Error('Sync interrupted by server.')); log.warn('Sync interrupted by server, caching current path: ' + pathCache); }; sync.onError = function(err) { // Regress to the path that needed to be synced but failed // (likely because of a sync LOCK) fs.pathToSync = upstreamPath || pathCache; sync.state = sync.SYNC_ERROR; sync.emit('error', err); log.error('Sync error', err); }; sync.onDisconnected = function() { // Remove listeners so we don't leak instance variables if("onbeforeunload" in global) { log.debug('Removing window.beforeunload handler'); global.removeEventListener('beforeunload', windowCloseHandler); } if("onunload" in global){ log.debug('Removing window.unload handler'); global.removeEventListener('unload', cleanupManager); } sync.state = sync.SYNC_DISCONNECTED; sync.emit('disconnected'); log.info('Disconnected'); }; // Request that a sync begin. sync.request = function() { // sync.request does not take any parameters // as the path to sync is determined internally // requestSync on the other hand optionally takes // a path to sync which can be specified for // internal use requestSync(); }; // Try to connect to the server. sync.connect = function(url, token) { // Bail if we're already connected if(sync.state !== sync.SYNC_DISCONNECTED && sync.state !== sync.ERROR) { log.warn('Tried to connect, but already connected'); sync.emit('error', new Error("MakeDrive: Attempted to connect to \"" + url + "\", but a connection already exists!")); return; } // Also bail if we already have a SyncManager if(manager) { return; } // Upgrade connection state to `connecting` log.info('Connecting to MakeDrive server'); sync.state = sync.SYNC_CONNECTING; function downstreamSyncCompleted(paths, needUpstream) { var startTime; // Re-wire message handler functions for regular syncing // now that initial downstream sync is completed. sync.onSyncing = function() { sync.state = sync.SYNC_SYNCING; sync.emit('syncing'); log.info('Started syncing'); startTime = Date.now(); }; sync.onCompleted = function(paths, needUpstream) { // If changes happened to the files that needed to be synced // during the sync itself, they will be overwritten // https://github.com/mozilla/makedrive/issues/129 function complete() { sync.state = sync.SYNC_CONNECTED; sync.emit('completed'); var duration = (Date.now() - startTime) + 'ms'; log.info('Completed syncing in ' + duration); } if(!paths && !needUpstream) { return complete(); } // Changes in the client are newer (determined during // the sync) and need to be upstreamed if(needUpstream) { upstreamPath = resolvePath(needUpstream); complete(); log.debug('Client changes are newer for ' + upstreamPath); return requestSync(upstreamPath); } // If changes happened during a downstream sync // Change the path that needs to be synced manager.resetUnsynced(paths, function(err) { if(err) { log.error('Error resetting unsynced paths for ' + paths, err); return sync.onError(err); } upstreamPath = null; complete(); }); }; // Upgrade connection state to 'connected' sync.state = sync.SYNC_CONNECTED; // If we're in manual mode, bail before starting auto-sync if(options.manual) { sync.manual(); } else { sync.auto(options.interval); } // In a browser, try to clean-up after ourselves when window goes away if("onbeforeunload" in global) { log.debug('Adding window.beforeunload handler'); global.addEventListener('beforeunload', windowCloseHandler); } if("onunload" in global){ log.debug('Adding window.unload handler'); global.addEventListener('unload', cleanupManager); } log.info('Connected'); sync.emit('connected'); // If the downstream was completed and some // versions of files were not synced as they were // newer on the client, upstream them if(needUpstream) { upstreamPath = resolvePath(needUpstream); log.debug('Client changes are newer for ' + upstreamPath); requestSync(upstreamPath); } else { upstreamPath = null; } } function connect(token) { // Try to connect to provided server URL. Use the raw Filer fs // instance for all rsync operations on the filesystem, so that we // can untangle changes done by user vs. sync code. manager = new SyncManager(sync, _fs); manager.init(url, token, options, function(err) { if(err) { log.error('Error connecting to ' + url, err); sync.onError(err); return; } var startTime; // Wait on initial downstream sync events to complete sync.onSyncing = function() { // do nothing, wait for onCompleted() log.info('Starting initial downstream sync'); startTime = Date.now(); }; sync.onCompleted = function(paths, needUpstream) { // Downstream sync is done, finish connect() setup var duration = (Date.now() - startTime) + 'ms'; log.info('Completed initial downstream sync in ' + duration); downstreamSyncCompleted(paths, needUpstream); }; }); } connect(token); }; // Disconnect from the server sync.disconnect = function() { // Bail if we're not already connected if(sync.state === sync.SYNC_DISCONNECTED || sync.state === sync.ERROR) { log.warn('Tried to disconnect while not connected'); sync.emit('error', new Error("MakeDrive: Attempted to disconnect, but no server connection exists!")); return; } // Stop auto-syncing if(autoSync) { clearInterval(autoSync); autoSync = null; fs.pathToSync = null; } // Do a proper network shutdown cleanupManager(); sync.onDisconnected(); }; return fs; } // Manage single instance of a Filer filesystem with auto-sync'ing var sharedFS; MakeDrive.fs = function(options) { options = options || {}; // We usually only want to hand out a single, shared instance // for every call, but sometimes you need multiple (e.g., tests) if(options.forceCreate) { return createFS(options); } if(!sharedFS) { sharedFS = createFS(options); } return sharedFS; };