From d6b1ca966978d7bc63e270b4ca45de528432a14d Mon Sep 17 00:00:00 2001 From: Mattias Runge-Broberg Date: Sun, 31 May 2020 20:54:41 +0200 Subject: [PATCH] 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. --- doc/api/readline.md | 53 +++++++++++++++++++++--- lib/readline.js | 47 ++++++++++++++++++++- test/parallel/test-readline-interface.js | 46 ++++++++++++++++++++ 3 files changed, 139 insertions(+), 7 deletions(-) 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 });