Skip to content

Commit

Permalink
feat(@inquirer/core): add AbortSignal to the context to programatical…
Browse files Browse the repository at this point in the history
…ly cancel a prompt (#1534)

Co-authored-by: Marcelo Shima <marceloshima@gmail.com>

Ref #1521
  • Loading branch information
SBoudrias committed Sep 2, 2024
1 parent cc1f4e0 commit 9b60356
Show file tree
Hide file tree
Showing 6 changed files with 110 additions and 39 deletions.
46 changes: 46 additions & 0 deletions packages/core/core.test.mts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
isEnterKey,
isSpaceKey,
Separator,
AbortPromptError,
CancelPromptError,
ValidationError,
HookError,
Expand Down Expand Up @@ -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 = () => {
Expand Down
14 changes: 12 additions & 2 deletions packages/core/src/lib/create-prompt.mts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Value, Config> = (
config: Prettify<Config>,
Expand All @@ -16,7 +16,7 @@ type ViewFunction<Value, Config> = (
export function createPrompt<Value, Config>(view: ViewFunction<Value, Config>) {
const prompt: Prompt<Value, Config> = (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();
Expand Down Expand Up @@ -44,6 +44,16 @@ export function createPrompt<Value, Config>(view: ViewFunction<Value, Config>) {
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) => {
Expand Down
10 changes: 10 additions & 0 deletions packages/core/src/lib/errors.mts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down
25 changes: 8 additions & 17 deletions packages/demo/demos/timeout.mjs
Original file line number Diff line number Diff line change
@@ -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);
}

Expand Down
53 changes: 33 additions & 20 deletions packages/prompts/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions packages/type/src/inquirer.mts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ export type Context = {
input?: NodeJS.ReadableStream;
output?: NodeJS.WritableStream;
clearPromptOnDone?: boolean;
signal?: AbortSignal;
};

export type Prompt<Value, Config> = (
Expand Down

0 comments on commit 9b60356

Please sign in to comment.