/programs/server/shell-server.js
JavaScript | 290 lines | 206 code | 42 blank | 42 comment | 18 complexity | 4b46d232dcba00049cb8ef4b3dcdf01c MD5 | raw file
Possible License(s): BSD-3-Clause, Apache-2.0, MIT, CC-BY-SA-3.0, 0BSD
- var assert = require("assert");
- var path = require("path");
- var stream = require("stream");
- var fs = require("fs");
- var net = require("net");
- var tty = require("tty");
- var vm = require("vm");
- var Fiber = require("fibers");
- var _ = require("underscore");
- var INFO_FILE_MODE = 0600; // Only the owner can read or write.
- var EXITING_MESSAGE =
- // Exported so that ./client.js can know what to expect.
- exports.EXITING_MESSAGE = "Shell exiting...";
- // Invoked by the server process to listen for incoming connections from
- // shell clients. Each connection gets its own REPL instance.
- exports.listen = function listen(shellDir) {
- new Server(shellDir).listen();
- };
- // Disabling the shell causes all attached clients to disconnect and exit.
- exports.disable = function disable(shellDir) {
- try {
- // Replace info.json with a file that says the shell server is
- // disabled, so that any connected shell clients will fail to
- // reconnect after the server process closes their sockets.
- fs.writeFileSync(
- getInfoFile(shellDir),
- JSON.stringify({
- status: "disabled",
- reason: "Shell server has shut down."
- }) + "\n",
- { mode: INFO_FILE_MODE }
- );
- } catch (ignored) {}
- };
- function Server(shellDir) {
- var self = this;
- assert.ok(self instanceof Server);
- self.shellDir = shellDir;
- self.key = Math.random().toString(36).slice(2);
- self.server = net.createServer(function(socket) {
- self.onConnection(socket);
- }).on("error", function(err) {
- console.error(err.stack);
- });
- }
- var Sp = Server.prototype;
- Sp.listen = function listen() {
- var self = this;
- var infoFile = getInfoFile(self.shellDir);
- fs.unlink(infoFile, function() {
- self.server.listen(0, "127.0.0.1", function() {
- fs.writeFileSync(infoFile, JSON.stringify({
- status: "enabled",
- port: self.server.address().port,
- key: self.key
- }) + "\n", {
- mode: INFO_FILE_MODE
- });
- });
- });
- };
- Sp.onConnection = function onConnection(socket) {
- var self = this;
- var dataSoFar = "";
- // Make sure this function doesn't try to write anything to the socket
- // after it has been closed.
- socket.on("close", function() {
- socket = null;
- });
- // If communication is not established within 1000ms of the first
- // connection, forcibly close the socket.
- var timeout = setTimeout(function() {
- if (socket) {
- socket.removeAllListeners("data");
- socket.end(EXITING_MESSAGE + "\n");
- }
- }, 1000);
- // Let connecting clients configure certain REPL options by sending a
- // JSON object over the socket. For example, only the client knows
- // whether it's running a TTY or an Emacs subshell or some other kind of
- // terminal, so the client must decide the value of options.terminal.
- socket.on("data", function onData(buffer) {
- // Just in case the options JSON comes in fragments.
- dataSoFar += buffer.toString("utf8");
- try {
- var options = JSON.parse(dataSoFar);
- } finally {
- if (! _.isObject(options)) {
- return; // Silence any parsing exceptions.
- }
- }
- if (socket) {
- socket.removeListener("data", onData);
- }
- if (options.key !== self.key) {
- if (socket) {
- socket.end(EXITING_MESSAGE + "\n");
- }
- return;
- }
- delete options.key;
- clearTimeout(timeout);
- // Immutable options.
- _.extend(options, {
- input: socket,
- output: socket,
- eval: evalCommand
- });
- // Overridable options.
- _.defaults(options, {
- prompt: "> ",
- terminal: true,
- useColors: true,
- useGlobal: true,
- ignoreUndefined: true,
- });
- self.startREPL(options);
- });
- };
- Sp.startREPL = function startREPL(options) {
- var self = this;
- if (! options.output.columns) {
- // The REPL's tab completion logic assumes process.stdout is a TTY,
- // and while that isn't technically true here, we can get tab
- // completion to behave correctly if we fake the .columns property.
- options.output.columns = getTerminalWidth();
- }
- // Make sure this function doesn't try to write anything to the output
- // stream after it has been closed.
- options.output.on("close", function() {
- options.output = null;
- });
- var repl = self.repl = require("repl").start(options);
- // History persists across shell sessions!
- self.initializeHistory();
- Object.defineProperty(repl.context, "_", {
- // Force the global _ variable to remain bound to underscore.
- get: function () { return _; },
- // Expose the last REPL result as __ instead of _.
- set: function(lastResult) {
- repl.context.__ = lastResult;
- },
- enumerable: true,
- // Allow this property to be (re)defined more than once (e.g. each
- // time the server restarts).
- configurable: true
- });
- // Use the same `require` function and `module` object visible to the
- // shell.js module.
- repl.context.require = require;
- repl.context.module = module;
- repl.context.repl = repl;
- // Some improvements to the existing help messages.
- repl.commands[".break"].help =
- "Terminate current command input and display new prompt";
- repl.commands[".exit"].help = "Disconnect from server and leave shell";
- repl.commands[".help"].help = "Show this help information";
- // When the REPL exits, signal the attached client to exit by sending it
- // the special EXITING_MESSAGE.
- repl.on("exit", function() {
- if (options.output) {
- options.output.write(EXITING_MESSAGE + "\n");
- options.output.end();
- }
- });
- // When the server process exits, end the output stream but do not
- // signal the attached client to exit.
- process.on("exit", function() {
- if (options.output) {
- options.output.end();
- }
- });
- // This Meteor-specific shell command rebuilds the application as if a
- // change was made to server code.
- repl.defineCommand("reload", {
- help: "Restart the server and the shell",
- action: function() {
- process.exit(0);
- }
- });
- };
- function getInfoFile(shellDir) {
- return path.join(shellDir, "info.json");
- }
- exports.getInfoFile = getInfoFile;
- function getHistoryFile(shellDir) {
- return path.join(shellDir, "history");
- }
- function getTerminalWidth() {
- try {
- // Inspired by https://github.com/TooTallNate/ttys/blob/master/index.js
- var fd = fs.openSync("/dev/tty", "r");
- assert.ok(tty.isatty(fd));
- var ws = new tty.WriteStream(fd);
- ws.end();
- return ws.columns;
- } catch (fancyApproachWasTooFancy) {
- return 80;
- }
- }
- // Shell commands need to be executed in fibers in case they call into
- // code that yields.
- function evalCommand(command, context, filename, callback) {
- Fiber(function() {
- try {
- var result = vm.runInThisContext(command, filename);
- } catch (error) {
- if (process.domain) {
- process.domain.emit("error", error);
- process.domain.exit();
- } else {
- callback(error);
- }
- return;
- }
- callback(null, result);
- }).run();
- }
- // This function allows a persistent history of shell commands to be saved
- // to and loaded from .meteor/local/shell-history.
- Sp.initializeHistory = function initializeHistory() {
- var self = this;
- var rli = self.repl.rli;
- var historyFile = getHistoryFile(self.shellDir);
- var historyFd = fs.openSync(historyFile, "a+");
- var historyLines = fs.readFileSync(historyFile, "utf8").split("\n");
- var seenLines = Object.create(null);
- if (! rli.history) {
- rli.history = [];
- rli.historyIndex = -1;
- }
- while (rli.history && historyLines.length > 0) {
- var line = historyLines.pop();
- if (line && /\S/.test(line) && ! seenLines[line]) {
- rli.history.push(line);
- seenLines[line] = true;
- }
- }
- rli.addListener("line", function(line) {
- if (historyFd >= 0 && /\S/.test(line)) {
- fs.writeSync(historyFd, line + "\n");
- }
- });
- self.repl.on("exit", function() {
- fs.closeSync(historyFd);
- historyFd = -1;
- });
- };