Skip to content

Commit

Permalink
packager: Terminal abstraction to manage TTYs
Browse files Browse the repository at this point in the history
Reviewed By: cpojer

Differential Revision: D4293146

fbshipit-source-id: 66e943b026197d293b5a518b4f97a0bced8d11bb
  • Loading branch information
Jean Lauliac authored and Facebook Github Bot committed Dec 14, 2016
1 parent f8f70d2 commit 26ed94c
Show file tree
Hide file tree
Showing 7 changed files with 261 additions and 38 deletions.
1 change: 0 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,6 @@
"opn": "^3.0.2",
"optimist": "^0.6.1",
"plist": "^1.2.0",
"progress": "^1.1.8",
"promise": "^7.1.1",
"react-clone-referenced-element": "^1.0.1",
"react-timer-mixin": "^0.13.2",
Expand Down
3 changes: 2 additions & 1 deletion packager/react-packager/src/Logger/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
const chalk = require('chalk');
const os = require('os');
const pkgjson = require('../../../package.json');
const terminal = require('../lib/terminal');

const {EventEmitter} = require('events');

Expand Down Expand Up @@ -134,7 +135,7 @@ function print(
}

// eslint-disable-next-line no-console-disallow
console.log(logEntryString);
terminal.log(logEntryString);

return logEntry;
}
Expand Down
40 changes: 12 additions & 28 deletions packager/react-packager/src/Server/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,14 @@ const AssetServer = require('../AssetServer');
const getPlatformExtension = require('../node-haste').getPlatformExtension;
const Bundler = require('../Bundler');
const MultipartResponse = require('./MultipartResponse');
const ProgressBar = require('progress');
const SourceMapConsumer = require('source-map').SourceMapConsumer;

const declareOpts = require('../lib/declareOpts');
const defaults = require('../../../defaults');
const mime = require('mime-types');
const path = require('path');
const terminal = require('../lib/terminal');
const throttle = require('lodash/throttle');
const url = require('url');

const debug = require('debug')('ReactNativePackager:Server');
Expand Down Expand Up @@ -454,7 +455,7 @@ class Server {
e => {
res.writeHead(500);
res.end('Internal Error');
console.log(e.stack); // eslint-disable-line no-console-disallow
terminal.log(e.stack); // eslint-disable-line no-console-disallow
}
);
} else {
Expand Down Expand Up @@ -697,13 +698,15 @@ class Server {

let consoleProgress = () => {};
if (process.stdout.isTTY && !this._opts.silent) {
const bar = new ProgressBar('transformed :current/:total (:percent)', {
complete: '=',
incomplete: ' ',
width: 40,
total: 1,
});
consoleProgress = debouncedTick(bar);
const onProgress = (doneCount, totalCount) => {
const format = 'transformed %s/%s (%s%)';
const percent = Math.floor(100 * doneCount / totalCount);
terminal.status(format, doneCount, totalCount, percent);
if (doneCount === totalCount) {
terminal.persistStatus();
}
};
consoleProgress = throttle(onProgress, 200);
}

const mres = MultipartResponse.wrap(req, res);
Expand Down Expand Up @@ -959,23 +962,4 @@ function contentsEqual(array: Array<mixed>, set: Set<mixed>): boolean {
return array.length === set.size && array.every(set.has, set);
}

function debouncedTick(progressBar) {
let n = 0;
let start, total;

return (_, t) => {
total = t;
n += 1;
if (start) {
if (progressBar.curr + n >= total || Date.now() - start > 200) {
progressBar.total = total;
progressBar.tick(n);
start = n = 0;
}
} else {
start = Date.now();
}
};
}

module.exports = Server;
9 changes: 5 additions & 4 deletions packager/react-packager/src/lib/TransformCache.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ const jsonStableStringify = require('json-stable-stringify');
const mkdirp = require('mkdirp');
const path = require('path');
const rimraf = require('rimraf');
const terminal = require('../lib/terminal');
const toFixedHex = require('./toFixedHex');
const writeFileAtomicSync = require('write-file-atomic').sync;

Expand Down Expand Up @@ -189,18 +190,18 @@ const GARBAGE_COLLECTOR = new (class GarbageCollector {
try {
this._collectSync();
} catch (error) {
console.error(error.stack);
console.error(
terminal.log(error.stack);
terminal.log(
'Error: Cleaning up the cache folder failed. Continuing anyway.',
);
console.error('The cache folder is: %s', getCacheDirPath());
terminal.log('The cache folder is: %s', getCacheDirPath());
}
this._lastCollected = Date.now();
}

_resetCache() {
rimraf.sync(getCacheDirPath());
console.log('Warning: The transform cache was reset.');
terminal.log('Warning: The transform cache was reset.');
this._cacheWasReset = true;
this._lastCollected = Date.now();
}
Expand Down
100 changes: 100 additions & 0 deletions packager/react-packager/src/lib/__tests__/terminal-test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
/**
* Copyright (c) 2015-present, Facebook, Inc.
* All rights reserved.
*
* This source code is licensed under the BSD-style license found in the
* LICENSE file in the root directory of this source tree. An additional grant
* of patent rights can be found in the PATENTS file in the same directory.
*/

'use strict';

jest.dontMock('../terminal');

jest.mock('readline', () => ({
moveCursor: (stream, dx, dy) => {
const {cursor, columns} = stream;
stream.cursor = Math.max(cursor - cursor % columns, cursor + dx) + dy * columns;
},
clearLine: (stream, dir) => {
if (dir !== 0) {throw new Error('unsupported');}
const {cursor, columns} = stream;
const curLine = cursor - cursor % columns;
const nextLine = curLine + columns;
for (var i = curLine; i < nextLine; ++i) {
stream.buffer[i] = ' ';
}
},
}));

describe('terminal', () => {

beforeEach(() => {
jest.resetModules();
});

function prepare(isTTY) {
const {Terminal} = require('../terminal');
const lines = 10;
const columns = 10;
const stream = Object.create(
isTTY ? require('tty').WriteStream.prototype : require('net').Socket,
);
Object.assign(stream, {
cursor: 0,
buffer: ' '.repeat(columns * lines).split(''),
columns,
lines,
write(str) {
for (let i = 0; i < str.length; ++i) {
if (str[i] === '\n') {
this.cursor = this.cursor - (this.cursor % columns) + columns;
} else {
this.buffer[this.cursor] = str[i];
++this.cursor;
}
}
},
});
return {stream, terminal: new Terminal(stream)};
}

it('is not printing status to non-interactive terminal', () => {
const {stream, terminal} = prepare(false);
terminal.log('foo %s', 'smth');
terminal.status('status');
terminal.log('bar');
expect(stream.buffer.join('').trim())
.toEqual('foo smth bar');
});

it('updates status when logging, single line', () => {
const {stream, terminal} = prepare(true);
terminal.log('foo');
terminal.status('status');
terminal.status('status2');
terminal.log('bar');
expect(stream.buffer.join('').trim())
.toEqual('foo bar status2');
});

it('updates status when logging, multi-line', () => {
const {stream, terminal} = prepare(true);
terminal.log('foo');
terminal.status('status\nanother');
terminal.log('bar');
expect(stream.buffer.join('').trim())
.toEqual('foo bar status another');
});

it('persists status', () => {
const {stream, terminal} = prepare(true);
terminal.log('foo');
terminal.status('status');
terminal.persistStatus();
terminal.log('bar');
expect(stream.buffer.join('').trim())
.toEqual('foo status bar');
});

});
137 changes: 137 additions & 0 deletions packager/react-packager/src/lib/terminal.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
/**
* Copyright (c) 2015-present, Facebook, Inc.
* All rights reserved.
*
* This source code is licensed under the BSD-style license found in the
* LICENSE file in the root directory of this source tree. An additional grant
* of patent rights can be found in the PATENTS file in the same directory.
*
* @flow
*/

'use strict';

const readline = require('readline');
const tty = require('tty');
const util = require('util');

/**
* Clear some text that was previously printed on an interactive stream,
* without trailing newline character (so we have to move back to the
* beginning of the line).
*/
function clearStringBackwards(stream: tty.WriteStream, str: string): void {
readline.moveCursor(stream, -stream.columns, 0);
readline.clearLine(stream, 0);
let lineCount = (str.match(/\n/g) || []).length;
while (lineCount > 0) {
readline.moveCursor(stream, 0, -1);
readline.clearLine(stream, 0);
--lineCount;
}
}

/**
* We don't just print things to the console, sometimes we also want to show
* and update progress. This utility just ensures the output stays neat: no
* missing newlines, no mangled log lines.
*
* const terminal = Terminal.default;
* terminal.status('Updating... 38%');
* terminal.log('warning: Something happened.');
* terminal.status('Updating, done.');
* terminal.persistStatus();
*
* The final output:
*
* warning: Something happened.
* Updating, done.
*
* Without the status feature, we may get a mangled output:
*
* Updating... 38%warning: Something happened.
* Updating, done.
*
* This is meant to be user-readable and TTY-oriented. We use stdout by default
* because it's more about status information than diagnostics/errors (stderr).
*
* Do not add any higher-level functionality in this class such as "warning" and
* "error" printers, as it is not meant for formatting/reporting. It has the
* single responsibility of handling status messages.
*/
class Terminal {

_statusStr: string;
_stream: net$Socket;

constructor(stream: net$Socket) {
this._stream = stream;
this._statusStr = '';
}

/**
* Same as status() without the formatting capabilities. We just clear and
* rewrite with the new status. If the stream is non-interactive we still
* keep track of the string so that `persistStatus` works.
*/
_setStatus(str: string): string {
const {_statusStr, _stream} = this;
if (_statusStr !== str && _stream instanceof tty.WriteStream) {
clearStringBackwards(_stream, _statusStr);
_stream.write(str);
}
this._statusStr = str;
return _statusStr;
}

/**
* Shows some text that is meant to be overriden later. Return the previous
* status that was shown and is no more. Calling `status()` with no argument
* removes the status altogether. The status is never shown in a
* non-interactive terminal: for example, if the output is redirected to a
* file, then we don't care too much about having a progress bar.
*/
status(format: string, ...args: Array<mixed>): string {
return this._setStatus(util.format(format, ...args));
}

/**
* Similar to `console.log`, except it moves the status/progress text out of
* the way correctly. In non-interactive terminals this is the same as
* `console.log`.
*/
log(format: string, ...args: Array<mixed>): void {
const oldStatus = this._setStatus('');
this._stream.write(util.format(format, ...args) + '\n');
this._setStatus(oldStatus);
}

/**
* Log the current status and start from scratch. This is useful if the last
* status was the last one of a series of updates.
*/
persistStatus(): void {
return this.log(this.status(''));
}

}

/**
* On the same pattern as node.js `console` module, we export the stdout-based
* terminal at the top-level, but provide access to the Terminal class as a
* field (so it can be used, for instance, with stderr).
*/
class GlobalTerminal extends Terminal {

Terminal: Class<Terminal>;

constructor() {
/* $FlowFixMe: Flow is wrong, Node.js docs specify that process.stderr is an
* instance of a net.Socket (a local socket, not network). */
super(process.stderr);
this.Terminal = Terminal;
}

}

module.exports = new GlobalTerminal();
9 changes: 5 additions & 4 deletions packager/react-packager/src/node-haste/Module.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ const fs = require('fs');
const invariant = require('invariant');
const isAbsolutePath = require('absolute-path');
const jsonStableStringify = require('json-stable-stringify');
const terminal = require('../lib/terminal');

const {join: joinPath, relative: relativePath, extname} = require('path');

Expand Down Expand Up @@ -266,13 +267,13 @@ class Module {
}
globalCache.fetch(cacheProps, (globalCacheError, globalCachedResult) => {
if (globalCacheError != null && Module._globalCacheRetries > 0) {
console.log(chalk.red(
'\nWarning: the global cache failed with error:',
terminal.log(chalk.red(
'Warning: the global cache failed with error:',
));
console.log(chalk.red(globalCacheError.stack));
terminal.log(chalk.red(globalCacheError.stack));
Module._globalCacheRetries--;
if (Module._globalCacheRetries <= 0) {
console.log(chalk.red(
terminal.log(chalk.red(
'No more retries, the global cache will be disabled for the ' +
'remainder of the transformation.',
));
Expand Down

0 comments on commit 26ed94c

Please sign in to comment.