Files
meteor/packages/shell-server/shell-server.js
Ben Newman a467255d9c Use IPC message to handle .reload command in meteor shell.
I considered using a nonzero process.exit code, but I didn't want to run
any risk of reusing a meaningful code, and the accepted range of codes
unfortunately does not include parseInt("reload", 36), or 1657112629.

Should fix #10934.
2020-02-26 12:13:48 -05:00

447 lines
13 KiB
JavaScript

import assert from "assert";
import { join as pathJoin } from "path";
import { PassThrough } from "stream";
import {
closeSync,
openSync,
readFileSync,
unlink,
writeFileSync,
writeSync,
} from "fs";
import { createServer } from "net";
import { start as replStart } from "repl";
// Enable process.sendMessage for communication with build process.
import "meteor/inter-process-messaging";
const INFO_FILE_MODE = parseInt("600", 8); // Only the owner can read or write.
const EXITING_MESSAGE = "Shell exiting...";
// Invoked by the server process to listen for incoming connections from
// shell clients. Each connection gets its own REPL instance.
export function listen(shellDir) {
function callback() {
new Server(shellDir).listen();
}
// If the server is still in the very early stages of starting up,
// Meteor.startup may not available yet.
if (typeof Meteor === "object") {
Meteor.startup(callback);
} else if (typeof __meteor_bootstrap__ === "object") {
const hooks = __meteor_bootstrap__.startupHooks;
if (hooks) {
hooks.push(callback);
} else {
// As a fallback, just call the callback asynchronously.
setImmediate(callback);
}
}
}
// Disabling the shell causes all attached clients to disconnect and exit.
export 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.
writeFileSync(
getInfoFile(shellDir),
JSON.stringify({
status: "disabled",
reason: "Shell server has shut down."
}) + "\n",
{ mode: INFO_FILE_MODE }
);
} catch (ignored) {}
}
// Shell commands need to be executed in a Fiber in case they call into
// code that yields. Using a Promise is an even better idea, since it runs
// its callbacks in Fibers drawn from a pool, so the Fibers are recycled.
const evalCommandPromise = Promise.resolve();
class Server {
constructor(shellDir) {
assert.ok(this instanceof Server);
this.shellDir = shellDir;
this.key = Math.random().toString(36).slice(2);
this.server =
createServer((socket) => {
this.onConnection(socket);
})
.on("error", (err) => {
console.error(err.stack);
});
}
listen() {
const infoFile = getInfoFile(this.shellDir);
unlink(infoFile, () => {
this.server.listen(0, "127.0.0.1", () => {
writeFileSync(infoFile, JSON.stringify({
status: "enabled",
port: this.server.address().port,
key: this.key
}) + "\n", {
mode: INFO_FILE_MODE
});
});
});
}
onConnection(socket) {
// 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.
const 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.
readJSONFromStream(socket, (error, options, replInputSocket) => {
clearTimeout(timeout);
if (error) {
socket = null;
console.error(error.stack);
return;
}
if (options.key !== this.key) {
if (socket) {
socket.end(EXITING_MESSAGE + "\n");
}
return;
}
delete options.key;
// Set the columns to what is being requested by the client.
if (options.columns && socket) {
socket.columns = options.columns;
}
delete options.columns;
options = Object.assign(
Object.create(null),
// Defaults for configurable options.
{
prompt: "> ",
terminal: true,
useColors: true,
ignoreUndefined: true,
},
// Configurable options
options,
// Immutable options.
{
input: replInputSocket,
useGlobal: false,
output: socket
}
);
// The prompt during an evaluateAndExit must be blank to ensure
// that the prompt doesn't inadvertently get parsed as part of
// the JSON communication channel.
if (options.evaluateAndExit) {
options.prompt = "";
}
// Start the REPL.
this.startREPL(options);
if (options.evaluateAndExit) {
this._wrappedDefaultEval.call(
Object.create(null),
options.evaluateAndExit.command,
global,
options.evaluateAndExit.filename || "<meteor shell>",
function (error, result) {
if (socket) {
function sendResultToSocket(message) {
// Sending back a JSON payload allows the client to
// distinguish between errors and successful results.
socket.end(JSON.stringify(message) + "\n");
}
if (error) {
sendResultToSocket({
error: error.toString(),
code: 1
});
} else {
sendResultToSocket({
result,
});
}
}
}
);
return;
}
delete options.evaluateAndExit;
this.enableInteractiveMode(options);
});
}
startREPL(options) {
// 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;
});
const repl = this.repl = replStart(options);
const { shellDir } = this;
// This is technique of setting `repl.context` is similar to how the
// `useGlobal` option would work during a normal `repl.start()` and
// allows shell access (and tab completion!) to Meteor globals (i.e.
// Underscore _, Meteor, etc.). By using this technique, which changes
// the context after startup, we avoid stomping on the special `_`
// variable (in `repl` this equals the value of the last command) from
// being overridden in the client/server socket-handshaking. Furthermore,
// by setting `useGlobal` back to true, we allow the default eval function
// to use the desired `runInThisContext` method (https://git.io/vbvAB).
repl.context = global;
repl.useGlobal = true;
setRequireAndModule(repl.context);
// In order to avoid duplicating code here, specifically the complexities
// of catching so-called "Recoverable Errors" (https://git.io/vbvbl),
// we will wrap the default eval, run it in a Fiber (via a Promise), and
// give it the opportunity to decide if the user is mid-code-block.
const defaultEval = repl.eval;
function wrappedDefaultEval(code, context, file, callback) {
if (Package.ecmascript) {
try {
code = Package.ecmascript.ECMAScript.compileForShell(code, {
cacheDirectory: getCacheDirectory(shellDir)
});
} catch (err) {
// Any Babel error here might be just fine since it's
// possible the code was incomplete (multi-line code on the REPL).
// The defaultEval below will use its own functionality to determine
// if this error is "recoverable".
}
}
evalCommandPromise
.then(() => defaultEval(code, context, file, callback))
.catch(callback);
}
// Have the REPL use the newly wrapped function instead and store the
// _wrappedDefaultEval so that evalulateAndExit calls can use it directly.
repl.eval = this._wrappedDefaultEval = wrappedDefaultEval;
}
enableInteractiveMode(options) {
// History persists across shell sessions!
this.initializeHistory();
const repl = this.repl;
// Implement an alternate means of fetching the return value,
// via `__` (double underscore) as originally implemented in:
// https://github.com/meteor/meteor/commit/2443d832265c7d1c
Object.defineProperty(repl.context, "__", {
get: () => repl.last,
set: (val) => {
repl.last = val;
},
// Allow this property to be (re)defined more than once (e.g. each
// time the server restarts).
configurable: true
});
// Some improvements to the existing help messages.
function addHelp(cmd, helpText) {
const info = repl.commands[cmd] || repl.commands["." + cmd];
if (info) {
info.help = helpText;
}
}
addHelp("break", "Terminate current command input and display new prompt");
addHelp("exit", "Disconnect from server and leave shell");
addHelp("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() {
if (process.sendMessage) {
process.sendMessage("shell-server", { command: "reload" });
} else {
process.exit(0);
}
}
});
}
// This function allows a persistent history of shell commands to be saved
// to and loaded from .meteor/local/shell/history.
initializeHistory() {
const repl = this.repl;
const historyFile = getHistoryFile(this.shellDir);
let historyFd = openSync(historyFile, "a+");
const historyLines = readFileSync(historyFile, "utf8").split("\n");
const seenLines = Object.create(null);
if (! repl.history) {
repl.history = [];
repl.historyIndex = -1;
}
while (repl.history && historyLines.length > 0) {
const line = historyLines.pop();
if (line && /\S/.test(line) && ! seenLines[line]) {
repl.history.push(line);
seenLines[line] = true;
}
}
repl.addListener("line", function(line) {
if (historyFd >= 0 && /\S/.test(line)) {
writeSync(historyFd, line + "\n");
}
});
this.repl.on("exit", function() {
closeSync(historyFd);
historyFd = -1;
});
}
}
function readJSONFromStream(inputStream, callback) {
const outputStream = new PassThrough();
let dataSoFar = "";
function onData(buffer) {
const lines = buffer.toString("utf8").split("\n");
while (lines.length > 0) {
dataSoFar += lines.shift();
let json;
try {
json = JSON.parse(dataSoFar);
} catch (error) {
if (error instanceof SyntaxError) {
continue;
}
return finish(error);
}
if (lines.length > 0) {
outputStream.write(lines.join("\n"));
}
inputStream.pipe(outputStream);
return finish(null, json);
}
}
function onClose() {
finish(new Error("stream unexpectedly closed"));
}
let finished = false;
function finish(error, json) {
if (! finished) {
finished = true;
inputStream.removeListener("data", onData);
inputStream.removeListener("error", finish);
inputStream.removeListener("close", onClose);
callback(error, json, outputStream);
}
}
inputStream.on("data", onData);
inputStream.on("error", finish);
inputStream.on("close", onClose);
}
function getInfoFile(shellDir) {
return pathJoin(shellDir, "info.json");
}
function getHistoryFile(shellDir) {
return pathJoin(shellDir, "history");
}
function getCacheDirectory(shellDir) {
return pathJoin(shellDir, "cache");
}
function setRequireAndModule(context) {
if (Package.modules) {
// Use the same `require` function and `module` object visible to the
// application.
const toBeInstalled = {};
const shellModuleName = "meteor-shell-" +
Math.random().toString(36).slice(2) + ".js";
toBeInstalled[shellModuleName] = function (require, exports, module) {
context.module = module;
context.require = require;
// Tab completion sometimes uses require.extensions, but only for
// the keys.
require.extensions = {
".js": true,
".json": true,
".node": true,
};
};
// This populates repl.context.{module,require} by evaluating the
// module defined above.
Package.modules.meteorInstall(toBeInstalled)("./" + shellModuleName);
}
}