Skip to content

Commit aaa9214

Browse files
committed
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.
1 parent e279304 commit aaa9214

File tree

3 files changed

+137
-6
lines changed

3 files changed

+137
-6
lines changed

doc/api/readline.md

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

237-
### `rl.question(query, callback)`
237+
### `rl.question(query[, options], callback)`
238238
<!-- YAML
239239
added: v0.3.3
240240
-->
241241

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

@@ -254,6 +257,10 @@ paused.
254257
If the `readline.Interface` was created with `output` set to `null` or
255258
`undefined` the `query` is not written.
256259

260+
The `callback` function passed to `rl.question()` does not follow the typical
261+
pattern of accepting an `Error` object or `null` as the first argument.
262+
The `callback` is called with the provided answer as the only argument.
263+
257264
Example usage:
258265

259266
```js
@@ -262,9 +269,41 @@ rl.question('What is your favorite food? ', (answer) => {
262269
});
263270
```
264271

265-
The `callback` function passed to `rl.question()` does not follow the typical
266-
pattern of accepting an `Error` object or `null` as the first argument.
267-
The `callback` is called with the provided answer as the only argument.
272+
Using an `AbortController` to cancel a question.
273+
274+
```js
275+
const ac = new AbortController();
276+
const signal = ac.signal;
277+
278+
rl.question('What is your favorite food? ', { signal }, (answer) => {
279+
console.log(`Oh, so your favorite food is ${answer}`);
280+
});
281+
282+
signal.addEventListener('abort', () => {
283+
console.log('The food question timed out');
284+
}, { once: true });
285+
286+
setTimeout(() => ac.abort(), 10000);
287+
```
288+
289+
If this method is invoked as it's util.promisify()ed version, it returns a
290+
Promise that fulfills with the answer. If the question is canceled using
291+
an `AbortController` it will reject with an `AbortError`.
292+
293+
```js
294+
const util = require('util');
295+
const question = util.promisify(rl.question).bind(rl);
296+
297+
async function questionExample() {
298+
try {
299+
const answer = await question('What is you favorite food? ');
300+
console.log(`Oh, so your favorite food is ${answer}`);
301+
} catch (err) {
302+
console.error('Question rejected', err);
303+
}
304+
}
305+
questionExample();
306+
```
268307

269308
### `rl.resume()`
270309
<!-- YAML
@@ -374,9 +413,13 @@ asynchronous iteration may result in missed lines.
374413
### `rl.line`
375414
<!-- YAML
376415
added: v0.1.98
416+
changes:
417+
- version: REPLACEME
418+
pr-url: https://github.com/nodejs/node/pull/33676
419+
description: Value will always be a string, never undefined.
377420
-->
378421

379-
* {string|undefined}
422+
* {string}
380423

381424
The current input data being processed by node.
382425

lib/readline.js

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

66+
const {
67+
AbortError
68+
} = require('internal/errors');
69+
6570
const {
6671
ERR_INVALID_ARG_VALUE,
6772
ERR_INVALID_CURSOR_POS,
@@ -86,6 +91,8 @@ const {
8691
kSubstringSearch,
8792
} = require('internal/readline/utils');
8893

94+
const { promisify } = require('internal/util');
95+
8996
const { clearTimeout, setTimeout } = require('timers');
9097
const {
9198
kEscape,
@@ -95,6 +102,7 @@ const {
95102
kClearScreenDown
96103
} = CSI;
97104

105+
98106
const { StringDecoder } = require('string_decoder');
99107

100108
// Lazy load Readable for startup performance.
@@ -188,6 +196,7 @@ function Interface(input, output, completer, terminal) {
188196

189197
const self = this;
190198

199+
this.line = '';
191200
this[kSubstringSearch] = null;
192201
this.output = output;
193202
this.input = input;
@@ -204,6 +213,8 @@ function Interface(input, output, completer, terminal) {
204213
};
205214
}
206215

216+
this._questionCancel = FunctionPrototypeBind(_questionCancel, this);
217+
207218
this.setPrompt(prompt);
208219

209220
this.terminal = !!terminal;
@@ -348,7 +359,16 @@ Interface.prototype.prompt = function(preserveCursor) {
348359
};
349360

350361

351-
Interface.prototype.question = function(query, cb) {
362+
Interface.prototype.question = function(query, options, cb) {
363+
cb = typeof options === 'function' ? options : cb;
364+
options = typeof options === 'object' ? options : {};
365+
366+
if (options.signal) {
367+
options.signal.addEventListener('abort', () => {
368+
this._questionCancel();
369+
}, { once: true });
370+
}
371+
352372
if (typeof cb === 'function') {
353373
if (this._questionCallback) {
354374
this.prompt();
@@ -361,6 +381,28 @@ Interface.prototype.question = function(query, cb) {
361381
}
362382
};
363383

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

365407
Interface.prototype._onLine = function(line) {
366408
if (this._questionCallback) {

test/parallel/test-readline-interface.js

+46
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ common.skipIfDumbTerminal();
2626

2727
const assert = require('assert');
2828
const readline = require('readline');
29+
const util = require('util');
2930
const {
3031
getStringWidth,
3132
stripVTControlCharacters
@@ -894,6 +895,51 @@ for (let i = 0; i < 12; i++) {
894895
rli.close();
895896
}
896897

898+
// Calling the promisified question
899+
{
900+
const [rli] = getInterface({ terminal });
901+
const question = util.promisify(rli.question).bind(rli);
902+
question('foo?')
903+
.then(common.mustCall((answer) => {
904+
assert.strictEqual(answer, 'bar');
905+
}));
906+
rli.write('bar\n');
907+
rli.close();
908+
}
909+
910+
// Aborting a question
911+
{
912+
const ac = new AbortController();
913+
const signal = ac.signal;
914+
const [rli] = getInterface({ terminal });
915+
rli.on('line', common.mustCall((line) => {
916+
assert.strictEqual(line, 'bar');
917+
}));
918+
rli.question('hello?', { signal }, common.mustNotCall());
919+
ac.abort();
920+
rli.write('bar\n');
921+
rli.close();
922+
}
923+
924+
// Aborting a promisified question
925+
{
926+
const ac = new AbortController();
927+
const signal = ac.signal;
928+
const [rli] = getInterface({ terminal });
929+
const question = util.promisify(rli.question).bind(rli);
930+
rli.on('line', common.mustCall((line) => {
931+
assert.strictEqual(line, 'bar');
932+
}));
933+
question('hello?', { signal })
934+
.then(common.mustNotCall())
935+
.catch(common.mustCall((error) => {
936+
assert.strictEqual(error.name, 'AbortError');
937+
}));
938+
ac.abort();
939+
rli.write('bar\n');
940+
rli.close();
941+
}
942+
897943
// Can create a new readline Interface with a null output argument
898944
{
899945
const [rli, fi] = getInterface({ output: null, terminal });

0 commit comments

Comments
 (0)