From 9b603563129b67e6c904ad01b26a7b3a9bb070f9 Mon Sep 17 00:00:00 2001 From: Simon Boudrias Date: Mon, 2 Sep 2024 15:31:52 -0400 Subject: [PATCH] feat(@inquirer/core): add AbortSignal to the context to programatically cancel a prompt (#1534) Co-authored-by: Marcelo Shima Ref #1521 --- packages/core/core.test.mts | 46 +++++++++++++++++++++ packages/core/src/lib/create-prompt.mts | 14 ++++++- packages/core/src/lib/errors.mts | 10 +++++ packages/demo/demos/timeout.mjs | 25 ++++-------- packages/prompts/README.md | 53 +++++++++++++++---------- packages/type/src/inquirer.mts | 1 + 6 files changed, 110 insertions(+), 39 deletions(-) diff --git a/packages/core/core.test.mts b/packages/core/core.test.mts index ba1161d3d..1e56eb295 100644 --- a/packages/core/core.test.mts +++ b/packages/core/core.test.mts @@ -18,6 +18,7 @@ import { isEnterKey, isSpaceKey, Separator, + AbortPromptError, CancelPromptError, ValidationError, HookError, @@ -567,6 +568,51 @@ it('allow cancelling the prompt multiple times', async () => { await expect(answer).rejects.toThrow(CancelPromptError); }); +it('allow aborting the prompt using signals', async () => { + const Prompt = (config: { message: string }, done: (value: string) => void) => { + useKeypress((key: KeypressEvent) => { + if (isEnterKey(key)) { + done('done'); + } + }); + + return config.message; + }; + + const prompt = createPrompt(Prompt); + const abortController = new AbortController(); + const { answer } = await render( + prompt, + { message: 'Question' }, + { signal: abortController.signal }, + ); + + abortController.abort(); + + await expect(answer).rejects.toThrow(AbortPromptError); +}); + +it('fail on aborted signals', async () => { + const Prompt = (config: { message: string }, done: (value: string) => void) => { + useKeypress((key: KeypressEvent) => { + if (isEnterKey(key)) { + done('done'); + } + }); + + return config.message; + }; + + const prompt = createPrompt(Prompt); + const { answer } = await render( + prompt, + { message: 'Question' }, + { signal: AbortSignal.abort() }, + ); + + await expect(answer).rejects.toThrow(AbortPromptError); +}); + describe('Error handling', () => { it('surface errors in render functions', async () => { const Prompt = () => { diff --git a/packages/core/src/lib/create-prompt.mts b/packages/core/src/lib/create-prompt.mts index 121fade0d..2cbe5ebc3 100644 --- a/packages/core/src/lib/create-prompt.mts +++ b/packages/core/src/lib/create-prompt.mts @@ -6,7 +6,7 @@ import { onExit as onSignalExit } from 'signal-exit'; import ScreenManager from './screen-manager.mjs'; import { CancelablePromise, type InquirerReadline } from '@inquirer/type'; import { withHooks, effectScheduler } from './hook-engine.mjs'; -import { CancelPromptError, ExitPromptError } from './errors.mjs'; +import { AbortPromptError, CancelPromptError, ExitPromptError } from './errors.mjs'; type ViewFunction = ( config: Prettify, @@ -16,7 +16,7 @@ type ViewFunction = ( export function createPrompt(view: ViewFunction) { const prompt: Prompt = (config, context = {}) => { // Default `input` to stdin - const { input = process.stdin } = context; + const { input = process.stdin, signal } = context; // Add mute capabilities to the output const output = new MuteStream(); @@ -44,6 +44,16 @@ export function createPrompt(view: ViewFunction) { reject(error); } + if (signal) { + const abort = () => fail(new AbortPromptError({ cause: signal.reason })); + if (signal.aborted) { + abort(); + return promise; + } + signal.addEventListener('abort', abort); + cleanups.add(() => signal.removeEventListener('abort', abort)); + } + withHooks(rl, (cycle) => { cleanups.add( onSignalExit((code, signal) => { diff --git a/packages/core/src/lib/errors.mts b/packages/core/src/lib/errors.mts index 2ca279626..b4416957a 100644 --- a/packages/core/src/lib/errors.mts +++ b/packages/core/src/lib/errors.mts @@ -1,3 +1,13 @@ +export class AbortPromptError extends Error { + override name = 'AbortPromptError'; + override message = 'Prompt was aborted'; + + constructor(options?: { cause?: unknown }) { + super(); + this.cause = options?.cause; + } +} + export class CancelPromptError extends Error { override name = 'CancelPromptError'; override message = 'Prompt was canceled'; diff --git a/packages/demo/demos/timeout.mjs b/packages/demo/demos/timeout.mjs index 1b82c3468..822047fed 100644 --- a/packages/demo/demos/timeout.mjs +++ b/packages/demo/demos/timeout.mjs @@ -1,26 +1,17 @@ import * as url from 'node:url'; -import { setTimeout } from 'node:timers/promises'; import { input } from '@inquirer/prompts'; async function demo() { - const ac = new AbortController(); - const prompt = input({ - message: 'Enter a value (timing out in 5 seconds)', - }); - - prompt - .finally(() => { - ac.abort(); - }) - // Silencing the cancellation error. - .catch(() => {}); + const answer = await input( + { message: 'Enter a value (timing out in 5 seconds)' }, + { signal: AbortSignal.timeout(5000) }, + ).catch((error) => { + if (error.name === 'AbortPromptError') { + return 'Default value'; + } - const defaultValue = setTimeout(5000, 'timeout', { signal: ac.signal }).then(() => { - prompt.cancel(); - return 'Timed out!'; + throw error; }); - - const answer = await Promise.race([defaultValue, prompt]); console.log('Answer:', answer); } diff --git a/packages/prompts/README.md b/packages/prompts/README.md index 93148ffa9..6c07179a2 100644 --- a/packages/prompts/README.md +++ b/packages/prompts/README.md @@ -169,6 +169,7 @@ The context options are: | input | `NodeJS.ReadableStream` | no | The stdin stream (defaults to `process.stdin`) | | output | `NodeJS.WritableStream` | no | The stdout stream (defaults to `process.stdout`) | | clearPromptOnDone | `boolean` | no | If true, we'll clear the screen after the prompt is answered | +| signal | `AbortSignal` | no | An AbortSignal to cancel prompts asynchronously | Example: @@ -191,16 +192,37 @@ const allowEmail = await confirm( ## Canceling prompt -All prompt functions are returning a cancelable promise. This special promise type has a `cancel` method that'll cancel and cleanup the prompt. +This can preferably be done with either an `AbortController` or `AbortSignal`. + +```js +// Example 1: using built-in AbortSignal utilities +import { confirm } from '@inquirer/prompts'; + +const answer = await confirm({ ... }, { signal: AbortSignal.timeout(5000) }); +``` + +```js +// Example 1: implementing custom cancellation logic +import { confirm } from '@inquirer/prompts'; + +const controller = new AbortController(); +setTimeout(() => { + controller.abort(); // This will reject the promise +}, 5000); + +const answer = await confirm({ ... }, { signal: controller.signal }); +``` + +Alternatively, all prompt functions are returning a cancelable promise. This special promise type has a `cancel` method that'll cancel and cleanup the prompt. On calling `cancel`, the answer promise will become rejected. ```js import { confirm } from '@inquirer/prompts'; -const answer = confirm(...); // note: for this you cannot use `await` +const promise = confirm(...); // Warning: for this pattern to work, `await` cannot be used. -answer.cancel(); +promise.cancel(); ``` # Recipes @@ -238,27 +260,18 @@ if (allowEmail) { ## Get default value after timeout ```js -import { setTimeout } from 'node:timers/promises'; import { input } from '@inquirer/prompts'; -const ac = new AbortController(); -const prompt = input({ - message: 'Enter a value (timing out in 5 seconds)', -}); - -prompt - .finally(() => { - ac.abort(); - }) - // Silencing the cancellation error. - .catch(() => {}); +const answer = await input( + { message: 'Enter a value (timing out in 5 seconds)' }, + { signal: AbortSignal.timeout(5000) }, +).catch((error) => { + if (error.name === 'AbortPromptError') { + return 'Default value'; + } -const defaultValue = setTimeout(5000, 'timeout', { signal: ac.signal }).then(() => { - prompt.cancel(); - return 'Timed out!'; + throw error; }); - -const answer = await Promise.race([defaultValue, prompt]); ``` ## Using as pre-commit/git hooks, or scripts diff --git a/packages/type/src/inquirer.mts b/packages/type/src/inquirer.mts index 3d42369e3..282fada41 100644 --- a/packages/type/src/inquirer.mts +++ b/packages/type/src/inquirer.mts @@ -26,6 +26,7 @@ export type Context = { input?: NodeJS.ReadableStream; output?: NodeJS.WritableStream; clearPromptOnDone?: boolean; + signal?: AbortSignal; }; export type Prompt = (