Skip to content

Commit 777266b

Browse files
committed
refactor(inquirer): remove rxjs runtime dependency
1 parent b3588be commit 777266b

6 files changed

Lines changed: 516 additions & 63 deletions

File tree

packages/inquirer/README.md

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,7 @@ yarn node packages/inquirer/examples/checkbox.js
117117

118118
Launch the prompt interface (inquiry session)
119119

120-
- **questions** (Array) containing [Question Object](#question) (using the [reactive interface](#reactive-interface), you can also pass a `Rx.Observable` instance)
120+
- **questions** a [Question Object](#question), an array or map of questions, or an RxJS-compatible Observable of questions
121121
- **answers** (object) contains values of already answered questions. Inquirer will avoid asking answers already provided here. Defaults `{}`.
122122
- returns a **Promise**
123123

@@ -328,9 +328,7 @@ The `postfix` property is useful if you want to provide an extension.
328328

329329
## Reactive interface
330330

331-
Internally, Inquirer uses the [JS reactive extension](https://github.com/ReactiveX/rxjs) to handle events and async flows.
332-
333-
This mean you can take advantage of this feature to provide more advanced flows. For example, you can dynamically add questions to be asked:
331+
`inquirer.prompt()` accepts an RxJS-compatible Observable of questions. This supports dynamic flows where questions are emitted over time:
334332

335333
```js
336334
const prompts = new Rx.Subject();

packages/inquirer/inquirer.test.ts

Lines changed: 72 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import stream from 'node:stream';
99
import tty from 'node:tty';
1010
import readline from 'node:readline';
1111
import { vi, expect, beforeEach, afterEach, describe, it, expectTypeOf } from 'vitest';
12-
import { of } from 'rxjs';
12+
import { from as observableFrom, map, of, Subject } from 'rxjs';
1313
import { AbortPromptError, createPrompt } from '@inquirer/core';
1414
import type { InquirerReadline } from '@inquirer/type';
1515
import inquirer from './src/index.ts';
@@ -254,6 +254,50 @@ describe('inquirer.prompt(...)', () => {
254254
expect(answers).toEqual({ q1: true, q2: false });
255255
expectTypeOf(answers).toEqualTypeOf<{ q1: any; q2: any }>();
256256
});
257+
258+
it('takes an Observable that emits questions over time', async () => {
259+
const questions = new Subject<Question & { answer: boolean }>();
260+
const processEvents: unknown[] = [];
261+
262+
const promise = inquirer.prompt(questions);
263+
promise.ui.process.subscribe((answer) => {
264+
processEvents.push(answer);
265+
});
266+
267+
questions.next({
268+
type: 'stub',
269+
name: 'q1',
270+
message: 'message',
271+
answer: true,
272+
});
273+
questions.next({
274+
type: 'stub',
275+
name: 'q2',
276+
message: 'message',
277+
answer: false,
278+
});
279+
questions.complete();
280+
281+
await expect(promise).resolves.toEqual({ q1: true, q2: false });
282+
expect(processEvents).toEqual([
283+
{ name: 'q1', answer: true },
284+
{ name: 'q2', answer: false },
285+
]);
286+
});
287+
288+
it('rejects when an Observable question source errors', async () => {
289+
const questions = new Subject<Question>();
290+
const error = new Error('Question source failed');
291+
const onError = vi.fn();
292+
293+
const promise = inquirer.prompt(questions);
294+
promise.ui.process.subscribe({ error: onError });
295+
296+
questions.error(error);
297+
298+
await expect(promise).rejects.toBe(error);
299+
expect(onError).toHaveBeenCalledWith(error);
300+
});
257301
});
258302

259303
it("should close and create a new readline instances each time it's called", async () => {
@@ -638,6 +682,33 @@ describe('inquirer.prompt(...)', () => {
638682
expect(spy).toHaveBeenCalledWith({ name: 'name', answer: 'doe' });
639683
});
640684

685+
it('should expose an RxJS-compatible Reactive interface', async () => {
686+
const processEvents: string[] = [];
687+
688+
const promise = inquirer.prompt([
689+
{
690+
type: 'stub',
691+
name: 'name1',
692+
message: 'message',
693+
answer: 'bar',
694+
},
695+
{
696+
type: 'stub',
697+
name: 'name',
698+
message: 'message',
699+
answer: 'doe',
700+
},
701+
]);
702+
observableFrom(promise.ui.process)
703+
.pipe(map(({ name, answer }) => `${name}:${String(answer)}`))
704+
.subscribe((answer) => {
705+
processEvents.push(answer);
706+
});
707+
708+
await promise;
709+
expect(processEvents).toEqual(['name1:bar', 'name:doe']);
710+
});
711+
641712
it('should expose the UI', async () => {
642713
const promise = inquirer.prompt([]);
643714
expect(promise.ui.answers).toBeTypeOf('object');

packages/inquirer/package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -78,12 +78,12 @@
7878
"@inquirer/prompts": "^8.4.3",
7979
"@inquirer/type": "^4.0.5",
8080
"mute-stream": "^4.0.0",
81-
"run-async": "^4.0.6",
82-
"rxjs": "^7.8.2"
81+
"run-async": "^4.0.6"
8382
},
8483
"devDependencies": {
8584
"@repo/tsconfig": "workspace:*",
8685
"@types/mute-stream": "^0.0.4",
86+
"rxjs": "^7.8.2",
8787
"typescript": "^6.0.2"
8888
},
8989
"peerDependencies": {

packages/inquirer/src/types.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import {
1212
select,
1313
} from '@inquirer/prompts';
1414
import type { Context, DistributiveMerge, Prettify } from '@inquirer/type';
15-
import { Observable } from 'rxjs';
15+
import type { Observable } from './utils/observable.ts';
1616

1717
export type Answers<Key extends string = string> = Record<Key, any>;
1818

packages/inquirer/src/ui/prompt.ts

Lines changed: 42 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,5 @@
11
/* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-return, @typescript-eslint/no-unsafe-assignment */
22
import readline from 'node:readline';
3-
import {
4-
defer,
5-
EMPTY,
6-
from,
7-
of,
8-
concatMap,
9-
filter,
10-
reduce,
11-
isObservable,
12-
Observable,
13-
lastValueFrom,
14-
} from 'rxjs';
153
import runAsync from 'run-async';
164
import MuteStream from 'mute-stream';
175
import { AbortPromptError } from '@inquirer/core';
@@ -24,6 +12,13 @@ import type {
2412
PromptSession,
2513
StreamOptions,
2614
} from '../types.ts';
15+
import {
16+
EMPTY,
17+
createObservableController,
18+
isObservableLike,
19+
observableToAsyncIterable,
20+
} from '../utils/observable.ts';
21+
import type { InteropObservable } from '../utils/observable.ts';
2722

2823
export const _ = {
2924
set: (obj: Record<string, unknown>, path: string = '', value: unknown): void => {
@@ -139,6 +134,8 @@ class UnknownPromptTypeError extends Error {
139134
}
140135
}
141136

137+
type AnswerEvent = { name: string; answer: unknown };
138+
142139
class TTYError extends Error {
143140
override name = 'TTYError';
144141
isTtyError = true;
@@ -208,7 +205,7 @@ function isPromptConstructor(
208205
export default class PromptsRunner<A extends Answers> {
209206
private prompts: PromptCollection;
210207
answers: Partial<A> = {};
211-
process: Observable<any> = EMPTY;
208+
process: InteropObservable<AnswerEvent> = EMPTY;
212209
private abortController: AbortController = new AbortController();
213210
private opt: StreamOptions;
214211

@@ -219,58 +216,48 @@ export default class PromptsRunner<A extends Answers> {
219216

220217
async run(questions: PromptSession<A>, answers?: Partial<A>): Promise<A> {
221218
this.abortController = new AbortController();
219+
const processController = createObservableController<AnswerEvent>();
220+
this.process = processController.observable;
222221

223222
// Keep global reference to the answers
224223
this.answers = typeof answers === 'object' ? { ...answers } : {};
225224

226-
let obs: Observable<Question<A>>;
225+
try {
226+
for await (const question of this.getQuestions(questions)) {
227+
if (await this.shouldRun(question)) {
228+
const answer = await this.fetchAnswer(question);
229+
_.set(this.answers, answer.name, answer.answer);
230+
processController.next(answer);
231+
}
232+
}
233+
234+
processController.complete();
235+
// oxlint-disable-next-line typescript/no-unsafe-type-assertion
236+
return this.answers as A;
237+
} catch (error: unknown) {
238+
processController.error(error);
239+
throw error;
240+
} finally {
241+
this.close();
242+
}
243+
}
244+
245+
private async *getQuestions(
246+
questions: PromptSession<A>,
247+
): AsyncGenerator<Question<A>, void> {
227248
if (isQuestionArray(questions)) {
228-
obs = from(questions);
229-
} else if (isObservable(questions)) {
230-
obs = questions;
249+
yield* questions;
250+
} else if (isObservableLike(questions)) {
251+
yield* observableToAsyncIterable(questions);
231252
} else if (isQuestionMap(questions)) {
232253
// Case: Called with a set of { name: question }
233-
obs = from(
234-
Object.entries(questions).map(([name, question]): Question<A> => {
235-
return Object.assign({}, question, { name });
236-
}),
237-
);
254+
for (const [name, question] of Object.entries(questions)) {
255+
yield Object.assign({}, question, { name });
256+
}
238257
} else {
239258
// Case: Called with a single question config
240-
obs = from([questions]);
259+
yield questions;
241260
}
242-
243-
this.process = obs.pipe(
244-
concatMap((question) =>
245-
of(question).pipe(
246-
concatMap((question) =>
247-
from(
248-
this.shouldRun(question).then((shouldRun: boolean | void) => {
249-
if (shouldRun) {
250-
return question;
251-
}
252-
return undefined;
253-
}),
254-
).pipe(filter((val) => val != null)),
255-
),
256-
concatMap((question) => defer(() => from(this.fetchAnswer(question)))),
257-
),
258-
),
259-
);
260-
261-
return (
262-
lastValueFrom(
263-
this.process.pipe(
264-
reduce((answersObj, answer: { name: string; answer: unknown }) => {
265-
_.set(answersObj, answer.name, answer.answer);
266-
return answersObj;
267-
}, this.answers),
268-
),
269-
)
270-
// oxlint-disable-next-line typescript/no-unsafe-type-assertion
271-
.then(() => this.answers as A)
272-
.finally(() => this.close())
273-
);
274261
}
275262

276263
private prepareQuestion = async (question: Question<A>) => {

0 commit comments

Comments
 (0)