alchemymvc
Version:
MVC framework for Node.js
990 lines (804 loc) • 18.8 kB
JavaScript
const SESSION_KEY = 'Syncables',
UPDATE_ID = Symbol('update_id'),
CLIENT_MAP = new Classes.WeakValueMap(),
QUEUE_CALLBACKS = Symbol('queue_callbacks'),
QUEUE_CALLBACK_ID = Symbol('queue_callback_id');
/**
* The Syncable class
*
* @constructor
*
* @author Jelle De Loecker <jelle@elevenways.be>
* @since 1.3.10
* @version 1.3.10
*
* @param {string} type
*/
const Syncable = Function.inherits('Alchemy.Base', function Syncable(type) {
if (!type) {
throw new Error('Each Syncable must have a type');
}
this.root = this;
this.type = type;
this.log = [];
this.counter = 0;
this.queues = new Map();
if (Blast.isNode) {
// Only used on the server
this.s2c_links = new Map();
} else {
// Only used on the client
this.c2s_link = null;
}
});
if (Blast.isNode) {
/**
* Handle an incoming linkup
*
* @author Jelle De Loecker <jelle@elevenways.be>
* @since 1.3.10
* @version 1.3.10
*
* @param {Conduit}
* @param {Linkup}
* @param {Object}
*/
Syncable.setStatic(function handleLink(conduit, linkup, config) {
let syncables = conduit.session(SESSION_KEY),
syncable,
error_msg;
if (!syncables) {
error_msg = 'No syncables found';
} else {
let type_map = syncables.get(config.type);
if (!type_map) {
error_msg = 'No syncables found for type: ' + config.type;
} else {
syncable = type_map.get(config.id);
if (!syncable) {
error_msg = 'No syncable found for id: ' + config.id;
}
}
}
if (error_msg) {
let err = new Error(error_msg);
console.log('ERROR:', err);
linkup.emit('error', err);
linkup.destroy();
return;
}
syncable.attachClient(conduit, linkup, config);
}, false);
/**
* Attach a client
*
* @author Jelle De Loecker <jelle@elevenways.be>
* @since 1.3.10
* @version 1.3.10
*/
Syncable.setMethod(function attachClient(scene_id, linkup, config) {
if (!scene_id) {
return;
}
if (!this.s2c_links) {
this.s2c_links = new Map();
}
if (typeof scene_id == 'object') {
scene_id = scene_id.scene_id;
}
if (!scene_id) {
return;
}
this.s2c_links.set(scene_id, linkup);
linkup.syncable_version = config.version || 0;
linkup.on('destroyed', () => {
this.s2c_links.delete(scene_id);
});
linkup.on('upstream-method', async (args, responder) => {
try {
// Get the value
let result = await this.handleUpstreamMethodRequest(args[0], args[1]);
// Make sure it's ready for the client-side
result = JSON.clone(result, 'toHawkejs');
// Send it to the client
responder(null, result);
} catch (err) {
responder(err);
}
});
// Send any updates that happened before the linkup was created
this.sendUpdateToLink(linkup);
this.emit('ready');
});
/**
* Handle an upstream method request
*
* @author Jelle De Loecker <jelle@elevenways.be>
* @since 1.3.10
* @version 1.3.10
*
* @param {string} name
* @param {Array} args
*/
Syncable.setMethod(function handleUpstreamMethodRequest(name, args) {
let method = this[name];
if (!method) {
throw new Error('No method found with name: ' + name);
}
if (!method.is_syncable_upstream) {
throw new Error('Method is not syncable upstream: ' + name);
}
return method.apply(this, args);
});
/**
* Register a client by one of their conduits.
* Only clients that are registered can be synced.
*
* @author Jelle De Loecker <jelle@elevenways.be>
* @since 1.3.10
* @version 1.3.10
*
* @type {Conduit}
*/
Syncable.setMethod(function registerClient(conduit) {
if (!conduit) {
return;
}
let syncables = conduit.session(SESSION_KEY);
if (!syncables) {
syncables = new Map();
conduit.session(SESSION_KEY, syncables);
}
let type_map = syncables.get(this.type);
if (!type_map) {
type_map = new Map();
syncables.set(this.type, type_map);
}
type_map.set(this.id, this);
});
}
if (Blast.isBrowser) {
/**
* Start the sync link from the browser to the server
*
* @author Jelle De Loecker <jelle@elevenways.be>
* @since 1.3.10
* @version 1.3.10
*/
Syncable.setMethod(function startSyncLink() {
if (typeof hawkejs == 'undefined' || !hawkejs.scene) {
Blast.setImmediate(this.startSyncLink.bind(this));
return;
}
alchemy.enableWebsockets();
let data = {
version : this.version,
type : this.type,
id : this.id,
};
let link = this.c2s_link = alchemy.linkup('syncablelink', data, () => {
this.emit('ready');
});
link.on('process_updates', (data) => {
for (let update of data.updates) {
this.processUpdate(update);
}
this.version = data.version;
});
});
}
/**
* Add a syncable method.
* The method itself should probably NOT trigger changes
*
* @author Jelle De Loecker <jelle@elevenways.be>
* @since 1.3.10
* @version 1.3.10
*
* @param {Object} data
*
* @return {Syncable}
*/
Syncable.setStatic(function setSyncMethod(types, method) {
return this.setHandledMethod(types, method, function handler(method, args) {
let result = method.apply(this, args);
if (this.is_server) {
this.addLog('call', [method.name, args]);
}
return result;
});
});
/**
* Add a method that will only be called on the client.
* No response will be returned, since it is sent to multiple clients.
*
* @author Jelle De Loecker <jelle@elevenways.be>
* @since 1.3.22
* @version 1.3.22
*
* @param {Object} data
*
* @return {Syncable}
*/
Syncable.setStatic(function setClientMethod(types, method) {
return this.setHandledMethod(types, method, function handler(method, args) {
if (this.is_server) {
this.addLog('call', [method.name, args]);
return;
}
let result = method.apply(this, args);
return result;
});
});
/**
* Add a method that fetches info from the server.
* The response is always a promise.
*
* @author Jelle De Loecker <jelle@elevenways.be>
* @since 1.3.10
* @version 1.3.10
*
* @param {Object} data
*
* @return {Syncable}
*/
Syncable.setStatic(function setUpstreamMethod(types, method) {
let result = this.setHandledMethod(types, method, function handler(method, args) {
let result;
if (this.is_server) {
result = method.apply(this, args);
} else {
let pledge = new Pledge();
let bomb = Function.timebomb(30*1000, (err) => {
pledge.reject(err);
});
Pledge.done(this.c2s_link.demand('upstream-method', [method.name, args]), (err, result) => {
bomb.defuse();
if (err) {
pledge.reject(err);
} else {
pledge.resolve(result);
}
});
result = pledge;
}
return result;
});
result.is_syncable_upstream = true;
return result;
});
/**
* Add a method that may or may not use types
*
* @author Jelle De Loecker <jelle@elevenways.be>
* @since 1.3.10
* @version 1.3.10
*
* @param {Array} types The optional types of the method
* @param {Function} method The main method implementation
* @param {Function} handler The handler
*
* @return {Syncable}
*/
Syncable.setStatic(function setHandledMethod(types, method, handler) {
let result;
if (typeof types == 'function') {
method = types;
types = null;
}
function director(...args) {
return handler.call(this, method, args);
}
if (types) {
result = this.setTypedMethod(types, method.name, director);
} else {
result = this.setMethod(method.name, director);
}
return result;
});
/**
* Add a property
*
* @author Jelle De Loecker <jelle@elevenways.be>
* @since 1.3.10
* @version 1.3.10
*
* @param {string} name
* @param {Object} options
*/
Syncable.setStatic(function setStateProperty(name, options) {
if (!options) {
options = {};
}
let has_default = !!options.default,
allow_set;
if (Blast.isNode) {
allow_set = options.allow_server_set ?? true;
} else {
allow_set = options.allow_client_set ?? false;
}
let getter;
if (has_default) {
let has_default_function = typeof options.default == 'function';
if (has_default_function) {
let default_function = options.default;
getter = function getter() {
let result = this.state[name];
if (result == null) {
this.state[name] = result = default_function.call(this);
}
return result;
};
} else {
let default_value = options.default;
getter = function getter() {
let result = this.state[name];
if (result == null) {
this.state[name] = result = default_value;
}
return result;
};
}
} else {
getter = function getter() {
return this.state[name];
}
}
if (allow_set) {
this.setProperty(name, getter, function setValue(value) {
this.setProperty(name, value);
});
} else {
this.setProperty(name, getter);
}
});
/**
* Undry this value
*
* @author Jelle De Loecker <jelle@elevenways.be>
* @since 1.3.10
* @version 1.3.10
*
* @param {Object} data
*
* @return {Syncable}
*/
Syncable.setStatic(function unDry(data) {
let result;
// Try to reuse the same instance on the client side
if (Blast.isBrowser) {
result = CLIENT_MAP.get(data.id);
if (result) {
return result;
}
}
let clone = JSON.clone(data);
result = new this(clone.type);
result.id = clone.id;
result.state = clone.state;
result.version = clone.version;
result.queues = clone.queues;
if (Blast.isBrowser) {
result.startSyncLink();
}
return result;
});
/**
* Is this the server instance?
*
* @author Jelle De Loecker <jelle@elevenways.be>
* @since 1.3.10
* @version 1.3.10
*
* @type {Conduit}
*/
Syncable.setProperty(function is_server() {
return Blast.isNode;
});
/**
* Enforce the ID property
*
* @author Jelle De Loecker <jelle@elevenways.be>
* @since 1.3.10
* @version 1.3.10
*
* @type {string}
*/
Syncable.enforceProperty(function id(new_value) {
if (!new_value) {
new_value = Crypto.randomHex(16);
}
// Remember this instance for later
if (Blast.isBrowser) {
CLIENT_MAP.set(new_value, this);
}
return new_value;
});
/**
* Enforce the queues property
*
* @author Jelle De Loecker <jelle@elevenways.be>
* @since 1.3.10
* @version 1.3.10
*
* @type {Map}
*/
Syncable.enforceProperty(function queues(new_value) {
if (!new_value) {
new_value = new Map();
} else {
// Loop over every queue entry
for (let [name, queue] of new_value) {
let listeners = [];
if (queue.listeners) {
for (let listener of queue.listeners) {
if (listener) {
listeners.push(listener);
}
}
}
queue.listeners = listeners;
}
}
return new_value;
});
/**
* Enforce the state property
*
* @author Jelle De Loecker <jelle@elevenways.be>
* @since 1.3.10
* @version 1.3.10
*
* @type {string}
*/
Syncable.enforceProperty(function state(new_value) {
if (!new_value) {
new_value = {};
}
return new_value;
});
/**
* Enforce the version property
*
* @author Jelle De Loecker <jelle@elevenways.be>
* @since 1.3.10
* @version 1.3.10
*
* @type {string}
*/
Syncable.enforceProperty(function version(new_value) {
if (!new_value) {
new_value = 0;
}
return new_value;
});
/**
* Clone for hawkejs
*
* @author Jelle De Loecker <jelle@elevenways.be>
* @since 1.3.10
* @version 1.3.10
*/
Syncable.setMethod(function toHawkejs() {
return this.constructor.unDry(this.toDry().value);
});
/**
* Serialize this syncable
*
* @author Jelle De Loecker <jelle@elevenways.be>
* @since 1.3.10
* @version 1.3.10
*/
Syncable.setMethod(function toDry() {
let queues = new Map();
for (let [name, queue] of this.queues) {
// Keep everything except the `listeners`
let entry = {
name : name,
listeners : [],
messages : queue.messages,
};
queues.set(name, entry);
}
let result = {
version : this.version,
queues : queues,
state : this.state,
type : this.type,
id : this.id,
};
return {value: result};
});
/**
* Process an update
*
* @author Jelle De Loecker <jelle@elevenways.be>
* @since 1.3.10
* @version 1.3.10
*/
Syncable.setMethod(function processUpdate(update) {
let type = update.type,
args = update.args;
if (type == 'set') {
this.setProperty(...args);
} else if (type == 'call') {
let name = args[0],
method_args = args[1];
this[name](...method_args);
} else if (type == 'push_queue') {
let name = args[0],
method_args = args[1];
this.pushQueue(name, ...method_args);
} else if (type == 'clear_queue') {
let name = args[0];
this.clearQueue(name);
} else {
throw new Error('Unknown update type: ' + type);
}
});
/**
* Send the actual update
*
* @author Jelle De Loecker <jelle@elevenways.be>
* @since 1.3.10
* @version 1.3.10
*/
Syncable.setMethod(function sendUpdates() {
if (this[UPDATE_ID]) {
clearTimeout(this[UPDATE_ID]);
this[UPDATE_ID] = null;
}
for (let linkup of this.s2c_links.values()) {
this.sendUpdateToLink(linkup);
}
});
/**
* Emit a change event for a certain property
*
* @author Jelle De Loecker <jelle@elevenways.be>
* @since 1.3.10
* @version 1.3.10
*
* @param {string} property
*/
Syncable.setMethod(function emitPropertyChange(property) {
let value = this[property];
this.emit('property_change_' + property, value, null);
});
/**
* Listen for a change event for a certain property
*
* @author Jelle De Loecker <jelle@elevenways.be>
* @since 1.3.10
* @version 1.3.10
*
* @param {string} property
* @param {Function} callback
*/
Syncable.setMethod(function watchProperty(property, callback) {
this.on('property_change_' + property, callback);
let value = this[property];
callback(value);
});
/**
* Watch a queue for changes
*
* @author Jelle De Loecker <jelle@elevenways.be>
* @since 1.3.10
* @version 1.3.10
*
* @param {string} name
* @param {Function} callback
*/
Syncable.setMethod(function watchQueue(name, callback) {
let queue = this.queues.get(name);
if (!queue) {
queue = {
name,
listeners: [],
messages : [],
};
this.queues.set(name, queue);
}
queue.listeners.push(callback);
// Drain all the messages
while (queue.messages.length) {
this.scheduleQueueCallback(queue.messages.shift(), callback);
}
});
/**
* Clear all the entries in a queue
*
* @author Jelle De Loecker <jelle@elevenways.be>
* @since 1.3.10
* @version 1.3.10
*
* @param {string} name
*/
Syncable.setAfterMethod('ready', function clearQueue(name) {
let queue = this.queues.get(name);
if (queue) {
queue.messages = [];
}
if (Blast.isNode) {
this.addLog('clear_queue', [name]);
}
});
/**
* Schedule a queue callback
* (This tries to keep events in different queues still use the same order)
*
* @author Jelle De Loecker <jelle@elevenways.be>
* @since 1.3.10
* @version 1.3.10
*/
Syncable.setAfterMethod('ready', function scheduleQueueCallback(config, callback) {
let args = config.args,
counter = config.counter;
if (!this[QUEUE_CALLBACKS]) {
this[QUEUE_CALLBACKS] = [];
}
this[QUEUE_CALLBACKS].push({args, counter, callback});
if (this[QUEUE_CALLBACK_ID]) {
clearTimeout(this[QUEUE_CALLBACK_ID]);
}
this[QUEUE_CALLBACK_ID] = setTimeout(() => {
this[QUEUE_CALLBACK_ID] = null;
this.processQueueCallbacks();
}, 10);
});
/**
* Actually do the queued callbacks
*
* @author Jelle De Loecker <jelle@elevenways.be>
* @since 1.3.10
* @version 1.3.10
*/
Syncable.setAfterMethod('ready', function processQueueCallbacks() {
let callbacks = this[QUEUE_CALLBACKS];
if (!callbacks) {
return;
}
this[QUEUE_CALLBACKS] = [];
callbacks.sort((a, b) => {
return a.counter - b.counter;
});
for (let item of callbacks) {
item.callback(...item.args);
}
});
/**
* Push something to a queue.
* If there are listeners, they will be called immediately.
*
* @author Jelle De Loecker <jelle@elevenways.be>
* @since 1.3.10
* @version 1.3.10
*
* @param {string} name
*/
Syncable.setAfterMethod('ready', function pushQueue(name, ...args) {
let queue = this.queues.get(name);
if (!queue) {
queue = {
name,
listeners: [],
messages : [],
};
this.queues.set(name, queue);
}
if (queue.listeners.length) {
for (let listener of queue.listeners) {
this.scheduleQueueCallback({args, counter: this.counter++}, listener);
}
} else if (!Blast.isNode) {
queue.messages.push({
counter : this.counter++,
args
});
}
if (Blast.isNode) {
this.addLog('push_queue', [name, args]);
}
});
/**
* Send an update to the given link
*
* @author Jelle De Loecker <jelle@elevenways.be>
* @since 1.3.10
* @version 1.3.10
*/
Syncable.setMethod(function sendUpdateToLink(link) {
let link_version = link.syncable_version;
if (link_version >= this.version) {
return;
}
let updates = [];
for (let i = link_version; i < this.version; i++) {
let entry = this.log[i];
if (entry) {
updates.push(entry);
}
}
if (updates.length) {
link.submit('process_updates', {
updates,
version: this.version,
});
link.syncable_version = this.version;
}
});
/**
* Queue an update to all the listeners
*
* @author Jelle De Loecker <jelle@elevenways.be>
* @since 1.3.10
* @version 1.3.10
*/
Syncable.setMethod(function queueUpdate() {
let update_id = this[UPDATE_ID];
if (update_id) {
clearTimeout(update_id);
}
this[UPDATE_ID] = setTimeout(this.sendUpdates.bind(this), 30);
});
/**
* Add something to the log
*
* @author Jelle De Loecker <jelle@elevenways.be>
* @since 1.3.10
* @version 1.3.10
*/
Syncable.setMethod(function _addLog(type, args) {
let entry = {
version : this.version,
type : type,
args : args,
};
this.log.push(entry);
this.queueUpdate();
});
/**
* Add something to the log and increase the version
*
* @author Jelle De Loecker <jelle@elevenways.be>
* @since 1.3.10
* @version 1.3.10
*/
Syncable.setMethod(function addLog(type, args) {
this.version++;
this._addLog(type, args);
});
/**
* Set a property to a specific value
*
* @author Jelle De Loecker <jelle@elevenways.be>
* @since 1.3.10
* @version 1.3.10
*/
Syncable.setMethod(function setProperty(key, value) {
if (this.state[key] !== value) {
this.state[key] = value;
if (this.is_server) {
this.addLog('set', [key, value]);
}
this.emitPropertyChange(key);
}
});
/**
* Release the syncable
* (On your own side)
*
* @author Jelle De Loecker <jelle@elevenways.be>
* @since 1.3.10
* @version 1.3.10
*/
Syncable.setMethod(function release() {
if (this.c2s_link) {
this.c2s_link.destroy();
this.c2s_link = null;
}
if (Blast.isBrowser) {
CLIENT_MAP.delete(this.id);
}
});