├── .gitignore ├── .travis.yml ├── package.json ├── examples └── simple-connect.js ├── tests └── tests.js ├── README.md └── src ├── nrepl-server.js └── nrepl-client.js /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | npm-debug.log -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | 3 | node_js: 4 | - lts/* 5 | 6 | before_script: 7 | - wget https://raw.githubusercontent.com/technomancy/leiningen/stable/bin/lein 8 | - chmod a+x lein 9 | - ./lein 10 | - export PATH=$PWD:$PATH 11 | - lein --version 12 | 13 | script: "npm test" 14 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nrepl-client", 3 | "version": "0.3.0", 4 | "description": "node client to interact with a Clojure nREPL server.", 5 | "main": "src/nrepl-client.js", 6 | "scripts": { 7 | "test": "node_modules/nodeunit/bin/nodeunit tests/tests.js --reporter minimal" 8 | }, 9 | "keywords": [ 10 | "Clojure", 11 | "ClojureScript", 12 | "nREPL", 13 | "live programming" 14 | ], 15 | "author": "Robert Krahn", 16 | "contributors": [ 17 | "Stuart Mitchell " 18 | ], 19 | "license": "MIT", 20 | "dependencies": { 21 | "bencode": "^2.0.1", 22 | "tree-kill": "~1.2.1" 23 | }, 24 | "devDependencies": { 25 | "async": "~0.9", 26 | "nodeunit": "~0.8" 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /examples/simple-connect.js: -------------------------------------------------------------------------------- 1 | /*global console,require*/ 2 | 3 | /* 4 | * This is an example of how to use nrepl-client. Running it with the command 5 | * below will also start an nrepl server via leiningen. This is not necessary if 6 | * you want to connect to an existing Clojure process. To see the details about 7 | * the server open src/nrepl-server.js 8 | * 9 | * Usage like 10 | * node -e "require('../src/nrepl-server').start({port: 7889}, function() { require('./simple-connect'); });" 11 | */ 12 | 13 | var nreplClient = require('../src/nrepl-client'); 14 | var util = require("util"); 15 | var port = 7889; 16 | var con = nreplClient.connect({port: port}); 17 | 18 | con.on("error", function(err) { 19 | console.error("error in nREPL client connection: ", err); 20 | }); 21 | 22 | function cljEval(next) { 23 | var expr = '(+ 3 4)'; 24 | con.eval(expr, function(err, result) { 25 | var value = result.reduce(function(result, msg) { 26 | return msg.value ? result + msg.value : result; }, ""); 27 | console.log('%s => %s', expr, value); 28 | next(); 29 | }); 30 | } 31 | 32 | con.once('connect', function() { 33 | console.log('Connected! Going to evaluate expression...'); 34 | cljEval(function() { 35 | console.log('... evaluating expression done!'); 36 | con.end(); 37 | }); 38 | }); 39 | -------------------------------------------------------------------------------- /tests/tests.js: -------------------------------------------------------------------------------- 1 | /*global console,require,module,setTimeout,clearTimeout*/ 2 | 3 | var nreplClient = require('../src/nrepl-client'); 4 | var nreplServer = require('../src/nrepl-server'); 5 | var async = require("async"); 6 | 7 | var exec = require("child_process").exec; 8 | 9 | var serverOpts = {port: 7889, verbose: true, startTimeout: 20*1000}, 10 | timeoutDelay = 10*1000, 11 | timeoutProc, client, server; 12 | 13 | function createTimeout(test) { 14 | return timeoutProc = setTimeout(function() { 15 | test.ok(false, 'timeout'); 16 | test.done(); 17 | }, timeoutDelay); 18 | } 19 | 20 | // -=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=- 21 | 22 | var tests = { 23 | 24 | setUp: function (callback) { 25 | async.waterfall([ 26 | function(next) { nreplServer.start(serverOpts, next); }, 27 | function(serverState, next) { 28 | server = serverState; 29 | client = nreplClient.connect({ 30 | port: serverState.port, 31 | verbose: true 32 | }); 33 | console.log("client connecting"); 34 | client.once('connect', function() { 35 | console.log("client connected"); 36 | next(); 37 | }); 38 | } 39 | ], callback); 40 | }, 41 | 42 | tearDown: function (callback) { 43 | // exec("bash -c 'ps aux | grep \":port 7889\" | grep -v grep | awk \"{ print $2 }\" | xargs kill -9'"); 44 | if (!client) { 45 | console.error("client undefined in tearDown?!"); 46 | callback(); return; 47 | } 48 | client.once('close', function() { 49 | clearTimeout(timeoutProc); 50 | nreplServer.stop(server, callback); 51 | }); 52 | client.end(); 53 | }, 54 | 55 | testSimpleEval: function (test) { 56 | test.expect(3); createTimeout(test); 57 | client.eval('(+ 3 4)', function(err, messages) { 58 | console.log("in simple eval"); 59 | console.log(messages); 60 | test.ok(!err, 'Got errors: ' + err); 61 | test.equal(messages[0].value, '7'); 62 | test.deepEqual(messages[1].status, ['done']); 63 | test.done(); 64 | }); 65 | } 66 | }; 67 | 68 | module.exports = tests; 69 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # node.js nREPL client [![Build Status](https://travis-ci.org/rksm/node-nrepl-client.png?branch=master)](https://travis-ci.org/rksm/node-nrepl-client) 2 | 3 | Connects node.js as a nrepl client to a [Clojure nrepl server](https://github.com/clojure/tools.nrepl). 4 | 5 | This is different from [cljs-noderepl](https://github.com/bodil/cljs-noderepl) 6 | and similar projects as it *does not connect node.js as the repl "target"* (so 7 | that a nrepl Clojure client can eval code in a JS context) *but the other way 8 | around* ;) 9 | 10 | 11 | ## Usage 12 | 13 | To connect to a running nREPL server and send and receive an eval request do: 14 | 15 | ```js 16 | var client = require('nrepl-client').connect({port: 7889}); 17 | 18 | client.once('connect', function() { 19 | var expr = '(+ 3 4)'; 20 | client.eval(expr, function(err, result) { 21 | console.log('%s => ', expr, err || result); 22 | client.end(); 23 | }); 24 | }); 25 | ``` 26 | 27 | For a more detailed example and to use node.js also to start an nREPL Clojure 28 | process see [examples/simple-connect.js](examples/simple-connect.js). 29 | 30 | ## API 31 | 32 | ### `nrepl-client` 33 | 34 | * `connect(options)` 35 | * Creates a [`net.Socket`](http://nodejs.org/api/net.html#net_class_net_socket) 36 | connection to an nREPL server. The connection object itself will have added 37 | methods, see below. 38 | * `options`: options from the [`net.connect`](http://nodejs.org/api/net.html#net_net_connect_options_connectionlistener) call. 39 | * returns a `net.Socket` clojure connection 40 | 41 | * clojure connection 42 | * Wraps [nREPL messages](https://github.com/clojure/tools.nrepl#messages). 43 | * `clone([session,] callback)` 44 | * `close([session,] callback)` 45 | * `describe([verbose,] callback)` 46 | * `eval(code, [session, id, evalFunc,] callback)` 47 | * `interrupt(session, id, callback)` 48 | * `loadFile(fileContent, [fileName, filePath,] callback)` 49 | * `lsSessions(callback)` 50 | * `stdin(stdin, callback)` 51 | * `send(msgObj, callback)` sends a custom message 52 | 53 | ### `nrepl-client/nrepl-server` 54 | 55 | * `start(options, callback)` 56 | * `options` options for configuring the nREPL server. Optional. `options == {startTimeout: NUMBER, verbose: BOOL, projectPath: STRING, hostname: STRING, port: NUMBER}`. See [nrepl-server.js](src/nrepl-server.js) for defaults. 57 | * `callback(err, serverState)` function called when the server is started. `serverState == {proc: PROCESS, hostname: STRING, port: NUMBER, started: BOOL, exited: BOOL, timedout: BOOL}` 58 | 59 | * `stop(serverState, callback)` 60 | * `serverState` serverState returned from start 61 | * `callback(err)` function called when the server is stopped 62 | -------------------------------------------------------------------------------- /src/nrepl-server.js: -------------------------------------------------------------------------------- 1 | /*global console,require,module,process,__dirname,setTimeout,clearTimeout*/ 2 | 3 | /* 4 | * Depending on how you start the clojure nREPL server you don't need this. 5 | * This will start a minimal nrepl via `lein repl :headless` to which the node 6 | * client will connect. 7 | * 8 | */ 9 | 10 | var path = require("path"); 11 | var ps = require("child_process"); 12 | var util = require("util"); 13 | var merge = util._extend; 14 | 15 | // note, the JVM will stick around when we just kill the spawning process 16 | // so we have to do a tree kill for the process. unfortunately the "tree-kill" 17 | // lib is currently not working on Mac OS, so we need this little hack: 18 | var kill = (process.platform === 'darwin') ? 19 | function(pid, signal) { 20 | ps.exec(util.format("ps a -o pid -o ppid |" 21 | + "grep %s | awk '{ print $1 }' |" 22 | + "xargs kill -s %s", pid, signal || 'SIGTERM')); 23 | } : require('tree-kill'); 24 | 25 | 26 | // -=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=- 27 | // Server start implementation. Tries to detect timeouts 28 | // -=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=- 29 | 30 | function startSentinel(options, serverState, thenDo) { 31 | var proc = serverState.proc, 32 | thenDoCalled = false; 33 | 34 | if (options.verbose) { 35 | proc.on('close', function(code) { console.log("nREPL server stopped with code %s: %s", code); }); 36 | proc.on('error', function(error) { console.log("nREPL server error %s", error); }); 37 | proc.stdout.pipe(process.stdout); 38 | proc.stderr.pipe(process.stdout); 39 | } 40 | 41 | proc.on('close', function(_) { serverState.exited = true; }); 42 | checkOutputForServerStart('nREPL server started on'); 43 | 44 | // -=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=- 45 | // helper 46 | 47 | function serverStartDance(serverOutput) { 48 | grabHostnameAndPortFromOutput(serverOutput); 49 | serverState.started = true; 50 | thenDoCalled = true; 51 | options.verbose && console.log('nREPL server started'); 52 | thenDo && thenDo(null, serverState); 53 | } 54 | 55 | function timeout() { 56 | if (thenDoCalled) return; 57 | thenDoCalled = true; 58 | thenDo && thenDo(new Error("nrepl server start timeout"), null); 59 | } 60 | 61 | function checkOutputForServerStart(expectedOutput) { 62 | var timeoutProc = setTimeout(timeout, options.startTimeout), 63 | outListener = gatherOut("stdout", check), 64 | errListener = gatherOut("stderr", check); 65 | proc.stdout.on('data', outListener); 66 | proc.stderr.on('data', errListener); 67 | 68 | function check(string) { 69 | if (string.indexOf(expectedOutput) === -1) return; 70 | proc.stdout.removeListener('data', outListener); 71 | proc.stderr.removeListener('data', errListener); 72 | clearTimeout(timeoutProc); 73 | serverStartDance(string); 74 | } 75 | } 76 | 77 | function gatherOut(type, subscriber) { 78 | return function(data) { 79 | serverState[type] = Buffer.concat([serverState[type], data]); 80 | subscriber(String(serverState[type])); 81 | } 82 | } 83 | 84 | function grabHostnameAndPortFromOutput(output) { 85 | if (!output) return 86 | var match = output.match("on port ([0-9]+) on host ([^\s]+)"); 87 | if (!match) return; 88 | if (match[1]) serverState.port = parseInt(match[1]); 89 | if (match[2]) serverState.hostname = match[2]; 90 | } 91 | 92 | } 93 | 94 | function startServer(hostname, port, projectPath, thenDo) { 95 | try { 96 | var procArgs = ["repl", ":headless"]; 97 | if (hostname) procArgs = procArgs.concat([':host', hostname]); 98 | if (port) procArgs = procArgs.concat([':port', port]); 99 | var proc = ps.spawn('lein', procArgs, {cwd: projectPath}); 100 | } catch (e) { thenDo(e, null); return; } 101 | thenDo(null, { 102 | proc: proc, 103 | stdout: new Buffer(""), 104 | stderr: new Buffer(""), 105 | hostname: undefined, port: undefined, // set when started 106 | started: false, 107 | exited: false, 108 | timedout: undefined 109 | }); 110 | } 111 | 112 | 113 | // -=-=-=-=-=-=-=-=-=-=- 114 | // the actual interface 115 | // -=-=-=-=-=-=-=-=-=-=- 116 | 117 | var defaultOptions = { 118 | startTimeout: 10*1000, // milliseconds 119 | verbose: false, 120 | projectPath: process.cwd(), 121 | // if host / port stay undefined they are choosen by leiningen 122 | hostname: undefined, 123 | port: undefined 124 | } 125 | 126 | function start(options, thenDo) { 127 | options = merge(merge({}, defaultOptions), options); 128 | startServer(options.hostname, options.port, 129 | options.projectPath, function(err, serverState) { 130 | if (err) thenDo(err, null); 131 | else startSentinel(options, serverState, thenDo); 132 | }); 133 | } 134 | 135 | function stop(serverState, thenDo) { 136 | if (serverState.exited) { thenDo(null); return; } 137 | // FIXME what if when kill doesn't work? At least attach to `close` and 138 | // throw a time out error... 139 | kill(serverState.proc.pid, 'SIGTERM'); 140 | serverState.proc.once('close', function() { 141 | console.log("Stopped nREPL server with pid %s", serverState.proc.pid); 142 | thenDo && thenDo(null); 143 | }); 144 | } 145 | 146 | module.exports = {start: start, stop: stop}; 147 | -------------------------------------------------------------------------------- /src/nrepl-client.js: -------------------------------------------------------------------------------- 1 | /*global console,require*/ 2 | 3 | var bencode = require('bencode'), 4 | util = require('util'), 5 | net = require('net'), 6 | stream = require('stream'), 7 | events = require("events");; 8 | 9 | function uuid() { // helper 10 | return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx' 11 | .replace(/[xy]/g, replacer).toUpperCase(); 12 | 13 | function replacer(c) { 14 | var r = Math.random()*16|0, 15 | v = c == 'x' ? r : (r&0x3|0x8); 16 | return v.toString(16); 17 | } 18 | } 19 | 20 | // id -> send data mapping 21 | // send data: {callback: FUNCTION} 22 | var sendsInProgress = {}; 23 | 24 | var messageLogPrinter = function(response, length) { 25 | var inspected = ""; 26 | if (typeof response === "object") { 27 | inspected += "{\n" + Object.keys(response).map(function(k) { 28 | var v = response[k]; 29 | if (typeof v === 'string' && v.length > 100) 30 | v = v.slice(0,100) + "..."; 31 | // v = '"' + (v.slice(0,100) + "...").replace(/"/g, '\\"') + '"'; 32 | return k + ": " + util.inspect(v, {depth: 0}); 33 | }).join(",\n ") + "\n}"; 34 | } else inspected = util.inspect(response, {depth: 0}); 35 | return inspected; 36 | } 37 | var nullLogger = {log: function(response, length) {}}; 38 | var defaultLogger = { 39 | log: function(response, length) { 40 | var printed = messageLogPrinter(response, length); 41 | console.log("nrepl message received (%s bytes, %s)", length, printed); 42 | } 43 | } 44 | var currentLogger = nullLogger; 45 | 46 | function createMessageStream(verbose, socket) { 47 | var messageStream = new stream.Transform(); 48 | messageStream._writableState.objectMode = false; 49 | messageStream._readableState.objectMode = true; 50 | messageStream._bytesLeft = 0; 51 | messageStream._messageCache = []; 52 | messageStream._chunkLeft = new Buffer(""); 53 | 54 | messageStream._transform = function(chunk, encoding, callback) { 55 | verbose && console.log("nREPL message chunk received (%s bytes)", chunk.length); 56 | this._bytesLeft += chunk.length; 57 | this._chunkLeft = Buffer.concat([this._chunkLeft, chunk]); 58 | var messages = []; 59 | try { 60 | while (this._bytesLeft > 0) { 61 | try { 62 | var response = bencode.decode(this._chunkLeft, 'utf8'); 63 | } catch (e) { 64 | // bencode.decode fails when the current chunk isn't 65 | // complete, in this case we just cache the chunk and wait to 66 | // be called again 67 | callback(); return; 68 | } 69 | var encodedResponseLength = bencode.encode(response, 'utf8').length; 70 | nreplLog(response, encodedResponseLength); 71 | this._bytesLeft -= encodedResponseLength; 72 | this.push(response); 73 | this._messageCache.push(response); 74 | this._chunkLeft = this._chunkLeft.slice(encodedResponseLength); 75 | this._messageCache = consumeNreplMessageStream(this.emit.bind(this), this._messageCache); 76 | } 77 | } catch (e) { 78 | this.emit('error', e); 79 | console.error('nrepl message receive error: ', e.stack || e); 80 | } 81 | callback(); 82 | }; 83 | 84 | return socket.pipe(messageStream); 85 | } 86 | 87 | function nreplLog(response, length) { 88 | try { 89 | currentLogger.log(response, length); 90 | } catch (e) { 91 | console.error("error in nrepl message logger: ", e); 92 | } 93 | } 94 | 95 | function consumeNreplMessageStream(emit, messages) { 96 | var receivers = messages.reduce(function(receivers, msg) { 97 | var queue = receivers[msg.id] || (receivers[msg.id] = []); 98 | queue.push(msg); 99 | return receivers; 100 | }, {}); 101 | Object.keys(receivers).forEach(function(id) { 102 | emit("messageSequence", id, receivers[id]); 103 | emit("messageSequence-" + id, receivers[id]); 104 | }); 105 | return []; 106 | } 107 | 108 | function nreplSend(socket, messageStream, msgSpec, callback) { 109 | var msg = {id: msgSpec.id || uuid()}; 110 | Object.keys(msgSpec).forEach(function(k) { 111 | if (msgSpec[k] !== undefined) msg[k] = msgSpec[k]; }); 112 | socket.write(bencode.encode(msg), 'binary'); 113 | 114 | var errors = [], messages = [], 115 | msgHandlerName = 'messageSequence-' + msg.id; 116 | messageStream.on('error', errHandler); 117 | messageStream.on(msgHandlerName, msgHandler); 118 | return msg; 119 | 120 | // -=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=- 121 | 122 | function errHandler(err) { errors.push(err); } 123 | function msgHandler(_messages) { 124 | var done = _messages.some(function(msg) { return !!msg.status; }); 125 | // return msg.status && msg.status.indexOf("done") > -1; }); 126 | messages = messages.concat(_messages); 127 | if (!done) return; 128 | messageStream.removeListener('error', errHandler); 129 | messageStream.removeListener(msgHandlerName, errHandler); 130 | callback && callback(errors.length > 0 ? errors : null, messages); 131 | } 132 | } 133 | 134 | // default nREPL ops, see https://github.com/clojure/tools.nrepl/blob/master/doc/ops.md 135 | 136 | function clone(connection, session, callback) { 137 | if (typeof session === 'function') { callback = session; session = undefined; } 138 | return connection.send({op: 'clone', session: session}, function(err, messages) { 139 | var newSess = messages && messages[0] && messages[0]["new-session"]; 140 | if (newSess) connection.sessions.push(newSess); 141 | callback(err, messages); 142 | }); 143 | } 144 | 145 | function close(connection, session, callback) { 146 | if (typeof session === 'function') { callback = session; session = undefined; } 147 | return connection.send({op: 'close', session: session}, function(err, messages) { 148 | var status = messages && messages[0] && messages[0].status; 149 | var closed = status && status.indexOf("session-closed") > -1; 150 | if (closed) connection.sessions = connection.sessions.filter(function(ea) { return ea != session; }); 151 | callback(err, messages); 152 | }); 153 | } 154 | 155 | function describe(connection, session, verbose, callback) { 156 | return connection.send({op: 'describe', 'verbose?': verbose ? 'true' : undefined}, callback); 157 | } 158 | 159 | function cljEval(connection, code, ns, session, id, evalFunc, callback) { 160 | if (typeof session === 'function') { callback = session; session = undefined; } 161 | else if (typeof ns === 'function') { callback = ns; ns = undefined; } 162 | else if (typeof id === 'function') { callback = id; id = undefined; } 163 | else if (typeof evalFunc === 'function') { callback = evalFunc; evalFunc = undefined; } 164 | return connection.send({op: 'eval', code: code, ns: ns || undefined, session: session, id: id, "eval": evalFunc}, callback); 165 | } 166 | 167 | function interrupt(connection, session, id, callback) { 168 | if (typeof session === 'function') { callback = session; session = undefined; } 169 | else if (typeof id === 'function') { callback = id; id = undefined; } 170 | return connection.send({op: 'interrupt', "interrupt-id": id, session: session}, callback); 171 | } 172 | 173 | function loadFile(connection, fileContent, fileName, filePath, session, id, callback) { 174 | if (typeof session === 'function') { callback = session; session = undefined; } 175 | else if (typeof id === 'function') { callback = id; id = undefined; } 176 | // :file-name Name of source file, e.g. io.clj 177 | // :file-path Source-path-relative path of the source file, e.g. clojure/java/io.clj 178 | return connection.send({op: 'load-file', "file": fileContent, "file-name": fileName, "file-path": filePath}, callback); 179 | } 180 | 181 | function lsSessions(connection, callback) { 182 | return connection.send({op: 'ls-sessions'}, function(err, messages) { 183 | var sessions = messages && messages[0] && messages[0]["sessions"]; 184 | if (sessions) connection.sessions = sessions; 185 | callback(err, messages); 186 | }); 187 | } 188 | 189 | function stdin(connection, stdin, callback) { 190 | return connection.send({op: 'stdin', stdin: stdin}, callback); 191 | } 192 | 193 | function connect(options) { 194 | var con = net.connect(options), 195 | messageStream = createMessageStream(options.verbose, con); 196 | con.sessions = []; 197 | con.messageStream = messageStream; 198 | con.send = nreplSend.bind(null, con, messageStream); 199 | con.clone = clone.bind(null, con); 200 | con.close = close.bind(null, con); 201 | con.describe = describe.bind(null, con); 202 | con.eval = cljEval.bind(null, con); 203 | con.interrupt = interrupt.bind(null, con); 204 | con.loadFile = loadFile.bind(null, con); 205 | con.lsSessions = lsSessions.bind(null, con); 206 | con.stdin = stdin.bind(null, con); 207 | return con; 208 | } 209 | 210 | module.exports = { 211 | connect: connect, 212 | log: { 213 | defaultLogger: defaultLogger, 214 | nullLogger: nullLogger, 215 | messageLogPrinter: messageLogPrinter, 216 | get currentLogger() { return currentLogger; }, 217 | set currentLogger(l) { return currentLogger = l; } 218 | } 219 | } 220 | --------------------------------------------------------------------------------