Skip to content

Commit

Permalink
readline: add support for the AbortController to the question method
Browse files Browse the repository at this point in the history
In some cases a question asked needs to be canceled. For instance
it might be desirable to cancel a question when a user presses
ctrl+c and triggers the SIGINT event.
Also an initial empty string was set for this.line since the
cursor methods fail if line is not initialized.
Added custom promisify support to the question method.

PR-URL: #33676
Reviewed-By: Benjamin Gruenbaum <benjamingr@gmail.com>
  • Loading branch information
mattiasrunge authored and targos committed Sep 4, 2021
1 parent 02b1df9 commit e6eee08
Show file tree
Hide file tree
Showing 3 changed files with 141 additions and 9 deletions.
53 changes: 48 additions & 5 deletions doc/api/readline.md
Expand Up @@ -256,13 +256,16 @@ paused.
If the `readline.Interface` was created with `output` set to `null` or
`undefined` the prompt is not written.

### `rl.question(query, callback)`
### `rl.question(query[, options], callback)`
<!-- YAML
added: v0.3.3
-->

* `query` {string} A statement or query to write to `output`, prepended to the
prompt.
* `options` {Object}
* `signal` {AbortSignal} Optionally allows the `question()` to be canceled
using an `AbortController`.
* `callback` {Function} A callback function that is invoked with the user's
input in response to the `query`.

Expand All @@ -276,6 +279,10 @@ paused.
If the `readline.Interface` was created with `output` set to `null` or
`undefined` the `query` is not written.

The `callback` function passed to `rl.question()` does not follow the typical
pattern of accepting an `Error` object or `null` as the first argument.
The `callback` is called with the provided answer as the only argument.

Example usage:

```js
Expand All @@ -284,9 +291,41 @@ rl.question('What is your favorite food? ', (answer) => {
});
```

The `callback` function passed to `rl.question()` does not follow the typical
pattern of accepting an `Error` object or `null` as the first argument.
The `callback` is called with the provided answer as the only argument.
Using an `AbortController` to cancel a question.

```js
const ac = new AbortController();
const signal = ac.signal;

rl.question('What is your favorite food? ', { signal }, (answer) => {
console.log(`Oh, so your favorite food is ${answer}`);
});

signal.addEventListener('abort', () => {
console.log('The food question timed out');
}, { once: true });

setTimeout(() => ac.abort(), 10000);
```

If this method is invoked as it's util.promisify()ed version, it returns a
Promise that fulfills with the answer. If the question is canceled using
an `AbortController` it will reject with an `AbortError`.

```js
const util = require('util');
const question = util.promisify(rl.question).bind(rl);

async function questionExample() {
try {
const answer = await question('What is you favorite food? ');
console.log(`Oh, so your favorite food is ${answer}`);
} catch (err) {
console.error('Question rejected', err);
}
}
questionExample();
```

### `rl.resume()`
<!-- YAML
Expand Down Expand Up @@ -396,9 +435,13 @@ asynchronous iteration may result in missed lines.
### `rl.line`
<!-- YAML
added: v0.1.98
changes:
- version: REPLACEME
pr-url: https://github.com/nodejs/node/pull/33676
description: Value will always be a string, never undefined.
-->

* {string|undefined}
* {string}

The current input data being processed by node.

Expand Down
49 changes: 46 additions & 3 deletions lib/readline.js
Expand Up @@ -57,16 +57,22 @@ const {
StringPrototypeSplit,
StringPrototypeStartsWith,
StringPrototypeTrim,
Promise,
Symbol,
SymbolAsyncIterator,
SafeStringIterator,
} = primordials;

const {
AbortError,
codes
} = require('internal/errors');

const {
ERR_INVALID_CALLBACK,
ERR_INVALID_CURSOR_POS,
ERR_INVALID_OPT_VALUE
} = require('internal/errors').codes;
ERR_INVALID_OPT_VALUE,
} = codes;
const {
validateArray,
validateString,
Expand All @@ -87,6 +93,8 @@ const {
kSubstringSearch,
} = require('internal/readline/utils');

const { promisify } = require('internal/util');

const { clearTimeout, setTimeout } = require('timers');
const {
kEscape,
Expand All @@ -96,6 +104,7 @@ const {
kClearScreenDown
} = CSI;


const { StringDecoder } = require('string_decoder');

// Lazy load Readable for startup performance.
Expand Down Expand Up @@ -197,6 +206,7 @@ function Interface(input, output, completer, terminal) {

const self = this;

this.line = '';
this[kSubstringSearch] = null;
this.output = output;
this.input = input;
Expand All @@ -214,6 +224,8 @@ function Interface(input, output, completer, terminal) {
};
}

this._questionCancel = FunctionPrototypeBind(_questionCancel, this);

this.setPrompt(prompt);

this.terminal = !!terminal;
Expand Down Expand Up @@ -349,7 +361,16 @@ Interface.prototype.prompt = function(preserveCursor) {
};


Interface.prototype.question = function(query, cb) {
Interface.prototype.question = function(query, options, cb) {
cb = typeof options === 'function' ? options : cb;
options = typeof options === 'object' ? options : {};

if (options.signal) {
options.signal.addEventListener('abort', () => {
this._questionCancel();
}, { once: true });
}

if (typeof cb === 'function') {
if (this._questionCallback) {
this.prompt();
Expand All @@ -362,6 +383,28 @@ Interface.prototype.question = function(query, cb) {
}
};

Interface.prototype.question[promisify.custom] = function(query, options) {
options = typeof options === 'object' ? options : {};

return new Promise((resolve, reject) => {
this.question(query, options, resolve);

if (options.signal) {
options.signal.addEventListener('abort', () => {
reject(new AbortError());
}, { once: true });
}
});
};

function _questionCancel() {
if (this._questionCallback) {
this._questionCallback = null;
this.setPrompt(this._oldPrompt);
this.clearLine();
}
}


Interface.prototype._onLine = function(line) {
if (this._questionCallback) {
Expand Down
48 changes: 47 additions & 1 deletion test/parallel/test-readline-interface.js
Expand Up @@ -19,13 +19,14 @@
// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE
// USE OR OTHER DEALINGS IN THE SOFTWARE.

// Flags: --expose-internals
// Flags: --expose-internals --experimental-abortcontroller
'use strict';
const common = require('../common');
common.skipIfDumbTerminal();

const assert = require('assert');
const readline = require('readline');
const util = require('util');
const {
getStringWidth,
stripVTControlCharacters
Expand Down Expand Up @@ -934,6 +935,51 @@ for (let i = 0; i < 12; i++) {
rli.close();
}

// Calling the promisified question
{
const [rli] = getInterface({ terminal });
const question = util.promisify(rli.question).bind(rli);
question('foo?')
.then(common.mustCall((answer) => {
assert.strictEqual(answer, 'bar');
}));
rli.write('bar\n');
rli.close();
}

// Aborting a question
{
const ac = new AbortController();
const signal = ac.signal;
const [rli] = getInterface({ terminal });
rli.on('line', common.mustCall((line) => {
assert.strictEqual(line, 'bar');
}));
rli.question('hello?', { signal }, common.mustNotCall());
ac.abort();
rli.write('bar\n');
rli.close();
}

// Aborting a promisified question
{
const ac = new AbortController();
const signal = ac.signal;
const [rli] = getInterface({ terminal });
const question = util.promisify(rli.question).bind(rli);
rli.on('line', common.mustCall((line) => {
assert.strictEqual(line, 'bar');
}));
question('hello?', { signal })
.then(common.mustNotCall())
.catch(common.mustCall((error) => {
assert.strictEqual(error.name, 'AbortError');
}));
ac.abort();
rli.write('bar\n');
rli.close();
}

// Can create a new readline Interface with a null output argument
{
const [rli, fi] = getInterface({ output: null, terminal });
Expand Down

0 comments on commit e6eee08

Please sign in to comment.