Skip to content

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

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

sai-ray wants to merge 20 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 private ListenerRegistry and adds a public withListeners(host) wrapper, so listeners can be attached to any IIoHost:

import { Toolkit, NonInteractiveIoHost, withListeners, byCode, IoRequest, ConfirmationRequest, StackDetailsPayload } from '@aws-cdk/toolkit-lib';

const host = withListeners(new NonInteractiveIoHost());

let count = 0;
const warnings: string[] = [];

// Observe. `byCode<T>` types `msg.data`, and registering returns a disposer.
const dispose = host.on(byCode<StackDetailsPayload>('CDK_TOOLKIT_I2901'), (msg) => { count += msg.data.stacks.length; });
dispose();

// Any predicate is a matcher, so you can match a whole level.
host.on((msg) => msg.level === 'warn', (msg) => { warnings.push(msg.message); });

// Restyle a message, or drop it, without the host knowing.
using _fmt = host.rewrite(byCode<StackDetailsPayload>('CDK_TOOLKIT_I2901'), (msg) => `${msg.data.stacks.length} stacks`, { level: 'debug' });
using _quiet = host.on(byCode('CDK_TOOLKIT_I5031'), () => ({ preventDefault: true }));

// Answer a request so the host is never asked to prompt.
using _yes = host.respond(byCode<IoRequest<ConfirmationRequest, boolean>>('CDK_TOOLKIT_I7010'), true);

const toolkit = new Toolkit({ ioHost: host });

A listener can observe a message (on/once), rewrite its text and level (rewrite/rewriteOnce), suppress it, or answer a request (respond/respondOnce). Each registration returns a disposer that works both as a call and under using. Documented in the toolkit-lib README under #### Attaching listeners to an IoHost.

Nine additions users interact with:

  • withListeners(host), which returns the host you passed in, typed T & IoEmitter. IoEmitter is the six registration methods it adds.
  • MessageMatcher, the one way to select messages, and byCode<T>(...codes) to build one from one or more message codes.
  • MessageListenerResult and MessageListenerResultOrPromise for what a listener may return (message, level, action, preventDefault, respond), and DisposeListener for what registering gives back.
  • RespondOptions and RewriteOptions, the options bags for respond/respondOnce and rewrite/rewriteOnce, replacing positional booleans and levels.

Listeners are keyed on a MessageMatcher, which is any (msg: IoMessage<unknown>) => boolean. A matcher that carries a payload type is just a type guard, so byCode<StackDetailsPayload>('CDK_TOOLKIT_I2901') types msg.data with nothing named in between. This PR also makes the message makers callable, so IO.CDK_TOOLKIT_I2901 is itself a matcher, and because a maker narrows all the way to IoRequest<T, U>, respond's value is checked against the request's response type. That is why IMessageMatcher, IRequestMatcher, MessagePredicate and IoHostWithListeners are deleted rather than published, and why ListenerRegistry takes exactly one signature per method, with the typed signatures declared once in IoEmitter.

withListeners is a Proxy, which is what keeps the inner host's own methods, getters, setters and full type intact, and wrapping is idempotent so there is never a second registry double-handling a message. Four of the proxy's rules are load-bearing rather than cosmetic, around binding this for hosts with #private fields, letting an own property shadow the additions, keeping the additions off Object.keys, and capturing the inner notify at wrap time so a later override cannot recurse. Each has a test in listeners.test.ts naming the failure it prevents.

CliIoHost no longer owns a registry, it is wrapped like anything else. CliIoHost.instance() hands out attachListeners(new CliIoHost(props)), and export interface CliIoHost extends IoEmitter {} merges the added methods into the class type, so every call site in cdk-toolkit.ts is textually unchanged while cli-io-host.ts loses 505 lines and gains 107. Three things fall out of that re-layering. The corkReplaying flag is gone, because the cork buffer now sits below the listener layer instead of replaying back through notify, which also fixes a latent double-count where telemetry saw a corked message twice. Listeners lose their privileged tier, so removeAllListeners, addInternal and removeUserListeners are deleted and stack-activity routing registers with plain on. And telemetry ordering is guaranteed rather than incidental, since it registers first and a later preventDefault can no longer stop it counting a dropped message.

One existing seam needed plumbing rather than porting. CliIoHost already had observeMessages, which reports the emitted message, its effective form, and whether a listener dropped it, all three for one message, and the snapshot recorder in test/_helpers/io-recorder.ts is built on it. It could compute that itself while the listeners ran inline. Now that they run a layer down, it cannot, so the private attachListeners takes an optional ListenerVerdictHook and the CLI forwards it into the same fan-out. ObservableIoHost, observeMessages and IoMessageObservation all survive, the last as an alias for ListenerVerdict, and io-recorder.ts is untouched. The hook is private because it only makes sense for a host at the bottom of the stack that wants the whole stream, and it is fixed at wrap time rather than added later, which is why passing it to an already-wrapped host throws.

Two things change in observable behavior, both intended:

  • preventDefault on a request with no answer now throws. A request's declared default is often approval, so resolving with it would approve on the user's behalf. Pair preventDefault with respond, or use respond, which sets both. No CLI path reaches this, since every @suppressMessages target is an info or trace notification.
  • A once listener is claimed before it is awaited, so concurrent emissions (e.g. parallel stacks) can no longer double-fire it.

Flagging two changes. Message makers gained .is recently in #1679 and this PR drops it in favour of calling the maker, which is what makes the matcher interfaces unnecessary, at the cost of overloading IO.X as a factory via .msg() and a predicate when called. And removeAllListeners is deleted, which after #1887 had only test call sites.

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

❌ Patch coverage is 98.13084% with 2 lines in your changes missing coverage. Please review.
✅ Project coverage is 91.03%. Comparing base (9aaa34e) to head (eb92d9b).
⚠️ Report is 20 commits behind head on main.

Files with missing lines Patch % Lines
packages/aws-cdk/lib/cli/io-host/cli-io-host.ts 98.13% 1 Missing and 1 partial ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #1708      +/-   ##
==========================================
- Coverage   91.30%   91.03%   -0.28%     
==========================================
  Files          79       79              
  Lines       12164    11819     -345     
  Branches     1719     1683      -36     
==========================================
- Hits        11106    10759     -347     
- Misses       1023     1026       +3     
+ Partials       35       34       -1     
Flag Coverage Δ
suite.unit 91.03% <98.13%> (-0.28%) ⬇️

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.

@sai-ray sai-ray added the pr/exempt-size-check Skips PR size check label Jul 7, 2026
Comment thread packages/@aws-cdk/toolkit-lib/lib/api/io/private/message-maker.ts
*/
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?

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.

Sorry, my earlier statement was off. I got there by trying to give public users a typed msg.data without a cast, and I talked myself into thinking that needed IO to be public. It doesn't. A public matcher interface is fine on its own, and only exporting IO itself would leak the maker types. Here's the interface, it's on the branch:

export interface IMessageMatcher<T> {
  is(msg: IoMessage<unknown>): msg is IoMessage<T>;
}
export interface IRequestMatcher<T, U> extends IMessageMatcher<T> {
  is(msg: IoMessage<unknown>): msg is IoRequest<T, U>;
}

The makers implement it so internally the CLI keeps passing them and gets typed data and the maker types stay private.

But the typing goal I was chasing doesn't actually resolve. Internally the CLI can use the matcher because it has the makers. A public user has no matcher to pass, since IO and the makers are private. To get one they'd either need us to export IO, or hand-write the matcher:

host.on({ is: (m): m is IoMessage<StackDetailsPayload> => m.code === 'CDK_TOOLKIT_I2901' }, (m) => m.data.stacks);

But that m is IoMessage<StackDetailsPayload> doesn't give much over just casting on the code path:

host.on('CDK_TOOLKIT_I2901', (m) => (m.data as StackDetailsPayload).stacks);

So the typed matcher only seems to pay off for the CLI. For public users it's a similar cast. Given that, does it earn a place on the public surface, or should the matcher be internal and leave the public API as IoMessageCode | MessagePredicate?

*/
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?

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.

added. missed it earlier.

* 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).

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.

Updated. It's a RespondOptions object now, so we can add more options later without changing the signature.

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?

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.

I held off IoEmitter because 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.

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.

Update, it exists now and it is separate. If it extended IIoHost the intersection would restate what the host already provides and flatten the caller's own type.

* 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.

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.

I dug into this and I'm not sure I found the right answer, so I'd value your read.

The generic form that keeps the host's full type is withListeners<T>(host: T): T & IoHostWithListeners. My worry is that the wrapper only implements notify, requestResponse, and the listener methods, so I think that type would promise the host's other members while they'd actually be undefined at runtime.

I tried two ways to make it honest and neither quite felt right:

  • A Proxy forwarding unknown members to the inner host. Reads seem fine, and a set trap would probably cover writes, but when I wrapped a host that already has a registry (like CliIoHost) I ended up with two registries both firing on notify, and I couldn't find a trap that resolved it cleanly.
  • Adding the listener methods straight onto the host and handing it back. That one is genuinely the host, but it changes the object that was passed in, and since the CLI host is a shared singleton I think that would affect everyone.

So for now I've gone back to returning IoHostWithListeners. It still extends IIoHost, which is all the toolkit consumes, and the caller still holds their own typed reference to the host they passed in, so it seems like they don't really lose it. If there's an approach you had in mind that avoids these I'd really appreciate the guidance. I couldn't reason one that holds up.

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.

Exactly, I think a proxy is the right solution here!

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.

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.

yes, and now the selector went from two maker arms plus a predicate down to a matcher or a predicate.

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.

But we now have IIoMessageMatcher!

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.

It went the other way, the interface is deleted. Makers are callable now, so a maker is the type guard directly and there is nothing left for the interface to do. One signature per method on the registry.

@mrgrain mrgrain left a comment

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.

Also some changes have been made on main that solve dispose much nicer. We should include them here.

* 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.

Technically this needs to be IIoHostWithListeners but like I've said, I don't see why we need a combined interface. IoEmitter might still be the better name even if we extend.

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.

Dropped the combined interface. IoEmitter holds the six registration methods and does not extend IIoHost,

Comment on lines +85 to +102

/**
* A message matcher
*
* Decides whether a message matches a listener, narrowing its payload type `T`.
*/
export interface IMessageMatcher<T> {
is(msg: IoMessage<unknown>): msg is IoMessage<T>;
}

/**
* A request matcher.
*
* Carries the response type `U` so an answer can be typed.
*/
export interface IRequestMatcher<T, U> extends IMessageMatcher<T> {
is(msg: IoMessage<unknown>): msg is IoRequest<T, U>;
}

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 these Matchers need to be generic? What functionality are we gaining?

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.

The generic only existed to carry the payload type and the makers do that themselves nowthat they are callable so deleted both. io-message.ts has no changes in this PR any more.

Comment on lines +77 to +83
on<T>(
matcher: IMessageMatcher<T>,
listener: (msg: IoMessage<T>) => MessageListenerResultOrPromise,
): () => void;
on(
selector: IoMessageCode | MessagePredicate,
listener: (msg: IoMessage<unknown>) => MessageListenerResultOrPromise,

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.

So we allow 3 different types here. Can we limit this? MessagePredicate and IMessageMatcher seem to be the same thing. Even IoMessageCode we can get rid of if we provide a code matcher.

@sai-ray sai-ray Sep 16, 2026

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.

Down to one.

export type MessageMatcher = (msg: IoMessage<unknown>) => boolean;

byCode replaced the IoMessageCode arm as per suggestion. Payload typing survives without a second arm because a narrowing matcher is just a type guard, and a type guard is assignable to MessageMatcher.

// The shared listener engine. Registration and message transformation live
// here; this host does its own I/O (writing, prompting, telemetry, observers)
// around `registry.apply`. See `on`/`once`/`rewrite`/`respond`.
private readonly registry = new ListenerRegistry();

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.

What is this registry? If we need it, this tells you that our wrapping pattern doesn't work as designed.

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.

this comment reframed the whole PR for me. CliIoHost is wrapped like any other host now and a declaration merge keeps every existing call site compiling.

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.

Please check which types need exporting from here. Also maybe some are duplicated with public types, not sure.

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 through them, and it is down to two exports, matchAny and ListenerRegistry.

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.

But we now have IIoMessageMatcher!

* Remove every listener registered via `on`/`once`/`rewrite`/`respond`,
* keeping the host's internal listeners so the host keeps working afterwards.
*/
public removeUserListeners(): 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.

To be fair, I'd rather get rid of the internal concept completely...

@github-actions

Copy link
Copy Markdown
Contributor

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

This branch was successfully deployed

2 active and 1 inactive (outdated) deployments
run-tests eb92d9b0 Deployed Sep 15, 2026 by sai-ray via integ_init-templates (init-python, 24.19) #6903
no-approval eb92d9b0 Deployed Sep 15, 2026 by sai-ray via prepare #6903
automation 74815a5d Deployed Jul 7, 2026 by sai-ray via Set AutoQueue on PR #1708 #3030
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

p2 pr/exempt-size-check Skips PR size check

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants