Skip to content

feat(toolkit-lib): withListeners API for IoHost#1708

Open
sai-ray wants to merge 8 commits into
mainfrom
sai/iohost-public-listener-api
Open

feat(toolkit-lib): withListeners API for IoHost#1708
sai-ray wants to merge 8 commits into
mainfrom
sai/iohost-public-listener-api

Conversation

@sai-ray

@sai-ray sai-ray commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

The CLI's CliIoHost has a listener mechanism that's private and welded to that one class. Programmatic toolkit-lib users have no way to observe or reshape what flows through their IoHost short of writing a whole custom host.

This PR extracts that engine into a shared ListenerRegistry and adds a public withListeners(host) wrapper, so listeners can be attached to any IIoHost:

 const host = withListeners(new NonInteractiveIoHost());
 host.on('CDK_TOOLKIT_I2901', (m) => { count += (m.data as StackDetailsPayload).stacks.length; });  // observe
 host.on((m) => m.level === 'warn', (m) => { warnings++; });                                        // or by predicate
 const toolkit = new Toolkit({ ioHost: host });

A listener can observe a message (on/once), rewrite its text (rewrite/rewriteOnce), suppress it, or answer a prompt (respond/respondOnce), and each registration returns a disposer. CliIoHost is refactored to delegate to the same ListenerRegistry, so there's one implementation of matching/ordering/rewriting/request-answering instead of two.

The additions users interact with:

  • withListeners(host) and its return type IoHostWithListeners.
  • MessagePredicate for matching on anything other than a single code.
  • MessageListenerResult, MessageListenerResultOrPromise

Listeners are keyed on a message code string (IoMessageCode, e.g. 'CDK_TOOLKIT_I2901') or a predicate. msg.data is delivered as unknown; cast it to the payload interface documented for that code in the message registry.

Checklist

  • This change contains a major version upgrade for a dependency and I confirm all breaking changes are addressed
    • Release notes for the new version:

By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license

@aws-cdk-automation aws-cdk-automation requested a review from a team July 7, 2026 14:03
@github-actions github-actions Bot added the p2 label Jul 7, 2026
@sai-ray sai-ray changed the title feat(toolkit-lib): public withListeners API for IoHost feat(toolkit-lib): public withListeners API for IoHost Jul 7, 2026
@sai-ray sai-ray changed the title feat(toolkit-lib): public withListeners API for IoHost feat(toolkit-lib): withListeners API for IoHost Jul 7, 2026
@codecov-commenter

codecov-commenter commented Jul 7, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 89.39%. Comparing base (9bf08e5) to head (13bcf0a).
⚠️ Report is 1 commits behind head on main.

Additional details and impacted files
@@            Coverage Diff             @@
##             main    #1708      +/-   ##
==========================================
- Coverage   89.59%   89.39%   -0.20%     
==========================================
  Files          77       77              
  Lines       11758    11552     -206     
  Branches     1651     1624      -27     
==========================================
- Hits        10534    10327     -207     
- Misses       1195     1197       +2     
+ Partials       29       28       -1     
Flag Coverage Δ
suite.unit 89.39% <100.00%> (-0.20%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@github-actions

github-actions Bot commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

Total lines changed 1101 is greater than 1000. Please consider breaking this PR down.

@sai-ray sai-ray added the pr/exempt-size-check Skips PR size check label Jul 7, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nope, these are private on purpose. Do not export them.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done. MessageInfo, CodeInfo, ActionLessMessage, ActionLessRequest are all un-exported again, and IO and the maker types are off the public barrel too. Nothing maker internal is public now

*/
export type MessageSelector<T> =
| IoMessageMaker<T>
| IoRequestMaker<T, any>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These are not public types, nor should they be. We can accept string here (a single code) or a new MessageMatcher class (via IMessageMatcher). We can make the MessageMakers implement that interface so we can keep passing them in.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Went with the string option. The public selector is now IoMessageCode (a single code) or a (msg) => boolean predicate, so it no longer references the maker types. I tried the IMessageMatcher route first but dropped it. To keep the typed maker path it wanted, IO would have to be public, and exporting IO pulls the maker internal types back onto the public surface through the same forgotten export cascade with api extractor. With IO private there's also no way for an external caller to obtain an IMessageMatcher. CliIoHost keeps passing makers internally through the private registry unchanged.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes there is, the CLI is a privileged caller that can use private exports from toolkit-lib.

In either case I don't understand why it's not possible. Can you share the IMessageMatcher interface you attempted?

*/
export type MessageSelector<T> =
| IoMessageMaker<T>
| IoRequestMaker<T, any>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes there is, the CLI is a privileged caller that can use private exports from toolkit-lib.

In either case I don't understand why it's not possible. Can you share the IMessageMatcher interface you attempted?

* ```
*/
rewrite(
code: IoMessageCode,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why no predicate?

* const dispose = host.respond('CDK_TOOLKIT_I7010', true);
* ```
*/
respond(code: IoMessageCode, value: unknown, suppressQuestion?: boolean): () => void;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I know is copied from what we currently have, but for a public API we need to think more careful about the design. If we ever want to add an other option this gets messy. We can preemptively put suppressQuestion in a property bag (options).

Comment on lines +155 to +157
export function withListeners(host: IIoHost): IoHostWithListeners {
return new ListeningIoHost(host);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure I love this, but I guess it doesn't hurt either. 🤷🏻

* codes are listed in the message registry:
* https://docs.aws.amazon.com/cdk/api/toolkit-lib/message-registry/
*/
export interface IoHostWithListeners extends IIoHost {

Copy link
Copy Markdown
Contributor

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 IIoHost interface? Or is IoEmitter a separate independent interface?

* const toolkit = new Toolkit({ ioHost: host });
* ```
*/
export function withListeners(host: IIoHost): IoHostWithListeners {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We have this, we can at least make this a generic type so that the inner host keeps its higher fidelity.

Comment on lines +146 to +154
public on<T>(
selector: IoMessageMaker<T> | IoRequestMaker<T, any> | ((msg: IoMessage<any>) => msg is IoMessage<T>),
listener: (msg: IoMessage<T>) => MessageListenerResultOrPromise,
): () => void;
public on(
predicate: (msg: IoMessage<any>) => boolean,
listener: (msg: IoMessage<unknown>) => MessageListenerResultOrPromise,
): () => void;
public on(selector: MessageSelector<any>, listener: MessageListenerFn): () => void {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

since this is a private class, it feels strange to have so many different signatures. But I guess thats because we couldn't make IIoMessageMatcher yet.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants