Skip to content

Commit 4e757ea

Browse files
mattiasrungetargos
authored andcommitted
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 <[email protected]>
1 parent 7b78c67 commit 4e757ea

File tree

3 files changed

+139
-7
lines changed

3 files changed

+139
-7
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

+45-2
Original file line numberDiff line numberDiff line change
@@ -57,15 +57,21 @@ 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_ARG_VALUE,
6773
ERR_INVALID_CURSOR_POS,
68-
} = require('internal/errors').codes;
74+
} = codes;
6975
const {
7076
validateCallback,
7177
validateString,
@@ -86,6 +92,8 @@ const {
8692
kSubstringSearch,
8793
} = require('internal/readline/utils');
8894

95+
const { promisify } = require('internal/util');
96+
8997
const { clearTimeout, setTimeout } = require('timers');
9098
const {
9199
kEscape,
@@ -95,6 +103,7 @@ const {
95103
kClearScreenDown
96104
} = CSI;
97105

106+
98107
const { StringDecoder } = require('string_decoder');
99108

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

189198
const self = this;
190199

200+
this.line = '';
191201
this[kSubstringSearch] = null;
192202
this.output = output;
193203
this.input = input;
@@ -204,6 +214,8 @@ function Interface(input, output, completer, terminal) {
204214
};
205215
}
206216

217+
this._questionCancel = FunctionPrototypeBind(_questionCancel, this);
218+
207219
this.setPrompt(prompt);
208220

209221
this.terminal = !!terminal;
@@ -340,7 +352,16 @@ Interface.prototype.prompt = function(preserveCursor) {
340352
};
341353

342354

343-
Interface.prototype.question = function(query, cb) {
355+
Interface.prototype.question = function(query, options, cb) {
356+
cb = typeof options === 'function' ? options : cb;
357+
options = typeof options === 'object' ? options : {};
358+
359+
if (options.signal) {
360+
options.signal.addEventListener('abort', () => {
361+
this._questionCancel();
362+
}, { once: true });
363+
}
364+
344365
if (typeof cb === 'function') {
345366
if (this._questionCallback) {
346367
this.prompt();
@@ -353,6 +374,28 @@ Interface.prototype.question = function(query, cb) {
353374
}
354375
};
355376

377+
Interface.prototype.question[promisify.custom] = function(query, options) {
378+
options = typeof options === 'object' ? options : {};
379+
380+
return new Promise((resolve, reject) => {
381+
this.question(query, options, resolve);
382+
383+
if (options.signal) {
384+
options.signal.addEventListener('abort', () => {
385+
reject(new AbortError());
386+
}, { once: true });
387+
}
388+
});
389+
};
390+
391+
function _questionCancel() {
392+
if (this._questionCallback) {
393+
this._questionCallback = null;
394+
this.setPrompt(this._oldPrompt);
395+
this.clearLine();
396+
}
397+
}
398+
356399

357400
Interface.prototype._onLine = function(line) {
358401
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)