From 924dae46281edcec55d608662becce9456b78424 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 29 Nov 2012 00:16:05 +0100 Subject: [PATCH 01/30] js: Ignore node_modules dirs --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 21adc7af..9229541f 100644 --- a/.gitignore +++ b/.gitignore @@ -11,4 +11,5 @@ coverage.xml dist/ docs/_build/ mopidy.log* +node_modules/ nosetests.xml From 2352dc8a033e7254369b055b712f33e88f7e8ade Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 29 Nov 2012 00:18:21 +0100 Subject: [PATCH 02/30] js: Add Buster.js test config --- js/buster.js | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 js/buster.js diff --git a/js/buster.js b/js/buster.js new file mode 100644 index 00000000..f789885a --- /dev/null +++ b/js/buster.js @@ -0,0 +1,9 @@ +var config = module.exports; + +config["tests"] = { + environment: "browser", + libs: ["lib/**/*.js"], + sources: ["src/**/*.js"], + testHelpers: ["test/**/*-helper.js"], + tests: ["test/**/*-test.js"] +}; From 01c972fd85fb378c2d6fdd4b2451c451c71e2ba9 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 29 Nov 2012 00:18:40 +0100 Subject: [PATCH 03/30] js: Add Grunt build config --- js/grunt.js | 65 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 65 insertions(+) create mode 100644 js/grunt.js diff --git a/js/grunt.js b/js/grunt.js new file mode 100644 index 00000000..97c3b25f --- /dev/null +++ b/js/grunt.js @@ -0,0 +1,65 @@ +/*global module:false*/ +module.exports = function (grunt) { + + grunt.initConfig({ + meta: { + banner: "/*! Mopidy.js - built " + + "<%= grunt.template.today('yyyy-mm-dd') %>\n" + + " * http://www.mopidy.com/\n" + + " * Copyright (c) <%= grunt.template.today('yyyy') %> " + + "Stein Magnus Jodal and contributors\n" + + " * Licensed under the Apache License, Version 2.0 */" + }, + dirs: { + dest: "../mopidy/frontends/http/data" + }, + lint: { + files: ["grunt.js", "src/**/*.js", "test/**/*-test.js"] + }, + buster: { + test: { + config: "buster.js" + } + }, + concat: { + dist: { + src: ["", "lib/**/*.js", "src/mopidy.js"], + dest: "<%= dirs.dest %>/mopidy.js" + } + }, + min: { + dist: { + src: ["", ""], + dest: "<%= dirs.dest %>/mopidy.min.js" + } + }, + watch: { + files: "", + tasks: "lint buster" + }, + jshint: { + options: { + curly: true, + eqeqeq: true, + immed: true, + indent: 4, + latedef: true, + newcap: true, + noarg: true, + sub: true, + quotmark: "double", + undef: true, + unused: true, + eqnull: true, + browser: true, + devel: true + }, + globals: {} + }, + uglify: {} + }); + + grunt.registerTask("default", "lint buster concat min"); + + grunt.loadNpmTasks("grunt-buster"); +}; From 2268a347a0b2fc123cb8d827f754deb315a06dec Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 29 Nov 2012 00:19:21 +0100 Subject: [PATCH 04/30] js: Add bane 0.3.0 for event handling --- js/lib/bane-0.3.0.js | 141 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 141 insertions(+) create mode 100644 js/lib/bane-0.3.0.js diff --git a/js/lib/bane-0.3.0.js b/js/lib/bane-0.3.0.js new file mode 100644 index 00000000..53d0ed0d --- /dev/null +++ b/js/lib/bane-0.3.0.js @@ -0,0 +1,141 @@ +((typeof define === "function" && define.amd && function (m) { define(m); }) || + (typeof module === "object" && function (m) { module.exports = m(); }) || + function (m) { this.bane = m(); } +)(function () { + "use strict"; + var slice = Array.prototype.slice; + + function handleError(event, error, errbacks) { + var i, l = errbacks.length; + if (l > 0) { + for (i = 0; i < l; ++i) { errbacks[i](event, error); } + return; + } + setTimeout(function () { + error.message = event + " listener threw error: " + error.message; + throw error; + }, 0); + } + + function assertFunction(fn) { + if (typeof fn !== "function") { + throw new TypeError("Listener is not function"); + } + return fn; + } + + function supervisors(object) { + if (!object.supervisors) { object.supervisors = []; } + return object.supervisors; + } + + function listeners(object, event) { + if (!object.listeners) { object.listeners = {}; } + if (event && !object.listeners[event]) { object.listeners[event] = []; } + return event ? object.listeners[event] : object.listeners; + } + + /** + * @signature var emitter = bane.createEmitter([object]); + * + * Create a new event emitter. If an object is passed, it will be modified + * by adding the event emitter methods (see below). + */ + function createEventEmitter(object) { + object = object || {}; + + function notifyListener(event, listener, args) { + try { + listener.listener.apply(listener.thisp || object, args); + } catch (e) { + handleError(event, e, object.errbacks || []); + } + } + + object.on = function (event, listener, thisp) { + if (typeof event === "function") { + return supervisors(this).push({ + listener: event, + thisp: listener + }); + } + listeners(this, event).push({ + listener: assertFunction(listener), + thisp: thisp + }); + }; + + object.off = function (event, listener) { + var fns, i, l; + if (typeof event === "function") { + fns = supervisors(this); + listener = event; + } else { + fns = listeners(this, event); + } + if (!listener) { + fns.splice(0, fns.length); + return; + } + for (i = 0, l = fns.length; i < l; ++i) { + if (fns[i].listener === listener) { + fns.splice(i, 1); + return; + } + } + }; + + object.once = function (event, listener, thisp) { + var wrapper = function () { + object.off(event, wrapper); + listener.apply(this, arguments); + }; + + object.on(event, wrapper, thisp); + }; + + object.bind = function (object, events) { + var prop, i, l; + if (!events) { + for (prop in object) { + if (typeof object[prop] === "function") { + this.on(prop, object[prop], object); + } + } + } else { + for (i = 0, l = events.length; i < l; ++i) { + if (typeof object[events[i]] === "function") { + this.on(events[i], object[events[i]], object); + } else { + throw new Error("No such method " + events[i]); + } + } + } + return object; + }; + + object.emit = function (event) { + var toNotify = listeners(this, event).slice(); + var args = slice.call(arguments, 1), i, l; + + for (i = 0, l = toNotify.length; i < l; i++) { + notifyListener(event, toNotify[i], args); + } + + toNotify = supervisors(this); + args = slice.call(arguments); + for (i = 0, l = toNotify.length; i < l; ++i) { + notifyListener(event, toNotify[i], args); + } + }; + + object.errback = function (listener) { + if (!this.errbacks) { this.errbacks = []; } + this.errbacks.push(assertFunction(listener)); + }; + + return object; + } + + return { createEventEmitter: createEventEmitter }; +}); From ceb71b40a189d10ebf1e705ab11bdabd6dd7dcb3 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 29 Nov 2012 00:19:41 +0100 Subject: [PATCH 05/30] js: Add when 1.6.1 for Promise support --- js/lib/when-1.6.1.js | 731 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 731 insertions(+) create mode 100644 js/lib/when-1.6.1.js diff --git a/js/lib/when-1.6.1.js b/js/lib/when-1.6.1.js new file mode 100644 index 00000000..e9a3bfc3 --- /dev/null +++ b/js/lib/when-1.6.1.js @@ -0,0 +1,731 @@ +/** @license MIT License (c) copyright B Cavalier & J Hann */ + +/** + * A lightweight CommonJS Promises/A and when() implementation + * when is part of the cujo.js family of libraries (http://cujojs.com/) + * + * Licensed under the MIT License at: + * http://www.opensource.org/licenses/mit-license.php + * + * @version 1.6.1 + */ + +(function(define) { 'use strict'; +define(['module'], function () { + var reduceArray, slice, undef; + + // + // Public API + // + + when.defer = defer; // Create a deferred + when.resolve = resolve; // Create a resolved promise + when.reject = reject; // Create a rejected promise + + when.join = join; // Join 2 or more promises + + when.all = all; // Resolve a list of promises + when.some = some; // Resolve a sub-set of promises + when.any = any; // Resolve one promise in a list + + when.map = map; // Array.map() for promises + when.reduce = reduce; // Array.reduce() for promises + + when.chain = chain; // Make a promise trigger another resolver + + when.isPromise = isPromise; // Determine if a thing is a promise + + /** + * Register an observer for a promise or immediate value. + * @function + * @name when + * @namespace + * + * @param promiseOrValue {*} + * @param {Function} [callback] callback to be called when promiseOrValue is + * successfully fulfilled. If promiseOrValue is an immediate value, callback + * will be invoked immediately. + * @param {Function} [errback] callback to be called when promiseOrValue is + * rejected. + * @param {Function} [progressHandler] callback to be called when progress updates + * are issued for promiseOrValue. + * @returns {Promise} a new {@link Promise} that will complete with the return + * value of callback or errback or the completion value of promiseOrValue if + * callback and/or errback is not supplied. + */ + function when(promiseOrValue, callback, errback, progressHandler) { + // Get a trusted promise for the input promiseOrValue, and then + // register promise handlers + return resolve(promiseOrValue).then(callback, errback, progressHandler); + } + + /** + * Returns promiseOrValue if promiseOrValue is a {@link Promise}, a new Promise if + * promiseOrValue is a foreign promise, or a new, already-fulfilled {@link Promise} + * whose value is promiseOrValue if promiseOrValue is an immediate value. + * @memberOf when + * + * @param promiseOrValue {*} + * @returns Guaranteed to return a trusted Promise. If promiseOrValue is a when.js {@link Promise} + * returns promiseOrValue, otherwise, returns a new, already-resolved, when.js {@link Promise} + * whose resolution value is: + * * the resolution value of promiseOrValue if it's a foreign promise, or + * * promiseOrValue if it's a value + */ + function resolve(promiseOrValue) { + var promise, deferred; + + if(promiseOrValue instanceof Promise) { + // It's a when.js promise, so we trust it + promise = promiseOrValue; + + } else { + // It's not a when.js promise. See if it's a foreign promise or a value. + + // Some promises, particularly Q promises, provide a valueOf method that + // attempts to synchronously return the fulfilled value of the promise, or + // returns the unresolved promise itself. Attempting to break a fulfillment + // value out of a promise appears to be necessary to break cycles between + // Q and When attempting to coerce each-other's promises in an infinite loop. + // For promises that do not implement "valueOf", the Object#valueOf is harmless. + // See: https://github.com/kriskowal/q/issues/106 + // IMPORTANT: Must check for a promise here, since valueOf breaks other things + // like Date. + if (isPromise(promiseOrValue) && typeof promiseOrValue.valueOf === 'function') { + promiseOrValue = promiseOrValue.valueOf(); + } + + if(isPromise(promiseOrValue)) { + // It looks like a thenable, but we don't know where it came from, + // so we don't trust its implementation entirely. Introduce a trusted + // middleman when.js promise + deferred = defer(); + + // IMPORTANT: This is the only place when.js should ever call .then() on + // an untrusted promise. + promiseOrValue.then(deferred.resolve, deferred.reject, deferred.progress); + promise = deferred.promise; + + } else { + // It's a value, not a promise. Create a resolved promise for it. + promise = fulfilled(promiseOrValue); + } + } + + return promise; + } + + /** + * Returns a rejected promise for the supplied promiseOrValue. If + * promiseOrValue is a value, it will be the rejection value of the + * returned promise. If promiseOrValue is a promise, its + * completion value will be the rejected value of the returned promise + * @memberOf when + * + * @param promiseOrValue {*} the rejected value of the returned {@link Promise} + * @return {Promise} rejected {@link Promise} + */ + function reject(promiseOrValue) { + return when(promiseOrValue, function(value) { + return rejected(value); + }); + } + + /** + * Trusted Promise constructor. A Promise created from this constructor is + * a trusted when.js promise. Any other duck-typed promise is considered + * untrusted. + * @constructor + * @name Promise + */ + function Promise(then) { + this.then = then; + } + + Promise.prototype = { + /** + * Register a callback that will be called when a promise is + * resolved or rejected. Optionally also register a progress handler. + * Shortcut for .then(alwaysback, alwaysback, progback) + * @memberOf Promise + * @param alwaysback {Function} + * @param progback {Function} + * @return {Promise} + */ + always: function(alwaysback, progback) { + return this.then(alwaysback, alwaysback, progback); + }, + + /** + * Register a rejection handler. Shortcut for .then(null, errback) + * @memberOf Promise + * @param errback {Function} + * @return {Promise} + */ + otherwise: function(errback) { + return this.then(undef, errback); + } + }; + + /** + * Create an already-resolved promise for the supplied value + * @private + * + * @param value anything + * @return {Promise} + */ + function fulfilled(value) { + var p = new Promise(function(callback) { + try { + return resolve(callback ? callback(value) : value); + } catch(e) { + return rejected(e); + } + }); + + return p; + } + + /** + * Create an already-rejected {@link Promise} with the supplied + * rejection reason. + * @private + * + * @param reason rejection reason + * @return {Promise} + */ + function rejected(reason) { + var p = new Promise(function(callback, errback) { + try { + return errback ? resolve(errback(reason)) : rejected(reason); + } catch(e) { + return rejected(e); + } + }); + + return p; + } + + /** + * Creates a new, Deferred with fully isolated resolver and promise parts, + * either or both of which may be given out safely to consumers. + * The Deferred itself has the full API: resolve, reject, progress, and + * then. The resolver has resolve, reject, and progress. The promise + * only has then. + * @memberOf when + * @function + * + * @return {Deferred} + */ + function defer() { + var deferred, promise, handlers, progressHandlers, + _then, _progress, _resolve; + + /** + * The promise for the new deferred + * @type {Promise} + */ + promise = new Promise(then); + + /** + * The full Deferred object, with {@link Promise} and {@link Resolver} parts + * @class Deferred + * @name Deferred + */ + deferred = { + then: then, + resolve: promiseResolve, + reject: promiseReject, + // TODO: Consider renaming progress() to notify() + progress: promiseProgress, + + promise: promise, + + resolver: { + resolve: promiseResolve, + reject: promiseReject, + progress: promiseProgress + } + }; + + handlers = []; + progressHandlers = []; + + /** + * Pre-resolution then() that adds the supplied callback, errback, and progback + * functions to the registered listeners + * @private + * + * @param [callback] {Function} resolution handler + * @param [errback] {Function} rejection handler + * @param [progback] {Function} progress handler + * @throws {Error} if any argument is not null, undefined, or a Function + */ + _then = function(callback, errback, progback) { + var deferred, progressHandler; + + deferred = defer(); + progressHandler = progback + ? function(update) { + try { + // Allow progress handler to transform progress event + deferred.progress(progback(update)); + } catch(e) { + // Use caught value as progress + deferred.progress(e); + } + } + : deferred.progress; + + handlers.push(function(promise) { + promise.then(callback, errback) + .then(deferred.resolve, deferred.reject, progressHandler); + }); + + progressHandlers.push(progressHandler); + + return deferred.promise; + }; + + /** + * Issue a progress event, notifying all progress listeners + * @private + * @param update {*} progress event payload to pass to all listeners + */ + _progress = function(update) { + processQueue(progressHandlers, update); + return update; + }; + + /** + * Transition from pre-resolution state to post-resolution state, notifying + * all listeners of the resolution or rejection + * @private + * @param completed {Promise} the completed value of this deferred + */ + _resolve = function(completed) { + completed = resolve(completed); + + // Replace _then with one that directly notifies with the result. + _then = completed.then; + // Replace _resolve so that this Deferred can only be completed once + _resolve = resolve; + // Make _progress a noop, to disallow progress for the resolved promise. + _progress = noop; + + // Notify handlers + processQueue(handlers, completed); + + // Free progressHandlers array since we'll never issue progress events + progressHandlers = handlers = undef; + + return completed; + }; + + return deferred; + + /** + * Wrapper to allow _then to be replaced safely + * @param [callback] {Function} resolution handler + * @param [errback] {Function} rejection handler + * @param [progback] {Function} progress handler + * @return {Promise} new Promise + * @throws {Error} if any argument is not null, undefined, or a Function + */ + function then(callback, errback, progback) { + return _then(callback, errback, progback); + } + + /** + * Wrapper to allow _resolve to be replaced + */ + function promiseResolve(val) { + return _resolve(val); + } + + /** + * Wrapper to allow _resolve to be replaced + */ + function promiseReject(err) { + return _resolve(rejected(err)); + } + + /** + * Wrapper to allow _progress to be replaced + * @param {*} update progress update + */ + function promiseProgress(update) { + return _progress(update); + } + } + + /** + * Determines if promiseOrValue is a promise or not. Uses the feature + * test from http://wiki.commonjs.org/wiki/Promises/A to determine if + * promiseOrValue is a promise. + * + * @param {*} promiseOrValue anything + * @returns {Boolean} true if promiseOrValue is a {@link Promise} + */ + function isPromise(promiseOrValue) { + return promiseOrValue && typeof promiseOrValue.then === 'function'; + } + + /** + * Initiates a competitive race, returning a promise that will resolve when + * howMany of the supplied promisesOrValues have resolved, or will reject when + * it becomes impossible for howMany to resolve, for example, when + * (promisesOrValues.length - howMany) + 1 input promises reject. + * @memberOf when + * + * @param promisesOrValues {Array} array of anything, may contain a mix + * of {@link Promise}s and values + * @param howMany {Number} number of promisesOrValues to resolve + * @param [callback] {Function} resolution handler + * @param [errback] {Function} rejection handler + * @param [progback] {Function} progress handler + * @returns {Promise} promise that will resolve to an array of howMany values that + * resolved first, or will reject with an array of (promisesOrValues.length - howMany) + 1 + * rejection reasons. + */ + function some(promisesOrValues, howMany, callback, errback, progback) { + + checkCallbacks(2, arguments); + + return when(promisesOrValues, function(promisesOrValues) { + + var toResolve, toReject, values, reasons, deferred, fulfillOne, rejectOne, progress, len, i; + + len = promisesOrValues.length >>> 0; + + toResolve = Math.max(0, Math.min(howMany, len)); + values = []; + + toReject = (len - toResolve) + 1; + reasons = []; + + deferred = defer(); + + // No items in the input, resolve immediately + if (!toResolve) { + deferred.resolve(values); + + } else { + progress = deferred.progress; + + rejectOne = function(reason) { + reasons.push(reason); + if(!--toReject) { + fulfillOne = rejectOne = noop; + deferred.reject(reasons); + } + }; + + fulfillOne = function(val) { + // This orders the values based on promise resolution order + // Another strategy would be to use the original position of + // the corresponding promise. + values.push(val); + + if (!--toResolve) { + fulfillOne = rejectOne = noop; + deferred.resolve(values); + } + }; + + for(i = 0; i < len; ++i) { + if(i in promisesOrValues) { + when(promisesOrValues[i], fulfiller, rejecter, progress); + } + } + } + + return deferred.then(callback, errback, progback); + + function rejecter(reason) { + rejectOne(reason); + } + + function fulfiller(val) { + fulfillOne(val); + } + + }); + } + + /** + * Initiates a competitive race, returning a promise that will resolve when + * any one of the supplied promisesOrValues has resolved or will reject when + * *all* promisesOrValues have rejected. + * @memberOf when + * + * @param promisesOrValues {Array|Promise} array of anything, may contain a mix + * of {@link Promise}s and values + * @param [callback] {Function} resolution handler + * @param [errback] {Function} rejection handler + * @param [progback] {Function} progress handler + * @returns {Promise} promise that will resolve to the value that resolved first, or + * will reject with an array of all rejected inputs. + */ + function any(promisesOrValues, callback, errback, progback) { + + function unwrapSingleResult(val) { + return callback ? callback(val[0]) : val[0]; + } + + return some(promisesOrValues, 1, unwrapSingleResult, errback, progback); + } + + /** + * Return a promise that will resolve only once all the supplied promisesOrValues + * have resolved. The resolution value of the returned promise will be an array + * containing the resolution values of each of the promisesOrValues. + * @memberOf when + * + * @param promisesOrValues {Array|Promise} array of anything, may contain a mix + * of {@link Promise}s and values + * @param [callback] {Function} + * @param [errback] {Function} + * @param [progressHandler] {Function} + * @returns {Promise} + */ + function all(promisesOrValues, callback, errback, progressHandler) { + checkCallbacks(1, arguments); + return map(promisesOrValues, identity).then(callback, errback, progressHandler); + } + + /** + * Joins multiple promises into a single returned promise. + * @memberOf when + * @param {Promise|*} [...promises] two or more promises to join + * @return {Promise} a promise that will fulfill when *all* the input promises + * have fulfilled, or will reject when *any one* of the input promises rejects. + */ + function join(/* ...promises */) { + return map(arguments, identity); + } + + /** + * Traditional map function, similar to `Array.prototype.map()`, but allows + * input to contain {@link Promise}s and/or values, and mapFunc may return + * either a value or a {@link Promise} + * + * @memberOf when + * + * @param promise {Array|Promise} array of anything, may contain a mix + * of {@link Promise}s and values + * @param mapFunc {Function} mapping function mapFunc(value) which may return + * either a {@link Promise} or value + * @returns {Promise} a {@link Promise} that will resolve to an array containing + * the mapped output values. + */ + function map(promise, mapFunc) { + return when(promise, function(array) { + var results, len, toResolve, resolve, reject, i, d; + + // Since we know the resulting length, we can preallocate the results + // array to avoid array expansions. + toResolve = len = array.length >>> 0; + results = []; + d = defer(); + + if(!toResolve) { + d.resolve(results); + } else { + + reject = d.reject; + resolve = function resolveOne(item, i) { + when(item, mapFunc).then(function(mapped) { + results[i] = mapped; + + if(!--toResolve) { + d.resolve(results); + } + }, reject); + }; + + // Since mapFunc may be async, get all invocations of it into flight + for(i = 0; i < len; i++) { + if(i in array) { + resolve(array[i], i); + } else { + --toResolve; + } + } + + } + + return d.promise; + + }); + } + + /** + * Traditional reduce function, similar to `Array.prototype.reduce()`, but + * input may contain {@link Promise}s and/or values, and reduceFunc + * may return either a value or a {@link Promise}, *and* initialValue may + * be a {@link Promise} for the starting value. + * @memberOf when + * + * @param promise {Array|Promise} array of anything, may contain a mix + * of {@link Promise}s and values. May also be a {@link Promise} for + * an array. + * @param reduceFunc {Function} reduce function reduce(currentValue, nextValue, index, total), + * where total is the total number of items being reduced, and will be the same + * in each call to reduceFunc. + * @param [initialValue] {*} starting value, or a {@link Promise} for the starting value + * @returns {Promise} that will resolve to the final reduced value + */ + function reduce(promise, reduceFunc /*, initialValue */) { + var args = slice.call(arguments, 1); + + return when(promise, function(array) { + var total; + + total = array.length; + + // Wrap the supplied reduceFunc with one that handles promises and then + // delegates to the supplied. + args[0] = function (current, val, i) { + return when(current, function (c) { + return when(val, function (value) { + return reduceFunc(c, value, i, total); + }); + }); + }; + + return reduceArray.apply(array, args); + }); + } + + /** + * Ensure that resolution of promiseOrValue will complete resolver with the completion + * value of promiseOrValue, or instead with resolveValue if it is provided. + * @memberOf when + * + * @param promiseOrValue + * @param resolver {Resolver} + * @param [resolveValue] anything + * @returns {Promise} + */ + function chain(promiseOrValue, resolver, resolveValue) { + var useResolveValue = arguments.length > 2; + + return when(promiseOrValue, + function(val) { + return resolver.resolve(useResolveValue ? resolveValue : val); + }, + resolver.reject, + resolver.progress + ); + } + + // + // Utility functions + // + + function processQueue(queue, value) { + var handler, i = 0; + + while (handler = queue[i++]) { + handler(value); + } + } + + /** + * Helper that checks arrayOfCallbacks to ensure that each element is either + * a function, or null or undefined. + * @private + * + * @param arrayOfCallbacks {Array} array to check + * @throws {Error} if any element of arrayOfCallbacks is something other than + * a Functions, null, or undefined. + */ + function checkCallbacks(start, arrayOfCallbacks) { + var arg, i = arrayOfCallbacks.length; + + while(i > start) { + arg = arrayOfCallbacks[--i]; + + if (arg != null && typeof arg != 'function') { + throw new Error('arg '+i+' must be a function'); + } + } + } + + /** + * No-Op function used in method replacement + * @private + */ + function noop() {} + + slice = [].slice; + + // ES5 reduce implementation if native not available + // See: http://es5.github.com/#x15.4.4.21 as there are many + // specifics and edge cases. + reduceArray = [].reduce || + function(reduceFunc /*, initialValue */) { + /*jshint maxcomplexity: 7*/ + + // ES5 dictates that reduce.length === 1 + + // This implementation deviates from ES5 spec in the following ways: + // 1. It does not check if reduceFunc is a Callable + + var arr, args, reduced, len, i; + + i = 0; + // This generates a jshint warning, despite being valid + // "Missing 'new' prefix when invoking a constructor." + // See https://github.com/jshint/jshint/issues/392 + arr = Object(this); + len = arr.length >>> 0; + args = arguments; + + // If no initialValue, use first item of array (we know length !== 0 here) + // and adjust i to start at second item + if(args.length <= 1) { + // Skip to the first real element in the array + for(;;) { + if(i in arr) { + reduced = arr[i++]; + break; + } + + // If we reached the end of the array without finding any real + // elements, it's a TypeError + if(++i >= len) { + throw new TypeError(); + } + } + } else { + // If initialValue provided, use it + reduced = args[1]; + } + + // Do the actual reduce + for(;i < len; ++i) { + // Skip holes + if(i in arr) { + reduced = reduceFunc(reduced, arr[i], i, arr); + } + } + + return reduced; + }; + + function identity(x) { + return x; + } + + return when; +}); +})(typeof define == 'function' && define.amd + ? define + : function (deps, factory) { typeof exports === 'object' + ? (module.exports = factory()) + : (this.when = factory()); + } + // Boilerplate for AMD, Node, and browser global +); From a539006d8bda8900284509cd445f5bc6baf632b5 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 29 Nov 2012 00:20:17 +0100 Subject: [PATCH 06/30] js: Add Function.prototype.bind polyfill to make PhantomJS 1.6 happy --- js/test/bind-helper.js | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 js/test/bind-helper.js diff --git a/js/test/bind-helper.js b/js/test/bind-helper.js new file mode 100644 index 00000000..a5a3e0f4 --- /dev/null +++ b/js/test/bind-helper.js @@ -0,0 +1,29 @@ +/* + * PhantomJS 1.6 does not support Function.prototype.bind, so we polyfill it. + * + * Implementation from: + * https://developer.mozilla.org/en-US/docs/JavaScript/Reference/Global_Objects/Function/bind + */ +if (!Function.prototype.bind) { + Function.prototype.bind = function (oThis) { + if (typeof this !== "function") { + // closest thing possible to the ECMAScript 5 internal IsCallable function + throw new TypeError("Function.prototype.bind - what is trying to be bound is not callable"); + } + + var aArgs = Array.prototype.slice.call(arguments, 1), + fToBind = this, + fNOP = function () {}, + fBound = function () { + return fToBind.apply(this instanceof fNOP && oThis + ? this + : oThis, + aArgs.concat(Array.prototype.slice.call(arguments))); + }; + + fNOP.prototype = this.prototype; + fBound.prototype = new fNOP(); + + return fBound; + }; +} From defc44b747d5e9b00dec38d7db24f7c062a3750b Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 29 Nov 2012 00:21:16 +0100 Subject: [PATCH 07/30] js: Add empty source and test files --- js/src/mopidy.js | 2 ++ js/test/mopidy-test.js | 4 ++++ 2 files changed, 6 insertions(+) create mode 100644 js/src/mopidy.js create mode 100644 js/test/mopidy-test.js diff --git a/js/src/mopidy.js b/js/src/mopidy.js new file mode 100644 index 00000000..5611150d --- /dev/null +++ b/js/src/mopidy.js @@ -0,0 +1,2 @@ +/*global bane:false, when:false*/ + diff --git a/js/test/mopidy-test.js b/js/test/mopidy-test.js new file mode 100644 index 00000000..aa53dd42 --- /dev/null +++ b/js/test/mopidy-test.js @@ -0,0 +1,4 @@ +/*global when:false, buster:false, assert:false, refute:false, Mopidy:false*/ + +buster.testCase("Mopidy", { +}); From 0a8476352675b51ee0f4374849ed3ead794e7434 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 29 Nov 2012 00:41:06 +0100 Subject: [PATCH 08/30] js: Add readme --- js/README.rst | 67 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 67 insertions(+) create mode 100644 js/README.rst diff --git a/js/README.rst b/js/README.rst new file mode 100644 index 00000000..3df26baa --- /dev/null +++ b/js/README.rst @@ -0,0 +1,67 @@ +********* +Mopidy.js +********* + +This is the source for the JavaScript library that is installed as a part of +Mopidy's HTTP frontend. The library makes Mopidy's core API available from the +browser, using JSON-RPC messages over a WebSocket to communicate with Mopidy. + + +Getting it +========== + +Regular and minified versions of Mopidy.js, ready for use, is installed +together with Mopidy. When the HTTP frontend is running, the files are +available at: + +- http://localhost:6680/mopidy/mopidy.js +- http://localhost:6680/mopidy/mopidy.min.js + +You may need to adjust hostname and port for your local setup. + +In the source repo, you can find the files at: + +- ``mopidy/frontends/http/data/mopidy.js`` +- ``mopidy/frontends/http/data/mopidy.min.js`` + + +Building from source +==================== + +1. Install `Node.js `_ and npm. There is a PPA if you're + running Ubuntu:: + + sudo apt-get install python-software-properties + sudo add-apt-repository ppa:chris-lea/node.js + sudo apt-get update + sudo apt-get install nodejs npm + +2. Assuming you install from PPA, setup your ``NODE_PATH`` environment variable + to include ``/usr/lib/node_modules``. Add the following to your + ``~/.bashrc`` or equivalent:: + + export NODE_PATH=/usr/lib/node_modules:$NODE_PATH + +3. Install `Buster.js `_ and `Grunt + `_ globally (or locally, and make sure you get their + binaries on your ``PATH``):: + + sudo npm -g install buster grunt + +4. Install the grunt-buster Grunt plugin locally, when in the ``js/`` dir:: + + cd js/ + npm install grunt-buster + +5. Run Grunt to lint, test, concatenate, and minify the source:: + + grunt + + The files in ``../mopidy/frontends/http/data/`` should now be up to date. + + +Development tips +================ + +If you're coding on the JavaScript library, you should know about ``grunt +watch``. It lints and tests the code every time you save a file. From 6a19d379ff26fe7eacba871c05d6e90e884a0671 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 29 Nov 2012 00:44:50 +0100 Subject: [PATCH 09/30] js: Add PhantomJS installation to readme --- js/README.rst | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/js/README.rst b/js/README.rst index 3df26baa..a68dd9a0 100644 --- a/js/README.rst +++ b/js/README.rst @@ -53,7 +53,15 @@ Building from source cd js/ npm install grunt-buster -5. Run Grunt to lint, test, concatenate, and minify the source:: +5. Install `PhantomJS `_ so that we can run the tests + without a browser:: + + sudo apt-get install phantomjs + + It is packaged in Ubuntu since 12.04, but I haven't tested with versions + older than 1.6 which is the one packaged in Ubuntu 12.10. + +6. Run Grunt to lint, test, concatenate, and minify the source:: grunt From 71f26260d38c4bd8acd32ce235acfef34b2233c0 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 29 Nov 2012 00:50:05 +0100 Subject: [PATCH 10/30] js: Add header to bane --- js/lib/bane-0.3.0.js | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/js/lib/bane-0.3.0.js b/js/lib/bane-0.3.0.js index 53d0ed0d..b82b8cd9 100644 --- a/js/lib/bane-0.3.0.js +++ b/js/lib/bane-0.3.0.js @@ -1,3 +1,11 @@ +/** + * BANE - Browser globals, AMD and Node Events + * + * https://github.com/busterjs/bane + * + * @version 0.3.0 + */ + ((typeof define === "function" && define.amd && function (m) { define(m); }) || (typeof module === "object" && function (m) { module.exports = m(); }) || function (m) { this.bane = m(); } From 6face51e5226dbbdff3959548a4677d6e2a66c6e Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 30 Nov 2012 02:04:28 +0100 Subject: [PATCH 11/30] js: Have 'grunt watch' concat and minify as well --- js/grunt.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/js/grunt.js b/js/grunt.js index 97c3b25f..7be4d882 100644 --- a/js/grunt.js +++ b/js/grunt.js @@ -35,7 +35,7 @@ module.exports = function (grunt) { }, watch: { files: "", - tasks: "lint buster" + tasks: "lint buster concat min" }, jshint: { options: { From 12f60f3a5251408dd86ba538b0d33b8cc07b4040 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 30 Nov 2012 02:05:40 +0100 Subject: [PATCH 12/30] js: Add fully working core API in JavaScript --- js/src/mopidy.js | 274 +++++++++++++++++ js/test/mopidy-test.js | 646 ++++++++++++++++++++++++++++++++++++++++- 2 files changed, 919 insertions(+), 1 deletion(-) diff --git a/js/src/mopidy.js b/js/src/mopidy.js index 5611150d..233f4a82 100644 --- a/js/src/mopidy.js +++ b/js/src/mopidy.js @@ -1,2 +1,276 @@ /*global bane:false, when:false*/ +function Mopidy(settings) { + var mopidy = this; + + mopidy._webSocket = null; + mopidy._pendingRequests = {}; + mopidy._backoffDelayMin = 1000; + mopidy._backoffDelayMax = 64000; + mopidy._backoffDelay = mopidy._backoffDelayMin; + + mopidy._settings = settings || {}; + mopidy._settings.webSocketUrl = + mopidy._settings.webSocketUrl || + "ws://" + document.location.host + "/mopidy/ws/"; + if (mopidy._settings.autoConnect !== false) { + mopidy._settings.autoConnect = true; + } + + bane.createEventEmitter(mopidy); + mopidy._delegateEvents(); + + if (mopidy._settings.autoConnect) { + mopidy._connect(); + } +} + +Mopidy.prototype._delegateEvents = function () { + var mopidy = this; + + // Remove existing event handlers + mopidy.off("websocket:close"); + mopidy.off("websocket:error"); + mopidy.off("websocket:incomingMessage"); + mopidy.off("websocket:open"); + mopidy.off("state:offline"); + + // Register basic set of event handlers + mopidy.on("websocket:close", mopidy._cleanup); + mopidy.on("websocket:error", mopidy._handleWebSocketError); + mopidy.on("websocket:incomingMessage", mopidy._handleMessage); + mopidy.on("websocket:open", mopidy._resetBackoffDelay); + mopidy.on("websocket:open", mopidy._getApiSpec); + mopidy.on("state:offline", mopidy._reconnect); +}; + +Mopidy.prototype._connect = function () { + var mopidy = this; + + if (mopidy._webSocket) { + if (mopidy._webSocket.readyState === WebSocket.OPEN) { + return; + } else { + mopidy._webSocket.close(); + } + } + + mopidy._webSocket = mopidy._settings.webSocket || + new WebSocket(mopidy._settings.webSocketUrl); + + mopidy._webSocket.onclose = function (close) { + mopidy.emit("websocket:close", close); + }; + + mopidy._webSocket.onerror = function (error) { + mopidy.emit("websocket:error", error); + }; + + mopidy._webSocket.onopen = function () { + mopidy.emit("websocket:open"); + }; + + mopidy._webSocket.onmessage = function (message) { + mopidy.emit("websocket:incomingMessage", message); + }; +}; + +Mopidy.prototype._cleanup = function (closeEvent) { + var mopidy = this; + + Object.keys(mopidy._pendingRequests).forEach(function (requestId) { + var resolver = mopidy._pendingRequests[requestId]; + delete mopidy._pendingRequests[requestId]; + resolver.reject({ + message: "WebSocket closed", + closeEvent: closeEvent + }); + }); + + mopidy.emit("state:offline"); +}; + +Mopidy.prototype._reconnect = function () { + var mopidy = this; + + mopidy.emit("reconnectionPending", { + timeToAttempt: mopidy._backoffDelay + }); + + setTimeout(function () { + mopidy.emit("reconnecting"); + mopidy._connect(); + }, mopidy._backoffDelay); + + mopidy._backoffDelay = mopidy._backoffDelay * 2; + if (mopidy._backoffDelay > mopidy._backoffDelayMax) { + mopidy._backoffDelay = mopidy._backoffDelayMax; + } +}; + +Mopidy.prototype._resetBackoffDelay = function () { + var mopidy = this; + + mopidy._backoffDelay = mopidy._backoffDelayMin; +}; + +Mopidy.prototype._handleWebSocketError = function (error) { + console.warn("WebSocket error:", error.stack || error); +}; + +Mopidy.prototype._send = function (message) { + var mopidy = this; + var deferred = when.defer(); + + switch (mopidy._webSocket.readyState) { + case WebSocket.CONNECTING: + deferred.resolver.reject({ + message: "WebSocket is still connecting" + }); + break; + case WebSocket.CLOSING: + deferred.resolver.reject({ + message: "WebSocket is closing" + }); + break; + case WebSocket.CLOSED: + deferred.resolver.reject({ + message: "WebSocket is closed" + }); + break; + default: + message.jsonrpc = "2.0"; + message.id = mopidy._nextRequestId(); + this._pendingRequests[message.id] = deferred.resolver; + this._webSocket.send(JSON.stringify(message)); + mopidy.emit("websocket:outgoingMessage", message); + } + + return deferred.promise; +}; + +Mopidy.prototype._nextRequestId = (function () { + var lastUsed = -1; + return function () { + lastUsed += 1; + return lastUsed; + }; +}()); + +Mopidy.prototype._handleMessage = function (message) { + var mopidy = this; + + try { + var data = JSON.parse(message.data); + if (data.hasOwnProperty("id")) { + mopidy._handleResponse(data); + } else if (data.hasOwnProperty("event")) { + mopidy._handleEvent(data); + } else { + console.warn( + "Unknown message type received. Message was: " + + message.data); + } + } catch (error) { + if (error instanceof SyntaxError) { + console.warn( + "WebSocket message parsing failed. Message was: " + + message.data); + } else { + throw error; + } + } +}; + +Mopidy.prototype._handleResponse = function (responseMessage) { + var mopidy = this; + + if (!mopidy._pendingRequests.hasOwnProperty(responseMessage.id)) { + console.warn( + "Unexpected response received. Message was:", responseMessage); + return; + } + var resolver = mopidy._pendingRequests[responseMessage.id]; + delete mopidy._pendingRequests[responseMessage.id]; + + if (responseMessage.hasOwnProperty("result")) { + resolver.resolve(responseMessage.result); + } else if (responseMessage.hasOwnProperty("error")) { + resolver.reject(responseMessage.error); + console.warn("Server returned error:", responseMessage.error); + } else { + resolver.reject({ + message: "Response without 'result' or 'error' received", + data: {response: responseMessage} + }); + console.warn( + "Response without 'result' or 'error' received. Message was:", + responseMessage); + } +}; + +Mopidy.prototype._handleEvent = function (eventMessage) { + var mopidy = this; + + var type = eventMessage.event; + var data = eventMessage; + delete data.event; + + mopidy.emit("event:" + mopidy._snakeToCamel(type), data); +}; + +Mopidy.prototype._getApiSpec = function () { + var mopidy = this; + + mopidy._send({method: "core.describe"}) + .then(mopidy._createApi.bind(mopidy), mopidy._handleWebSocketError) + .then(null, mopidy._handleWebSocketError); +}; + +Mopidy.prototype._createApi = function (methods) { + var mopidy = this; + + var caller = function (method) { + return function () { + var params = Array.prototype.slice.call(arguments); + return mopidy._send({ + method: method, + params: params + }); + }; + }; + + var getPath = function (fullName) { + var path = fullName.split("."); + if (path.length >= 1 && path[0] === "core") { + path = path.slice(1); + } + return path; + }; + + var createObjects = function (objPath) { + var parentObj = mopidy; + objPath.forEach(function (objName) { + objName = mopidy._snakeToCamel(objName); + parentObj[objName] = parentObj[objName] || {}; + parentObj = parentObj[objName]; + }); + return parentObj; + }; + + var createMethod = function (fullMethodName) { + var methodPath = getPath(fullMethodName); + var methodName = mopidy._snakeToCamel(methodPath.slice(-1)[0]); + var object = createObjects(methodPath.slice(0, -1)); + object[methodName] = caller(fullMethodName); + }; + + Object.keys(methods).forEach(createMethod); + mopidy.emit("state:online"); +}; + +Mopidy.prototype._snakeToCamel = function (name) { + return name.replace(/(_[a-z])/g, function (match) { + return match.toUpperCase().replace("_", ""); + }); +}; diff --git a/js/test/mopidy-test.js b/js/test/mopidy-test.js index aa53dd42..112a2506 100644 --- a/js/test/mopidy-test.js +++ b/js/test/mopidy-test.js @@ -1,4 +1,648 @@ -/*global when:false, buster:false, assert:false, refute:false, Mopidy:false*/ +/*global buster:false, assert:false, refute:false, when:false, Mopidy:false*/ buster.testCase("Mopidy", { + setUp: function () { + // Sinon.JS doesn't manage to stub PhantomJS' WebSocket implementation, + // so we replace it with a dummy temporarily. + var fakeWebSocket = function () { + return { + send: function () {}, + close: function () {} + }; + }; + fakeWebSocket.CONNECTING = 0; + fakeWebSocket.OPEN = 1; + fakeWebSocket.CLOSING = 2; + fakeWebSocket.CLOSED = 3; + this.realWebSocket = WebSocket; + window.WebSocket = fakeWebSocket; + + this.webSocketConstructorStub = this.stub(window, "WebSocket"); + + this.webSocket = { + close: this.stub(), + send: this.stub() + }; + this.mopidy = new Mopidy({webSocket: this.webSocket}); + }, + + tearDown: function () { + window.WebSocket = this.realWebSocket; + }, + + "constructor": { + "connects when autoConnect is true": function () { + new Mopidy({autoConnect: true}); + + assert.calledOnceWith(this.webSocketConstructorStub, + "ws://" + document.location.host + "/mopidy/ws/"); + }, + + "does not connect when autoConnect is false": function () { + new Mopidy({autoConnect: false}); + + refute.called(this.webSocketConstructorStub); + }, + + "does not connect when passed a WebSocket": function () { + new Mopidy({webSocket: {}}); + + refute.called(this.webSocketConstructorStub); + } + }, + + "._connect": { + "does nothing when the WebSocket is open": function () { + this.webSocket.readyState = WebSocket.OPEN; + var mopidy = new Mopidy({webSocket: this.webSocket}); + + mopidy._connect(); + + refute.called(this.webSocket.close); + refute.called(this.webSocketConstructorStub); + } + }, + + "WebSocket events": { + "emits 'websocket:close' when connection is closed": function () { + var spy = this.spy(); + this.mopidy.off("websocket:close"); + this.mopidy.on("websocket:close", spy); + + var closeEvent = {}; + this.webSocket.onclose(closeEvent); + + assert.calledOnceWith(spy, closeEvent); + }, + + "emits 'websocket:error' when errors occurs": function () { + var spy = this.spy(); + this.mopidy.off("websocket:error"); + this.mopidy.on("websocket:error", spy); + + var errorEvent = {}; + this.webSocket.onerror(errorEvent); + + assert.calledOnceWith(spy, errorEvent); + }, + + "emits 'websocket:incomingMessage' when a message arrives": function () { + var spy = this.spy(); + this.mopidy.off("websocket:incomingMessage"); + this.mopidy.on("websocket:incomingMessage", spy); + + var messageEvent = {data: "this is a message"}; + this.webSocket.onmessage(messageEvent); + + assert.calledOnceWith(spy, messageEvent); + }, + + "emits 'websocket:open' when connection is opened": function () { + var spy = this.spy(); + this.mopidy.off("websocket:open"); + this.mopidy.on("websocket:open", spy); + + this.webSocket.onopen(); + + assert.calledOnceWith(spy); + } + }, + + "._cleanup": { + setUp: function () { + this.mopidy.off("state:offline"); + }, + + "is called on 'websocket:close' event": function () { + var closeEvent = {}; + var stub = this.stub(this.mopidy, "_cleanup"); + this.mopidy._delegateEvents(); + + this.mopidy.emit("websocket:close", closeEvent); + + assert.calledOnceWith(stub, closeEvent); + }, + + "rejects all pending requests": function (done) { + var closeEvent = {}; + assert.equals(Object.keys(this.mopidy._pendingRequests).length, 0); + + var promise1 = this.mopidy._send({method: "foo"}); + var promise2 = this.mopidy._send({method: "bar"}); + assert.equals(Object.keys(this.mopidy._pendingRequests).length, 2); + + this.mopidy._cleanup(closeEvent); + + assert.equals(Object.keys(this.mopidy._pendingRequests).length, 0); + when.join(promise1, promise2).then(done(function () { + assert(false, "Promises should be rejected"); + }), done(function (error) { + assert.equals(error.message, "WebSocket closed"); + assert.same(error.closeEvent, closeEvent); + })); + }, + + "emits 'state:offline' event when done": function () { + var spy = this.spy(); + this.mopidy.on("state:offline", spy); + + this.mopidy._cleanup({}); + + assert.calledOnceWith(spy); + } + }, + + "._reconnect": { + "is called when the state changes to offline": function () { + var stub = this.stub(this.mopidy, "_reconnect"); + this.mopidy._delegateEvents(); + + this.mopidy.emit("state:offline"); + + assert.calledOnceWith(stub); + }, + + "tries to connect after an increasing backoff delay": function () { + var clock = this.useFakeTimers(); + var connectStub = this.stub(this.mopidy, "_connect"); + var pendingSpy = this.spy(); + this.mopidy.on("reconnectionPending", pendingSpy); + var reconnectingSpy = this.spy(); + this.mopidy.on("reconnecting", reconnectingSpy); + + refute.called(connectStub); + + this.mopidy._reconnect(); + assert.calledOnceWith(pendingSpy, {timeToAttempt: 1000}); + clock.tick(0); + refute.called(connectStub); + clock.tick(1000); + assert.calledOnceWith(reconnectingSpy); + assert.calledOnce(connectStub); + + pendingSpy.reset(); + reconnectingSpy.reset(); + this.mopidy._reconnect(); + assert.calledOnceWith(pendingSpy, {timeToAttempt: 2000}); + assert.calledOnce(connectStub); + clock.tick(0); + assert.calledOnce(connectStub); + clock.tick(1000); + assert.calledOnce(connectStub); + clock.tick(1000); + assert.calledOnceWith(reconnectingSpy); + assert.calledTwice(connectStub); + + pendingSpy.reset(); + reconnectingSpy.reset(); + this.mopidy._reconnect(); + assert.calledOnceWith(pendingSpy, {timeToAttempt: 4000}); + assert.calledTwice(connectStub); + clock.tick(0); + assert.calledTwice(connectStub); + clock.tick(2000); + assert.calledTwice(connectStub); + clock.tick(2000); + assert.calledOnceWith(reconnectingSpy); + assert.calledThrice(connectStub); + }, + + "tries to connect at least about once per minute": function () { + var clock = this.useFakeTimers(); + var connectStub = this.stub(this.mopidy, "_connect"); + var pendingSpy = this.spy(); + this.mopidy.on("reconnectionPending", pendingSpy); + this.mopidy._backoffDelay = this.mopidy._backoffDelayMax; + + refute.called(connectStub); + + this.mopidy._reconnect(); + assert.calledOnceWith(pendingSpy, {timeToAttempt: 64000}); + clock.tick(0); + refute.called(connectStub); + clock.tick(64000); + assert.calledOnce(connectStub); + + pendingSpy.reset(); + this.mopidy._reconnect(); + assert.calledOnceWith(pendingSpy, {timeToAttempt: 64000}); + assert.calledOnce(connectStub); + clock.tick(0); + assert.calledOnce(connectStub); + clock.tick(64000); + assert.calledTwice(connectStub); + } + }, + + "._resetBackoffDelay": { + "is called on 'websocket:open' event": function () { + var stub = this.stub(this.mopidy, "_resetBackoffDelay"); + this.mopidy._delegateEvents(); + + this.mopidy.emit("websocket:open"); + + assert.calledOnceWith(stub); + }, + + "resets the backoff delay to the minimum value": function () { + this.mopidy._backoffDelay = this.mopidy._backoffDelayMax; + + this.mopidy._resetBackoffDelay(); + + assert.equals(this.mopidy._backoffDelay, + this.mopidy._backoffDelayMin); + } + }, + + "._handleWebSocketError": { + "is called on 'websocket:error' event": function () { + var error = {}; + var stub = this.stub(this.mopidy, "_handleWebSocketError"); + this.mopidy._delegateEvents(); + + this.mopidy.emit("websocket:error", error); + + assert.calledOnceWith(stub, error); + }, + + "without stack logs the error to the console": function () { + var stub = this.stub(console, "warn"); + var error = {}; + + this.mopidy._handleWebSocketError(error); + + assert.calledOnceWith(stub, "WebSocket error:", error); + }, + + "with stack logs the error to the console": function () { + var stub = this.stub(console, "warn"); + var error = {stack: "foo"}; + + this.mopidy._handleWebSocketError(error); + + assert.calledOnceWith(stub, "WebSocket error:", error.stack); + } + }, + + "._send": { + "adds JSON-RPC fields to the message": function () { + this.stub(this.mopidy, "_nextRequestId").returns(1); + var stub = this.stub(JSON, "stringify"); + + this.mopidy._send({method: "foo"}); + + assert.calledOnceWith(stub, { + jsonrpc: "2.0", + id: 1, + method: "foo" + }); + }, + + "adds a resolver to the pending requests queue": function () { + this.stub(this.mopidy, "_nextRequestId").returns(1); + assert.equals(Object.keys(this.mopidy._pendingRequests).length, 0); + + this.mopidy._send({method: "foo"}); + + assert.equals(Object.keys(this.mopidy._pendingRequests).length, 1); + assert.isFunction(this.mopidy._pendingRequests[1].resolve); + }, + + "sends message on the WebSocket": function () { + refute.called(this.mopidy._webSocket.send); + + this.mopidy._send({method: "foo"}); + + assert.calledOnce(this.mopidy._webSocket.send); + }, + + "emits a 'websocket:outgoingMessage' event": function () { + var spy = this.spy(); + this.mopidy.on("websocket:outgoingMessage", spy); + this.stub(this.mopidy, "_nextRequestId").returns(1); + + this.mopidy._send({method: "foo"}); + + assert.calledOnceWith(spy, { + jsonrpc: "2.0", + id: 1, + method: "foo" + }); + }, + + "immediately rejects request if CONNECTING": function (done) { + this.mopidy._webSocket.readyState = WebSocket.CONNECTING; + + var promise = this.mopidy._send({method: "foo"}); + + refute.called(this.mopidy._webSocket.send); + promise.then(done(function () { + assert(false); + }), done(function (error) { + assert.equals( + error.message, "WebSocket is still connecting"); + })); + }, + + "immediately rejects request if CLOSING": function (done) { + this.mopidy._webSocket.readyState = WebSocket.CLOSING; + + var promise = this.mopidy._send({method: "foo"}); + + refute.called(this.mopidy._webSocket.send); + promise.then(done(function () { + assert(false); + }), done(function (error) { + assert.equals( + error.message, "WebSocket is closing"); + })); + }, + + "immediately rejects request if CLOSED": function (done) { + this.mopidy._webSocket.readyState = WebSocket.CLOSED; + + var promise = this.mopidy._send({method: "foo"}); + + refute.called(this.mopidy._webSocket.send); + promise.then(done(function () { + assert(false); + }), done(function (error) { + assert.equals( + error.message, "WebSocket is closed"); + })); + } + }, + + "._nextRequestId": { + "returns an ever increasing ID": function () { + var base = this.mopidy._nextRequestId(); + assert.equals(this.mopidy._nextRequestId(), base + 1); + assert.equals(this.mopidy._nextRequestId(), base + 2); + assert.equals(this.mopidy._nextRequestId(), base + 3); + } + }, + + "._handleMessage": { + "is called on 'websocket:incomingMessage' event": function () { + var messageEvent = {}; + var stub = this.stub(this.mopidy, "_handleMessage"); + this.mopidy._delegateEvents(); + + this.mopidy.emit("websocket:incomingMessage", messageEvent); + + assert.calledOnceWith(stub, messageEvent); + }, + + "passes JSON-RPC responses on to _handleResponse": function () { + var stub = this.stub(this.mopidy, "_handleResponse"); + var message = { + jsonrpc: "2.0", + id: 1, + result: null + }; + var messageEvent = {data: JSON.stringify(message)}; + + this.mopidy._handleMessage(messageEvent); + + assert.calledOnceWith(stub, message); + }, + + "passes events on to _handleEvent": function () { + var stub = this.stub(this.mopidy, "_handleEvent"); + var message = { + event: "track_playback_started", + track: {} + }; + var messageEvent = {data: JSON.stringify(message)}; + + this.mopidy._handleMessage(messageEvent); + + assert.calledOnceWith(stub, message); + }, + + "logs unknown messages": function () { + var stub = this.stub(console, "warn"); + var messageEvent = {data: JSON.stringify({foo: "bar"})}; + + this.mopidy._handleMessage(messageEvent); + + assert.calledOnceWith(stub, + "Unknown message type received. Message was: " + + messageEvent.data); + }, + + "logs JSON parsing errors": function () { + var stub = this.stub(console, "warn"); + var messageEvent = {data: "foobarbaz"}; + + this.mopidy._handleMessage(messageEvent); + + assert.calledOnceWith(stub, + "WebSocket message parsing failed. Message was: " + + messageEvent.data); + } + }, + + "._handleResponse": { + "logs unexpected responses": function () { + var stub = this.stub(console, "warn"); + var responseMessage = { + jsonrpc: "2.0", + id: 1337, + result: null + }; + + this.mopidy._handleResponse(responseMessage); + + assert.calledOnceWith(stub, + "Unexpected response received. Message was:", responseMessage); + }, + + "removes the matching request from the pending queue": function () { + assert.equals(Object.keys(this.mopidy._pendingRequests).length, 0); + this.mopidy._send({method: "bar"}); + assert.equals(Object.keys(this.mopidy._pendingRequests).length, 1); + + this.mopidy._handleResponse({ + jsonrpc: "2.0", + id: Object.keys(this.mopidy._pendingRequests)[0], + result: "baz" + }); + + assert.equals(Object.keys(this.mopidy._pendingRequests).length, 0); + }, + + "resolves requests which get results back": function (done) { + var promise = this.mopidy._send({method: "bar"}); + var responseResult = {}; + var responseMessage = { + jsonrpc: "2.0", + id: Object.keys(this.mopidy._pendingRequests)[0], + result: responseResult + }; + + this.mopidy._handleResponse(responseMessage); + promise.then(done(function (result) { + assert.equals(result, responseResult); + }), done(function () { + assert(false); + })); + }, + + "rejects and logs requests which get errors back": function (done) { + var stub = this.stub(console, "warn"); + var promise = this.mopidy._send({method: "bar"}); + var responseError = {message: "Error", data: {}}; + var responseMessage = { + jsonrpc: "2.0", + id: Object.keys(this.mopidy._pendingRequests)[0], + error: responseError + }; + + this.mopidy._handleResponse(responseMessage); + + assert.calledOnceWith(stub, + "Server returned error:", responseError); + promise.then(done(function () { + assert(false); + }), done(function (error) { + assert.equals(error, responseError); + })); + }, + + "rejects and logs responses without result or error": function (done) { + var stub = this.stub(console, "warn"); + var promise = this.mopidy._send({method: "bar"}); + var responseMessage = { + jsonrpc: "2.0", + id: Object.keys(this.mopidy._pendingRequests)[0] + }; + + this.mopidy._handleResponse(responseMessage); + + assert.calledOnceWith(stub, + "Response without 'result' or 'error' received. Message was:", + responseMessage); + promise.then(done(function () { + assert(false); + }), done(function (error) { + assert.equals( + error.message, + "Response without 'result' or 'error' received"); + assert.equals(error.data.response, responseMessage); + })); + } + }, + + "._handleEvent": { + "emits server side even on Mopidy object": function () { + var spy = this.spy(); + this.mopidy.on(spy); + var track = {}; + var message = { + event: "track_playback_started", + track: track + }; + + this.mopidy._handleEvent(message); + + assert.calledOnceWith(spy, + "event:trackPlaybackStarted", {track: track}); + } + }, + + "._getApiSpec": { + "is called on 'websocket:open' event": function () { + var stub = this.stub(this.mopidy, "_getApiSpec"); + this.mopidy._delegateEvents(); + + this.mopidy.emit("websocket:open"); + + assert.calledOnceWith(stub); + }, + + "gets Api description from server and calls _createApi": function () { + var methods = {}; + var sendStub = this.stub(this.mopidy, "_send"); + sendStub.returns(when.resolve(methods)); + var _createApiStub = this.stub(this.mopidy, "_createApi"); + + this.mopidy._getApiSpec(); + + assert.calledOnceWith(sendStub, {method: "core.describe"}); + assert.calledOnceWith(_createApiStub, methods); + } + }, + + "._createApi": { + "can create an API with methods on the root object": function () { + refute.defined(this.mopidy.hello); + refute.defined(this.mopidy.hi); + + this.mopidy._createApi({ + hello: { + description: "Says hello", + params: [] + }, + hi: { + description: "Says hi", + params: [] + } + }); + + assert.isFunction(this.mopidy.hello); + assert.isFunction(this.mopidy.hi); + }, + + "can create an API with methods on a sub-object": function () { + refute.defined(this.mopidy.hello); + + this.mopidy._createApi({ + "hello.world": { + description: "Says hello to the world", + params: [] + } + }); + + assert.defined(this.mopidy.hello); + assert.isFunction(this.mopidy.hello.world); + }, + + "strips off 'core' from method paths": function () { + refute.defined(this.mopidy.hello); + + this.mopidy._createApi({ + "core.hello.world": { + description: "Says hello to the world", + params: [] + } + }); + + assert.defined(this.mopidy.hello); + assert.isFunction(this.mopidy.hello.world); + }, + + "converts snake_case to camelCase": function () { + refute.defined(this.mopidy.mightyGreetings); + + this.mopidy._createApi({ + "mighty_greetings.hello_world": { + description: "Says hello to the world", + params: [] + } + }); + + assert.defined(this.mopidy.mightyGreetings); + assert.isFunction(this.mopidy.mightyGreetings.helloWorld); + }, + + "triggers 'state:online' event when API is ready for use": function () { + var spy = this.spy(); + this.mopidy.on("state:online", spy); + + this.mopidy._createApi({}); + + assert.calledOnceWith(spy); + } + } }); From fc9ab6053a3c8253d1d0bf04c2ffae8ae99192ac Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 30 Nov 2012 02:18:08 +0100 Subject: [PATCH 13/30] js: Make backoff delay limits settings --- js/src/mopidy.js | 18 +++++++++--------- js/test/mopidy-test.js | 4 ++-- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/js/src/mopidy.js b/js/src/mopidy.js index 233f4a82..be1d62be 100644 --- a/js/src/mopidy.js +++ b/js/src/mopidy.js @@ -3,12 +3,6 @@ function Mopidy(settings) { var mopidy = this; - mopidy._webSocket = null; - mopidy._pendingRequests = {}; - mopidy._backoffDelayMin = 1000; - mopidy._backoffDelayMax = 64000; - mopidy._backoffDelay = mopidy._backoffDelayMin; - mopidy._settings = settings || {}; mopidy._settings.webSocketUrl = mopidy._settings.webSocketUrl || @@ -16,6 +10,12 @@ function Mopidy(settings) { if (mopidy._settings.autoConnect !== false) { mopidy._settings.autoConnect = true; } + mopidy._settings.backoffDelayMin = mopidy._settings.backoffDelayMin || 1000; + mopidy._settings.backoffDelayMax = mopidy._settings.backoffDelayMax || 64000; + + mopidy._backoffDelay = mopidy._settings.backoffDelayMin; + mopidy._pendingRequests = {}; + mopidy._webSocket = null; bane.createEventEmitter(mopidy); mopidy._delegateEvents(); @@ -103,15 +103,15 @@ Mopidy.prototype._reconnect = function () { }, mopidy._backoffDelay); mopidy._backoffDelay = mopidy._backoffDelay * 2; - if (mopidy._backoffDelay > mopidy._backoffDelayMax) { - mopidy._backoffDelay = mopidy._backoffDelayMax; + if (mopidy._backoffDelay > mopidy._settings.backoffDelayMax) { + mopidy._backoffDelay = mopidy._settings.backoffDelayMax; } }; Mopidy.prototype._resetBackoffDelay = function () { var mopidy = this; - mopidy._backoffDelay = mopidy._backoffDelayMin; + mopidy._backoffDelay = mopidy._settings.backoffDelayMin; }; Mopidy.prototype._handleWebSocketError = function (error) { diff --git a/js/test/mopidy-test.js b/js/test/mopidy-test.js index 112a2506..80155d2f 100644 --- a/js/test/mopidy-test.js +++ b/js/test/mopidy-test.js @@ -212,7 +212,7 @@ buster.testCase("Mopidy", { var connectStub = this.stub(this.mopidy, "_connect"); var pendingSpy = this.spy(); this.mopidy.on("reconnectionPending", pendingSpy); - this.mopidy._backoffDelay = this.mopidy._backoffDelayMax; + this.mopidy._backoffDelay = this.mopidy._settings.backoffDelayMax; refute.called(connectStub); @@ -250,7 +250,7 @@ buster.testCase("Mopidy", { this.mopidy._resetBackoffDelay(); assert.equals(this.mopidy._backoffDelay, - this.mopidy._backoffDelayMin); + this.mopidy._settings.backoffDelayMin); } }, From 3045ac01582a56cb41810b19dc622a7569ca4d0e Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 30 Nov 2012 02:41:39 +0100 Subject: [PATCH 14/30] js: Add description and params list to API functions --- js/src/mopidy.js | 2 ++ js/test/mopidy-test.js | 4 ++++ 2 files changed, 6 insertions(+) diff --git a/js/src/mopidy.js b/js/src/mopidy.js index be1d62be..5dbee57e 100644 --- a/js/src/mopidy.js +++ b/js/src/mopidy.js @@ -263,6 +263,8 @@ Mopidy.prototype._createApi = function (methods) { var methodName = mopidy._snakeToCamel(methodPath.slice(-1)[0]); var object = createObjects(methodPath.slice(0, -1)); object[methodName] = caller(fullMethodName); + object[methodName].description = methods[fullMethodName].description; + object[methodName].params = methods[fullMethodName].params; }; Object.keys(methods).forEach(createMethod); diff --git a/js/test/mopidy-test.js b/js/test/mopidy-test.js index 80155d2f..35317378 100644 --- a/js/test/mopidy-test.js +++ b/js/test/mopidy-test.js @@ -591,7 +591,11 @@ buster.testCase("Mopidy", { }); assert.isFunction(this.mopidy.hello); + assert.equals(this.mopidy.hello.description, "Says hello"); + assert.equals(this.mopidy.hello.params, []); assert.isFunction(this.mopidy.hi); + assert.equals(this.mopidy.hi.description, "Says hi"); + assert.equals(this.mopidy.hi.params, []); }, "can create an API with methods on a sub-object": function () { From 08dcfd56b17d9f1b5817fef5b42466e80c706fbf Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 30 Nov 2012 11:16:42 +0100 Subject: [PATCH 15/30] js: Extract configuration function --- js/src/mopidy.js | 29 ++++++++++++++++++++--------- 1 file changed, 20 insertions(+), 9 deletions(-) diff --git a/js/src/mopidy.js b/js/src/mopidy.js index 5dbee57e..4d24ab2f 100644 --- a/js/src/mopidy.js +++ b/js/src/mopidy.js @@ -3,15 +3,7 @@ function Mopidy(settings) { var mopidy = this; - mopidy._settings = settings || {}; - mopidy._settings.webSocketUrl = - mopidy._settings.webSocketUrl || - "ws://" + document.location.host + "/mopidy/ws/"; - if (mopidy._settings.autoConnect !== false) { - mopidy._settings.autoConnect = true; - } - mopidy._settings.backoffDelayMin = mopidy._settings.backoffDelayMin || 1000; - mopidy._settings.backoffDelayMax = mopidy._settings.backoffDelayMax || 64000; + mopidy._configure(settings || {}); mopidy._backoffDelay = mopidy._settings.backoffDelayMin; mopidy._pendingRequests = {}; @@ -25,6 +17,25 @@ function Mopidy(settings) { } } +Mopidy.prototype._configure = function (settings) { + var mopidy = this; + + mopidy._settings = settings; + + mopidy._settings.webSocketUrl = + mopidy._settings.webSocketUrl || + "ws://" + document.location.host + "/mopidy/ws/"; + + if (mopidy._settings.autoConnect !== false) { + mopidy._settings.autoConnect = true; + } + + mopidy._settings.backoffDelayMin = + mopidy._settings.backoffDelayMin || 1000; + mopidy._settings.backoffDelayMax = + mopidy._settings.backoffDelayMax || 64000; +}; + Mopidy.prototype._delegateEvents = function () { var mopidy = this; From cb9b0f6ba1d81a39bb3a668c12c8e20f2864e2f1 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 30 Nov 2012 11:22:13 +0100 Subject: [PATCH 16/30] js: Simplify configure function --- js/src/mopidy.js | 22 +++++++++------------- 1 file changed, 9 insertions(+), 13 deletions(-) diff --git a/js/src/mopidy.js b/js/src/mopidy.js index 4d24ab2f..3ffa8b1a 100644 --- a/js/src/mopidy.js +++ b/js/src/mopidy.js @@ -3,7 +3,7 @@ function Mopidy(settings) { var mopidy = this; - mopidy._configure(settings || {}); + mopidy._settings = mopidy._configure(settings || {}); mopidy._backoffDelay = mopidy._settings.backoffDelayMin; mopidy._pendingRequests = {}; @@ -18,22 +18,18 @@ function Mopidy(settings) { } Mopidy.prototype._configure = function (settings) { - var mopidy = this; - - mopidy._settings = settings; - - mopidy._settings.webSocketUrl = - mopidy._settings.webSocketUrl || + settings.webSocketUrl = + settings.webSocketUrl || "ws://" + document.location.host + "/mopidy/ws/"; - if (mopidy._settings.autoConnect !== false) { - mopidy._settings.autoConnect = true; + if (settings.autoConnect !== false) { + settings.autoConnect = true; } - mopidy._settings.backoffDelayMin = - mopidy._settings.backoffDelayMin || 1000; - mopidy._settings.backoffDelayMax = - mopidy._settings.backoffDelayMax || 64000; + settings.backoffDelayMin = settings.backoffDelayMin || 1000; + settings.backoffDelayMax = settings.backoffDelayMax || 64000; + + return settings; }; Mopidy.prototype._delegateEvents = function () { From 0e799c2795589f51eb7908070dd923364be8019d Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 30 Nov 2012 11:23:48 +0100 Subject: [PATCH 17/30] js: Formatting --- js/src/mopidy.js | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/js/src/mopidy.js b/js/src/mopidy.js index 3ffa8b1a..703c3e9f 100644 --- a/js/src/mopidy.js +++ b/js/src/mopidy.js @@ -18,8 +18,7 @@ function Mopidy(settings) { } Mopidy.prototype._configure = function (settings) { - settings.webSocketUrl = - settings.webSocketUrl || + settings.webSocketUrl = settings.webSocketUrl || "ws://" + document.location.host + "/mopidy/ws/"; if (settings.autoConnect !== false) { From 3c56f6cbcee97f77313e3154a06ed719c2967f99 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 30 Nov 2012 12:04:47 +0100 Subject: [PATCH 18/30] js: Use and all over --- js/src/mopidy.js | 168 ++++++++++++++++++++--------------------------- 1 file changed, 73 insertions(+), 95 deletions(-) diff --git a/js/src/mopidy.js b/js/src/mopidy.js index 703c3e9f..20bda382 100644 --- a/js/src/mopidy.js +++ b/js/src/mopidy.js @@ -1,19 +1,17 @@ /*global bane:false, when:false*/ function Mopidy(settings) { - var mopidy = this; + this._settings = this._configure(settings || {}); - mopidy._settings = mopidy._configure(settings || {}); + this._backoffDelay = this._settings.backoffDelayMin; + this._pendingRequests = {}; + this._webSocket = null; - mopidy._backoffDelay = mopidy._settings.backoffDelayMin; - mopidy._pendingRequests = {}; - mopidy._webSocket = null; + bane.createEventEmitter(this); + this._delegateEvents(); - bane.createEventEmitter(mopidy); - mopidy._delegateEvents(); - - if (mopidy._settings.autoConnect) { - mopidy._connect(); + if (this._settings.autoConnect) { + this._connect(); } } @@ -32,92 +30,82 @@ Mopidy.prototype._configure = function (settings) { }; Mopidy.prototype._delegateEvents = function () { - var mopidy = this; - // Remove existing event handlers - mopidy.off("websocket:close"); - mopidy.off("websocket:error"); - mopidy.off("websocket:incomingMessage"); - mopidy.off("websocket:open"); - mopidy.off("state:offline"); + this.off("websocket:close"); + this.off("websocket:error"); + this.off("websocket:incomingMessage"); + this.off("websocket:open"); + this.off("state:offline"); // Register basic set of event handlers - mopidy.on("websocket:close", mopidy._cleanup); - mopidy.on("websocket:error", mopidy._handleWebSocketError); - mopidy.on("websocket:incomingMessage", mopidy._handleMessage); - mopidy.on("websocket:open", mopidy._resetBackoffDelay); - mopidy.on("websocket:open", mopidy._getApiSpec); - mopidy.on("state:offline", mopidy._reconnect); + this.on("websocket:close", this._cleanup); + this.on("websocket:error", this._handleWebSocketError); + this.on("websocket:incomingMessage", this._handleMessage); + this.on("websocket:open", this._resetBackoffDelay); + this.on("websocket:open", this._getApiSpec); + this.on("state:offline", this._reconnect); }; Mopidy.prototype._connect = function () { - var mopidy = this; - - if (mopidy._webSocket) { - if (mopidy._webSocket.readyState === WebSocket.OPEN) { + if (this._webSocket) { + if (this._webSocket.readyState === WebSocket.OPEN) { return; } else { - mopidy._webSocket.close(); + this._webSocket.close(); } } - mopidy._webSocket = mopidy._settings.webSocket || - new WebSocket(mopidy._settings.webSocketUrl); + this._webSocket = this._settings.webSocket || + new WebSocket(this._settings.webSocketUrl); - mopidy._webSocket.onclose = function (close) { - mopidy.emit("websocket:close", close); - }; + this._webSocket.onclose = function (close) { + this.emit("websocket:close", close); + }.bind(this); - mopidy._webSocket.onerror = function (error) { - mopidy.emit("websocket:error", error); - }; + this._webSocket.onerror = function (error) { + this.emit("websocket:error", error); + }.bind(this); - mopidy._webSocket.onopen = function () { - mopidy.emit("websocket:open"); - }; + this._webSocket.onopen = function () { + this.emit("websocket:open"); + }.bind(this); - mopidy._webSocket.onmessage = function (message) { - mopidy.emit("websocket:incomingMessage", message); - }; + this._webSocket.onmessage = function (message) { + this.emit("websocket:incomingMessage", message); + }.bind(this); }; Mopidy.prototype._cleanup = function (closeEvent) { - var mopidy = this; - - Object.keys(mopidy._pendingRequests).forEach(function (requestId) { - var resolver = mopidy._pendingRequests[requestId]; - delete mopidy._pendingRequests[requestId]; + Object.keys(this._pendingRequests).forEach(function (requestId) { + var resolver = this._pendingRequests[requestId]; + delete this._pendingRequests[requestId]; resolver.reject({ message: "WebSocket closed", closeEvent: closeEvent }); - }); + }.bind(this)); - mopidy.emit("state:offline"); + this.emit("state:offline"); }; Mopidy.prototype._reconnect = function () { - var mopidy = this; - - mopidy.emit("reconnectionPending", { - timeToAttempt: mopidy._backoffDelay + this.emit("reconnectionPending", { + timeToAttempt: this._backoffDelay }); setTimeout(function () { - mopidy.emit("reconnecting"); - mopidy._connect(); - }, mopidy._backoffDelay); + this.emit("reconnecting"); + this._connect(); + }.bind(this), this._backoffDelay); - mopidy._backoffDelay = mopidy._backoffDelay * 2; - if (mopidy._backoffDelay > mopidy._settings.backoffDelayMax) { - mopidy._backoffDelay = mopidy._settings.backoffDelayMax; + this._backoffDelay = this._backoffDelay * 2; + if (this._backoffDelay > this._settings.backoffDelayMax) { + this._backoffDelay = this._settings.backoffDelayMax; } }; Mopidy.prototype._resetBackoffDelay = function () { - var mopidy = this; - - mopidy._backoffDelay = mopidy._settings.backoffDelayMin; + this._backoffDelay = this._settings.backoffDelayMin; }; Mopidy.prototype._handleWebSocketError = function (error) { @@ -125,10 +113,9 @@ Mopidy.prototype._handleWebSocketError = function (error) { }; Mopidy.prototype._send = function (message) { - var mopidy = this; var deferred = when.defer(); - switch (mopidy._webSocket.readyState) { + switch (this._webSocket.readyState) { case WebSocket.CONNECTING: deferred.resolver.reject({ message: "WebSocket is still connecting" @@ -146,10 +133,10 @@ Mopidy.prototype._send = function (message) { break; default: message.jsonrpc = "2.0"; - message.id = mopidy._nextRequestId(); + message.id = this._nextRequestId(); this._pendingRequests[message.id] = deferred.resolver; this._webSocket.send(JSON.stringify(message)); - mopidy.emit("websocket:outgoingMessage", message); + this.emit("websocket:outgoingMessage", message); } return deferred.promise; @@ -164,14 +151,12 @@ Mopidy.prototype._nextRequestId = (function () { }()); Mopidy.prototype._handleMessage = function (message) { - var mopidy = this; - try { var data = JSON.parse(message.data); if (data.hasOwnProperty("id")) { - mopidy._handleResponse(data); + this._handleResponse(data); } else if (data.hasOwnProperty("event")) { - mopidy._handleEvent(data); + this._handleEvent(data); } else { console.warn( "Unknown message type received. Message was: " + @@ -189,15 +174,14 @@ Mopidy.prototype._handleMessage = function (message) { }; Mopidy.prototype._handleResponse = function (responseMessage) { - var mopidy = this; - - if (!mopidy._pendingRequests.hasOwnProperty(responseMessage.id)) { + if (!this._pendingRequests.hasOwnProperty(responseMessage.id)) { console.warn( "Unexpected response received. Message was:", responseMessage); return; } - var resolver = mopidy._pendingRequests[responseMessage.id]; - delete mopidy._pendingRequests[responseMessage.id]; + + var resolver = this._pendingRequests[responseMessage.id]; + delete this._pendingRequests[responseMessage.id]; if (responseMessage.hasOwnProperty("result")) { resolver.resolve(responseMessage.result); @@ -216,35 +200,29 @@ Mopidy.prototype._handleResponse = function (responseMessage) { }; Mopidy.prototype._handleEvent = function (eventMessage) { - var mopidy = this; - var type = eventMessage.event; var data = eventMessage; delete data.event; - mopidy.emit("event:" + mopidy._snakeToCamel(type), data); + this.emit("event:" + this._snakeToCamel(type), data); }; Mopidy.prototype._getApiSpec = function () { - var mopidy = this; - - mopidy._send({method: "core.describe"}) - .then(mopidy._createApi.bind(mopidy), mopidy._handleWebSocketError) - .then(null, mopidy._handleWebSocketError); + this._send({method: "core.describe"}) + .then(this._createApi.bind(this), this._handleWebSocketError) + .then(null, this._handleWebSocketError); }; Mopidy.prototype._createApi = function (methods) { - var mopidy = this; - var caller = function (method) { return function () { var params = Array.prototype.slice.call(arguments); - return mopidy._send({ + return this._send({ method: method, params: params }); - }; - }; + }.bind(this); + }.bind(this); var getPath = function (fullName) { var path = fullName.split("."); @@ -255,26 +233,26 @@ Mopidy.prototype._createApi = function (methods) { }; var createObjects = function (objPath) { - var parentObj = mopidy; + var parentObj = this; objPath.forEach(function (objName) { - objName = mopidy._snakeToCamel(objName); + objName = this._snakeToCamel(objName); parentObj[objName] = parentObj[objName] || {}; parentObj = parentObj[objName]; - }); + }.bind(this)); return parentObj; - }; + }.bind(this); var createMethod = function (fullMethodName) { var methodPath = getPath(fullMethodName); - var methodName = mopidy._snakeToCamel(methodPath.slice(-1)[0]); + var methodName = this._snakeToCamel(methodPath.slice(-1)[0]); var object = createObjects(methodPath.slice(0, -1)); object[methodName] = caller(fullMethodName); object[methodName].description = methods[fullMethodName].description; object[methodName].params = methods[fullMethodName].params; - }; + }.bind(this); Object.keys(methods).forEach(createMethod); - mopidy.emit("state:online"); + this.emit("state:online"); }; Mopidy.prototype._snakeToCamel = function (name) { From b62d4d5374829a636e903279dd7addb6b8763c31 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 1 Dec 2012 15:06:20 +0100 Subject: [PATCH 19/30] js: Add console polyfill --- js/lib/console-polyfill.js | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 js/lib/console-polyfill.js diff --git a/js/lib/console-polyfill.js b/js/lib/console-polyfill.js new file mode 100644 index 00000000..7acfe813 --- /dev/null +++ b/js/lib/console-polyfill.js @@ -0,0 +1,27 @@ +/*jslint browser: true, plusplus: true */ + +/** + * From + * http://skratchdot.com/2012/05/prevent-console-calls-from-throwing-errors/ + */ + +(function (window) { + 'use strict'; + + var i = 0, + emptyFunction = function () {}, + functionNames = [ + 'assert', 'clear', 'count', 'debug', 'dir', + 'dirxml', 'error', 'exception', 'group', 'groupCollapsed', + 'groupEnd', 'info', 'log', 'profile', 'profileEnd', 'table', + 'time', 'timeEnd', 'timeStamp', 'trace', 'warn' + ]; + + // Make sure window.console exists + window.console = window.console || {}; + + // Make sure all functions exist + for (i = 0; i < functionNames.length; i++) { + window.console[functionNames[i]] = window.console[functionNames[i]] || emptyFunction; + } +}(window)); From 7f570de239b0e402ff79c7315529f65816de6f63 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 1 Dec 2012 17:18:06 +0100 Subject: [PATCH 20/30] js: Polyfill console inside our own namespace --- js/lib/console-polyfill.js | 27 --------------------------- js/src/mopidy.js | 23 +++++++++++++++++------ js/test/mopidy-test.js | 14 +++++++------- 3 files changed, 24 insertions(+), 40 deletions(-) delete mode 100644 js/lib/console-polyfill.js diff --git a/js/lib/console-polyfill.js b/js/lib/console-polyfill.js deleted file mode 100644 index 7acfe813..00000000 --- a/js/lib/console-polyfill.js +++ /dev/null @@ -1,27 +0,0 @@ -/*jslint browser: true, plusplus: true */ - -/** - * From - * http://skratchdot.com/2012/05/prevent-console-calls-from-throwing-errors/ - */ - -(function (window) { - 'use strict'; - - var i = 0, - emptyFunction = function () {}, - functionNames = [ - 'assert', 'clear', 'count', 'debug', 'dir', - 'dirxml', 'error', 'exception', 'group', 'groupCollapsed', - 'groupEnd', 'info', 'log', 'profile', 'profileEnd', 'table', - 'time', 'timeEnd', 'timeStamp', 'trace', 'warn' - ]; - - // Make sure window.console exists - window.console = window.console || {}; - - // Make sure all functions exist - for (i = 0; i < functionNames.length; i++) { - window.console[functionNames[i]] = window.console[functionNames[i]] || emptyFunction; - } -}(window)); diff --git a/js/src/mopidy.js b/js/src/mopidy.js index 20bda382..26ac9197 100644 --- a/js/src/mopidy.js +++ b/js/src/mopidy.js @@ -2,6 +2,7 @@ function Mopidy(settings) { this._settings = this._configure(settings || {}); + this._console = this._getConsole(); this._backoffDelay = this._settings.backoffDelayMin; this._pendingRequests = {}; @@ -29,6 +30,16 @@ Mopidy.prototype._configure = function (settings) { return settings; }; +Mopidy.prototype._getConsole = function () { + var console = window.console || {}; + + console.log = console.log || function () {}; + console.warn = console.warn || function () {}; + console.error = console.error || function () {}; + + return console; +}; + Mopidy.prototype._delegateEvents = function () { // Remove existing event handlers this.off("websocket:close"); @@ -109,7 +120,7 @@ Mopidy.prototype._resetBackoffDelay = function () { }; Mopidy.prototype._handleWebSocketError = function (error) { - console.warn("WebSocket error:", error.stack || error); + this._console.warn("WebSocket error:", error.stack || error); }; Mopidy.prototype._send = function (message) { @@ -158,13 +169,13 @@ Mopidy.prototype._handleMessage = function (message) { } else if (data.hasOwnProperty("event")) { this._handleEvent(data); } else { - console.warn( + this._console.warn( "Unknown message type received. Message was: " + message.data); } } catch (error) { if (error instanceof SyntaxError) { - console.warn( + this._console.warn( "WebSocket message parsing failed. Message was: " + message.data); } else { @@ -175,7 +186,7 @@ Mopidy.prototype._handleMessage = function (message) { Mopidy.prototype._handleResponse = function (responseMessage) { if (!this._pendingRequests.hasOwnProperty(responseMessage.id)) { - console.warn( + this._console.warn( "Unexpected response received. Message was:", responseMessage); return; } @@ -187,13 +198,13 @@ Mopidy.prototype._handleResponse = function (responseMessage) { resolver.resolve(responseMessage.result); } else if (responseMessage.hasOwnProperty("error")) { resolver.reject(responseMessage.error); - console.warn("Server returned error:", responseMessage.error); + this._console.warn("Server returned error:", responseMessage.error); } else { resolver.reject({ message: "Response without 'result' or 'error' received", data: {response: responseMessage} }); - console.warn( + this._console.warn( "Response without 'result' or 'error' received. Message was:", responseMessage); } diff --git a/js/test/mopidy-test.js b/js/test/mopidy-test.js index 35317378..8525e09b 100644 --- a/js/test/mopidy-test.js +++ b/js/test/mopidy-test.js @@ -266,7 +266,7 @@ buster.testCase("Mopidy", { }, "without stack logs the error to the console": function () { - var stub = this.stub(console, "warn"); + var stub = this.stub(this.mopidy._console, "warn"); var error = {}; this.mopidy._handleWebSocketError(error); @@ -275,7 +275,7 @@ buster.testCase("Mopidy", { }, "with stack logs the error to the console": function () { - var stub = this.stub(console, "warn"); + var stub = this.stub(this.mopidy._console, "warn"); var error = {stack: "foo"}; this.mopidy._handleWebSocketError(error); @@ -421,7 +421,7 @@ buster.testCase("Mopidy", { }, "logs unknown messages": function () { - var stub = this.stub(console, "warn"); + var stub = this.stub(this.mopidy._console, "warn"); var messageEvent = {data: JSON.stringify({foo: "bar"})}; this.mopidy._handleMessage(messageEvent); @@ -432,7 +432,7 @@ buster.testCase("Mopidy", { }, "logs JSON parsing errors": function () { - var stub = this.stub(console, "warn"); + var stub = this.stub(this.mopidy._console, "warn"); var messageEvent = {data: "foobarbaz"}; this.mopidy._handleMessage(messageEvent); @@ -445,7 +445,7 @@ buster.testCase("Mopidy", { "._handleResponse": { "logs unexpected responses": function () { - var stub = this.stub(console, "warn"); + var stub = this.stub(this.mopidy._console, "warn"); var responseMessage = { jsonrpc: "2.0", id: 1337, @@ -490,7 +490,7 @@ buster.testCase("Mopidy", { }, "rejects and logs requests which get errors back": function (done) { - var stub = this.stub(console, "warn"); + var stub = this.stub(this.mopidy._console, "warn"); var promise = this.mopidy._send({method: "bar"}); var responseError = {message: "Error", data: {}}; var responseMessage = { @@ -511,7 +511,7 @@ buster.testCase("Mopidy", { }, "rejects and logs responses without result or error": function (done) { - var stub = this.stub(console, "warn"); + var stub = this.stub(this.mopidy._console, "warn"); var promise = this.mopidy._send({method: "bar"}); var responseMessage = { jsonrpc: "2.0", From accf018ae811ebbfc5e81dded3379f3575e0c2ce Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 3 Dec 2012 00:06:34 +0100 Subject: [PATCH 21/30] js: Add concatenated and minified versions of mopidy.js --- mopidy/frontends/http/data/mopidy.js | 1160 ++++++++++++++++++++++ mopidy/frontends/http/data/mopidy.min.js | 5 + 2 files changed, 1165 insertions(+) create mode 100644 mopidy/frontends/http/data/mopidy.js create mode 100644 mopidy/frontends/http/data/mopidy.min.js diff --git a/mopidy/frontends/http/data/mopidy.js b/mopidy/frontends/http/data/mopidy.js new file mode 100644 index 00000000..29f076c5 --- /dev/null +++ b/mopidy/frontends/http/data/mopidy.js @@ -0,0 +1,1160 @@ +/*! Mopidy.js - built 2012-12-03 + * http://www.mopidy.com/ + * Copyright (c) 2012 Stein Magnus Jodal and contributors + * Licensed under the Apache License, Version 2.0 */ + +/** + * BANE - Browser globals, AMD and Node Events + * + * https://github.com/busterjs/bane + * + * @version 0.3.0 + */ + +((typeof define === "function" && define.amd && function (m) { define(m); }) || + (typeof module === "object" && function (m) { module.exports = m(); }) || + function (m) { this.bane = m(); } +)(function () { + "use strict"; + var slice = Array.prototype.slice; + + function handleError(event, error, errbacks) { + var i, l = errbacks.length; + if (l > 0) { + for (i = 0; i < l; ++i) { errbacks[i](event, error); } + return; + } + setTimeout(function () { + error.message = event + " listener threw error: " + error.message; + throw error; + }, 0); + } + + function assertFunction(fn) { + if (typeof fn !== "function") { + throw new TypeError("Listener is not function"); + } + return fn; + } + + function supervisors(object) { + if (!object.supervisors) { object.supervisors = []; } + return object.supervisors; + } + + function listeners(object, event) { + if (!object.listeners) { object.listeners = {}; } + if (event && !object.listeners[event]) { object.listeners[event] = []; } + return event ? object.listeners[event] : object.listeners; + } + + /** + * @signature var emitter = bane.createEmitter([object]); + * + * Create a new event emitter. If an object is passed, it will be modified + * by adding the event emitter methods (see below). + */ + function createEventEmitter(object) { + object = object || {}; + + function notifyListener(event, listener, args) { + try { + listener.listener.apply(listener.thisp || object, args); + } catch (e) { + handleError(event, e, object.errbacks || []); + } + } + + object.on = function (event, listener, thisp) { + if (typeof event === "function") { + return supervisors(this).push({ + listener: event, + thisp: listener + }); + } + listeners(this, event).push({ + listener: assertFunction(listener), + thisp: thisp + }); + }; + + object.off = function (event, listener) { + var fns, i, l; + if (typeof event === "function") { + fns = supervisors(this); + listener = event; + } else { + fns = listeners(this, event); + } + if (!listener) { + fns.splice(0, fns.length); + return; + } + for (i = 0, l = fns.length; i < l; ++i) { + if (fns[i].listener === listener) { + fns.splice(i, 1); + return; + } + } + }; + + object.once = function (event, listener, thisp) { + var wrapper = function () { + object.off(event, wrapper); + listener.apply(this, arguments); + }; + + object.on(event, wrapper, thisp); + }; + + object.bind = function (object, events) { + var prop, i, l; + if (!events) { + for (prop in object) { + if (typeof object[prop] === "function") { + this.on(prop, object[prop], object); + } + } + } else { + for (i = 0, l = events.length; i < l; ++i) { + if (typeof object[events[i]] === "function") { + this.on(events[i], object[events[i]], object); + } else { + throw new Error("No such method " + events[i]); + } + } + } + return object; + }; + + object.emit = function (event) { + var toNotify = listeners(this, event).slice(); + var args = slice.call(arguments, 1), i, l; + + for (i = 0, l = toNotify.length; i < l; i++) { + notifyListener(event, toNotify[i], args); + } + + toNotify = supervisors(this); + args = slice.call(arguments); + for (i = 0, l = toNotify.length; i < l; ++i) { + notifyListener(event, toNotify[i], args); + } + }; + + object.errback = function (listener) { + if (!this.errbacks) { this.errbacks = []; } + this.errbacks.push(assertFunction(listener)); + }; + + return object; + } + + return { createEventEmitter: createEventEmitter }; +}); + +/** @license MIT License (c) copyright B Cavalier & J Hann */ + +/** + * A lightweight CommonJS Promises/A and when() implementation + * when is part of the cujo.js family of libraries (http://cujojs.com/) + * + * Licensed under the MIT License at: + * http://www.opensource.org/licenses/mit-license.php + * + * @version 1.6.1 + */ + +(function(define) { 'use strict'; +define(['module'], function () { + var reduceArray, slice, undef; + + // + // Public API + // + + when.defer = defer; // Create a deferred + when.resolve = resolve; // Create a resolved promise + when.reject = reject; // Create a rejected promise + + when.join = join; // Join 2 or more promises + + when.all = all; // Resolve a list of promises + when.some = some; // Resolve a sub-set of promises + when.any = any; // Resolve one promise in a list + + when.map = map; // Array.map() for promises + when.reduce = reduce; // Array.reduce() for promises + + when.chain = chain; // Make a promise trigger another resolver + + when.isPromise = isPromise; // Determine if a thing is a promise + + /** + * Register an observer for a promise or immediate value. + * @function + * @name when + * @namespace + * + * @param promiseOrValue {*} + * @param {Function} [callback] callback to be called when promiseOrValue is + * successfully fulfilled. If promiseOrValue is an immediate value, callback + * will be invoked immediately. + * @param {Function} [errback] callback to be called when promiseOrValue is + * rejected. + * @param {Function} [progressHandler] callback to be called when progress updates + * are issued for promiseOrValue. + * @returns {Promise} a new {@link Promise} that will complete with the return + * value of callback or errback or the completion value of promiseOrValue if + * callback and/or errback is not supplied. + */ + function when(promiseOrValue, callback, errback, progressHandler) { + // Get a trusted promise for the input promiseOrValue, and then + // register promise handlers + return resolve(promiseOrValue).then(callback, errback, progressHandler); + } + + /** + * Returns promiseOrValue if promiseOrValue is a {@link Promise}, a new Promise if + * promiseOrValue is a foreign promise, or a new, already-fulfilled {@link Promise} + * whose value is promiseOrValue if promiseOrValue is an immediate value. + * @memberOf when + * + * @param promiseOrValue {*} + * @returns Guaranteed to return a trusted Promise. If promiseOrValue is a when.js {@link Promise} + * returns promiseOrValue, otherwise, returns a new, already-resolved, when.js {@link Promise} + * whose resolution value is: + * * the resolution value of promiseOrValue if it's a foreign promise, or + * * promiseOrValue if it's a value + */ + function resolve(promiseOrValue) { + var promise, deferred; + + if(promiseOrValue instanceof Promise) { + // It's a when.js promise, so we trust it + promise = promiseOrValue; + + } else { + // It's not a when.js promise. See if it's a foreign promise or a value. + + // Some promises, particularly Q promises, provide a valueOf method that + // attempts to synchronously return the fulfilled value of the promise, or + // returns the unresolved promise itself. Attempting to break a fulfillment + // value out of a promise appears to be necessary to break cycles between + // Q and When attempting to coerce each-other's promises in an infinite loop. + // For promises that do not implement "valueOf", the Object#valueOf is harmless. + // See: https://github.com/kriskowal/q/issues/106 + // IMPORTANT: Must check for a promise here, since valueOf breaks other things + // like Date. + if (isPromise(promiseOrValue) && typeof promiseOrValue.valueOf === 'function') { + promiseOrValue = promiseOrValue.valueOf(); + } + + if(isPromise(promiseOrValue)) { + // It looks like a thenable, but we don't know where it came from, + // so we don't trust its implementation entirely. Introduce a trusted + // middleman when.js promise + deferred = defer(); + + // IMPORTANT: This is the only place when.js should ever call .then() on + // an untrusted promise. + promiseOrValue.then(deferred.resolve, deferred.reject, deferred.progress); + promise = deferred.promise; + + } else { + // It's a value, not a promise. Create a resolved promise for it. + promise = fulfilled(promiseOrValue); + } + } + + return promise; + } + + /** + * Returns a rejected promise for the supplied promiseOrValue. If + * promiseOrValue is a value, it will be the rejection value of the + * returned promise. If promiseOrValue is a promise, its + * completion value will be the rejected value of the returned promise + * @memberOf when + * + * @param promiseOrValue {*} the rejected value of the returned {@link Promise} + * @return {Promise} rejected {@link Promise} + */ + function reject(promiseOrValue) { + return when(promiseOrValue, function(value) { + return rejected(value); + }); + } + + /** + * Trusted Promise constructor. A Promise created from this constructor is + * a trusted when.js promise. Any other duck-typed promise is considered + * untrusted. + * @constructor + * @name Promise + */ + function Promise(then) { + this.then = then; + } + + Promise.prototype = { + /** + * Register a callback that will be called when a promise is + * resolved or rejected. Optionally also register a progress handler. + * Shortcut for .then(alwaysback, alwaysback, progback) + * @memberOf Promise + * @param alwaysback {Function} + * @param progback {Function} + * @return {Promise} + */ + always: function(alwaysback, progback) { + return this.then(alwaysback, alwaysback, progback); + }, + + /** + * Register a rejection handler. Shortcut for .then(null, errback) + * @memberOf Promise + * @param errback {Function} + * @return {Promise} + */ + otherwise: function(errback) { + return this.then(undef, errback); + } + }; + + /** + * Create an already-resolved promise for the supplied value + * @private + * + * @param value anything + * @return {Promise} + */ + function fulfilled(value) { + var p = new Promise(function(callback) { + try { + return resolve(callback ? callback(value) : value); + } catch(e) { + return rejected(e); + } + }); + + return p; + } + + /** + * Create an already-rejected {@link Promise} with the supplied + * rejection reason. + * @private + * + * @param reason rejection reason + * @return {Promise} + */ + function rejected(reason) { + var p = new Promise(function(callback, errback) { + try { + return errback ? resolve(errback(reason)) : rejected(reason); + } catch(e) { + return rejected(e); + } + }); + + return p; + } + + /** + * Creates a new, Deferred with fully isolated resolver and promise parts, + * either or both of which may be given out safely to consumers. + * The Deferred itself has the full API: resolve, reject, progress, and + * then. The resolver has resolve, reject, and progress. The promise + * only has then. + * @memberOf when + * @function + * + * @return {Deferred} + */ + function defer() { + var deferred, promise, handlers, progressHandlers, + _then, _progress, _resolve; + + /** + * The promise for the new deferred + * @type {Promise} + */ + promise = new Promise(then); + + /** + * The full Deferred object, with {@link Promise} and {@link Resolver} parts + * @class Deferred + * @name Deferred + */ + deferred = { + then: then, + resolve: promiseResolve, + reject: promiseReject, + // TODO: Consider renaming progress() to notify() + progress: promiseProgress, + + promise: promise, + + resolver: { + resolve: promiseResolve, + reject: promiseReject, + progress: promiseProgress + } + }; + + handlers = []; + progressHandlers = []; + + /** + * Pre-resolution then() that adds the supplied callback, errback, and progback + * functions to the registered listeners + * @private + * + * @param [callback] {Function} resolution handler + * @param [errback] {Function} rejection handler + * @param [progback] {Function} progress handler + * @throws {Error} if any argument is not null, undefined, or a Function + */ + _then = function(callback, errback, progback) { + var deferred, progressHandler; + + deferred = defer(); + progressHandler = progback + ? function(update) { + try { + // Allow progress handler to transform progress event + deferred.progress(progback(update)); + } catch(e) { + // Use caught value as progress + deferred.progress(e); + } + } + : deferred.progress; + + handlers.push(function(promise) { + promise.then(callback, errback) + .then(deferred.resolve, deferred.reject, progressHandler); + }); + + progressHandlers.push(progressHandler); + + return deferred.promise; + }; + + /** + * Issue a progress event, notifying all progress listeners + * @private + * @param update {*} progress event payload to pass to all listeners + */ + _progress = function(update) { + processQueue(progressHandlers, update); + return update; + }; + + /** + * Transition from pre-resolution state to post-resolution state, notifying + * all listeners of the resolution or rejection + * @private + * @param completed {Promise} the completed value of this deferred + */ + _resolve = function(completed) { + completed = resolve(completed); + + // Replace _then with one that directly notifies with the result. + _then = completed.then; + // Replace _resolve so that this Deferred can only be completed once + _resolve = resolve; + // Make _progress a noop, to disallow progress for the resolved promise. + _progress = noop; + + // Notify handlers + processQueue(handlers, completed); + + // Free progressHandlers array since we'll never issue progress events + progressHandlers = handlers = undef; + + return completed; + }; + + return deferred; + + /** + * Wrapper to allow _then to be replaced safely + * @param [callback] {Function} resolution handler + * @param [errback] {Function} rejection handler + * @param [progback] {Function} progress handler + * @return {Promise} new Promise + * @throws {Error} if any argument is not null, undefined, or a Function + */ + function then(callback, errback, progback) { + return _then(callback, errback, progback); + } + + /** + * Wrapper to allow _resolve to be replaced + */ + function promiseResolve(val) { + return _resolve(val); + } + + /** + * Wrapper to allow _resolve to be replaced + */ + function promiseReject(err) { + return _resolve(rejected(err)); + } + + /** + * Wrapper to allow _progress to be replaced + * @param {*} update progress update + */ + function promiseProgress(update) { + return _progress(update); + } + } + + /** + * Determines if promiseOrValue is a promise or not. Uses the feature + * test from http://wiki.commonjs.org/wiki/Promises/A to determine if + * promiseOrValue is a promise. + * + * @param {*} promiseOrValue anything + * @returns {Boolean} true if promiseOrValue is a {@link Promise} + */ + function isPromise(promiseOrValue) { + return promiseOrValue && typeof promiseOrValue.then === 'function'; + } + + /** + * Initiates a competitive race, returning a promise that will resolve when + * howMany of the supplied promisesOrValues have resolved, or will reject when + * it becomes impossible for howMany to resolve, for example, when + * (promisesOrValues.length - howMany) + 1 input promises reject. + * @memberOf when + * + * @param promisesOrValues {Array} array of anything, may contain a mix + * of {@link Promise}s and values + * @param howMany {Number} number of promisesOrValues to resolve + * @param [callback] {Function} resolution handler + * @param [errback] {Function} rejection handler + * @param [progback] {Function} progress handler + * @returns {Promise} promise that will resolve to an array of howMany values that + * resolved first, or will reject with an array of (promisesOrValues.length - howMany) + 1 + * rejection reasons. + */ + function some(promisesOrValues, howMany, callback, errback, progback) { + + checkCallbacks(2, arguments); + + return when(promisesOrValues, function(promisesOrValues) { + + var toResolve, toReject, values, reasons, deferred, fulfillOne, rejectOne, progress, len, i; + + len = promisesOrValues.length >>> 0; + + toResolve = Math.max(0, Math.min(howMany, len)); + values = []; + + toReject = (len - toResolve) + 1; + reasons = []; + + deferred = defer(); + + // No items in the input, resolve immediately + if (!toResolve) { + deferred.resolve(values); + + } else { + progress = deferred.progress; + + rejectOne = function(reason) { + reasons.push(reason); + if(!--toReject) { + fulfillOne = rejectOne = noop; + deferred.reject(reasons); + } + }; + + fulfillOne = function(val) { + // This orders the values based on promise resolution order + // Another strategy would be to use the original position of + // the corresponding promise. + values.push(val); + + if (!--toResolve) { + fulfillOne = rejectOne = noop; + deferred.resolve(values); + } + }; + + for(i = 0; i < len; ++i) { + if(i in promisesOrValues) { + when(promisesOrValues[i], fulfiller, rejecter, progress); + } + } + } + + return deferred.then(callback, errback, progback); + + function rejecter(reason) { + rejectOne(reason); + } + + function fulfiller(val) { + fulfillOne(val); + } + + }); + } + + /** + * Initiates a competitive race, returning a promise that will resolve when + * any one of the supplied promisesOrValues has resolved or will reject when + * *all* promisesOrValues have rejected. + * @memberOf when + * + * @param promisesOrValues {Array|Promise} array of anything, may contain a mix + * of {@link Promise}s and values + * @param [callback] {Function} resolution handler + * @param [errback] {Function} rejection handler + * @param [progback] {Function} progress handler + * @returns {Promise} promise that will resolve to the value that resolved first, or + * will reject with an array of all rejected inputs. + */ + function any(promisesOrValues, callback, errback, progback) { + + function unwrapSingleResult(val) { + return callback ? callback(val[0]) : val[0]; + } + + return some(promisesOrValues, 1, unwrapSingleResult, errback, progback); + } + + /** + * Return a promise that will resolve only once all the supplied promisesOrValues + * have resolved. The resolution value of the returned promise will be an array + * containing the resolution values of each of the promisesOrValues. + * @memberOf when + * + * @param promisesOrValues {Array|Promise} array of anything, may contain a mix + * of {@link Promise}s and values + * @param [callback] {Function} + * @param [errback] {Function} + * @param [progressHandler] {Function} + * @returns {Promise} + */ + function all(promisesOrValues, callback, errback, progressHandler) { + checkCallbacks(1, arguments); + return map(promisesOrValues, identity).then(callback, errback, progressHandler); + } + + /** + * Joins multiple promises into a single returned promise. + * @memberOf when + * @param {Promise|*} [...promises] two or more promises to join + * @return {Promise} a promise that will fulfill when *all* the input promises + * have fulfilled, or will reject when *any one* of the input promises rejects. + */ + function join(/* ...promises */) { + return map(arguments, identity); + } + + /** + * Traditional map function, similar to `Array.prototype.map()`, but allows + * input to contain {@link Promise}s and/or values, and mapFunc may return + * either a value or a {@link Promise} + * + * @memberOf when + * + * @param promise {Array|Promise} array of anything, may contain a mix + * of {@link Promise}s and values + * @param mapFunc {Function} mapping function mapFunc(value) which may return + * either a {@link Promise} or value + * @returns {Promise} a {@link Promise} that will resolve to an array containing + * the mapped output values. + */ + function map(promise, mapFunc) { + return when(promise, function(array) { + var results, len, toResolve, resolve, reject, i, d; + + // Since we know the resulting length, we can preallocate the results + // array to avoid array expansions. + toResolve = len = array.length >>> 0; + results = []; + d = defer(); + + if(!toResolve) { + d.resolve(results); + } else { + + reject = d.reject; + resolve = function resolveOne(item, i) { + when(item, mapFunc).then(function(mapped) { + results[i] = mapped; + + if(!--toResolve) { + d.resolve(results); + } + }, reject); + }; + + // Since mapFunc may be async, get all invocations of it into flight + for(i = 0; i < len; i++) { + if(i in array) { + resolve(array[i], i); + } else { + --toResolve; + } + } + + } + + return d.promise; + + }); + } + + /** + * Traditional reduce function, similar to `Array.prototype.reduce()`, but + * input may contain {@link Promise}s and/or values, and reduceFunc + * may return either a value or a {@link Promise}, *and* initialValue may + * be a {@link Promise} for the starting value. + * @memberOf when + * + * @param promise {Array|Promise} array of anything, may contain a mix + * of {@link Promise}s and values. May also be a {@link Promise} for + * an array. + * @param reduceFunc {Function} reduce function reduce(currentValue, nextValue, index, total), + * where total is the total number of items being reduced, and will be the same + * in each call to reduceFunc. + * @param [initialValue] {*} starting value, or a {@link Promise} for the starting value + * @returns {Promise} that will resolve to the final reduced value + */ + function reduce(promise, reduceFunc /*, initialValue */) { + var args = slice.call(arguments, 1); + + return when(promise, function(array) { + var total; + + total = array.length; + + // Wrap the supplied reduceFunc with one that handles promises and then + // delegates to the supplied. + args[0] = function (current, val, i) { + return when(current, function (c) { + return when(val, function (value) { + return reduceFunc(c, value, i, total); + }); + }); + }; + + return reduceArray.apply(array, args); + }); + } + + /** + * Ensure that resolution of promiseOrValue will complete resolver with the completion + * value of promiseOrValue, or instead with resolveValue if it is provided. + * @memberOf when + * + * @param promiseOrValue + * @param resolver {Resolver} + * @param [resolveValue] anything + * @returns {Promise} + */ + function chain(promiseOrValue, resolver, resolveValue) { + var useResolveValue = arguments.length > 2; + + return when(promiseOrValue, + function(val) { + return resolver.resolve(useResolveValue ? resolveValue : val); + }, + resolver.reject, + resolver.progress + ); + } + + // + // Utility functions + // + + function processQueue(queue, value) { + var handler, i = 0; + + while (handler = queue[i++]) { + handler(value); + } + } + + /** + * Helper that checks arrayOfCallbacks to ensure that each element is either + * a function, or null or undefined. + * @private + * + * @param arrayOfCallbacks {Array} array to check + * @throws {Error} if any element of arrayOfCallbacks is something other than + * a Functions, null, or undefined. + */ + function checkCallbacks(start, arrayOfCallbacks) { + var arg, i = arrayOfCallbacks.length; + + while(i > start) { + arg = arrayOfCallbacks[--i]; + + if (arg != null && typeof arg != 'function') { + throw new Error('arg '+i+' must be a function'); + } + } + } + + /** + * No-Op function used in method replacement + * @private + */ + function noop() {} + + slice = [].slice; + + // ES5 reduce implementation if native not available + // See: http://es5.github.com/#x15.4.4.21 as there are many + // specifics and edge cases. + reduceArray = [].reduce || + function(reduceFunc /*, initialValue */) { + /*jshint maxcomplexity: 7*/ + + // ES5 dictates that reduce.length === 1 + + // This implementation deviates from ES5 spec in the following ways: + // 1. It does not check if reduceFunc is a Callable + + var arr, args, reduced, len, i; + + i = 0; + // This generates a jshint warning, despite being valid + // "Missing 'new' prefix when invoking a constructor." + // See https://github.com/jshint/jshint/issues/392 + arr = Object(this); + len = arr.length >>> 0; + args = arguments; + + // If no initialValue, use first item of array (we know length !== 0 here) + // and adjust i to start at second item + if(args.length <= 1) { + // Skip to the first real element in the array + for(;;) { + if(i in arr) { + reduced = arr[i++]; + break; + } + + // If we reached the end of the array without finding any real + // elements, it's a TypeError + if(++i >= len) { + throw new TypeError(); + } + } + } else { + // If initialValue provided, use it + reduced = args[1]; + } + + // Do the actual reduce + for(;i < len; ++i) { + // Skip holes + if(i in arr) { + reduced = reduceFunc(reduced, arr[i], i, arr); + } + } + + return reduced; + }; + + function identity(x) { + return x; + } + + return when; +}); +})(typeof define == 'function' && define.amd + ? define + : function (deps, factory) { typeof exports === 'object' + ? (module.exports = factory()) + : (this.when = factory()); + } + // Boilerplate for AMD, Node, and browser global +); + +/*global bane:false, when:false*/ + +function Mopidy(settings) { + this._settings = this._configure(settings || {}); + this._console = this._getConsole(); + + this._backoffDelay = this._settings.backoffDelayMin; + this._pendingRequests = {}; + this._webSocket = null; + + bane.createEventEmitter(this); + this._delegateEvents(); + + if (this._settings.autoConnect) { + this._connect(); + } +} + +Mopidy.prototype._configure = function (settings) { + settings.webSocketUrl = settings.webSocketUrl || + "ws://" + document.location.host + "/mopidy/ws/"; + + if (settings.autoConnect !== false) { + settings.autoConnect = true; + } + + settings.backoffDelayMin = settings.backoffDelayMin || 1000; + settings.backoffDelayMax = settings.backoffDelayMax || 64000; + + return settings; +}; + +Mopidy.prototype._getConsole = function () { + var console = window.console || {}; + + console.log = console.log || function () {}; + console.warn = console.warn || function () {}; + console.error = console.error || function () {}; + + return console; +}; + +Mopidy.prototype._delegateEvents = function () { + // Remove existing event handlers + this.off("websocket:close"); + this.off("websocket:error"); + this.off("websocket:incomingMessage"); + this.off("websocket:open"); + this.off("state:offline"); + + // Register basic set of event handlers + this.on("websocket:close", this._cleanup); + this.on("websocket:error", this._handleWebSocketError); + this.on("websocket:incomingMessage", this._handleMessage); + this.on("websocket:open", this._resetBackoffDelay); + this.on("websocket:open", this._getApiSpec); + this.on("state:offline", this._reconnect); +}; + +Mopidy.prototype._connect = function () { + if (this._webSocket) { + if (this._webSocket.readyState === WebSocket.OPEN) { + return; + } else { + this._webSocket.close(); + } + } + + this._webSocket = this._settings.webSocket || + new WebSocket(this._settings.webSocketUrl); + + this._webSocket.onclose = function (close) { + this.emit("websocket:close", close); + }.bind(this); + + this._webSocket.onerror = function (error) { + this.emit("websocket:error", error); + }.bind(this); + + this._webSocket.onopen = function () { + this.emit("websocket:open"); + }.bind(this); + + this._webSocket.onmessage = function (message) { + this.emit("websocket:incomingMessage", message); + }.bind(this); +}; + +Mopidy.prototype._cleanup = function (closeEvent) { + Object.keys(this._pendingRequests).forEach(function (requestId) { + var resolver = this._pendingRequests[requestId]; + delete this._pendingRequests[requestId]; + resolver.reject({ + message: "WebSocket closed", + closeEvent: closeEvent + }); + }.bind(this)); + + this.emit("state:offline"); +}; + +Mopidy.prototype._reconnect = function () { + this.emit("reconnectionPending", { + timeToAttempt: this._backoffDelay + }); + + setTimeout(function () { + this.emit("reconnecting"); + this._connect(); + }.bind(this), this._backoffDelay); + + this._backoffDelay = this._backoffDelay * 2; + if (this._backoffDelay > this._settings.backoffDelayMax) { + this._backoffDelay = this._settings.backoffDelayMax; + } +}; + +Mopidy.prototype._resetBackoffDelay = function () { + this._backoffDelay = this._settings.backoffDelayMin; +}; + +Mopidy.prototype._handleWebSocketError = function (error) { + this._console.warn("WebSocket error:", error.stack || error); +}; + +Mopidy.prototype._send = function (message) { + var deferred = when.defer(); + + switch (this._webSocket.readyState) { + case WebSocket.CONNECTING: + deferred.resolver.reject({ + message: "WebSocket is still connecting" + }); + break; + case WebSocket.CLOSING: + deferred.resolver.reject({ + message: "WebSocket is closing" + }); + break; + case WebSocket.CLOSED: + deferred.resolver.reject({ + message: "WebSocket is closed" + }); + break; + default: + message.jsonrpc = "2.0"; + message.id = this._nextRequestId(); + this._pendingRequests[message.id] = deferred.resolver; + this._webSocket.send(JSON.stringify(message)); + this.emit("websocket:outgoingMessage", message); + } + + return deferred.promise; +}; + +Mopidy.prototype._nextRequestId = (function () { + var lastUsed = -1; + return function () { + lastUsed += 1; + return lastUsed; + }; +}()); + +Mopidy.prototype._handleMessage = function (message) { + try { + var data = JSON.parse(message.data); + if (data.hasOwnProperty("id")) { + this._handleResponse(data); + } else if (data.hasOwnProperty("event")) { + this._handleEvent(data); + } else { + this._console.warn( + "Unknown message type received. Message was: " + + message.data); + } + } catch (error) { + if (error instanceof SyntaxError) { + this._console.warn( + "WebSocket message parsing failed. Message was: " + + message.data); + } else { + throw error; + } + } +}; + +Mopidy.prototype._handleResponse = function (responseMessage) { + if (!this._pendingRequests.hasOwnProperty(responseMessage.id)) { + this._console.warn( + "Unexpected response received. Message was:", responseMessage); + return; + } + + var resolver = this._pendingRequests[responseMessage.id]; + delete this._pendingRequests[responseMessage.id]; + + if (responseMessage.hasOwnProperty("result")) { + resolver.resolve(responseMessage.result); + } else if (responseMessage.hasOwnProperty("error")) { + resolver.reject(responseMessage.error); + this._console.warn("Server returned error:", responseMessage.error); + } else { + resolver.reject({ + message: "Response without 'result' or 'error' received", + data: {response: responseMessage} + }); + this._console.warn( + "Response without 'result' or 'error' received. Message was:", + responseMessage); + } +}; + +Mopidy.prototype._handleEvent = function (eventMessage) { + var type = eventMessage.event; + var data = eventMessage; + delete data.event; + + this.emit("event:" + this._snakeToCamel(type), data); +}; + +Mopidy.prototype._getApiSpec = function () { + this._send({method: "core.describe"}) + .then(this._createApi.bind(this), this._handleWebSocketError) + .then(null, this._handleWebSocketError); +}; + +Mopidy.prototype._createApi = function (methods) { + var caller = function (method) { + return function () { + var params = Array.prototype.slice.call(arguments); + return this._send({ + method: method, + params: params + }); + }.bind(this); + }.bind(this); + + var getPath = function (fullName) { + var path = fullName.split("."); + if (path.length >= 1 && path[0] === "core") { + path = path.slice(1); + } + return path; + }; + + var createObjects = function (objPath) { + var parentObj = this; + objPath.forEach(function (objName) { + objName = this._snakeToCamel(objName); + parentObj[objName] = parentObj[objName] || {}; + parentObj = parentObj[objName]; + }.bind(this)); + return parentObj; + }.bind(this); + + var createMethod = function (fullMethodName) { + var methodPath = getPath(fullMethodName); + var methodName = this._snakeToCamel(methodPath.slice(-1)[0]); + var object = createObjects(methodPath.slice(0, -1)); + object[methodName] = caller(fullMethodName); + object[methodName].description = methods[fullMethodName].description; + object[methodName].params = methods[fullMethodName].params; + }.bind(this); + + Object.keys(methods).forEach(createMethod); + this.emit("state:online"); +}; + +Mopidy.prototype._snakeToCamel = function (name) { + return name.replace(/(_[a-z])/g, function (match) { + return match.toUpperCase().replace("_", ""); + }); +}; diff --git a/mopidy/frontends/http/data/mopidy.min.js b/mopidy/frontends/http/data/mopidy.min.js new file mode 100644 index 00000000..dc9d63c5 --- /dev/null +++ b/mopidy/frontends/http/data/mopidy.min.js @@ -0,0 +1,5 @@ +/*! Mopidy.js - built 2012-12-03 + * http://www.mopidy.com/ + * Copyright (c) 2012 Stein Magnus Jodal and contributors + * Licensed under the Apache License, Version 2.0 */ +function Mopidy(e){this._settings=this._configure(e||{}),this._console=this._getConsole(),this._backoffDelay=this._settings.backoffDelayMin,this._pendingRequests={},this._webSocket=null,bane.createEventEmitter(this),this._delegateEvents(),this._settings.autoConnect&&this._connect()}(typeof define=="function"&&define.amd&&function(e){define(e)}||typeof module=="object"&&function(e){module.exports=e()}||function(e){this.bane=e()})(function(){"use strict";function t(e,t,n){var r,i=n.length;if(i>0){for(r=0;r>>0,o=Math.max(0,Math.min(t,v)),a=[],u=v-o+1,l=[],c=f();if(!o)c.resolve(a);else{d=c.progress,p=function(e){l.push(e),--u||(h=p=w,c.reject(l))},h=function(e){a.push(e),--o||(h=p=w,c.resolve(a))};for(m=0;m>>0,n=[],l=f();if(!s)l.resolve(n);else{u=l.reject,o=function(i,o){r(i,t).then(function(e){n[o]=e,--s||l.resolve(n)},u)};for(a=0;a2;return r(e,function(e){return t.resolve(i?n:e)},t.reject,t.progress)}function y(e,t){var n,r=0;while(n=e[r++])n(t)}function b(e,t){var n,r=t.length;while(r>e){n=t[--r];if(n!=null&&typeof n!="function")throw new Error("arg "+r+" must be a function")}}function w(){}function E(e){return e}var e,t,n;return r.defer=f,r.resolve=i,r.reject=s,r.join=d,r.all=p,r.some=c,r.any=h,r.map=v,r.reduce=m,r.chain=g,r.isPromise=l,o.prototype={always:function(e,t){return this.then(e,e,t)},otherwise:function(e){return this.then(n,e)}},t=[].slice,e=[].reduce||function(e){var t,n,r,i,s;s=0,t=Object(this),i=t.length>>>0,n=arguments;if(n.length<=1)for(;;){if(s in t){r=t[s++];break}if(++s>=i)throw new TypeError}else r=n[1];for(;sthis._settings.backoffDelayMax&&(this._backoffDelay=this._settings.backoffDelayMax)},Mopidy.prototype._resetBackoffDelay=function(){this._backoffDelay=this._settings.backoffDelayMin},Mopidy.prototype._handleWebSocketError=function(e){this._console.warn("WebSocket error:",e.stack||e)},Mopidy.prototype._send=function(e){var t=when.defer();switch(this._webSocket.readyState){case WebSocket.CONNECTING:t.resolver.reject({message:"WebSocket is still connecting"});break;case WebSocket.CLOSING:t.resolver.reject({message:"WebSocket is closing"});break;case WebSocket.CLOSED:t.resolver.reject({message:"WebSocket is closed"});break;default:e.jsonrpc="2.0",e.id=this._nextRequestId(),this._pendingRequests[e.id]=t.resolver,this._webSocket.send(JSON.stringify(e)),this.emit("websocket:outgoingMessage",e)}return t.promise},Mopidy.prototype._nextRequestId=function(){var e=-1;return function(){return e+=1,e}}(),Mopidy.prototype._handleMessage=function(e){try{var t=JSON.parse(e.data);t.hasOwnProperty("id")?this._handleResponse(t):t.hasOwnProperty("event")?this._handleEvent(t):this._console.warn("Unknown message type received. Message was: "+e.data)}catch(n){if(!(n instanceof SyntaxError))throw n;this._console.warn("WebSocket message parsing failed. Message was: "+e.data)}},Mopidy.prototype._handleResponse=function(e){if(!this._pendingRequests.hasOwnProperty(e.id)){this._console.warn("Unexpected response received. Message was:",e);return}var t=this._pendingRequests[e.id];delete this._pendingRequests[e.id],e.hasOwnProperty("result")?t.resolve(e.result):e.hasOwnProperty("error")?(t.reject(e.error),this._console.warn("Server returned error:",e.error)):(t.reject({message:"Response without 'result' or 'error' received",data:{response:e}}),this._console.warn("Response without 'result' or 'error' received. Message was:",e))},Mopidy.prototype._handleEvent=function(e){var t=e.event,n=e;delete n.event,this.emit("event:"+this._snakeToCamel(t),n)},Mopidy.prototype._getApiSpec=function(){this._send({method:"core.describe"}).then(this._createApi.bind(this),this._handleWebSocketError).then(null,this._handleWebSocketError)},Mopidy.prototype._createApi=function(e){var t=function(e){return function(){var t=Array.prototype.slice.call(arguments);return this._send({method:e,params:t})}.bind(this)}.bind(this),n=function(e){var t=e.split(".");return t.length>=1&&t[0]==="core"&&(t=t.slice(1)),t},r=function(e){var t=this;return e.forEach(function(e){e=this._snakeToCamel(e),t[e]=t[e]||{},t=t[e]}.bind(this)),t}.bind(this),i=function(i){var s=n(i),o=this._snakeToCamel(s.slice(-1)[0]),u=r(s.slice(0,-1));u[o]=t(i),u[o].description=e[i].description,u[o].params=e[i].params}.bind(this);Object.keys(e).forEach(i),this.emit("state:online")},Mopidy.prototype._snakeToCamel=function(e){return e.replace(/(_[a-z])/g,function(e){return e.toUpperCase().replace("_","")})}; \ No newline at end of file From dd9f0497ea3494bc58dea817837bd577a86843c1 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 3 Dec 2012 00:06:44 +0100 Subject: [PATCH 22/30] js: Document Mopidy.js usage --- mopidy/frontends/http/__init__.py | 297 +++++++++++++++++++++++++++++- 1 file changed, 292 insertions(+), 5 deletions(-) diff --git a/mopidy/frontends/http/__init__.py b/mopidy/frontends/http/__init__.py index d98734b2..ebc57345 100644 --- a/mopidy/frontends/http/__init__.py +++ b/mopidy/frontends/http/__init__.py @@ -115,17 +115,304 @@ Example JSON-RPC request:: Example JSON-RPC response:: - {"jsonrpc": "2.0", "id": 1, "result": {"__model__": "Track", ...}} + {"jsonrpc": "2.0", "id": 1, "result": {"__model__": "Track", "...": "..."}} The JSON-RPC method ``core.describe`` returns a data structure describing all available methods. If you're unsure how the core API maps to JSON-RPC, having a look at the ``core.describe`` response can be helpful. -JavaScript wrapper -================== -A JavaScript library wrapping the JSON-RPC over WebSocket API is under -development. Details on it will appear here when it's released. +Mopidy.js JavaScript library +============================ + +We've made a JavaScript library, Mopidy.js, which wraps the WebSocket and gets +you quickly started with working on your client instead of figuring out how to +communicate with Mopidy. + + +Getting the library +------------------- + +Regular and minified versions of Mopidy.js, ready for use, is installed +together with Mopidy. When the HTTP frontend is running, the files are +available at: + +- http://localhost:6680/mopidy/mopidy.js +- http://localhost:6680/mopidy/mopidy.min.js + +You may need to adjust hostname and port for your local setup. + +Thus, if you use Mopidy to host your web client, like described above, you can +load the latest version of Mopidy.js by adding the following script tag to your +HTML file: + +.. code-block:: html + + + +If you don't use Mopidy to host your web client, you can find the JS files in +the Git repo at: + +- ``mopidy/frontends/http/data/mopidy.js`` +- ``mopidy/frontends/http/data/mopidy.min.js`` + +If you want to work on the Mopidy.js library itself, you'll find a complete +development setup in the ``js/`` dir in our repo. The instructions in +``js/README.rst`` will guide you on your way. + + +Creating an instance +-------------------- + +Once you got Mopidy.js loaded, you need to create an instance of the wrapper: + +.. code-block:: js + + var mopidy = new Mopidy(); + +When you instantiate ``Mopidy()`` without arguments, it will connect to +the WebSocket at ``/mopidy/ws/`` on the current host. Thus, if you don't host +your web client using Mopidy's web server, you'll need to pass the URL to the +WebSocket end point: + +.. code-block:: js + + var mopidy = new Mopidy({ + webSocketUrl: "ws://localhost:6680/mopidy/ws/" + }); + + +Hooking up to events +-------------------- + +Once you have a Mopidy.js object, you can hook up to the events it emits. To +explore your possibilities, it can be useful to subscribe to all events and log +them: + +.. code-block:: js + + mopidy.on(console.log); + +Several types of events are emitted: + +- You can get notified about when the Mopidy.js object is connected to the + server and ready for method calls, when it's offline, and when it's trying to + reconnect to the server by looking at the events ``state:online``, + ``state:offline``, ``reconnectionPending``, and ``reconnecting``. + +- You can get events sent from the Mopidy server by looking at the events with + the name prefix ``event:``, like ``event:trackPlaybackStarted``. + +- You can introspect what happens internally on the WebSocket by looking at the + events emitted with the name prefix ``websocket:``. + +Mopidy.js uses the event emitter library `BANE +`_, so you should refer to BANE's +short API documentation to see how you can hook up your listeners to the +different events. + + +Calling core API methods +------------------------ + +Once your Mopidy.js object has connected to the Mopidy server and emits the +``state:online`` event, it is ready to accept core API method calls: + +.. code-block:: js + + mopidy.on("state:online", function () [ + mopidy.playback.next(); + }); + +All methods in Mopidy's :ref:`core-api` is available via Mopidy.js. The core +API attributes is *not* available, but that shouldn't be a problem as we've +added (undocumented) getters and setters for all of them, so you can access the +attributes as well from JavaScript. + +Both the WebSocket API and the JavaScript API are based on introspection of the +core Python API. Thus, they will always be up to date and immediately reflect +any changes we do to the core API. + +The best way to explore the JavaScript API, is probably by opening your +browser's console, and using its tab completion to navigate the API. You'll +find the Mopidy core API exposed under ``mopidy.playback``, +``mopidy.tracklist``, ``mopidy.playlists``, and ``mopidy.library``. + +All methods in the JavaScript API have an associated data structure describing +the params it expects, and most methods also got API documentation available. +This is available right there in the browser console, by looking at the +method's ``description`` and ``params`` attributes: + +.. code-block:: js + + console.log(mopidy.playback.next.params); + console.log(mopidy.playback.next.description); + +Obviously, you'll want to get a return value from many of your method calls. +Since everything is happening across the WebSocket and maybe even across the +network, you'll get the results asynchronously. Instead of having to pass +callbacks and errbacks to every method you call, the methods return "promise" +objects, which you can use to pipe the future result as input to another +method, or to hook up callback and errback functions. + +.. code-block:: js + + var track = mopidy.playback.getCurrentTrack(); + // => ``track`` isn't a track, but a "promise" object + +Instead, typical usage will look like this: + +.. code-block:: js + + var printCurrentTrack = function (track) { + if (track) { + console.log("Currently playing:", track.name, "by", + track.artists[0].name, "from", track.album.name); + } else { + console.log("No current track"); + } + }; + + mopidy.playback.getCurrentTrack().then(printCurrentTrack, console.error); + +The first function passed to ``then()``, ``printCurrentTrack``, is the callback +that will be called if the method call succeeds. The second function, +``console.error``, is the errback that will be called if anything goes wrong. +If you don't hook up an errback, debugging will be hard as errors will silently +go missing. + +For debugging, you may be interested in errors from function without +interesting return values as well. In that case, you can pass ``null`` as the +callback: + +.. code-block:: js + + mopidy.playback.next().then(null, console.error); + +The promise objects returned by Mopidy.js adheres to the `CommonJS Promises/A +`_ standard. We use the +implementation known as `when.js `_. Please +refer to when.js' documentation or the standard for further details on how to +work with promise objects. + + +Example to get started with +--------------------------- + +1. Create an empty directory for your web client. + +2. Change the setting :attr:`mopidy.settings.HTTP_SERVER_STATIC_DIR` to point + to your new directory. + +3. Make sure that you've included + ``mopidy.frontends.http.HttpFrontend`` in + :attr:`mopidy.settings.FRONTENDS`. + +4. Start/restart Mopidy. + +5. Create a file in the directory named ``index.html`` containing e.g. "Hello, + world!". + +6. Visit http://localhost:6680/ to confirm that you can view your new HTML file + there. + +7. Include Mopidy.js in your web page: + + .. code-block:: html + + + +8. Add one of the following Mopidy.js examples of how to queue and start + playback of your first playlist either to your web page or a JavaScript file + that you include in your web page. + + "Imperative" style: + + .. code-block:: js + + var trackDesc = function (track) { + return track.name + " by " + track.artists[0].name + + " from " + track.album.name; + }; + + var queueAndPlayFirstPlaylist = function () { + mopidy.playlists.getPlaylists().then(function (playlists) { + var playlist = playlists[0]; + console.log("Loading playlist:", playlist.name); + mopidy.tracklist.add(playlist.tracks).then(function (tlTracks) { + mopidy.playback.play(tlTracks[0]).then(function () { + mopidy.playback.getCurrentTrack().then(function (track) { + console.log("Now playing:", trackDesc(track)); + }, console.error); + }, console.error); + }, console.error); + }, console.error); + }; + + var mopidy = new Mopidy(); // Connect to server + mopidy.on(console.log); // Log all events + mopidy.on("state:online", queueAndPlayFirstPlaylist); + + Approximately the same behavior in a more functional style, using chaining + of promisies. + + .. code-block:: js + + var getFirst = function (list) { + return list[0]; + }; + + var extractTracks = function (playlist) { + return playlist.tracks; + }; + + var printTypeAndName = function (model) { + console.log(model.__model__ + ": " + model.name); + // By returning the playlist, this function can be inserted + // anywhere a model with a name is piped in the chain. + return model; + }; + + var trackDesc = function (track) { + return track.name + " by " + track.artists[0].name + + " from " + track.album.name; + }; + + var printNowPlaying = function () { + // By returning any arguments we get, the function can be inserted + // anywhere in the chain. + var args = arguments; + return mopidy.playback.getCurrentTrack().then(function (track) { + console.log("Now playing:", trackDesc(track)); + return args; + }); + }; + + var queueAndPlayFirstPlaylist = function () { + mopidy.playlists.getPlaylists() + // => list of Playlists + .then(getFirst, console.error) + // => Playlist + .then(printTypeAndName, console.error) + // => Playlist + .then(extractTracks, console.error) + // => list of Tracks + .then(mopidy.tracklist.add, console.error) + // => list of TlTracks + .then(getFirst, console.error) + // => TlTrack + .then(mopidy.playback.play, console.error) + // => null + .then(printNowPlaying, console.error); + }; + + var mopidy = new Mopidy(); // Connect to server + mopidy.on(console.log); // Log all events + mopidy.on("state:online", queueAndPlayFirstPlaylist); + +9. The web page should now queue and play your first playlist every time your + load it. See the browser's console for output from the function, any errors, + and a all events that are emitted. """ # flake8: noqa From b5c5bcd645353cf91dfa2a9e547ed96ca9eef10f Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 3 Dec 2012 22:59:01 +0100 Subject: [PATCH 23/30] js: Fix grammatical error --- mopidy/frontends/http/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mopidy/frontends/http/__init__.py b/mopidy/frontends/http/__init__.py index ebc57345..4fa48c79 100644 --- a/mopidy/frontends/http/__init__.py +++ b/mopidy/frontends/http/__init__.py @@ -239,7 +239,7 @@ find the Mopidy core API exposed under ``mopidy.playback``, ``mopidy.tracklist``, ``mopidy.playlists``, and ``mopidy.library``. All methods in the JavaScript API have an associated data structure describing -the params it expects, and most methods also got API documentation available. +the params it expects, and most methods also have API documentation available. This is available right there in the browser console, by looking at the method's ``description`` and ``params`` attributes: From d860ab0ae040d541b732430f79b3a72379d0a41a Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 3 Dec 2012 23:02:24 +0100 Subject: [PATCH 24/30] js: Document behavior on calls when we're offline --- mopidy/frontends/http/__init__.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/mopidy/frontends/http/__init__.py b/mopidy/frontends/http/__init__.py index 4fa48c79..7f8db3b2 100644 --- a/mopidy/frontends/http/__init__.py +++ b/mopidy/frontends/http/__init__.py @@ -224,6 +224,10 @@ Once your Mopidy.js object has connected to the Mopidy server and emits the mopidy.playback.next(); }); +Any calls you make before the ``state:online`` event is emitted will fail. If +you've hooked up an errback (more on that a bit later) to the promise returned +from the call, the errback will be called with an error message. + All methods in Mopidy's :ref:`core-api` is available via Mopidy.js. The core API attributes is *not* available, but that shouldn't be a problem as we've added (undocumented) getters and setters for all of them, so you can access the From ddd3d6ba98630a7bc3dca852cf12b89b24d80f49 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 3 Dec 2012 23:05:25 +0100 Subject: [PATCH 25/30] js: 'params' and 'description' is from the Python API --- mopidy/frontends/http/__init__.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/mopidy/frontends/http/__init__.py b/mopidy/frontends/http/__init__.py index 7f8db3b2..fab5ff80 100644 --- a/mopidy/frontends/http/__init__.py +++ b/mopidy/frontends/http/__init__.py @@ -243,9 +243,9 @@ find the Mopidy core API exposed under ``mopidy.playback``, ``mopidy.tracklist``, ``mopidy.playlists``, and ``mopidy.library``. All methods in the JavaScript API have an associated data structure describing -the params it expects, and most methods also have API documentation available. -This is available right there in the browser console, by looking at the -method's ``description`` and ``params`` attributes: +the Python params it expects, and most methods also have the Python API +documentation available. This is available right there in the browser console, +by looking at the method's ``description`` and ``params`` attributes: .. code-block:: js From 126b7815d042ee1cc2997cbdd83445d4891ea16a Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 3 Dec 2012 23:08:40 +0100 Subject: [PATCH 26/30] js: Document parameter handling --- mopidy/frontends/http/__init__.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/mopidy/frontends/http/__init__.py b/mopidy/frontends/http/__init__.py index fab5ff80..7697b60e 100644 --- a/mopidy/frontends/http/__init__.py +++ b/mopidy/frontends/http/__init__.py @@ -252,6 +252,11 @@ by looking at the method's ``description`` and ``params`` attributes: console.log(mopidy.playback.next.params); console.log(mopidy.playback.next.description); +JSON-RPC 2.0 limits method parameters to be sent *either* by-position or +by-name. Combinations of both, like we're used to from Python, isn't supported +by JSON-RPC 2.0. To further limit this, Mopidy.js currently only supports +passing parameters by-position. + Obviously, you'll want to get a return value from many of your method calls. Since everything is happening across the WebSocket and maybe even across the network, you'll get the results asynchronously. Instead of having to pass From b807c38b01172793d4c460074933d64573de4c79 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 4 Dec 2012 09:04:16 +0100 Subject: [PATCH 27/30] js: Upgrade BANE to 0.4 --- js/lib/{bane-0.3.0.js => bane-0.4.0.js} | 42 ++++++++++++++++------ mopidy/frontends/http/data/mopidy.js | 44 ++++++++++++++++++------ mopidy/frontends/http/data/mopidy.min.js | 4 +-- 3 files changed, 67 insertions(+), 23 deletions(-) rename js/lib/{bane-0.3.0.js => bane-0.4.0.js} (79%) diff --git a/js/lib/bane-0.3.0.js b/js/lib/bane-0.4.0.js similarity index 79% rename from js/lib/bane-0.3.0.js rename to js/lib/bane-0.4.0.js index b82b8cd9..a1da6efa 100644 --- a/js/lib/bane-0.3.0.js +++ b/js/lib/bane-0.4.0.js @@ -3,7 +3,7 @@ * * https://github.com/busterjs/bane * - * @version 0.3.0 + * @version 0.4.0 */ ((typeof define === "function" && define.amd && function (m) { define(m); }) || @@ -43,6 +43,11 @@ return event ? object.listeners[event] : object.listeners; } + function errbacks(object) { + if (!object.errbacks) { object.errbacks = []; } + return object.errbacks; + } + /** * @signature var emitter = bane.createEmitter([object]); * @@ -56,7 +61,7 @@ try { listener.listener.apply(listener.thisp || object, args); } catch (e) { - handleError(event, e, object.errbacks || []); + handleError(event, e, errbacks(object)); } } @@ -74,7 +79,24 @@ }; object.off = function (event, listener) { - var fns, i, l; + var fns, events, i, l; + if (!event) { + fns = supervisors(this); + fns.splice(0, fns.length); + + events = listeners(this); + for (i in events) { + if (events.hasOwnProperty(i)) { + fns = listeners(this, i); + fns.splice(0, fns.length); + } + } + + fns = errbacks(this); + fns.splice(0, fns.length); + + return; + } if (typeof event === "function") { fns = supervisors(this); listener = event; @@ -82,8 +104,8 @@ fns = listeners(this, event); } if (!listener) { - fns.splice(0, fns.length); - return; + fns.splice(0, fns.length); + return; } for (i = 0, l = fns.length; i < l; ++i) { if (fns[i].listener === listener) { @@ -123,15 +145,15 @@ }; object.emit = function (event) { - var toNotify = listeners(this, event).slice(); - var args = slice.call(arguments, 1), i, l; + var toNotify = supervisors(this); + var args = slice.call(arguments), i, l; - for (i = 0, l = toNotify.length; i < l; i++) { + for (i = 0, l = toNotify.length; i < l; ++i) { notifyListener(event, toNotify[i], args); } - toNotify = supervisors(this); - args = slice.call(arguments); + toNotify = listeners(this, event).slice() + args = slice.call(arguments, 1); for (i = 0, l = toNotify.length; i < l; ++i) { notifyListener(event, toNotify[i], args); } diff --git a/mopidy/frontends/http/data/mopidy.js b/mopidy/frontends/http/data/mopidy.js index 29f076c5..55fec91c 100644 --- a/mopidy/frontends/http/data/mopidy.js +++ b/mopidy/frontends/http/data/mopidy.js @@ -1,4 +1,4 @@ -/*! Mopidy.js - built 2012-12-03 +/*! Mopidy.js - built 2012-12-04 * http://www.mopidy.com/ * Copyright (c) 2012 Stein Magnus Jodal and contributors * Licensed under the Apache License, Version 2.0 */ @@ -8,7 +8,7 @@ * * https://github.com/busterjs/bane * - * @version 0.3.0 + * @version 0.4.0 */ ((typeof define === "function" && define.amd && function (m) { define(m); }) || @@ -48,6 +48,11 @@ return event ? object.listeners[event] : object.listeners; } + function errbacks(object) { + if (!object.errbacks) { object.errbacks = []; } + return object.errbacks; + } + /** * @signature var emitter = bane.createEmitter([object]); * @@ -61,7 +66,7 @@ try { listener.listener.apply(listener.thisp || object, args); } catch (e) { - handleError(event, e, object.errbacks || []); + handleError(event, e, errbacks(object)); } } @@ -79,7 +84,24 @@ }; object.off = function (event, listener) { - var fns, i, l; + var fns, events, i, l; + if (!event) { + fns = supervisors(this); + fns.splice(0, fns.length); + + events = listeners(this); + for (i in events) { + if (events.hasOwnProperty(i)) { + fns = listeners(this, i); + fns.splice(0, fns.length); + } + } + + fns = errbacks(this); + fns.splice(0, fns.length); + + return; + } if (typeof event === "function") { fns = supervisors(this); listener = event; @@ -87,8 +109,8 @@ fns = listeners(this, event); } if (!listener) { - fns.splice(0, fns.length); - return; + fns.splice(0, fns.length); + return; } for (i = 0, l = fns.length; i < l; ++i) { if (fns[i].listener === listener) { @@ -128,15 +150,15 @@ }; object.emit = function (event) { - var toNotify = listeners(this, event).slice(); - var args = slice.call(arguments, 1), i, l; + var toNotify = supervisors(this); + var args = slice.call(arguments), i, l; - for (i = 0, l = toNotify.length; i < l; i++) { + for (i = 0, l = toNotify.length; i < l; ++i) { notifyListener(event, toNotify[i], args); } - toNotify = supervisors(this); - args = slice.call(arguments); + toNotify = listeners(this, event).slice() + args = slice.call(arguments, 1); for (i = 0, l = toNotify.length; i < l; ++i) { notifyListener(event, toNotify[i], args); } diff --git a/mopidy/frontends/http/data/mopidy.min.js b/mopidy/frontends/http/data/mopidy.min.js index dc9d63c5..9c7b9e7c 100644 --- a/mopidy/frontends/http/data/mopidy.min.js +++ b/mopidy/frontends/http/data/mopidy.min.js @@ -1,5 +1,5 @@ -/*! Mopidy.js - built 2012-12-03 +/*! Mopidy.js - built 2012-12-04 * http://www.mopidy.com/ * Copyright (c) 2012 Stein Magnus Jodal and contributors * Licensed under the Apache License, Version 2.0 */ -function Mopidy(e){this._settings=this._configure(e||{}),this._console=this._getConsole(),this._backoffDelay=this._settings.backoffDelayMin,this._pendingRequests={},this._webSocket=null,bane.createEventEmitter(this),this._delegateEvents(),this._settings.autoConnect&&this._connect()}(typeof define=="function"&&define.amd&&function(e){define(e)}||typeof module=="object"&&function(e){module.exports=e()}||function(e){this.bane=e()})(function(){"use strict";function t(e,t,n){var r,i=n.length;if(i>0){for(r=0;r>>0,o=Math.max(0,Math.min(t,v)),a=[],u=v-o+1,l=[],c=f();if(!o)c.resolve(a);else{d=c.progress,p=function(e){l.push(e),--u||(h=p=w,c.reject(l))},h=function(e){a.push(e),--o||(h=p=w,c.resolve(a))};for(m=0;m>>0,n=[],l=f();if(!s)l.resolve(n);else{u=l.reject,o=function(i,o){r(i,t).then(function(e){n[o]=e,--s||l.resolve(n)},u)};for(a=0;a2;return r(e,function(e){return t.resolve(i?n:e)},t.reject,t.progress)}function y(e,t){var n,r=0;while(n=e[r++])n(t)}function b(e,t){var n,r=t.length;while(r>e){n=t[--r];if(n!=null&&typeof n!="function")throw new Error("arg "+r+" must be a function")}}function w(){}function E(e){return e}var e,t,n;return r.defer=f,r.resolve=i,r.reject=s,r.join=d,r.all=p,r.some=c,r.any=h,r.map=v,r.reduce=m,r.chain=g,r.isPromise=l,o.prototype={always:function(e,t){return this.then(e,e,t)},otherwise:function(e){return this.then(n,e)}},t=[].slice,e=[].reduce||function(e){var t,n,r,i,s;s=0,t=Object(this),i=t.length>>>0,n=arguments;if(n.length<=1)for(;;){if(s in t){r=t[s++];break}if(++s>=i)throw new TypeError}else r=n[1];for(;sthis._settings.backoffDelayMax&&(this._backoffDelay=this._settings.backoffDelayMax)},Mopidy.prototype._resetBackoffDelay=function(){this._backoffDelay=this._settings.backoffDelayMin},Mopidy.prototype._handleWebSocketError=function(e){this._console.warn("WebSocket error:",e.stack||e)},Mopidy.prototype._send=function(e){var t=when.defer();switch(this._webSocket.readyState){case WebSocket.CONNECTING:t.resolver.reject({message:"WebSocket is still connecting"});break;case WebSocket.CLOSING:t.resolver.reject({message:"WebSocket is closing"});break;case WebSocket.CLOSED:t.resolver.reject({message:"WebSocket is closed"});break;default:e.jsonrpc="2.0",e.id=this._nextRequestId(),this._pendingRequests[e.id]=t.resolver,this._webSocket.send(JSON.stringify(e)),this.emit("websocket:outgoingMessage",e)}return t.promise},Mopidy.prototype._nextRequestId=function(){var e=-1;return function(){return e+=1,e}}(),Mopidy.prototype._handleMessage=function(e){try{var t=JSON.parse(e.data);t.hasOwnProperty("id")?this._handleResponse(t):t.hasOwnProperty("event")?this._handleEvent(t):this._console.warn("Unknown message type received. Message was: "+e.data)}catch(n){if(!(n instanceof SyntaxError))throw n;this._console.warn("WebSocket message parsing failed. Message was: "+e.data)}},Mopidy.prototype._handleResponse=function(e){if(!this._pendingRequests.hasOwnProperty(e.id)){this._console.warn("Unexpected response received. Message was:",e);return}var t=this._pendingRequests[e.id];delete this._pendingRequests[e.id],e.hasOwnProperty("result")?t.resolve(e.result):e.hasOwnProperty("error")?(t.reject(e.error),this._console.warn("Server returned error:",e.error)):(t.reject({message:"Response without 'result' or 'error' received",data:{response:e}}),this._console.warn("Response without 'result' or 'error' received. Message was:",e))},Mopidy.prototype._handleEvent=function(e){var t=e.event,n=e;delete n.event,this.emit("event:"+this._snakeToCamel(t),n)},Mopidy.prototype._getApiSpec=function(){this._send({method:"core.describe"}).then(this._createApi.bind(this),this._handleWebSocketError).then(null,this._handleWebSocketError)},Mopidy.prototype._createApi=function(e){var t=function(e){return function(){var t=Array.prototype.slice.call(arguments);return this._send({method:e,params:t})}.bind(this)}.bind(this),n=function(e){var t=e.split(".");return t.length>=1&&t[0]==="core"&&(t=t.slice(1)),t},r=function(e){var t=this;return e.forEach(function(e){e=this._snakeToCamel(e),t[e]=t[e]||{},t=t[e]}.bind(this)),t}.bind(this),i=function(i){var s=n(i),o=this._snakeToCamel(s.slice(-1)[0]),u=r(s.slice(0,-1));u[o]=t(i),u[o].description=e[i].description,u[o].params=e[i].params}.bind(this);Object.keys(e).forEach(i),this.emit("state:online")},Mopidy.prototype._snakeToCamel=function(e){return e.replace(/(_[a-z])/g,function(e){return e.toUpperCase().replace("_","")})}; \ No newline at end of file +function Mopidy(e){this._settings=this._configure(e||{}),this._console=this._getConsole(),this._backoffDelay=this._settings.backoffDelayMin,this._pendingRequests={},this._webSocket=null,bane.createEventEmitter(this),this._delegateEvents(),this._settings.autoConnect&&this._connect()}(typeof define=="function"&&define.amd&&function(e){define(e)}||typeof module=="object"&&function(e){module.exports=e()}||function(e){this.bane=e()})(function(){"use strict";function t(e,t,n){var r,i=n.length;if(i>0){for(r=0;r>>0,o=Math.max(0,Math.min(t,v)),a=[],u=v-o+1,l=[],c=f();if(!o)c.resolve(a);else{d=c.progress,p=function(e){l.push(e),--u||(h=p=w,c.reject(l))},h=function(e){a.push(e),--o||(h=p=w,c.resolve(a))};for(m=0;m>>0,n=[],l=f();if(!s)l.resolve(n);else{u=l.reject,o=function(i,o){r(i,t).then(function(e){n[o]=e,--s||l.resolve(n)},u)};for(a=0;a2;return r(e,function(e){return t.resolve(i?n:e)},t.reject,t.progress)}function y(e,t){var n,r=0;while(n=e[r++])n(t)}function b(e,t){var n,r=t.length;while(r>e){n=t[--r];if(n!=null&&typeof n!="function")throw new Error("arg "+r+" must be a function")}}function w(){}function E(e){return e}var e,t,n;return r.defer=f,r.resolve=i,r.reject=s,r.join=d,r.all=p,r.some=c,r.any=h,r.map=v,r.reduce=m,r.chain=g,r.isPromise=l,o.prototype={always:function(e,t){return this.then(e,e,t)},otherwise:function(e){return this.then(n,e)}},t=[].slice,e=[].reduce||function(e){var t,n,r,i,s;s=0,t=Object(this),i=t.length>>>0,n=arguments;if(n.length<=1)for(;;){if(s in t){r=t[s++];break}if(++s>=i)throw new TypeError}else r=n[1];for(;sthis._settings.backoffDelayMax&&(this._backoffDelay=this._settings.backoffDelayMax)},Mopidy.prototype._resetBackoffDelay=function(){this._backoffDelay=this._settings.backoffDelayMin},Mopidy.prototype._handleWebSocketError=function(e){this._console.warn("WebSocket error:",e.stack||e)},Mopidy.prototype._send=function(e){var t=when.defer();switch(this._webSocket.readyState){case WebSocket.CONNECTING:t.resolver.reject({message:"WebSocket is still connecting"});break;case WebSocket.CLOSING:t.resolver.reject({message:"WebSocket is closing"});break;case WebSocket.CLOSED:t.resolver.reject({message:"WebSocket is closed"});break;default:e.jsonrpc="2.0",e.id=this._nextRequestId(),this._pendingRequests[e.id]=t.resolver,this._webSocket.send(JSON.stringify(e)),this.emit("websocket:outgoingMessage",e)}return t.promise},Mopidy.prototype._nextRequestId=function(){var e=-1;return function(){return e+=1,e}}(),Mopidy.prototype._handleMessage=function(e){try{var t=JSON.parse(e.data);t.hasOwnProperty("id")?this._handleResponse(t):t.hasOwnProperty("event")?this._handleEvent(t):this._console.warn("Unknown message type received. Message was: "+e.data)}catch(n){if(!(n instanceof SyntaxError))throw n;this._console.warn("WebSocket message parsing failed. Message was: "+e.data)}},Mopidy.prototype._handleResponse=function(e){if(!this._pendingRequests.hasOwnProperty(e.id)){this._console.warn("Unexpected response received. Message was:",e);return}var t=this._pendingRequests[e.id];delete this._pendingRequests[e.id],e.hasOwnProperty("result")?t.resolve(e.result):e.hasOwnProperty("error")?(t.reject(e.error),this._console.warn("Server returned error:",e.error)):(t.reject({message:"Response without 'result' or 'error' received",data:{response:e}}),this._console.warn("Response without 'result' or 'error' received. Message was:",e))},Mopidy.prototype._handleEvent=function(e){var t=e.event,n=e;delete n.event,this.emit("event:"+this._snakeToCamel(t),n)},Mopidy.prototype._getApiSpec=function(){this._send({method:"core.describe"}).then(this._createApi.bind(this),this._handleWebSocketError).then(null,this._handleWebSocketError)},Mopidy.prototype._createApi=function(e){var t=function(e){return function(){var t=Array.prototype.slice.call(arguments);return this._send({method:e,params:t})}.bind(this)}.bind(this),n=function(e){var t=e.split(".");return t.length>=1&&t[0]==="core"&&(t=t.slice(1)),t},r=function(e){var t=this;return e.forEach(function(e){e=this._snakeToCamel(e),t[e]=t[e]||{},t=t[e]}.bind(this)),t}.bind(this),i=function(i){var s=n(i),o=this._snakeToCamel(s.slice(-1)[0]),u=r(s.slice(0,-1));u[o]=t(i),u[o].description=e[i].description,u[o].params=e[i].params}.bind(this);Object.keys(e).forEach(i),this.emit("state:online")},Mopidy.prototype._snakeToCamel=function(e){return e.replace(/(_[a-z])/g,function(e){return e.toUpperCase().replace("_","")})}; \ No newline at end of file From 43316babcd35d7ef08892dd552052c8ef6514325 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 4 Dec 2012 09:05:52 +0100 Subject: [PATCH 28/30] js: Remove trailing whitespace --- js/test/mopidy-test.js | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/js/test/mopidy-test.js b/js/test/mopidy-test.js index 8525e09b..c134c0e1 100644 --- a/js/test/mopidy-test.js +++ b/js/test/mopidy-test.js @@ -156,7 +156,7 @@ buster.testCase("Mopidy", { "is called when the state changes to offline": function () { var stub = this.stub(this.mopidy, "_reconnect"); this.mopidy._delegateEvents(); - + this.mopidy.emit("state:offline"); assert.calledOnceWith(stub); @@ -334,13 +334,13 @@ buster.testCase("Mopidy", { this.mopidy._webSocket.readyState = WebSocket.CONNECTING; var promise = this.mopidy._send({method: "foo"}); - + refute.called(this.mopidy._webSocket.send); promise.then(done(function () { assert(false); }), done(function (error) { assert.equals( - error.message, "WebSocket is still connecting"); + error.message, "WebSocket is still connecting"); })); }, @@ -348,13 +348,13 @@ buster.testCase("Mopidy", { this.mopidy._webSocket.readyState = WebSocket.CLOSING; var promise = this.mopidy._send({method: "foo"}); - + refute.called(this.mopidy._webSocket.send); promise.then(done(function () { assert(false); }), done(function (error) { assert.equals( - error.message, "WebSocket is closing"); + error.message, "WebSocket is closing"); })); }, @@ -362,13 +362,13 @@ buster.testCase("Mopidy", { this.mopidy._webSocket.readyState = WebSocket.CLOSED; var promise = this.mopidy._send({method: "foo"}); - + refute.called(this.mopidy._webSocket.send); promise.then(done(function () { assert(false); }), done(function (error) { assert.equals( - error.message, "WebSocket is closed"); + error.message, "WebSocket is closed"); })); } }, @@ -401,7 +401,7 @@ buster.testCase("Mopidy", { result: null }; var messageEvent = {data: JSON.stringify(message)}; - + this.mopidy._handleMessage(messageEvent); assert.calledOnceWith(stub, message); @@ -414,7 +414,7 @@ buster.testCase("Mopidy", { track: {} }; var messageEvent = {data: JSON.stringify(message)}; - + this.mopidy._handleMessage(messageEvent); assert.calledOnceWith(stub, message); From 8d3fd468086e64c73467e950fb8c55b42e6d3652 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 4 Dec 2012 09:18:34 +0100 Subject: [PATCH 29/30] js: Add close() method to close WebSocket without reconnecting --- js/src/mopidy.js | 5 +++++ js/test/mopidy-test.js | 17 +++++++++++++++++ mopidy/frontends/http/data/mopidy.js | 5 +++++ mopidy/frontends/http/data/mopidy.min.js | 2 +- 4 files changed, 28 insertions(+), 1 deletion(-) diff --git a/js/src/mopidy.js b/js/src/mopidy.js index 26ac9197..66c17b79 100644 --- a/js/src/mopidy.js +++ b/js/src/mopidy.js @@ -119,6 +119,11 @@ Mopidy.prototype._resetBackoffDelay = function () { this._backoffDelay = this._settings.backoffDelayMin; }; +Mopidy.prototype.close = function () { + this.off("state:offline", this._reconnect); + this._webSocket.close(); +}; + Mopidy.prototype._handleWebSocketError = function (error) { this._console.warn("WebSocket error:", error.stack || error); }; diff --git a/js/test/mopidy-test.js b/js/test/mopidy-test.js index c134c0e1..fd1f73c6 100644 --- a/js/test/mopidy-test.js +++ b/js/test/mopidy-test.js @@ -254,6 +254,23 @@ buster.testCase("Mopidy", { } }, + "close": { + "unregisters reconnection hooks": function () { + this.stub(this.mopidy, "off"); + + this.mopidy.close(); + + assert.calledOnceWith( + this.mopidy.off, "state:offline", this.mopidy._reconnect); + }, + + "closes the WebSocket": function () { + this.mopidy.close(); + + assert.calledOnceWith(this.mopidy._webSocket.close); + } + }, + "._handleWebSocketError": { "is called on 'websocket:error' event": function () { var error = {}; diff --git a/mopidy/frontends/http/data/mopidy.js b/mopidy/frontends/http/data/mopidy.js index 55fec91c..6249ef7f 100644 --- a/mopidy/frontends/http/data/mopidy.js +++ b/mopidy/frontends/http/data/mopidy.js @@ -1028,6 +1028,11 @@ Mopidy.prototype._resetBackoffDelay = function () { this._backoffDelay = this._settings.backoffDelayMin; }; +Mopidy.prototype.close = function () { + this.off("state:offline", this._reconnect); + this._webSocket.close(); +}; + Mopidy.prototype._handleWebSocketError = function (error) { this._console.warn("WebSocket error:", error.stack || error); }; diff --git a/mopidy/frontends/http/data/mopidy.min.js b/mopidy/frontends/http/data/mopidy.min.js index 9c7b9e7c..42d34319 100644 --- a/mopidy/frontends/http/data/mopidy.min.js +++ b/mopidy/frontends/http/data/mopidy.min.js @@ -2,4 +2,4 @@ * http://www.mopidy.com/ * Copyright (c) 2012 Stein Magnus Jodal and contributors * Licensed under the Apache License, Version 2.0 */ -function Mopidy(e){this._settings=this._configure(e||{}),this._console=this._getConsole(),this._backoffDelay=this._settings.backoffDelayMin,this._pendingRequests={},this._webSocket=null,bane.createEventEmitter(this),this._delegateEvents(),this._settings.autoConnect&&this._connect()}(typeof define=="function"&&define.amd&&function(e){define(e)}||typeof module=="object"&&function(e){module.exports=e()}||function(e){this.bane=e()})(function(){"use strict";function t(e,t,n){var r,i=n.length;if(i>0){for(r=0;r>>0,o=Math.max(0,Math.min(t,v)),a=[],u=v-o+1,l=[],c=f();if(!o)c.resolve(a);else{d=c.progress,p=function(e){l.push(e),--u||(h=p=w,c.reject(l))},h=function(e){a.push(e),--o||(h=p=w,c.resolve(a))};for(m=0;m>>0,n=[],l=f();if(!s)l.resolve(n);else{u=l.reject,o=function(i,o){r(i,t).then(function(e){n[o]=e,--s||l.resolve(n)},u)};for(a=0;a2;return r(e,function(e){return t.resolve(i?n:e)},t.reject,t.progress)}function y(e,t){var n,r=0;while(n=e[r++])n(t)}function b(e,t){var n,r=t.length;while(r>e){n=t[--r];if(n!=null&&typeof n!="function")throw new Error("arg "+r+" must be a function")}}function w(){}function E(e){return e}var e,t,n;return r.defer=f,r.resolve=i,r.reject=s,r.join=d,r.all=p,r.some=c,r.any=h,r.map=v,r.reduce=m,r.chain=g,r.isPromise=l,o.prototype={always:function(e,t){return this.then(e,e,t)},otherwise:function(e){return this.then(n,e)}},t=[].slice,e=[].reduce||function(e){var t,n,r,i,s;s=0,t=Object(this),i=t.length>>>0,n=arguments;if(n.length<=1)for(;;){if(s in t){r=t[s++];break}if(++s>=i)throw new TypeError}else r=n[1];for(;sthis._settings.backoffDelayMax&&(this._backoffDelay=this._settings.backoffDelayMax)},Mopidy.prototype._resetBackoffDelay=function(){this._backoffDelay=this._settings.backoffDelayMin},Mopidy.prototype._handleWebSocketError=function(e){this._console.warn("WebSocket error:",e.stack||e)},Mopidy.prototype._send=function(e){var t=when.defer();switch(this._webSocket.readyState){case WebSocket.CONNECTING:t.resolver.reject({message:"WebSocket is still connecting"});break;case WebSocket.CLOSING:t.resolver.reject({message:"WebSocket is closing"});break;case WebSocket.CLOSED:t.resolver.reject({message:"WebSocket is closed"});break;default:e.jsonrpc="2.0",e.id=this._nextRequestId(),this._pendingRequests[e.id]=t.resolver,this._webSocket.send(JSON.stringify(e)),this.emit("websocket:outgoingMessage",e)}return t.promise},Mopidy.prototype._nextRequestId=function(){var e=-1;return function(){return e+=1,e}}(),Mopidy.prototype._handleMessage=function(e){try{var t=JSON.parse(e.data);t.hasOwnProperty("id")?this._handleResponse(t):t.hasOwnProperty("event")?this._handleEvent(t):this._console.warn("Unknown message type received. Message was: "+e.data)}catch(n){if(!(n instanceof SyntaxError))throw n;this._console.warn("WebSocket message parsing failed. Message was: "+e.data)}},Mopidy.prototype._handleResponse=function(e){if(!this._pendingRequests.hasOwnProperty(e.id)){this._console.warn("Unexpected response received. Message was:",e);return}var t=this._pendingRequests[e.id];delete this._pendingRequests[e.id],e.hasOwnProperty("result")?t.resolve(e.result):e.hasOwnProperty("error")?(t.reject(e.error),this._console.warn("Server returned error:",e.error)):(t.reject({message:"Response without 'result' or 'error' received",data:{response:e}}),this._console.warn("Response without 'result' or 'error' received. Message was:",e))},Mopidy.prototype._handleEvent=function(e){var t=e.event,n=e;delete n.event,this.emit("event:"+this._snakeToCamel(t),n)},Mopidy.prototype._getApiSpec=function(){this._send({method:"core.describe"}).then(this._createApi.bind(this),this._handleWebSocketError).then(null,this._handleWebSocketError)},Mopidy.prototype._createApi=function(e){var t=function(e){return function(){var t=Array.prototype.slice.call(arguments);return this._send({method:e,params:t})}.bind(this)}.bind(this),n=function(e){var t=e.split(".");return t.length>=1&&t[0]==="core"&&(t=t.slice(1)),t},r=function(e){var t=this;return e.forEach(function(e){e=this._snakeToCamel(e),t[e]=t[e]||{},t=t[e]}.bind(this)),t}.bind(this),i=function(i){var s=n(i),o=this._snakeToCamel(s.slice(-1)[0]),u=r(s.slice(0,-1));u[o]=t(i),u[o].description=e[i].description,u[o].params=e[i].params}.bind(this);Object.keys(e).forEach(i),this.emit("state:online")},Mopidy.prototype._snakeToCamel=function(e){return e.replace(/(_[a-z])/g,function(e){return e.toUpperCase().replace("_","")})}; \ No newline at end of file +function Mopidy(e){this._settings=this._configure(e||{}),this._console=this._getConsole(),this._backoffDelay=this._settings.backoffDelayMin,this._pendingRequests={},this._webSocket=null,bane.createEventEmitter(this),this._delegateEvents(),this._settings.autoConnect&&this._connect()}(typeof define=="function"&&define.amd&&function(e){define(e)}||typeof module=="object"&&function(e){module.exports=e()}||function(e){this.bane=e()})(function(){"use strict";function t(e,t,n){var r,i=n.length;if(i>0){for(r=0;r>>0,o=Math.max(0,Math.min(t,v)),a=[],u=v-o+1,l=[],c=f();if(!o)c.resolve(a);else{d=c.progress,p=function(e){l.push(e),--u||(h=p=w,c.reject(l))},h=function(e){a.push(e),--o||(h=p=w,c.resolve(a))};for(m=0;m>>0,n=[],l=f();if(!s)l.resolve(n);else{u=l.reject,o=function(i,o){r(i,t).then(function(e){n[o]=e,--s||l.resolve(n)},u)};for(a=0;a2;return r(e,function(e){return t.resolve(i?n:e)},t.reject,t.progress)}function y(e,t){var n,r=0;while(n=e[r++])n(t)}function b(e,t){var n,r=t.length;while(r>e){n=t[--r];if(n!=null&&typeof n!="function")throw new Error("arg "+r+" must be a function")}}function w(){}function E(e){return e}var e,t,n;return r.defer=f,r.resolve=i,r.reject=s,r.join=d,r.all=p,r.some=c,r.any=h,r.map=v,r.reduce=m,r.chain=g,r.isPromise=l,o.prototype={always:function(e,t){return this.then(e,e,t)},otherwise:function(e){return this.then(n,e)}},t=[].slice,e=[].reduce||function(e){var t,n,r,i,s;s=0,t=Object(this),i=t.length>>>0,n=arguments;if(n.length<=1)for(;;){if(s in t){r=t[s++];break}if(++s>=i)throw new TypeError}else r=n[1];for(;sthis._settings.backoffDelayMax&&(this._backoffDelay=this._settings.backoffDelayMax)},Mopidy.prototype._resetBackoffDelay=function(){this._backoffDelay=this._settings.backoffDelayMin},Mopidy.prototype.close=function(){this.off("state:offline",this._reconnect),this._webSocket.close()},Mopidy.prototype._handleWebSocketError=function(e){this._console.warn("WebSocket error:",e.stack||e)},Mopidy.prototype._send=function(e){var t=when.defer();switch(this._webSocket.readyState){case WebSocket.CONNECTING:t.resolver.reject({message:"WebSocket is still connecting"});break;case WebSocket.CLOSING:t.resolver.reject({message:"WebSocket is closing"});break;case WebSocket.CLOSED:t.resolver.reject({message:"WebSocket is closed"});break;default:e.jsonrpc="2.0",e.id=this._nextRequestId(),this._pendingRequests[e.id]=t.resolver,this._webSocket.send(JSON.stringify(e)),this.emit("websocket:outgoingMessage",e)}return t.promise},Mopidy.prototype._nextRequestId=function(){var e=-1;return function(){return e+=1,e}}(),Mopidy.prototype._handleMessage=function(e){try{var t=JSON.parse(e.data);t.hasOwnProperty("id")?this._handleResponse(t):t.hasOwnProperty("event")?this._handleEvent(t):this._console.warn("Unknown message type received. Message was: "+e.data)}catch(n){if(!(n instanceof SyntaxError))throw n;this._console.warn("WebSocket message parsing failed. Message was: "+e.data)}},Mopidy.prototype._handleResponse=function(e){if(!this._pendingRequests.hasOwnProperty(e.id)){this._console.warn("Unexpected response received. Message was:",e);return}var t=this._pendingRequests[e.id];delete this._pendingRequests[e.id],e.hasOwnProperty("result")?t.resolve(e.result):e.hasOwnProperty("error")?(t.reject(e.error),this._console.warn("Server returned error:",e.error)):(t.reject({message:"Response without 'result' or 'error' received",data:{response:e}}),this._console.warn("Response without 'result' or 'error' received. Message was:",e))},Mopidy.prototype._handleEvent=function(e){var t=e.event,n=e;delete n.event,this.emit("event:"+this._snakeToCamel(t),n)},Mopidy.prototype._getApiSpec=function(){this._send({method:"core.describe"}).then(this._createApi.bind(this),this._handleWebSocketError).then(null,this._handleWebSocketError)},Mopidy.prototype._createApi=function(e){var t=function(e){return function(){var t=Array.prototype.slice.call(arguments);return this._send({method:e,params:t})}.bind(this)}.bind(this),n=function(e){var t=e.split(".");return t.length>=1&&t[0]==="core"&&(t=t.slice(1)),t},r=function(e){var t=this;return e.forEach(function(e){e=this._snakeToCamel(e),t[e]=t[e]||{},t=t[e]}.bind(this)),t}.bind(this),i=function(i){var s=n(i),o=this._snakeToCamel(s.slice(-1)[0]),u=r(s.slice(0,-1));u[o]=t(i),u[o].description=e[i].description,u[o].params=e[i].params}.bind(this);Object.keys(e).forEach(i),this.emit("state:online")},Mopidy.prototype._snakeToCamel=function(e){return e.replace(/(_[a-z])/g,function(e){return e.toUpperCase().replace("_","")})}; \ No newline at end of file From 68a6f2f8aaeaf680e5731514dd231c4b4183a382 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 4 Dec 2012 09:23:32 +0100 Subject: [PATCH 30/30] js: Doc how to clean up after Mopidy.js --- mopidy/frontends/http/__init__.py | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/mopidy/frontends/http/__init__.py b/mopidy/frontends/http/__init__.py index 7697b60e..d81d4791 100644 --- a/mopidy/frontends/http/__init__.py +++ b/mopidy/frontends/http/__init__.py @@ -305,6 +305,28 @@ refer to when.js' documentation or the standard for further details on how to work with promise objects. +Cleaning up +----------- + +If you for some reason want to clean up after Mopidy.js before the web page is +closed or navigated away from, you can close the WebSocket, unregister all +event listeners, and delete the object like this: + +.. code-block:: js + + // Close the WebSocket without reconnecting. Letting the object be garbage + // collected will have the same effect, so this isn't striclty necessary. + mopidy.close(); + + // Unregister all event listeners. If you don't do this, you may have + // lingering references to the object causing the garbage collector to not + // clean up after it. + mopidy.off(); + + // Delete your reference to the object, so it can be garbage collected. + mopidy = null; + + Example to get started with ---------------------------