-
Notifications
You must be signed in to change notification settings - Fork 113
feat(toolkit-lib): withListeners API for IoHost
#1708
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
sai-ray
wants to merge
17
commits into
main
Choose a base branch
from
sai/iohost-public-listener-api
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
17 commits
Select commit
Hold shift + click to select a range
74815a5
chore: midwork
sai-ray ccacf31
Merge remote-tracking branch 'origin/main' into sai/iohost-public-lis…
sai-ray bca9162
chore: keep AbsentData off public API
sai-ray f9e68aa
chore: midwork
sai-ray e9a6c1b
Merge branch 'main' into sai/iohost-public-listener-api
sai-ray b9195c5
chore: midwork
sai-ray 48b3b31
chore: midwork
sai-ray 13bcf0a
chore: midwork
sai-ray 674b076
chore: midwork
sai-ray cac287d
chore: midwork
sai-ray 6196861
chore: midwork
sai-ray 7917b0a
chore: midwork
sai-ray 51c0d77
chore: midwork
sai-ray 2e8248c
chore: midwork
sai-ray ae73a7d
chore: midwork
sai-ray a686a1a
Merge branch 'main' into sai/iohost-public-listener-api
sai-ray b4177e6
Merge branch 'main' into sai/iohost-public-listener-api
sai-ray File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Some comments aren't visible on the classic Files Changed page.
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,3 +1,4 @@ | ||
| export * from './io-host'; | ||
| export * from './io-message'; | ||
| export * from './toolkit-action'; | ||
| export * from './listeners'; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,317 @@ | ||
| import type { IIoHost } from './io-host'; | ||
| import type { IoMessage, IoRequest, IMessageMatcher, IRequestMatcher, IoMessageCode, IoMessageLevel } from './io-message'; | ||
| import { ListenerRegistry } from './private/listener-registry'; | ||
| import type { MessageListenerResultOrPromise } from './private/listener-registry'; | ||
|
|
||
| // Re-export the listener result vocabulary from the shared registry so it is | ||
| // part of the public API alongside `withListeners`. | ||
| export type { MessageListenerResult, MessageListenerResultOrPromise } from './private/listener-registry'; | ||
|
|
||
| /** | ||
| * A predicate that decides whether a listener applies to a message. | ||
| * | ||
| * Use one to match a family of messages instead of a single code — e.g. every | ||
| * message of a given level, or any of several codes. It touches only the public | ||
| * `IoMessage` shape. | ||
| * | ||
| * @example | ||
| * ```ts | ||
| * host.on((msg) => msg.level === 'warn', listener); | ||
| * ``` | ||
| */ | ||
| export type MessagePredicate = (msg: IoMessage<unknown>) => boolean; | ||
|
|
||
| /** | ||
| * Options for `respond`/ `respondOnce`. | ||
| */ | ||
| export interface RespondOptions { | ||
| /** | ||
| * Whether also to suppress surfacing the question text. | ||
| * @default true - answer silently | ||
| */ | ||
| readonly suppressQuestion?: boolean; | ||
| } | ||
|
|
||
| /** | ||
| * An `IIoHost` that additionally lets listeners be attached to it. | ||
| * | ||
| * The result of `withListeners`. It is still an `IIoHost`, so it drops straight | ||
| * into the `ioHost` slot of a new `Toolkit`, and it exposes methods to observe, | ||
| * reshape, or answer individual messages without subclassing a host. | ||
| * | ||
| * Listeners are keyed on a message `code` (e.g. `'CDK_TOOLKIT_I2901'`). The | ||
| * codes are listed in the message registry: | ||
| * https://docs.aws.amazon.com/cdk/api/toolkit-lib/message-registry/ | ||
| */ | ||
| export interface IoHostWithListeners extends IIoHost { | ||
| /** | ||
| * Register a listener that is invoked for every message that matches — either | ||
| * a single message `code` (e.g. `'CDK_TOOLKIT_I2901'`), or a | ||
| * `MessagePredicate` that matches a family of messages. | ||
| * | ||
| * The listener may return a `MessageListenerResult` to update the message | ||
| * text and/or level or prevent the default handling (asking the wrapped host | ||
| * to write it); returning nothing leaves the message untouched. The listener | ||
| * may be async (return a `Promise`); it is awaited before the message is | ||
| * handled further. Returns a function that removes the listener again. | ||
| * | ||
| * The message payload is delivered as `unknown`; see the message registry for | ||
| * the shape carried by each code. | ||
| * | ||
| * @example | ||
| * ```ts | ||
| * const dispose = host.on('CDK_TOOLKIT_I2901', async (msg) => { | ||
| * myCount += (msg.data as StackDetailsPayload).stacks.length; | ||
| * await persist(myCount); | ||
| * }); | ||
| * ``` | ||
| * | ||
| * @example | ||
| * ```ts | ||
| * // A predicate matches a family of messages, e.g. every warning: | ||
| * const dispose = host.on((msg) => msg.level === 'warn', (msg) => { | ||
| * warnings.push(msg.message); | ||
| * }); | ||
| * ``` | ||
| */ | ||
| on<T>( | ||
| matcher: IMessageMatcher<T>, | ||
| listener: (msg: IoMessage<T>) => MessageListenerResultOrPromise, | ||
| ): () => void; | ||
| on( | ||
| selector: IoMessageCode | MessagePredicate, | ||
| listener: (msg: IoMessage<unknown>) => MessageListenerResultOrPromise, | ||
| ): () => void; | ||
|
|
||
| /** | ||
| * Like `on`, but the listener is automatically removed after it has been | ||
| * invoked once. | ||
| */ | ||
| once<T>( | ||
| matcher: IMessageMatcher<T>, | ||
| listener: (msg: IoMessage<T>) => MessageListenerResultOrPromise, | ||
| ): () => void; | ||
| once( | ||
| selector: IoMessageCode | MessagePredicate, | ||
| listener: (msg: IoMessage<unknown>) => MessageListenerResultOrPromise, | ||
| ): () => void; | ||
|
|
||
| /** | ||
| * Register a formatter that replaces the printed text of matching messages — | ||
| * selected by a message `code`, a `MessagePredicate`, or a matcher. This lets | ||
| * a caller define _how_ a toolkit message is presented without the host | ||
| * needing to know about it. | ||
| * | ||
| * Optionally pass a `level` to also override the message's level. Syntactic | ||
| * sugar for an `on` listener that returns the new `message` and `level`. | ||
| * Returns a function that removes the formatter again. | ||
| * | ||
| * @example | ||
| * ```ts | ||
| * const dispose = host.rewrite('CDK_TOOLKIT_I2901', (msg) => | ||
| * `${(msg.data as StackDetailsPayload).stacks.length} stacks`); | ||
| * ``` | ||
| */ | ||
| rewrite<T>( | ||
| matcher: IMessageMatcher<T>, | ||
| formatter: (msg: IoMessage<T>) => string, | ||
| level?: IoMessageLevel, | ||
| ): () => void; | ||
| rewrite( | ||
| selector: IoMessageCode | MessagePredicate, | ||
| formatter: (msg: IoMessage<unknown>) => string, | ||
| level?: IoMessageLevel, | ||
| ): () => void; | ||
|
|
||
| /** | ||
| * Like `rewrite`, but the formatter is automatically removed after it has | ||
| * been applied once. | ||
| */ | ||
| rewriteOnce<T>( | ||
| matcher: IMessageMatcher<T>, | ||
| formatter: (msg: IoMessage<T>) => string, | ||
| level?: IoMessageLevel, | ||
| ): () => void; | ||
| rewriteOnce( | ||
| selector: IoMessageCode | MessagePredicate, | ||
| formatter: (msg: IoMessage<unknown>) => string, | ||
| level?: IoMessageLevel, | ||
| ): () => void; | ||
|
|
||
| /** | ||
| * Answer a request (by its code) on the caller's behalf with a fixed value, | ||
| * so the wrapped host is not asked to prompt. Syntactic sugar for an `on` | ||
| * listener that responds with the value and prevents the default; for | ||
| * conditional answers or to also reword the question, use `on`/`once` | ||
| * directly. Returns a function that removes the responder again. | ||
| * | ||
| * By default the question is answered silently; pass | ||
| * `{ suppressQuestion: false }` to still surface the question while answering | ||
| * it. | ||
| * | ||
| * @example | ||
| * ```ts | ||
| * const dispose = host.respond('CDK_TOOLKIT_I7010', true); | ||
| * ``` | ||
| */ | ||
| respond<T, U>( | ||
| matcher: IRequestMatcher<T, U>, | ||
| value: U, | ||
| options?: RespondOptions, | ||
| ): () => void; | ||
| respond( | ||
| code: IoMessageCode, | ||
| value: unknown, | ||
| options?: RespondOptions, | ||
| ): () => void; | ||
|
|
||
| /** | ||
| * Like `respond`, but the answer is given only once and then removed. | ||
| */ | ||
| respondOnce<T, U>( | ||
| matcher: IRequestMatcher<T, U>, | ||
| value: U, | ||
| options?: RespondOptions, | ||
| ): () => void; | ||
| respondOnce( | ||
| code: IoMessageCode, | ||
| value: unknown, | ||
| options?: RespondOptions, | ||
| ): () => void; | ||
| } | ||
|
|
||
| /** | ||
| * Wrap any `IIoHost` so listeners can be attached to it. | ||
| * | ||
| * The returned host forwards `notify` and `requestResponse` to the host you | ||
| * pass in, running any matching listeners in registration order in between. On | ||
| * `notify` it runs the listeners, applies any rewrite, and skips the wrapped | ||
| * host's write if a listener prevented the default. On `requestResponse` it runs | ||
| * them too, so a listener can rewrite the prompt text or answer it with | ||
| * `respond`, in which case the request resolves without asking the wrapped host. | ||
| * | ||
| * The result is still an `IIoHost`, so it drops straight into a new `Toolkit`. | ||
| * Its lifecycle stays yours: you wrap a host, register listeners, and pass it to | ||
| * the toolkit, all explicit. | ||
| * | ||
| * @example | ||
| * ```ts | ||
| * const base = new NonInteractiveIoHost(); // or your own host | ||
| * const host = withListeners(base); | ||
| * host.on('CDK_TOOLKIT_I2901', (m) => { count += (m.data as StackDetailsPayload).stacks.length; }); | ||
| * const toolkit = new Toolkit({ ioHost: host }); | ||
| * ``` | ||
| */ | ||
| export function withListeners(host: IIoHost): IoHostWithListeners { | ||
| return new ListeningIoHost(host); | ||
| } | ||
|
|
||
| /** | ||
| * An `IIoHost` that runs a `ListenerRegistry` around a wrapped host. | ||
| * | ||
| * The registry is the shared listener engine (also used by the CLI's terminal | ||
| * host); this class adds the wrapped-host plumbing that turns it into an | ||
| * `IIoHost`, and adapts the public code-keyed API onto the registry's matchers. | ||
| */ | ||
| class ListeningIoHost implements IoHostWithListeners { | ||
| private readonly registry = new ListenerRegistry(); | ||
|
|
||
| public constructor(private readonly inner: IIoHost) { | ||
| } | ||
|
|
||
| public async notify(msg: IoMessage<unknown>): Promise<void> { | ||
| const { message, preventDefault } = await this.registry.apply(msg); | ||
| if (preventDefault) { | ||
| return; | ||
| } | ||
| return this.inner.notify(message); | ||
| } | ||
|
|
||
| public async requestResponse<T>(msg: IoRequest<unknown, T>): Promise<T> { | ||
| const { message, preventDefault, responded } = await this.registry.apply(msg); | ||
|
|
||
| // A listener suppressed the default handling: resolve with the (possibly | ||
| // overridden) default response without asking the wrapped host. | ||
| if (preventDefault) { | ||
| return message.defaultResponse; | ||
| } | ||
|
|
||
| // A listener answered the request but wants the question surfaced: show it | ||
| // via the wrapped host, then resolve with the answer instead of prompting. | ||
| if (responded) { | ||
| await this.inner.notify(message); | ||
| return message.defaultResponse; | ||
| } | ||
|
|
||
| // No listener answered: let the wrapped host resolve the (possibly reworded) | ||
| // request as it sees fit (it may prompt, or use its own default). | ||
| return this.inner.requestResponse(message); | ||
| } | ||
|
|
||
| public on( | ||
| selector: IMessageMatcher<any> | IoMessageCode | MessagePredicate, | ||
| listener: (msg: IoMessage<any>) => MessageListenerResultOrPromise, | ||
| ): () => void { | ||
| return this.registry.on(toMatcher(selector), listener); | ||
| } | ||
|
|
||
| public once( | ||
| selector: IMessageMatcher<any> | IoMessageCode | MessagePredicate, | ||
| listener: (msg: IoMessage<any>) => MessageListenerResultOrPromise, | ||
| ): () => void { | ||
| return this.registry.once(toMatcher(selector), listener); | ||
| } | ||
|
|
||
| public rewrite( | ||
| selector: IMessageMatcher<any> | IoMessageCode | MessagePredicate, | ||
| formatter: (msg: IoMessage<any>) => string, | ||
| level?: IoMessageLevel, | ||
| ): () => void { | ||
| return this.registry.rewrite(toMatcher(selector), formatter, level); | ||
| } | ||
|
|
||
| public rewriteOnce( | ||
| selector: IMessageMatcher<any> | IoMessageCode | MessagePredicate, | ||
| formatter: (msg: IoMessage<any>) => string, | ||
| level?: IoMessageLevel, | ||
| ): () => void { | ||
| return this.registry.rewriteOnce(toMatcher(selector), formatter, level); | ||
| } | ||
|
|
||
| public respond( | ||
| selector: IRequestMatcher<any, any> | IoMessageCode, | ||
| value: unknown, | ||
| options: RespondOptions = {}, | ||
| ): () => void { | ||
| return this.registry.respond(toMatcher(selector), value, options.suppressQuestion); | ||
| } | ||
|
|
||
| public respondOnce( | ||
| selector: IRequestMatcher<any, any> | IoMessageCode, | ||
| value: unknown, | ||
| options: RespondOptions = {}, | ||
| ): () => void { | ||
| return this.registry.respondOnce(toMatcher(selector), value, options.suppressQuestion); | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Resolve a code-or-predicate selector into the predicate the registry matches | ||
| * on: a code becomes a code-equality check, a predicate is used as-is. | ||
| */ | ||
| function toMatcher(selector: IMessageMatcher<any> | IoMessageCode | MessagePredicate): MessagePredicate { | ||
| if (typeof selector === 'string') { | ||
| return byCode(selector); | ||
| } | ||
| if (typeof selector === 'function') { | ||
| return selector; | ||
| } | ||
| return (msg) => selector.is(msg); | ||
| } | ||
|
|
||
| /** | ||
| * Build a matcher that fires for messages carrying the given code. | ||
| */ | ||
| function byCode(code: IoMessageCode): MessagePredicate { | ||
| return (msg) => msg.code === code; | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
do we need to extend the
IIoHostinterface? Or isIoEmittera separate independent interface?There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I held off
IoEmitterbecause I wasn't sure of the benefit over the wrapper for our cases. The one place it would help is reusing one emitter across many hosts. If that is a expected case, then this would be small addition.