diff --git a/docs/api/js.rst b/docs/api/js.rst index 3044e4ec..e314360d 100644 --- a/docs/api/js.rst +++ b/docs/api/js.rst @@ -74,7 +74,7 @@ development setup in the ``js/`` dir in our repo. The instructions in Creating an instance ==================== -Once you got Mopidy.js loaded, you need to create an instance of the wrapper: +Once you have Mopidy.js loaded, you need to create an instance of the wrapper: .. code-block:: js @@ -100,6 +100,31 @@ later: // ... do other stuff, like hooking up events ... mopidy.connect(); +When creating an instance, you can specify the following settings: + +``autoConnect`` + Whether or not to connect to the WebSocket on instance creation. Defaults + to true. + +``backoffDelayMin`` + The minimum number of milliseconds to wait after a connection error before + we try to reconnect. For every failed attempt, the backoff delay is doubled + until it reaches ``backoffDelayMax``. Defaults to 1000. + +``backoffDelayMax`` + The maximum number of milliseconds to wait after a connection error before + we try to reconnect. Defaults to 64000. + +``webSocket`` + An existing WebSocket object to be used instead of creating a new + WebSocket. Defaults to undefined. + +``webSocketUrl`` + URL used when creating new WebSocket objects. Defaults to + ``ws:///mopidy/ws``, or + ``ws://localhost/mopidy/ws`` if ``document.location.host`` isn't + available, like it is in the browser environment. + Hooking up to events ==================== @@ -145,7 +170,8 @@ Once your Mopidy.js object has connected to the Mopidy server and emits the 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. +from the call, the errback will be called with a ``Mopidy.ConnectionError`` +instance. 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 @@ -204,22 +230,34 @@ Instead, typical usage will look like this: } }; - mopidy.playback.getCurrentTrack().then( - printCurrentTrack, console.error.bind(console)); + mopidy.playback.getCurrentTrack() + .done(printCurrentTrack); -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. +The function passed to ``done()``, ``printCurrentTrack``, is the callback +that will be called if the method call succeeds. If anything goes wrong, +``done()`` will throw an exception. -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: +If you want to explicitly handle any errors and avoid an exception being +thrown, you can register an error handler function anywhere in a promise +chain. The function will be called with the error object as the only argument: .. code-block:: js - mopidy.playback.next().then(null, console.error.bind(console)); + mopidy.playback.getCurrentTrack() + .catch(console.error.bind(console)); + .done(printCurrentTrack); + +You can also register the error handler at the end of the promise chain by +passing it as the second argument to ``done()``: + +.. code-block:: js + + mopidy.playback.getCurrentTrack() + .done(printCurrentTrack, console.error.bind(console)); + +If you don't hook up an error handler function and never call ``done()`` on the +promise object, when.js will log warnings to the console that you have +unhandled errors. In general, unhandled errors will not go silently missing. The promise objects returned by Mopidy.js adheres to the `CommonJS Promises/A `_ standard. We use the @@ -283,44 +321,38 @@ Example to get started with .. code-block:: js - var consoleError = console.error.bind(console); - 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]; + var queueAndPlay = function (playlistNum, trackNum) { + playlistNum = playlistNum || 0; + trackNum = trackNum || 0; + mopidy.playlists.getPlaylists().done(function (playlists) { + var playlist = playlists[playlistNum]; 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) { + mopidy.tracklist.add(playlist.tracks).done(function (tlTracks) { + mopidy.playback.play(tlTracks[trackNum]).done(function () { + mopidy.playback.getCurrentTrack().done(function (track) { console.log("Now playing:", trackDesc(track)); - }, consoleError); - }, consoleError); - }, consoleError); - }, consoleError); + }); + }); + }); + }); }; var mopidy = new Mopidy(); // Connect to server mopidy.on(console.log.bind(console)); // Log all events - mopidy.on("state:online", queueAndPlayFirstPlaylist); + mopidy.on("state:online", queueAndPlay); Approximately the same behavior in a more functional style, using chaining - of promisies. + of promises. .. code-block:: js - var consoleError = console.error.bind(console); - - var getFirst = function (list) { - return list[0]; - }; - - var extractTracks = function (playlist) { - return playlist.tracks; + var get = function (key, object) { + return object[key]; }; var printTypeAndName = function (model) { @@ -339,33 +371,36 @@ Example to get started with // 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; - }); + return mopidy.playback.getCurrentTrack() + .done(function (track) { + console.log("Now playing:", trackDesc(track)); + return args; + }); }; - var queueAndPlayFirstPlaylist = function () { + var queueAndPlay = function (playlistNum, trackNum) { + playlistNum = playlistNum || 0; + trackNum = trackNum || 0; mopidy.playlists.getPlaylists() // => list of Playlists - .then(getFirst, consoleError) + .fold(get, playlistNum) // => Playlist - .then(printTypeAndName, consoleError) + .then(printTypeAndName) // => Playlist - .then(extractTracks, consoleError) + .fold(get, 'tracks') // => list of Tracks - .then(mopidy.tracklist.add, consoleError) + .then(mopidy.tracklist.add) // => list of TlTracks - .then(getFirst, consoleError) + .fold(get, trackNum) // => TlTrack - .then(mopidy.playback.play, consoleError) + .then(mopidy.playback.play) // => null - .then(printNowPlaying, consoleError); + .done(printNowPlaying, console.error.bind(console)); }; var mopidy = new Mopidy(); // Connect to server mopidy.on(console.log.bind(console)); // Log all events - mopidy.on("state:online", queueAndPlayFirstPlaylist); + mopidy.on("state:online", queueAndPlay); 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, diff --git a/docs/changelog.rst b/docs/changelog.rst index b10575ed..dd57d8ef 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -78,6 +78,20 @@ Feature release. Mopidy's HTTP server among other Zeroconf-published HTTP servers on the local network. +- Update Mopidy.js to use when.js 3. If you maintain a Mopidy client, you + should review the `differences between when.js 2 and 3 + `_ + and the `when.js debugging guide + `_. + This version has been released to npm as Mopidy.js v0.3.0. + +- All of Mopidy.js' promise rejection values are now of the Error type. This + ensures that all JavaScript VMs will show a useful stack trace if a rejected + promise's value is used to throw an exception. To allow catch clauses to + handle different errors differently, server side errors are of the type + ``Mopidy.ServerError``, and connection related errors are of the type + ``Mopidy.ConnectionError``. + **MPD frontend** - Proper command tokenization for MPD requests. This replaces the old regex diff --git a/js/Gruntfile.js b/js/Gruntfile.js index 812ecec4..437ad9d0 100644 --- a/js/Gruntfile.js +++ b/js/Gruntfile.js @@ -26,7 +26,7 @@ module.exports = function (grunt) { }, options: { postBundleCB: function (err, src, next) { - next(null, grunt.template.process("<%= meta.banner %>") + src); + next(err, grunt.template.process("<%= meta.banner %>") + src); }, standalone: "Mopidy" } @@ -45,7 +45,7 @@ module.exports = function (grunt) { }, options: { postBundleCB: function (err, src, next) { - next(null, grunt.template.process("<%= meta.banner %>") + src); + next(err, grunt.template.process("<%= meta.banner %>") + src); }, standalone: "Mopidy" } diff --git a/js/README.md b/js/README.md index 54bdc502..966e9b2e 100644 --- a/js/README.md +++ b/js/README.md @@ -47,13 +47,9 @@ See the [Mopidy.js documentation](http://docs.mopidy.com/en/latest/api/js/). Building from source -------------------- -1. Install [Node.js](http://nodejs.org/) and npm. There is a PPA if you're - running Ubuntu: +1. Install [Node.js](http://nodejs.org/) and npm. 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 + sudo apt-get install nodejs-legacy npm 2. Enter the `js/` in Mopidy's Git repo dir and install all dependencies: @@ -84,6 +80,20 @@ To run other [grunt](http://gruntjs.com/) targets which isn't predefined in Changelog --------- +### 0.3.0 (UNRELEASED) + +- Upgrade to when.js 3, which brings great performance improvements and better + debugging facilities. If you maintain a Mopidy client, you should review the + [differences between when.js 2 and 3](https://github.com/cujojs/when/blob/master/docs/api.md#upgrading-to-30-from-2x) + and the + [when.js debugging guide](https://github.com/cujojs/when/blob/master/docs/api.md#debugging-promises). + +- All promise rejection values are now of the Error type. This ensures that all + JavaScript VMs will show a useful stack trace if a rejected promise's value + is used to throw an exception. To allow catch clauses to handle different + errors differently, server side errors are of the type `Mopidy.ServerError`, + and connection related errors are of the type `Mopidy.ConnectionError`. + ### 0.2.0 (2014-01-04) - **Backwards incompatible change for Node.js users:** diff --git a/js/package.json b/js/package.json index d16cfaa9..4082afb9 100644 --- a/js/package.json +++ b/js/package.json @@ -1,6 +1,6 @@ { "name": "mopidy", - "version": "0.2.0", + "version": "0.3.0", "description": "Client lib for controlling a Mopidy music server over a WebSocket", "homepage": "http://www.mopidy.com/", "author": { @@ -16,17 +16,18 @@ "dependencies": { "bane": "~1.1.0", "faye-websocket": "~0.7.2", - "when": "~2.7.1" + "when": "~3.2.3" }, "devDependencies": { - "buster": "~0.7.8", - "grunt": "~0.4.2", + "buster": "~0.7.13", + "browserify": "~3", + "grunt": "~0.4.5", "grunt-buster": "~0.3.1", - "grunt-browserify": "~1.3.0", - "grunt-contrib-jshint": "~0.8.0", - "grunt-contrib-uglify": "~0.2.7", - "grunt-contrib-watch": "~0.5.3", - "phantomjs": "~1.9.2-6" + "grunt-browserify": "~1.3.2", + "grunt-contrib-jshint": "~0.10.0", + "grunt-contrib-uglify": "~0.5.0", + "grunt-contrib-watch": "~0.6.1", + "phantomjs": "~1.9.7-8" }, "scripts": { "test": "grunt test", diff --git a/js/src/mopidy.js b/js/src/mopidy.js index 1667f9b1..8586d231 100644 --- a/js/src/mopidy.js +++ b/js/src/mopidy.js @@ -24,13 +24,27 @@ function Mopidy(settings) { } } +Mopidy.ConnectionError = function (message) { + this.name = "ConnectionError"; + this.message = message; +}; +Mopidy.ConnectionError.prototype = new Error(); +Mopidy.ConnectionError.prototype.constructor = Mopidy.ConnectionError; + +Mopidy.ServerError = function (message) { + this.name = "ServerError"; + this.message = message; +}; +Mopidy.ServerError.prototype = new Error(); +Mopidy.ServerError.prototype.constructor = Mopidy.ServerError; + Mopidy.WebSocket = websocket.Client; Mopidy.prototype._configure = function (settings) { var currentHost = (typeof document !== "undefined" && document.location.host) || "localhost"; settings.webSocketUrl = settings.webSocketUrl || - "ws://" + currentHost + "/mopidy/ws/"; + "ws://" + currentHost + "/mopidy/ws"; if (settings.autoConnect !== false) { settings.autoConnect = true; @@ -102,10 +116,9 @@ 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 - }); + var error = new Mopidy.ConnectionError("WebSocket closed"); + error.closeEvent = closeEvent; + resolver.reject(error); }.bind(this)); this.emit("state:offline"); @@ -141,33 +154,25 @@ Mopidy.prototype._handleWebSocketError = function (error) { }; Mopidy.prototype._send = function (message) { - var deferred = when.defer(); - switch (this._webSocket.readyState) { case Mopidy.WebSocket.CONNECTING: - deferred.resolver.reject({ - message: "WebSocket is still connecting" - }); - break; + return when.reject( + new Mopidy.ConnectionError("WebSocket is still connecting")); case Mopidy.WebSocket.CLOSING: - deferred.resolver.reject({ - message: "WebSocket is closing" - }); - break; + return when.reject( + new Mopidy.ConnectionError("WebSocket is closing")); case Mopidy.WebSocket.CLOSED: - deferred.resolver.reject({ - message: "WebSocket is closed" - }); - break; + return when.reject( + new Mopidy.ConnectionError("WebSocket is closed")); default: + var deferred = when.defer(); 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; } - - return deferred.promise; }; Mopidy.prototype._nextRequestId = (function () { @@ -208,19 +213,22 @@ Mopidy.prototype._handleResponse = function (responseMessage) { return; } + var error; 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); + error = new Mopidy.ServerError(responseMessage.error.message); + error.code = responseMessage.error.code; + error.data = responseMessage.error.data; + resolver.reject(error); this._console.warn("Server returned error:", responseMessage.error); } else { - resolver.reject({ - message: "Response without 'result' or 'error' received", - data: {response: responseMessage} - }); + error = new Error("Response without 'result' or 'error' received"); + error.data = {response: responseMessage}; + resolver.reject(error); 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 9f2509fc..4485bc32 100644 --- a/js/test/mopidy-test.js +++ b/js/test/mopidy-test.js @@ -48,7 +48,7 @@ buster.testCase("Mopidy", { document.location.host || "localhost"; assert.calledOnceWith(this.webSocketConstructorStub, - "ws://" + currentHost + "/mopidy/ws/"); + "ws://" + currentHost + "/mopidy/ws"); }, "does not connect when autoConnect is false": function () { @@ -84,7 +84,7 @@ buster.testCase("Mopidy", { document.location.host || "localhost"; assert.calledOnceWith(this.webSocketConstructorStub, - "ws://" + currentHost + "/mopidy/ws/"); + "ws://" + currentHost + "/mopidy/ws"); }, "does nothing when the WebSocket is open": function () { @@ -169,12 +169,18 @@ buster.testCase("Mopidy", { 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); - })); + when.settle([promise1, promise2]).done( + done(function (descriptors) { + assert.equals(descriptors.length, 2); + descriptors.forEach(function (d) { + assert.equals(d.state, "rejected"); + assert(d.reason instanceof Error); + assert(d.reason instanceof Mopidy.ConnectionError); + assert.equals(d.reason.message, "WebSocket closed"); + assert.same(d.reason.closeEvent, closeEvent); + }); + }) + ); }, "emits 'state:offline' event when done": function () { @@ -388,12 +394,17 @@ buster.testCase("Mopidy", { 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"); - })); + promise.done( + done(function () { + assert(false); + }), + done(function (error) { + assert(error instanceof Error); + assert(error instanceof Mopidy.ConnectionError); + assert.equals( + error.message, "WebSocket is still connecting"); + }) + ); }, "immediately rejects request if CLOSING": function (done) { @@ -402,12 +413,16 @@ buster.testCase("Mopidy", { 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"); - })); + promise.done( + done(function () { + assert(false); + }), + done(function (error) { + assert(error instanceof Error); + assert(error instanceof Mopidy.ConnectionError); + assert.equals(error.message, "WebSocket is closing"); + }) + ); }, "immediately rejects request if CLOSED": function (done) { @@ -416,12 +431,16 @@ buster.testCase("Mopidy", { 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"); - })); + promise.done( + done(function () { + assert(false); + }), + done(function (error) { + assert(error instanceof Error); + assert(error instanceof Mopidy.ConnectionError); + assert.equals(error.message, "WebSocket is closed"); + }) + ); } }, @@ -544,7 +563,11 @@ buster.testCase("Mopidy", { "rejects and logs requests which get errors back": function (done) { var stub = this.stub(this.mopidy._console, "warn"); var promise = this.mopidy._send({method: "bar"}); - var responseError = {message: "Error", data: {}}; + var responseError = { + code: -32601, + message: "Method not found", + data: {} + }; var responseMessage = { jsonrpc: "2.0", id: Object.keys(this.mopidy._pendingRequests)[0], @@ -555,11 +578,49 @@ buster.testCase("Mopidy", { assert.calledOnceWith(stub, "Server returned error:", responseError); - promise.then(done(function () { - assert(false); - }), done(function (error) { - assert.equals(error, responseError); - })); + promise.done( + done(function () { + assert(false); + }), + done(function (error) { + assert(error instanceof Error); + assert.equals(error.code, responseError.code); + assert.equals(error.message, responseError.message); + assert.equals(error.data, responseError.data); + }) + ); + }, + + "rejects and logs requests which get errors without data": function (done) { + var stub = this.stub(this.mopidy._console, "warn"); + var promise = this.mopidy._send({method: "bar"}); + var responseError = { + code: -32601, + message: "Method not found" + // 'data' key intentionally missing + }; + 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.done( + done(function () { + assert(false); + }), + done(function (error) { + assert(error instanceof Error); + assert(error instanceof Mopidy.ServerError); + assert.equals(error.code, responseError.code); + assert.equals(error.message, responseError.message); + refute.defined(error.data); + }) + ); }, "rejects and logs responses without result or error": function (done) { @@ -575,14 +636,18 @@ buster.testCase("Mopidy", { 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); - })); + promise.done( + done(function () { + assert(false); + }), + done(function (error) { + assert(error instanceof Error); + assert.equals( + error.message, + "Response without 'result' or 'error' received"); + assert.equals(error.data.response, responseMessage); + }) + ); } },