Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions packages/@aws-cdk/toolkit-lib/lib/api/io/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
export * from './io-host';
export * from './io-message';
export * from './toolkit-action';
export * from './listeners';

export { IO } from './private/messages';
export type { IoMessageMaker, IoRequestMaker, MessageInfo, CodeInfo } from './private/message-maker';
export type { ActionLessMessage, ActionLessRequest } from './private/io-helper';
220 changes: 220 additions & 0 deletions packages/@aws-cdk/toolkit-lib/lib/api/io/listeners.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,220 @@
import type { IIoHost } from './io-host';
import type { IoMessage, IoMessageLevel, IoRequest } from './io-message';
import { ListenerRegistry } from './private/listener-registry';
import type { MessageListenerResultOrPromise, MessageSelector } from './private/listener-registry';
import type { IoMessageMaker, IoRequestMaker } from './private/message-maker';

// Re-export the listener vocabulary from the shared registry so it is part of
// the public API alongside `withListeners`.
export { matchAny } from './private/listener-registry';
export type { MessageListenerResult, MessageListenerResultOrPromise, MessageSelector } from './private/listener-registry';

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

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.

/**
* Register a listener that is invoked for every message that matches the
* selector.
*
* 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.
*
* @example
* ```ts
* const dispose = host.on(IO.CDK_TOOLKIT_I2901, async (msg) => {
* myCount += msg.data.stacks.length;
* await persist(myCount);
* });
* ```
*
* @example
* ```ts
* // Match with a custom predicate instead of a code, e.g. a maker's `.is`:
* const dispose = host.on(IO.CDK_TOOLKIT_I7010.is, (msg) => ({ respond: true }));
* ```
*/
on<T>(
selector: IoMessageMaker<T> | IoRequestMaker<T, any> | ((msg: IoMessage<any>) => msg is IoMessage<T>),
listener: (msg: IoMessage<T>) => MessageListenerResultOrPromise,
): () => void;
on(
predicate: (msg: IoMessage<any>) => boolean,
listener: (msg: IoMessage<unknown>) => MessageListenerResultOrPromise,
Comment on lines +77 to +83

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.

): () => void;

/**
* Like `on`, but the listener is automatically removed after it has been
* invoked once.
*/
once<T>(
selector: IoMessageMaker<T> | IoRequestMaker<T, any> | ((msg: IoMessage<any>) => msg is IoMessage<T>),
listener: (msg: IoMessage<T>) => MessageListenerResultOrPromise,
): () => void;
once(
predicate: (msg: IoMessage<any>) => boolean,
listener: (msg: IoMessage<unknown>) => MessageListenerResultOrPromise,
): () => void;

/**
* Register a formatter that replaces the printed text of messages with the
* given code. 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(IO.CDK_TOOLKIT_I2901, (msg) =>
* serializeStructure(msg.data.stacks, true));
* ```
*/
rewrite<T>(
code: IoMessageMaker<T> | IoRequestMaker<T, any>,
formatter: (msg: IoMessage<T>) => string,
level?: IoMessageLevel,
): () => void;

/**
* Like `rewrite`, but the formatter is automatically removed after it has
* been applied once.
*/
rewriteOnce<T>(
code: IoMessageMaker<T> | IoRequestMaker<T, any>,
formatter: (msg: IoMessage<T>) => 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.
*
* @param suppressQuestion - whether to also suppress surfacing the question
* text. Defaults to `true` (answer silently). Pass `false` to still surface
* the question while answering it.
*
* @example
* ```ts
* const dispose = host.respond(IO.CDK_TOOLKIT_I7010, true);
* ```
*/
respond<T, U>(code: IoRequestMaker<T, U>, value: U, suppressQuestion?: boolean): () => void;

/**
* Like `respond`, but the answer is given only once and then removed.
*/
respondOnce<T, U>(code: IoRequestMaker<T, U>, value: U, suppressQuestion?: boolean): () => 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(IO.CDK_TOOLKIT_I2901, (m) => { count += m.data.stacks.length; });
* 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!

return new ListeningIoHost(host);
}
Comment on lines +205 to +207

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. 🤷🏻


/**
* 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`.
*/
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: MessageSelector<any>, listener: (msg: IoMessage<any>) => MessageListenerResultOrPromise): () => void {
return this.registry.on(selector as any, listener as any);
}

public once(selector: MessageSelector<any>, listener: (msg: IoMessage<any>) => MessageListenerResultOrPromise): () => void {
return this.registry.once(selector as any, listener as any);
}

public rewrite<T>(
code: IoMessageMaker<T> | IoRequestMaker<T, any>,
formatter: (msg: IoMessage<T>) => string,
level?: IoMessageLevel,
): () => void {
return this.registry.rewrite(code, formatter, level);
}

public rewriteOnce<T>(
code: IoMessageMaker<T> | IoRequestMaker<T, any>,
formatter: (msg: IoMessage<T>) => string,
level?: IoMessageLevel,
): () => void {
return this.registry.rewriteOnce(code, formatter, level);
}

public respond<T, U>(code: IoRequestMaker<T, U>, value: U, suppressQuestion = true): () => void {
return this.registry.respond(code, value, suppressQuestion);
}

public respondOnce<T, U>(code: IoRequestMaker<T, U>, value: U, suppressQuestion = true): () => void {
return this.registry.respondOnce(code, value, suppressQuestion);
}
}
1 change: 1 addition & 0 deletions packages/@aws-cdk/toolkit-lib/lib/api/io/private/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,5 @@ export * from './level-priority';
export * from './span';
export * from './message-maker';
export * from './messages';
export * from './listener-registry';
export * from './types';
Loading
Loading