diff --git a/doc/api/readline.md b/doc/api/readline.md index 7ea2a1d5a60552..38c4cb2f10656a 100644 --- a/doc/api/readline.md +++ b/doc/api/readline.md @@ -234,13 +234,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)` * `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`. @@ -254,6 +257,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 @@ -262,9 +269,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()` -* {string|undefined} +* {string} The current input data being processed by node. diff --git a/lib/readline.js b/lib/readline.js index 888ac81d564d11..dc6ca288bc29fd 100644 --- a/lib/readline.js +++ b/lib/readline.js @@ -57,15 +57,21 @@ const { StringPrototypeSplit, StringPrototypeStartsWith, StringPrototypeTrim, + Promise, Symbol, SymbolAsyncIterator, SafeStringIterator, } = primordials; +const { + AbortError, + codes +} = require('internal/errors'); + const { ERR_INVALID_ARG_VALUE, ERR_INVALID_CURSOR_POS, -} = require('internal/errors').codes; +} = codes; const { validateCallback, validateString, @@ -86,6 +92,8 @@ const { kSubstringSearch, } = require('internal/readline/utils'); +const { promisify } = require('internal/util'); + const { clearTimeout, setTimeout } = require('timers'); const { kEscape, @@ -95,6 +103,7 @@ const { kClearScreenDown } = CSI; + const { StringDecoder } = require('string_decoder'); // Lazy load Readable for startup performance. @@ -188,6 +197,7 @@ function Interface(input, output, completer, terminal) { const self = this; + this.line = ''; this[kSubstringSearch] = null; this.output = output; this.input = input; @@ -204,6 +214,8 @@ function Interface(input, output, completer, terminal) { }; } + this._questionCancel = FunctionPrototypeBind(_questionCancel, this); + this.setPrompt(prompt); this.terminal = !!terminal; @@ -348,7 +360,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(); @@ -361,6 +382,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) { diff --git a/test/parallel/test-readline-interface.js b/test/parallel/test-readline-interface.js index 8e32edcc49768a..db844e7ce122a1 100644 --- a/test/parallel/test-readline-interface.js +++ b/test/parallel/test-readline-interface.js @@ -26,6 +26,7 @@ common.skipIfDumbTerminal(); const assert = require('assert'); const readline = require('readline'); +const util = require('util'); const { getStringWidth, stripVTControlCharacters @@ -894,6 +895,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 });