Skip to content

Commit e6eee08

Browse files
mattiasrungetargos
authored andcommittedSep 4, 2021
readline: add support for the AbortController to the question method
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>
1 parent 02b1df9 commit e6eee08

File tree

3 files changed

+141
-9
lines changed

3 files changed

+141
-9
lines changed
 

Diff for: ‎doc/api/readline.md

+48-5
Original file line numberDiff line numberDiff line change
@@ -256,13 +256,16 @@ paused.
256256
If the `readline.Interface` was created with `output` set to `null` or
257257
`undefined` the prompt is not written.
258258

259-
### `rl.question(query, callback)`
259+
### `rl.question(query[, options], callback)`
260260
<!-- YAML
261261
added: v0.3.3
262262
-->
263263

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

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

282+
The `callback` function passed to `rl.question()` does not follow the typical
283+
pattern of accepting an `Error` object or `null` as the first argument.
284+
The `callback` is called with the provided answer as the only argument.
285+
279286
Example usage:
280287

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

287-
The `callback` function passed to `rl.question()` does not follow the typical
288-
pattern of accepting an `Error` object or `null` as the first argument.
289-
The `callback` is called with the provided answer as the only argument.
294+
Using an `AbortController` to cancel a question.
295+
296+
```js
297+
const ac = new AbortController();
298+
const signal = ac.signal;
299+
300+
rl.question('What is your favorite food? ', { signal }, (answer) => {
301+
console.log(`Oh, so your favorite food is ${answer}`);
302+
});
303+
304+
signal.addEventListener('abort', () => {
305+
console.log('The food question timed out');
306+
}, { once: true });
307+
308+
setTimeout(() => ac.abort(), 10000);
309+
```
310+
311+
If this method is invoked as it's util.promisify()ed version, it returns a
312+
Promise that fulfills with the answer. If the question is canceled using
313+
an `AbortController` it will reject with an `AbortError`.
314+
315+
```js
316+
const util = require('util');
317+
const question = util.promisify(rl.question).bind(rl);
318+
319+
async function questionExample() {
320+
try {
321+
const answer = await question('What is you favorite food? ');
322+
console.log(`Oh, so your favorite food is ${answer}`);
323+
} catch (err) {
324+
console.error('Question rejected', err);
325+
}
326+
}
327+
questionExample();
328+
```
290329

291330
### `rl.resume()`
292331
<!-- YAML
@@ -396,9 +435,13 @@ asynchronous iteration may result in missed lines.
396435
### `rl.line`
397436
<!-- YAML
398437
added: v0.1.98
438+
changes:
439+
- version: REPLACEME
440+
pr-url: https://github.com/nodejs/node/pull/33676
441+
description: Value will always be a string, never undefined.
399442
-->
400443

401-
* {string|undefined}
444+
* {string}
402445

403446
The current input data being processed by node.
404447

Diff for: ‎lib/readline.js

+46-3
Original file line numberDiff line numberDiff line change
@@ -57,16 +57,22 @@ const {
5757
StringPrototypeSplit,
5858
StringPrototypeStartsWith,
5959
StringPrototypeTrim,
60+
Promise,
6061
Symbol,
6162
SymbolAsyncIterator,
6263
SafeStringIterator,
6364
} = primordials;
6465

66+
const {
67+
AbortError,
68+
codes
69+
} = require('internal/errors');
70+
6571
const {
6672
ERR_INVALID_CALLBACK,
6773
ERR_INVALID_CURSOR_POS,
68-
ERR_INVALID_OPT_VALUE
69-
} = require('internal/errors').codes;
74+
ERR_INVALID_OPT_VALUE,
75+
} = codes;
7076
const {
7177
validateArray,
7278
validateString,
@@ -87,6 +93,8 @@ const {
8793
kSubstringSearch,
8894
} = require('internal/readline/utils');
8995

96+
const { promisify } = require('internal/util');
97+
9098
const { clearTimeout, setTimeout } = require('timers');
9199
const {
92100
kEscape,
@@ -96,6 +104,7 @@ const {
96104
kClearScreenDown
97105
} = CSI;
98106

107+
99108
const { StringDecoder } = require('string_decoder');
100109

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

198207
const self = this;
199208

209+
this.line = '';
200210
this[kSubstringSearch] = null;
201211
this.output = output;
202212
this.input = input;
@@ -214,6 +224,8 @@ function Interface(input, output, completer, terminal) {
214224
};
215225
}
216226

227+
this._questionCancel = FunctionPrototypeBind(_questionCancel, this);
228+
217229
this.setPrompt(prompt);
218230

219231
this.terminal = !!terminal;
@@ -349,7 +361,16 @@ Interface.prototype.prompt = function(preserveCursor) {
349361
};
350362

351363

352-
Interface.prototype.question = function(query, cb) {
364+
Interface.prototype.question = function(query, options, cb) {
365+
cb = typeof options === 'function' ? options : cb;
366+
options = typeof options === 'object' ? options : {};
367+
368+
if (options.signal) {
369+
options.signal.addEventListener('abort', () => {
370+
this._questionCancel();
371+
}, { once: true });
372+
}
373+
353374
if (typeof cb === 'function') {
354375
if (this._questionCallback) {
355376
this.prompt();
@@ -362,6 +383,28 @@ Interface.prototype.question = function(query, cb) {
362383
}
363384
};
364385

386+
Interface.prototype.question[promisify.custom] = function(query, options) {
387+
options = typeof options === 'object' ? options : {};
388+
389+
return new Promise((resolve, reject) => {
390+
this.question(query, options, resolve);
391+
392+
if (options.signal) {
393+
options.signal.addEventListener('abort', () => {
394+
reject(new AbortError());
395+
}, { once: true });
396+
}
397+
});
398+
};
399+
400+
function _questionCancel() {
401+
if (this._questionCallback) {
402+
this._questionCallback = null;
403+
this.setPrompt(this._oldPrompt);
404+
this.clearLine();
405+
}
406+
}
407+
365408

366409
Interface.prototype._onLine = function(line) {
367410
if (this._questionCallback) {

Diff for: ‎test/parallel/test-readline-interface.js

+47-1
Original file line numberDiff line numberDiff line change
@@ -19,13 +19,14 @@
1919
// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE
2020
// USE OR OTHER DEALINGS IN THE SOFTWARE.
2121

22-
// Flags: --expose-internals
22+
// Flags: --expose-internals --experimental-abortcontroller
2323
'use strict';
2424
const common = require('../common');
2525
common.skipIfDumbTerminal();
2626

2727
const assert = require('assert');
2828
const readline = require('readline');
29+
const util = require('util');
2930
const {
3031
getStringWidth,
3132
stripVTControlCharacters
@@ -934,6 +935,51 @@ for (let i = 0; i < 12; i++) {
934935
rli.close();
935936
}
936937

938+
// Calling the promisified question
939+
{
940+
const [rli] = getInterface({ terminal });
941+
const question = util.promisify(rli.question).bind(rli);
942+
question('foo?')
943+
.then(common.mustCall((answer) => {
944+
assert.strictEqual(answer, 'bar');
945+
}));
946+
rli.write('bar\n');
947+
rli.close();
948+
}
949+
950+
// Aborting a question
951+
{
952+
const ac = new AbortController();
953+
const signal = ac.signal;
954+
const [rli] = getInterface({ terminal });
955+
rli.on('line', common.mustCall((line) => {
956+
assert.strictEqual(line, 'bar');
957+
}));
958+
rli.question('hello?', { signal }, common.mustNotCall());
959+
ac.abort();
960+
rli.write('bar\n');
961+
rli.close();
962+
}
963+
964+
// Aborting a promisified question
965+
{
966+
const ac = new AbortController();
967+
const signal = ac.signal;
968+
const [rli] = getInterface({ terminal });
969+
const question = util.promisify(rli.question).bind(rli);
970+
rli.on('line', common.mustCall((line) => {
971+
assert.strictEqual(line, 'bar');
972+
}));
973+
question('hello?', { signal })
974+
.then(common.mustNotCall())
975+
.catch(common.mustCall((error) => {
976+
assert.strictEqual(error.name, 'AbortError');
977+
}));
978+
ac.abort();
979+
rli.write('bar\n');
980+
rli.close();
981+
}
982+
937983
// Can create a new readline Interface with a null output argument
938984
{
939985
const [rli, fi] = getInterface({ output: null, terminal });

0 commit comments

Comments
 (0)
Please sign in to comment.